feat: 添加屏保管理页面和相关功能

- 在前端添加 `ScreenSaverManagement` 页面,支持屏保的创建、编辑、删除和状态切换
- 在 `AiModelController` 中添加 Swagger 注解以描述 API 操作
- 在 `pom.xml` 中添加 `springdoc-openapi-starter-webmvc-ui` 依赖
- 更新 `role-permission` 和 `tenants` 页面的分页逻辑
- 在 `sys-params` 页面中使用 `ListTable` 组件并优化分页显示
dev_na
chenhao 2026-04-20 11:30:26 +08:00
parent b1fd9de87d
commit 6107e611f4
68 changed files with 2989 additions and 324 deletions

View File

@ -161,6 +161,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>
<build>

View File

@ -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));
}
}

View File

@ -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<AndroidCreateRealtimeMeetingVO> 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<RealtimeMeetingSessionStatusVO> 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<List<MeetingTranscriptVO>> 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<RealtimeMeetingSessionStatusVO> 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<Boolean> 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<AndroidRealtimeGrpcSessionVO> openRealtimeGrpcSession(@PathVariable Long id,
HttpServletRequest request,

View File

@ -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<AndroidScreenSaverCatalogVO> 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);
}
}

View File

@ -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<LegacyLoginResponse> login(@Valid @RequestBody LoginRequest request) {
TokenResponse tokenResponse = null;
@ -43,6 +47,7 @@ public class LegacyAuthController {
));
}
@Operation(summary = "兼容刷新令牌")
@PostMapping("/refresh")
public LegacyApiResponse<LegacyRefreshTokenResponse> refresh(@RequestBody(required = false) RefreshRequest request,
@RequestHeader(value = "Authorization", required = false) String authorization,

View File

@ -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<LegacyClientDownloadResponse> latestByPlatform(@RequestParam(value = "platform_code", required = false) String platformCode,
@RequestParam(value = "platform_type", required = false) String platformType,

View File

@ -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<List<LegacyExternalAppItemResponse>> active(@RequestParam(value = "is_active", required = false) Integer ignoredIsActive) {
return LegacyApiResponse.ok(legacyCatalogAdapterService.listActiveExternalApps());

View File

@ -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<List<LegacyLlmModelItemResponse>> activeModels() {

View File

@ -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<LegacyMeetingCreateResponse> 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<Void> uploadAudio(@RequestParam("meeting_id") Long meetingId,
@ -150,6 +155,7 @@ public class LegacyMeetingController {
return LegacyApiResponse.ok("上传成功", null);
}
@Operation(summary = "兼容分页查询会议")
@GetMapping
@PreAuthorize("isAuthenticated()")
public LegacyApiResponse<LegacyMeetingListResponse> 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<LegacyMeetingAccessPasswordResponse> 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<Void> delete(@PathVariable Long meetingId) {

View File

@ -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<LegacyPromptListResponse> activePrompts(@PathVariable String scene) {

View File

@ -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<List<LegacyScreenSaverItemResponse>> active() {
return LegacyApiResponse.ok(legacyScreenSaverAdapterService.listActiveScreenSavers());
}
}

View File

@ -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<AiModelVO> save(@RequestBody AiModelDTO dto) {
return ApiResponse.ok(aiModelService.saveModel(dto));
}
@Operation(summary = "更新AI模型")
@PutMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<AiModelVO> 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<Boolean> 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<PageResult<List<AiModelVO>>> 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<List<String>> 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<AiLocalProfileVO> 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<AiModelVO> getDefault(@RequestParam String type) {

View File

@ -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<Map<String, Object>> 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<ClientDownload> create(@RequestBody ClientDownloadDTO dto) {
return ApiResponse.ok(clientDownloadService.create(dto, currentLoginUser()));
}
@Operation(summary = "修改客户端下载包")
@PutMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<ClientDownload> update(@PathVariable Long id, @RequestBody ClientDownloadDTO dto) {
return ApiResponse.ok(clientDownloadService.update(id, dto, currentLoginUser()));
}
@Operation(summary = "删除客户端下载包")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
@ -65,6 +72,7 @@ public class ClientDownloadController {
return ApiResponse.ok(true);
}
@Operation(summary = "上传客户端安装包")
@PostMapping("/upload")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> upload(@RequestParam("platformCode") String platformCode,

View File

@ -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<Map<String, Object>> 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<List<MeetingVO>> getRecent() {

View File

@ -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<Map<String, Object>>> 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<ExternalApp> create(@RequestBody ExternalAppDTO dto) {
return ApiResponse.ok(externalAppService.create(dto, currentLoginUser()));
}
@Operation(summary = "修改外部应用")
@PutMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<ExternalApp> update(@PathVariable Long id, @RequestBody ExternalAppDTO dto) {
return ApiResponse.ok(externalAppService.update(id, dto, currentLoginUser()));
}
@Operation(summary = "删除外部应用")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> 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<Map<String, Object>> uploadApk(@RequestParam("apkFile") MultipartFile apkFile) throws IOException {
return ApiResponse.ok(externalAppService.uploadApk(apkFile));
}
@Operation(summary = "上传外部应用图标")
@PostMapping("/upload-icon")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> uploadIcon(@RequestParam("iconFile") MultipartFile iconFile) throws IOException {

View File

@ -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<HotWordVO> 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<HotWordVO> 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<Boolean> 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<PageResult<List<HotWordVO>>> page(
@ -136,6 +143,7 @@ public class HotWordController {
return ApiResponse.ok(result);
}
@Operation(summary = "生成热词拼音")
@GetMapping("/pinyin")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<String>> getPinyin(@RequestParam String word) {

View File

@ -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<Map<String, Object>> getProgress(@PathVariable Long id) {
@ -125,6 +129,7 @@ public class MeetingController {
return ApiResponse.ok(fallback);
}
@Operation(summary = "上传会议音频")
@PostMapping("/upload")
@PreAuthorize("isAuthenticated()")
public ApiResponse<String> 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<MeetingVO> create(@Valid @RequestBody CreateMeetingCommand command) {
@ -154,6 +160,7 @@ public class MeetingController {
));
}
@Operation(summary = "创建实时会议")
@PostMapping("/realtime/start")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingVO> createRealtime(@Valid @RequestBody CreateRealtimeMeetingCommand command) {
@ -167,6 +174,7 @@ public class MeetingController {
));
}
@Operation(summary = "分页查询会议")
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<MeetingVO>>> page(
@ -190,6 +198,7 @@ public class MeetingController {
));
}
@Operation(summary = "查询会议详情")
@GetMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingVO> 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<byte[]> 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<List<MeetingTranscriptVO>> 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<RealtimeMeetingSessionStatusVO> 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<Map<Long, RealtimeMeetingSessionStatusVO>> getRealtimeSessionStatuses(@RequestBody List<Long> ids) {
@ -265,6 +278,7 @@ public class MeetingController {
return ApiResponse.ok(result);
}
@Operation(summary = "追加实时转写片段")
@PostMapping("/{id}/realtime/transcripts")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> appendRealtimeTranscripts(@PathVariable Long id, @RequestBody List<RealtimeTranscriptItemDTO> items) {
@ -275,6 +289,7 @@ public class MeetingController {
return ApiResponse.ok(true);
}
@Operation(summary = "暂停实时会议")
@PostMapping("/{id}/realtime/pause")
@PreAuthorize("isAuthenticated()")
public ApiResponse<RealtimeMeetingSessionStatusVO> 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<RealtimeSocketSessionVO> openRealtimeSocketSession(@PathVariable Long id,
@ -304,6 +320,7 @@ public class MeetingController {
));
}
@Operation(summary = "完成实时会议")
@PostMapping("/{id}/realtime/complete")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> 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<Boolean> 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<Boolean> updateTranscript(@PathVariable Long id,
@ -342,6 +361,7 @@ public class MeetingController {
return ApiResponse.ok(true);
}
@Operation(summary = "更新参会人员")
@PutMapping("/{id}/participants")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> 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<Boolean> 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<Boolean> retryTranscription(@PathVariable Long id) {
@ -376,6 +398,7 @@ public class MeetingController {
return ApiResponse.ok(true);
}
@Operation(summary = "更新会议基础信息")
@PutMapping("/{id}/basic")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> 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<Boolean> 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<Boolean> delete(@PathVariable Long id) {

View File

@ -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<MeetingPreviewAccessVO> getPreviewAccess(@PathVariable Long id) {
try {
@ -35,6 +39,7 @@ public class MeetingPublicPreviewController {
}
}
@Operation(summary = "获取会议公开预览内容")
@GetMapping("/{id}/preview")
public ApiResponse<PublicMeetingPreviewVO> getPreview(@PathVariable Long id,
@RequestParam(required = false) String accessPassword) {

View File

@ -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<PromptTemplateVO> 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<PromptTemplateVO> 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<Boolean> 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<Boolean> 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<PageResult<List<PromptTemplateVO>>> page(

View File

@ -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<ScreenSaverAdminVO>> 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<ScreenSaver> create(@RequestBody ScreenSaverDTO dto) {
return ApiResponse.ok(screenSaverService.create(dto, currentLoginUser()));
}
@Operation(summary = "修改屏保")
@PutMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<ScreenSaver> update(@PathVariable Long id, @RequestBody ScreenSaverDTO dto) {
return ApiResponse.ok(screenSaverService.update(id, dto, currentLoginUser()));
}
@Operation(summary = "删除屏保")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
screenSaverService.removeScreenSaver(id, currentLoginUser());
return ApiResponse.ok(true);
}
@Operation(summary = "上传屏保图片")
@PostMapping("/upload-image")
@PreAuthorize("isAuthenticated()")
public ApiResponse<ScreenSaverImageUploadVO> uploadImage(@RequestParam("imageFile") MultipartFile imageFile) throws IOException {
return ApiResponse.ok(screenSaverService.uploadImage(imageFile));
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -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<SpeakerVO> 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<PageResult<List<SpeakerVO>>> 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<SpeakerVO>> list() {
@ -56,6 +62,7 @@ public class SpeakerController {
return ApiResponse.ok(speakerService.listVisible(loginUser));
}
@Operation(summary = "删除讲话人")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {

View File

@ -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<AndroidScreenSaverItemVO> items;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<ScreenSaverAdminVO> items;
}

View File

@ -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<String, Object> requestData;
@TableField(typeHandler = JacksonTypeHandler.class)
@Schema(description = "任务响应结果")
private Map<String, Object> responseData;
@TableField(typeHandler = JacksonTypeHandler.class)
@Schema(description = "任务运行配置")
private Map<String, Object> taskConfig;
@Schema(description = "结果文件路径")
private String resultFilePath;
@Schema(description = "错误信息")
private String errorMsg;
@Schema(description = "开始时间")
private LocalDateTime startedAt;
@Schema(description = "完成时间")
private LocalDateTime completedAt;
}

View File

@ -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<String, Object> mediaConfig;
@Schema(description = "是否默认模型")
private Integer isDefault;
@Schema(description = "备注")
private String remark;
}

View File

@ -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;
}

View File

@ -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<String, Object> appInfo;
@Schema(description = "图标地址")
private String iconUrl;
@Schema(description = "应用描述")
private String description;
@Schema(description = "排序值")
private Integer sortOrder;
@Schema(description = "创建人ID")
private Long createdBy;
}

View File

@ -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<String> 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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<String> tags;
@Schema(description = "使用次数")
private Integer usageCount;
@Schema(description = "提示词内容")
private String promptContent;
@Schema(description = "备注")
private String remark;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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<ScreenSaver> {
}

View File

@ -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<LegacyScreenSaverItemResponse> listActiveScreenSavers();
}

View File

@ -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<LegacyScreenSaverItemResponse> 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();
}
}

View File

@ -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<ScreenSaver> {
List<ScreenSaverAdminVO> 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);
}

View File

@ -447,12 +447,15 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> 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) {

View File

@ -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<ScreenSaverMapper, ScreenSaver> 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<String> 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<ScreenSaverAdminVO> listForAdmin(LoginUser loginUser, String keyword, Integer status, String scopeType, Long ownerUserId) {
LambdaQueryWrapper<ScreenSaver> wrapper = new LambdaQueryWrapper<ScreenSaver>()
.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<ScreenSaver> selected = selectActiveEntities(userId);
String sourceScope = selected.isEmpty() ? SCOPE_PLATFORM : normalizeScopeType(selected.get(0).getScopeType());
return new ScreenSaverSelectionResult(sourceScope, toAdminVOs(selected));
}
private List<ScreenSaver> selectActiveEntities(Long userId) {
if (userId != null) {
List<ScreenSaver> userScoped = listActiveByScope(SCOPE_USER, userId);
if (!userScoped.isEmpty()) {
return userScoped;
}
}
return listActiveByScope(SCOPE_PLATFORM, null);
}
private List<ScreenSaver> listActiveByScope(String scopeType, Long ownerUserId) {
LambdaQueryWrapper<ScreenSaver> wrapper = new LambdaQueryWrapper<ScreenSaver>()
.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<ScreenSaverAdminVO> toAdminVOs(List<ScreenSaver> entities) {
if (entities == null || entities.isEmpty()) {
return List.of();
}
Map<Long, String> creatorNames = resolveCreatorNames(entities);
return entities.stream()
.map(item -> ScreenSaverAdminVO.from(item, creatorNames.get(item.getCreatedBy())))
.toList();
}
private Map<Long, String> resolveCreatorNames(List<ScreenSaver> entities) {
List<Long> 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<ScreenSaver>()
.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) {
}
}

View File

@ -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

View File

@ -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<LegacyScreenSaverItemResponse> 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());
}
}

View File

@ -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<String, Object> 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)
);
}
}

2
frontend/.gitignore vendored
View File

@ -1,5 +1,5 @@
# Logs
logs
/logs
*.log
npm-debug.log*
yarn-debug.log*

View File

@ -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<ScreenSaverDTO>) {
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;
}

View File

@ -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[];

View File

@ -1,6 +1,11 @@
/* 列表表格容器 */
.list-table-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* 行选中样式 */

View File

@ -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;
}

View File

@ -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);

View File

@ -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() {
<div className="roles-layout">
<Row gutter={24} className="roles-layout__row">
<Col span={7} className="roles-layout__side">
<Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} variant="borderless" className="app-page__panel-card roles-side-card">
<Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} bordered={false} className="app-page__panel-card roles-side-card">
<div className="role-search-panel">
{isPlatformMode && (
<Select
@ -494,11 +492,8 @@ export default function Roles() {
/>
</div>
<div className="role-list-pagination">
<AppPagination
current={rolePage.current}
pageSize={rolePage.size}
total={rolePage.total}
onChange={handleRolePageChange}
<Pagination
{...getStandardPagination(rolePage.total, rolePage.current, rolePage.size, handleRolePageChange, { size: "small", showSizeChanger: true, pageSizeOptions: ["10", "20", "50"] })}
/>
</div>
</Card>
@ -508,19 +503,12 @@ export default function Roles() {
{selectedRole ? (
<Card
className="app-page__panel-card roles-detail-card"
variant="borderless"
bordered={false}
title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>}
extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handlePrimarySave} disabled={saveDisabled} style={{ borderRadius: "6px" }}>{saveLabel}</Button>}
>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as RoleTabKey)}
className="role-detail-tabs"
items={[
{
key: "permissions",
label: <Space><KeyOutlined />{"功能权限"}</Space>,
children: (
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as RoleTabKey)} className="role-detail-tabs">
<Tabs.TabPane tab={<Space><KeyOutlined />{"功能权限"}</Space>} key="permissions">
<div className="role-detail-pane">
<div className="permission-tree-wrapper">
<Tree
@ -539,12 +527,8 @@ export default function Roles() {
/>
</div>
</div>
)
},
{
key: "dataScope",
label: <Space><ApartmentOutlined />{"数据权限"}</Space>,
children: (
</Tabs.TabPane>
<Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
<div className="role-detail-pane">
<div style={{ marginBottom: 16 }}>
<Radio.Group value={dataScopeType} onChange={(event) => setDataScopeType(event.target.value)} optionType="button" buttonStyle="solid">
@ -572,12 +556,8 @@ export default function Roles() {
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
)}
</div>
)
},
{
key: "users",
label: <Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>,
children: (
</Tabs.TabPane>
<Tabs.TabPane tab={<Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>} key="users">
<div className="role-detail-pane">
<div className="role-members-toolbar">
<Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
@ -588,7 +568,7 @@ export default function Roles() {
size="small"
loading={loadingUsers}
dataSource={roleUsers}
pagination={getStandardPagination(roleUsers.length, 1, 10)}
pagination={{ ...getStandardPagination(roleUsers.length, 1, 10, undefined, { size: "small", showSizeChanger: false }), current: undefined }}
columns={[
{
title: "用户信息",
@ -617,10 +597,8 @@ export default function Roles() {
]}
/>
</div>
)
}
]}
/>
</Tabs.TabPane>
</Tabs>
</Card>
) : (
<div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
@ -629,14 +607,14 @@ export default function Roles() {
</Row>
</div>
<Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnHidden>
<Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnClose>
<div style={{ marginBottom: 16 }}>
<Input placeholder="搜索用户名或显示名称" prefix={<SearchOutlined />} value={userSearchText} onChange={(event) => setUserSearchText(event.target.value)} allowClear />
</div>
<Table rowKey="userId" size="small" dataSource={filteredModalUsers} pagination={getStandardPagination(filteredModalUsers.length, 1, 6)} rowSelection={{ selectedRowKeys: selectedUserKeys, onChange: (keys) => setSelectedUserKeys(keys as number[]) }} columns={[{ title: "显示名称", dataIndex: "displayName" }, { title: "用户名", dataIndex: "username" }, { title: "手机号", dataIndex: "phone" }]} />
<Table rowKey="userId" size="small" dataSource={filteredModalUsers} pagination={{ ...getStandardPagination(filteredModalUsers.length, 1, 6, undefined, { size: "small", showSizeChanger: false }), current: undefined }} rowSelection={{ selectedRowKeys: selectedUserKeys, onChange: (keys) => setSelectedUserKeys(keys as number[]) }} columns={[{ title: "显示名称", dataIndex: "displayName" }, { title: "用户名", dataIndex: "username" }, { title: "手机号", dataIndex: "phone" }]} />
</Modal>
<Drawer title={editing ? "编辑角色" : "新增角色"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{"取消"}</Button><Button type="primary" loading={saving} onClick={() => void submitBasic()}>{"保存"}</Button></div>}>
<Drawer title={editing ? "编辑角色" : "新增角色"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{"取消"}</Button><Button type="primary" loading={saving} onClick={() => void submitBasic()}>{"保存"}</Button></div>}>
<Form form={form} layout="vertical">
<Form.Item label="租户" name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}>
<Select options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} disabled={!!editing} />

View File

@ -1,15 +1,15 @@
import { Avatar, Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, TreeSelect, Typography, Upload, message,App } from "antd";
import { Avatar, Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Tag, TreeSelect, Typography, Upload, message } from "antd";
import type { DefaultOptionType } from "antd/es/select";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApartmentOutlined, DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined, SearchOutlined, ShopOutlined, UploadOutlined, UserOutlined } from "@ant-design/icons";
import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants, listUserRoles, listUsers, saveUserRoles, updateUser, uploadPlatformAsset } from "@/api";
import AppPagination from "@/components/shared/AppPagination";
import { createUser, deleteUser, getUserDetail, listOrgs, listRoles, listTenants, listUserRoles, listUsers, updateUser, uploadPlatformAsset } from "@/api";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import ListTable from "@/components/shared/ListTable/ListTable";
import AppPagination from "@/components/shared/AppPagination";
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
import { getStandardPagination } from "@/utils/pagination";
import type { SysOrg, SysRole, SysTenant, SysUser } from "@/types";
import "./index.less";
@ -64,7 +64,6 @@ function MembershipOrgSelect({ fieldProps, name, tenantId }: { fieldProps: any;
}
export default function Users() {
const { message } = App.useApp();
const { t } = useTranslation();
const { can } = usePermission();
const { items: statusDict } = useDict("sys_common_status");
@ -398,9 +397,17 @@ export default function Users() {
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
<Table rowKey="userId" columns={columns} dataSource={filteredData} loading={loading} size="middle" scroll={{ x: "max-content" }} pagination={false} />
<ListTable rowKey="userId" columns={columns} dataSource={filteredData} loading={loading} scroll={{ y: "calc(100vh - 420px)" }} pagination={false} />
</div>
<AppPagination current={current} pageSize={pageSize} total={filteredData.length} onChange={(page, size) => { setCurrent(page); setPageSize(size); }} />
<AppPagination
current={current}
pageSize={pageSize}
total={filteredData.length}
onChange={(page, size) => {
setCurrent(page);
setPageSize(size);
}}
/>
</Card>
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>

View File

@ -18,6 +18,7 @@ import { useTranslation } from "react-i18next";
import { ClusterOutlined, KeyOutlined, SafetyCertificateOutlined, SaveOutlined, SearchOutlined } from "@ant-design/icons";
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "@/api";
import PageHeader from "@/components/shared/PageHeader";
import { getStandardPagination } from "@/utils/pagination";
import type { SysPermission, SysRole } from "@/types";
const { Text } = Typography;
@ -219,7 +220,7 @@ export default function RolePermissionBinding() {
onClick: () => setSelectedRoleId(record.roleId),
className: "cursor-pointer"
})}
pagination={{ pageSize: 10, showTotal: (total) => t("common.total", { total }) }}
pagination={{ ...getStandardPagination(filteredRoles.length, 1, 10, undefined, { showSizeChanger: false }), current: undefined }}
columns={[
{
title: t("roles.roleName"),

View File

@ -18,6 +18,7 @@ import { listRoles, listUserRoles, listUsers, saveUserRoles } from "@/api";
import { SaveOutlined, SearchOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
import type { SysRole, SysUser } from "@/types";
import PageHeader from "@/components/shared/PageHeader";
import { getStandardPagination } from "@/utils/pagination";
const { Text } = Typography;
@ -139,7 +140,7 @@ export default function UserRoleBinding() {
onClick: () => setSelectedUserId(record.userId),
className: "cursor-pointer"
})}
pagination={{ pageSize: 10, showTotal: (total) => t("common.total", { total }) }}
pagination={{ ...getStandardPagination(filteredUsers.length, 1, 10, undefined, { showSizeChanger: false }), current: undefined }}
columns={[
{
title: t("users.userInfo"),

View File

@ -298,16 +298,29 @@ function formatPlayerTime(seconds: number) {
return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`;
}
const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void }> = ({ meetingId, onComplete }) => {
const MeetingProgressDisplay: React.FC<{
meetingId: number;
onComplete: () => void;
onProgressUpdate?: (meeting: MeetingVO) => void;
compact?: boolean;
}> = ({ meetingId, onComplete, onProgressUpdate, compact }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
useEffect(() => {
const fetchProgress = async () => {
try {
const res = await getMeetingProgress(meetingId);
if (res.data?.data) {
setProgress(res.data.data);
if (res.data.data.percent === 100) {
const [progressRes, detailRes] = await Promise.all([
getMeetingProgress(meetingId),
getMeetingDetail(meetingId),
]);
if (detailRes.data?.data) {
onProgressUpdate?.(detailRes.data.data);
}
if (progressRes.data?.data) {
setProgress(progressRes.data.data);
if (progressRes.data.data.percent === 100) {
onComplete();
}
}
@ -319,7 +332,7 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo
fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meetingId, onComplete]);
}, [meetingId, onComplete, onProgressUpdate]);
const percent = progress?.percent || 0;
const isError = percent < 0;
@ -332,6 +345,41 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo
return remainSeconds > 0 ? `${minutes}${remainSeconds}` : `${minutes} 分钟`;
};
if (compact) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
textAlign: 'center',
}}
>
<Title level={4} style={{ marginBottom: 24 }}>
AI
</Title>
<Progress
type="circle"
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : { '0%': '#6c73ff', '100%': '#8d63ff' }}
size={140}
strokeWidth={10}
/>
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 16, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 4 }}>
{progress?.message || '正在分析内容...'}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{isError ? '--' : formatEta(progress?.eta)}
</Text>
</div>
</div>
);
}
return (
<div
style={{
@ -368,19 +416,25 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>{isError ? 'ERROR' : `${percent}%`}</Title>
<Title level={4} style={{ margin: 0 }}>
{isError ? 'ERROR' : `${percent}%`}
</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>{isError ? '--' : formatEta(progress?.eta)}</Title>
<Title level={4} style={{ margin: 0 }}>
{isError ? '--' : formatEta(progress?.eta)}
</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>{isError ? '已中断' : '正常'}</Title>
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>
{isError ? '已中断' : '正常'}
</Title>
</Space>
</Col>
</Row>
@ -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 = () => {
</Card>
<div style={{ flex: 1, minHeight: 0 }}>
{meeting.status === 1 || meeting.status === 2 ? (
<MeetingProgressDisplay meetingId={meeting.id} onComplete={() => fetchData(meeting.id)} />
{meeting.status === 1 ? (
<MeetingProgressDisplay
meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)}
onProgressUpdate={(updated) => {
if (updated.status !== meeting.status) {
void fetchData(updated.id);
}
}}
/>
) : (
<Row gutter={24} style={{ height: '100%' }}>
<Col xs={24} lg={14} style={{ height: '100%' }}>
@ -1618,7 +1679,15 @@ const MeetingDetail: React.FC = () => {
styles={{ body: { padding: 24, height: '100%', overflowY: 'auto', overflowX: 'hidden', minWidth: 0 } }}
>
<div ref={summaryPdfRef} className="markdown-shell">
{meeting.summaryContent ? (
{meeting.status === 2 ? (
<div style={{ textAlign: 'center' }}>
<MeetingProgressDisplay
meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)}
compact
/>
</div>
) : meeting.summaryContent ? (
isEditingSummary ? (
<Input.TextArea
value={summaryDraft}
@ -1632,14 +1701,7 @@ const MeetingDetail: React.FC = () => {
)
) : (
<div style={{ textAlign: 'center', marginTop: 100 }}>
{meeting.status === 2 ? (
<Space direction="vertical">
<LoadingOutlined style={{ fontSize: 24 }} spin />
<Text type="secondary">...</Text>
</Space>
) : (
<Empty description="暂无总结" />
)}
</div>
)}
</div>

View File

@ -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;
}
}

View File

@ -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<string, "image/jpeg" | "image/png">([
["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<string>((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<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("图片加载失败"));
image.src = src;
});
}
type CropDialogProps = {
state: CropModalState;
onCancel: () => void;
onConfirm: (file: File) => Promise<void>;
};
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<DragState | null>(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<Blob | null>((resolve) => {
if (state.mimeType === "image/png") {
canvas.toBlob(resolve, "image/png");
} else {
canvas.toBlob(resolve, "image/jpeg", 0.92);
}
});
if (!blob) {
throw new Error("导出裁剪图片失败");
}
return new File([blob], fileName, { type: state.mimeType });
};
const handleConfirm = async () => {
setLoading(true);
try {
const file = await exportCroppedFile();
await onConfirm(file);
} catch (error) {
message.error(error instanceof Error ? error.message : "裁剪上传失败");
} finally {
setLoading(false);
}
};
return (
<Modal
open={state.open}
onCancel={loading ? undefined : onCancel}
footer={null}
width={1100}
centered
destroyOnHidden
className="screen-saver-crop-modal"
maskClosable={!loading}
closable={!loading}
>
<div className="screen-saver-crop-modal__layout">
<div className="screen-saver-crop-modal__stage">
<div className="screen-saver-crop-modal__stage-head">
<h3></h3>
<p> 8:5 1280 × 800使</p>
</div>
<div
className={`screen-saver-crop-modal__viewport${dragging ? " is-dragging" : ""}`}
onMouseDown={(event) => beginDrag(event.clientX, event.clientY)}
onMouseMove={(event) => updateDrag(event.clientX, event.clientY)}
onMouseUp={endDrag}
onMouseLeave={endDrag}
onTouchStart={(event) => {
const touch = event.touches[0];
if (touch) {
beginDrag(touch.clientX, touch.clientY);
}
}}
onTouchMove={(event) => {
const touch = event.touches[0];
if (touch) {
updateDrag(touch.clientX, touch.clientY);
}
}}
onTouchEnd={endDrag}
>
{state.src ? (
<img
src={state.src}
alt="待裁剪屏保"
className="screen-saver-crop-modal__image"
draggable={false}
style={{
width: displaySize.width,
height: displaySize.height,
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px))`,
}}
/>
) : null}
</div>
<div className="screen-saver-crop-modal__meta">
<span>{naturalSize.width || "-"} × {naturalSize.height || "-"}</span>
<span>{CROP_WIDTH} × {CROP_HEIGHT}</span>
</div>
</div>
<div className="screen-saver-crop-modal__sidebar">
<div className="screen-saver-crop-modal__sidebar-card">
<h4></h4>
<p></p>
<div style={{ marginTop: 18 }}>
<Text type="secondary"></Text>
<Slider
min={minZoom}
max={Math.max(minZoom * 3, minZoom + 0.2)}
step={0.01}
value={zoom}
tooltip={{ formatter: (value) => `${Math.round((value || 1) / minZoom * 100)}%` }}
onChange={handleZoomChange}
/>
</div>
</div>
<div className="screen-saver-crop-modal__sidebar-card">
<h4></h4>
<p> JPG / JPEG / PNG 1280 × 800 </p>
</div>
<div className="screen-saver-crop-modal__footer">
<Button onClick={onCancel} disabled={loading}></Button>
<Button type="primary" icon={<ScissorOutlined />} loading={loading} onClick={() => void handleConfirm()}>
</Button>
</div>
</div>
</div>
</Modal>
);
}
export default function ScreenSaverManagement() {
const { message } = App.useApp();
const [form] = Form.useForm<ScreenSaverFormValues>();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<ScreenSaverVO | null>(null);
const [records, setRecords] = useState<ScreenSaverVO[]>([]);
const [users, setUsers] = useState<SysUser[]>([]);
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<CropModalState>({
open: false,
src: "",
fileName: "",
mimeType: "image/jpeg",
});
const userMap = useMemo(() => {
return new Map<number, SysUser>(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<ScreenSaverVO> = [
{
title: "屏保画面",
key: "visual",
width: 330,
render: (_, record) => (
<div className="screen-saver-table-visual">
<div className="screen-saver-table-thumb">
{record.imageUrl ? <img src={record.imageUrl} alt={record.name} /> : null}
</div>
<Space direction="vertical" size={3}>
<Text strong>{record.name}</Text>
<Text type="secondary">{record.description || "暂无描述"}</Text>
<Space wrap size={[8, 6]}>
<span className="screen-saver-preview-pill">{getImageFormatLabel(record.imageFormat)}</span>
<span className="screen-saver-preview-pill">{record.imageWidth || CROP_WIDTH} × {record.imageHeight || CROP_HEIGHT}</span>
</Space>
</Space>
</div>
),
},
{
title: "作用域",
key: "scope",
width: 220,
render: (_, record) => (
<Space direction="vertical" size={4}>
{record.scopeType === "USER" ? (
<Tag color="gold" icon={<UserOutlined />}></Tag>
) : (
<Tag color="blue" icon={<TeamOutlined />}></Tag>
)}
<Text type="secondary">
{record.scopeType === "USER"
? `归属:${normalizeOwnerLabel(record.ownerUserId ? userMap.get(record.ownerUserId) : undefined)}`
: "全平台共用"}
</Text>
</Space>
),
},
{
title: "播放与状态",
key: "status",
width: 210,
render: (_, record) => (
<Space direction="vertical" size={6}>
<Text>{record.displayDurationSec} / </Text>
<Text type="secondary">{record.sortOrder ?? 0}</Text>
<Switch size="small" checked={record.status === 1} onChange={(checked) => void handleToggleStatus(record, checked)} />
</Space>
),
},
{
title: "创建信息",
key: "creator",
width: 180,
render: (_, record) => {
const timeValue = record.updatedAt || record.createdAt;
return (
<Space direction="vertical" size={4}>
<Text>{record.creatorUsername || "-"}</Text>
<Text type="secondary">
{timeValue ? dayjs(timeValue).format("YYYY-MM-DD HH:mm:ss") : "-"}
</Text>
</Space>
);
},
},
{
title: "操作",
key: "action",
width: 140,
fixed: "right",
render: (_, record) => (
<Space size={4}>
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Popconfirm title="确认删除该屏保吗?" onConfirm={() => void handleDelete(record)}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const currentImageUrl = Form.useWatch("imageUrl", form);
const currentScopeType = Form.useWatch("scopeType", form) || "PLATFORM";
const currentOwnerUserId = Form.useWatch("ownerUserId", form);
return (
<div className="app-page screen-saver-page">
<Card
className="app-page__content-card shadow-sm screen-saver-table-card"
style={{ flex: 1, minHeight: 0 }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
title="屏保管理"
extra={(
<Space wrap>
<Input
placeholder="搜索名称、描述、创建人或归属用户"
prefix={<SearchOutlined />}
allowClear
style={{ width: 280 }}
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
/>
<Select
value={scopeFilter}
style={{ width: 140 }}
onChange={(value) => setScopeFilter(value)}
options={[
{ label: "全部作用域", value: "all" },
{ label: "平台级", value: "PLATFORM" },
{ label: "用户级", value: "USER" },
]}
/>
<Select
value={statusFilter}
style={{ width: 140 }}
onChange={(value) => setStatusFilter(value)}
options={[
{ label: "全部状态", value: "all" },
{ label: "已启用", value: "enabled" },
{ label: "已停用", value: "disabled" },
]}
/>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</Space>
)}
>
<div className="app-page__table-wrap screen-saver-table-wrap">
<Table
rowKey="id"
columns={columns}
dataSource={pagedRecords}
loading={loading}
pagination={false}
locale={{
emptyText: <Empty description="暂无屏保素材" />,
}}
scroll={{ x: 1100, y: "100%" }}
/>
</div>
<AppPagination
current={page}
pageSize={pageSize}
total={filteredRecords.length}
onChange={(nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
}}
/>
</Card>
<Drawer
title={editing ? "编辑屏保" : "新增屏保"}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={760}
destroyOnHidden
forceRender
className="screen-saver-drawer"
styles={{ body: { padding: 24 } }}
footer={(
<div className="screen-saver-drawer__footer">
<Button onClick={() => setDrawerOpen(false)}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void handleSubmit()}>
</Button>
</div>
)}
>
<Form form={form} layout="vertical" className="screen-saver-drawer__form">
<Row gutter={16}>
<Col xs={24} md={14}>
<Form.Item name="name" label="屏保名称" rules={[{ required: true, message: "请输入屏保名称" }]}>
<Input placeholder="例如:大厅欢迎屏、品牌发布屏" />
</Form.Item>
</Col>
<Col xs={24} md={10}>
<Form.Item name="displayDurationSec" label="展示时长(秒)" rules={[{ required: true, message: "请输入展示时长" }]}>
<InputNumber min={3} max={3600} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="scopeType" label="作用域" rules={[{ required: true, message: "请选择作用域" }]}>
<Select
options={[
{ label: "平台级(全平台共用)", value: "PLATFORM" },
{ label: "用户级(指定用户优先)", value: "USER" },
]}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="ownerUserId"
label="归属用户"
rules={currentScopeType === "USER" ? [{ required: true, message: "请选择归属用户" }] : []}
>
<Select
disabled={currentScopeType !== "USER"}
showSearch
allowClear
placeholder={currentScopeType === "USER" ? "请选择用户" : "平台级无需选择"}
optionFilterProp="label"
options={users.map((user) => ({
value: user.userId,
label: `${normalizeOwnerLabel(user)} / ${user.username}`,
}))}
/>
</Form.Item>
</Col>
</Row>
<Card
className="screen-saver-preview-card"
style={{ marginBottom: 18 }}
styles={{ body: { padding: 18 } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
<div>
<Title level={5} style={{ margin: 0 }}></Title>
<Text type="secondary"> 8:5 1280 × 800</Text>
</div>
<Upload {...uploadProps}>
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
</Button>
</Upload>
</Space>
<div className="screen-saver-preview-stage">
{currentImageUrl ? (
<img src={currentImageUrl} alt="屏保预览" />
) : (
<div style={{ height: "100%", display: "grid", placeItems: "center", color: "rgba(235,244,255,.72)" }}>
<Space direction="vertical" align="center" size={10}>
<ScissorOutlined style={{ fontSize: 26 }} />
<span></span>
</Space>
</div>
)}
</div>
<Space wrap>
<span className="screen-saver-preview-pill"> 1280 × 800</span>
<span className="screen-saver-preview-pill"> {currentScopeType === "USER" ? "用户级" : "平台级"}</span>
{currentScopeType === "USER" && currentOwnerUserId ? (
<span className="screen-saver-preview-pill">
{normalizeOwnerLabel(userMap.get(currentOwnerUserId))}
</span>
) : null}
</Space>
</div>
</Card>
<Row gutter={16}>
<Col xs={24} md={8}>
<Form.Item name="imageFormat" label="图片格式" rules={[{ required: true, message: "请先上传图片" }]}>
<Input disabled />
</Form.Item>
</Col>
<Col xs={12} md={8}>
<Form.Item name="imageWidth" label="宽度">
<InputNumber disabled style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col xs={12} md={8}>
<Form.Item name="imageHeight" label="高度">
<InputNumber disabled style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="imageUrl" label="图片地址" rules={[{ required: true, message: "请先上传裁剪后的屏保图片" }]}>
<Input placeholder="上传完成后自动回填" readOnly />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={3} placeholder="描述这张屏保用于什么场景、展示何种品牌信息或氛围。" />
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="sortOrder" label="排序值">
<InputNumber min={0} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="remark" label="备注">
<Input placeholder="例如:大厅屏、品牌发布期、用户专属欢迎页" />
</Form.Item>
</Col>
</Row>
<Form.Item name="statusEnabled" label="启用状态" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Drawer>
<ScreenSaverCropDialog
state={cropState}
onCancel={() => setCropState({ open: false, src: "", fileName: "", mimeType: "image/jpeg" })}
onConfirm={handleUploadCroppedImage}
/>
</div>
);
}

View File

@ -7,6 +7,7 @@ import { createTenant, deleteTenant, listTenants, updateTenant } from "@/api";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import AppPagination from "@/components/shared/AppPagination";
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
import type { SysTenant } from "@/types";
@ -162,26 +163,28 @@ export default function Tenants() {
</Space>
</Card>
<div className="flex-1 overflow-y-auto pr-2">
<Card
className="app-page__content-card shadow-sm"
style={{ flex: 1, minHeight: 0 }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflowX: "hidden", overflowY: "auto", padding: "24px" }}>
<List
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
loading={loading}
dataSource={data}
renderItem={renderTenantCard}
pagination={{
total,
current: params.current,
pageSize: params.size,
onChange: (page, size) => setParams({ ...params, current: page, size: size || params.size }),
showSizeChanger: true,
showQuickJumper: true,
showTotal: (count) => t("common.total", { total: count }),
pageSizeOptions: ["10", "20", "50", "100"],
style: { marginTop: "24px", marginBottom: "24px" }
}}
pagination={false}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} /> }}
/>
</div>
<AppPagination
current={params.current}
pageSize={params.size}
total={total}
onChange={(page, size) => setParams({ ...params, current: page, size: size || params.size })}
/>
</Card>
<Drawer title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
<Form form={form} layout="vertical">

View File

@ -1,4 +1,4 @@
import { Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Typography, App } from 'antd';
import { Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Typography, message } from "antd";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ProfileOutlined, SearchOutlined } from "@ant-design/icons";
@ -13,7 +13,6 @@ import "./index.less";
const { Text } = Typography;
export default function Dictionaries() {
const { message } = App.useApp();
const { t } = useTranslation();
const { can } = usePermission();
const { items: statusDict } = useDict("sys_common_status");
@ -174,7 +173,12 @@ export default function Dictionaries() {
rowKey="dictTypeId"
loading={loadingTypes}
dataSource={types}
pagination={getStandardPagination(typeTotal, typeParams.current, typeParams.size, (page, size) => setTypeParams({ ...typeParams, current: page, size }))}
pagination={{
...getStandardPagination(typeTotal, typeParams.current, typeParams.size, (page, size) => setTypeParams({ ...typeParams, current: page, size })),
simple: true,
size: "small",
position: ["bottomCenter"]
}}
size="small"
showHeader={false}
scroll={{ y: "calc(100vh - 480px)" }}
@ -245,7 +249,7 @@ export default function Dictionaries() {
</Col>
</Row>
<Drawer title={<Space><BookOutlined aria-hidden="true" /><span>{editingType ? t("dicts.drawerTitleTypeEdit") : t("dicts.drawerTitleTypeCreate")}</span></Space>} open={typeDrawerVisible} onClose={() => setTypeDrawerVisible(false)} width={400} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setTypeDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleTypeSubmit}>{t("common.save")}</Button></div>}>
<Drawer title={<Space><BookOutlined aria-hidden="true" /><span>{editingType ? t("dicts.drawerTitleTypeEdit") : t("dicts.drawerTitleTypeCreate")}</span></Space>} open={typeDrawerVisible} onClose={() => setTypeDrawerVisible(false)} width={400} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setTypeDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleTypeSubmit}>{t("common.save")}</Button></div>}>
<Form form={typeForm} layout="vertical">
<Form.Item label={t("dicts.typeCode")} name="typeCode" rules={[{ required: true, message: t("dicts.typeCode") }]}>
<Input disabled={!!editingType} placeholder={t("dictsExt.typeCodePlaceholder")} />

View File

@ -1,12 +1,12 @@
import { Button, Card, DatePicker, Descriptions, Input, Modal, Select, Space, Tabs, Tag, Typography } from "antd";
import { Button, Card, DatePicker, Descriptions, Input, Modal, Popconfirm, Select, Space, Tabs, Tag, Typography, message } from "antd";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { EyeOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
import { fetchLogModules, fetchLogs } from "@/api";
import AppPagination from "@/components/shared/AppPagination";
import { DeleteOutlined, EyeOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
import { cleanLogs, fetchLogModules, fetchLogs } from "@/api";
import { useDict } from "@/hooks/useDict";
import PageHeader from "@/components/shared/PageHeader";
import ListTable from "@/components/shared/ListTable/ListTable";
import AppPagination from "@/components/shared/AppPagination";
import type { SysLog, UserProfile } from "@/types";
const { RangePicker } = DatePicker;
@ -21,6 +21,7 @@ export default function Logs() {
const [moduleOptions, setModuleOptions] = useState<string[]>([]);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [selectedLog, setSelectedLog] = useState<SysLog | null>(null);
const [cleaning, setCleaning] = useState(false);
const [params, setParams] = useState({
current: 1,
size: 20,
@ -47,6 +48,11 @@ export default function Logs() {
}, []);
const isPlatformAdmin = Boolean(userProfile?.isPlatformAdmin);
const activeLogTypeLabel = useMemo(() => {
const dictLabel = logTypeDict.find((item) => item.itemValue === activeTab)?.itemLabel;
if (dictLabel) return dictLabel;
return activeTab === "OPERATION" ? t("logs.opLog") : t("logs.loginLog");
}, [activeTab, logTypeDict, t]);
const loadData = async (currentParams = params) => {
setLoading(true);
@ -71,10 +77,11 @@ export default function Logs() {
fetchLogModules().then((items) => setModuleOptions(items || [])).catch(() => setModuleOptions([]));
}, [activeTab]);
const handleTableChange = (_pagination: any, _filters: any, sorter: any) => {
const handleTableChange = (pagination: any, _filters: any, sorter: any) => {
setParams({
...params,
current: 1,
current: pagination.current,
size: pagination.pageSize,
sortField: sorter.field || "createdAt",
sortOrder: sorter.order || "descend"
});
@ -103,6 +110,19 @@ export default function Logs() {
loadData(resetParams);
};
const handleClean = async () => {
setCleaning(true);
try {
await cleanLogs(activeTab);
message.success(t("logsExt.cleanSuccess", { type: activeLogTypeLabel }));
const nextParams = { ...params, current: 1 };
setParams(nextParams);
await loadData(nextParams);
} finally {
setCleaning(false);
}
};
const renderDuration = (ms?: number) => {
if (!ms && ms !== 0) return "-";
let color = "";
@ -261,18 +281,25 @@ export default function Logs() {
onChange={(key) => { setActiveTab(key); setParams((prev) => ({ ...prev, current: 1, moduleName: "" })); }}
size="large"
className="flex-shrink-0"
items={
logTypeDict.length > 0
? logTypeDict.map((item) => ({
key: item.itemValue,
label: <span>{item.itemValue === "OPERATION" ? <InfoCircleOutlined aria-hidden="true" /> : <UserOutlined aria-hidden="true" />}{item.itemLabel}</span>
}))
: [
{ key: "OPERATION", label: <span><InfoCircleOutlined aria-hidden="true" />{t("logs.opLog")}</span> },
{ key: "LOGIN", label: <span><UserOutlined aria-hidden="true" />{t("logs.loginLog")}</span> }
]
}
/>
tabBarExtraContent={(
<Popconfirm
title={t("logsExt.cleanConfirmTitle", { type: activeLogTypeLabel })}
description={t("logsExt.cleanConfirmDescription")}
okText={t("common.confirm")}
cancelText={t("common.cancel")}
okButtonProps={{ danger: true, loading: cleaning }}
onConfirm={() => void handleClean()}
>
<Button danger icon={<DeleteOutlined aria-hidden="true" />} loading={cleaning}>
{t("logsExt.cleanCurrent", { type: activeLogTypeLabel })}
</Button>
</Popconfirm>
)}
>
{logTypeDict.length > 0
? logTypeDict.map((item) => <Tabs.TabPane tab={<span>{item.itemValue === "OPERATION" ? <InfoCircleOutlined aria-hidden="true" /> : <UserOutlined aria-hidden="true" />}{item.itemLabel}</span>} key={item.itemValue} />)
: <><Tabs.TabPane tab={<span><InfoCircleOutlined aria-hidden="true" />{t("logs.opLog")}</span>} key="OPERATION" /><Tabs.TabPane tab={<span><UserOutlined aria-hidden="true" />{t("logs.loginLog")}</span>} key="LOGIN" /></>}
</Tabs>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
<ListTable
@ -282,11 +309,18 @@ export default function Logs() {
loading={loading}
onChange={handleTableChange}
totalCount={total}
scroll={{ x: "max-content" }}
scroll={{ y: "calc(100vh - 460px)" }}
pagination={false}
/>
</div>
<AppPagination current={params.current} pageSize={params.size} total={total} onChange={(page, size) => setParams((prev) => ({ ...prev, current: page, size }))} />
<AppPagination
current={params.current}
pageSize={params.size}
total={total}
onChange={(page, pageSize) => {
setParams({ ...params, current: page, size: pageSize });
}}
/>
</Card>
<Modal title={t("logs.detailTitle")} open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={[<Button key="close" onClick={() => setDetailModalVisible(false)}>{t("logsExt.close")}</Button>]} width={700}>

View File

@ -1,4 +1,4 @@
import { Button, Card, Col, Form, Input, Row, Upload, App } from 'antd';
import { Button, Card, Col, Form, Input, Row, Upload, message } from "antd";
import { FileTextOutlined, GlobalOutlined, PictureOutlined, SaveOutlined, UploadOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@ -25,7 +25,6 @@ function ImagePreview({ url, label, hint }: { url?: string; label: string; hint:
}
export default function PlatformSettings() {
const { message } = App.useApp();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@ -54,7 +53,7 @@ export default function PlatformSettings() {
form.setFieldValue(fieldName, url);
message.success(t("common.success"));
} catch (error) {
// message.error(error instanceof Error ? error.message : t("common.error"));
message.error(error instanceof Error ? error.message : t("common.error"));
return Upload.LIST_IGNORE;
}
return false;
@ -87,7 +86,13 @@ export default function PlatformSettings() {
<Row gutter={24}>
<Col span={24}>
<Card title={<><GlobalOutlined className="mr-2" />{t("platformSettings.basicInfo")}</>} className="app-page__content-card mb-6" loading={loading}>
<Form.Item label={t("platformSettings.projectName")} name="projectName" rules={[{ required: true, message: t("platformSettings.projectNameRequired") }]}>
<Form.Item label={t("platformSettings.projectName")} name="projectName" rules={[
{ required: true, message: t("platformSettings.projectNameRequired") },
{
max:50,
message:t('common.maxLength',{length:50})
}
]}>
<Input placeholder={t("platformSettings.projectNamePlaceholder")} />
</Form.Item>
<Form.Item label={t("platformSettings.desc")} name="systemDescription">

View File

@ -1,12 +1,13 @@
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from "antd";
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Tag, Tooltip, Typography, message } from "antd";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from "@ant-design/icons";
import { createParam, deleteParam, pageParams, updateParam } from "@/api";
import AppPagination from "@/components/shared/AppPagination";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import ListTable from "@/components/shared/ListTable/ListTable";
import AppPagination from "@/components/shared/AppPagination";
import type { SysParamQuery, SysParamVO } from "@/types";
import "./index.less";
@ -188,18 +189,25 @@ export default function SysParams() {
</Card>
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<Table
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
<ListTable
rowKey="paramId"
columns={columns}
dataSource={data}
loading={loading}
size="middle"
tableLayout="fixed"
scroll={{ x: "max-content" }}
scroll={{ y: "calc(100vh - 350px)" }}
totalCount={total}
pagination={false}
/>
<AppPagination current={queryParams.pageNum || 1} pageSize={queryParams.pageSize || 10} total={total} onChange={handlePageChange} />
</div>
<AppPagination
current={queryParams.pageNum || 1}
pageSize={queryParams.pageSize || 10}
total={total}
onChange={(page, pageSize) => {
handlePageChange(page, pageSize);
}}
/>
</Card>
<Drawer

View File

@ -19,6 +19,7 @@ const UserRoleBinding = lazy(() => import("@/pages/bindings/user-role"));
const RolePermissionBinding = lazy(() => import("@/pages/bindings/role-permission"));
const ClientManagement = lazy(() => import("@/pages/business/ClientManagement"));
const ExternalAppManagement = lazy(() => import("@/pages/business/ExternalAppManagement"));
const ScreenSaverManagement = lazy(() => import("@/pages/business/ScreenSaverManagement"));
import SpeakerReg from "../pages/business/SpeakerReg";
const RealtimeAsrSession = lazy(async () => {
@ -64,6 +65,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/aimodels", label: "模型配置", element: <AiModels />, perm: "menu:aimodel" },
{ path: "/clients", label: "客户端管理", element: <ClientManagement />, perm: "menu:clients" },
{ path: "/external-apps", label: "外部应用管理", element: <ExternalAppManagement />, perm: "menu:external-apps" },
{ path: "/screen-savers", label: "屏保管理", element: <ScreenSaverManagement />, perm: "menu:screen-savers" },
{ path: "/meetings", label: "会议中心", element: <Meetings />, perm: "menu:meeting" },
];