diff --git a/backend/pom.xml b/backend/pom.xml index e3bbb83..daa948e 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -161,6 +161,11 @@ org.springframework.boot spring-boot-starter-actuator + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + diff --git a/backend/src/main/java/com/imeeting/config/OpenApiConfig.java b/backend/src/main/java/com/imeeting/config/OpenApiConfig.java new file mode 100644 index 0000000..a3ceda8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/config/OpenApiConfig.java @@ -0,0 +1,40 @@ +package com.imeeting.config; + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme.In; +import io.swagger.v3.oas.models.security.SecurityScheme.Type; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@SecurityScheme( + name = OpenApiConfig.BEARER_AUTH_SCHEME, + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + in = SecuritySchemeIn.HEADER +) +public class OpenApiConfig { + + public static final String BEARER_AUTH_SCHEME = "bearerAuth"; + + @Bean + public OpenAPI imeetingOpenApi() { + return new OpenAPI() + .info(new Info() + .title("iMeeting 接口文档") + .description("iMeeting 后端 REST 接口与兼容接口文档") + .version("v0.1.0")) + .addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH_SCHEME)) + .schemaRequirement(BEARER_AUTH_SCHEME, new io.swagger.v3.oas.models.security.SecurityScheme() + .type(Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(In.HEADER)); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java index 3d8d4c5..3ccf1b2 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java @@ -22,6 +22,8 @@ import com.imeeting.service.biz.MeetingRuntimeProfileResolver; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService; import com.unisbase.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -35,6 +37,7 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.List; +@Tag(name = "Android实时会议") @RestController @RequestMapping("/api/android/meeting") @RequiredArgsConstructor @@ -52,6 +55,7 @@ public class AndroidMeetingRealtimeController { private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver; private final GrpcServerProperties grpcServerProperties; + @Operation(summary = "创建Android实时会议") @PostMapping("/realtime/create") public ApiResponse createRealtimeMeeting(HttpServletRequest request, @RequestBody(required = false) AndroidCreateRealtimeMeetingCommand command) { @@ -99,6 +103,7 @@ public class AndroidMeetingRealtimeController { return ApiResponse.ok(vo); } + @Operation(summary = "查询Android实时会议状态") @GetMapping("/{id}/realtime/session-status") public ApiResponse getRealtimeSessionStatus(@PathVariable Long id, HttpServletRequest request) { AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); @@ -107,6 +112,7 @@ public class AndroidMeetingRealtimeController { return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id)); } + @Operation(summary = "查询Android会议转写") @GetMapping("/{id}/transcripts") public ApiResponse> getTranscripts(@PathVariable Long id, HttpServletRequest request) { AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); @@ -115,6 +121,7 @@ public class AndroidMeetingRealtimeController { return ApiResponse.ok(meetingQueryService.getTranscripts(id)); } + @Operation(summary = "暂停Android实时会议") @PostMapping("/{id}/realtime/pause") public ApiResponse pauseRealtimeMeeting(@PathVariable Long id, HttpServletRequest request) { AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); @@ -123,6 +130,7 @@ public class AndroidMeetingRealtimeController { return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id)); } + @Operation(summary = "完成Android实时会议") @PostMapping("/{id}/realtime/complete") public ApiResponse completeRealtimeMeeting(@PathVariable Long id, HttpServletRequest request, @@ -138,6 +146,7 @@ public class AndroidMeetingRealtimeController { return ApiResponse.ok(true); } + @Operation(summary = "打开Android实时会议gRPC会话") @PostMapping("/{id}/realtime/grpc-session") public ApiResponse openRealtimeGrpcSession(@PathVariable Long id, HttpServletRequest request, diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidScreenSaverController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidScreenSaverController.java new file mode 100644 index 0000000..bf99c49 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidScreenSaverController.java @@ -0,0 +1,49 @@ +package com.imeeting.controller.android; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.AndroidScreenSaverCatalogVO; +import com.imeeting.dto.android.AndroidScreenSaverItemVO; +import com.imeeting.dto.biz.ScreenSaverSelectionResult; +import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.biz.ScreenSaverService; +import com.unisbase.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +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; + +@Tag(name = "Android屏保") +@RestController +@RequestMapping("/api/android/screensavers") +@RequiredArgsConstructor +public class AndroidScreenSaverController { + + private final AndroidAuthService androidAuthService; + private final ScreenSaverService screenSaverService; + + @Operation(summary = "获取当前生效屏保") + @GetMapping("/active") + public ApiResponse active(HttpServletRequest request) { + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + ScreenSaverSelectionResult selection = screenSaverService.getActiveSelection(authContext == null ? null : authContext.getUserId()); + AndroidScreenSaverCatalogVO vo = new AndroidScreenSaverCatalogVO(); + vo.setRefreshIntervalSec(300); + vo.setPlayMode("SEQUENTIAL"); + vo.setSourceScope(selection.getSourceScope()); + vo.setItems(selection.getItems().stream().map(item -> { + AndroidScreenSaverItemVO child = new AndroidScreenSaverItemVO(); + child.setId(item.getId()); + child.setName(item.getName()); + child.setImageUrl(item.getImageUrl()); + child.setDescription(item.getDescription()); + child.setDisplayDurationSec(item.getDisplayDurationSec()); + child.setSortOrder(item.getSortOrder()); + child.setUpdatedAt(item.getUpdatedAt()); + return child; + }).toList()); + return ApiResponse.ok(vo); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyAuthController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyAuthController.java index f302489..03a6f79 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyAuthController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyAuthController.java @@ -10,6 +10,8 @@ import com.unisbase.dto.SysRoleDTO; import com.unisbase.dto.SysUserDTO; import com.unisbase.dto.TokenResponse; import com.unisbase.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; @@ -21,6 +23,7 @@ import org.springframework.util.StringUtils; import java.util.List; +@Tag(name = "兼容认证接口") @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -28,6 +31,7 @@ public class LegacyAuthController { private final AuthService authService; + @Operation(summary = "兼容登录") @PostMapping("/login") public LegacyApiResponse login(@Valid @RequestBody LoginRequest request) { TokenResponse tokenResponse = null; @@ -43,6 +47,7 @@ public class LegacyAuthController { )); } + @Operation(summary = "兼容刷新令牌") @PostMapping("/refresh") public LegacyApiResponse refresh(@RequestBody(required = false) RefreshRequest request, @RequestHeader(value = "Authorization", required = false) String authorization, diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyClientController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyClientController.java index c0672af..9a0d6a9 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyClientController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyClientController.java @@ -3,12 +3,15 @@ package com.imeeting.controller.android.legacy; import com.imeeting.dto.android.legacy.LegacyApiResponse; import com.imeeting.dto.android.legacy.LegacyClientDownloadResponse; import com.imeeting.service.android.legacy.LegacyCatalogAdapterService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; 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 = "兼容客户端下载接口") @RestController @RequestMapping("/api/clients") @RequiredArgsConstructor @@ -16,6 +19,7 @@ public class LegacyClientController { private final LegacyCatalogAdapterService legacyCatalogAdapterService; + @Operation(summary = "查询平台最新客户端") @GetMapping("/latest/by-platform") public LegacyApiResponse latestByPlatform(@RequestParam(value = "platform_code", required = false) String platformCode, @RequestParam(value = "platform_type", required = false) String platformType, diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyExternalAppController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyExternalAppController.java index 2e096eb..1646491 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyExternalAppController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyExternalAppController.java @@ -3,6 +3,8 @@ package com.imeeting.controller.android.legacy; import com.imeeting.dto.android.legacy.LegacyApiResponse; import com.imeeting.dto.android.legacy.LegacyExternalAppItemResponse; import com.imeeting.service.android.legacy.LegacyCatalogAdapterService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -11,6 +13,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; +@Tag(name = "兼容外部应用接口") @RestController @RequestMapping("/api/external-apps") @RequiredArgsConstructor @@ -18,6 +21,7 @@ public class LegacyExternalAppController { private final LegacyCatalogAdapterService legacyCatalogAdapterService; + @Operation(summary = "查询启用的外部应用") @GetMapping("/active") public LegacyApiResponse> active(@RequestParam(value = "is_active", required = false) Integer ignoredIsActive) { return LegacyApiResponse.ok(legacyCatalogAdapterService.listActiveExternalApps()); diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyLlmModelController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyLlmModelController.java index a19011f..1da2c5a 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyLlmModelController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyLlmModelController.java @@ -6,6 +6,8 @@ import com.imeeting.dto.biz.AiModelVO; import com.imeeting.service.biz.AiModelService; import com.unisbase.dto.PageResult; import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; @@ -16,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Objects; +@Tag(name = "兼容模型接口") @RestController @RequestMapping("/api/llm-models") @RequiredArgsConstructor @@ -23,6 +26,7 @@ public class LegacyLlmModelController { private final AiModelService aiModelService; + @Operation(summary = "查询启用的大模型列表") @GetMapping("/active") @PreAuthorize("isAuthenticated()") public LegacyApiResponse> activeModels() { diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java index 3bd3016..eca0e61 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java @@ -32,6 +32,8 @@ 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.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.access.prepost.PreAuthorize; @@ -56,6 +58,7 @@ import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; +@Tag(name = "兼容会议接口") @RestController @RequestMapping("/api/meetings") @@ -125,6 +128,7 @@ public class LegacyMeetingController { this.objectMapper = objectMapper; } + @Operation(summary = "兼容创建会议") @PostMapping @PreAuthorize("isAuthenticated()") public LegacyApiResponse create(@RequestBody LegacyMeetingCreateRequest request) { @@ -132,6 +136,7 @@ public class LegacyMeetingController { return LegacyApiResponse.ok(new LegacyMeetingCreateResponse(meeting.getId())); } + @Operation(summary = "兼容上传会议音频") @PostMapping("/upload-audio") @PreAuthorize("isAuthenticated()") public LegacyApiResponse uploadAudio(@RequestParam("meeting_id") Long meetingId, @@ -150,6 +155,7 @@ public class LegacyMeetingController { return LegacyApiResponse.ok("上传成功", null); } + @Operation(summary = "兼容分页查询会议") @GetMapping @PreAuthorize("isAuthenticated()") public LegacyApiResponse list(@RequestParam(value = "user_id", required = false) Long ignoredUserId, @@ -181,12 +187,14 @@ public class LegacyMeetingController { return LegacyApiResponse.ok(data); } + @Operation(summary = "兼容查询会议预览数据") @GetMapping("/{meetingId}/preview-data") public LegacyApiResponse previewData(@PathVariable Long meetingId) { LegacyMeetingPreviewResult result = buildPreviewResult(meetingId); return new LegacyApiResponse<>(result.getCode(), result.getMessage(), result.getData()); } + @Operation(summary = "兼容更新会议访问密码") @PutMapping("/{meetingId}/access-password") @PreAuthorize("isAuthenticated()") public LegacyApiResponse updateAccessPassword(@PathVariable Long meetingId, @@ -202,6 +210,7 @@ public class LegacyMeetingController { return LegacyApiResponse.ok(new LegacyMeetingAccessPasswordResponse(password)); } + @Operation(summary = "兼容删除会议") @DeleteMapping("/{meetingId}") @PreAuthorize("isAuthenticated()") public LegacyApiResponse delete(@PathVariable Long meetingId) { diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyPromptController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyPromptController.java index a5662e9..9bcfa4e 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyPromptController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyPromptController.java @@ -7,6 +7,8 @@ import com.imeeting.dto.biz.PromptTemplateVO; import com.imeeting.service.biz.PromptTemplateService; import com.unisbase.dto.PageResult; import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; @@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Objects; +@Tag(name = "兼容提示词接口") @RestController @RequestMapping("/api/prompts") @RequiredArgsConstructor @@ -27,6 +30,7 @@ public class LegacyPromptController { private final PromptTemplateService promptTemplateService; + @Operation(summary = "查询场景提示词") @GetMapping("/active/{scene}") @PreAuthorize("isAuthenticated()") public LegacyApiResponse activePrompts(@PathVariable String scene) { diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyScreenSaverController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyScreenSaverController.java new file mode 100644 index 0000000..dad6cb1 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyScreenSaverController.java @@ -0,0 +1,28 @@ +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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 = "兼容屏保接口") +@RestController +@RequestMapping("/api/screensavers") +@RequiredArgsConstructor +public class LegacyScreenSaverController { + + private final LegacyScreenSaverAdapterService legacyScreenSaverAdapterService; + + @Operation(summary = "查询启用的屏保列表") + @GetMapping("/active") + public LegacyApiResponse> active() { + return LegacyApiResponse.ok(legacyScreenSaverAdapterService.listActiveScreenSavers()); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java index 4fdfd6c..c934e16 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java @@ -4,17 +4,19 @@ package com.imeeting.controller.biz; import com.imeeting.dto.biz.AiModelDTO; import com.imeeting.dto.biz.AiLocalProfileVO; import com.imeeting.dto.biz.AiModelVO; - 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.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; +@Tag(name = "AI模型管理") @RestController @RequestMapping("/api/biz/aimodel") public class AiModelController { @@ -25,12 +27,14 @@ public class AiModelController { this.aiModelService = aiModelService; } + @Operation(summary = "新增AI模型") @PostMapping @PreAuthorize("isAuthenticated()") public ApiResponse save(@RequestBody AiModelDTO dto) { return ApiResponse.ok(aiModelService.saveModel(dto)); } + @Operation(summary = "更新AI模型") @PutMapping @PreAuthorize("isAuthenticated()") public ApiResponse update(@RequestBody AiModelDTO dto) { @@ -54,6 +58,7 @@ public class AiModelController { return ApiResponse.ok(aiModelService.updateModel(dto)); } + @Operation(summary = "删除AI模型") @DeleteMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse delete(@PathVariable Long id, @RequestParam String type) { @@ -74,6 +79,7 @@ public class AiModelController { return ApiResponse.ok(aiModelService.removeModelById(id, type)); } + @Operation(summary = "分页查询AI模型") @GetMapping("/page") @PreAuthorize("isAuthenticated()") public ApiResponse>> page( @@ -85,6 +91,7 @@ public class AiModelController { return ApiResponse.ok(aiModelService.pageModels(current, size, name, type, loginUser.getTenantId())); } + @Operation(summary = "拉取远程模型列表") @GetMapping("/remote-list") @PreAuthorize("isAuthenticated()") public ApiResponse> remoteList( @@ -94,6 +101,7 @@ public class AiModelController { return ApiResponse.ok(aiModelService.fetchRemoteModels(provider, baseUrl, apiKey)); } + @Operation(summary = "测试本地模型连通性") @PostMapping("/local-connectivity-test") @PreAuthorize("isAuthenticated()") public ApiResponse testLocalConnectivity(@RequestBody AiModelDTO dto) { @@ -106,6 +114,7 @@ public class AiModelController { return ApiResponse.ok(aiModelService.testLocalConnectivity(dto.getBaseUrl(), dto.getApiKey())); } + @Operation(summary = "获取默认AI模型") @GetMapping("/default") @PreAuthorize("isAuthenticated()") public ApiResponse getDefault(@RequestParam String type) { diff --git a/backend/src/main/java/com/imeeting/controller/biz/ClientDownloadController.java b/backend/src/main/java/com/imeeting/controller/biz/ClientDownloadController.java index 25ff7c7..1aa78f7 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/ClientDownloadController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/ClientDownloadController.java @@ -5,6 +5,8 @@ import com.imeeting.entity.biz.ClientDownload; import com.imeeting.service.biz.ClientDownloadService; import com.unisbase.common.ApiResponse; import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; @@ -24,6 +26,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +@Tag(name = "客户端下载管理") @RestController @RequestMapping("/api/clients") @RequiredArgsConstructor @@ -31,6 +34,7 @@ public class ClientDownloadController { private final ClientDownloadService clientDownloadService; + @Operation(summary = "查询客户端下载包列表") @GetMapping @PreAuthorize("isAuthenticated()") public ApiResponse> list(@RequestParam(value = "platformCode", required = false) String platformCode, @@ -46,18 +50,21 @@ public class ClientDownloadController { return ApiResponse.ok(data); } + @Operation(summary = "新增客户端下载包") @PostMapping @PreAuthorize("isAuthenticated()") public ApiResponse create(@RequestBody ClientDownloadDTO dto) { return ApiResponse.ok(clientDownloadService.create(dto, currentLoginUser())); } + @Operation(summary = "修改客户端下载包") @PutMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse update(@PathVariable Long id, @RequestBody ClientDownloadDTO dto) { return ApiResponse.ok(clientDownloadService.update(id, dto, currentLoginUser())); } + @Operation(summary = "删除客户端下载包") @DeleteMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse delete(@PathVariable Long id) { @@ -65,6 +72,7 @@ public class ClientDownloadController { return ApiResponse.ok(true); } + @Operation(summary = "上传客户端安装包") @PostMapping("/upload") @PreAuthorize("isAuthenticated()") public ApiResponse> upload(@RequestParam("platformCode") String platformCode, @@ -75,4 +83,4 @@ public class ClientDownloadController { private LoginUser currentLoginUser() { return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/DashboardController.java b/backend/src/main/java/com/imeeting/controller/biz/DashboardController.java index fed4460..3e93332 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/DashboardController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/DashboardController.java @@ -3,6 +3,8 @@ import com.imeeting.dto.biz.MeetingVO; import com.imeeting.service.biz.MeetingQueryService; import com.unisbase.common.ApiResponse; import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; @@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Map; +@Tag(name = "工作台") @RestController @RequestMapping("/api/biz/dashboard") public class DashboardController { @@ -22,6 +25,7 @@ public class DashboardController { this.meetingQueryService = meetingQueryService; } + @Operation(summary = "获取工作台统计数据") @GetMapping("/stats") @PreAuthorize("isAuthenticated()") public ApiResponse> getStats() { @@ -30,6 +34,7 @@ public class DashboardController { return ApiResponse.ok(meetingQueryService.getDashboardStats(user.getTenantId(), user.getUserId(), isAdmin)); } + @Operation(summary = "获取最近会议列表") @GetMapping("/recent") @PreAuthorize("isAuthenticated()") public ApiResponse> getRecent() { diff --git a/backend/src/main/java/com/imeeting/controller/biz/ExternalAppController.java b/backend/src/main/java/com/imeeting/controller/biz/ExternalAppController.java index 1588dde..b154655 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/ExternalAppController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/ExternalAppController.java @@ -5,6 +5,8 @@ import com.imeeting.entity.biz.ExternalApp; import com.imeeting.service.biz.ExternalAppService; import com.unisbase.common.ApiResponse; import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; @@ -23,6 +25,7 @@ import java.io.IOException; import java.util.List; import java.util.Map; +@Tag(name = "外部应用管理") @RestController @RequestMapping("/api/external-apps") @RequiredArgsConstructor @@ -30,6 +33,7 @@ public class ExternalAppController { private final ExternalAppService externalAppService; + @Operation(summary = "查询外部应用列表") @GetMapping @PreAuthorize("isAuthenticated()") public ApiResponse>> list(@RequestParam(value = "appType", required = false) String appType, @@ -37,18 +41,21 @@ public class ExternalAppController { return ApiResponse.ok(externalAppService.listForAdmin(currentLoginUser(), appType, status)); } + @Operation(summary = "新增外部应用") @PostMapping @PreAuthorize("isAuthenticated()") public ApiResponse create(@RequestBody ExternalAppDTO dto) { return ApiResponse.ok(externalAppService.create(dto, currentLoginUser())); } + @Operation(summary = "修改外部应用") @PutMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse update(@PathVariable Long id, @RequestBody ExternalAppDTO dto) { return ApiResponse.ok(externalAppService.update(id, dto, currentLoginUser())); } + @Operation(summary = "删除外部应用") @DeleteMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse delete(@PathVariable Long id) { @@ -56,12 +63,14 @@ public class ExternalAppController { return ApiResponse.ok(true); } + @Operation(summary = "上传外部应用APK") @PostMapping("/upload-apk") @PreAuthorize("isAuthenticated()") public ApiResponse> uploadApk(@RequestParam("apkFile") MultipartFile apkFile) throws IOException { return ApiResponse.ok(externalAppService.uploadApk(apkFile)); } + @Operation(summary = "上传外部应用图标") @PostMapping("/upload-icon") @PreAuthorize("isAuthenticated()") public ApiResponse> uploadIcon(@RequestParam("iconFile") MultipartFile iconFile) throws IOException { @@ -71,4 +80,4 @@ public class ExternalAppController { private LoginUser currentLoginUser() { return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java b/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java index 91f9982..ee1a149 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java @@ -11,6 +11,8 @@ import com.imeeting.service.biz.HotWordService; import com.unisbase.common.ApiResponse; import com.unisbase.dto.PageResult; import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; @@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.stream.Collectors; +@Tag(name = "热词管理") @RestController @RequestMapping("/api/biz/hotword") public class HotWordController { @@ -35,6 +38,7 @@ public class HotWordController { return Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin()); } + @Operation(summary = "新增热词") @PostMapping @PreAuthorize("isAuthenticated()") public ApiResponse save(@RequestBody HotWordDTO hotWordDTO) { @@ -48,6 +52,7 @@ public class HotWordController { return ApiResponse.ok(hotWordService.saveHotWord(hotWordDTO, loginUser.getUserId())); } + @Operation(summary = "修改热词") @PutMapping @PreAuthorize("isAuthenticated()") public ApiResponse update(@RequestBody HotWordDTO hotWordDTO) { @@ -76,6 +81,7 @@ public class HotWordController { return ApiResponse.ok(hotWordService.updateHotWord(hotWordDTO)); } + @Operation(summary = "删除热词") @DeleteMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse delete(@PathVariable Long id) { @@ -97,6 +103,7 @@ public class HotWordController { return ApiResponse.ok(hotWordService.removeById(id)); } + @Operation(summary = "分页查询热词") @GetMapping("/page") @PreAuthorize("isAuthenticated()") public ApiResponse>> page( @@ -136,6 +143,7 @@ public class HotWordController { return ApiResponse.ok(result); } + @Operation(summary = "生成热词拼音") @GetMapping("/pinyin") @PreAuthorize("isAuthenticated()") public ApiResponse> getPinyin(@RequestParam String word) { diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index d0b0b85..548e6ac 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -28,6 +28,8 @@ import com.imeeting.service.biz.RealtimeMeetingSocketSessionService; import com.unisbase.common.ApiResponse; import com.unisbase.dto.PageResult; import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; @@ -57,6 +59,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +@Tag(name = "会议管理") @RestController @RequestMapping("/api/biz/meeting") public class MeetingController { @@ -94,6 +97,7 @@ public class MeetingController { this.resourcePrefix = resourcePrefix; } + @Operation(summary = "查询会议处理进度") @GetMapping("/{id}/progress") @PreAuthorize("isAuthenticated()") public ApiResponse> getProgress(@PathVariable Long id) { @@ -125,6 +129,7 @@ public class MeetingController { return ApiResponse.ok(fallback); } + @Operation(summary = "上传会议音频") @PostMapping("/upload") @PreAuthorize("isAuthenticated()") public ApiResponse upload(@RequestParam("file") MultipartFile file) throws IOException { @@ -141,6 +146,7 @@ public class MeetingController { return ApiResponse.ok(baseResourcePrefix + "audio/" + fileName); } + @Operation(summary = "创建离线会议") @PostMapping @PreAuthorize("isAuthenticated()") public ApiResponse create(@Valid @RequestBody CreateMeetingCommand command) { @@ -154,6 +160,7 @@ public class MeetingController { )); } + @Operation(summary = "创建实时会议") @PostMapping("/realtime/start") @PreAuthorize("isAuthenticated()") public ApiResponse createRealtime(@Valid @RequestBody CreateRealtimeMeetingCommand command) { @@ -167,6 +174,7 @@ public class MeetingController { )); } + @Operation(summary = "分页查询会议") @GetMapping("/page") @PreAuthorize("isAuthenticated()") public ApiResponse>> page( @@ -190,6 +198,7 @@ public class MeetingController { )); } + @Operation(summary = "查询会议详情") @GetMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse getDetail(@PathVariable Long id) { @@ -199,6 +208,7 @@ public class MeetingController { return ApiResponse.ok(meetingQueryService.getDetail(id)); } + @Operation(summary = "导出会议摘要") @GetMapping("/{id}/summary/export") @PreAuthorize("isAuthenticated()") public ResponseEntity exportSummary(@PathVariable Long id, @RequestParam(defaultValue = "pdf") String format) { @@ -219,6 +229,7 @@ public class MeetingController { .body(exportResult.getContent()); } + @Operation(summary = "查询会议转写记录") @GetMapping("/{id}/transcripts") @PreAuthorize("isAuthenticated()") public ApiResponse> getTranscripts(@PathVariable Long id) { @@ -228,6 +239,7 @@ public class MeetingController { return ApiResponse.ok(meetingQueryService.getTranscripts(id)); } + @Operation(summary = "查询实时会议状态") @GetMapping("/{id}/realtime/session-status") @PreAuthorize("isAuthenticated()") public ApiResponse getRealtimeSessionStatus(@PathVariable Long id) { @@ -237,6 +249,7 @@ public class MeetingController { return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id)); } + @Operation(summary = "批量查询实时会议状态") @PostMapping("/realtime/session-status/batch") @PreAuthorize("isAuthenticated()") public ApiResponse> getRealtimeSessionStatuses(@RequestBody List ids) { @@ -265,6 +278,7 @@ public class MeetingController { return ApiResponse.ok(result); } + @Operation(summary = "追加实时转写片段") @PostMapping("/{id}/realtime/transcripts") @PreAuthorize("isAuthenticated()") public ApiResponse appendRealtimeTranscripts(@PathVariable Long id, @RequestBody List items) { @@ -275,6 +289,7 @@ public class MeetingController { return ApiResponse.ok(true); } + @Operation(summary = "暂停实时会议") @PostMapping("/{id}/realtime/pause") @PreAuthorize("isAuthenticated()") public ApiResponse pauseRealtimeMeeting(@PathVariable Long id) { @@ -284,6 +299,7 @@ public class MeetingController { return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id)); } + @Operation(summary = "打开实时会议Socket会话") @PostMapping("/{id}/realtime/socket-session") @PreAuthorize("isAuthenticated()") public ApiResponse openRealtimeSocketSession(@PathVariable Long id, @@ -304,6 +320,7 @@ public class MeetingController { )); } + @Operation(summary = "完成实时会议") @PostMapping("/{id}/realtime/complete") @PreAuthorize("isAuthenticated()") public ApiResponse completeRealtimeMeeting(@PathVariable Long id, @RequestBody(required = false) RealtimeMeetingCompleteDTO dto) { @@ -318,6 +335,7 @@ public class MeetingController { return ApiResponse.ok(true); } + @Operation(summary = "更新会议讲话人") @PutMapping("/speaker") @PreAuthorize("isAuthenticated()") public ApiResponse updateSpeaker(@RequestBody MeetingSpeakerUpdateDTO dto) { @@ -328,6 +346,7 @@ public class MeetingController { return ApiResponse.ok(true); } + @Operation(summary = "更新会议转写") @PutMapping("/{id}/transcripts/{transcriptId}") @PreAuthorize("isAuthenticated()") public ApiResponse updateTranscript(@PathVariable Long id, @@ -342,6 +361,7 @@ public class MeetingController { return ApiResponse.ok(true); } + @Operation(summary = "更新参会人员") @PutMapping("/{id}/participants") @PreAuthorize("isAuthenticated()") public ApiResponse updateParticipants(@PathVariable Long id, @@ -354,6 +374,7 @@ public class MeetingController { return ApiResponse.ok(true); } + @Operation(summary = "重新生成会议摘要") @PostMapping("/{id}/summary/regenerate") @PreAuthorize("isAuthenticated()") public ApiResponse reSummary(@PathVariable Long id, @Valid @RequestBody MeetingResummaryDTO dto) { @@ -366,6 +387,7 @@ public class MeetingController { return ApiResponse.ok(true); } + @Operation(summary = "重试音频转写") @PostMapping("/{id}/transcripts/regenerate") @PreAuthorize("isAuthenticated()") public ApiResponse retryTranscription(@PathVariable Long id) { @@ -376,6 +398,7 @@ public class MeetingController { return ApiResponse.ok(true); } + @Operation(summary = "更新会议基础信息") @PutMapping("/{id}/basic") @PreAuthorize("isAuthenticated()") public ApiResponse updateBasic(@PathVariable Long id, @RequestBody UpdateMeetingBasicCommand command) { @@ -387,6 +410,7 @@ public class MeetingController { return ApiResponse.ok(true); } + @Operation(summary = "更新会议摘要") @PutMapping("/{id}/summary") @PreAuthorize("isAuthenticated()") public ApiResponse updateSummary(@PathVariable Long id, @RequestBody UpdateMeetingSummaryCommand command) { @@ -398,6 +422,7 @@ public class MeetingController { return ApiResponse.ok(true); } + @Operation(summary = "删除会议") @DeleteMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse delete(@PathVariable Long id) { diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java index 59ed8a7..a37a4bb 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java @@ -6,12 +6,15 @@ import com.imeeting.entity.biz.Meeting; import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingQueryService; import com.unisbase.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "会议公开预览") @RestController @RequestMapping("/api/public/meetings") public class MeetingPublicPreviewController { @@ -25,6 +28,7 @@ public class MeetingPublicPreviewController { this.meetingAccessService = meetingAccessService; } + @Operation(summary = "查询会议预览访问要求") @GetMapping("/{id}/preview/access") public ApiResponse getPreviewAccess(@PathVariable Long id) { try { @@ -35,6 +39,7 @@ public class MeetingPublicPreviewController { } } + @Operation(summary = "获取会议公开预览内容") @GetMapping("/{id}/preview") public ApiResponse getPreview(@PathVariable Long id, @RequestParam(required = false) String accessPassword) { diff --git a/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java b/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java index 7d1c7a5..f431ca2 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java @@ -4,17 +4,19 @@ package com.imeeting.controller.biz; import com.imeeting.dto.biz.PromptTemplateDTO; import com.imeeting.dto.biz.PromptTemplateVO; import com.imeeting.entity.biz.PromptTemplate; - 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.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; +@Tag(name = "提示词模板管理") @RestController @RequestMapping("/api/biz/prompt") public class PromptTemplateController { @@ -25,6 +27,7 @@ public class PromptTemplateController { this.promptTemplateService = promptTemplateService; } + @Operation(summary = "新增提示词模板") @PostMapping @PreAuthorize("isAuthenticated()") public ApiResponse save(@RequestBody PromptTemplateDTO dto) { @@ -46,6 +49,7 @@ public class PromptTemplateController { return ApiResponse.ok(promptTemplateService.saveTemplate(dto, loginUser.getUserId(), loginUser.getTenantId())); } + @Operation(summary = "修改提示词模板") @PutMapping @PreAuthorize("isAuthenticated()") public ApiResponse update(@RequestBody PromptTemplateDTO dto) { @@ -73,6 +77,7 @@ public class PromptTemplateController { return ApiResponse.ok(promptTemplateService.updateTemplate(dto)); } + @Operation(summary = "更新提示词模板状态") @PutMapping("/{id}/status") @PreAuthorize("isAuthenticated()") public ApiResponse updateStatus(@PathVariable Long id, @RequestParam Integer status) { @@ -110,6 +115,7 @@ public class PromptTemplateController { return ApiResponse.ok(true); } + @Operation(summary = "删除提示词模板") @DeleteMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse delete(@PathVariable Long id) { @@ -137,6 +143,7 @@ public class PromptTemplateController { return ApiResponse.ok(promptTemplateService.removeById(id)); } + @Operation(summary = "分页查询提示词模板") @GetMapping("/page") @PreAuthorize("isAuthenticated()") public ApiResponse>> page( diff --git a/backend/src/main/java/com/imeeting/controller/biz/ScreenSaverController.java b/backend/src/main/java/com/imeeting/controller/biz/ScreenSaverController.java new file mode 100644 index 0000000..c04e8ad --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/ScreenSaverController.java @@ -0,0 +1,79 @@ +package com.imeeting.controller.biz; + +import com.imeeting.dto.biz.ScreenSaverAdminVO; +import com.imeeting.dto.biz.ScreenSaverDTO; +import com.imeeting.dto.biz.ScreenSaverImageUploadVO; +import com.imeeting.entity.biz.ScreenSaver; +import com.imeeting.service.biz.ScreenSaverService; +import com.unisbase.common.ApiResponse; +import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.context.SecurityContextHolder; +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.util.List; + +@Tag(name = "屏保管理") +@RestController +@RequestMapping("/api/screen-savers") +@RequiredArgsConstructor +public class ScreenSaverController { + + private final ScreenSaverService screenSaverService; + + @Operation(summary = "查询屏保列表") + @GetMapping + @PreAuthorize("isAuthenticated()") + public ApiResponse> list(@RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "status", required = false) Integer status, + @RequestParam(value = "scopeType", required = false) String scopeType, + @RequestParam(value = "ownerUserId", required = false) Long ownerUserId) { + return ApiResponse.ok(screenSaverService.listForAdmin(currentLoginUser(), keyword, status, scopeType, ownerUserId)); + } + + @Operation(summary = "新增屏保") + @PostMapping + @PreAuthorize("isAuthenticated()") + public ApiResponse create(@RequestBody ScreenSaverDTO dto) { + return ApiResponse.ok(screenSaverService.create(dto, currentLoginUser())); + } + + @Operation(summary = "修改屏保") + @PutMapping("/{id}") + @PreAuthorize("isAuthenticated()") + public ApiResponse update(@PathVariable Long id, @RequestBody ScreenSaverDTO dto) { + return ApiResponse.ok(screenSaverService.update(id, dto, currentLoginUser())); + } + + @Operation(summary = "删除屏保") + @DeleteMapping("/{id}") + @PreAuthorize("isAuthenticated()") + public ApiResponse delete(@PathVariable Long id) { + screenSaverService.removeScreenSaver(id, currentLoginUser()); + return ApiResponse.ok(true); + } + + @Operation(summary = "上传屏保图片") + @PostMapping("/upload-image") + @PreAuthorize("isAuthenticated()") + public ApiResponse uploadImage(@RequestParam("imageFile") MultipartFile imageFile) throws IOException { + return ApiResponse.ok(screenSaverService.uploadImage(imageFile)); + } + + private LoginUser currentLoginUser() { + return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java index 7450d65..15fe239 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java @@ -6,12 +6,15 @@ import com.imeeting.service.biz.SpeakerService; import com.unisbase.common.ApiResponse; import com.unisbase.dto.PageResult; import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; +@Tag(name = "讲话人管理") @RestController @RequestMapping("/api/biz/speaker") public class SpeakerController { @@ -22,6 +25,7 @@ public class SpeakerController { this.speakerService = speakerService; } + @Operation(summary = "注册讲话人样本") @PostMapping("/register") @PreAuthorize("isAuthenticated()") public ApiResponse register(@ModelAttribute SpeakerRegisterDTO registerDTO) { @@ -33,6 +37,7 @@ public class SpeakerController { return ApiResponse.ok(speakerService.register(registerDTO, loginUser)); } + @Operation(summary = "分页查询讲话人") @GetMapping("/page") @PreAuthorize("isAuthenticated()") public ApiResponse>> page( @@ -46,6 +51,7 @@ public class SpeakerController { return ApiResponse.ok(speakerService.pageVisible(current, size, name, loginUser)); } + @Operation(summary = "查询可见讲话人") @GetMapping("/list") @PreAuthorize("isAuthenticated()") public ApiResponse> list() { @@ -56,6 +62,7 @@ public class SpeakerController { return ApiResponse.ok(speakerService.listVisible(loginUser)); } + @Operation(summary = "删除讲话人") @DeleteMapping("/{id}") @PreAuthorize("isAuthenticated()") public ApiResponse delete(@PathVariable Long id) { diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverCatalogVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverCatalogVO.java new file mode 100644 index 0000000..e978e21 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverCatalogVO.java @@ -0,0 +1,13 @@ +package com.imeeting.dto.android; + +import lombok.Data; + +import java.util.List; + +@Data +public class AndroidScreenSaverCatalogVO { + private Integer refreshIntervalSec; + private String playMode; + private String sourceScope; + private List items; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverItemVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverItemVO.java new file mode 100644 index 0000000..81cc769 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverItemVO.java @@ -0,0 +1,14 @@ +package com.imeeting.dto.android; + +import lombok.Data; + +@Data +public class AndroidScreenSaverItemVO { + private Long id; + private String name; + private String imageUrl; + private String description; + private Integer displayDurationSec; + private Integer sortOrder; + private String updatedAt; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyScreenSaverItemResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyScreenSaverItemResponse.java new file mode 100644 index 0000000..faeff45 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyScreenSaverItemResponse.java @@ -0,0 +1,54 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.imeeting.dto.biz.ScreenSaverAdminVO; +import lombok.Data; + +@Data +public class LegacyScreenSaverItemResponse { + private Long id; + + private String name; + + @JsonProperty("image_url") + private String imageUrl; + + private String description; + + @JsonProperty("display_duration_sec") + private Integer displayDurationSec; + + @JsonProperty("sort_order") + private Integer sortOrder; + + @JsonProperty("is_active") + private Integer isActive; + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("updated_at") + private String updatedAt; + + @JsonProperty("created_by") + private Long createdBy; + + @JsonProperty("creator_username") + private String creatorUsername; + + public static LegacyScreenSaverItemResponse from(ScreenSaverAdminVO source) { + LegacyScreenSaverItemResponse response = new LegacyScreenSaverItemResponse(); + response.setId(source.getId()); + response.setName(source.getName()); + response.setImageUrl(source.getImageUrl()); + response.setDescription(source.getDescription()); + response.setDisplayDurationSec(source.getDisplayDurationSec()); + response.setSortOrder(source.getSortOrder()); + response.setIsActive(Integer.valueOf(1).equals(source.getStatus()) ? 1 : 0); + response.setCreatedAt(source.getCreatedAt()); + response.setUpdatedAt(source.getUpdatedAt()); + response.setCreatedBy(source.getCreatedBy()); + response.setCreatorUsername(source.getCreatorUsername()); + return response; + } +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverAdminVO.java b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverAdminVO.java new file mode 100644 index 0000000..12a6566 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverAdminVO.java @@ -0,0 +1,49 @@ +package com.imeeting.dto.biz; + +import com.imeeting.entity.biz.ScreenSaver; +import lombok.Data; + +@Data +public class ScreenSaverAdminVO { + private Long id; + private Long tenantId; + private String scopeType; + private Long ownerUserId; + private String name; + private String imageUrl; + private String description; + private Integer displayDurationSec; + private Integer imageWidth; + private Integer imageHeight; + private String imageFormat; + private Integer sortOrder; + private Integer status; + private String remark; + private Long createdBy; + private String creatorUsername; + private String createdAt; + private String updatedAt; + + public static ScreenSaverAdminVO from(ScreenSaver entity, String creatorUsername) { + ScreenSaverAdminVO vo = new ScreenSaverAdminVO(); + vo.setId(entity.getId()); + vo.setTenantId(entity.getTenantId()); + vo.setScopeType(entity.getScopeType()); + vo.setOwnerUserId(entity.getOwnerUserId()); + vo.setName(entity.getName()); + vo.setImageUrl(entity.getImageUrl()); + vo.setDescription(entity.getDescription()); + vo.setDisplayDurationSec(entity.getDisplayDurationSec()); + vo.setImageWidth(entity.getImageWidth()); + vo.setImageHeight(entity.getImageHeight()); + vo.setImageFormat(entity.getImageFormat()); + vo.setSortOrder(entity.getSortOrder()); + vo.setStatus(entity.getStatus()); + vo.setRemark(entity.getRemark()); + vo.setCreatedBy(entity.getCreatedBy()); + vo.setCreatorUsername(creatorUsername); + vo.setCreatedAt(entity.getCreatedAt() == null ? null : entity.getCreatedAt().toString()); + vo.setUpdatedAt(entity.getUpdatedAt() == null ? null : entity.getUpdatedAt().toString()); + return vo; + } +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverDTO.java b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverDTO.java new file mode 100644 index 0000000..f6fcb1b --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverDTO.java @@ -0,0 +1,19 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +@Data +public class ScreenSaverDTO { + private String scopeType; + private Long ownerUserId; + private String name; + private String imageUrl; + private String description; + private Integer displayDurationSec; + private Integer imageWidth; + private Integer imageHeight; + private String imageFormat; + private Integer sortOrder; + private Integer status; + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverImageUploadVO.java b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverImageUploadVO.java new file mode 100644 index 0000000..a6fcbd5 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverImageUploadVO.java @@ -0,0 +1,12 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +@Data +public class ScreenSaverImageUploadVO { + private String imageUrl; + private Long fileSize; + private Integer imageWidth; + private Integer imageHeight; + private String imageFormat; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverSelectionResult.java b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverSelectionResult.java new file mode 100644 index 0000000..e96593a --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/ScreenSaverSelectionResult.java @@ -0,0 +1,13 @@ +package com.imeeting.dto.biz; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +@Data +@AllArgsConstructor +public class ScreenSaverSelectionResult { + private String sourceScope; + private List items; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/AiTask.java b/backend/src/main/java/com/imeeting/entity/biz/AiTask.java index abf5d36..078fa02 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/AiTask.java +++ b/backend/src/main/java/com/imeeting/entity/biz/AiTask.java @@ -5,37 +5,50 @@ import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDateTime; import java.util.Map; @Data +@Schema(description = "AI任务实体") @TableName(value = "biz_ai_tasks", autoResultMap = true) public class AiTask { @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "任务ID") private Long id; + @Schema(description = "会议ID") private Long meetingId; + @Schema(description = "任务类型") private String taskType; + @Schema(description = "任务状态") private Integer status; @TableField(typeHandler = JacksonTypeHandler.class) + @Schema(description = "任务请求参数") private Map requestData; @TableField(typeHandler = JacksonTypeHandler.class) + @Schema(description = "任务响应结果") private Map responseData; @TableField(typeHandler = JacksonTypeHandler.class) + @Schema(description = "任务运行配置") private Map taskConfig; + @Schema(description = "结果文件路径") private String resultFilePath; + @Schema(description = "错误信息") private String errorMsg; + @Schema(description = "开始时间") private LocalDateTime startedAt; + @Schema(description = "完成时间") private LocalDateTime completedAt; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/AsrModel.java b/backend/src/main/java/com/imeeting/entity/biz/AsrModel.java index a562027..2b532e9 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/AsrModel.java +++ b/backend/src/main/java/com/imeeting/entity/biz/AsrModel.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.unisbase.entity.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -13,27 +14,38 @@ import java.util.Map; @Data @EqualsAndHashCode(callSuper = true) +@Schema(description = "语音识别模型实体") @TableName(value = "biz_asr_models", autoResultMap = true) public class AsrModel extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "模型ID") private Long id; + @Schema(description = "模型名称") private String modelName; + @Schema(description = "服务提供商") private String provider; + @Schema(description = "服务基础地址") private String baseUrl; + @Schema(description = "接口密钥") private String apiKey; + @Schema(description = "模型编码") private String modelCode; + @Schema(description = "WebSocket地址") private String wsUrl; @TableField(typeHandler = JacksonTypeHandler.class) + @Schema(description = "音频媒体参数配置") private Map mediaConfig; + @Schema(description = "是否默认模型") private Integer isDefault; + @Schema(description = "备注") private String remark; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/ClientDownload.java b/backend/src/main/java/com/imeeting/entity/biz/ClientDownload.java index 396579d..6102fa9 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/ClientDownload.java +++ b/backend/src/main/java/com/imeeting/entity/biz/ClientDownload.java @@ -4,35 +4,49 @@ 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_client_downloads") public class ClientDownload extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "下载包ID") private Long id; + @Schema(description = "平台类型") private String platformType; + @Schema(description = "平台名称") private String platformName; + @Schema(description = "平台编码") private String platformCode; + @Schema(description = "版本号") private String version; + @Schema(description = "版本编码") private Long versionCode; + @Schema(description = "下载地址") private String downloadUrl; + @Schema(description = "文件大小") private Long fileSize; + @Schema(description = "版本说明") private String releaseNotes; + @Schema(description = "是否最新版本") private Integer isLatest; + @Schema(description = "最低系统版本") private String minSystemVersion; + @Schema(description = "创建人ID") private Long createdBy; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/ExternalApp.java b/backend/src/main/java/com/imeeting/entity/biz/ExternalApp.java index de381ae..91bf6cd 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/ExternalApp.java +++ b/backend/src/main/java/com/imeeting/entity/biz/ExternalApp.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.unisbase.entity.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -13,23 +14,32 @@ import java.util.Map; @Data @EqualsAndHashCode(callSuper = true) +@Schema(description = "外部应用实体") @TableName(value = "biz_external_apps", autoResultMap = true) public class ExternalApp extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "应用ID") private Long id; + @Schema(description = "应用名称") private String appName; + @Schema(description = "应用类型") private String appType; @TableField(typeHandler = JacksonTypeHandler.class) + @Schema(description = "应用扩展信息") private Map appInfo; + @Schema(description = "图标地址") private String iconUrl; + @Schema(description = "应用描述") private String description; + @Schema(description = "排序值") private Integer sortOrder; + @Schema(description = "创建人ID") private Long createdBy; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/HotWord.java b/backend/src/main/java/com/imeeting/entity/biz/HotWord.java index 6a819f3..c8a64ca 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/HotWord.java +++ b/backend/src/main/java/com/imeeting/entity/biz/HotWord.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.unisbase.entity.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -13,27 +14,38 @@ import java.util.List; @Data @EqualsAndHashCode(callSuper = true) +@Schema(description = "热词实体") @TableName(value = "biz_hot_words", autoResultMap = true) public class HotWord extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "热词ID") private Long id; + @Schema(description = "热词内容") private String word; + @Schema(description = "是否公共热词") private Integer isPublic; + @Schema(description = "创建人ID") private Long creatorId; @TableField(typeHandler = JacksonTypeHandler.class) + @Schema(description = "拼音列表") private List pinyinList; + @Schema(description = "匹配策略") private Integer matchStrategy; + @Schema(description = "热词分类") private String category; + @Schema(description = "权重") private Integer weight; + @Schema(description = "是否已同步") private Integer isSynced; + @Schema(description = "备注") private String remark; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java b/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java index b11486f..2d99048 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java +++ b/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java @@ -4,6 +4,7 @@ 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; @@ -11,28 +12,40 @@ import java.math.BigDecimal; @Data @EqualsAndHashCode(callSuper = true) +@Schema(description = "大语言模型实体") @TableName("biz_llm_models") public class LlmModel extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "模型ID") private Long id; + @Schema(description = "模型名称") private String modelName; + @Schema(description = "服务提供商") private String provider; + @Schema(description = "服务基础地址") private String baseUrl; + @Schema(description = "接口路径") private String apiPath; + @Schema(description = "接口密钥") private String apiKey; + @Schema(description = "模型编码") private String modelCode; + @Schema(description = "温度参数") private BigDecimal temperature; + @Schema(description = "Top P参数") private BigDecimal topP; + @Schema(description = "是否默认模型") private Integer isDefault; + @Schema(description = "备注") private String remark; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java index 9ca9237..5a9497d 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableField; 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; @@ -12,37 +13,53 @@ import java.time.LocalDateTime; @Data @EqualsAndHashCode(callSuper = true) +@Schema(description = "会议实体") @TableName(value = "biz_meetings", autoResultMap = true) public class Meeting extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "会议ID") private Long id; + @Schema(description = "会议标题") private String title; + @Schema(description = "会议时间") private LocalDateTime meetingTime; + @Schema(description = "参会人员") private String participants; + @Schema(description = "会议标签") private String tags; + @Schema(description = "音频地址") private String audioUrl; + @Schema(description = "音频保存状态") private String audioSaveStatus; + @Schema(description = "音频保存说明") private String audioSaveMessage; + @Schema(description = "访问密码") private String accessPassword; + @Schema(description = "创建人ID") private Long creatorId; + @Schema(description = "创建人名称") private String creatorName; + @Schema(description = "主持人用户ID") private Long hostUserId; + @Schema(description = "主持人名称") private String hostName; + @Schema(description = "最新摘要任务ID") private Long latestSummaryTaskId; @TableField(exist = false) + @Schema(description = "会议摘要内容") private String summaryContent; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscript.java b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscript.java index c21064a..ad68252 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscript.java +++ b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscript.java @@ -3,31 +3,43 @@ package com.imeeting.entity.biz; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDateTime; @Data +@Schema(description = "会议转写片段实体") @TableName("biz_meeting_transcripts") public class MeetingTranscript { @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "转写片段ID") private Long id; + @Schema(description = "会议ID") private Long meetingId; + @Schema(description = "讲话人ID") private String speakerId; + @Schema(description = "讲话人名称") private String speakerName; + @Schema(description = "讲话人标签") private String speakerLabel; + @Schema(description = "转写内容") private String content; + @Schema(description = "开始时间,单位毫秒") private Integer startTime; + @Schema(description = "结束时间,单位毫秒") private Integer endTime; + @Schema(description = "排序值") private Integer sortOrder; + @Schema(description = "创建时间") private LocalDateTime createdAt; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java b/backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java index cfcc5f2..11fa906 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java +++ b/backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java @@ -4,32 +4,44 @@ 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(value = "biz_prompt_templates", autoResultMap = true) public class PromptTemplate extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "模板ID") private Long id; + @Schema(description = "模板名称") private String templateName; + @Schema(description = "模板描述") private String description; + @Schema(description = "模板分类") private String category; + @Schema(description = "是否系统内置") private Integer isSystem; + @Schema(description = "创建人ID") private Long creatorId; @com.baomidou.mybatisplus.annotation.TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class) + @Schema(description = "业务标签列表") private java.util.List tags; + @Schema(description = "使用次数") private Integer usageCount; + @Schema(description = "提示词内容") private String promptContent; + @Schema(description = "备注") private String remark; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/PromptTemplateUserConfig.java b/backend/src/main/java/com/imeeting/entity/biz/PromptTemplateUserConfig.java index 6ed7ab7..464c685 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/PromptTemplateUserConfig.java +++ b/backend/src/main/java/com/imeeting/entity/biz/PromptTemplateUserConfig.java @@ -4,19 +4,23 @@ 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_prompt_template_user_config") public class PromptTemplateUserConfig 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 templateId; } - diff --git a/backend/src/main/java/com/imeeting/entity/biz/ScreenSaver.java b/backend/src/main/java/com/imeeting/entity/biz/ScreenSaver.java new file mode 100644 index 0000000..6614ca6 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/ScreenSaver.java @@ -0,0 +1,55 @@ +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_savers") +public class ScreenSaver extends BaseEntity { + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "屏保ID") + private Long id; + + @Schema(description = "作用域类型") + private String scopeType; + + @Schema(description = "所属用户ID") + private Long ownerUserId; + + @Schema(description = "屏保名称") + private String name; + + @Schema(description = "图片地址") + private String imageUrl; + + @Schema(description = "屏保描述") + private String description; + + @Schema(description = "展示时长,单位秒") + private Integer displayDurationSec; + + @Schema(description = "图片宽度") + private Integer imageWidth; + + @Schema(description = "图片高度") + private Integer imageHeight; + + @Schema(description = "图片格式") + private String imageFormat; + + @Schema(description = "排序值") + private Integer sortOrder; + + @Schema(description = "创建人ID") + private Long createdBy; + + @Schema(description = "备注") + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/Speaker.java b/backend/src/main/java/com/imeeting/entity/biz/Speaker.java index 779550f..974f65f 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Speaker.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Speaker.java @@ -4,32 +4,44 @@ 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_speakers") public class Speaker extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "讲话人ID") private Long id; + @Schema(description = "租户ID") private Long tenantId; + @Schema(description = "创建人ID") private Long creatorId; + @Schema(description = "关联用户ID") private Long userId; + @Schema(description = "外部讲话人ID") private String externalSpeakerId; + @Schema(description = "讲话人名称") private String name; + @Schema(description = "音频样本路径") private String voicePath; + @Schema(description = "音频扩展名") private String voiceExt; + @Schema(description = "音频大小") private Long voiceSize; + @Schema(description = "备注") private String remark; // Note: status, createdAt, updatedAt, isDeleted are in BaseEntity diff --git a/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverMapper.java new file mode 100644 index 0000000..0fc78a5 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverMapper.java @@ -0,0 +1,9 @@ +package com.imeeting.mapper.biz; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.ScreenSaver; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ScreenSaverMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterService.java b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterService.java new file mode 100644 index 0000000..7c9f97b --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterService.java @@ -0,0 +1,9 @@ +package com.imeeting.service.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyScreenSaverItemResponse; + +import java.util.List; + +public interface LegacyScreenSaverAdapterService { + List listActiveScreenSavers(); +} diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyScreenSaverAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyScreenSaverAdapterServiceImpl.java new file mode 100644 index 0000000..b1f930e --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyScreenSaverAdapterServiceImpl.java @@ -0,0 +1,28 @@ +package com.imeeting.service.android.legacy.impl; + +import com.imeeting.dto.android.legacy.LegacyScreenSaverItemResponse; +import com.imeeting.dto.biz.ScreenSaverSelectionResult; +import com.imeeting.service.android.legacy.LegacyScreenSaverAdapterService; +import com.imeeting.service.biz.ScreenSaverService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class LegacyScreenSaverAdapterServiceImpl implements LegacyScreenSaverAdapterService { + + private final ScreenSaverService screenSaverService; + + @Override + public List listActiveScreenSavers() { + ScreenSaverSelectionResult selection = screenSaverService.getActiveSelection(null); + if (selection == null || selection.getItems() == null || selection.getItems().isEmpty()) { + return List.of(); + } + return selection.getItems().stream() + .map(LegacyScreenSaverItemResponse::from) + .toList(); + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/ScreenSaverService.java b/backend/src/main/java/com/imeeting/service/biz/ScreenSaverService.java new file mode 100644 index 0000000..1fb875b --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/ScreenSaverService.java @@ -0,0 +1,27 @@ +package com.imeeting.service.biz; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.imeeting.dto.biz.ScreenSaverAdminVO; +import com.imeeting.dto.biz.ScreenSaverDTO; +import com.imeeting.dto.biz.ScreenSaverImageUploadVO; +import com.imeeting.dto.biz.ScreenSaverSelectionResult; +import com.imeeting.entity.biz.ScreenSaver; +import com.unisbase.security.LoginUser; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +public interface ScreenSaverService extends IService { + List listForAdmin(LoginUser loginUser, String keyword, Integer status, String scopeType, Long ownerUserId); + + ScreenSaver create(ScreenSaverDTO dto, LoginUser loginUser); + + ScreenSaver update(Long id, ScreenSaverDTO dto, LoginUser loginUser); + + void removeScreenSaver(Long id, LoginUser loginUser); + + ScreenSaverImageUploadVO uploadImage(MultipartFile file) throws IOException; + + ScreenSaverSelectionResult getActiveSelection(Long userId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index b9329ef..d50b719 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -447,12 +447,15 @@ public class AiTaskServiceImpl extends ServiceImpl impleme taskRecord.setRequestData(req); this.updateById(taskRecord); - String url = llmModel.getBaseUrl() + (llmModel.getApiPath() != null ? llmModel.getApiPath() : "/v1/chat/completions"); + String url = appendPath(llmModel.getBaseUrl(), + (llmModel.getApiPath() == null || llmModel.getApiPath().isBlank()) + ? "v1/chat/completions" + : llmModel.getApiPath()); String requestBody = objectMapper.writeValueAsString(req); log.info("Sending LLM summary request to url={}, body={}", url, requestBody); HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) + .uri(buildUri(url)) .header("Content-Type", "application/json; charset=UTF-8") .header("Accept", "application/json") .header("Authorization", "Bearer " + llmModel.getApiKey()) @@ -535,7 +538,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private String postJson(String url, Object body, String apiKey) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(URI.create(url)) + .uri(buildUri(url)) .header("Content-Type", "application/json"); if (apiKey != null && !apiKey.isBlank()) { builder.header("Authorization", "Bearer " + apiKey); @@ -547,7 +550,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } private String get(String url, String apiKey) throws Exception { - HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)); + HttpRequest.Builder builder = HttpRequest.newBuilder().uri(buildUri(url)); if (apiKey != null && !apiKey.isBlank()) { builder.header("Authorization", "Bearer " + apiKey); } @@ -555,10 +558,40 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } private String appendPath(String baseUrl, String path) { - if (baseUrl.endsWith("/")) { - return baseUrl + path; + String normalizedBaseUrl = normalizeUrlComponent(baseUrl, "baseUrl"); + String normalizedPath = normalizePath(path); + if (normalizedPath.isEmpty()) { + return normalizedBaseUrl; } - return baseUrl + "/" + path; + if (normalizedPath.startsWith("http://") || normalizedPath.startsWith("https://")) { + return normalizedPath; + } + if (normalizedBaseUrl.endsWith("/")) { + return normalizedBaseUrl + normalizedPath; + } + return normalizedBaseUrl + "/" + normalizedPath; + } + + private URI buildUri(String rawUrl) { + return URI.create(normalizeUrlComponent(rawUrl, "url")); + } + + private String normalizeUrlComponent(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " cannot be blank"); + } + return value.trim(); + } + + private String normalizePath(String path) { + if (path == null || path.isBlank()) { + return ""; + } + String normalized = path.trim(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + return normalized; } private String sanitizeSummaryContent(String content) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/ScreenSaverServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/ScreenSaverServiceImpl.java new file mode 100644 index 0000000..4fd0325 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/ScreenSaverServiceImpl.java @@ -0,0 +1,390 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.imeeting.dto.biz.ScreenSaverAdminVO; +import com.imeeting.dto.biz.ScreenSaverDTO; +import com.imeeting.dto.biz.ScreenSaverImageUploadVO; +import com.imeeting.dto.biz.ScreenSaverSelectionResult; +import com.imeeting.entity.biz.ScreenSaver; +import com.imeeting.mapper.biz.ScreenSaverMapper; +import com.imeeting.service.biz.ScreenSaverService; +import com.unisbase.entity.SysUser; +import com.unisbase.mapper.SysUserMapper; +import com.unisbase.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ScreenSaverServiceImpl extends ServiceImpl implements ScreenSaverService { + + private static final long GLOBAL_TENANT_ID = 0L; + private static final String SCOPE_PLATFORM = "PLATFORM"; + private static final String SCOPE_USER = "USER"; + private static final int REQUIRED_WIDTH = 1280; + private static final int REQUIRED_HEIGHT = 800; + private static final Set ALLOWED_FORMATS = Set.of("jpg", "jpeg", "png"); + + private final SysUserMapper sysUserMapper; + + @Value("${unisbase.app.upload-path}") + private String uploadPath; + + @Value("${unisbase.app.resource-prefix:/api/static/}") + private String resourcePrefix; + + @Override + public List listForAdmin(LoginUser loginUser, String keyword, Integer status, String scopeType, Long ownerUserId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .orderByAsc(ScreenSaver::getSortOrder) + .orderByDesc(ScreenSaver::getId); + if (StringUtils.hasText(keyword)) { + String trimmed = keyword.trim(); + wrapper.and(w -> w.like(ScreenSaver::getName, trimmed) + .or() + .like(ScreenSaver::getDescription, trimmed)); + } + if (status != null) { + wrapper.eq(ScreenSaver::getStatus, status); + } + if (StringUtils.hasText(scopeType)) { + wrapper.eq(ScreenSaver::getScopeType, normalizeScopeType(scopeType)); + } + if (ownerUserId != null) { + wrapper.eq(ScreenSaver::getOwnerUserId, ownerUserId); + } + return toAdminVOs(this.list(wrapper)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ScreenSaver create(ScreenSaverDTO dto, LoginUser loginUser) { + validate(dto, false, null); + ScreenSaver entity = new ScreenSaver(); + applyDto(entity, dto, false); + entity.setTenantId(GLOBAL_TENANT_ID); + entity.setCreatedBy(loginUser.getUserId()); + if (entity.getStatus() == null) { + entity.setStatus(1); + } + this.save(entity); + return entity; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ScreenSaver update(Long id, ScreenSaverDTO dto, LoginUser loginUser) { + ScreenSaver entity = requireExisting(id); + String previousImageUrl = entity.getImageUrl(); + validate(dto, true, entity); + applyDto(entity, dto, true); + entity.setTenantId(GLOBAL_TENANT_ID); + this.updateById(entity); + if (dto.getImageUrl() != null && !Objects.equals(previousImageUrl, entity.getImageUrl())) { + deleteManagedFileIfUnused(previousImageUrl, entity.getId()); + } + return entity; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeScreenSaver(Long id, LoginUser loginUser) { + ScreenSaver entity = requireExisting(id); + String imageUrl = entity.getImageUrl(); + this.removeById(entity.getId()); + deleteManagedFileIfUnused(imageUrl, entity.getId()); + } + + @Override + public ScreenSaverImageUploadVO uploadImage(MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) { + throw new RuntimeException("image file is required"); + } + String originalName = sanitizeFileName(file.getOriginalFilename()); + String format = resolveAndValidateFormat(originalName, file.getContentType()); + ImageMetadata metadata = readImageMetadata(file); + if (metadata.width() != REQUIRED_WIDTH || metadata.height() != REQUIRED_HEIGHT) { + throw new RuntimeException("image must be 1280x800"); + } + + String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; + Path targetDir = Paths.get(basePath, "screen-savers", "images"); + Files.createDirectories(targetDir); + String targetName = UUID.randomUUID() + "_" + originalName; + Path target = targetDir.resolve(targetName); + Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + + ScreenSaverImageUploadVO vo = new ScreenSaverImageUploadVO(); + vo.setImageUrl(buildResourceUrl("screen-savers/images/" + target.getFileName())); + vo.setFileSize(file.getSize()); + vo.setImageWidth(metadata.width()); + vo.setImageHeight(metadata.height()); + vo.setImageFormat(format); + return vo; + } + + @Override + public ScreenSaverSelectionResult getActiveSelection(Long userId) { + List selected = selectActiveEntities(userId); + String sourceScope = selected.isEmpty() ? SCOPE_PLATFORM : normalizeScopeType(selected.get(0).getScopeType()); + return new ScreenSaverSelectionResult(sourceScope, toAdminVOs(selected)); + } + + private List selectActiveEntities(Long userId) { + if (userId != null) { + List userScoped = listActiveByScope(SCOPE_USER, userId); + if (!userScoped.isEmpty()) { + return userScoped; + } + } + return listActiveByScope(SCOPE_PLATFORM, null); + } + + private List listActiveByScope(String scopeType, Long ownerUserId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(ScreenSaver::getStatus, 1) + .eq(ScreenSaver::getScopeType, scopeType) + .orderByAsc(ScreenSaver::getSortOrder) + .orderByDesc(ScreenSaver::getId); + if (ownerUserId == null) { + wrapper.isNull(ScreenSaver::getOwnerUserId); + } else { + wrapper.eq(ScreenSaver::getOwnerUserId, ownerUserId); + } + return this.list(wrapper); + } + + private List toAdminVOs(List entities) { + if (entities == null || entities.isEmpty()) { + return List.of(); + } + Map creatorNames = resolveCreatorNames(entities); + return entities.stream() + .map(item -> ScreenSaverAdminVO.from(item, creatorNames.get(item.getCreatedBy()))) + .toList(); + } + + private Map resolveCreatorNames(List entities) { + List creatorIds = entities.stream() + .map(ScreenSaver::getCreatedBy) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (creatorIds.isEmpty()) { + return Map.of(); + } + return sysUserMapper.selectBatchIds(creatorIds).stream() + .collect(Collectors.toMap( + SysUser::getUserId, + user -> user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(), + (left, right) -> left, + HashMap::new + )); + } + + private void validate(ScreenSaverDTO dto, boolean partial, ScreenSaver existing) { + if (dto == null) { + throw new RuntimeException("payload is required"); + } + if (!partial) { + if (!StringUtils.hasText(dto.getName())) { + throw new RuntimeException("name is required"); + } + if (!StringUtils.hasText(dto.getImageUrl())) { + throw new RuntimeException("imageUrl is required"); + } + requireImageMetadata(dto); + } + if (dto.getImageUrl() != null || dto.getImageWidth() != null || dto.getImageHeight() != null || dto.getImageFormat() != null) { + requireImageMetadata(dto); + } + + String resolvedScopeType = resolveScopeTypeForValidation(dto, existing); + Long resolvedOwnerUserId = dto.getOwnerUserId() != null ? dto.getOwnerUserId() : existing == null ? null : existing.getOwnerUserId(); + if (SCOPE_USER.equals(resolvedScopeType) && resolvedOwnerUserId == null) { + throw new RuntimeException("ownerUserId is required when scopeType is USER"); + } + if (!SCOPE_PLATFORM.equals(resolvedScopeType) && !SCOPE_USER.equals(resolvedScopeType)) { + throw new RuntimeException("scopeType only supports PLATFORM or USER"); + } + if (dto.getDisplayDurationSec() != null && (dto.getDisplayDurationSec() < 3 || dto.getDisplayDurationSec() > 3600)) { + throw new RuntimeException("displayDurationSec must be between 3 and 3600"); + } + } + + private void requireImageMetadata(ScreenSaverDTO dto) { + if (!StringUtils.hasText(dto.getImageUrl())) { + throw new RuntimeException("imageUrl is required"); + } + if (dto.getImageWidth() == null || dto.getImageHeight() == null || !StringUtils.hasText(dto.getImageFormat())) { + throw new RuntimeException("image metadata is required"); + } + if (dto.getImageWidth() != REQUIRED_WIDTH || dto.getImageHeight() != REQUIRED_HEIGHT) { + throw new RuntimeException("image must be 1280x800"); + } + if (!ALLOWED_FORMATS.contains(dto.getImageFormat().trim().toLowerCase())) { + throw new RuntimeException("imageFormat only supports jpg/jpeg/png"); + } + } + + private String resolveScopeTypeForValidation(ScreenSaverDTO dto, ScreenSaver existing) { + if (dto.getScopeType() != null) { + return normalizeScopeType(dto.getScopeType()); + } + if (existing != null && StringUtils.hasText(existing.getScopeType())) { + return normalizeScopeType(existing.getScopeType()); + } + return SCOPE_PLATFORM; + } + + private void applyDto(ScreenSaver entity, ScreenSaverDTO dto, boolean partial) { + if (!partial || dto.getScopeType() != null) { + entity.setScopeType(resolveScopeTypeForValidation(dto, entity)); + } else if (!StringUtils.hasText(entity.getScopeType())) { + entity.setScopeType(SCOPE_PLATFORM); + } + if (!partial || dto.getOwnerUserId() != null || (dto.getScopeType() != null && SCOPE_PLATFORM.equals(normalizeScopeType(dto.getScopeType())))) { + entity.setOwnerUserId(SCOPE_PLATFORM.equals(entity.getScopeType()) ? null : dto.getOwnerUserId()); + } + if (!partial || dto.getName() != null) { + entity.setName(trimToNull(dto.getName())); + } + if (!partial || dto.getImageUrl() != null) { + entity.setImageUrl(trimToNull(dto.getImageUrl())); + } + if (!partial || dto.getDescription() != null) { + entity.setDescription(trimToNull(dto.getDescription())); + } + if (!partial || dto.getDisplayDurationSec() != null) { + entity.setDisplayDurationSec(dto.getDisplayDurationSec()); + } + if (!partial || dto.getImageWidth() != null) { + entity.setImageWidth(dto.getImageWidth()); + } + if (!partial || dto.getImageHeight() != null) { + entity.setImageHeight(dto.getImageHeight()); + } + if (!partial || dto.getImageFormat() != null) { + entity.setImageFormat(trimToNull(dto.getImageFormat())); + } + if (!partial || dto.getSortOrder() != null) { + entity.setSortOrder(dto.getSortOrder()); + } + if (!partial || dto.getStatus() != null) { + entity.setStatus(dto.getStatus()); + } + if (!partial || dto.getRemark() != null) { + entity.setRemark(trimToNull(dto.getRemark())); + } + } + + private ScreenSaver requireExisting(Long id) { + ScreenSaver entity = this.getById(id); + if (entity == null) { + throw new RuntimeException("screen saver not found"); + } + return entity; + } + + private String resolveAndValidateFormat(String fileName, String contentType) { + String extension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + if (!ALLOWED_FORMATS.contains(extension)) { + throw new RuntimeException("only jpg/jpeg/png are supported"); + } + if (StringUtils.hasText(contentType)) { + String normalized = contentType.trim().toLowerCase(); + if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/jpg")) { + throw new RuntimeException("invalid image content type"); + } + } + return extension; + } + + private ImageMetadata readImageMetadata(MultipartFile file) throws IOException { + try (InputStream inputStream = file.getInputStream()) { + BufferedImage image = ImageIO.read(inputStream); + if (image == null) { + throw new RuntimeException("invalid image file"); + } + return new ImageMetadata(image.getWidth(), image.getHeight()); + } + } + + private String sanitizeFileName(String fileName) { + String value = fileName == null || fileName.isBlank() ? "image.png" : fileName; + value = value.replace('\\', '/'); + int slashIndex = value.lastIndexOf('/'); + if (slashIndex >= 0) { + value = value.substring(slashIndex + 1); + } + value = value.replaceAll("[^A-Za-z0-9._-]", "_"); + return value.isBlank() ? "image.png" : value; + } + + private String buildResourceUrl(String relativePath) { + String prefix = resourcePrefix.endsWith("/") ? resourcePrefix : resourcePrefix + "/"; + return prefix + relativePath.replace('\\', '/'); + } + + private void deleteManagedFileIfUnused(String imageUrl, Long excludeId) { + if (!StringUtils.hasText(imageUrl)) { + return; + } + long references = this.count(new LambdaQueryWrapper() + .eq(ScreenSaver::getImageUrl, imageUrl) + .ne(excludeId != null, ScreenSaver::getId, excludeId)); + if (references > 0) { + return; + } + String prefix = resourcePrefix.endsWith("/") ? resourcePrefix : resourcePrefix + "/"; + if (!imageUrl.startsWith(prefix)) { + return; + } + String relativePath = imageUrl.substring(prefix.length()); + if (!relativePath.startsWith("screen-savers/images/")) { + return; + } + Path target = Paths.get(uploadPath, relativePath.replace('/', java.io.File.separatorChar)); + try { + Files.deleteIfExists(target); + } catch (IOException ignored) { + // Ignore cleanup failure to avoid breaking main flow. + } + } + + private String normalizeScopeType(String scopeType) { + return scopeType == null ? SCOPE_PLATFORM : scopeType.trim().toUpperCase(); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private record ImageMetadata(int width, int height) { + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index d051127..cb8c258 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -20,6 +20,14 @@ spring: write-dates-as-timestamps: false time-zone: GMT+8 +springdoc: + api-docs: + enabled: true + swagger-ui: + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + mybatis-plus: configuration: map-underscore-to-camel-case: true @@ -53,6 +61,9 @@ unisbase: - /api/auth/** - /api/static/** - /api/public/meetings/** + - /v3/api-docs/** + - /swagger-ui.html + - /swagger-ui/** - /ws/** internal-auth: enabled: true diff --git a/backend/src/test/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterServiceImplTest.java b/backend/src/test/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterServiceImplTest.java new file mode 100644 index 0000000..2cb00b0 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterServiceImplTest.java @@ -0,0 +1,50 @@ +package com.imeeting.service.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyScreenSaverItemResponse; +import com.imeeting.dto.biz.ScreenSaverAdminVO; +import com.imeeting.dto.biz.ScreenSaverSelectionResult; +import com.imeeting.service.android.legacy.impl.LegacyScreenSaverAdapterServiceImpl; +import com.imeeting.service.biz.ScreenSaverService; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LegacyScreenSaverAdapterServiceImplTest { + + @Test + void listActiveScreenSaversShouldMapLegacyFields() { + ScreenSaverService screenSaverService = mock(ScreenSaverService.class); + + ScreenSaverAdminVO item = new ScreenSaverAdminVO(); + item.setId(9L); + item.setName("欢迎屏"); + item.setImageUrl("/api/static/screen-savers/images/a.jpg"); + item.setDescription("主大厅欢迎屏"); + item.setDisplayDurationSec(12); + item.setSortOrder(3); + item.setStatus(1); + item.setCreatedAt("2026-04-17T16:00:00"); + item.setUpdatedAt("2026-04-17T16:10:00"); + item.setCreatedBy(7L); + item.setCreatorUsername("admin"); + + when(screenSaverService.getActiveSelection(null)) + .thenReturn(new ScreenSaverSelectionResult("PLATFORM", List.of(item))); + + LegacyScreenSaverAdapterServiceImpl service = new LegacyScreenSaverAdapterServiceImpl(screenSaverService); + + List result = service.listActiveScreenSavers(); + + assertEquals(1, result.size()); + assertEquals(9L, result.get(0).getId()); + assertEquals("欢迎屏", result.get(0).getName()); + assertEquals("/api/static/screen-savers/images/a.jpg", result.get(0).getImageUrl()); + assertEquals(12, result.get(0).getDisplayDurationSec()); + assertEquals(1, result.get(0).getIsActive()); + assertEquals("admin", result.get(0).getCreatorUsername()); + } +} diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java index 689c07c..21ad449 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java @@ -1,72 +1,77 @@ -//package com.imeeting.service.biz.impl; -// -//import com.baomidou.mybatisplus.core.mapper.BaseMapper; -//import com.imeeting.entity.biz.AiTask; -//import com.imeeting.entity.biz.Meeting; -//import com.imeeting.mapper.biz.AiTaskMapper; -//import com.imeeting.mapper.biz.MeetingMapper; -//import com.imeeting.mapper.biz.MeetingTranscriptMapper; -//import com.imeeting.support.TaskSecurityContextRunner; -//import com.imeeting.service.biz.AiModelService; -//import com.imeeting.service.biz.HotWordService; -//import com.imeeting.service.biz.MeetingSummaryFileService; -//import com.unisbase.mapper.SysUserMapper; -//import org.junit.jupiter.api.Test; -//import org.springframework.data.redis.core.StringRedisTemplate; -//import org.springframework.test.util.ReflectionTestUtils; -// -//import java.util.HashMap; -//import java.util.Map; -// -//import static org.junit.jupiter.api.Assertions.assertEquals; -//import static org.junit.jupiter.api.Assertions.assertThrows; -//import static org.mockito.ArgumentMatchers.any; -//import static org.mockito.Mockito.mock; -//import static org.mockito.Mockito.when; -// -//class AiTaskServiceImplTest { -// -// @Test -// void processSummaryTaskShouldFailWhenSummaryModelMissing() { -// MeetingMapper meetingMapper = mock(MeetingMapper.class); -// MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); -// AiModelService aiModelService = mock(AiModelService.class); -// AiTaskMapper aiTaskMapper = mock(AiTaskMapper.class); -// -// when(aiTaskMapper.updateById(any(AiTask.class))).thenReturn(1); -// when(aiModelService.getModelById(99L, "LLM")).thenReturn(null); -// -// AiTaskServiceImpl service = new AiTaskServiceImpl( -// meetingMapper, -// transcriptMapper, -// aiModelService, -// new com.fasterxml.jackson.databind.ObjectMapper(), -// mock(SysUserMapper.class), -// mock(HotWordService.class), -// mock(StringRedisTemplate.class), -// mock(MeetingSummaryFileService.class), -// mock(TaskSecurityContextRunner.class) -// ); -// ReflectionTestUtils.setField(service, BaseMapper.class, "baseMapper", aiTaskMapper); -// -// Meeting meeting = new Meeting(); -// meeting.setId(1L); -// -// AiTask summaryTask = new AiTask(); -// summaryTask.setId(10L); -// summaryTask.setMeetingId(1L); -// summaryTask.setTaskType("SUMMARY"); -// summaryTask.setStatus(0); -// Map taskConfig = new HashMap<>(); -// taskConfig.put("summaryModelId", 99L); -// summaryTask.setTaskConfig(taskConfig); -// -// RuntimeException exception = assertThrows(RuntimeException.class, () -> -// ReflectionTestUtils.invokeMethod(service, "processSummaryTask", meeting, "speaker: test", summaryTask) -// ); -// -// assertEquals("LLM模型配置不存在", exception.getMessage()); -// assertEquals(3, summaryTask.getStatus()); -// assertEquals("LLM model not found: 99", summaryTask.getErrorMsg()); -// } -//} +package com.imeeting.service.biz.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.mapper.biz.MeetingMapper; +import com.imeeting.mapper.biz.MeetingTranscriptMapper; +import com.imeeting.service.biz.AiModelService; +import com.imeeting.service.biz.HotWordService; +import com.imeeting.service.biz.MeetingSummaryFileService; +import com.imeeting.support.TaskSecurityContextRunner; +import com.unisbase.mapper.SysUserMapper; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.URI; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +class AiTaskServiceImplTest { + + @Test + void appendPathShouldTrimWhitespaceAndNormalizeSlashBoundaries() { + AiTaskServiceImpl service = createService(); + + String url = ReflectionTestUtils.invokeMethod( + service, + "appendPath", + " http://10.100.52.43:1234/ ", + " /v1/chat/completions " + ); + + assertEquals("http://10.100.52.43:1234/v1/chat/completions", url); + } + + @Test + void appendPathShouldReturnAbsolutePathWhenApiPathIsFullUrl() { + AiTaskServiceImpl service = createService(); + + String url = ReflectionTestUtils.invokeMethod( + service, + "appendPath", + " http://10.100.52.43:1234/ ", + " https://example.com/custom-endpoint " + ); + + assertEquals("https://example.com/custom-endpoint", url); + } + + @Test + void buildUriShouldTrimWhitespaceBeforeUriCreate() { + AiTaskServiceImpl service = createService(); + + URI uri = ReflectionTestUtils.invokeMethod( + service, + "buildUri", + " http://10.100.52.43:1234/v1/chat/completions " + ); + + assertEquals("http://10.100.52.43:1234/v1/chat/completions", uri.toString()); + } + + private AiTaskServiceImpl createService() { + return new AiTaskServiceImpl( + mock(MeetingMapper.class), + mock(MeetingTranscriptMapper.class), + mock(AiModelService.class), + new ObjectMapper(), + mock(SysUserMapper.class), + mock(HotWordService.class), + mock(StringRedisTemplate.class), + mock(MeetingSummaryFileService.class), + mock(MeetingSummaryPromptAssembler.class), + mock(TaskSecurityContextRunner.class) + ); + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..602cbea 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,5 +1,5 @@ # Logs -logs +/logs *.log npm-debug.log* yarn-debug.log* diff --git a/frontend/src/api/business/screenSaver.ts b/frontend/src/api/business/screenSaver.ts new file mode 100644 index 0000000..72859c4 --- /dev/null +++ b/frontend/src/api/business/screenSaver.ts @@ -0,0 +1,82 @@ +import http from "../http"; + +export type ScreenSaverScopeType = "PLATFORM" | "USER"; + +export interface ScreenSaverVO { + id: number; + tenantId?: number; + scopeType: ScreenSaverScopeType; + ownerUserId?: number | null; + name: string; + imageUrl: string; + description?: string; + displayDurationSec: number; + imageWidth?: number; + imageHeight?: number; + imageFormat?: string; + sortOrder?: number; + status: number; + remark?: string; + createdBy?: number; + creatorUsername?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface ScreenSaverDTO { + scopeType: ScreenSaverScopeType; + ownerUserId?: number | null; + name: string; + imageUrl: string; + description?: string; + displayDurationSec: number; + imageWidth: number; + imageHeight: number; + imageFormat: string; + sortOrder?: number; + status: number; + remark?: string; +} + +export interface ScreenSaverUploadResult { + imageUrl: string; + fileSize: number; + imageWidth: number; + imageHeight: number; + imageFormat: string; +} + +export async function listScreenSavers(params?: { + keyword?: string; + status?: number; + scopeType?: ScreenSaverScopeType; + ownerUserId?: number; +}) { + const resp = await http.get("/api/screen-savers", { params }); + return resp.data.data as ScreenSaverVO[]; +} + +export async function createScreenSaver(payload: ScreenSaverDTO) { + const resp = await http.post("/api/screen-savers", payload); + return resp.data.data as ScreenSaverVO; +} + +export async function updateScreenSaver(id: number, payload: Partial) { + const resp = await http.put(`/api/screen-savers/${id}`, payload); + return resp.data.data as ScreenSaverVO; +} + +export async function deleteScreenSaver(id: number) { + const resp = await http.delete(`/api/screen-savers/${id}`); + return resp.data.data as boolean; +} + +export async function uploadScreenSaverImage(file: File) { + const formData = new FormData(); + formData.append("imageFile", file); + const resp = await http.post("/api/screen-savers/upload-image", formData, { + headers: { "Content-Type": "multipart/form-data" }, + timeout: 600000 + }); + return resp.data.data as ScreenSaverUploadResult; +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4719458..ea8f8aa 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -203,7 +203,10 @@ export async function fetchLogs(params: any) { const resp = await http.get("/sys/api/logs", { params }); return resp.data.data; } - +export async function cleanLogs(logType: string) { + const resp = await http.delete("/sys/api/logs/clean", { params: { logType } }); + return resp.data.data as boolean; +} export async function fetchLogModules() { const resp = await http.get("/sys/api/logs/modules"); return resp.data.data as string[]; diff --git a/frontend/src/components/shared/ListTable/ListTable.css b/frontend/src/components/shared/ListTable/ListTable.css index 1dcd522..295ef03 100644 --- a/frontend/src/components/shared/ListTable/ListTable.css +++ b/frontend/src/components/shared/ListTable/ListTable.css @@ -1,6 +1,11 @@ /* 列表表格容器 */ .list-table-container { width: 100%; + height: 100%; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; } /* 行选中样式 */ diff --git a/frontend/src/components/shared/MainLayout/MainLayout.css b/frontend/src/components/shared/MainLayout/MainLayout.css index e71ef55..13e2e3d 100644 --- a/frontend/src/components/shared/MainLayout/MainLayout.css +++ b/frontend/src/components/shared/MainLayout/MainLayout.css @@ -16,12 +16,17 @@ .main-content { background: var(--bg-color-secondary); - overflow-y: auto; + overflow: hidden; flex: 1; padding: 16px; + display: flex; + flex-direction: column; } .content-wrapper { padding: 0; - min-height: 100%; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; } \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 7a61d8a..72bced6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -834,18 +834,39 @@ body::after { height: 100%; display: flex; flex-direction: column; + min-height: 0; } .ant-table-wrapper .ant-table { flex: 1; min-height: 0; + display: flex; + flex-direction: column; +} + +.ant-table-wrapper .ant-table-container { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.ant-table-wrapper .ant-table-content, +.ant-table-wrapper .ant-table-body { + flex: 1; + min-height: 0; +} + +.ant-table-wrapper .ant-table-body { + overflow-y: auto !important; + max-height: none !important; } .ant-table-wrapper .ant-table-pagination.ant-pagination.app-global-pagination, .app-global-pagination.ant-pagination { margin: auto 0 0 0 !important; flex-shrink: 0; - flex: 1 1 100%; + flex: none; box-sizing: border-box; padding: 12px 24px; background: var(--app-bg-card); diff --git a/frontend/src/pages/access/roles/index.tsx b/frontend/src/pages/access/roles/index.tsx index 3280757..96190b6 100644 --- a/frontend/src/pages/access/roles/index.tsx +++ b/frontend/src/pages/access/roles/index.tsx @@ -1,4 +1,4 @@ -import { Avatar, Button, Card, Col, Drawer, Empty, Form, Input, List, Radio, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Tooltip, Tree, Typography, Row, App } from 'antd'; +import { Avatar, Button, Card, Col, Drawer, Empty, Form, Input, List, Pagination, Radio, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Tooltip, Tree, Typography, Row } from "antd"; import type { DataNode } from "antd/es/tree"; import { useEffect, useMemo, useState } from "react"; import { @@ -35,7 +35,6 @@ import { } from "@/api"; import { useDict } from "@/hooks/useDict"; import { usePermission } from "@/hooks/usePermission"; -import AppPagination from "@/components/shared/AppPagination"; import PageHeader from "@/components/shared/PageHeader"; import { getStandardPagination } from "@/utils/pagination"; import type { RoleDataScope, SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types"; @@ -167,7 +166,6 @@ function getDataScopeDescription(scopeType: string) { const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`; export default function Roles() { - const { message } = App.useApp(); const { can } = usePermission(); const { items: statusDict } = useDict("sys_common_status"); const [loading, setLoading] = useState(false); @@ -434,7 +432,7 @@ export default function Roles() {
- {"角色列表"}} variant="borderless" className="app-page__panel-card roles-side-card"> + {"角色列表"}} bordered={false} className="app-page__panel-card roles-side-card">
{isPlatformMode && ( } value={userSearchText} onChange={(event) => setUserSearchText(event.target.value)} allowClear />
- setSelectedUserKeys(keys as number[]) }} columns={[{ title: "显示名称", dataIndex: "displayName" }, { title: "用户名", dataIndex: "username" }, { title: "手机号", dataIndex: "phone" }]} /> +
setSelectedUserKeys(keys as number[]) }} columns={[{ title: "显示名称", dataIndex: "displayName" }, { title: "用户名", dataIndex: "username" }, { title: "手机号", dataIndex: "phone" }]} /> - setDrawerOpen(false)} width={420} destroyOnHidden forceRender footer={
}> + setDrawerOpen(false)} width={420} destroyOnClose footer={
}>
+ - { setCurrent(page); setPageSize(size); }} /> + { + setCurrent(page); + setPageSize(size); + }} + /> 当前进度 - {isError ? 'ERROR' : `${percent}%`} + + {isError ? 'ERROR' : `${percent}%`} + 预计剩余 - {isError ? '--' : formatEta(progress?.eta)} + + {isError ? '--' : formatEta(progress?.eta)} + 任务状态 - {isError ? '已中断' : '正常'} + + {isError ? '已中断' : '正常'} + @@ -611,6 +665,18 @@ const MeetingDetail: React.FC = () => { const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false); const [floatingTranscriptPlayerLayout, setFloatingTranscriptPlayerLayout] = useState<{ left: number; width: number } | null>(null); + const fetchData = useCallback(async (meetingId: number) => { + try { + const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]); + setMeeting(detailRes.data.data); + setTranscripts(transcriptRes.data.data || []); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }, []); + const analysis = useMemo( () => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ''), [meeting?.analysis, meeting?.summaryContent, meeting?.tags], @@ -703,14 +769,14 @@ const MeetingDetail: React.FC = () => { root?.removeEventListener('scroll', updateFloatingPlayerState); resizeObserver?.disconnect(); }; - }, [meeting?.audioUrl]); + }, [meeting?.audioUrl, meeting?.status]); useEffect(() => { if (!id) return; fetchData(Number(id)); loadAiConfigs(); loadUsers(); - }, [id]); + }, [id, fetchData]); useEffect(() => { setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item))); @@ -731,7 +797,6 @@ const MeetingDetail: React.FC = () => { setSharePasswordDraft(normalizedPassword); }, [sharePopoverOpen, meeting?.accessPassword]); - useEffect(() => { const audio = audioRef.current; if (!audio) return undefined; @@ -763,19 +828,7 @@ const MeetingDetail: React.FC = () => { audio.removeEventListener('pause', handlePause); audio.removeEventListener('ended', handleEnded); }; - }, [meeting?.audioUrl, audioPlaybackRate]); - - const fetchData = useCallback(async (meetingId: number) => { - try { - const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]); - setMeeting(detailRes.data.data); - setTranscripts(transcriptRes.data.data || []); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } - }, []); + }, [meeting?.audioUrl, audioPlaybackRate, meeting?.status]); const loadAiConfigs = async () => { try { @@ -1354,8 +1407,16 @@ const MeetingDetail: React.FC = () => {
- {meeting.status === 1 || meeting.status === 2 ? ( - fetchData(meeting.id)} /> + {meeting.status === 1 ? ( + fetchData(meeting.id)} + onProgressUpdate={(updated) => { + if (updated.status !== meeting.status) { + void fetchData(updated.id); + } + }} + /> ) : (
@@ -1618,7 +1679,15 @@ const MeetingDetail: React.FC = () => { styles={{ body: { padding: 24, height: '100%', overflowY: 'auto', overflowX: 'hidden', minWidth: 0 } }} >
- {meeting.summaryContent ? ( + {meeting.status === 2 ? ( +
+ fetchData(meeting.id)} + compact + /> +
+ ) : meeting.summaryContent ? ( isEditingSummary ? ( { ) ) : (
- {meeting.status === 2 ? ( - - - 正在重新总结... - - ) : ( - - )} +
)}
diff --git a/frontend/src/pages/business/ScreenSaverManagement.css b/frontend/src/pages/business/ScreenSaverManagement.css new file mode 100644 index 0000000..bdd1705 --- /dev/null +++ b/frontend/src/pages/business/ScreenSaverManagement.css @@ -0,0 +1,333 @@ +.screen-saver-page { + --screen-saver-border: rgba(15, 23, 42, 0.08); + --screen-saver-shadow: 0 18px 45px rgba(15, 23, 42, 0.08); + --screen-saver-accent: #1677ff; + --screen-saver-dark: #10233f; + --screen-saver-muted: #5b6b84; +} + +.screen-saver-page .screen-saver-table-card { + border: 1px solid var(--screen-saver-border); + border-radius: 20px; + background: rgba(255, 255, 255, 0.96); + box-shadow: var(--screen-saver-shadow); +} + +.screen-saver-page .screen-saver-table-card .ant-card-head { + padding: 0 24px; + min-height: 72px; +} + +.screen-saver-page .screen-saver-table-card .ant-card-head-wrapper { + align-items: flex-start; + gap: 16px; +} + +.screen-saver-page .screen-saver-table-card .ant-card-head-title { + color: var(--screen-saver-dark); + font-size: 18px; + font-weight: 700; +} + +.screen-saver-page .screen-saver-table-card .ant-card-extra { + max-width: 100%; +} + +.screen-saver-page .screen-saver-table-wrap { + flex: 1; + min-height: 0; + min-width: 0; + padding: 24px 24px 0; + display: flex; + flex-direction: column; +} + +.screen-saver-page .screen-saver-table-wrap .ant-table-wrapper, +.screen-saver-page .screen-saver-table-wrap .ant-spin-nested-loading, +.screen-saver-page .screen-saver-table-wrap .ant-spin-container, +.screen-saver-page .screen-saver-table-wrap .ant-table, +.screen-saver-page .screen-saver-table-wrap .ant-table-container { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.screen-saver-page .screen-saver-table-wrap .ant-table-body { + flex: 1; + min-height: 0; + overflow-y: auto !important; +} + +.screen-saver-page .screen-saver-preview-pill, +.screen-saver-drawer .screen-saver-preview-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(22, 119, 255, 0.08); + color: var(--screen-saver-accent); + font-size: 12px; + font-weight: 600; +} + +.screen-saver-page .screen-saver-table-visual { + display: flex; + align-items: center; + gap: 12px; +} + +.screen-saver-page .screen-saver-table-thumb { + width: 120px; + aspect-ratio: 8 / 5; + overflow: hidden; + border-radius: 14px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: linear-gradient(135deg, rgba(222, 231, 245, 0.9), rgba(241, 245, 251, 0.92)); +} + +.screen-saver-page .screen-saver-table-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.screen-saver-drawer .screen-saver-drawer__footer { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.screen-saver-drawer .screen-saver-preview-card .ant-card-body { + overflow: hidden; +} + +.screen-saver-drawer .screen-saver-preview-card .ant-space { + width: 100%; +} + +.screen-saver-drawer .screen-saver-preview-card .ant-space-item:first-child { + min-width: 0; +} + +.screen-saver-drawer .screen-saver-preview-card { + overflow: hidden; + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: linear-gradient(180deg, rgba(248, 250, 255, 0.98), rgba(255, 255, 255, 1)); +} + +.screen-saver-drawer .screen-saver-preview-stage { + position: relative; + overflow: hidden; + width: 100%; + max-width: 420px; + margin: 0 auto; + aspect-ratio: 8 / 5; + border-radius: 18px; + background: + linear-gradient(140deg, rgba(11, 24, 48, 0.92), rgba(34, 59, 102, 0.88)); +} + +.screen-saver-drawer .screen-saver-preview-stage::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.18)); + pointer-events: none; +} + +.screen-saver-drawer .screen-saver-preview-stage img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.screen-saver-crop-modal .ant-modal-content { + overflow: hidden; + border-radius: 26px; +} + +.screen-saver-crop-modal .ant-modal-body { + padding: 0; +} + +.screen-saver-crop-modal__layout { + display: grid; + grid-template-columns: minmax(0, 1.2fr) 320px; + min-height: 620px; +} + +.screen-saver-crop-modal__stage { + display: flex; + flex-direction: column; + justify-content: center; + gap: 20px; + padding: 28px; + background: + radial-gradient(circle at top left, rgba(22, 119, 255, 0.18), transparent 28%), + linear-gradient(160deg, #081326, #12284b 55%, #17315b 100%); +} + +.screen-saver-crop-modal__stage-head { + color: rgba(233, 242, 252, 0.92); +} + +.screen-saver-crop-modal__stage-head h3 { + margin: 0 0 8px; + color: #f8fbff; + font-size: 24px; + font-weight: 800; + letter-spacing: -0.04em; +} + +.screen-saver-crop-modal__stage-head p { + margin: 0; + color: rgba(223, 233, 247, 0.72); + line-height: 1.8; +} + +.screen-saver-crop-modal__viewport { + position: relative; + width: min(100%, 640px); + aspect-ratio: 8 / 5; + align-self: center; + overflow: hidden; + border-radius: 30px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(6, 13, 28, 0.78); + box-shadow: 0 24px 50px rgba(5, 12, 25, 0.4); + touch-action: none; + cursor: grab; +} + +.screen-saver-crop-modal__viewport.is-dragging { + cursor: grabbing; +} + +.screen-saver-crop-modal__viewport::before, +.screen-saver-crop-modal__viewport::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; +} + +.screen-saver-crop-modal__viewport::before { + border: 1px solid rgba(255, 255, 255, 0.16); +} + +.screen-saver-crop-modal__viewport::after { + background-image: + linear-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.14) 1px, transparent 1px); + background-size: 33.333% 50%; + opacity: 0.36; +} + +.screen-saver-crop-modal__image { + position: absolute; + top: 50%; + left: 50%; + max-width: none; + max-height: none; + user-select: none; + -webkit-user-drag: none; + transform-origin: center center; + will-change: transform; +} + +.screen-saver-crop-modal__meta { + display: flex; + justify-content: space-between; + gap: 12px; + color: rgba(232, 241, 252, 0.72); + font-size: 12px; +} + +.screen-saver-crop-modal__sidebar { + display: flex; + flex-direction: column; + gap: 18px; + padding: 28px 24px; + background: linear-gradient(180deg, #ffffff, #f7f9fd); +} + +.screen-saver-crop-modal__sidebar-card { + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 18px; + padding: 18px; + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.06); +} + +.screen-saver-crop-modal__sidebar-card h4 { + margin: 0 0 8px; + color: var(--screen-saver-dark); + font-size: 16px; + font-weight: 700; +} + +.screen-saver-crop-modal__sidebar-card p { + margin: 0; + color: var(--screen-saver-muted); + line-height: 1.75; +} + +.screen-saver-crop-modal__footer { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: auto; +} + +@media (max-width: 992px) { + .screen-saver-crop-modal__layout { + grid-template-columns: 1fr; + } + + .screen-saver-crop-modal__stage { + padding: 24px; + } + + .screen-saver-crop-modal__sidebar { + padding: 20px 24px 24px; + } +} + +@media (max-width: 768px) { + .screen-saver-page .screen-saver-table-card .ant-card-head { + padding: 0 16px; + } + + .screen-saver-page .screen-saver-table-card .ant-card-head-wrapper { + flex-direction: column; + } + + .screen-saver-page .screen-saver-table-card .ant-card-extra { + margin-inline-start: 0; + width: 100%; + } + + .screen-saver-page .screen-saver-table-card .ant-card-extra .ant-space { + width: 100%; + } + + .screen-saver-page .screen-saver-table-wrap { + padding: 16px 16px 0; + } + + .screen-saver-page .screen-saver-preview-stage { + width: min(100%, 360px); + max-height: 225px; + } + + .screen-saver-page .screen-saver-table-visual { + align-items: flex-start; + } + + .screen-saver-page .screen-saver-table-thumb { + width: 88px; + } +} diff --git a/frontend/src/pages/business/ScreenSaverManagement.tsx b/frontend/src/pages/business/ScreenSaverManagement.tsx new file mode 100644 index 0000000..059040a --- /dev/null +++ b/frontend/src/pages/business/ScreenSaverManagement.tsx @@ -0,0 +1,866 @@ +import "./ScreenSaverManagement.css"; + +import { + App, + Button, + Card, + Col, + Drawer, + Empty, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Row, + Select, + Slider, + Space, + Switch, + Table, + Tag, + Typography, + Upload, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { + DeleteOutlined, + EditOutlined, + PictureOutlined, + PlusOutlined, + ReloadOutlined, + SaveOutlined, + ScissorOutlined, + SearchOutlined, + TeamOutlined, + UploadOutlined, + UserOutlined, +} from "@ant-design/icons"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { UploadProps } from "antd"; +import AppPagination from "@/components/shared/AppPagination"; +import { + createScreenSaver, + deleteScreenSaver, + listScreenSavers, + type ScreenSaverDTO, + type ScreenSaverScopeType, + type ScreenSaverUploadResult, + type ScreenSaverVO, + updateScreenSaver, + uploadScreenSaverImage, +} from "@/api/business/screenSaver"; +import { listUsers } from "@/api"; +import type { SysUser } from "@/types"; +import dayjs from "dayjs"; + +const { Text, Title } = Typography; +const { TextArea } = Input; + +const CROP_WIDTH = 1280; +const CROP_HEIGHT = 800; +const VIEWPORT_WIDTH = 640; +const VIEWPORT_HEIGHT = 400; +const ALLOWED_TYPES = new Map([ + ["image/jpeg", "image/jpeg"], + ["image/jpg", "image/jpeg"], + ["image/png", "image/png"], +]); + +type ScreenSaverFormValues = { + scopeType: ScreenSaverScopeType; + ownerUserId?: number; + name: string; + imageUrl: string; + description?: string; + displayDurationSec: number; + imageWidth: number; + imageHeight: number; + imageFormat: string; + sortOrder?: number; + statusEnabled: boolean; + remark?: string; +}; + +type CropModalState = { + open: boolean; + src: string; + fileName: string; + mimeType: "image/jpeg" | "image/png"; +}; + +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 normalizeOwnerLabel(user?: SysUser) { + if (!user) { + return "未指定"; + } + return user.displayName || user.username || `用户 ${user.userId}`; +} + +function getImageFormatLabel(format?: string) { + if (!format) { + return "-"; + } + return format.toUpperCase(); +} + +function validateImageFile(file: File) { + const normalizedType = ALLOWED_TYPES.get(file.type.toLowerCase()); + if (!normalizedType) { + throw new Error("仅支持 jpg、jpeg、png 图片"); + } + return normalizedType; +} + +async function readFileAsDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(new Error("读取图片失败")); + reader.readAsDataURL(file); + }); +} + +function createImage(src: string) { + return new Promise((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; +}; + +function ScreenSaverCropDialog({ 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(null); + + useEffect(() => { + if (!state.open) { + return; + } + let active = true; + void createImage(state.src) + .then((image) => { + if (!active) { + return; + } + const nextMinZoom = Math.max(VIEWPORT_WIDTH / image.width, VIEWPORT_HEIGHT / 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_WIDTH) / 2); + const maxY = Math.max(0, (height - VIEWPORT_HEIGHT) / 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 = CROP_WIDTH; + canvas.height = CROP_HEIGHT; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("浏览器不支持图片裁剪"); + } + const previewScale = CROP_WIDTH / VIEWPORT_WIDTH; + const exportedWidth = image.width * zoom * previewScale; + const exportedHeight = image.height * zoom * previewScale; + const drawX = CROP_WIDTH / 2 - exportedWidth / 2 + offset.x * previewScale; + const drawY = CROP_HEIGHT / 2 - exportedHeight / 2 + offset.y * previewScale; + context.drawImage(image, drawX, drawY, exportedWidth, exportedHeight); + const extension = state.mimeType === "image/png" ? "png" : "jpg"; + const fileName = state.fileName.replace(/\.[^.]+$/, "") + `_1280x800.${extension}`; + const blob = await new Promise((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 ( + +
+
+
+

裁剪成屏保成品图

+

请在固定 8:5 取景框内调整画面位置。导出尺寸固定为 1280 × 800,安卓端将直接使用该成品图展示。

+
+
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 ? ( + 待裁剪屏保 + ) : null} +
+
+ 原图:{naturalSize.width || "-"} × {naturalSize.height || "-"} + 输出:{CROP_WIDTH} × {CROP_HEIGHT} +
+
+
+
+

缩放与构图

+

拖动画面调整主体位置。保留足够安全边距,避免标题、人物或徽标在不同设备上被视觉切边。

+
+ 缩放 + `${Math.round((value || 1) / minZoom * 100)}%` }} + onChange={handleZoomChange} + /> +
+
+
+

交付标准

+

仅支持 JPG / JPEG / PNG。导出的屏保图片会以 1280 × 800 固定尺寸上传,后端仅做格式与尺寸校验,不再重新裁剪。

+
+
+ + +
+
+
+
+ ); +} + +export default function ScreenSaverManagement() { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [records, setRecords] = useState([]); + const [users, setUsers] = useState([]); + const [searchValue, setSearchValue] = useState(""); + const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all"); + const [scopeFilter, setScopeFilter] = useState<"all" | ScreenSaverScopeType>("all"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [uploading, setUploading] = useState(false); + const [cropState, setCropState] = useState({ + open: false, + src: "", + fileName: "", + mimeType: "image/jpeg", + }); + + const userMap = useMemo(() => { + return new Map(users.map((user) => [user.userId, user])); + }, [users]); + + const loadData = async () => { + setLoading(true); + try { + const [screenSavers, userList] = await Promise.all([ + listScreenSavers(), + listUsers().catch(() => [] as SysUser[]), + ]); + setRecords(screenSavers || []); + setUsers(userList || []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadData(); + }, []); + + const filteredRecords = useMemo(() => { + const keyword = searchValue.trim().toLowerCase(); + return records.filter((item) => { + if (scopeFilter !== "all" && item.scopeType !== scopeFilter) { + return false; + } + if (statusFilter === "enabled" && item.status !== 1) { + return false; + } + if (statusFilter === "disabled" && item.status === 1) { + return false; + } + if (!keyword) { + return true; + } + const ownerName = normalizeOwnerLabel(item.ownerUserId ? userMap.get(item.ownerUserId) : undefined); + return [item.name, item.description, item.creatorUsername, ownerName, item.imageFormat] + .some((field) => String(field || "").toLowerCase().includes(keyword)); + }); + }, [records, scopeFilter, statusFilter, searchValue, userMap]); + + const pagedRecords = useMemo(() => { + const start = (page - 1) * pageSize; + return filteredRecords.slice(start, start + pageSize); + }, [filteredRecords, page, pageSize]); + + useEffect(() => { + setPage(1); + }, [searchValue, statusFilter, scopeFilter]); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ + scopeType: "PLATFORM", + displayDurationSec: 15, + sortOrder: 0, + statusEnabled: true, + imageWidth: CROP_WIDTH, + imageHeight: CROP_HEIGHT, + imageFormat: "jpg", + }); + setDrawerOpen(true); + }; + + const openEdit = (record: ScreenSaverVO) => { + setEditing(record); + form.setFieldsValue({ + scopeType: record.scopeType, + ownerUserId: record.ownerUserId || undefined, + name: record.name, + imageUrl: record.imageUrl, + description: record.description, + displayDurationSec: record.displayDurationSec, + imageWidth: record.imageWidth || CROP_WIDTH, + imageHeight: record.imageHeight || CROP_HEIGHT, + imageFormat: record.imageFormat || "jpg", + sortOrder: record.sortOrder, + statusEnabled: record.status === 1, + remark: record.remark, + }); + setDrawerOpen(true); + }; + + const handleDelete = async (record: ScreenSaverVO) => { + await deleteScreenSaver(record.id); + message.success("屏保已删除"); + await loadData(); + }; + + const handleSubmit = async () => { + const values = await form.validateFields(); + const payload: ScreenSaverDTO = { + scopeType: values.scopeType, + ownerUserId: values.scopeType === "USER" ? values.ownerUserId ?? null : null, + name: values.name.trim(), + imageUrl: values.imageUrl.trim(), + description: values.description?.trim(), + displayDurationSec: values.displayDurationSec, + imageWidth: values.imageWidth, + imageHeight: values.imageHeight, + imageFormat: values.imageFormat.trim().toLowerCase(), + sortOrder: values.sortOrder, + status: values.statusEnabled ? 1 : 0, + remark: values.remark?.trim(), + }; + + setSaving(true); + try { + if (editing) { + await updateScreenSaver(editing.id, payload); + message.success("屏保已更新"); + } else { + await createScreenSaver(payload); + message.success("屏保已创建"); + } + setDrawerOpen(false); + await loadData(); + } finally { + setSaving(false); + } + }; + + const openCropper = async (file: File) => { + const mimeType = validateImageFile(file); + const src = await readFileAsDataUrl(file); + setCropState({ + open: true, + src, + fileName: file.name, + mimeType, + }); + }; + + const handleUploadCroppedImage = async (file: File) => { + setUploading(true); + try { + const result: ScreenSaverUploadResult = await uploadScreenSaverImage(file); + form.setFieldsValue({ + imageUrl: result.imageUrl, + imageWidth: result.imageWidth, + imageHeight: result.imageHeight, + imageFormat: result.imageFormat, + }); + setCropState({ open: false, src: "", fileName: "", mimeType: "image/jpeg" }); + message.success("屏保图片已上传"); + } finally { + setUploading(false); + } + }; + + const uploadProps: UploadProps = { + showUploadList: false, + beforeUpload: (file) => { + void openCropper(file as File).catch((error: Error) => { + message.error(error.message || "图片处理失败"); + }); + return Upload.LIST_IGNORE; + }, + }; + + const handleToggleStatus = async (record: ScreenSaverVO, checked: boolean) => { + await updateScreenSaver(record.id, { status: checked ? 1 : 0 }); + message.success(checked ? "屏保已启用" : "屏保已停用"); + await loadData(); + }; + + const columns: ColumnsType = [ + { + title: "屏保画面", + key: "visual", + width: 330, + render: (_, record) => ( +
+
+ {record.imageUrl ? {record.name} : null} +
+ + {record.name} + {record.description || "暂无描述"} + + {getImageFormatLabel(record.imageFormat)} + {record.imageWidth || CROP_WIDTH} × {record.imageHeight || CROP_HEIGHT} + + +
+ ), + }, + { + title: "作用域", + key: "scope", + width: 220, + render: (_, record) => ( + + {record.scopeType === "USER" ? ( + }>用户级 + ) : ( + }>平台级 + )} + + {record.scopeType === "USER" + ? `归属:${normalizeOwnerLabel(record.ownerUserId ? userMap.get(record.ownerUserId) : undefined)}` + : "全平台共用"} + + + ), + }, + { + title: "播放与状态", + key: "status", + width: 210, + render: (_, record) => ( + + {record.displayDurationSec} 秒 / 张 + 排序值:{record.sortOrder ?? 0} + void handleToggleStatus(record, checked)} /> + + ), + }, + { + title: "创建信息", + key: "creator", + width: 180, + render: (_, record) => { + const timeValue = record.updatedAt || record.createdAt; + return ( + + {record.creatorUsername || "-"} + + {timeValue ? dayjs(timeValue).format("YYYY-MM-DD HH:mm:ss") : "-"} + + + ); + }, + }, + { + title: "操作", + key: "action", + width: 140, + fixed: "right", + render: (_, record) => ( + + + + + )} + > +
+
, + }} + scroll={{ x: 1100, y: "100%" }} + /> + + { + setPage(nextPage); + setPageSize(nextSize); + }} + /> + + + setDrawerOpen(false)} + width={760} + destroyOnHidden + forceRender + className="screen-saver-drawer" + styles={{ body: { padding: 24 } }} + footer={( +
+ + +
+ )} + > + + +
+ + + + + + + + + + + + + + + ({ + value: user.userId, + label: `${normalizeOwnerLabel(user)} / ${user.username}`, + }))} + /> + + + + + +
+ +
+ 屏保成片预览 + 固定 8:5 构图,导出 1280 × 800。上传后后端只做校验与存储。 +
+ + + +
+
+ {currentImageUrl ? ( + 屏保预览 + ) : ( +
+ + + 请选择图片并完成裁剪后上传 + +
+ )} +
+ + 输出规格 1280 × 800 + 当前作用域 {currentScopeType === "USER" ? "用户级" : "平台级"} + {currentScopeType === "USER" && currentOwnerUserId ? ( + + 归属 {normalizeOwnerLabel(userMap.get(currentOwnerUserId))} + + ) : null} + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +