feat: 添加屏保用户配置和相关功能
- 在 `ScreenSaverServiceImpl` 中添加用户状态配置逻辑 - 添加 `ScreenSaverUserConfig` 实体类和 `ScreenSaverUserConfigMapper` 映射器 - 更新 `LegacyMeetingAttendeeResponse`, `LegacyMeetingPreviewDataResponse`, 和 `LegacyMeetingProcessingStatusResponse` 以包含 Swagger 注解 - 添加 `ScreenSaverServiceImplTest` 单元测试 - 更新 `AndroidCreateRealtimeMeetingVO` 以包含 Swagger 注解 - 在 `AndroidAuthServiceImplTest` 中添加匿名认证测试 - 添加 `AndroidExternalAppController` 控制器dev_na
parent
6107e611f4
commit
900f092d5e
|
|
@ -0,0 +1,74 @@
|
||||||
|
package com.imeeting.controller.android;
|
||||||
|
|
||||||
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import com.unisbase.dto.LoginRequest;
|
||||||
|
import com.unisbase.dto.RefreshRequest;
|
||||||
|
import com.unisbase.dto.TokenResponse;
|
||||||
|
import com.unisbase.service.AuthService;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@Tag(name = "Android认证接口")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/android/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AndroidAuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
@Operation(summary = "Android登录")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回登录成功后的访问令牌、刷新令牌和当前用户信息",
|
||||||
|
content = @Content(schema = @Schema(implementation = TokenResponse.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ApiResponse<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||||
|
return ApiResponse.ok(authService.login(request, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Android刷新令牌")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回刷新后的访问令牌、刷新令牌和当前用户信息",
|
||||||
|
content = @Content(schema = @Schema(implementation = TokenResponse.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public ApiResponse<TokenResponse> refresh(@RequestBody(required = false) RefreshRequest request,
|
||||||
|
@RequestHeader(value = "Authorization", required = false) String authorization,
|
||||||
|
@RequestHeader(value = "X-Android-Access-Token", required = false) String androidAccessToken) {
|
||||||
|
return ApiResponse.ok(authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveRefreshToken(RefreshRequest request, String authorization, String androidAccessToken) {
|
||||||
|
if (request != null && StringUtils.hasText(request.getRefreshToken())) {
|
||||||
|
return request.getRefreshToken().trim();
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(androidAccessToken)) {
|
||||||
|
return androidAccessToken.trim();
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(authorization)) {
|
||||||
|
String value = authorization.trim();
|
||||||
|
if (value.startsWith("Bearer ")) {
|
||||||
|
return value.substring(7).trim();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("refreshToken不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.imeeting.controller.android;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.imeeting.entity.biz.ClientDownload;
|
||||||
|
import com.imeeting.service.android.AndroidAuthService;
|
||||||
|
import com.imeeting.service.biz.ClientDownloadService;
|
||||||
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@Tag(name = "Android客户端接口")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/android/clients")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AndroidClientController {
|
||||||
|
|
||||||
|
private final AndroidAuthService androidAuthService;
|
||||||
|
private final ClientDownloadService clientDownloadService;
|
||||||
|
|
||||||
|
@Operation(summary = "查询平台最新客户端")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回指定平台当前生效的最新客户端安装包信息",
|
||||||
|
content = @Content(schema = @Schema(implementation = ClientDownload.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@GetMapping("/latest/by-platform")
|
||||||
|
public ApiResponse<ClientDownload> latestByPlatform(HttpServletRequest request,
|
||||||
|
@RequestParam(value = "platform_code", required = false) String platformCode,
|
||||||
|
@RequestParam(value = "platform_type", required = false) String platformType,
|
||||||
|
@RequestParam(value = "platform_name", required = false) String platformName) {
|
||||||
|
androidAuthService.authenticateHttp(request);
|
||||||
|
if ((platformCode == null || platformCode.isBlank())
|
||||||
|
&& ((platformType == null || platformType.isBlank()) || (platformName == null || platformName.isBlank()))) {
|
||||||
|
return ApiResponse.error("请提供 platform_code 参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
LambdaQueryWrapper<ClientDownload> wrapper = new LambdaQueryWrapper<ClientDownload>()
|
||||||
|
.eq(ClientDownload::getStatus, 1)
|
||||||
|
.eq(ClientDownload::getIsLatest, 1);
|
||||||
|
if (platformCode != null && !platformCode.isBlank()) {
|
||||||
|
wrapper.apply("LOWER(platform_code) = {0}", platformCode.trim().toLowerCase());
|
||||||
|
} else {
|
||||||
|
wrapper.apply("LOWER(platform_type) = {0}", platformType.trim().toLowerCase())
|
||||||
|
.apply("LOWER(platform_name) = {0}", platformName.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(ClientDownload::getVersionCode)
|
||||||
|
.orderByDesc(ClientDownload::getId)
|
||||||
|
.last("LIMIT 1");
|
||||||
|
|
||||||
|
ClientDownload clientDownload = clientDownloadService.getOne(wrapper);
|
||||||
|
if (clientDownload == null) {
|
||||||
|
return ApiResponse.error("暂无最新客户端");
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(clientDownload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package com.imeeting.controller.android;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.imeeting.entity.biz.ExternalApp;
|
||||||
|
import com.imeeting.service.android.AndroidAuthService;
|
||||||
|
import com.imeeting.service.biz.ExternalAppService;
|
||||||
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Tag(name = "Android外部应用接口")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/android/external-apps")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AndroidExternalAppController {
|
||||||
|
|
||||||
|
private final AndroidAuthService androidAuthService;
|
||||||
|
private final ExternalAppService externalAppService;
|
||||||
|
|
||||||
|
@Operation(summary = "查询启用的外部应用")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回当前启用的外部应用列表",
|
||||||
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ExternalApp.class)))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@GetMapping("/active")
|
||||||
|
public ApiResponse<List<ExternalApp>> active(HttpServletRequest request,
|
||||||
|
@RequestParam(value = "is_active", required = false) Integer ignoredIsActive) {
|
||||||
|
androidAuthService.authenticateHttp(request);
|
||||||
|
List<ExternalApp> apps = externalAppService.list(new LambdaQueryWrapper<ExternalApp>()
|
||||||
|
.eq(ExternalApp::getStatus, 1)
|
||||||
|
.orderByAsc(ExternalApp::getSortOrder)
|
||||||
|
.orderByDesc(ExternalApp::getCreatedAt));
|
||||||
|
return ApiResponse.ok(apps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.imeeting.controller.android;
|
||||||
|
|
||||||
|
import com.imeeting.dto.android.AndroidAuthContext;
|
||||||
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
|
import com.imeeting.service.android.AndroidAuthService;
|
||||||
|
import com.imeeting.service.biz.AiModelService;
|
||||||
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import com.unisbase.dto.PageResult;
|
||||||
|
import com.unisbase.security.LoginUser;
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Tag(name = "Android模型接口")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/android/llm-models")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AndroidLlmModelController {
|
||||||
|
|
||||||
|
private final AndroidAuthService androidAuthService;
|
||||||
|
private final AiModelService aiModelService;
|
||||||
|
|
||||||
|
@Operation(summary = "查询启用的大模型列表")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回当前租户下启用的大语言模型列表",
|
||||||
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = AiModelVO.class)))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@GetMapping("/active")
|
||||||
|
public ApiResponse<List<AiModelVO>> activeModels(HttpServletRequest request) {
|
||||||
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
|
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
|
||||||
|
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId());
|
||||||
|
List<AiModelVO> enabledModels = result.getRecords() == null
|
||||||
|
? List.of()
|
||||||
|
: result.getRecords().stream()
|
||||||
|
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
|
||||||
|
.toList();
|
||||||
|
return ApiResponse.ok(enabledModels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package com.imeeting.controller.android;
|
||||||
|
|
||||||
|
import com.imeeting.dto.android.AndroidAuthContext;
|
||||||
|
import com.unisbase.security.LoginUser;
|
||||||
|
|
||||||
|
final class AndroidLoginUserSupport {
|
||||||
|
|
||||||
|
private AndroidLoginUserSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static LoginUser requireLoginUser(AndroidAuthContext authContext) {
|
||||||
|
LoginUser loginUser = toLoginUser(authContext);
|
||||||
|
if (loginUser == null) {
|
||||||
|
throw new RuntimeException("Android用户未登录或认证无效");
|
||||||
|
}
|
||||||
|
return loginUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
static LoginUser toLoginUser(AndroidAuthContext authContext) {
|
||||||
|
if (authContext == null || authContext.isAnonymous()
|
||||||
|
|| authContext.getUserId() == null || authContext.getTenantId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
LoginUser loginUser = new LoginUser(
|
||||||
|
authContext.getUserId(),
|
||||||
|
authContext.getTenantId(),
|
||||||
|
authContext.getUsername(),
|
||||||
|
authContext.getPlatformAdmin(),
|
||||||
|
authContext.getTenantAdmin(),
|
||||||
|
authContext.getPermissions()
|
||||||
|
);
|
||||||
|
loginUser.setDisplayName(authContext.getDisplayName());
|
||||||
|
return loginUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isAdmin(AndroidAuthContext authContext) {
|
||||||
|
return authContext != null
|
||||||
|
&& (Boolean.TRUE.equals(authContext.getPlatformAdmin())
|
||||||
|
|| Boolean.TRUE.equals(authContext.getTenantAdmin()));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String resolveDisplayName(AndroidAuthContext authContext) {
|
||||||
|
if (authContext == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (authContext.getDisplayName() != null && !authContext.getDisplayName().isBlank()) {
|
||||||
|
return authContext.getDisplayName().trim();
|
||||||
|
}
|
||||||
|
if (authContext.getUsername() != null && !authContext.getUsername().isBlank()) {
|
||||||
|
return authContext.getUsername().trim();
|
||||||
|
}
|
||||||
|
return authContext.getDeviceId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,483 @@
|
||||||
|
package com.imeeting.controller.android;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.imeeting.common.RedisKeys;
|
||||||
|
import com.imeeting.dto.android.AndroidAuthContext;
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest;
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse;
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest;
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyMeetingPreviewDataResponse;
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult;
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse;
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
|
||||||
|
import com.imeeting.dto.biz.MeetingVO;
|
||||||
|
import com.imeeting.entity.biz.AiTask;
|
||||||
|
import com.imeeting.entity.biz.Meeting;
|
||||||
|
import com.imeeting.entity.biz.PromptTemplate;
|
||||||
|
import com.imeeting.service.android.AndroidAuthService;
|
||||||
|
import com.imeeting.service.android.legacy.LegacyMeetingAdapterService;
|
||||||
|
import com.imeeting.service.biz.AiTaskService;
|
||||||
|
import com.imeeting.service.biz.MeetingAccessService;
|
||||||
|
import com.imeeting.service.biz.MeetingCommandService;
|
||||||
|
import com.imeeting.service.biz.MeetingQueryService;
|
||||||
|
import com.imeeting.service.biz.MeetingService;
|
||||||
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import com.unisbase.dto.PageResult;
|
||||||
|
import com.unisbase.entity.SysUser;
|
||||||
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
|
import com.unisbase.security.LoginUser;
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Tag(name = "Android会议接口")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/android/meetings")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AndroidMeetingController {
|
||||||
|
|
||||||
|
private static final String STAGE_DATA_INITIALIZATION = "data_initialization";
|
||||||
|
private static final String STAGE_AUDIO_TRANSCRIPTION = "audio_transcription";
|
||||||
|
private static final String STAGE_SUMMARY_GENERATION = "summary_generation";
|
||||||
|
private static final String STAGE_COMPLETED = "completed";
|
||||||
|
|
||||||
|
private final AndroidAuthService androidAuthService;
|
||||||
|
private final LegacyMeetingAdapterService legacyMeetingAdapterService;
|
||||||
|
private final MeetingQueryService meetingQueryService;
|
||||||
|
private final MeetingAccessService meetingAccessService;
|
||||||
|
private final MeetingCommandService meetingCommandService;
|
||||||
|
private final MeetingService meetingService;
|
||||||
|
private final AiTaskService aiTaskService;
|
||||||
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
private final SysUserMapper sysUserMapper;
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Operation(summary = "创建Android离线会议")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回新创建的会议详情",
|
||||||
|
content = @Content(schema = @Schema(implementation = MeetingVO.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<MeetingVO> create(HttpServletRequest request, @RequestBody LegacyMeetingCreateRequest command) {
|
||||||
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
|
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
|
||||||
|
return ApiResponse.ok(legacyMeetingAdapterService.createMeeting(command, loginUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "上传Android会议音频")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回上传后的会议 ID 和音频地址",
|
||||||
|
content = @Content(schema = @Schema(implementation = LegacyUploadAudioResponse.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@PostMapping("/upload-audio")
|
||||||
|
public ApiResponse<LegacyUploadAudioResponse> uploadAudio(HttpServletRequest request,
|
||||||
|
@RequestParam("meeting_id") Long meetingId,
|
||||||
|
@RequestParam(value = "prompt_id", required = false) Long promptId,
|
||||||
|
@RequestParam(value = "model_code", required = false) String modelCode,
|
||||||
|
@RequestParam(value = "force_replace", defaultValue = "false") boolean forceReplace,
|
||||||
|
@RequestParam("audio_file") MultipartFile audioFile) throws IOException {
|
||||||
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
|
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
|
||||||
|
return ApiResponse.ok(legacyMeetingAdapterService.uploadAndTriggerOfflineProcess(
|
||||||
|
meetingId,
|
||||||
|
promptId,
|
||||||
|
modelCode,
|
||||||
|
forceReplace,
|
||||||
|
audioFile,
|
||||||
|
loginUser
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "分页查询Android会议")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回当前用户可见的会议分页结果",
|
||||||
|
content = @Content(schema = @Schema(implementation = PageResult.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<PageResult<List<MeetingVO>>> list(HttpServletRequest request,
|
||||||
|
@RequestParam(value = "user_id", required = false) Long ignoredUserId,
|
||||||
|
@RequestParam(defaultValue = "1") Integer page,
|
||||||
|
@RequestParam(value = "page_size", defaultValue = "10") Integer pageSize,
|
||||||
|
@RequestParam(required = false) String title) {
|
||||||
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
|
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
|
||||||
|
return ApiResponse.ok(meetingQueryService.pageMeetings(
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
title,
|
||||||
|
loginUser.getTenantId(),
|
||||||
|
loginUser.getUserId(),
|
||||||
|
AndroidLoginUserSupport.resolveDisplayName(authContext),
|
||||||
|
"all",
|
||||||
|
AndroidLoginUserSupport.isAdmin(authContext)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "查询Android会议预览数据")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回会议预览结果,包含已完成摘要或处理中状态信息",
|
||||||
|
content = @Content(schema = @Schema(implementation = LegacyMeetingPreviewDataResponse.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@GetMapping("/{meetingId}/preview-data")
|
||||||
|
public ApiResponse<LegacyMeetingPreviewDataResponse> previewData(HttpServletRequest request, @PathVariable Long meetingId) {
|
||||||
|
androidAuthService.authenticateHttp(request);
|
||||||
|
LegacyMeetingPreviewResult result = buildPreviewResult(meetingId);
|
||||||
|
return new ApiResponse<>(result.getCode(), result.getMessage(), result.getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "更新Android会议访问密码")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回更新后的会议访问密码,传空时表示清空访问密码",
|
||||||
|
content = @Content(schema = @Schema(implementation = String.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@PutMapping("/{meetingId}/access-password")
|
||||||
|
public ApiResponse<String> updateAccessPassword(HttpServletRequest request,
|
||||||
|
@PathVariable Long meetingId,
|
||||||
|
@RequestBody(required = false) LegacyMeetingAccessPasswordRequest command) {
|
||||||
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
|
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
|
||||||
|
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
|
||||||
|
if (!Objects.equals(meeting.getCreatorId(), loginUser.getUserId())) {
|
||||||
|
return ApiResponse.error("仅会议创建人可设置访问密码");
|
||||||
|
}
|
||||||
|
String password = normalizePassword(command == null ? null : command.getPassword());
|
||||||
|
meeting.setAccessPassword(password);
|
||||||
|
meetingService.updateById(meeting);
|
||||||
|
return ApiResponse.ok(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除Android会议")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回是否删除成功",
|
||||||
|
content = @Content(schema = @Schema(implementation = Boolean.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@DeleteMapping("/{meetingId}")
|
||||||
|
public ApiResponse<Boolean> delete(HttpServletRequest request, @PathVariable Long meetingId) {
|
||||||
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
|
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
|
||||||
|
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
|
||||||
|
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
||||||
|
meetingCommandService.deleteMeeting(meetingId);
|
||||||
|
return ApiResponse.ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LegacyMeetingPreviewResult buildPreviewResult(Long meetingId) {
|
||||||
|
Meeting meeting = meetingService.getById(meetingId);
|
||||||
|
if (meeting == null) {
|
||||||
|
return new LegacyMeetingPreviewResult("404", "会议不存在", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
AiTask asrTask = findLatestTask(meetingId, "ASR");
|
||||||
|
AiTask summaryTask = findLatestTask(meetingId, "SUMMARY");
|
||||||
|
boolean summaryCompleted = summaryTask != null && Integer.valueOf(2).equals(summaryTask.getStatus());
|
||||||
|
MeetingVO detail = (Integer.valueOf(3).equals(meeting.getStatus()) || summaryCompleted)
|
||||||
|
? meetingQueryService.getDetail(meetingId)
|
||||||
|
: null;
|
||||||
|
boolean hasSummary = detail != null && detail.getSummaryContent() != null && !detail.getSummaryContent().isBlank();
|
||||||
|
|
||||||
|
if (hasSummary) {
|
||||||
|
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask));
|
||||||
|
}
|
||||||
|
if (summaryCompleted) {
|
||||||
|
return new LegacyMeetingPreviewResult(
|
||||||
|
"504",
|
||||||
|
"处理已完成,但摘要尚未同步,请稍后重试",
|
||||||
|
buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可查看详情", 100, STAGE_COMPLETED))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isFailed(asrTask) || Integer.valueOf(4).equals(meeting.getStatus())) {
|
||||||
|
return new LegacyMeetingPreviewResult(
|
||||||
|
"503",
|
||||||
|
buildFailureMessage(asrTask, "转写"),
|
||||||
|
buildProcessingPreview(meeting, summaryTask, processingStatus("转写或总结失败", 50, STAGE_AUDIO_TRANSCRIPTION))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isFailed(summaryTask)) {
|
||||||
|
return new LegacyMeetingPreviewResult(
|
||||||
|
"503",
|
||||||
|
buildFailureMessage(summaryTask, "总结"),
|
||||||
|
buildProcessingPreview(meeting, summaryTask, processingStatus("转写或总结失败", 75, STAGE_SUMMARY_GENERATION))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer realtimeProgress = resolveRealtimeProgress(meetingId);
|
||||||
|
if (realtimeProgress != null) {
|
||||||
|
if (realtimeProgress < 90) {
|
||||||
|
return new LegacyMeetingPreviewResult(
|
||||||
|
"400",
|
||||||
|
"会议正在处理中",
|
||||||
|
buildProcessingPreview(meeting, summaryTask, processingStatus("正在转写音频", 50, STAGE_AUDIO_TRANSCRIPTION))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (realtimeProgress == 90) {
|
||||||
|
return new LegacyMeetingPreviewResult(
|
||||||
|
"400",
|
||||||
|
"会议正在处理中",
|
||||||
|
buildProcessingPreview(meeting, summaryTask, processingStatus("正在生成总结", 75, STAGE_SUMMARY_GENERATION))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isSummaryStage = isSummaryStage(meeting.getStatus(), summaryTask);
|
||||||
|
boolean isAsrStage = isAsrStage(meeting.getStatus(), asrTask, hasAudio(meeting), isSummaryStage);
|
||||||
|
|
||||||
|
if (!isAsrStage && !isSummaryStage) {
|
||||||
|
return new LegacyMeetingPreviewResult(
|
||||||
|
"400",
|
||||||
|
"会议正在处理中",
|
||||||
|
buildProcessingPreview(meeting, summaryTask, processingStatus("会议数据准备中", 25, STAGE_DATA_INITIALIZATION))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isSummaryStage) {
|
||||||
|
return new LegacyMeetingPreviewResult(
|
||||||
|
"400",
|
||||||
|
"会议正在处理中",
|
||||||
|
buildProcessingPreview(meeting, summaryTask, processingStatus("正在转写音频", 50, STAGE_AUDIO_TRANSCRIPTION))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new LegacyMeetingPreviewResult(
|
||||||
|
"400",
|
||||||
|
"会议正在处理中",
|
||||||
|
buildProcessingPreview(meeting, summaryTask, processingStatus("正在生成总结", 75, STAGE_SUMMARY_GENERATION))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LegacyMeetingPreviewDataResponse buildCompletedPreview(Meeting meeting, MeetingVO detail, AiTask summaryTask) {
|
||||||
|
LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse();
|
||||||
|
data.setMeetingId(meeting.getId());
|
||||||
|
data.setTitle(meeting.getTitle());
|
||||||
|
data.setMeetingTime(formatDateTime(meeting.getMeetingTime()));
|
||||||
|
data.setSummary(detail.getSummaryContent());
|
||||||
|
data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName()));
|
||||||
|
Long promptId = resolvePromptId(summaryTask);
|
||||||
|
data.setPromptId(promptId);
|
||||||
|
data.setPromptName(resolvePromptName(promptId));
|
||||||
|
List<LegacyMeetingAttendeeResponse> attendees = buildAttendees(meeting.getParticipants());
|
||||||
|
data.setAttendees(attendees);
|
||||||
|
data.setAttendeesCount(attendees.size());
|
||||||
|
data.setHasPassword(meeting.getAccessPassword() != null && !meeting.getAccessPassword().isBlank());
|
||||||
|
data.setProcessingStatus(processingStatus("摘要已生成,可查看详情", 100, STAGE_COMPLETED));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LegacyMeetingPreviewDataResponse buildProcessingPreview(Meeting meeting,
|
||||||
|
AiTask summaryTask,
|
||||||
|
LegacyMeetingProcessingStatusResponse status) {
|
||||||
|
LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse();
|
||||||
|
data.setMeetingId(meeting.getId());
|
||||||
|
data.setTitle(meeting.getTitle());
|
||||||
|
data.setMeetingTime(formatDateTime(meeting.getMeetingTime()));
|
||||||
|
data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName()));
|
||||||
|
Long promptId = resolvePromptId(summaryTask);
|
||||||
|
data.setPromptId(promptId);
|
||||||
|
data.setPromptName(resolvePromptName(promptId));
|
||||||
|
data.setHasPassword(meeting.getAccessPassword() != null && !meeting.getAccessPassword().isBlank());
|
||||||
|
data.setProcessingStatus(status);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LegacyMeetingProcessingStatusResponse processingStatus(String overallStatus, int overallProgress, String currentStage) {
|
||||||
|
return new LegacyMeetingProcessingStatusResponse(overallStatus, overallProgress, currentStage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer resolveRealtimeProgress(Long meetingId) {
|
||||||
|
String rawProgress = redisTemplate.opsForValue().get(RedisKeys.meetingProgressKey(meetingId));
|
||||||
|
if (rawProgress == null || rawProgress.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode progress = objectMapper.readTree(rawProgress);
|
||||||
|
return progress.hasNonNull("percent") ? progress.path("percent").asInt() : null;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildFailureMessage(AiTask failedTask, String stageName) {
|
||||||
|
String error = failedTask == null || failedTask.getErrorMsg() == null || failedTask.getErrorMsg().isBlank()
|
||||||
|
? "处理失败"
|
||||||
|
: failedTask.getErrorMsg();
|
||||||
|
return "会议" + stageName + "失败: " + error;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRunningAsr(AiTask task) {
|
||||||
|
return task != null && (Integer.valueOf(0).equals(task.getStatus()) || Integer.valueOf(1).equals(task.getStatus()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRunningSummary(AiTask task) {
|
||||||
|
return task != null && (Integer.valueOf(0).equals(task.getStatus()) || Integer.valueOf(1).equals(task.getStatus()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isFailed(AiTask task) {
|
||||||
|
return task != null && Integer.valueOf(3).equals(task.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
private AiTask findLatestTask(Long meetingId, String taskType) {
|
||||||
|
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getMeetingId, meetingId)
|
||||||
|
.eq(AiTask::getTaskType, taskType)
|
||||||
|
.orderByDesc(AiTask::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolvePromptId(AiTask summaryTask) {
|
||||||
|
if (summaryTask == null || summaryTask.getTaskConfig() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object rawPromptId = summaryTask.getTaskConfig().get("promptId");
|
||||||
|
if (rawPromptId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (rawPromptId instanceof Number number) {
|
||||||
|
return number.longValue();
|
||||||
|
}
|
||||||
|
String value = String.valueOf(rawPromptId).trim();
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(value);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolvePromptName(Long promptId) {
|
||||||
|
if (promptId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
PromptTemplate template = promptTemplateService.getById(promptId);
|
||||||
|
return template == null ? null : template.getTemplateName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<LegacyMeetingAttendeeResponse> buildAttendees(String participants) {
|
||||||
|
return buildAttendees(parseParticipantIds(participants));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<LegacyMeetingAttendeeResponse> buildAttendees(List<Long> participantIds) {
|
||||||
|
if (participantIds == null || participantIds.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Map<Long, SysUser> userMap = sysUserMapper.selectBatchIds(participantIds).stream()
|
||||||
|
.collect(Collectors.toMap(SysUser::getUserId, user -> user, (left, right) -> left, LinkedHashMap::new));
|
||||||
|
|
||||||
|
return participantIds.stream()
|
||||||
|
.map(userId -> {
|
||||||
|
SysUser user = userMap.get(userId);
|
||||||
|
String caption = user == null
|
||||||
|
? String.valueOf(userId)
|
||||||
|
: (user.getDisplayName() != null ? user.getDisplayName() : user.getUsername());
|
||||||
|
String username = user == null ? null : user.getUsername();
|
||||||
|
return new LegacyMeetingAttendeeResponse(userId, username, caption);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> parseParticipantIds(String participants) {
|
||||||
|
if (participants == null || participants.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return Arrays.stream(participants.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(value -> !value.isEmpty())
|
||||||
|
.map(value -> {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(value);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizePassword(String password) {
|
||||||
|
if (password == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = password.trim();
|
||||||
|
return normalized.isEmpty() ? null : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveCreatorDisplayName(Long creatorId, String fallbackName) {
|
||||||
|
if (creatorId == null) {
|
||||||
|
return fallbackName;
|
||||||
|
}
|
||||||
|
SysUser creator = sysUserMapper.selectById(creatorId);
|
||||||
|
if (creator == null) {
|
||||||
|
return fallbackName;
|
||||||
|
}
|
||||||
|
if (creator.getDisplayName() != null && !creator.getDisplayName().isBlank()) {
|
||||||
|
return creator.getDisplayName();
|
||||||
|
}
|
||||||
|
if (creator.getUsername() != null && !creator.getUsername().isBlank()) {
|
||||||
|
return creator.getUsername();
|
||||||
|
}
|
||||||
|
return fallbackName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasAudio(Meeting meeting) {
|
||||||
|
return meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSummaryStage(Integer meetingStatus, AiTask summaryTask) {
|
||||||
|
return Integer.valueOf(2).equals(meetingStatus) || isRunningSummary(summaryTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAsrStage(Integer meetingStatus, AiTask asrTask, boolean hasAudio, boolean isSummaryStage) {
|
||||||
|
return Integer.valueOf(1).equals(meetingStatus)
|
||||||
|
|| isRunningAsr(asrTask)
|
||||||
|
|| (hasAudio && !isSummaryStage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDateTime(LocalDateTime value) {
|
||||||
|
return value == null ? null : value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,11 @@ import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
||||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||||
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
|
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
|
||||||
import com.unisbase.common.ApiResponse;
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -56,6 +60,13 @@ public class AndroidMeetingRealtimeController {
|
||||||
private final GrpcServerProperties grpcServerProperties;
|
private final GrpcServerProperties grpcServerProperties;
|
||||||
|
|
||||||
@Operation(summary = "创建Android实时会议")
|
@Operation(summary = "创建Android实时会议")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回实时会议创建结果与本次生效的运行时参数",
|
||||||
|
content = @Content(schema = @Schema(implementation = AndroidCreateRealtimeMeetingVO.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
@PostMapping("/realtime/create")
|
@PostMapping("/realtime/create")
|
||||||
public ApiResponse<AndroidCreateRealtimeMeetingVO> createRealtimeMeeting(HttpServletRequest request,
|
public ApiResponse<AndroidCreateRealtimeMeetingVO> createRealtimeMeeting(HttpServletRequest request,
|
||||||
@RequestBody(required = false) AndroidCreateRealtimeMeetingCommand command) {
|
@RequestBody(required = false) AndroidCreateRealtimeMeetingCommand command) {
|
||||||
|
|
@ -104,6 +115,13 @@ public class AndroidMeetingRealtimeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "查询Android实时会议状态")
|
@Operation(summary = "查询Android实时会议状态")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回实时会议当前状态、恢复信息和连接状态",
|
||||||
|
content = @Content(schema = @Schema(implementation = RealtimeMeetingSessionStatusVO.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
@GetMapping("/{id}/realtime/session-status")
|
@GetMapping("/{id}/realtime/session-status")
|
||||||
public ApiResponse<RealtimeMeetingSessionStatusVO> getRealtimeSessionStatus(@PathVariable Long id, HttpServletRequest request) {
|
public ApiResponse<RealtimeMeetingSessionStatusVO> getRealtimeSessionStatus(@PathVariable Long id, HttpServletRequest request) {
|
||||||
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
|
|
@ -113,6 +131,13 @@ public class AndroidMeetingRealtimeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "查询Android会议转写")
|
@Operation(summary = "查询Android会议转写")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回会议转写记录列表",
|
||||||
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = MeetingTranscriptVO.class)))
|
||||||
|
)
|
||||||
|
})
|
||||||
@GetMapping("/{id}/transcripts")
|
@GetMapping("/{id}/transcripts")
|
||||||
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id, HttpServletRequest request) {
|
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id, HttpServletRequest request) {
|
||||||
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
|
|
@ -122,6 +147,13 @@ public class AndroidMeetingRealtimeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "暂停Android实时会议")
|
@Operation(summary = "暂停Android实时会议")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回暂停后的实时会议状态",
|
||||||
|
content = @Content(schema = @Schema(implementation = RealtimeMeetingSessionStatusVO.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
@PostMapping("/{id}/realtime/pause")
|
@PostMapping("/{id}/realtime/pause")
|
||||||
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);
|
||||||
|
|
@ -131,6 +163,13 @@ public class AndroidMeetingRealtimeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "完成Android实时会议")
|
@Operation(summary = "完成Android实时会议")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回实时会议完成是否成功",
|
||||||
|
content = @Content(schema = @Schema(implementation = Boolean.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
@PostMapping("/{id}/realtime/complete")
|
@PostMapping("/{id}/realtime/complete")
|
||||||
public ApiResponse<Boolean> completeRealtimeMeeting(@PathVariable Long id,
|
public ApiResponse<Boolean> completeRealtimeMeeting(@PathVariable Long id,
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
|
|
@ -147,6 +186,13 @@ public class AndroidMeetingRealtimeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "打开Android实时会议gRPC会话")
|
@Operation(summary = "打开Android实时会议gRPC会话")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回实时会议 gRPC 会话信息,包括连接参数和状态",
|
||||||
|
content = @Content(schema = @Schema(implementation = AndroidRealtimeGrpcSessionVO.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
@PostMapping("/{id}/realtime/grpc-session")
|
@PostMapping("/{id}/realtime/grpc-session")
|
||||||
public ApiResponse<AndroidRealtimeGrpcSessionVO> openRealtimeGrpcSession(@PathVariable Long id,
|
public ApiResponse<AndroidRealtimeGrpcSessionVO> openRealtimeGrpcSession(@PathVariable Long id,
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package com.imeeting.controller.android;
|
||||||
|
|
||||||
|
import com.imeeting.dto.android.AndroidAuthContext;
|
||||||
|
import com.imeeting.dto.biz.PromptTemplateVO;
|
||||||
|
import com.imeeting.service.android.AndroidAuthService;
|
||||||
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import com.unisbase.dto.PageResult;
|
||||||
|
import com.unisbase.security.LoginUser;
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Tag(name = "Android提示词接口")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/android/prompts")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AndroidPromptController {
|
||||||
|
|
||||||
|
private static final String LEGACY_MEETING_SCENE = "MEETING_TASK";
|
||||||
|
|
||||||
|
private final AndroidAuthService androidAuthService;
|
||||||
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
|
||||||
|
@Operation(summary = "查询场景提示词")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回指定场景下当前用户可用且启用的提示词模板列表",
|
||||||
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PromptTemplateVO.class)))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@GetMapping("/active/{scene}")
|
||||||
|
public ApiResponse<List<PromptTemplateVO>> activePrompts(HttpServletRequest request, @PathVariable String scene) {
|
||||||
|
if (!LEGACY_MEETING_SCENE.equals(scene)) {
|
||||||
|
return ApiResponse.error("scene only supports MEETING_TASK");
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
|
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
|
||||||
|
PageResult<List<PromptTemplateVO>> result = promptTemplateService.pageTemplates(
|
||||||
|
1,
|
||||||
|
1000,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
loginUser.getTenantId(),
|
||||||
|
loginUser.getUserId(),
|
||||||
|
loginUser.getIsPlatformAdmin(),
|
||||||
|
loginUser.getIsTenantAdmin()
|
||||||
|
);
|
||||||
|
List<PromptTemplateVO> enabledTemplates = result.getRecords() == null
|
||||||
|
? List.of()
|
||||||
|
: result.getRecords().stream()
|
||||||
|
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
|
||||||
|
.toList();
|
||||||
|
return ApiResponse.ok(enabledTemplates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,12 @@ import com.imeeting.dto.android.AndroidScreenSaverItemVO;
|
||||||
import com.imeeting.dto.biz.ScreenSaverSelectionResult;
|
import com.imeeting.dto.biz.ScreenSaverSelectionResult;
|
||||||
import com.imeeting.service.android.AndroidAuthService;
|
import com.imeeting.service.android.AndroidAuthService;
|
||||||
import com.imeeting.service.biz.ScreenSaverService;
|
import com.imeeting.service.biz.ScreenSaverService;
|
||||||
|
import com.imeeting.support.TaskSecurityContextRunner;
|
||||||
import com.unisbase.common.ApiResponse;
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -23,12 +27,20 @@ public class AndroidScreenSaverController {
|
||||||
|
|
||||||
private final AndroidAuthService androidAuthService;
|
private final AndroidAuthService androidAuthService;
|
||||||
private final ScreenSaverService screenSaverService;
|
private final ScreenSaverService screenSaverService;
|
||||||
|
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||||
|
|
||||||
@Operation(summary = "获取当前生效屏保")
|
@Operation(summary = "获取当前生效屏保")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "返回当前生效的屏保配置和轮播项列表",
|
||||||
|
content = @Content(schema = @Schema(implementation = AndroidScreenSaverCatalogVO.class))
|
||||||
|
)
|
||||||
|
})
|
||||||
@GetMapping("/active")
|
@GetMapping("/active")
|
||||||
public ApiResponse<AndroidScreenSaverCatalogVO> active(HttpServletRequest request) {
|
public ApiResponse<AndroidScreenSaverCatalogVO> active(HttpServletRequest request) {
|
||||||
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
ScreenSaverSelectionResult selection = screenSaverService.getActiveSelection(authContext == null ? null : authContext.getUserId());
|
ScreenSaverSelectionResult selection = querySelection(authContext);
|
||||||
AndroidScreenSaverCatalogVO vo = new AndroidScreenSaverCatalogVO();
|
AndroidScreenSaverCatalogVO vo = new AndroidScreenSaverCatalogVO();
|
||||||
vo.setRefreshIntervalSec(300);
|
vo.setRefreshIntervalSec(300);
|
||||||
vo.setPlayMode("SEQUENTIAL");
|
vo.setPlayMode("SEQUENTIAL");
|
||||||
|
|
@ -46,4 +58,16 @@ public class AndroidScreenSaverController {
|
||||||
}).toList());
|
}).toList());
|
||||||
return ApiResponse.ok(vo);
|
return ApiResponse.ok(vo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ScreenSaverSelectionResult querySelection(AndroidAuthContext authContext) {
|
||||||
|
if (authContext == null || authContext.isAnonymous()
|
||||||
|
|| authContext.getUserId() == null || authContext.getTenantId() == null) {
|
||||||
|
return screenSaverService.getActiveSelection(null);
|
||||||
|
}
|
||||||
|
return taskSecurityContextRunner.callAsTenantUser(
|
||||||
|
authContext.getTenantId(),
|
||||||
|
authContext.getUserId(),
|
||||||
|
() -> screenSaverService.getActiveSelection(authContext.getUserId())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,16 @@ package com.imeeting.controller.android.legacy;
|
||||||
import com.imeeting.dto.android.legacy.LegacyApiResponse;
|
import com.imeeting.dto.android.legacy.LegacyApiResponse;
|
||||||
import com.imeeting.dto.android.legacy.LegacyScreenSaverItemResponse;
|
import com.imeeting.dto.android.legacy.LegacyScreenSaverItemResponse;
|
||||||
import com.imeeting.service.android.legacy.LegacyScreenSaverAdapterService;
|
import com.imeeting.service.android.legacy.LegacyScreenSaverAdapterService;
|
||||||
|
import com.imeeting.support.TaskSecurityContextRunner;
|
||||||
|
import com.unisbase.dto.InternalAuthCheckResponse;
|
||||||
|
import com.unisbase.security.LoginUser;
|
||||||
|
import com.unisbase.service.TokenValidationService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
@ -18,11 +25,77 @@ import java.util.List;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class LegacyScreenSaverController {
|
public class LegacyScreenSaverController {
|
||||||
|
|
||||||
|
private static final String HEADER_AUTHORIZATION = "Authorization";
|
||||||
|
private static final String BEARER_PREFIX = "Bearer ";
|
||||||
|
|
||||||
private final LegacyScreenSaverAdapterService legacyScreenSaverAdapterService;
|
private final LegacyScreenSaverAdapterService legacyScreenSaverAdapterService;
|
||||||
|
private final TokenValidationService tokenValidationService;
|
||||||
|
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||||
|
|
||||||
@Operation(summary = "查询启用的屏保列表")
|
@Operation(summary = "查询启用的屏保列表")
|
||||||
@GetMapping("/active")
|
@GetMapping("/active")
|
||||||
public LegacyApiResponse<List<LegacyScreenSaverItemResponse>> active() {
|
public LegacyApiResponse<List<LegacyScreenSaverItemResponse>> active(HttpServletRequest request) {
|
||||||
return LegacyApiResponse.ok(legacyScreenSaverAdapterService.listActiveScreenSavers());
|
LoginUser loginUser = resolveLoginUser(request);
|
||||||
|
return LegacyApiResponse.ok(queryActive(loginUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<LegacyScreenSaverItemResponse> queryActive(LoginUser loginUser) {
|
||||||
|
if (loginUser == null || loginUser.getUserId() == null || loginUser.getTenantId() == null) {
|
||||||
|
return legacyScreenSaverAdapterService.listActiveScreenSavers(null);
|
||||||
|
}
|
||||||
|
return taskSecurityContextRunner.callAsTenantUser(
|
||||||
|
loginUser.getTenantId(),
|
||||||
|
loginUser.getUserId(),
|
||||||
|
() -> legacyScreenSaverAdapterService.listActiveScreenSavers(loginUser.getUserId())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoginUser resolveLoginUser(HttpServletRequest request) {
|
||||||
|
LoginUser loginUser = currentLoginUserFromContext();
|
||||||
|
if (loginUser != null) {
|
||||||
|
return loginUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = resolveBearerToken(request);
|
||||||
|
if (!StringUtils.hasText(token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
InternalAuthCheckResponse authResult = tokenValidationService.validateAccessToken(token);
|
||||||
|
if (authResult == null || !authResult.isValid()
|
||||||
|
|| authResult.getUserId() == null || authResult.getTenantId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginUser resolved = new LoginUser(
|
||||||
|
authResult.getUserId(),
|
||||||
|
authResult.getTenantId(),
|
||||||
|
authResult.getUsername(),
|
||||||
|
authResult.getPlatformAdmin(),
|
||||||
|
authResult.getTenantAdmin(),
|
||||||
|
authResult.getPermissions()
|
||||||
|
);
|
||||||
|
resolved.setDisplayName(authResult.getDisplayName());
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoginUser currentLoginUserFromContext() {
|
||||||
|
if (SecurityContextHolder.getContext().getAuthentication() == null
|
||||||
|
|| !(SecurityContextHolder.getContext().getAuthentication().getPrincipal() instanceof LoginUser loginUser)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (loginUser.getUserId() == null || loginUser.getTenantId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return loginUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveBearerToken(HttpServletRequest request) {
|
||||||
|
String authorization = request.getHeader(HEADER_AUTHORIZATION);
|
||||||
|
if (!StringUtils.hasText(authorization) || !authorization.startsWith(BEARER_PREFIX)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String token = authorization.substring(BEARER_PREFIX.length()).trim();
|
||||||
|
return token.isEmpty() ? null : token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,17 @@ public class ScreenSaverController {
|
||||||
return ApiResponse.ok(screenSaverService.update(id, dto, currentLoginUser()));
|
return ApiResponse.ok(screenSaverService.update(id, dto, currentLoginUser()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "更新屏保状态")
|
||||||
|
@PutMapping("/{id}/status")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ApiResponse<Boolean> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
|
||||||
|
boolean success = screenSaverService.updateStatus(id, status, currentLoginUser());
|
||||||
|
if (!success) {
|
||||||
|
return ApiResponse.error("Screen saver not found or no permission");
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除屏保")
|
@Operation(summary = "删除屏保")
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,40 @@ package com.imeeting.dto.android;
|
||||||
|
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Schema(description = "Android 实时会议创建结果")
|
||||||
@Data
|
@Data
|
||||||
public class AndroidCreateRealtimeMeetingVO {
|
public class AndroidCreateRealtimeMeetingVO {
|
||||||
|
@Schema(description = "会议 ID")
|
||||||
private Long meetingId;
|
private Long meetingId;
|
||||||
|
@Schema(description = "会议标题")
|
||||||
private String title;
|
private String title;
|
||||||
|
@Schema(description = "主持人用户 ID")
|
||||||
private Long hostUserId;
|
private Long hostUserId;
|
||||||
|
@Schema(description = "主持人名称")
|
||||||
private String hostName;
|
private String hostName;
|
||||||
|
@Schema(description = "实时音频采样率")
|
||||||
private Integer sampleRate;
|
private Integer sampleRate;
|
||||||
|
@Schema(description = "音频通道数")
|
||||||
private Integer channels;
|
private Integer channels;
|
||||||
|
@Schema(description = "音频编码格式")
|
||||||
private String encoding;
|
private String encoding;
|
||||||
|
@Schema(description = "最终生效的 ASR 模型 ID")
|
||||||
private Long resolvedAsrModelId;
|
private Long resolvedAsrModelId;
|
||||||
|
@Schema(description = "最终生效的 ASR 模型名称")
|
||||||
private String resolvedAsrModelName;
|
private String resolvedAsrModelName;
|
||||||
|
@Schema(description = "最终生效的总结模型 ID")
|
||||||
private Long resolvedSummaryModelId;
|
private Long resolvedSummaryModelId;
|
||||||
|
@Schema(description = "最终生效的总结模型名称")
|
||||||
private String resolvedSummaryModelName;
|
private String resolvedSummaryModelName;
|
||||||
|
@Schema(description = "最终生效的提示词模板 ID")
|
||||||
private Long resolvedPromptId;
|
private Long resolvedPromptId;
|
||||||
|
@Schema(description = "最终生效的提示词模板名称")
|
||||||
private String resolvedPromptName;
|
private String resolvedPromptName;
|
||||||
|
@Schema(description = "恢复会议时使用的运行时参数")
|
||||||
private RealtimeMeetingResumeConfig resumeConfig;
|
private RealtimeMeetingResumeConfig resumeConfig;
|
||||||
|
@Schema(description = "当前实时会议状态")
|
||||||
private RealtimeMeetingSessionStatusVO status;
|
private RealtimeMeetingSessionStatusVO status;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,26 @@ package com.imeeting.dto.android;
|
||||||
|
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Schema(description = "Android 实时会议 gRPC 会话信息")
|
||||||
@Data
|
@Data
|
||||||
public class AndroidRealtimeGrpcSessionVO {
|
public class AndroidRealtimeGrpcSessionVO {
|
||||||
|
@Schema(description = "会议 ID")
|
||||||
private Long meetingId;
|
private Long meetingId;
|
||||||
|
@Schema(description = "实时流会话令牌")
|
||||||
private String streamToken;
|
private String streamToken;
|
||||||
|
@Schema(description = "令牌剩余有效秒数")
|
||||||
private Long expiresInSeconds;
|
private Long expiresInSeconds;
|
||||||
|
@Schema(description = "实时音频采样率")
|
||||||
private Integer sampleRate;
|
private Integer sampleRate;
|
||||||
|
@Schema(description = "音频通道数")
|
||||||
private Integer channels;
|
private Integer channels;
|
||||||
|
@Schema(description = "音频编码格式")
|
||||||
private String encoding;
|
private String encoding;
|
||||||
|
@Schema(description = "恢复会议时使用的运行时参数")
|
||||||
private RealtimeMeetingResumeConfig resumeConfig;
|
private RealtimeMeetingResumeConfig resumeConfig;
|
||||||
|
@Schema(description = "当前实时会议状态")
|
||||||
private RealtimeMeetingSessionStatusVO status;
|
private RealtimeMeetingSessionStatusVO status;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
package com.imeeting.dto.android;
|
package com.imeeting.dto.android;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
@Schema(description = "Android 屏保配置")
|
||||||
@Data
|
@Data
|
||||||
public class AndroidScreenSaverCatalogVO {
|
public class AndroidScreenSaverCatalogVO {
|
||||||
|
@Schema(description = "客户端建议刷新间隔,单位秒")
|
||||||
private Integer refreshIntervalSec;
|
private Integer refreshIntervalSec;
|
||||||
|
@Schema(description = "播放模式")
|
||||||
private String playMode;
|
private String playMode;
|
||||||
|
@Schema(description = "当前屏保来源范围")
|
||||||
private String sourceScope;
|
private String sourceScope;
|
||||||
|
@Schema(description = "屏保图片项列表")
|
||||||
private List<AndroidScreenSaverItemVO> items;
|
private List<AndroidScreenSaverItemVO> items;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
package com.imeeting.dto.android;
|
package com.imeeting.dto.android;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Schema(description = "Android 屏保图片项")
|
||||||
@Data
|
@Data
|
||||||
public class AndroidScreenSaverItemVO {
|
public class AndroidScreenSaverItemVO {
|
||||||
|
@Schema(description = "屏保项 ID")
|
||||||
private Long id;
|
private Long id;
|
||||||
|
@Schema(description = "屏保名称")
|
||||||
private String name;
|
private String name;
|
||||||
|
@Schema(description = "屏保图片地址")
|
||||||
private String imageUrl;
|
private String imageUrl;
|
||||||
|
@Schema(description = "屏保描述")
|
||||||
private String description;
|
private String description;
|
||||||
|
@Schema(description = "单张展示时长,单位秒")
|
||||||
private Integer displayDurationSec;
|
private Integer displayDurationSec;
|
||||||
|
@Schema(description = "排序值")
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
|
@Schema(description = "最近更新时间")
|
||||||
private String updatedAt;
|
private String updatedAt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
package com.imeeting.dto.android.legacy;
|
package com.imeeting.dto.android.legacy;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Schema(description = "参会人信息")
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class LegacyMeetingAttendeeResponse {
|
public class LegacyMeetingAttendeeResponse {
|
||||||
@JsonProperty("user_id")
|
@JsonProperty("user_id")
|
||||||
|
@Schema(description = "用户 ID")
|
||||||
private Long userId;
|
private Long userId;
|
||||||
|
|
||||||
|
@Schema(description = "用户名")
|
||||||
private String username;
|
private String username;
|
||||||
|
|
||||||
|
@Schema(description = "展示名称")
|
||||||
private String caption;
|
private String caption;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,52 @@
|
||||||
package com.imeeting.dto.android.legacy;
|
package com.imeeting.dto.android.legacy;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
@Schema(description = "Android 会议预览数据")
|
||||||
@Data
|
@Data
|
||||||
public class LegacyMeetingPreviewDataResponse {
|
public class LegacyMeetingPreviewDataResponse {
|
||||||
@JsonProperty("meeting_id")
|
@JsonProperty("meeting_id")
|
||||||
|
@Schema(description = "会议 ID")
|
||||||
private Long meetingId;
|
private Long meetingId;
|
||||||
|
|
||||||
|
@Schema(description = "会议标题")
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@JsonProperty("meeting_time")
|
@JsonProperty("meeting_time")
|
||||||
|
@Schema(description = "会议时间")
|
||||||
private String meetingTime;
|
private String meetingTime;
|
||||||
|
|
||||||
|
@Schema(description = "会议摘要")
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
@JsonProperty("creator_username")
|
@JsonProperty("creator_username")
|
||||||
|
@Schema(description = "创建人名称")
|
||||||
private String creatorUsername;
|
private String creatorUsername;
|
||||||
|
|
||||||
@JsonProperty("prompt_id")
|
@JsonProperty("prompt_id")
|
||||||
|
@Schema(description = "提示词模板 ID")
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
|
||||||
@JsonProperty("prompt_name")
|
@JsonProperty("prompt_name")
|
||||||
|
@Schema(description = "提示词模板名称")
|
||||||
private String promptName;
|
private String promptName;
|
||||||
|
|
||||||
|
@Schema(description = "参会人列表")
|
||||||
private List<LegacyMeetingAttendeeResponse> attendees;
|
private List<LegacyMeetingAttendeeResponse> attendees;
|
||||||
|
|
||||||
@JsonProperty("attendees_count")
|
@JsonProperty("attendees_count")
|
||||||
|
@Schema(description = "参会人数")
|
||||||
private Integer attendeesCount;
|
private Integer attendeesCount;
|
||||||
|
|
||||||
@JsonProperty("has_password")
|
@JsonProperty("has_password")
|
||||||
|
@Schema(description = "是否设置访问密码")
|
||||||
private Boolean hasPassword;
|
private Boolean hasPassword;
|
||||||
|
|
||||||
@JsonProperty("processing_status")
|
@JsonProperty("processing_status")
|
||||||
|
@Schema(description = "处理状态")
|
||||||
private LegacyMeetingProcessingStatusResponse processingStatus;
|
private LegacyMeetingProcessingStatusResponse processingStatus;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
package com.imeeting.dto.android.legacy;
|
package com.imeeting.dto.android.legacy;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Schema(description = "会议处理状态")
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class LegacyMeetingProcessingStatusResponse {
|
public class LegacyMeetingProcessingStatusResponse {
|
||||||
@JsonProperty("overall_status")
|
@JsonProperty("overall_status")
|
||||||
|
@Schema(description = "整体状态说明")
|
||||||
private String overallStatus;
|
private String overallStatus;
|
||||||
|
|
||||||
@JsonProperty("overall_progress")
|
@JsonProperty("overall_progress")
|
||||||
|
@Schema(description = "整体进度百分比")
|
||||||
private Integer overallProgress;
|
private Integer overallProgress;
|
||||||
|
|
||||||
@JsonProperty("current_stage")
|
@JsonProperty("current_stage")
|
||||||
|
@Schema(description = "当前阶段")
|
||||||
private String currentStage;
|
private String currentStage;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
package com.imeeting.dto.android.legacy;
|
package com.imeeting.dto.android.legacy;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Schema(description = "Android 上传会议音频结果")
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class LegacyUploadAudioResponse {
|
public class LegacyUploadAudioResponse {
|
||||||
@JsonProperty("meeting_id")
|
@JsonProperty("meeting_id")
|
||||||
|
@Schema(description = "会议 ID")
|
||||||
private Long meetingId;
|
private Long meetingId;
|
||||||
|
|
||||||
@JsonProperty("audio_url")
|
@JsonProperty("audio_url")
|
||||||
|
@Schema(description = "上传后的音频访问地址")
|
||||||
private String audioUrl;
|
private String audioUrl;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,46 @@
|
||||||
package com.imeeting.dto.biz;
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Schema(description = "AI 模型信息")
|
||||||
@Data
|
@Data
|
||||||
public class AiModelVO {
|
public class AiModelVO {
|
||||||
|
@Schema(description = "模型 ID")
|
||||||
private Long id;
|
private Long id;
|
||||||
|
@Schema(description = "租户 ID")
|
||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
|
@Schema(description = "模型类型")
|
||||||
private String modelType;
|
private String modelType;
|
||||||
|
@Schema(description = "模型名称")
|
||||||
private String modelName;
|
private String modelName;
|
||||||
|
@Schema(description = "提供方")
|
||||||
private String provider;
|
private String provider;
|
||||||
|
@Schema(description = "服务基础地址")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
@Schema(description = "接口路径")
|
||||||
private String apiPath;
|
private String apiPath;
|
||||||
|
@Schema(description = "接口密钥,返回时通常为脱敏值")
|
||||||
private String apiKey; // Will be masked in actual implementation
|
private String apiKey; // Will be masked in actual implementation
|
||||||
|
@Schema(description = "模型编码")
|
||||||
private String modelCode;
|
private String modelCode;
|
||||||
|
@Schema(description = "WebSocket 地址")
|
||||||
private String wsUrl;
|
private String wsUrl;
|
||||||
|
@Schema(description = "温度参数")
|
||||||
private BigDecimal temperature;
|
private BigDecimal temperature;
|
||||||
|
@Schema(description = "TopP 参数")
|
||||||
private BigDecimal topP;
|
private BigDecimal topP;
|
||||||
|
@Schema(description = "多媒体配置")
|
||||||
private Map<String, Object> mediaConfig;
|
private Map<String, Object> mediaConfig;
|
||||||
|
@Schema(description = "是否为默认模型")
|
||||||
private Integer isDefault;
|
private Integer isDefault;
|
||||||
|
@Schema(description = "启用状态")
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
@Schema(description = "备注")
|
||||||
private String remark;
|
private String remark;
|
||||||
|
@Schema(description = "创建时间")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
package com.imeeting.dto.biz;
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Schema(description = "会议转写记录")
|
||||||
@Data
|
@Data
|
||||||
public class MeetingTranscriptVO {
|
public class MeetingTranscriptVO {
|
||||||
|
@Schema(description = "转写记录 ID")
|
||||||
private Long id;
|
private Long id;
|
||||||
|
@Schema(description = "说话人标识")
|
||||||
private String speakerId;
|
private String speakerId;
|
||||||
|
@Schema(description = "说话人名称")
|
||||||
private String speakerName;
|
private String speakerName;
|
||||||
|
@Schema(description = "说话人标签")
|
||||||
private String speakerLabel;
|
private String speakerLabel;
|
||||||
|
@Schema(description = "转写文本内容")
|
||||||
private String content;
|
private String content;
|
||||||
|
@Schema(description = "开始时间,单位毫秒")
|
||||||
private Integer startTime;
|
private Integer startTime;
|
||||||
|
@Schema(description = "结束时间,单位毫秒")
|
||||||
private Integer endTime;
|
private Integer endTime;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,60 @@
|
||||||
package com.imeeting.dto.biz;
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Schema(description = "会议详情返回对象")
|
||||||
@Data
|
@Data
|
||||||
public class MeetingVO {
|
public class MeetingVO {
|
||||||
|
@Schema(description = "会议 ID")
|
||||||
private Long id;
|
private Long id;
|
||||||
|
@Schema(description = "租户 ID")
|
||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
|
@Schema(description = "创建人用户 ID")
|
||||||
private Long creatorId;
|
private Long creatorId;
|
||||||
|
@Schema(description = "创建人名称")
|
||||||
private String creatorName;
|
private String creatorName;
|
||||||
|
@Schema(description = "主持人用户 ID")
|
||||||
private Long hostUserId;
|
private Long hostUserId;
|
||||||
|
@Schema(description = "主持人名称")
|
||||||
private String hostName;
|
private String hostName;
|
||||||
|
@Schema(description = "会议标题")
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
@Schema(description = "会议时间")
|
||||||
private LocalDateTime meetingTime;
|
private LocalDateTime meetingTime;
|
||||||
|
|
||||||
|
@Schema(description = "参会人 ID 串,逗号分隔")
|
||||||
private String participants;
|
private String participants;
|
||||||
|
@Schema(description = "参会人 ID 列表")
|
||||||
private List<Long> participantIds;
|
private List<Long> participantIds;
|
||||||
|
@Schema(description = "标签串")
|
||||||
private String tags;
|
private String tags;
|
||||||
|
@Schema(description = "音频地址")
|
||||||
private String audioUrl;
|
private String audioUrl;
|
||||||
|
@Schema(description = "音频保存状态")
|
||||||
private String audioSaveStatus;
|
private String audioSaveStatus;
|
||||||
|
@Schema(description = "音频保存说明")
|
||||||
private String audioSaveMessage;
|
private String audioSaveMessage;
|
||||||
|
@Schema(description = "访问密码")
|
||||||
private String accessPassword;
|
private String accessPassword;
|
||||||
|
@Schema(description = "音频时长,单位秒")
|
||||||
private Integer duration;
|
private Integer duration;
|
||||||
|
@Schema(description = "会议摘要内容")
|
||||||
private String summaryContent;
|
private String summaryContent;
|
||||||
|
@Schema(description = "最后一次用户补充提示词")
|
||||||
private String lastUserPrompt;
|
private String lastUserPrompt;
|
||||||
|
@Schema(description = "分析结果")
|
||||||
private Map<String, Object> analysis;
|
private Map<String, Object> analysis;
|
||||||
|
@Schema(description = "会议状态")
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
@Schema(description = "创建时间")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,38 @@
|
||||||
package com.imeeting.dto.biz;
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Schema(description = "提示词模板信息")
|
||||||
@Data
|
@Data
|
||||||
public class PromptTemplateVO {
|
public class PromptTemplateVO {
|
||||||
|
@Schema(description = "模板 ID")
|
||||||
private Long id;
|
private Long id;
|
||||||
|
@Schema(description = "租户 ID")
|
||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
|
@Schema(description = "创建人用户 ID")
|
||||||
private Long creatorId;
|
private Long creatorId;
|
||||||
|
@Schema(description = "模板名称")
|
||||||
private String templateName;
|
private String templateName;
|
||||||
|
@Schema(description = "模板描述")
|
||||||
private String description;
|
private String description;
|
||||||
|
@Schema(description = "模板分类")
|
||||||
private String category;
|
private String category;
|
||||||
|
@Schema(description = "是否为系统模板")
|
||||||
private Integer isSystem;
|
private Integer isSystem;
|
||||||
|
@Schema(description = "标签列表")
|
||||||
private java.util.List<String> tags;
|
private java.util.List<String> tags;
|
||||||
|
@Schema(description = "使用次数")
|
||||||
private Integer usageCount;
|
private Integer usageCount;
|
||||||
|
@Schema(description = "提示词正文")
|
||||||
private String promptContent;
|
private String promptContent;
|
||||||
|
@Schema(description = "启用状态")
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
@Schema(description = "备注")
|
||||||
private String remark;
|
private String remark;
|
||||||
|
@Schema(description = "创建时间")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
@Schema(description = "更新时间")
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,30 @@
|
||||||
package com.imeeting.dto.biz;
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Schema(description = "实时会议恢复配置")
|
||||||
@Data
|
@Data
|
||||||
public class RealtimeMeetingResumeConfig {
|
public class RealtimeMeetingResumeConfig {
|
||||||
|
@Schema(description = "ASR 模型 ID")
|
||||||
private Long asrModelId;
|
private Long asrModelId;
|
||||||
|
@Schema(description = "识别模式")
|
||||||
private String mode;
|
private String mode;
|
||||||
|
@Schema(description = "识别语言")
|
||||||
private String language;
|
private String language;
|
||||||
|
@Schema(description = "是否开启说话人区分")
|
||||||
private Integer useSpkId;
|
private Integer useSpkId;
|
||||||
|
@Schema(description = "是否开启标点恢复")
|
||||||
private Boolean enablePunctuation;
|
private Boolean enablePunctuation;
|
||||||
|
@Schema(description = "是否开启 ITN 归一化")
|
||||||
private Boolean enableItn;
|
private Boolean enableItn;
|
||||||
|
@Schema(description = "是否开启文本润色")
|
||||||
private Boolean enableTextRefine;
|
private Boolean enableTextRefine;
|
||||||
|
@Schema(description = "是否保存音频")
|
||||||
private Boolean saveAudio;
|
private Boolean saveAudio;
|
||||||
|
@Schema(description = "热词列表")
|
||||||
private List<Map<String, Object>> hotwords;
|
private List<Map<String, Object>> hotwords;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
package com.imeeting.dto.biz;
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Schema(description = "实时会议会话状态")
|
||||||
@Data
|
@Data
|
||||||
public class RealtimeMeetingSessionStatusVO {
|
public class RealtimeMeetingSessionStatusVO {
|
||||||
|
@Schema(description = "会议 ID")
|
||||||
private Long meetingId;
|
private Long meetingId;
|
||||||
|
@Schema(description = "实时会议状态")
|
||||||
private String status;
|
private String status;
|
||||||
|
@Schema(description = "是否已存在转写内容")
|
||||||
private Boolean hasTranscript;
|
private Boolean hasTranscript;
|
||||||
|
@Schema(description = "是否允许恢复")
|
||||||
private Boolean canResume;
|
private Boolean canResume;
|
||||||
|
@Schema(description = "距离恢复过期剩余秒数")
|
||||||
private Long remainingSeconds;
|
private Long remainingSeconds;
|
||||||
|
@Schema(description = "恢复过期时间戳")
|
||||||
private Long resumeExpireAt;
|
private Long resumeExpireAt;
|
||||||
|
@Schema(description = "是否存在活动连接")
|
||||||
private Boolean activeConnection;
|
private Boolean activeConnection;
|
||||||
|
@Schema(description = "恢复会议所需参数")
|
||||||
private RealtimeMeetingResumeConfig resumeConfig;
|
private RealtimeMeetingResumeConfig resumeConfig;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.imeeting.entity.biz;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.unisbase.entity.BaseEntity;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@Schema(description = "屏保用户配置实体")
|
||||||
|
@TableName("biz_screen_saver_user_config")
|
||||||
|
public class ScreenSaverUserConfig extends BaseEntity {
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
@Schema(description = "配置 ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Schema(description = "用户 ID")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Schema(description = "屏保素材 ID")
|
||||||
|
private Long screenSaverId;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.imeeting.mapper.biz;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.imeeting.entity.biz.ScreenSaverUserConfig;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ScreenSaverUserConfigMapper extends BaseMapper<ScreenSaverUserConfig> {
|
||||||
|
}
|
||||||
|
|
@ -19,8 +19,6 @@ import org.springframework.util.StringUtils;
|
||||||
public class AndroidAuthServiceImpl implements AndroidAuthService {
|
public class AndroidAuthServiceImpl implements AndroidAuthService {
|
||||||
|
|
||||||
private static final String HEADER_DEVICE_ID = "X-Android-Device-Id";
|
private static final String HEADER_DEVICE_ID = "X-Android-Device-Id";
|
||||||
private static final String HEADER_TENANT_CODE = "X-Android-Tenant-Code";
|
|
||||||
private static final String HEADER_ACCESS_TOKEN = "X-Android-Access-Token";
|
|
||||||
private static final String HEADER_APP_ID = "X-Android-App-Id";
|
private static final String HEADER_APP_ID = "X-Android-App-Id";
|
||||||
private static final String HEADER_APP_VERSION = "X-Android-App-Version";
|
private static final String HEADER_APP_VERSION = "X-Android-App-Version";
|
||||||
private static final String HEADER_PLATFORM = "X-Android-Platform";
|
private static final String HEADER_PLATFORM = "X-Android-Platform";
|
||||||
|
|
@ -35,11 +33,11 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
|
||||||
ClientAuth.AuthType authType = auth == null ? ClientAuth.AuthType.AUTH_TYPE_UNSPECIFIED : auth.getAuthType();
|
ClientAuth.AuthType authType = auth == null ? ClientAuth.AuthType.AUTH_TYPE_UNSPECIFIED : auth.getAuthType();
|
||||||
if (authType == ClientAuth.AuthType.USER_JWT) {
|
if (authType == ClientAuth.AuthType.USER_JWT) {
|
||||||
InternalAuthCheckResponse authResult = validateToken(auth == null ? null : auth.getAccessToken());
|
InternalAuthCheckResponse authResult = validateToken(auth == null ? null : auth.getAccessToken());
|
||||||
return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(), auth == null ? null : auth.getTenantCode(), auth == null ? null : auth.getAppId(),
|
return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(),auth == null ? null : auth.getAppId(),
|
||||||
auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), auth == null ? null : auth.getAccessToken(), fallbackDeviceId, authResult, null);
|
auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), auth == null ? null : auth.getAccessToken(), fallbackDeviceId, authResult, null);
|
||||||
}
|
}
|
||||||
if (authType == ClientAuth.AuthType.DEVICE_TOKEN) {
|
if (authType == ClientAuth.AuthType.DEVICE_TOKEN) {
|
||||||
return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(), auth == null ? null : auth.getTenantCode(), auth == null ? null : auth.getAppId(),
|
return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(), auth == null ? null : auth.getAppId(),
|
||||||
auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), auth == null ? null : auth.getAccessToken(), fallbackDeviceId, null, null);
|
auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), auth == null ? null : auth.getAccessToken(), fallbackDeviceId, null, null);
|
||||||
}
|
}
|
||||||
if (properties.isEnabled() && !properties.isAllowAnonymous()) {
|
if (properties.isEnabled() && !properties.isAllowAnonymous()) {
|
||||||
|
|
@ -47,7 +45,6 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
|
||||||
}
|
}
|
||||||
return buildContext("NONE", true,
|
return buildContext("NONE", true,
|
||||||
auth == null ? null : auth.getDeviceId(),
|
auth == null ? null : auth.getDeviceId(),
|
||||||
auth == null ? null : auth.getTenantCode(),
|
|
||||||
auth == null ? null : auth.getAppId(),
|
auth == null ? null : auth.getAppId(),
|
||||||
auth == null ? null : auth.getAppVersion(),
|
auth == null ? null : auth.getAppVersion(),
|
||||||
auth == null ? null : auth.getPlatform(),
|
auth == null ? null : auth.getPlatform(),
|
||||||
|
|
@ -61,13 +58,19 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
|
||||||
public AndroidAuthContext authenticateHttp(HttpServletRequest request) {
|
public AndroidAuthContext authenticateHttp(HttpServletRequest request) {
|
||||||
LoginUser loginUser = currentLoginUser();
|
LoginUser loginUser = currentLoginUser();
|
||||||
String resolvedToken = resolveHttpToken(request);
|
String resolvedToken = resolveHttpToken(request);
|
||||||
|
String deviceId = firstHeader(request, HEADER_DEVICE_ID);
|
||||||
|
String appId = request.getHeader(HEADER_APP_ID);
|
||||||
|
String appVersion = firstHeader(request, HEADER_APP_VERSION);
|
||||||
|
String platform = request.getHeader(HEADER_PLATFORM);
|
||||||
|
|
||||||
|
requireAndroidHttpHeaders(deviceId, appVersion, platform);
|
||||||
|
|
||||||
if (loginUser != null) {
|
if (loginUser != null) {
|
||||||
return buildContext("USER_JWT", false,
|
return buildContext("USER_JWT", false,
|
||||||
request.getHeader(HEADER_DEVICE_ID),
|
deviceId,
|
||||||
request.getHeader(HEADER_TENANT_CODE),
|
appId,
|
||||||
request.getHeader(HEADER_APP_ID),
|
appVersion,
|
||||||
request.getHeader(HEADER_APP_VERSION),
|
platform,
|
||||||
request.getHeader(HEADER_PLATFORM),
|
|
||||||
resolvedToken,
|
resolvedToken,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
|
@ -77,34 +80,30 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
|
||||||
if (StringUtils.hasText(resolvedToken)) {
|
if (StringUtils.hasText(resolvedToken)) {
|
||||||
InternalAuthCheckResponse authResult = validateToken(resolvedToken);
|
InternalAuthCheckResponse authResult = validateToken(resolvedToken);
|
||||||
return buildContext("USER_JWT", false,
|
return buildContext("USER_JWT", false,
|
||||||
request.getHeader(HEADER_DEVICE_ID),
|
deviceId,
|
||||||
request.getHeader(HEADER_TENANT_CODE),
|
appId,
|
||||||
request.getHeader(HEADER_APP_ID),
|
appVersion,
|
||||||
request.getHeader(HEADER_APP_VERSION),
|
platform,
|
||||||
request.getHeader(HEADER_PLATFORM),
|
|
||||||
resolvedToken,
|
resolvedToken,
|
||||||
null,
|
null,
|
||||||
authResult,
|
authResult,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
|
if (properties.isAllowAnonymous()) {
|
||||||
if (properties.isEnabled() && !properties.isAllowAnonymous()) {
|
|
||||||
throw new RuntimeException("Android HTTP auth is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildContext("NONE", true,
|
return buildContext("NONE", true,
|
||||||
request.getHeader(HEADER_DEVICE_ID),
|
deviceId,
|
||||||
request.getHeader(HEADER_TENANT_CODE),
|
appId,
|
||||||
request.getHeader(HEADER_APP_ID),
|
appVersion,
|
||||||
request.getHeader(HEADER_APP_VERSION),
|
platform,
|
||||||
request.getHeader(HEADER_PLATFORM),
|
null,
|
||||||
request.getHeader(HEADER_ACCESS_TOKEN),
|
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
|
throw new RuntimeException("Android HTTP access token is required");
|
||||||
|
}
|
||||||
|
|
||||||
private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, String tenantCode,
|
private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId,
|
||||||
String appId, String appVersion, String platform, String accessToken,
|
String appId, String appVersion, String platform, String accessToken,
|
||||||
String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) {
|
String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) {
|
||||||
String resolvedDeviceId = StringUtils.hasText(deviceId) ? deviceId : fallbackDeviceId;
|
String resolvedDeviceId = StringUtils.hasText(deviceId) ? deviceId : fallbackDeviceId;
|
||||||
|
|
@ -115,7 +114,6 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
|
||||||
context.setAuthMode(authMode);
|
context.setAuthMode(authMode);
|
||||||
context.setAnonymous(anonymous);
|
context.setAnonymous(anonymous);
|
||||||
context.setDeviceId(resolvedDeviceId.trim());
|
context.setDeviceId(resolvedDeviceId.trim());
|
||||||
context.setTenantCode(StringUtils.hasText(tenantCode) ? tenantCode.trim() : null);
|
|
||||||
context.setAppId(StringUtils.hasText(appId) ? appId.trim() : null);
|
context.setAppId(StringUtils.hasText(appId) ? appId.trim() : null);
|
||||||
context.setAppVersion(StringUtils.hasText(appVersion) ? appVersion.trim() : null);
|
context.setAppVersion(StringUtils.hasText(appVersion) ? appVersion.trim() : null);
|
||||||
context.setPlatform(StringUtils.hasText(platform) ? platform.trim() : "android");
|
context.setPlatform(StringUtils.hasText(platform) ? platform.trim() : "android");
|
||||||
|
|
@ -164,10 +162,35 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
|
||||||
|
|
||||||
private String resolveHttpToken(HttpServletRequest request) {
|
private String resolveHttpToken(HttpServletRequest request) {
|
||||||
String authorization = request.getHeader(HEADER_AUTHORIZATION);
|
String authorization = request.getHeader(HEADER_AUTHORIZATION);
|
||||||
if (StringUtils.hasText(authorization) && authorization.startsWith(BEARER_PREFIX)) {
|
if (!StringUtils.hasText(authorization)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!authorization.startsWith(BEARER_PREFIX)) {
|
||||||
|
throw new RuntimeException("Android HTTP access token is invalid");
|
||||||
|
}
|
||||||
return authorization.substring(BEARER_PREFIX.length()).trim();
|
return authorization.substring(BEARER_PREFIX.length()).trim();
|
||||||
}
|
}
|
||||||
return normalizeToken(request.getHeader(HEADER_ACCESS_TOKEN));
|
|
||||||
|
private String firstHeader(HttpServletRequest request, String... names) {
|
||||||
|
for (String name : names) {
|
||||||
|
String value = request.getHeader(name);
|
||||||
|
if (StringUtils.hasText(value)) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireAndroidHttpHeaders(String deviceId, String appVersion, String platform) {
|
||||||
|
if (!StringUtils.hasText(deviceId)) {
|
||||||
|
throw new RuntimeException("Android device_id is required");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(appVersion)) {
|
||||||
|
throw new RuntimeException("Android-App-Version is required");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(platform)) {
|
||||||
|
throw new RuntimeException("X-Android-Platform is required");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeToken(String token) {
|
private String normalizeToken(String token) {
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,5 @@ import com.imeeting.dto.android.legacy.LegacyScreenSaverItemResponse;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface LegacyScreenSaverAdapterService {
|
public interface LegacyScreenSaverAdapterService {
|
||||||
List<LegacyScreenSaverItemResponse> listActiveScreenSavers();
|
List<LegacyScreenSaverItemResponse> listActiveScreenSavers(Long userId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ public class LegacyScreenSaverAdapterServiceImpl implements LegacyScreenSaverAda
|
||||||
private final ScreenSaverService screenSaverService;
|
private final ScreenSaverService screenSaverService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<LegacyScreenSaverItemResponse> listActiveScreenSavers() {
|
public List<LegacyScreenSaverItemResponse> listActiveScreenSavers(Long userId) {
|
||||||
ScreenSaverSelectionResult selection = screenSaverService.getActiveSelection(null);
|
ScreenSaverSelectionResult selection = screenSaverService.getActiveSelection(userId);
|
||||||
if (selection == null || selection.getItems() == null || selection.getItems().isEmpty()) {
|
if (selection == null || selection.getItems() == null || selection.getItems().isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ public interface ScreenSaverService extends IService<ScreenSaver> {
|
||||||
|
|
||||||
ScreenSaver update(Long id, ScreenSaverDTO dto, LoginUser loginUser);
|
ScreenSaver update(Long id, ScreenSaverDTO dto, LoginUser loginUser);
|
||||||
|
|
||||||
|
boolean updateStatus(Long id, Integer status, LoginUser loginUser);
|
||||||
|
|
||||||
void removeScreenSaver(Long id, LoginUser loginUser);
|
void removeScreenSaver(Long id, LoginUser loginUser);
|
||||||
|
|
||||||
ScreenSaverImageUploadVO uploadImage(MultipartFile file) throws IOException;
|
ScreenSaverImageUploadVO uploadImage(MultipartFile file) throws IOException;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ import com.imeeting.dto.biz.ScreenSaverDTO;
|
||||||
import com.imeeting.dto.biz.ScreenSaverImageUploadVO;
|
import com.imeeting.dto.biz.ScreenSaverImageUploadVO;
|
||||||
import com.imeeting.dto.biz.ScreenSaverSelectionResult;
|
import com.imeeting.dto.biz.ScreenSaverSelectionResult;
|
||||||
import com.imeeting.entity.biz.ScreenSaver;
|
import com.imeeting.entity.biz.ScreenSaver;
|
||||||
|
import com.imeeting.entity.biz.ScreenSaverUserConfig;
|
||||||
import com.imeeting.mapper.biz.ScreenSaverMapper;
|
import com.imeeting.mapper.biz.ScreenSaverMapper;
|
||||||
|
import com.imeeting.mapper.biz.ScreenSaverUserConfigMapper;
|
||||||
import com.imeeting.service.biz.ScreenSaverService;
|
import com.imeeting.service.biz.ScreenSaverService;
|
||||||
import com.unisbase.entity.SysUser;
|
import com.unisbase.entity.SysUser;
|
||||||
import com.unisbase.mapper.SysUserMapper;
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
|
|
@ -27,6 +29,8 @@ import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -39,13 +43,17 @@ import java.util.stream.Collectors;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, ScreenSaver> implements ScreenSaverService {
|
public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, ScreenSaver> implements ScreenSaverService {
|
||||||
|
|
||||||
private static final long GLOBAL_TENANT_ID = 0L;
|
|
||||||
private static final String SCOPE_PLATFORM = "PLATFORM";
|
private static final String SCOPE_PLATFORM = "PLATFORM";
|
||||||
private static final String SCOPE_USER = "USER";
|
private static final String SCOPE_USER = "USER";
|
||||||
|
private static final String SCOPE_MIXED = "MIXED";
|
||||||
private static final int REQUIRED_WIDTH = 1280;
|
private static final int REQUIRED_WIDTH = 1280;
|
||||||
private static final int REQUIRED_HEIGHT = 800;
|
private static final int REQUIRED_HEIGHT = 800;
|
||||||
private static final Set<String> ALLOWED_FORMATS = Set.of("jpg", "jpeg", "png");
|
private static final Set<String> ALLOWED_FORMATS = Set.of("jpg", "jpeg", "png");
|
||||||
|
private static final Comparator<ScreenSaver> SCREEN_SAVER_ORDER = Comparator
|
||||||
|
.comparing((ScreenSaver item) -> item.getSortOrder() == null ? 0 : item.getSortOrder())
|
||||||
|
.thenComparing(ScreenSaver::getId, Comparator.nullsLast(Comparator.reverseOrder()));
|
||||||
|
|
||||||
|
private final ScreenSaverUserConfigMapper userConfigMapper;
|
||||||
private final SysUserMapper sysUserMapper;
|
private final SysUserMapper sysUserMapper;
|
||||||
|
|
||||||
@Value("${unisbase.app.upload-path}")
|
@Value("${unisbase.app.upload-path}")
|
||||||
|
|
@ -56,7 +64,7 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ScreenSaverAdminVO> listForAdmin(LoginUser loginUser, String keyword, Integer status, String scopeType, Long ownerUserId) {
|
public List<ScreenSaverAdminVO> listForAdmin(LoginUser loginUser, String keyword, Integer status, String scopeType, Long ownerUserId) {
|
||||||
LambdaQueryWrapper<ScreenSaver> wrapper = new LambdaQueryWrapper<ScreenSaver>()
|
LambdaQueryWrapper<ScreenSaver> wrapper = buildVisibilityWrapper(loginUser)
|
||||||
.orderByAsc(ScreenSaver::getSortOrder)
|
.orderByAsc(ScreenSaver::getSortOrder)
|
||||||
.orderByDesc(ScreenSaver::getId);
|
.orderByDesc(ScreenSaver::getId);
|
||||||
if (StringUtils.hasText(keyword)) {
|
if (StringUtils.hasText(keyword)) {
|
||||||
|
|
@ -65,25 +73,27 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
|
||||||
.or()
|
.or()
|
||||||
.like(ScreenSaver::getDescription, trimmed));
|
.like(ScreenSaver::getDescription, trimmed));
|
||||||
}
|
}
|
||||||
if (status != null) {
|
|
||||||
wrapper.eq(ScreenSaver::getStatus, status);
|
|
||||||
}
|
|
||||||
if (StringUtils.hasText(scopeType)) {
|
if (StringUtils.hasText(scopeType)) {
|
||||||
wrapper.eq(ScreenSaver::getScopeType, normalizeScopeType(scopeType));
|
wrapper.eq(ScreenSaver::getScopeType, normalizeScopeType(scopeType));
|
||||||
}
|
}
|
||||||
if (ownerUserId != null) {
|
if (ownerUserId != null) {
|
||||||
wrapper.eq(ScreenSaver::getOwnerUserId, ownerUserId);
|
wrapper.eq(ScreenSaver::getOwnerUserId, ownerUserId);
|
||||||
}
|
}
|
||||||
return toAdminVOs(this.list(wrapper));
|
List<ScreenSaver> records = this.list(wrapper);
|
||||||
|
Map<Long, Integer> userStatusMap = queryUserStatusMap(loginUser == null ? null : loginUser.getUserId(), extractPlatformIds(records));
|
||||||
|
return toAdminVOs(records, userStatusMap).stream()
|
||||||
|
.filter(item -> status == null || Objects.equals(item.getStatus(), status))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public ScreenSaver create(ScreenSaverDTO dto, LoginUser loginUser) {
|
public ScreenSaver create(ScreenSaverDTO dto, LoginUser loginUser) {
|
||||||
validate(dto, false, null);
|
ScreenSaverDTO normalizedDto = normalizeCreateDto(dto, loginUser);
|
||||||
|
validate(normalizedDto, false, null);
|
||||||
ScreenSaver entity = new ScreenSaver();
|
ScreenSaver entity = new ScreenSaver();
|
||||||
applyDto(entity, dto, false);
|
applyDto(entity, normalizedDto, false);
|
||||||
entity.setTenantId(GLOBAL_TENANT_ID);
|
entity.setTenantId(loginUser.getTenantId());
|
||||||
entity.setCreatedBy(loginUser.getUserId());
|
entity.setCreatedBy(loginUser.getUserId());
|
||||||
if (entity.getStatus() == null) {
|
if (entity.getStatus() == null) {
|
||||||
entity.setStatus(1);
|
entity.setStatus(1);
|
||||||
|
|
@ -96,10 +106,14 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public ScreenSaver update(Long id, ScreenSaverDTO dto, LoginUser loginUser) {
|
public ScreenSaver update(Long id, ScreenSaverDTO dto, LoginUser loginUser) {
|
||||||
ScreenSaver entity = requireExisting(id);
|
ScreenSaver entity = requireExisting(id);
|
||||||
|
assertCanManageEntity(entity, loginUser);
|
||||||
|
dto = normalizeUpdateDto(dto, entity, loginUser);
|
||||||
|
assertNonAdminCannotTransferOwnership(entity, dto, loginUser);
|
||||||
|
|
||||||
String previousImageUrl = entity.getImageUrl();
|
String previousImageUrl = entity.getImageUrl();
|
||||||
validate(dto, true, entity);
|
validate(dto, true, entity);
|
||||||
applyDto(entity, dto, true);
|
applyDto(entity, dto, true);
|
||||||
entity.setTenantId(GLOBAL_TENANT_ID);
|
entity.setTenantId(loginUser.getTenantId());
|
||||||
this.updateById(entity);
|
this.updateById(entity);
|
||||||
if (dto.getImageUrl() != null && !Objects.equals(previousImageUrl, entity.getImageUrl())) {
|
if (dto.getImageUrl() != null && !Objects.equals(previousImageUrl, entity.getImageUrl())) {
|
||||||
deleteManagedFileIfUnused(previousImageUrl, entity.getId());
|
deleteManagedFileIfUnused(previousImageUrl, entity.getId());
|
||||||
|
|
@ -107,10 +121,30 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public boolean updateStatus(Long id, Integer status, LoginUser loginUser) {
|
||||||
|
validateStatus(status);
|
||||||
|
ScreenSaver entity = requireVisibleEntity(id, loginUser);
|
||||||
|
if (entity == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlatformScope(entity)) {
|
||||||
|
return upsertUserStatusConfig(id, status, loginUser);
|
||||||
|
}
|
||||||
|
if (!canManageEntity(entity, loginUser)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entity.setStatus(status);
|
||||||
|
return this.updateById(entity);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void removeScreenSaver(Long id, LoginUser loginUser) {
|
public void removeScreenSaver(Long id, LoginUser loginUser) {
|
||||||
ScreenSaver entity = requireExisting(id);
|
ScreenSaver entity = requireExisting(id);
|
||||||
|
assertCanManageEntity(entity, loginUser);
|
||||||
String imageUrl = entity.getImageUrl();
|
String imageUrl = entity.getImageUrl();
|
||||||
this.removeById(entity.getId());
|
this.removeById(entity.getId());
|
||||||
deleteManagedFileIfUnused(imageUrl, entity.getId());
|
deleteManagedFileIfUnused(imageUrl, entity.getId());
|
||||||
|
|
@ -124,9 +158,9 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
|
||||||
String originalName = sanitizeFileName(file.getOriginalFilename());
|
String originalName = sanitizeFileName(file.getOriginalFilename());
|
||||||
String format = resolveAndValidateFormat(originalName, file.getContentType());
|
String format = resolveAndValidateFormat(originalName, file.getContentType());
|
||||||
ImageMetadata metadata = readImageMetadata(file);
|
ImageMetadata metadata = readImageMetadata(file);
|
||||||
if (metadata.width() != REQUIRED_WIDTH || metadata.height() != REQUIRED_HEIGHT) {
|
// if (metadata.width() != REQUIRED_WIDTH || metadata.height() != REQUIRED_HEIGHT) {
|
||||||
throw new RuntimeException("image must be 1280x800");
|
// throw new RuntimeException("image must be 1280x800");
|
||||||
}
|
// }
|
||||||
|
|
||||||
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
|
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
|
||||||
Path targetDir = Paths.get(basePath, "screen-savers", "images");
|
Path targetDir = Paths.get(basePath, "screen-savers", "images");
|
||||||
|
|
@ -146,19 +180,108 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ScreenSaverSelectionResult getActiveSelection(Long userId) {
|
public ScreenSaverSelectionResult getActiveSelection(Long userId) {
|
||||||
List<ScreenSaver> selected = selectActiveEntities(userId);
|
List<ScreenSaver> platformItems = listActiveByScope(SCOPE_PLATFORM, null);
|
||||||
String sourceScope = selected.isEmpty() ? SCOPE_PLATFORM : normalizeScopeType(selected.get(0).getScopeType());
|
if (userId == null) {
|
||||||
return new ScreenSaverSelectionResult(sourceScope, toAdminVOs(selected));
|
return new ScreenSaverSelectionResult(SCOPE_PLATFORM, toAdminVOs(platformItems));
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ScreenSaver> selectActiveEntities(Long userId) {
|
Map<Long, Integer> userStatusMap = queryUserStatusMap(userId, extractPlatformIds(platformItems));
|
||||||
if (userId != null) {
|
List<ScreenSaver> effectivePlatformItems = platformItems.stream()
|
||||||
List<ScreenSaver> userScoped = listActiveByScope(SCOPE_USER, userId);
|
.filter(item -> effectiveStatus(item, userStatusMap.get(item.getId())) == 1)
|
||||||
if (!userScoped.isEmpty()) {
|
.toList();
|
||||||
return userScoped;
|
List<ScreenSaver> userItems = listActiveByScope(SCOPE_USER, userId);
|
||||||
|
|
||||||
|
List<ScreenSaver> selected = new ArrayList<>(effectivePlatformItems.size() + userItems.size());
|
||||||
|
selected.addAll(effectivePlatformItems);
|
||||||
|
selected.addAll(userItems);
|
||||||
|
selected.sort(SCREEN_SAVER_ORDER);
|
||||||
|
|
||||||
|
return new ScreenSaverSelectionResult(resolveSourceScope(effectivePlatformItems, userItems), toAdminVOs(selected, userStatusMap));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ScreenSaverDTO normalizeCreateDto(ScreenSaverDTO dto, LoginUser loginUser) {
|
||||||
|
if (dto == null) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return listActiveByScope(SCOPE_PLATFORM, null);
|
String scopeType = normalizeScopeType(dto.getScopeType());
|
||||||
|
if (!SCOPE_PLATFORM.equals(scopeType) && !SCOPE_USER.equals(scopeType)) {
|
||||||
|
throw new RuntimeException("scopeType only supports PLATFORM or USER");
|
||||||
|
}
|
||||||
|
if (SCOPE_USER.equals(scopeType)) {
|
||||||
|
dto.setOwnerUserId(loginUser.getUserId());
|
||||||
|
} else if (!isAdmin(loginUser)) {
|
||||||
|
throw new RuntimeException("no permission to create platform screen saver");
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ScreenSaverDTO normalizeUpdateDto(ScreenSaverDTO dto, ScreenSaver existing, LoginUser loginUser) {
|
||||||
|
if (dto == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String scopeType = resolveScopeTypeForValidation(dto, existing);
|
||||||
|
if (SCOPE_USER.equals(scopeType)) {
|
||||||
|
dto.setOwnerUserId(loginUser.getUserId());
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LambdaQueryWrapper<ScreenSaver> buildVisibilityWrapper(LoginUser loginUser) {
|
||||||
|
LambdaQueryWrapper<ScreenSaver> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (loginUser == null || loginUser.getUserId() == null) {
|
||||||
|
return wrapper.eq(ScreenSaver::getScopeType, SCOPE_PLATFORM);
|
||||||
|
}
|
||||||
|
if (isAdmin(loginUser)) {
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
return wrapper.and(w -> w.eq(ScreenSaver::getScopeType, SCOPE_PLATFORM)
|
||||||
|
.or(sw -> sw.eq(ScreenSaver::getScopeType, SCOPE_USER)
|
||||||
|
.eq(ScreenSaver::getOwnerUserId, loginUser.getUserId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean upsertUserStatusConfig(Long screenSaverId, Integer status, LoginUser loginUser) {
|
||||||
|
ScreenSaverUserConfig existing = userConfigMapper.selectOne(new LambdaQueryWrapper<ScreenSaverUserConfig>()
|
||||||
|
.eq(ScreenSaverUserConfig::getTenantId, loginUser.getTenantId())
|
||||||
|
.eq(ScreenSaverUserConfig::getUserId, loginUser.getUserId())
|
||||||
|
.eq(ScreenSaverUserConfig::getScreenSaverId, screenSaverId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (existing != null) {
|
||||||
|
existing.setStatus(status);
|
||||||
|
return userConfigMapper.updateById(existing) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreenSaverUserConfig entity = new ScreenSaverUserConfig();
|
||||||
|
entity.setTenantId(loginUser.getTenantId());
|
||||||
|
entity.setUserId(loginUser.getUserId());
|
||||||
|
entity.setScreenSaverId(screenSaverId);
|
||||||
|
entity.setStatus(status);
|
||||||
|
return userConfigMapper.insert(entity) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, Integer> queryUserStatusMap(Long userId, List<Long> screenSaverIds) {
|
||||||
|
if (userId == null || screenSaverIds == null || screenSaverIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
List<ScreenSaverUserConfig> configs = userConfigMapper.selectList(new LambdaQueryWrapper<ScreenSaverUserConfig>()
|
||||||
|
.eq(ScreenSaverUserConfig::getUserId, userId)
|
||||||
|
.in(ScreenSaverUserConfig::getScreenSaverId, screenSaverIds));
|
||||||
|
|
||||||
|
Map<Long, Integer> statusMap = new HashMap<>();
|
||||||
|
for (ScreenSaverUserConfig config : configs) {
|
||||||
|
statusMap.put(config.getScreenSaverId(), config.getStatus());
|
||||||
|
}
|
||||||
|
return statusMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> extractPlatformIds(List<ScreenSaver> entities) {
|
||||||
|
if (entities == null || entities.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return entities.stream()
|
||||||
|
.filter(this::isPlatformScope)
|
||||||
|
.map(ScreenSaver::getId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ScreenSaver> listActiveByScope(String scopeType, Long ownerUserId) {
|
private List<ScreenSaver> listActiveByScope(String scopeType, Long ownerUserId) {
|
||||||
|
|
@ -176,12 +299,21 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ScreenSaverAdminVO> toAdminVOs(List<ScreenSaver> entities) {
|
private List<ScreenSaverAdminVO> toAdminVOs(List<ScreenSaver> entities) {
|
||||||
|
return toAdminVOs(entities, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ScreenSaverAdminVO> toAdminVOs(List<ScreenSaver> entities, Map<Long, Integer> userStatusMap) {
|
||||||
if (entities == null || entities.isEmpty()) {
|
if (entities == null || entities.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
Map<Long, String> creatorNames = resolveCreatorNames(entities);
|
Map<Long, String> creatorNames = resolveCreatorNames(entities);
|
||||||
return entities.stream()
|
return entities.stream()
|
||||||
.map(item -> ScreenSaverAdminVO.from(item, creatorNames.get(item.getCreatedBy())))
|
.map(item -> {
|
||||||
|
String creatorName = item.getCreatedBy() == null ? null : creatorNames.get(item.getCreatedBy());
|
||||||
|
ScreenSaverAdminVO vo = ScreenSaverAdminVO.from(item, creatorName);
|
||||||
|
vo.setStatus(effectiveStatus(item, userStatusMap.get(item.getId())));
|
||||||
|
return vo;
|
||||||
|
})
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,6 +335,25 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int effectiveStatus(ScreenSaver entity, Integer userStatus) {
|
||||||
|
if (isPlatformScope(entity) && userStatus != null) {
|
||||||
|
return userStatus;
|
||||||
|
}
|
||||||
|
return entity.getStatus() == null ? 1 : entity.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveSourceScope(List<ScreenSaver> platformItems, List<ScreenSaver> userItems) {
|
||||||
|
boolean hasPlatform = platformItems != null && !platformItems.isEmpty();
|
||||||
|
boolean hasUser = userItems != null && !userItems.isEmpty();
|
||||||
|
if (hasPlatform && hasUser) {
|
||||||
|
return SCOPE_MIXED;
|
||||||
|
}
|
||||||
|
if (hasUser) {
|
||||||
|
return SCOPE_USER;
|
||||||
|
}
|
||||||
|
return SCOPE_PLATFORM;
|
||||||
|
}
|
||||||
|
|
||||||
private void validate(ScreenSaverDTO dto, boolean partial, ScreenSaver existing) {
|
private void validate(ScreenSaverDTO dto, boolean partial, ScreenSaver existing) {
|
||||||
if (dto == null) {
|
if (dto == null) {
|
||||||
throw new RuntimeException("payload is required");
|
throw new RuntimeException("payload is required");
|
||||||
|
|
@ -240,9 +391,9 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
|
||||||
if (dto.getImageWidth() == null || dto.getImageHeight() == null || !StringUtils.hasText(dto.getImageFormat())) {
|
if (dto.getImageWidth() == null || dto.getImageHeight() == null || !StringUtils.hasText(dto.getImageFormat())) {
|
||||||
throw new RuntimeException("image metadata is required");
|
throw new RuntimeException("image metadata is required");
|
||||||
}
|
}
|
||||||
if (dto.getImageWidth() != REQUIRED_WIDTH || dto.getImageHeight() != REQUIRED_HEIGHT) {
|
// if (dto.getImageWidth() != REQUIRED_WIDTH || dto.getImageHeight() != REQUIRED_HEIGHT) {
|
||||||
throw new RuntimeException("image must be 1280x800");
|
// throw new RuntimeException("image must be 1280x800");
|
||||||
}
|
// }
|
||||||
if (!ALLOWED_FORMATS.contains(dto.getImageFormat().trim().toLowerCase())) {
|
if (!ALLOWED_FORMATS.contains(dto.getImageFormat().trim().toLowerCase())) {
|
||||||
throw new RuntimeException("imageFormat only supports jpg/jpeg/png");
|
throw new RuntimeException("imageFormat only supports jpg/jpeg/png");
|
||||||
}
|
}
|
||||||
|
|
@ -307,6 +458,58 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ScreenSaver requireVisibleEntity(Long id, LoginUser loginUser) {
|
||||||
|
return this.getOne(buildVisibilityWrapper(loginUser)
|
||||||
|
.eq(ScreenSaver::getId, id)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertCanManageEntity(ScreenSaver entity, LoginUser loginUser) {
|
||||||
|
if (!canManageEntity(entity, loginUser)) {
|
||||||
|
throw new RuntimeException("no permission to modify this screen saver");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean canManageEntity(ScreenSaver entity, LoginUser loginUser) {
|
||||||
|
if (entity == null || loginUser == null || loginUser.getUserId() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isAdmin(loginUser)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isUserScope(entity) && Objects.equals(entity.getOwnerUserId(), loginUser.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertNonAdminCannotTransferOwnership(ScreenSaver entity, ScreenSaverDTO dto, LoginUser loginUser) {
|
||||||
|
if (dto == null || isAdmin(loginUser) || entity == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dto.getScopeType() != null && !Objects.equals(normalizeScopeType(dto.getScopeType()), entity.getScopeType())) {
|
||||||
|
throw new RuntimeException("no permission to change scopeType");
|
||||||
|
}
|
||||||
|
if (dto.getOwnerUserId() != null && !Objects.equals(dto.getOwnerUserId(), entity.getOwnerUserId())) {
|
||||||
|
throw new RuntimeException("no permission to change ownerUserId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAdmin(LoginUser loginUser) {
|
||||||
|
return loginUser != null && (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPlatformScope(ScreenSaver entity) {
|
||||||
|
return entity != null && SCOPE_PLATFORM.equals(normalizeScopeType(entity.getScopeType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isUserScope(ScreenSaver entity) {
|
||||||
|
return entity != null && SCOPE_USER.equals(normalizeScopeType(entity.getScopeType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateStatus(Integer status) {
|
||||||
|
if (status == null || (status != 0 && status != 1)) {
|
||||||
|
throw new RuntimeException("status only supports 0 or 1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String resolveAndValidateFormat(String fileName, String contentType) {
|
private String resolveAndValidateFormat(String fileName, String contentType) {
|
||||||
String extension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
|
String extension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
|
||||||
if (!ALLOWED_FORMATS.contains(extension)) {
|
if (!ALLOWED_FORMATS.contains(extension)) {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ unisbase:
|
||||||
- /api/auth/**
|
- /api/auth/**
|
||||||
- /api/static/**
|
- /api/static/**
|
||||||
- /api/public/meetings/**
|
- /api/public/meetings/**
|
||||||
|
- /api/android/screensavers/active
|
||||||
|
- /api/screensavers/active
|
||||||
- /v3/api-docs/**
|
- /v3/api-docs/**
|
||||||
- /swagger-ui.html
|
- /swagger-ui.html
|
||||||
- /swagger-ui/**
|
- /swagger-ui/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.imeeting.controller.android;
|
||||||
|
|
||||||
|
import com.imeeting.dto.android.AndroidAuthContext;
|
||||||
|
import com.imeeting.dto.android.AndroidScreenSaverCatalogVO;
|
||||||
|
import com.imeeting.dto.biz.ScreenSaverSelectionResult;
|
||||||
|
import com.imeeting.service.android.AndroidAuthService;
|
||||||
|
import com.imeeting.service.biz.ScreenSaverService;
|
||||||
|
import com.imeeting.support.TaskSecurityContextRunner;
|
||||||
|
import com.unisbase.common.ApiResponse;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class AndroidScreenSaverControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeShouldRunSelectionInsideTenantSecurityContextWhenLoggedIn() {
|
||||||
|
AndroidAuthService androidAuthService = mock(AndroidAuthService.class);
|
||||||
|
ScreenSaverService screenSaverService = mock(ScreenSaverService.class);
|
||||||
|
TaskSecurityContextRunner taskSecurityContextRunner = mock(TaskSecurityContextRunner.class);
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
|
||||||
|
AndroidAuthContext authContext = new AndroidAuthContext();
|
||||||
|
authContext.setAnonymous(false);
|
||||||
|
authContext.setTenantId(9L);
|
||||||
|
authContext.setUserId(88L);
|
||||||
|
|
||||||
|
when(androidAuthService.authenticateHttp(request)).thenReturn(authContext);
|
||||||
|
when(taskSecurityContextRunner.callAsTenantUser(eq(9L), eq(88L), any()))
|
||||||
|
.thenAnswer(invocation -> ((Supplier<?>) invocation.getArgument(2)).get());
|
||||||
|
when(screenSaverService.getActiveSelection(88L))
|
||||||
|
.thenReturn(new ScreenSaverSelectionResult("USER", List.of()));
|
||||||
|
|
||||||
|
AndroidScreenSaverController controller =
|
||||||
|
new AndroidScreenSaverController(androidAuthService, screenSaverService, taskSecurityContextRunner);
|
||||||
|
|
||||||
|
ApiResponse<AndroidScreenSaverCatalogVO> response = controller.active(request);
|
||||||
|
|
||||||
|
verify(taskSecurityContextRunner).callAsTenantUser(eq(9L), eq(88L), any());
|
||||||
|
verify(screenSaverService).getActiveSelection(88L);
|
||||||
|
assertEquals("USER", response.getData().getSourceScope());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeShouldSkipSecurityContextRunnerWhenAnonymous() {
|
||||||
|
AndroidAuthService androidAuthService = mock(AndroidAuthService.class);
|
||||||
|
ScreenSaverService screenSaverService = mock(ScreenSaverService.class);
|
||||||
|
TaskSecurityContextRunner taskSecurityContextRunner = mock(TaskSecurityContextRunner.class);
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
|
||||||
|
AndroidAuthContext authContext = new AndroidAuthContext();
|
||||||
|
authContext.setAnonymous(true);
|
||||||
|
|
||||||
|
when(androidAuthService.authenticateHttp(request)).thenReturn(authContext);
|
||||||
|
when(screenSaverService.getActiveSelection(null))
|
||||||
|
.thenReturn(new ScreenSaverSelectionResult("PLATFORM", List.of()));
|
||||||
|
|
||||||
|
AndroidScreenSaverController controller =
|
||||||
|
new AndroidScreenSaverController(androidAuthService, screenSaverService, taskSecurityContextRunner);
|
||||||
|
|
||||||
|
ApiResponse<AndroidScreenSaverCatalogVO> response = controller.active(request);
|
||||||
|
|
||||||
|
verify(taskSecurityContextRunner, never()).callAsTenantUser(any(), any(), any());
|
||||||
|
verify(screenSaverService).getActiveSelection(null);
|
||||||
|
assertEquals("PLATFORM", response.getData().getSourceScope());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
package com.imeeting.controller.android.legacy;
|
||||||
|
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyApiResponse;
|
||||||
|
import com.imeeting.dto.android.legacy.LegacyScreenSaverItemResponse;
|
||||||
|
import com.imeeting.service.android.legacy.LegacyScreenSaverAdapterService;
|
||||||
|
import com.imeeting.support.TaskSecurityContextRunner;
|
||||||
|
import com.unisbase.dto.InternalAuthCheckResponse;
|
||||||
|
import com.unisbase.service.TokenValidationService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class LegacyScreenSaverControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeShouldRunAdapterInsideTenantSecurityContextWhenTokenExists() {
|
||||||
|
LegacyScreenSaverAdapterService adapterService = mock(LegacyScreenSaverAdapterService.class);
|
||||||
|
TokenValidationService tokenValidationService = mock(TokenValidationService.class);
|
||||||
|
TaskSecurityContextRunner taskSecurityContextRunner = mock(TaskSecurityContextRunner.class);
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
|
||||||
|
when(request.getHeader("Authorization")).thenReturn("Bearer access-token");
|
||||||
|
InternalAuthCheckResponse authResult = new InternalAuthCheckResponse();
|
||||||
|
authResult.setValid(true);
|
||||||
|
authResult.setUserId(55L);
|
||||||
|
authResult.setTenantId(7L);
|
||||||
|
authResult.setUsername("alice");
|
||||||
|
when(tokenValidationService.validateAccessToken("access-token")).thenReturn(authResult);
|
||||||
|
|
||||||
|
when(taskSecurityContextRunner.callAsTenantUser(eq(7L), eq(55L), any()))
|
||||||
|
.thenAnswer(invocation -> ((Supplier<?>) invocation.getArgument(2)).get());
|
||||||
|
|
||||||
|
LegacyScreenSaverItemResponse item = new LegacyScreenSaverItemResponse();
|
||||||
|
item.setId(1L);
|
||||||
|
when(adapterService.listActiveScreenSavers(55L)).thenReturn(List.of(item));
|
||||||
|
|
||||||
|
LegacyScreenSaverController controller = new LegacyScreenSaverController(
|
||||||
|
adapterService,
|
||||||
|
tokenValidationService,
|
||||||
|
taskSecurityContextRunner
|
||||||
|
);
|
||||||
|
|
||||||
|
LegacyApiResponse<List<LegacyScreenSaverItemResponse>> response = controller.active(request);
|
||||||
|
|
||||||
|
verify(taskSecurityContextRunner).callAsTenantUser(eq(7L), eq(55L), any());
|
||||||
|
verify(adapterService).listActiveScreenSavers(55L);
|
||||||
|
assertEquals(1, response.getData().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeShouldReturnAnonymousSelectionWhenTokenMissing() {
|
||||||
|
LegacyScreenSaverAdapterService adapterService = mock(LegacyScreenSaverAdapterService.class);
|
||||||
|
TokenValidationService tokenValidationService = mock(TokenValidationService.class);
|
||||||
|
TaskSecurityContextRunner taskSecurityContextRunner = mock(TaskSecurityContextRunner.class);
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
|
||||||
|
when(request.getHeader("Authorization")).thenReturn(null);
|
||||||
|
LegacyScreenSaverItemResponse item = new LegacyScreenSaverItemResponse();
|
||||||
|
item.setId(2L);
|
||||||
|
when(adapterService.listActiveScreenSavers(null)).thenReturn(List.of(item));
|
||||||
|
|
||||||
|
LegacyScreenSaverController controller = new LegacyScreenSaverController(
|
||||||
|
adapterService,
|
||||||
|
tokenValidationService,
|
||||||
|
taskSecurityContextRunner
|
||||||
|
);
|
||||||
|
|
||||||
|
LegacyApiResponse<List<LegacyScreenSaverItemResponse>> response = controller.active(request);
|
||||||
|
|
||||||
|
verify(taskSecurityContextRunner, never()).callAsTenantUser(any(), any(), any());
|
||||||
|
verify(adapterService).listActiveScreenSavers(null);
|
||||||
|
assertEquals(1, response.getData().size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import java.util.Set;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
@ -60,4 +61,28 @@ class AndroidAuthServiceImplTest {
|
||||||
assertEquals(Set.of("meeting:create"), context.getPermissions());
|
assertEquals(Set.of("meeting:create"), context.getPermissions());
|
||||||
assertEquals("access-token", context.getAccessToken());
|
assertEquals("access-token", context.getAccessToken());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authenticateHttpShouldAllowAnonymousWhenConfigured() {
|
||||||
|
AndroidGrpcAuthProperties properties = new AndroidGrpcAuthProperties();
|
||||||
|
properties.setAllowAnonymous(true);
|
||||||
|
TokenValidationService tokenValidationService = mock(TokenValidationService.class);
|
||||||
|
AndroidAuthServiceImpl service = new AndroidAuthServiceImpl(properties, tokenValidationService);
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
|
||||||
|
when(request.getHeader("Authorization")).thenReturn(null);
|
||||||
|
when(request.getHeader("X-Android-Device-Id")).thenReturn("device-anon");
|
||||||
|
when(request.getHeader("X-Android-App-Id")).thenReturn("imeeting");
|
||||||
|
when(request.getHeader("X-Android-App-Version")).thenReturn("1.0.0");
|
||||||
|
when(request.getHeader("X-Android-Platform")).thenReturn("android");
|
||||||
|
|
||||||
|
AndroidAuthContext context = service.authenticateHttp(request);
|
||||||
|
|
||||||
|
assertTrue(context.isAnonymous());
|
||||||
|
assertEquals("NONE", context.getAuthMode());
|
||||||
|
assertEquals("device-anon", context.getDeviceId());
|
||||||
|
assertNull(context.getUserId());
|
||||||
|
assertNull(context.getTenantId());
|
||||||
|
assertNull(context.getAccessToken());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,12 @@ class LegacyScreenSaverAdapterServiceImplTest {
|
||||||
item.setCreatedBy(7L);
|
item.setCreatedBy(7L);
|
||||||
item.setCreatorUsername("admin");
|
item.setCreatorUsername("admin");
|
||||||
|
|
||||||
when(screenSaverService.getActiveSelection(null))
|
when(screenSaverService.getActiveSelection(55L))
|
||||||
.thenReturn(new ScreenSaverSelectionResult("PLATFORM", List.of(item)));
|
.thenReturn(new ScreenSaverSelectionResult("PLATFORM", List.of(item)));
|
||||||
|
|
||||||
LegacyScreenSaverAdapterServiceImpl service = new LegacyScreenSaverAdapterServiceImpl(screenSaverService);
|
LegacyScreenSaverAdapterServiceImpl service = new LegacyScreenSaverAdapterServiceImpl(screenSaverService);
|
||||||
|
|
||||||
List<LegacyScreenSaverItemResponse> result = service.listActiveScreenSavers();
|
List<LegacyScreenSaverItemResponse> result = service.listActiveScreenSavers(55L);
|
||||||
|
|
||||||
assertEquals(1, result.size());
|
assertEquals(1, result.size());
|
||||||
assertEquals(9L, result.get(0).getId());
|
assertEquals(9L, result.get(0).getId());
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.imeeting.dto.biz.ScreenSaverAdminVO;
|
||||||
|
import com.imeeting.dto.biz.ScreenSaverSelectionResult;
|
||||||
|
import com.imeeting.entity.biz.ScreenSaver;
|
||||||
|
import com.imeeting.entity.biz.ScreenSaverUserConfig;
|
||||||
|
import com.imeeting.mapper.biz.ScreenSaverUserConfigMapper;
|
||||||
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
|
import com.unisbase.security.LoginUser;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.spy;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class ScreenSaverServiceImplTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getActiveSelectionShouldMergePlatformAndUserItems() {
|
||||||
|
ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class);
|
||||||
|
SysUserMapper sysUserMapper = mock(SysUserMapper.class);
|
||||||
|
when(userConfigMapper.selectList(any())).thenReturn(List.of(userConfig(101L, 77L, 0)));
|
||||||
|
when(sysUserMapper.selectBatchIds(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
ScreenSaverServiceImpl service = spy(new ScreenSaverServiceImpl(userConfigMapper, sysUserMapper));
|
||||||
|
doReturn(List.of(
|
||||||
|
screenSaver(101L, "PLATFORM", null, 1, 2),
|
||||||
|
screenSaver(102L, "PLATFORM", null, 1, 5)
|
||||||
|
)).doReturn(List.of(
|
||||||
|
screenSaver(201L, "USER", 77L, 1, 1)
|
||||||
|
)).when(service).list(any(LambdaQueryWrapper.class));
|
||||||
|
|
||||||
|
ScreenSaverSelectionResult result = service.getActiveSelection(77L);
|
||||||
|
|
||||||
|
assertEquals("MIXED", result.getSourceScope());
|
||||||
|
assertEquals(List.of(201L, 102L), result.getItems().stream().map(ScreenSaverAdminVO::getId).toList());
|
||||||
|
assertEquals(List.of(1, 1), result.getItems().stream().map(ScreenSaverAdminVO::getStatus).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listForAdminShouldApplyCurrentUserStatusFilter() {
|
||||||
|
ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class);
|
||||||
|
SysUserMapper sysUserMapper = mock(SysUserMapper.class);
|
||||||
|
when(userConfigMapper.selectList(any())).thenReturn(List.of(userConfig(101L, 88L, 0)));
|
||||||
|
when(sysUserMapper.selectBatchIds(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
ScreenSaverServiceImpl service = spy(new ScreenSaverServiceImpl(userConfigMapper, sysUserMapper));
|
||||||
|
doReturn(List.of(
|
||||||
|
screenSaver(101L, "PLATFORM", null, 1, 1),
|
||||||
|
screenSaver(201L, "USER", 88L, 1, 2)
|
||||||
|
)).when(service).list(any(LambdaQueryWrapper.class));
|
||||||
|
|
||||||
|
List<ScreenSaverAdminVO> result = service.listForAdmin(loginUser(88L, 9L, false), null, 0, null, null);
|
||||||
|
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
assertEquals(101L, result.get(0).getId());
|
||||||
|
assertEquals(0, result.get(0).getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateStatusShouldStoreUserOverrideForPlatformItem() {
|
||||||
|
ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class);
|
||||||
|
SysUserMapper sysUserMapper = mock(SysUserMapper.class);
|
||||||
|
when(userConfigMapper.selectOne(any())).thenReturn(null);
|
||||||
|
when(userConfigMapper.insert(any(ScreenSaverUserConfig.class))).thenReturn(1);
|
||||||
|
|
||||||
|
ScreenSaverServiceImpl service = spy(new ScreenSaverServiceImpl(userConfigMapper, sysUserMapper));
|
||||||
|
doReturn(screenSaver(101L, "PLATFORM", null, 1, 1)).when(service).getOne(any(LambdaQueryWrapper.class));
|
||||||
|
|
||||||
|
boolean success = service.updateStatus(101L, 0, loginUser(88L, 9L, false));
|
||||||
|
|
||||||
|
assertTrue(success);
|
||||||
|
ArgumentCaptor<ScreenSaverUserConfig> captor = ArgumentCaptor.forClass(ScreenSaverUserConfig.class);
|
||||||
|
verify(userConfigMapper).insert(captor.capture());
|
||||||
|
verify(service, never()).updateById(any(ScreenSaver.class));
|
||||||
|
assertEquals(9L, captor.getValue().getTenantId());
|
||||||
|
assertEquals(88L, captor.getValue().getUserId());
|
||||||
|
assertEquals(101L, captor.getValue().getScreenSaverId());
|
||||||
|
assertEquals(0, captor.getValue().getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ScreenSaver screenSaver(Long id, String scopeType, Long ownerUserId, Integer status, Integer sortOrder) {
|
||||||
|
ScreenSaver entity = new ScreenSaver();
|
||||||
|
entity.setId(id);
|
||||||
|
entity.setScopeType(scopeType);
|
||||||
|
entity.setOwnerUserId(ownerUserId);
|
||||||
|
entity.setName("item-" + id);
|
||||||
|
entity.setImageUrl("/api/static/" + id + ".jpg");
|
||||||
|
entity.setStatus(status);
|
||||||
|
entity.setSortOrder(sortOrder);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ScreenSaverUserConfig userConfig(Long screenSaverId, Long userId, Integer status) {
|
||||||
|
ScreenSaverUserConfig config = new ScreenSaverUserConfig();
|
||||||
|
config.setScreenSaverId(screenSaverId);
|
||||||
|
config.setUserId(userId);
|
||||||
|
config.setStatus(status);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoginUser loginUser(Long userId, Long tenantId, boolean admin) {
|
||||||
|
LoginUser loginUser = new LoginUser();
|
||||||
|
loginUser.setUserId(userId);
|
||||||
|
loginUser.setTenantId(tenantId);
|
||||||
|
loginUser.setIsTenantAdmin(admin);
|
||||||
|
loginUser.setIsPlatformAdmin(false);
|
||||||
|
return loginUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,11 @@ export async function updateScreenSaver(id: number, payload: Partial<ScreenSaver
|
||||||
return resp.data.data as ScreenSaverVO;
|
return resp.data.data as ScreenSaverVO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateScreenSaverStatus(id: number, status: number) {
|
||||||
|
const resp = await http.put(`/api/screen-savers/${id}/status`, null, { params: { status } });
|
||||||
|
return resp.data.data as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteScreenSaver(id: number) {
|
export async function deleteScreenSaver(id: number) {
|
||||||
const resp = await http.delete(`/api/screen-savers/${id}`);
|
const resp = await http.delete(`/api/screen-savers/${id}`);
|
||||||
return resp.data.data as boolean;
|
return resp.data.data as boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import "./ScreenSaverManagement.css";
|
import "./ScreenSaverManagement.css";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
|
|
@ -48,10 +48,11 @@ import {
|
||||||
type ScreenSaverUploadResult,
|
type ScreenSaverUploadResult,
|
||||||
type ScreenSaverVO,
|
type ScreenSaverVO,
|
||||||
updateScreenSaver,
|
updateScreenSaver,
|
||||||
|
updateScreenSaverStatus,
|
||||||
uploadScreenSaverImage,
|
uploadScreenSaverImage,
|
||||||
} from "@/api/business/screenSaver";
|
} from "@/api/business/screenSaver";
|
||||||
import { listUsers } from "@/api";
|
import { listUsers } from "@/api";
|
||||||
import type { SysUser } from "@/types";
|
import type { SysUser, UserProfile } from "@/types";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
@ -87,6 +88,8 @@ type CropModalState = {
|
||||||
src: string;
|
src: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
mimeType: "image/jpeg" | "image/png";
|
mimeType: "image/jpeg" | "image/png";
|
||||||
|
targetWidth: number;
|
||||||
|
targetHeight: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DragState = {
|
type DragState = {
|
||||||
|
|
@ -156,6 +159,8 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps)
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
const dragRef = useRef<DragState | null>(null);
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
|
||||||
|
const { targetWidth, targetHeight } = state;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.open) {
|
if (!state.open) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -229,20 +234,20 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps)
|
||||||
const exportCroppedFile = async () => {
|
const exportCroppedFile = async () => {
|
||||||
const image = await createImage(state.src);
|
const image = await createImage(state.src);
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = CROP_WIDTH;
|
canvas.width = targetWidth;
|
||||||
canvas.height = CROP_HEIGHT;
|
canvas.height = targetHeight;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("浏览器不支持图片裁剪");
|
throw new Error("浏览器不支持图片裁剪");
|
||||||
}
|
}
|
||||||
const previewScale = CROP_WIDTH / VIEWPORT_WIDTH;
|
const previewScale = targetWidth / VIEWPORT_WIDTH;
|
||||||
const exportedWidth = image.width * zoom * previewScale;
|
const exportedWidth = image.width * zoom * previewScale;
|
||||||
const exportedHeight = image.height * zoom * previewScale;
|
const exportedHeight = image.height * zoom * previewScale;
|
||||||
const drawX = CROP_WIDTH / 2 - exportedWidth / 2 + offset.x * previewScale;
|
const drawX = targetWidth / 2 - exportedWidth / 2 + offset.x * previewScale;
|
||||||
const drawY = CROP_HEIGHT / 2 - exportedHeight / 2 + offset.y * previewScale;
|
const drawY = targetHeight / 2 - exportedHeight / 2 + offset.y * previewScale;
|
||||||
context.drawImage(image, drawX, drawY, exportedWidth, exportedHeight);
|
context.drawImage(image, drawX, drawY, exportedWidth, exportedHeight);
|
||||||
const extension = state.mimeType === "image/png" ? "png" : "jpg";
|
const extension = state.mimeType === "image/png" ? "png" : "jpg";
|
||||||
const fileName = state.fileName.replace(/\.[^.]+$/, "") + `_1280x800.${extension}`;
|
const fileName = state.fileName.replace(/\.[^.]+$/, "") + `_${targetWidth}x${targetHeight}.${extension}`;
|
||||||
const blob = await new Promise<Blob | null>((resolve) => {
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
if (state.mimeType === "image/png") {
|
if (state.mimeType === "image/png") {
|
||||||
canvas.toBlob(resolve, "image/png");
|
canvas.toBlob(resolve, "image/png");
|
||||||
|
|
@ -262,7 +267,7 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps)
|
||||||
const file = await exportCroppedFile();
|
const file = await exportCroppedFile();
|
||||||
await onConfirm(file);
|
await onConfirm(file);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : "裁剪上传失败");
|
message.error(error instanceof Error ? error.message : "瑁佸壀涓婁紶澶辫触");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -284,7 +289,7 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps)
|
||||||
<div className="screen-saver-crop-modal__stage">
|
<div className="screen-saver-crop-modal__stage">
|
||||||
<div className="screen-saver-crop-modal__stage-head">
|
<div className="screen-saver-crop-modal__stage-head">
|
||||||
<h3>裁剪成屏保成品图</h3>
|
<h3>裁剪成屏保成品图</h3>
|
||||||
<p>请在固定 8:5 取景框内调整画面位置。导出尺寸固定为 1280 × 800,安卓端将直接使用该成品图展示。</p>
|
<p>请在固定 8:5 取景框内调整画面位置。导出尺寸固定为 {targetWidth} × {targetHeight},安卓端将直接使用该成品图展示。</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`screen-saver-crop-modal__viewport${dragging ? " is-dragging" : ""}`}
|
className={`screen-saver-crop-modal__viewport${dragging ? " is-dragging" : ""}`}
|
||||||
|
|
@ -322,13 +327,13 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps)
|
||||||
</div>
|
</div>
|
||||||
<div className="screen-saver-crop-modal__meta">
|
<div className="screen-saver-crop-modal__meta">
|
||||||
<span>原图:{naturalSize.width || "-"} × {naturalSize.height || "-"}</span>
|
<span>原图:{naturalSize.width || "-"} × {naturalSize.height || "-"}</span>
|
||||||
<span>输出:{CROP_WIDTH} × {CROP_HEIGHT}</span>
|
<span>输出:{targetWidth} × {targetHeight}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="screen-saver-crop-modal__sidebar">
|
<div className="screen-saver-crop-modal__sidebar">
|
||||||
<div className="screen-saver-crop-modal__sidebar-card">
|
<div className="screen-saver-crop-modal__sidebar-card">
|
||||||
<h4>缩放与构图</h4>
|
<h4>缩放与构图</h4>
|
||||||
<p>拖动画面调整主体位置。保留足够安全边距,避免标题、人物或徽标在不同设备上被视觉切边。</p>
|
<p>拖动画面调整主体位置,保留足够安全边距,避免标题、人物或徽标在不同设备上被裁切。</p>
|
||||||
<div style={{ marginTop: 18 }}>
|
<div style={{ marginTop: 18 }}>
|
||||||
<Text type="secondary">缩放</Text>
|
<Text type="secondary">缩放</Text>
|
||||||
<Slider
|
<Slider
|
||||||
|
|
@ -343,7 +348,7 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps)
|
||||||
</div>
|
</div>
|
||||||
<div className="screen-saver-crop-modal__sidebar-card">
|
<div className="screen-saver-crop-modal__sidebar-card">
|
||||||
<h4>交付标准</h4>
|
<h4>交付标准</h4>
|
||||||
<p>仅支持 JPG / JPEG / PNG。导出的屏保图片会以 1280 × 800 固定尺寸上传,后端仅做格式与尺寸校验,不再重新裁剪。</p>
|
<p>仅支持 JPG / JPEG / PNG。导出的屏保图片会以 {targetWidth} × {targetHeight} 固定尺寸上传,后端仅做格式与尺寸校验,不再重新裁剪。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="screen-saver-crop-modal__footer">
|
<div className="screen-saver-crop-modal__footer">
|
||||||
<Button onClick={onCancel} disabled={loading}>取消</Button>
|
<Button onClick={onCancel} disabled={loading}>取消</Button>
|
||||||
|
|
@ -377,7 +382,15 @@ export default function ScreenSaverManagement() {
|
||||||
src: "",
|
src: "",
|
||||||
fileName: "",
|
fileName: "",
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
|
targetWidth: CROP_WIDTH,
|
||||||
|
targetHeight: CROP_HEIGHT,
|
||||||
});
|
});
|
||||||
|
const userProfile = useMemo<UserProfile>(() => {
|
||||||
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
|
return profileStr ? JSON.parse(profileStr) : {};
|
||||||
|
}, []);
|
||||||
|
const currentUserId = Number(userProfile.userId || 0);
|
||||||
|
const isAdmin = userProfile.isAdmin === true || userProfile.isPlatformAdmin === true || userProfile.isTenantAdmin === true;
|
||||||
|
|
||||||
const userMap = useMemo(() => {
|
const userMap = useMemo(() => {
|
||||||
return new Map<number, SysUser>(users.map((user) => [user.userId, user]));
|
return new Map<number, SysUser>(users.map((user) => [user.userId, user]));
|
||||||
|
|
@ -435,7 +448,8 @@ export default function ScreenSaverManagement() {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
scopeType: "PLATFORM",
|
scopeType: "USER",
|
||||||
|
ownerUserId: currentUserId || undefined,
|
||||||
displayDurationSec: 15,
|
displayDurationSec: 15,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
statusEnabled: true,
|
statusEnabled: true,
|
||||||
|
|
@ -447,6 +461,10 @@ export default function ScreenSaverManagement() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (record: ScreenSaverVO) => {
|
const openEdit = (record: ScreenSaverVO) => {
|
||||||
|
if (!isAdmin && (record.scopeType !== "USER" || record.ownerUserId !== currentUserId)) {
|
||||||
|
message.warning("普通用户只能编辑自己的用户级屏保");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
scopeType: record.scopeType,
|
scopeType: record.scopeType,
|
||||||
|
|
@ -473,9 +491,11 @@ export default function ScreenSaverManagement() {
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
const resolvedScopeType = isAdmin ? values.scopeType : "USER";
|
||||||
|
const resolvedOwnerUserId = resolvedScopeType === "USER" ? currentUserId || null : null;
|
||||||
const payload: ScreenSaverDTO = {
|
const payload: ScreenSaverDTO = {
|
||||||
scopeType: values.scopeType,
|
scopeType: resolvedScopeType,
|
||||||
ownerUserId: values.scopeType === "USER" ? values.ownerUserId ?? null : null,
|
ownerUserId: resolvedOwnerUserId,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
imageUrl: values.imageUrl.trim(),
|
imageUrl: values.imageUrl.trim(),
|
||||||
description: values.description?.trim(),
|
description: values.description?.trim(),
|
||||||
|
|
@ -507,11 +527,16 @@ export default function ScreenSaverManagement() {
|
||||||
const openCropper = async (file: File) => {
|
const openCropper = async (file: File) => {
|
||||||
const mimeType = validateImageFile(file);
|
const mimeType = validateImageFile(file);
|
||||||
const src = await readFileAsDataUrl(file);
|
const src = await readFileAsDataUrl(file);
|
||||||
|
const targetWidth = form.getFieldValue("imageWidth") || CROP_WIDTH;
|
||||||
|
const targetHeight = form.getFieldValue("imageHeight") || CROP_HEIGHT;
|
||||||
|
|
||||||
setCropState({
|
setCropState({
|
||||||
open: true,
|
open: true,
|
||||||
src,
|
src,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
mimeType,
|
mimeType,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -525,7 +550,7 @@ export default function ScreenSaverManagement() {
|
||||||
imageHeight: result.imageHeight,
|
imageHeight: result.imageHeight,
|
||||||
imageFormat: result.imageFormat,
|
imageFormat: result.imageFormat,
|
||||||
});
|
});
|
||||||
setCropState({ open: false, src: "", fileName: "", mimeType: "image/jpeg" });
|
setCropState((prev) => ({ ...prev, open: false, src: "" }));
|
||||||
message.success("屏保图片已上传");
|
message.success("屏保图片已上传");
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
|
@ -543,7 +568,7 @@ export default function ScreenSaverManagement() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleStatus = async (record: ScreenSaverVO, checked: boolean) => {
|
const handleToggleStatus = async (record: ScreenSaverVO, checked: boolean) => {
|
||||||
await updateScreenSaver(record.id, { status: checked ? 1 : 0 });
|
await updateScreenSaverStatus(record.id, checked ? 1 : 0);
|
||||||
message.success(checked ? "屏保已启用" : "屏保已停用");
|
message.success(checked ? "屏保已启用" : "屏保已停用");
|
||||||
await loadData();
|
await loadData();
|
||||||
};
|
};
|
||||||
|
|
@ -621,20 +646,27 @@ export default function ScreenSaverManagement() {
|
||||||
key: "action",
|
key: "action",
|
||||||
width: 140,
|
width: 140,
|
||||||
fixed: "right",
|
fixed: "right",
|
||||||
render: (_, record) => (
|
render: (_, record) => {
|
||||||
|
const canManageRecord = isAdmin || (record.scopeType === "USER" && record.ownerUserId === currentUserId);
|
||||||
|
if (!canManageRecord) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||||
<Popconfirm title="确认删除该屏保吗?" onConfirm={() => void handleDelete(record)}>
|
<Popconfirm title="确认删除该屏保吗?" onConfirm={() => void handleDelete(record)}>
|
||||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const currentImageUrl = Form.useWatch("imageUrl", form);
|
const currentImageUrl = Form.useWatch("imageUrl", form);
|
||||||
const currentScopeType = Form.useWatch("scopeType", form) || "PLATFORM";
|
const currentScopeType = Form.useWatch("scopeType", form) || "PLATFORM";
|
||||||
const currentOwnerUserId = Form.useWatch("ownerUserId", form);
|
const currentOwnerUserId = Form.useWatch("ownerUserId", form);
|
||||||
|
const currentWidth = Form.useWatch("imageWidth", form) || CROP_WIDTH;
|
||||||
|
const currentHeight = Form.useWatch("imageHeight", form) || CROP_HEIGHT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page screen-saver-page">
|
<div className="app-page screen-saver-page">
|
||||||
|
|
@ -738,48 +770,52 @@ export default function ScreenSaverManagement() {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{isAdmin ? (
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} md={12}>
|
<Col xs={24} md={12}>
|
||||||
<Form.Item name="scopeType" label="作用域" rules={[{ required: true, message: "请选择作用域" }]}>
|
<Form.Item name="scopeType" label="作用域" rules={[{ required: true, message: "请选择作用域" }]}>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
{ label: "平台级(全平台共用)", value: "PLATFORM" },
|
{ label: "平台级(全平台共用)", value: "PLATFORM" },
|
||||||
{ label: "用户级(指定用户优先)", value: "USER" },
|
{ label: "用户级(当前用户自己使用)", value: "USER" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={12}>
|
<Col xs={24} md={12}>
|
||||||
<Form.Item
|
<Form.Item label="归属用户">
|
||||||
name="ownerUserId"
|
<Input
|
||||||
label="归属用户"
|
value={currentScopeType === "USER" ? normalizeOwnerLabel(userMap.get(currentUserId)) : "平台级无需选择"}
|
||||||
rules={currentScopeType === "USER" ? [{ required: true, message: "请选择归属用户" }] : []}
|
readOnly
|
||||||
>
|
|
||||||
<Select
|
|
||||||
disabled={currentScopeType !== "USER"}
|
|
||||||
showSearch
|
|
||||||
allowClear
|
|
||||||
placeholder={currentScopeType === "USER" ? "请选择用户" : "平台级无需选择"}
|
|
||||||
optionFilterProp="label"
|
|
||||||
options={users.map((user) => ({
|
|
||||||
value: user.userId,
|
|
||||||
label: `${normalizeOwnerLabel(user)} / ${user.username}`,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
) : (
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item label="作用域">
|
||||||
|
<Input value="个人级" readOnly />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item label="归属用户">
|
||||||
|
<Input value={normalizeOwnerLabel(userMap.get(currentUserId))} readOnly />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
className="screen-saver-preview-card"
|
className="screen-saver-preview-card"
|
||||||
style={{ marginBottom: 18 }}
|
style={{ marginBottom: 18 }}
|
||||||
styles={{ body: { padding: 18 } }}
|
styles={{ body: { padding: 18 } }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
|
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
|
||||||
<div>
|
<div>
|
||||||
<Title level={5} style={{ margin: 0 }}>屏保成片预览</Title>
|
<Title level={5} style={{ margin: 0 }}>屏保成片预览</Title>
|
||||||
<Text type="secondary">固定 8:5 构图,导出 1280 × 800。上传后后端只做校验与存储。</Text>
|
<Text type="secondary">固定 8:5 构图,根据设定尺寸导出。上传后后端只做校验与存储。</Text>
|
||||||
</div>
|
</div>
|
||||||
<Upload {...uploadProps}>
|
<Upload {...uploadProps}>
|
||||||
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
|
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
|
||||||
|
|
@ -800,11 +836,11 @@ export default function ScreenSaverManagement() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<span className="screen-saver-preview-pill">输出规格 1280 × 800</span>
|
<span className="screen-saver-preview-pill">输出规格 {currentWidth} × {currentHeight}</span>
|
||||||
<span className="screen-saver-preview-pill">当前作用域 {currentScopeType === "USER" ? "用户级" : "平台级"}</span>
|
<span className="screen-saver-preview-pill">当前作用域 {currentScopeType === "USER" ? "用户级" : "平台级"}</span>
|
||||||
{currentScopeType === "USER" && currentOwnerUserId ? (
|
{currentScopeType === "USER" ? (
|
||||||
<span className="screen-saver-preview-pill">
|
<span className="screen-saver-preview-pill">
|
||||||
归属 {normalizeOwnerLabel(userMap.get(currentOwnerUserId))}
|
归属 {normalizeOwnerLabel(userMap.get(currentUserId))}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
@ -818,13 +854,13 @@ export default function ScreenSaverManagement() {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={8}>
|
<Col xs={12} md={8}>
|
||||||
<Form.Item name="imageWidth" label="宽度">
|
<Form.Item name="imageWidth" label="宽度" rules={[{ required: true, message: "请输入宽度" }]}>
|
||||||
<InputNumber disabled style={{ width: "100%" }} />
|
<InputNumber min={100} max={4096} style={{ width: "100%" }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={8}>
|
<Col xs={12} md={8}>
|
||||||
<Form.Item name="imageHeight" label="高度">
|
<Form.Item name="imageHeight" label="高度" rules={[{ required: true, message: "请输入高度" }]}>
|
||||||
<InputNumber disabled style={{ width: "100%" }} />
|
<InputNumber min={100} max={4096} style={{ width: "100%" }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
@ -834,7 +870,7 @@ export default function ScreenSaverManagement() {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="description" label="描述">
|
<Form.Item name="description" label="描述">
|
||||||
<TextArea rows={3} placeholder="描述这张屏保用于什么场景、展示何种品牌信息或氛围。" />
|
<TextArea rows={3} placeholder="描述这张屏保用于什么场景、展示什么信息。" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
|
|
@ -845,20 +881,22 @@ export default function ScreenSaverManagement() {
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={12}>
|
<Col xs={24} md={12}>
|
||||||
<Form.Item name="remark" label="备注">
|
<Form.Item name="remark" label="备注">
|
||||||
<Input placeholder="例如:大厅屏、品牌发布期、用户专属欢迎页" />
|
<Input placeholder="例如:大厅屏、品牌发布期、个人欢迎页" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{(isAdmin || currentScopeType === "USER") ? (
|
||||||
<Form.Item name="statusEnabled" label="启用状态" valuePropName="checked">
|
<Form.Item name="statusEnabled" label="启用状态" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<ScreenSaverCropDialog
|
<ScreenSaverCropDialog
|
||||||
state={cropState}
|
state={cropState}
|
||||||
onCancel={() => setCropState({ open: false, src: "", fileName: "", mimeType: "image/jpeg" })}
|
onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))}
|
||||||
onConfirm={handleUploadCroppedImage}
|
onConfirm={handleUploadCroppedImage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,224 @@
|
||||||
import { Row, Col, Card, Typography, Table, Tag, Skeleton, Button } from "antd";
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
|
||||||
import {
|
import {
|
||||||
VideoCameraOutlined,
|
HistoryOutlined,
|
||||||
DesktopOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
SyncOutlined,
|
LoadingOutlined,
|
||||||
ArrowRightOutlined
|
AudioOutlined,
|
||||||
} from "@ant-design/icons";
|
RobotOutlined,
|
||||||
import { useTranslation } from "react-i18next";
|
CalendarOutlined,
|
||||||
import StatCard from "@/components/shared/StatCard/StatCard";
|
TeamOutlined,
|
||||||
import PageHeader from "@/components/shared/PageHeader";
|
RiseOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { getDashboardStats, getRecentTasks, DashboardStats } from '@/api/business/dashboard';
|
||||||
|
import { MeetingVO, getMeetingProgress, MeetingProgress } from '@/api/business/meeting';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
export default function Dashboard() {
|
const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => {
|
||||||
const { t } = useTranslation();
|
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
||||||
|
|
||||||
const recentMeetings = [
|
useEffect(() => {
|
||||||
{ key: "1", name: "Product Sync", time: "2024-02-10 14:00", duration: "45min", status: "processing" },
|
if (meeting.status !== 1 && meeting.status !== 2) return;
|
||||||
{ key: "2", name: "Tech Review", time: "2024-02-10 10:00", duration: "60min", status: "success" },
|
|
||||||
{ key: "3", name: "Daily Standup", time: "2024-02-10 09:00", duration: "15min", status: "success" },
|
|
||||||
{ key: "4", name: "Client Call", time: "2024-02-10 16:30", duration: "30min", status: "default" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const columns = [
|
const fetchProgress = async () => {
|
||||||
{
|
try {
|
||||||
title: t("dashboard.meetingName"),
|
const res = await getMeetingProgress(meeting.id);
|
||||||
dataIndex: "name",
|
if (res.data?.data) {
|
||||||
key: "name",
|
setProgress(res.data.data);
|
||||||
render: (text: string) => <Text strong>{text}</Text>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("dashboard.startTime"),
|
|
||||||
dataIndex: "time",
|
|
||||||
key: "time",
|
|
||||||
className: "tabular-nums",
|
|
||||||
render: (text: string) => <Text type="secondary">{text}</Text>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("dashboard.duration"),
|
|
||||||
dataIndex: "duration",
|
|
||||||
key: "duration",
|
|
||||||
width: 100,
|
|
||||||
className: "tabular-nums"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("common.status"),
|
|
||||||
dataIndex: "status",
|
|
||||||
key: "status",
|
|
||||||
width: 120,
|
|
||||||
render: (status: string) => {
|
|
||||||
if (status === "processing") return <Tag icon={<SyncOutlined spin aria-hidden="true" />} color="processing">{t("dashboardExt.processing")}</Tag>;
|
|
||||||
if (status === "success") return <Tag icon={<CheckCircleOutlined aria-hidden="true" />} color="success">{t("dashboardExt.completed")}</Tag>;
|
|
||||||
return <Tag color="default">{t("dashboardExt.pending")}</Tag>;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProgress();
|
||||||
|
const timer = setInterval(fetchProgress, 3000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [meeting.id, meeting.status]);
|
||||||
|
|
||||||
|
if (meeting.status !== 1 && meeting.status !== 2) return null;
|
||||||
|
|
||||||
|
const percent = progress?.percent || 0;
|
||||||
|
const isError = percent < 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12, padding: '12px 16px', background: 'var(--app-bg-surface-soft)', borderRadius: 8, border: '1px solid var(--app-border-color)' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
|
||||||
|
{progress?.message || '准备分析中...'}
|
||||||
|
</Text>
|
||||||
|
{!isError && <Text strong style={{ color: '#1890ff' }}>{percent}%</Text>}
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={isError ? 100 : percent}
|
||||||
|
size="small"
|
||||||
|
status={isError ? 'exception' : (percent === 100 ? 'success' : 'active')}
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Dashboard: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const processingCount = Number(stats?.processingTasks || 0);
|
||||||
|
const dashboardLoading = loading && processingCount > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDashboardData();
|
||||||
|
const timer = setInterval(fetchDashboardData, 5000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchDashboardData = async () => {
|
||||||
|
try {
|
||||||
|
const [statsRes, tasksRes] = await Promise.all([getDashboardStats(), getRecentTasks()]);
|
||||||
|
setStats(statsRes.data.data);
|
||||||
|
setRecentTasks(tasksRes.data.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Dashboard data load failed', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTaskProgress = (item: MeetingVO) => {
|
||||||
|
const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', maxWidth: 450 }}>
|
||||||
|
<Steps
|
||||||
|
size="small"
|
||||||
|
current={currentStep}
|
||||||
|
status={item.status === 4 ? 'error' : (item.status === 3 ? 'finish' : 'process')}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: '语音转录',
|
||||||
|
icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />,
|
||||||
|
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转录中' : '排队中')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("common.action"),
|
title: '智能总结',
|
||||||
key: "action",
|
icon: item.status === 2 ? <LoadingOutlined spin /> : <RobotOutlined />,
|
||||||
width: 80,
|
description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在生成' : '待执行')
|
||||||
render: () => <Button type="link" size="small" icon={<ArrowRightOutlined aria-hidden="true" />} aria-label={t("dashboard.viewAll")} />
|
},
|
||||||
|
{
|
||||||
|
title: '分析完成',
|
||||||
|
icon: item.status === 3 ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : <FileTextOutlined />,
|
||||||
}
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ label: '累计会议记录', value: stats?.totalMeetings, icon: <HistoryOutlined />, color: '#1890ff' },
|
||||||
|
{
|
||||||
|
label: '当前分析中任务',
|
||||||
|
value: stats?.processingTasks,
|
||||||
|
icon: processingCount > 0 ? <LoadingOutlined spin /> : <ClockCircleOutlined />,
|
||||||
|
color: '#faad14'
|
||||||
|
},
|
||||||
|
{ label: '今日新增分析', value: stats?.todayNew, icon: <RiseOutlined />, color: '#52c41a' },
|
||||||
|
{ label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: <CheckCircleOutlined />, color: '#13c2c2' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page dashboard-page">
|
<div style={{ padding: '24px', background: 'var(--app-bg-page)', minHeight: '100%', overflowY: 'auto' }}>
|
||||||
<PageHeader
|
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||||
title={t("dashboard.title")}
|
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||||
subtitle={t("dashboard.subtitle")}
|
{statCards.map((s, idx) => (
|
||||||
|
<Col span={6} key={idx}>
|
||||||
|
<Card variant="borderless" style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
|
||||||
|
<Statistic
|
||||||
|
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
|
||||||
|
value={s.value || 0}
|
||||||
|
valueStyle={{ color: s.color, fontWeight: 700 }}
|
||||||
|
prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })}
|
||||||
/>
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
<div className="app-page__page-actions">
|
<Card
|
||||||
<Button icon={<SyncOutlined aria-hidden="true" />} size="small">{t("common.refresh")}</Button>
|
title={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Space><ClockCircleOutlined /> 最近任务动态</Space>
|
||||||
|
<Button type="link" onClick={() => navigate('/meetings')}>查看历史记录</Button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
variant="borderless"
|
||||||
|
style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
loading={dashboardLoading}
|
||||||
|
dataSource={recentTasks}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<Row gutter={32} align="middle">
|
||||||
|
<Col span={8}>
|
||||||
|
<Space direction="vertical" size={4}>
|
||||||
|
<Title level={5} style={{ margin: 0, cursor: 'pointer' }} onClick={() => navigate(`/meetings/${item.id}`)}>
|
||||||
|
{item.title}
|
||||||
|
</Title>
|
||||||
|
<Space size={12} split={<Divider type="vertical" style={{ margin: 0 }} />}>
|
||||||
|
<Text type="secondary"><CalendarOutlined /> {dayjs(item.meetingTime).format('MM-DD HH:mm')}</Text>
|
||||||
|
<Text type="secondary"><TeamOutlined /> {item.participants || item.creatorName || '未指定'}</Text>
|
||||||
|
</Space>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{item.tags?.split(',').filter(Boolean).map((t) => (
|
||||||
|
<Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
|
||||||
<Row gutter={[24, 24]}>
|
<Col span={12}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
{renderTaskProgress(item)}
|
||||||
<StatCard title={t("dashboard.todayMeetings")} value={12} icon={<VideoCameraOutlined aria-hidden="true" />} color="blue" trend={{ value: 8, direction: "up" }} />
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
|
||||||
<StatCard title={t("dashboard.activeDevices")} value={45} icon={<DesktopOutlined aria-hidden="true" />} color="green" trend={{ value: 2, direction: "up" }} />
|
<Col span={4} style={{ textAlign: 'right' }}>
|
||||||
</Col>
|
<Button
|
||||||
<Col xs={24} sm={12} lg={6}>
|
type={item.status === 3 ? 'primary' : 'default'}
|
||||||
<StatCard title={t("dashboard.transcriptionDuration")} value={1280} suffix="min" icon={<ClockCircleOutlined aria-hidden="true" />} color="orange" trend={{ value: 5, direction: "down" }} />
|
ghost={item.status === 3}
|
||||||
</Col>
|
icon={item.status === 3 ? <FileTextOutlined /> : <PlayCircleOutlined />}
|
||||||
<Col xs={24} sm={12} lg={6}>
|
onClick={() => navigate(`/meetings/${item.id}`)}
|
||||||
<StatCard title={t("dashboard.totalUsers")} value={320} icon={<UserOutlined aria-hidden="true" />} color="purple" trend={{ value: 12, direction: "up" }} />
|
>
|
||||||
|
{item.status === 3 ? '查看纪要' : '监控详情'}
|
||||||
|
</Button>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row gutter={[24, 24]} className="mt-6">
|
<MeetingProgressDisplay meeting={item} />
|
||||||
<Col xs={24} xl={16}>
|
|
||||||
<Card title={t("dashboard.recentMeetings")} bordered={false} className="app-page__content-card" extra={<Button type="link" size="small">{t("dashboard.viewAll")}</Button>} styles={{ body: { padding: 0 } }}>
|
|
||||||
<Table dataSource={recentMeetings} columns={columns} pagination={false} size="middle" className="roles-table" />
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} xl={8}>
|
|
||||||
<Card title={t("dashboard.deviceLoad")} bordered={false} className="app-page__content-card">
|
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
|
||||||
<Skeleton active paragraph={{ rows: 4 }} />
|
|
||||||
<div className="mt-4 text-gray-400 flex items-center gap-2">
|
|
||||||
<SyncOutlined spin aria-hidden="true" />
|
|
||||||
<span>{t("dashboardExt.chartLoading")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
locale={{ emptyText: <Empty description="暂无近期分析任务" /> }}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
|
||||||
|
<style>{`
|
||||||
|
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
|
||||||
|
.ant-steps-item-description { font-size: 11px !important; }
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
.avatar-crop-modal .ant-modal-content {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal .ant-modal-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) 320px;
|
||||||
|
min-height: 540px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__stage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
padding: 32px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(22, 119, 255, 0.18), transparent 28%),
|
||||||
|
linear-gradient(160deg, #081326, #12284b 55%, #17315b 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__stage-head {
|
||||||
|
width: 100%;
|
||||||
|
color: rgba(233, 242, 252, 0.92);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__stage-head h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #f8fbff;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__stage-head p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(223, 233, 247, 0.72);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__viewport {
|
||||||
|
position: relative;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(6, 13, 28, 0.78);
|
||||||
|
box-shadow: 0 24px 50px rgba(5, 12, 25, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__viewport.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Added inner shadow to make border clearer on dark image */
|
||||||
|
.avatar-crop-modal__viewport::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__image {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform-origin: center;
|
||||||
|
max-width: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__sidebar-card {
|
||||||
|
padding: 32px 28px;
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__sidebar-card h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #10233f;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__sidebar-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #5b6b84;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 24px 28px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
import { Button, Modal, Slider, Typography, App } from "antd";
|
||||||
|
import { ScissorOutlined } from "@ant-design/icons";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import "./AvatarCropDialog.css";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const VIEWPORT_SIZE = 300;
|
||||||
|
const ALLOWED_TYPES = new Map([
|
||||||
|
["image/jpeg", "jpg"],
|
||||||
|
["image/jpg", "jpg"],
|
||||||
|
["image/png", "png"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type CropModalState = {
|
||||||
|
open: boolean;
|
||||||
|
src: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: "image/jpeg" | "image/png";
|
||||||
|
targetSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
originX: number;
|
||||||
|
originY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createImage(src: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("图片加载失败"));
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type CropDialogProps = {
|
||||||
|
state: CropModalState;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (file: File) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AvatarCropDialog({ state, onCancel, onConfirm }: CropDialogProps) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 });
|
||||||
|
const [minZoom, setMinZoom] = useState(1);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
|
||||||
|
const { targetSize } = state;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
void createImage(state.src)
|
||||||
|
.then((image) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextMinZoom = Math.max(VIEWPORT_SIZE / image.width, VIEWPORT_SIZE / image.height);
|
||||||
|
setNaturalSize({ width: image.width, height: image.height });
|
||||||
|
setMinZoom(nextMinZoom);
|
||||||
|
setZoom(nextMinZoom);
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
message.error(error.message || "图片加载失败");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [message, state.open, state.src]);
|
||||||
|
|
||||||
|
const displaySize = useMemo(() => ({
|
||||||
|
width: naturalSize.width * zoom,
|
||||||
|
height: naturalSize.height * zoom,
|
||||||
|
}), [naturalSize, zoom]);
|
||||||
|
|
||||||
|
const clampOffset = (x: number, y: number, currentZoom = zoom) => {
|
||||||
|
const width = naturalSize.width * currentZoom;
|
||||||
|
const height = naturalSize.height * currentZoom;
|
||||||
|
const maxX = Math.max(0, (width - VIEWPORT_SIZE) / 2);
|
||||||
|
const maxY = Math.max(0, (height - VIEWPORT_SIZE) / 2);
|
||||||
|
return {
|
||||||
|
x: clamp(x, -maxX, maxX),
|
||||||
|
y: clamp(y, -maxY, maxY),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomChange = (nextZoom: number) => {
|
||||||
|
const nextOffset = clampOffset(offset.x, offset.y, nextZoom);
|
||||||
|
setZoom(nextZoom);
|
||||||
|
setOffset(nextOffset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginDrag = (clientX: number, clientY: number) => {
|
||||||
|
dragRef.current = {
|
||||||
|
startX: clientX,
|
||||||
|
startY: clientY,
|
||||||
|
originX: offset.x,
|
||||||
|
originY: offset.y,
|
||||||
|
};
|
||||||
|
setDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDrag = (clientX: number, clientY: number) => {
|
||||||
|
if (!dragRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deltaX = clientX - dragRef.current.startX;
|
||||||
|
const deltaY = clientY - dragRef.current.startY;
|
||||||
|
setOffset(clampOffset(dragRef.current.originX + deltaX, dragRef.current.originY + deltaY));
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
dragRef.current = null;
|
||||||
|
setDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportCroppedFile = async () => {
|
||||||
|
const image = await createImage(state.src);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = targetSize;
|
||||||
|
canvas.height = targetSize;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("浏览器不支持图片裁剪");
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewScale = targetSize / VIEWPORT_SIZE;
|
||||||
|
const exportedWidth = image.width * zoom * previewScale;
|
||||||
|
const exportedHeight = image.height * zoom * previewScale;
|
||||||
|
const drawX = targetSize / 2 - exportedWidth / 2 + offset.x * previewScale;
|
||||||
|
const drawY = targetSize / 2 - exportedHeight / 2 + offset.y * previewScale;
|
||||||
|
context.drawImage(image, drawX, drawY, exportedWidth, exportedHeight);
|
||||||
|
|
||||||
|
// 如果想要导出也是圆角的透明底图片(PNG),可以在这里进行裁剪
|
||||||
|
// if (state.mimeType === "image/png") {
|
||||||
|
// context.globalCompositeOperation = "destination-in";
|
||||||
|
// context.beginPath();
|
||||||
|
// context.arc(targetSize / 2, targetSize / 2, targetSize / 2, 0, Math.PI * 2);
|
||||||
|
// context.fill();
|
||||||
|
// }
|
||||||
|
|
||||||
|
const extension = state.mimeType === "image/png" ? "png" : "jpg";
|
||||||
|
const fileName = state.fileName.replace(/\.[^.]+$/, "") + `_avatar.${extension}`;
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
if (state.mimeType === "image/png") {
|
||||||
|
canvas.toBlob(resolve, "image/png");
|
||||||
|
} else {
|
||||||
|
canvas.toBlob(resolve, "image/jpeg", 0.92);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error("导出裁剪图片失败");
|
||||||
|
}
|
||||||
|
return new File([blob], fileName, { type: state.mimeType });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const file = await exportCroppedFile();
|
||||||
|
await onConfirm(file);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : "裁剪上传失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={state.open}
|
||||||
|
onCancel={loading ? undefined : onCancel}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
className="avatar-crop-modal"
|
||||||
|
maskClosable={!loading}
|
||||||
|
closable={!loading}
|
||||||
|
>
|
||||||
|
<div className="avatar-crop-modal__layout">
|
||||||
|
<div className="avatar-crop-modal__stage">
|
||||||
|
<div className="avatar-crop-modal__stage-head">
|
||||||
|
<h3>调整头像</h3>
|
||||||
|
<p>请拖拽和缩放图片,将其调整至圆形区域内。最终将以 {targetSize} × {targetSize} 的尺寸保存。</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`avatar-crop-modal__viewport${dragging ? " is-dragging" : ""}`}
|
||||||
|
onMouseDown={(event) => beginDrag(event.clientX, event.clientY)}
|
||||||
|
onMouseMove={(event) => updateDrag(event.clientX, event.clientY)}
|
||||||
|
onMouseUp={endDrag}
|
||||||
|
onMouseLeave={endDrag}
|
||||||
|
onTouchStart={(event) => {
|
||||||
|
const touch = event.touches[0];
|
||||||
|
if (touch) {
|
||||||
|
beginDrag(touch.clientX, touch.clientY);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTouchMove={(event) => {
|
||||||
|
const touch = event.touches[0];
|
||||||
|
if (touch) {
|
||||||
|
updateDrag(touch.clientX, touch.clientY);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTouchEnd={endDrag}
|
||||||
|
>
|
||||||
|
{state.src ? (
|
||||||
|
<img
|
||||||
|
src={state.src}
|
||||||
|
alt="待裁剪头像"
|
||||||
|
className="avatar-crop-modal__image"
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
width: displaySize.width,
|
||||||
|
height: displaySize.height,
|
||||||
|
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px))`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="avatar-crop-modal__mask"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="avatar-crop-modal__sidebar">
|
||||||
|
<div className="avatar-crop-modal__sidebar-card">
|
||||||
|
<h4>缩放</h4>
|
||||||
|
<div style={{ marginTop: 18 }}>
|
||||||
|
<Slider
|
||||||
|
min={minZoom}
|
||||||
|
max={Math.max(minZoom * 3, minZoom + 0.2)}
|
||||||
|
step={0.01}
|
||||||
|
value={zoom}
|
||||||
|
tooltip={{ formatter: (value) => `${Math.round((value || 1) / minZoom * 100)}%` }}
|
||||||
|
onChange={handleZoomChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="avatar-crop-modal__footer">
|
||||||
|
<Button onClick={onCancel} disabled={loading}>取消</Button>
|
||||||
|
<Button type="primary" icon={<ScissorOutlined />} loading={loading} onClick={() => void handleConfirm()}>
|
||||||
|
生成并上传
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api";
|
import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api";
|
||||||
import PageHeader from "@/components/shared/PageHeader";
|
import PageHeader from "@/components/shared/PageHeader";
|
||||||
import type { BotCredential, UserProfile } from "@/types";
|
import type { BotCredential, UserProfile } from "@/types";
|
||||||
|
import AvatarCropDialog, { type CropModalState } from "./AvatarCropDialog";
|
||||||
|
|
||||||
const { Paragraph, Title, Text } = Typography;
|
const { Paragraph, Title, Text } = Typography;
|
||||||
|
|
||||||
|
|
@ -19,6 +20,13 @@ export default function Profile() {
|
||||||
const [credential, setCredential] = useState<BotCredential | null>(null);
|
const [credential, setCredential] = useState<BotCredential | null>(null);
|
||||||
const [profileForm] = Form.useForm();
|
const [profileForm] = Form.useForm();
|
||||||
const [pwdForm] = Form.useForm();
|
const [pwdForm] = Form.useForm();
|
||||||
|
const [cropState, setCropState] = useState<CropModalState>({
|
||||||
|
open: false,
|
||||||
|
src: "",
|
||||||
|
fileName: "",
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
targetSize: 300,
|
||||||
|
});
|
||||||
|
|
||||||
const loadUser = async () => {
|
const loadUser = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -60,19 +68,34 @@ export default function Profile() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAvatarUpload = async (file: File) => {
|
const handleAvatarUpload = (file: File) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
setCropState({
|
||||||
|
open: true,
|
||||||
|
src: String(reader.result || ""),
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type === "image/png" ? "image/png" : "image/jpeg",
|
||||||
|
targetSize: 300,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.onerror = () => message.error(t("common.error"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadCroppedImage = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
setAvatarUploading(true);
|
setAvatarUploading(true);
|
||||||
const url = await uploadPlatformAsset(file);
|
const url = await uploadPlatformAsset(file);
|
||||||
profileForm.setFieldValue("avatarUrl", url);
|
profileForm.setFieldValue("avatarUrl", url);
|
||||||
|
setCropState((prev) => ({ ...prev, open: false, src: "" }));
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : t("common.error"));
|
message.error(error instanceof Error ? error.message : t("common.error"));
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarUploading(false);
|
setAvatarUploading(false);
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdatePassword = async () => {
|
const handleUpdatePassword = async () => {
|
||||||
|
|
@ -109,7 +132,11 @@ export default function Profile() {
|
||||||
<Row gutter={24}>
|
<Row gutter={24}>
|
||||||
<Col xs={24} lg={8}>
|
<Col xs={24} lg={8}>
|
||||||
<Card className="app-page__content-card text-center" loading={loading}>
|
<Card className="app-page__content-card text-center" loading={loading}>
|
||||||
|
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload} disabled={avatarUploading}>
|
||||||
|
<div style={{ cursor: "pointer", display: "inline-block" }}>
|
||||||
<Avatar size={80} src={avatarUrl} icon={avatarUrl ? undefined : <UserOutlined />} style={{ backgroundColor: "#1677ff", marginBottom: 16 }} />
|
<Avatar size={80} src={avatarUrl} icon={avatarUrl ? undefined : <UserOutlined />} style={{ backgroundColor: "#1677ff", marginBottom: 16 }} />
|
||||||
|
</div>
|
||||||
|
</Upload>
|
||||||
<Title level={5} style={{ margin: 0 }}>{user?.displayName}</Title>
|
<Title level={5} style={{ margin: 0 }}>{user?.displayName}</Title>
|
||||||
<Text type="secondary">@{user?.username}</Text>
|
<Text type="secondary">@{user?.username}</Text>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|
@ -269,6 +296,12 @@ export default function Profile() {
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<AvatarCropDialog
|
||||||
|
state={cropState}
|
||||||
|
onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))}
|
||||||
|
onConfirm={handleUploadCroppedImage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue