Compare commits

...

3 Commits

Author SHA1 Message Date
chenhao 1cce0aeabb feat: 添加授权码管理和设备自注册功能
- 新增 `AndroidTenantProviderConfig` 配置类,提供租户解析逻辑
- 更新 `AndroidAuthService` 接口,添加 `authenticateHttp` 方法的可选参数
- 新增前端授权码管理页面 `LicenseManagement`,支持授权码列表展示和导入
- 新增 `AndroidDeviceRegistrationServiceImpl` 的 `register` 方法,支持设备自注册并验证租户代码
- 更新 `AndroidAuthServiceImpl`,在认证过程中应用授权信息
- 更新相关 DTO 和接口,支持新的授权和设备注册逻辑
2026-06-09 17:09:45 +08:00
chenhao d47a66febd fix: 修正 ffmpeg 路径和音频处理逻辑
- 更新 `application-dev.yml` 中的 `ffmpeg-path` 配置
- 在 `SpeakerServiceImpl` 中添加 `StandardCharsets.UTF_8` 编码
- 优化 `LegacyMeetingAdapterServiceImpl` 和 `MeetingDomainSupport` 中的音频处理逻辑
2026-06-09 09:22:09 +08:00
chenhao e1e321a86d feat: 添加会议进度通知和离线会议冲突处理
- 在 `MeetingProgressServiceImpl` 中添加 `notifyUnifiedStatusChangedIfNeeded` 方法,用于通知会议状态变更
- 新增 `ExistingOfflineMeetingException` 和 `AndroidOfflineMeetingConflictVO` 类,用于处理离线会议冲突
- 更新前端 `Meeting.ts` 和 `MeetingPreview.tsx`,添加统一会议状态字段和访问校验逻辑
- 新增 `AndroidPublicLoginConfirmPayload` 和 `AndroidUnifiedMeetingStatusRequest` DTO 类,用于公有设备登录确认和统一会议状态查询
2026-06-08 16:19:40 +08:00
86 changed files with 4200 additions and 493 deletions

View File

@ -0,0 +1,9 @@
package com.imeeting.common;
public final class LicenseConstants {
private LicenseConstants() {
}
public static final String TEMP_SERIAL_PREFIX = "SN";
public static final String IMPORT_BATCH_PREFIX = "LIC";
}

View File

@ -123,6 +123,10 @@ public final class RedisKeys {
return "biz:meeting:android:draft:" + meetingId; return "biz:meeting:android:draft:" + meetingId;
} }
public static String androidDeviceWeatherKey(String cityName) {
return "biz:android:device:weather:" + cityName;
}
public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER"; public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER";
public static final String SYS_PARAM_FIELD_VALUE = "value"; public static final String SYS_PARAM_FIELD_VALUE = "value";
public static final String SYS_PARAM_FIELD_TYPE = "type"; public static final String SYS_PARAM_FIELD_TYPE = "type";

View File

@ -21,4 +21,9 @@ public final class SysParamKeys {
public static final String MEETING_POINTS_LLM_RATIO = "meeting.points.llm_ratio"; public static final String MEETING_POINTS_LLM_RATIO = "meeting.points.llm_ratio";
public static final String MEETING_POINTS_INITIAL_BALANCE = "meeting.points.initial_balance"; public static final String MEETING_POINTS_INITIAL_BALANCE = "meeting.points.initial_balance";
public static final String MEETING_POINTS_ACCOUNT_MODE = "meeting.points.account_mode"; public static final String MEETING_POINTS_ACCOUNT_MODE = "meeting.points.account_mode";
public static final String LICENSE_TEMP_DEFAULT_COUNT = "license.temp.default.count";
public static final String LICENSE_TEMP_DEFAULT_EXPIRE_MONTHS = "license.temp.default.expire.months";
public static final String LICENSE_DEFAULT_PRODUCT_CODE = "license.default.product.code";
public static final String DEVICE_WEATHER_QWEATHER_BASE_URL = "device.weather.qweather.base_url";
public static final String DEVICE_WEATHER_QWEATHER_KEY = "device.weather.qweather.key";
} }

View File

@ -0,0 +1,13 @@
package com.imeeting.common.exception;
import lombok.Getter;
@Getter
public class ExistingOfflineMeetingException extends RuntimeException {
private final Long meetingId;
public ExistingOfflineMeetingException(Long meetingId) {
super("有未结束会议");
this.meetingId = meetingId;
}
}

View File

@ -0,0 +1,116 @@
package com.imeeting.config;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.unisbase.config.properties.UnisBaseProperties;
import com.unisbase.entity.SysTenant;
import com.unisbase.mapper.SysTenantMapper;
import com.unisbase.security.SpringSecurityTenantProvider;
import com.unisbase.spi.UnisBaseTenantProvider;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Configuration
public class AndroidTenantProviderConfig {
private static final String TENANT_ID_HEADER = "X-Tenant-Id";
private static final String TENANT_CODE_HEADER = "X-Tenant-Code";
@Bean
public UnisBaseTenantProvider unisBaseTenantProvider(UnisBaseProperties properties,
ObjectProvider<SysTenantMapper> sysTenantMapperProvider) {
return new AndroidTenantProvider(properties, sysTenantMapperProvider);
}
private static final class AndroidTenantProvider extends SpringSecurityTenantProvider {
private final UnisBaseProperties properties;
private final ObjectProvider<SysTenantMapper> sysTenantMapperProvider;
private AndroidTenantProvider(UnisBaseProperties properties, ObjectProvider<SysTenantMapper> sysTenantMapperProvider) {
super(properties);
this.properties = properties;
this.sysTenantMapperProvider = sysTenantMapperProvider;
}
@Override
public Long getCurrentTenantId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof com.unisbase.security.LoginUser user && user.getTenantId() != null) {
return user.getTenantId();
}
Long tenantId = parseTenantIdHeader();
if (tenantId != null) {
return tenantId;
}
String tenantCode = header(TENANT_CODE_HEADER);
if (StringUtils.hasText(tenantCode)) {
Long resolvedTenantId = resolveTenantIdByCode(tenantCode.trim());
if (resolvedTenantId != null) {
return resolvedTenantId;
}
}
if (isSingleTenantMode()) {
Long defaultTenantId = properties.getTenant().getDefaultTenantId();
return defaultTenantId == null || defaultTenantId <= 0 ? 1L : defaultTenantId;
}
return 0L;
}
private Long parseTenantIdHeader() {
String value = header(TENANT_ID_HEADER);
if (!StringUtils.hasText(value)) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException ex) {
return null;
}
}
private Long resolveTenantIdByCode(String tenantCode) {
SysTenantMapper sysTenantMapper = sysTenantMapperProvider.getIfAvailable();
if (sysTenantMapper == null) {
return null;
}
try {
SysTenant tenant = sysTenantMapper.selectOne(new LambdaQueryWrapper<SysTenant>()
.eq(SysTenant::getTenantCode, tenantCode)
.eq(SysTenant::getIsDeleted, 0)
.last("LIMIT 1"));
return tenant == null ? null : tenant.getId();
} catch (Exception ex) {
return null;
}
}
private String header(String name) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return null;
}
HttpServletRequest request = attributes.getRequest();
return request == null ? null : request.getHeader(name);
}
private boolean isSingleTenantMode() {
if (properties == null || properties.getTenant() == null) {
return false;
}
String mode = properties.getTenant().getMode();
if (mode != null && !mode.isBlank()) {
return "single".equalsIgnoreCase(mode.trim());
}
return !properties.getTenant().isEnabled();
}
}
}

View File

@ -67,6 +67,11 @@ public class AndroidAuthController {
appVersion, appVersion,
platform platform
); );
androidDeviceBindingService.recordLogin(
deviceId.trim(),
response.getCurrentTenantId(),
response.getUser().getUserId()
);
} }
return ApiResponse.ok(response); return ApiResponse.ok(response);
} }

View File

@ -5,6 +5,7 @@ import com.imeeting.support.AndroidRequestLogHelper;
import com.imeeting.entity.biz.ClientDownload; import com.imeeting.entity.biz.ClientDownload;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.ClientDownloadService; import com.imeeting.service.biz.ClientDownloadService;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@ -38,6 +39,7 @@ public class AndroidClientController {
) )
}) })
@GetMapping("/latest/by-platform") @GetMapping("/latest/by-platform")
@Anonymous
public ApiResponse<ClientDownload> latestByPlatform(HttpServletRequest request, public ApiResponse<ClientDownload> latestByPlatform(HttpServletRequest request,
@RequestParam(value = "platform_code", required = false) String platformCode, @RequestParam(value = "platform_code", required = false) String platformCode,
@RequestParam(value = "platform_type", required = false) String platformType, @RequestParam(value = "platform_type", required = false) String platformType,

View File

@ -1,9 +1,11 @@
package com.imeeting.controller.android; package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidDeviceHomeStatsVO;
import com.imeeting.dto.android.AndroidDeviceRegisterRequest; import com.imeeting.dto.android.AndroidDeviceRegisterRequest;
import com.imeeting.dto.android.AndroidDeviceRegisterResponse; import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidDeviceHomeService;
import com.imeeting.service.android.AndroidDeviceRegistrationService; import com.imeeting.service.android.AndroidDeviceRegistrationService;
import com.imeeting.support.AndroidRequestLogHelper; import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous; import com.unisbase.annotation.Anonymous;
@ -16,6 +18,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -27,8 +31,11 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class AndroidDeviceController { public class AndroidDeviceController {
private static final String TENANT_CODE_HEADER = "X-Tenant-Code";
private final AndroidAuthService androidAuthService; private final AndroidAuthService androidAuthService;
private final AndroidDeviceRegistrationService androidDeviceRegistrationService; private final AndroidDeviceRegistrationService androidDeviceRegistrationService;
private final AndroidDeviceHomeService androidDeviceHomeService;
@Operation(summary = "设备自注册") @Operation(summary = "设备自注册")
@ApiResponses({ @ApiResponses({
@ -42,14 +49,46 @@ public class AndroidDeviceController {
@Anonymous @Anonymous
public ApiResponse<AndroidDeviceRegisterResponse> register(HttpServletRequest request, public ApiResponse<AndroidDeviceRegisterResponse> register(HttpServletRequest request,
@RequestBody(required = false) AndroidDeviceRegisterRequest command) { @RequestBody(required = false) AndroidDeviceRegisterRequest command) {
AndroidRequestLogHelper.logRequest(log, "Android设备", "设备自注册", "request", command); if (command == null) {
throw new IllegalArgumentException("注册请求不能为空");
}
String tenantCode = resolveTenantCode(request, command);
AndroidRequestLogHelper.logRequest(log, "Android设备", "设备自注册", "request", command, "tenantCode", tenantCode);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request, false); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request, false);
AndroidDeviceRegisterResponse response = androidDeviceRegistrationService.register( AndroidDeviceRegisterResponse response = androidDeviceRegistrationService.register(
tenantCode,
authContext.getDeviceId(), authContext.getDeviceId(),
command == null ? null : command.getDeviceName(), command.getDeviceName(),
command == null ? authContext.getPlatform() : command.getTerminalType(), command.getTerminalType() == null ? authContext.getPlatform() : command.getTerminalType(),
command == null ? authContext.getAppVersion() : command.getTerminalVersion() command.getTerminalVersion() == null ? authContext.getAppVersion() : command.getTerminalVersion()
); );
return ApiResponse.ok(response); return ApiResponse.ok(response);
} }
@Operation(summary = "查询设备首页统计")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回设备首页统计信息",
content = @Content(schema = @Schema(implementation = AndroidDeviceHomeStatsVO.class))
)
})
@GetMapping("/home")
@Anonymous
public ApiResponse<AndroidDeviceHomeStatsVO> home(HttpServletRequest request) {
AndroidRequestLogHelper.logRequest(log, "Android设备", "查询设备首页统计");
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request, true, true);
return ApiResponse.ok(androidDeviceHomeService.getHomeStats(authContext));
}
private String resolveTenantCode(HttpServletRequest request, AndroidDeviceRegisterRequest command) {
if (command != null && StringUtils.hasText(command.getTenantCode())) {
return command.getTenantCode().trim();
}
String tenantCodeFromHeader = request == null ? null : request.getHeader(TENANT_CODE_HEADER);
if (StringUtils.hasText(tenantCodeFromHeader)) {
return tenantCodeFromHeader.trim();
}
throw new IllegalArgumentException("tenantCode不能为空");
}
} }

View File

@ -9,6 +9,8 @@ import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidMeetingConfigVo; import com.imeeting.dto.android.AndroidMeetingConfigVo;
import com.imeeting.dto.android.AndroidOfflineMeetingConflictVO; import com.imeeting.dto.android.AndroidOfflineMeetingConflictVO;
import com.imeeting.dto.android.AndroidOfflineMeetingFinishRequest; import com.imeeting.dto.android.AndroidOfflineMeetingFinishRequest;
import com.imeeting.dto.android.AndroidUnifiedMeetingStatusRequest;
import com.imeeting.dto.android.AndroidUnifiedMeetingStatusResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest; import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest;
import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse; import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest;
@ -17,8 +19,10 @@ import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult;
import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse; import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.PromptTemplateVO; import com.imeeting.dto.biz.PromptTemplateVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusVO;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.PromptTemplate; import com.imeeting.entity.biz.PromptTemplate;
@ -93,6 +97,7 @@ public class AndroidMeetingController {
private final SysDictItemService dictItemService; private final SysDictItemService dictItemService;
private final SysParamService paramService; private final SysParamService paramService;
private final MeetingProgressService meetingProgressService; private final MeetingProgressService meetingProgressService;
private final MeetingUnifiedStatusService meetingUnifiedStatusService;
@Autowired @Autowired
public AndroidMeetingController(AndroidAuthService androidAuthService, public AndroidMeetingController(AndroidAuthService androidAuthService,
@ -108,7 +113,8 @@ public class AndroidMeetingController {
AiModelService aiModelService, AiModelService aiModelService,
SysDictItemService dictItemService, SysDictItemService dictItemService,
SysParamService paramService, SysParamService paramService,
MeetingProgressService meetingProgressService) { MeetingProgressService meetingProgressService,
MeetingUnifiedStatusService meetingUnifiedStatusService) {
this.androidAuthService = androidAuthService; this.androidAuthService = androidAuthService;
this.androidChunkUploadService = androidChunkUploadService; this.androidChunkUploadService = androidChunkUploadService;
this.legacyMeetingAdapterService = legacyMeetingAdapterService; this.legacyMeetingAdapterService = legacyMeetingAdapterService;
@ -123,6 +129,7 @@ public class AndroidMeetingController {
this.aiModelService = aiModelService; this.aiModelService = aiModelService;
this.paramService = paramService; this.paramService = paramService;
this.dictItemService = dictItemService; this.dictItemService = dictItemService;
this.meetingUnifiedStatusService = meetingUnifiedStatusService;
} }
@Operation(summary = "创建Android离线会议") @Operation(summary = "创建Android离线会议")
@ -280,6 +287,41 @@ public class AndroidMeetingController {
return new ApiResponse<>(result.getCode(), result.getMessage(), result.getData()); return new ApiResponse<>(result.getCode(), result.getMessage(), result.getData());
} }
@Operation(summary = "查询Android会议统一状态")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回统一状态及可选内容",
content = @Content(schema = @Schema(implementation = AndroidUnifiedMeetingStatusResponse.class))
)
})
@PostMapping("/{meetingId}/status")
@Anonymous
public ApiResponse<AndroidUnifiedMeetingStatusResponse> getUnifiedStatus(HttpServletRequest request,
@PathVariable Long meetingId,
@RequestBody(required = false) AndroidUnifiedMeetingStatusRequest command) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "查询会议统一状态",
"meetingId", meetingId,
"request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
MeetingVO meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser);
UnifiedMeetingStatusVO status = meetingUnifiedStatusService.resolve(meetingId);
boolean includeTranscript = Boolean.TRUE.equals(command == null ? null : command.getIncludeTranscript());
boolean includeSummary = Boolean.TRUE.equals(command == null ? null : command.getIncludeSummary());
List<MeetingTranscriptVO> transcripts = includeTranscript ? meetingQueryService.getTranscripts(meetingId) : null;
String summaryContent = includeSummary ? meetingQueryService.getDetailIgnoreTenant(meetingId).getSummaryContent() : null;
return ApiResponse.ok(AndroidUnifiedMeetingStatusResponse.builder()
.meetingId(meetingId)
.status(status)
.meeting(meeting)
.includesTranscript(includeTranscript)
.transcripts(transcripts)
.includesSummary(includeSummary)
.summaryContent(summaryContent)
.build());
}
@Operation(summary = "更新Android会议访问密码") @Operation(summary = "更新Android会议访问密码")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse( @io.swagger.v3.oas.annotations.responses.ApiResponse(

View File

@ -14,6 +14,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -58,6 +59,18 @@ public class DeviceManagementController {
return ApiResponse.ok(deviceOnlineManagementService.kick(id, currentLoginUser())); return ApiResponse.ok(deviceOnlineManagementService.kick(id, currentLoginUser()));
} }
@Operation(summary = "删除设备并解绑授权")
@DeleteMapping("/{id}")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(deviceOnlineManagementService.delete(id, currentLoginUser()));
}
@Operation(summary = "重置设备首页统计")
@PostMapping("/{id}/reset")
public ApiResponse<Boolean> reset(@PathVariable Long id) {
return ApiResponse.ok(deviceOnlineManagementService.resetStats(id, currentLoginUser()));
}
private LoginUser currentLoginUser() { private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
} }

View File

@ -0,0 +1,45 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.LicenseImportResultVO;
import com.imeeting.dto.biz.LicenseVO;
import com.imeeting.service.biz.LicenseService;
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.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
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/admin/licenses")
@RequiredArgsConstructor
public class LicenseManagementController {
private final LicenseService licenseService;
@Operation(summary = "查询当前租户授权列表")
@GetMapping
public ApiResponse<List<LicenseVO>> list() {
return ApiResponse.ok(licenseService.listCurrentTenantLicenses(currentLoginUser()));
}
@Operation(summary = "导入当前租户正式授权")
@PostMapping("/import")
public ApiResponse<LicenseImportResultVO> importLicenses(@RequestParam("file") MultipartFile file) throws IOException {
return ApiResponse.ok(licenseService.importFormalLicenses(file, currentLoginUser()));
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -31,6 +31,7 @@ import com.imeeting.service.biz.MeetingExportService;
import com.imeeting.service.biz.MeetingProgressService; import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingQueryService; import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingTranscriptFileService; import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.MeetingUnifiedStatusService;
import com.imeeting.service.biz.PromptTemplateService; import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService; import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
@ -87,6 +88,7 @@ public class MeetingController {
private final MeetingProgressService meetingProgressService; private final MeetingProgressService meetingProgressService;
private final SysParamService sysParamService; private final SysParamService sysParamService;
private final AiTaskService aiTaskService; private final AiTaskService aiTaskService;
private final MeetingUnifiedStatusService meetingUnifiedStatusService;
@Value("${imeeting.h5.base-url:}") @Value("${imeeting.h5.base-url:}")
private String h5BaseUrl; private String h5BaseUrl;
@ -103,7 +105,8 @@ public class MeetingController {
MeetingAudioUploadSupport meetingAudioUploadSupport, MeetingAudioUploadSupport meetingAudioUploadSupport,
MeetingProgressService meetingProgressService, MeetingProgressService meetingProgressService,
SysParamService sysParamService, SysParamService sysParamService,
AiTaskService aiTaskService) { AiTaskService aiTaskService,
MeetingUnifiedStatusService meetingUnifiedStatusService) {
this.meetingQueryService = meetingQueryService; this.meetingQueryService = meetingQueryService;
this.meetingCommandService = meetingCommandService; this.meetingCommandService = meetingCommandService;
this.meetingAccessService = meetingAccessService; this.meetingAccessService = meetingAccessService;
@ -116,6 +119,7 @@ public class MeetingController {
this.meetingProgressService = meetingProgressService; this.meetingProgressService = meetingProgressService;
this.sysParamService = sysParamService; this.sysParamService = sysParamService;
this.aiTaskService = aiTaskService; this.aiTaskService = aiTaskService;
this.meetingUnifiedStatusService = meetingUnifiedStatusService;
} }
@Operation(summary = "查询会议处理进度") @Operation(summary = "查询会议处理进度")
@ -139,7 +143,9 @@ public class MeetingController {
return ApiResponse.ok(Map.of("percent", 5, "message", "识别中,等待进度刷新...")); return ApiResponse.ok(Map.of("percent", 5, "message", "识别中,等待进度刷新..."));
} }
} }
return ApiResponse.ok(progress); Map<String, Object> payload = new LinkedHashMap<>(progress);
payload.put("unifiedStatus", meetingUnifiedStatusService.resolve(id));
return ApiResponse.ok(payload);
} }
@ -172,7 +178,9 @@ public class MeetingController {
progress = Map.of("percent", 5, "message", "识别中,等待进度刷新..."); progress = Map.of("percent", 5, "message", "识别中,等待进度刷新...");
} }
} }
result.put(id, progress); Map<String, Object> payload = new LinkedHashMap<>(progress);
payload.put("unifiedStatus", meetingUnifiedStatusService.resolve(id));
result.put(id, payload);
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
// Ignore inaccessible meetings in batch mode. // Ignore inaccessible meetings in batch mode.
} }

View File

@ -0,0 +1,36 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Android 设备首页统计响应")
public class AndroidDeviceHomeStatsVO {
@Schema(description = "设备名称")
private String deviceName;
@Schema(description = "授权类型1-临时2-正式")
private Integer licenseType;
@Schema(description = "剩余分钟数")
private Long remainingMinutes;
@Schema(description = "会议数量")
private Long meetingCount;
@Schema(description = "会议总时长,单位分钟")
private Long meetingDurationMinutes;
@Schema(description = "登录用户数")
private Long loginUserCount;
@Schema(description = "H5 地址")
private String h5BaseUrl;
@Schema(description = "天气信息")
private AndroidDeviceHomeWeatherVO weather;
@Schema(description = "是否已登录")
private Boolean loggedIn;
}

View File

@ -0,0 +1,18 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Android 设备首页天气信息")
public class AndroidDeviceHomeWeatherVO {
@Schema(description = "城市名称")
private String cityName;
@Schema(description = "天气文本")
private String text;
@Schema(description = "温度,单位摄氏度")
private String temperature;
}

View File

@ -6,6 +6,9 @@ import lombok.Data;
@Data @Data
@Schema(description = "Android设备注册请求") @Schema(description = "Android设备注册请求")
public class AndroidDeviceRegisterRequest { public class AndroidDeviceRegisterRequest {
@Schema(description = "租户编码", requiredMode = Schema.RequiredMode.REQUIRED)
private String tenantCode;
@Schema(description = "设备名称") @Schema(description = "设备名称")
private String deviceName; private String deviceName;

View File

@ -20,4 +20,7 @@ public class AndroidDeviceRegisterResponse {
@Schema(description = "是否已被用户占用") @Schema(description = "是否已被用户占用")
private Boolean occupied; private Boolean occupied;
@Schema(description = "授权类型1-临时2-正式")
private Integer licenseType;
} }

View File

@ -0,0 +1,10 @@
package com.imeeting.dto.android;
import lombok.Data;
@Data
public class AndroidDeviceWeatherCacheValue {
private String cityName;
private String text;
private String temperature;
}

View File

@ -0,0 +1,13 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
@Schema(description = "Android 离线会议冲突信息")
public class AndroidOfflineMeetingConflictVO {
@Schema(description = "未结束会议ID")
private Long meetingId;
}

View File

@ -0,0 +1,17 @@
package com.imeeting.dto.android;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Android 离线会议结束请求")
public class AndroidOfflineMeetingFinishRequest {
@JsonProperty("finish_stage")
@Schema(description = "结束阶段PRE_END / UPLOAD_FINISHED")
private String finishStage;
@JsonProperty("total_chunks")
@Schema(description = "总分片数finish_stage=UPLOAD_FINISHED 时必填")
private Integer totalChunks;
}

View File

@ -0,0 +1,23 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "公有设备扫码登录确认消息载荷")
public class AndroidPublicLoginConfirmPayload {
@Schema(description = "扫码会话ID")
private String sessionId;
@Schema(description = "租户ID")
private Long tenantId;
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "用户名")
private String username;
@Schema(description = "显示名称")
private String displayName;
}

View File

@ -0,0 +1,17 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "公有设备扫码会话结果")
public class AndroidPublicMeetingSessionResultVO {
@Schema(description = "返回模式QR_CODE / PENDING_MESSAGE")
private String mode;
@Schema(description = "二维码信息,仅 mode=QR_CODE 时返回")
private AndroidPublicMeetingSessionVO qrCode;
@Schema(description = "待处理消息,仅 mode=PENDING_MESSAGE 时返回")
private AndroidPushMessageVO message;
}

View File

@ -0,0 +1,26 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Android 推送消息视图")
public class AndroidPushMessageVO {
@Schema(description = "消息ID")
private String messageId;
@Schema(description = "消息时间戳,毫秒")
private Long timestamp;
@Schema(description = "消息类型")
private String type;
@Schema(description = "消息标题")
private String title;
@Schema(description = "消息内容")
private String content;
@Schema(description = "是否需要ACK")
private Boolean needAck;
}

View File

@ -0,0 +1,14 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Android 统一会议状态查询请求")
public class AndroidUnifiedMeetingStatusRequest {
@Schema(description = "是否附带转录内容,默认 false")
private Boolean includeTranscript;
@Schema(description = "是否附带总结内容,默认 false")
private Boolean includeSummary;
}

View File

@ -0,0 +1,36 @@
package com.imeeting.dto.android;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
@Schema(description = "Android 统一会议状态响应")
public class AndroidUnifiedMeetingStatusResponse {
@Schema(description = "会议ID")
private Long meetingId;
@Schema(description = "统一状态")
private UnifiedMeetingStatusVO status;
@Schema(description = "会议基础信息")
private MeetingVO meeting;
@Schema(description = "是否附带转录内容")
private Boolean includesTranscript;
@Schema(description = "转录内容,可选返回")
private List<MeetingTranscriptVO> transcripts;
@Schema(description = "是否附带总结内容")
private Boolean includesSummary;
@Schema(description = "总结内容,可选返回")
private String summaryContent;
}

View File

@ -12,4 +12,7 @@ public class DeviceAdminUpdateCommand {
@Schema(description = "状态1启用0停用") @Schema(description = "状态1启用0停用")
private Integer status; private Integer status;
@Schema(description = "设备所在天气城市")
private String weatherCityName;
} }

View File

@ -39,6 +39,12 @@ public class DeviceOnlineAdminVO {
@Schema(description = "最后一次在线时间") @Schema(description = "最后一次在线时间")
private LocalDateTime lastOnlineAt; private LocalDateTime lastOnlineAt;
@Schema(description = "统计重置时间")
private LocalDateTime statsResetAt;
@Schema(description = "设备天气城市")
private String weatherCityName;
@Schema(description = "状态1启用0停用") @Schema(description = "状态1启用0停用")
private Integer status; private Integer status;

View File

@ -0,0 +1,23 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "正式授权导入结果")
public class LicenseImportResultVO {
@Schema(description = "导入批次号")
private String importBatchNo;
@Schema(description = "导入总数")
private Integer totalCount;
@Schema(description = "替换中的设备数量")
private Integer replacedCount;
@Schema(description = "新增未使用正式授权数量")
private Integer unusedFormalCount;
@Schema(description = "失效的临时授权数量")
private Integer invalidatedTempCount;
}

View File

@ -0,0 +1,20 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "正式授权导入行")
public class LicenseImportRow {
@Schema(description = "授权序列号")
private String licenseSerial;
@Schema(description = "授权码")
private String licenseCode;
@Schema(description = "产品编码")
private String productCode;
@Schema(description = "备注")
private String remark;
}

View File

@ -0,0 +1,49 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "授权列表视图")
public class LicenseVO {
@Schema(description = "主键ID")
private Long id;
@Schema(description = "租户ID")
private Long tenantId;
@Schema(description = "授权序列号")
private String licenseSerial;
@Schema(description = "授权码")
private String licenseCode;
@Schema(description = "授权类型")
private Integer licenseType;
@Schema(description = "授权状态")
private Integer licenseStatus;
@Schema(description = "产品编码")
private String productCode;
@Schema(description = "绑定设备编码")
private String deviceCode;
@Schema(description = "绑定时间")
private LocalDateTime bindTime;
@Schema(description = "过期时间")
private LocalDateTime expireTime;
@Schema(description = "导入批次号")
private String importBatchNo;
@Schema(description = "导入时间")
private LocalDateTime importTime;
@Schema(description = "备注")
private String remark;
}

View File

@ -0,0 +1,25 @@
package com.imeeting.dto.biz;
import lombok.Getter;
@Getter
public enum UnifiedMeetingStatusStage {
WAITING_UPLOAD("WAITING_UPLOAD", "待上传录音文件", false),
INITIALIZING("INITIALIZING", "数据初始化", false),
TRANSCRIBING("TRANSCRIBING", "转译音频", false),
SUMMARIZING("SUMMARIZING", "生成总结", false),
COMPLETED("COMPLETED", "处理完成", true),
FAILED_INITIALIZING("FAILED_INITIALIZING", "数据初始化失败", true),
FAILED_TRANSCRIBING("FAILED_TRANSCRIBING", "转译音频失败", true),
FAILED_SUMMARIZING("FAILED_SUMMARIZING", "生成总结失败", true);
private final String code;
private final String text;
private final boolean terminal;
UnifiedMeetingStatusStage(String code, String text, boolean terminal) {
this.code = code;
this.text = text;
this.terminal = terminal;
}
}

View File

@ -0,0 +1,20 @@
package com.imeeting.dto.biz;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class UnifiedMeetingStatusVO {
private Long meetingId;
private String statusCode;
private String statusText;
private Integer percent;
private String message;
private Integer eta;
private String failedStageCode;
private String failedStageText;
private Boolean canViewTranscript;
private Boolean canViewAiChapters;
private Boolean canViewSummary;
}

View File

@ -28,4 +28,8 @@ public class DeviceInfoEntity extends BaseEntity {
private String terminalVersion; private String terminalVersion;
private LocalDateTime lastOnlineAt; private LocalDateTime lastOnlineAt;
private LocalDateTime statsResetAt;
private String weatherCityName;
} }

View File

@ -0,0 +1,31 @@
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;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("biz_device_login_log")
@Schema(description = "设备登录日志实体")
public class DeviceLoginLogEntity extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "主键ID")
private Long id;
@Schema(description = "设备编码")
private String deviceCode;
@Schema(description = "登录用户ID")
private Long userId;
@Schema(description = "登录时间")
private LocalDateTime loginAt;
}

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;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("biz_license")
@Schema(description = "设备授权实体")
public class LicenseEntity extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "主键ID")
private Long id;
@Schema(description = "授权序列号")
private String licenseSerial;
@Schema(description = "授权码")
private String licenseCode;
@Schema(description = "授权类型1-临时2-正式")
private Integer licenseType;
@Schema(description = "授权状态1-未使用2-使用中3-已过期4-已失效")
private Integer licenseStatus;
@Schema(description = "产品BOM编码")
private String productCode;
@Schema(description = "绑定设备编码")
private String deviceCode;
@Schema(description = "绑定时间")
private LocalDateTime bindTime;
@Schema(description = "过期时间")
private LocalDateTime expireTime;
@Schema(description = "导入批次号")
private String importBatchNo;
@Schema(description = "导入时间")
private LocalDateTime importTime;
@Schema(description = "备注")
private String remark;
}

View File

@ -0,0 +1,19 @@
package com.imeeting.enums;
import lombok.Getter;
@Getter
public enum LicenseStatusEnum {
UNUSED(1, "未使用"),
IN_USE(2, "使用中"),
EXPIRED(3, "已过期"),
INVALID(4, "已失效");
private final int code;
private final String desc;
LicenseStatusEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
}

View File

@ -0,0 +1,17 @@
package com.imeeting.enums;
import lombok.Getter;
@Getter
public enum LicenseTypeEnum {
TEMPORARY(1, "临时"),
FORMAL(2, "正式");
private final int code;
private final String desc;
LicenseTypeEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
}

View File

@ -6,7 +6,8 @@ import lombok.Getter;
public enum MeetingPushTypeEnum { public enum MeetingPushTypeEnum {
PUBLIC_MEETING_LOGIN_CONFIRM("PUBLIC_MEETING_LOGIN_CONFIRM", "公有设备扫码登录确认消息"), PUBLIC_MEETING_LOGIN_CONFIRM("PUBLIC_MEETING_LOGIN_CONFIRM", "公有设备扫码登录确认消息"),
MEETING_PENDING("MEETING_PENDING", "待开始会议通知"), MEETING_PENDING("MEETING_PENDING", "待开始会议通知"),
MEETING_COMPLETED("MEETING_COMPLETED", "会议完成通知"); MEETING_COMPLETED("MEETING_COMPLETED", "会议完成通知"),
MEETING_STATUS_CHANGED("MEETING_STATUS_CHANGED", "会议状态变更通知");
private final String code; private final String code;
private final String desc; private final String desc;

View File

@ -3,9 +3,9 @@ package com.imeeting.grpc.push;
import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidDeviceSessionState; import com.imeeting.dto.android.AndroidDeviceSessionState;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.service.android.AndroidDeviceSessionService; import com.imeeting.service.android.AndroidDeviceSessionService;
import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.service.biz.DeviceOnlineManagementService; import com.imeeting.service.biz.DeviceOnlineManagementService;
import com.unisbase.common.exception.BusinessException; import com.unisbase.common.exception.BusinessException;
import io.grpc.BindableService; import io.grpc.BindableService;
@ -116,8 +116,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
request.getAppVersion(), request.getAppVersion(),
resolvePlatform(request.getPlatform()) resolvePlatform(request.getPlatform())
); );
authContext.setUserId(parseNullableLong(request.getUserId(), "user_id"));
authContext.setTenantId(parseNullableLong(request.getTenantId(), "tenant_id"));
AndroidDeviceSessionState sessionState = androidDeviceSessionService.openSession(authContext, request.getConnectionId()); AndroidDeviceSessionState sessionState = androidDeviceSessionService.openSession(authContext, request.getConnectionId());
connectionId = sessionState.getConnectionId(); connectionId = sessionState.getConnectionId();
deviceId = sessionState.getDeviceId(); deviceId = sessionState.getDeviceId();
@ -181,30 +179,15 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
return; return;
} }
if (!request.getConnectionId().isBlank() && !request.getConnectionId().equals(connectionId)) { if (!request.getConnectionId().isBlank() && !request.getConnectionId().equals(connectionId)) {
log.info(buildLog("gRPC确认拒绝",
"ACK连接ID与当前活动连接不一致请求连接ID=" + request.getConnectionId() + "当前连接ID=" + connectionId,
deviceId,
appVersion,
platform));
sendError(responseObserver, "PUSH_CONNECTION_MISMATCH", "Connection id does not match active session", false, sendError(responseObserver, "PUSH_CONNECTION_MISMATCH", "Connection id does not match active session", false,
deviceId, appVersion, platform); deviceId, appVersion, platform);
return; return;
} }
if (!request.getDeviceId().isBlank() && !request.getDeviceId().equals(deviceId)) { if (!request.getDeviceId().isBlank() && !request.getDeviceId().equals(deviceId)) {
log.info(buildLog("gRPC确认拒绝",
"ACK设备ID与当前活动设备不一致请求设备ID=" + request.getDeviceId() + "当前设备ID=" + deviceId,
deviceId,
appVersion,
platform));
sendError(responseObserver, "PUSH_DEVICE_MISMATCH", "Device id does not match active session", false, sendError(responseObserver, "PUSH_DEVICE_MISMATCH", "Device id does not match active session", false,
deviceId, appVersion, platform); deviceId, appVersion, platform);
return; return;
} }
log.info(buildLog("gRPC消息确认",
"收到客户端ACK确认消息ID=" + safe(request.getMessageId()) + "连接ID=" + connectionId,
deviceId,
appVersion,
platform));
androidPushMessageService.ack(request.getMessageId(), deviceId); androidPushMessageService.ack(request.getMessageId(), deviceId);
} }
@ -212,11 +195,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
if (connected) { if (connected) {
return true; return true;
} }
log.info(buildLog("gRPC请求拒绝",
"连接尚未建立即发送后续消息",
deviceId,
appVersion,
platform));
sendError(responseObserver, "PUSH_NOT_CONNECTED", "Push connection has not been established", false, sendError(responseObserver, "PUSH_NOT_CONNECTED", "Push connection has not been established", false,
deviceId, appVersion, platform); deviceId, appVersion, platform);
return false; return false;
@ -230,11 +208,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
androidGatewayPushService.unregister(connectionId); androidGatewayPushService.unregister(connectionId);
androidDeviceSessionService.closeSession(connectionId); androidDeviceSessionService.closeSession(connectionId);
deviceOnlineManagementService.recordDisconnected(deviceId, state == null ? null : state.getLastSeenAt()); deviceOnlineManagementService.recordDisconnected(deviceId, state == null ? null : state.getLastSeenAt());
log.info(buildLog("gRPC连接关闭",
"Android推送连接已关闭连接ID=" + connectionId,
deviceId,
state == null ? appVersion : state.getAppVersion(),
state == null ? platform : state.getPlatform()));
connectionId = null; connectionId = null;
deviceId = null; deviceId = null;
appVersion = null; appVersion = null;
@ -251,11 +224,6 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
String deviceId, String deviceId,
String appVersion, String appVersion,
String platform) { String platform) {
log.info(buildLog("gRPC错误响应",
"向客户端返回错误,错误码=" + safe(code) + ",原因=" + safe(message),
deviceId,
appVersion,
platform));
responseObserver.onNext(ServerMessage.newBuilder() responseObserver.onNext(ServerMessage.newBuilder()
.setError(ErrorEvent.newBuilder() .setError(ErrorEvent.newBuilder()
.setCode(code) .setCode(code)
@ -286,15 +254,4 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
case PLATFORM_UNKNOWN, UNRECOGNIZED -> "android"; case PLATFORM_UNKNOWN, UNRECOGNIZED -> "android";
}; };
} }
private Long parseNullableLong(String value, String fieldName) {
if (value == null || value.isBlank()) {
return null;
}
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException ex) {
throw new RuntimeException("Invalid " + fieldName);
}
}
} }

View File

@ -87,6 +87,8 @@ public interface DeviceInfoMapper extends BaseMapper<DeviceInfoEntity> {
d.terminal_type AS terminalType, d.terminal_type AS terminalType,
d.terminal_version AS terminalVersion, d.terminal_version AS terminalVersion,
d.last_online_at AS lastOnlineAt, d.last_online_at AS lastOnlineAt,
d.stats_reset_at AS statsResetAt,
d.weather_city_name AS weatherCityName,
d.status AS status, d.status AS status,
d.created_at AS createdAt, d.created_at AS createdAt,
d.updated_at AS updatedAt, d.updated_at AS updatedAt,

View File

@ -0,0 +1,31 @@
package com.imeeting.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.DeviceLoginLogEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
@Mapper
public interface DeviceLoginLogMapper extends BaseMapper<DeviceLoginLogEntity> {
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COUNT(DISTINCT user_id)
FROM biz_device_login_log
WHERE tenant_id = #{tenantId}
AND device_code = #{deviceCode}
AND is_deleted = 0
<if test="resetAt != null">
AND login_at &gt;= #{resetAt}
</if>
</script>
""")
Long countDistinctUsersSince(@Param("tenantId") Long tenantId,
@Param("deviceCode") String deviceCode,
@Param("resetAt") LocalDateTime resetAt);
}

View File

@ -0,0 +1,223 @@
package com.imeeting.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.dto.biz.LicenseVO;
import com.imeeting.entity.biz.LicenseEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface LicenseMapper extends BaseMapper<LicenseEntity> {
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT *
FROM biz_license
WHERE device_code = #{deviceCode}
AND is_deleted = 0
ORDER BY updated_at DESC, id DESC
LIMIT 1
""")
LicenseEntity selectByDeviceCodeIgnoreTenant(@Param("deviceCode") String deviceCode);
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT *
FROM biz_license
WHERE tenant_id = #{tenantId}
AND device_code = #{deviceCode}
AND license_status = 2
AND is_deleted = 0
AND (expire_time IS NULL OR expire_time > CURRENT_TIMESTAMP)
ORDER BY updated_at DESC, id DESC
LIMIT 1
""")
LicenseEntity selectValidBoundByTenantAndDeviceIgnoreTenant(@Param("tenantId") Long tenantId,
@Param("deviceCode") String deviceCode);
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT *
FROM biz_license
WHERE tenant_id = #{tenantId}
AND license_code = #{licenseCode}
AND is_deleted = 0
LIMIT 1
""")
LicenseEntity selectByTenantAndCodeIgnoreTenant(@Param("tenantId") Long tenantId, @Param("licenseCode") String licenseCode);
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT *
FROM biz_license
WHERE license_serial = #{licenseSerial}
AND is_deleted = 0
LIMIT 1
""")
LicenseEntity selectBySerialIgnoreTenant(@Param("licenseSerial") String licenseSerial);
@InterceptorIgnore(tenantLine = "true")
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
@Select("SELECT nextval('biz_license_temp_serial_seq')")
Long nextTempSerialValue();
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT *
FROM biz_license
WHERE tenant_id = #{tenantId}
AND license_status = 2
AND device_code = #{deviceCode}
AND is_deleted = 0
LIMIT 1
""")
LicenseEntity selectActiveByTenantAndDeviceIgnoreTenant(@Param("tenantId") Long tenantId, @Param("deviceCode") String deviceCode);
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT *
FROM biz_license
WHERE tenant_id = #{tenantId}
AND license_status = 2
AND device_code IS NOT NULL
AND license_type = 1
AND is_deleted = 0
ORDER BY bind_time ASC NULLS LAST, id ASC
""")
List<LicenseEntity> selectBoundTemporaryLicensesForReplace(@Param("tenantId") Long tenantId);
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT *
FROM biz_license
WHERE tenant_id = #{tenantId}
AND license_status = 1
AND license_type = 2
AND import_batch_no = #{importBatchNo}
AND is_deleted = 0
ORDER BY created_at ASC, id ASC
""")
List<LicenseEntity> selectUnusedFormalLicenses(@Param("tenantId") Long tenantId, @Param("importBatchNo") String importBatchNo);
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT *
FROM biz_license
WHERE tenant_id = #{tenantId}
AND license_status = 1
AND is_deleted = 0
AND (expire_time IS NULL OR expire_time > CURRENT_TIMESTAMP)
ORDER BY license_type ASC, created_at ASC, id ASC
LIMIT 1
""")
LicenseEntity selectFirstAssignableLicense(@Param("tenantId") Long tenantId);
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT *
FROM biz_license
WHERE tenant_id = #{tenantId}
AND license_status = 2
AND device_code IS NOT NULL
AND is_deleted = 0
ORDER BY bind_time ASC NULLS LAST, id ASC
""")
List<LicenseEntity> selectBoundLicenses(@Param("tenantId") Long tenantId);
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT
id,
tenant_id AS tenantId,
license_serial AS licenseSerial,
license_code AS licenseCode,
license_type AS licenseType,
license_status AS licenseStatus,
product_code AS productCode,
device_code AS deviceCode,
bind_time AS bindTime,
expire_time AS expireTime,
import_batch_no AS importBatchNo,
import_time AS importTime,
remark
FROM biz_license
WHERE tenant_id = #{tenantId}
AND is_deleted = 0
ORDER BY updated_at DESC, id DESC
""")
List<LicenseVO> selectListByTenant(@Param("tenantId") Long tenantId);
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE biz_license
SET license_status = 3,
device_code = NULL,
bind_time = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE is_deleted = 0
AND license_status IN (1, 2)
AND expire_time IS NOT NULL
AND expire_time <= CURRENT_TIMESTAMP
""")
int expireDueLicenses();
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE biz_license
SET device_code = NULL,
bind_time = NULL,
license_status = #{licenseStatus},
updated_at = CURRENT_TIMESTAMP
WHERE id = #{id}
AND is_deleted = 0
""")
int clearBindingById(@Param("id") Long id, @Param("licenseStatus") Integer licenseStatus);
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE biz_license
SET device_code = #{deviceCode},
bind_time = #{bindTime},
license_status = #{licenseStatus},
updated_at = CURRENT_TIMESTAMP
WHERE id = #{id}
AND is_deleted = 0
""")
int bindLicenseById(@Param("id") Long id,
@Param("deviceCode") String deviceCode,
@Param("bindTime") LocalDateTime bindTime,
@Param("licenseStatus") Integer licenseStatus);
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE biz_license
SET license_status = 4,
device_code = NULL,
bind_time = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE tenant_id = #{tenantId}
AND license_type = 1
AND is_deleted = 0
AND license_status IN (1, 2, 3)
""")
int invalidateAllTemporaryLicenses(@Param("tenantId") Long tenantId);
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE biz_license
SET license_status = 4,
device_code = NULL,
bind_time = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = #{id}
AND is_deleted = 0
""")
int invalidateById(@Param("id") Long id);
}

View File

@ -7,9 +7,45 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
@Mapper @Mapper
public interface MeetingMapper extends BaseMapper<Meeting> { public interface MeetingMapper extends BaseMapper<Meeting> {
@InterceptorIgnore(tenantLine = "true") @InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM biz_meetings WHERE id = #{id} AND is_deleted = 0") @Select("SELECT * FROM biz_meetings WHERE id = #{id} AND is_deleted = 0")
Meeting selectByIdIgnoreTenant(@Param("id") Long id); Meeting selectByIdIgnoreTenant(@Param("id") Long id);
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COUNT(1)
FROM biz_meetings
WHERE tenant_id = #{tenantId}
AND source_device_code = #{deviceCode}
AND is_deleted = 0
<if test="resetAt != null">
AND created_at &gt;= #{resetAt}
</if>
</script>
""")
Long countByDeviceSince(@Param("tenantId") Long tenantId,
@Param("deviceCode") String deviceCode,
@Param("resetAt") LocalDateTime resetAt);
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COALESCE(SUM(COALESCE(effective_audio_duration_seconds, 0)), 0)
FROM biz_meetings
WHERE tenant_id = #{tenantId}
AND source_device_code = #{deviceCode}
AND is_deleted = 0
<if test="resetAt != null">
AND created_at &gt;= #{resetAt}
</if>
</script>
""")
Long sumMeetingDurationSecondsByDeviceSince(@Param("tenantId") Long tenantId,
@Param("deviceCode") String deviceCode,
@Param("resetAt") LocalDateTime resetAt);
} }

View File

@ -1,9 +1,27 @@
package com.imeeting.mapper.biz; package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.ScreenSaver; import com.imeeting.entity.biz.ScreenSaver;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper @Mapper
public interface ScreenSaverMapper extends BaseMapper<ScreenSaver> { public interface ScreenSaverMapper extends BaseMapper<ScreenSaver> {
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT *
FROM biz_screen_savers
WHERE tenant_id = #{tenantId}
AND scope_type = 'PLATFORM'
AND status = 1
AND owner_user_id IS NULL
AND is_deleted = 0
ORDER BY sort_order ASC, id DESC
""")
List<ScreenSaver> selectActivePlatformByTenantIgnoreTenant(@Param("tenantId") Long tenantId);
} }

View File

@ -9,4 +9,6 @@ public interface AndroidAuthService {
AndroidAuthContext authenticateHttp(HttpServletRequest request); AndroidAuthContext authenticateHttp(HttpServletRequest request);
AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered); AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered);
AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered, boolean allowOptionalToken);
} }

View File

@ -6,4 +6,6 @@ public interface AndroidDeviceBindingService {
void validatePrivateDeviceAccess(String deviceCode, Long tenantId, Long userId); void validatePrivateDeviceAccess(String deviceCode, Long tenantId, Long userId);
void unbindPrivateDevice(String deviceCode); void unbindPrivateDevice(String deviceCode);
void recordLogin(String deviceCode, Long tenantId, Long userId);
} }

View File

@ -0,0 +1,9 @@
package com.imeeting.service.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidDeviceHomeStatsVO;
public interface AndroidDeviceHomeService {
AndroidDeviceHomeStatsVO getHomeStats(AndroidAuthContext authContext);
}

View File

@ -3,7 +3,7 @@ package com.imeeting.service.android;
import com.imeeting.dto.android.AndroidDeviceRegisterResponse; import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
public interface AndroidDeviceRegistrationService { public interface AndroidDeviceRegistrationService {
AndroidDeviceRegisterResponse register(String deviceCode, String deviceName, String terminalType, String terminalVersion); AndroidDeviceRegisterResponse register(String tenantCode, String deviceCode, String deviceName, String terminalType, String terminalVersion);
void requireRegistered(String deviceCode); void requireRegistered(String deviceCode);
} }

View File

@ -8,4 +8,6 @@ public interface AndroidMeetingPushService {
void pushPublicLoginConfirm(String deviceId, AndroidPublicLoginConfirmPayload payload); void pushPublicLoginConfirm(String deviceId, AndroidPublicLoginConfirmPayload payload);
void pushMeetingCompleted(Long meetingId); void pushMeetingCompleted(Long meetingId);
void pushMeetingStatusChanged(Long meetingId, String statusCode);
} }

View File

@ -3,9 +3,11 @@ package com.imeeting.service.android.impl;
import com.imeeting.config.grpc.AndroidGrpcAuthProperties; import com.imeeting.config.grpc.AndroidGrpcAuthProperties;
import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.entity.biz.LicenseEntity;
import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidDeviceBindingService; import com.imeeting.service.android.AndroidDeviceBindingService;
import com.imeeting.service.biz.LicenseService;
import com.unisbase.common.exception.BusinessException; import com.unisbase.common.exception.BusinessException;
import com.unisbase.dto.InternalAuthCheckResponse; import com.unisbase.dto.InternalAuthCheckResponse;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
@ -34,27 +36,34 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
private final TokenValidationService tokenValidationService; private final TokenValidationService tokenValidationService;
private final DeviceInfoMapper deviceInfoMapper; private final DeviceInfoMapper deviceInfoMapper;
private final AndroidDeviceBindingService androidDeviceBindingService; private final AndroidDeviceBindingService androidDeviceBindingService;
private final LicenseService licenseService;
@Override @Override
public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform) { public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform) {
if (properties.isEnabled() && !properties.isAllowAnonymous()) { if (properties.isEnabled() && !properties.isAllowAnonymous()) {
throw new RuntimeException("Android gRPC push does not allow anonymous access"); throw new RuntimeException("Android gRPC push does not allow anonymous access");
} }
LicenseEntity license = licenseService.requireValidBoundLicense(deviceId);
DeviceInfoEntity device = requireRegisteredDevice(deviceId); DeviceInfoEntity device = requireRegisteredDevice(deviceId);
assertDeviceEnabled(device); assertDeviceEnabled(device);
AndroidAuthContext context = buildContext("NONE", true, deviceId, null, appVersion, platform, null, null, null, null); AndroidAuthContext context = buildContext("NONE", true, deviceId, null, appVersion, platform, null, null, null, null);
context.setUserId(device.getUserId()); context.setUserId(device.getUserId());
context.setTenantId(device.getTenantId()); context.setTenantId(license.getTenantId());
return context; return context;
} }
@Override @Override
public AndroidAuthContext authenticateHttp(HttpServletRequest request) { public AndroidAuthContext authenticateHttp(HttpServletRequest request) {
return authenticateHttp(request, true); return authenticateHttp(request, true, false);
} }
@Override @Override
public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered) { public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered) {
return authenticateHttp(request, requireRegistered, false);
}
@Override
public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered, boolean allowOptionalToken) {
LoginUser loginUser = currentLoginUser(); LoginUser loginUser = currentLoginUser();
String resolvedToken = resolveHttpToken(request); String resolvedToken = resolveHttpToken(request);
String deviceId = firstHeader(request, HEADER_DEVICE_ID); String deviceId = firstHeader(request, HEADER_DEVICE_ID);
@ -63,12 +72,17 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
String platform = request.getHeader(HEADER_PLATFORM); String platform = request.getHeader(HEADER_PLATFORM);
requireAndroidHttpHeaders(deviceId, appVersion, platform); requireAndroidHttpHeaders(deviceId, appVersion, platform);
log.info("[安卓接口访问]X-Android-Device-Id={},X-Android-App-Version={},X-Android-Platform={}",deviceId,appVersion,platform); log.info("[安卓接口访问]X-Android-Device-Id={},X-Android-App-Version={},X-Android-Platform={}", deviceId, appVersion, platform);
DeviceInfoEntity device = requireRegistered ? requireRegisteredDevice(deviceId) : findDevice(deviceId); DeviceInfoEntity device = requireRegistered ? requireRegisteredDevice(deviceId) : findDevice(deviceId);
assertDeviceEnabled(device); assertDeviceEnabled(device);
LicenseEntity license = requireRegistered ? licenseService.requireValidBoundLicense(deviceId) : null;
if (loginUser != null) { if (loginUser != null) {
androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, loginUser.getTenantId(), loginUser.getUserId()); if (!allowOptionalToken) {
return buildContext("USER_JWT", false, androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, loginUser.getTenantId(), loginUser.getUserId());
}
AndroidAuthContext context = buildContext("USER_JWT", false,
deviceId, deviceId,
appId, appId,
appVersion, appVersion,
@ -77,12 +91,15 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
null, null,
null, null,
loginUser); loginUser);
return applyLicenseContext(context, license, allowOptionalToken);
} }
if (StringUtils.hasText(resolvedToken)) { if (StringUtils.hasText(resolvedToken)) {
InternalAuthCheckResponse authResult = validateToken(resolvedToken); InternalAuthCheckResponse authResult = validateToken(resolvedToken);
androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, authResult.getTenantId(), authResult.getUserId()); if (requireRegistered && !allowOptionalToken) {
return buildContext("USER_JWT", false, androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, authResult.getTenantId(), authResult.getUserId());
}
AndroidAuthContext context = buildContext("USER_JWT", false,
deviceId, deviceId,
appId, appId,
appVersion, appVersion,
@ -91,9 +108,11 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
null, null,
authResult, authResult,
null); null);
return applyLicenseContext(context, license, allowOptionalToken);
} }
if (properties.isAllowAnonymous()) { if (properties.isAllowAnonymous()) {
return buildContext("NONE", true, AndroidAuthContext context = buildContext("NONE", true,
deviceId, deviceId,
appId, appId,
appVersion, appVersion,
@ -102,10 +121,33 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
null, null,
null, null,
null); null);
return applyLicenseContext(context, license, allowOptionalToken);
} }
throw new RuntimeException("Missing Android HTTP access token"); throw new RuntimeException("Missing Android HTTP access token");
} }
private AndroidAuthContext applyLicenseContext(AndroidAuthContext context, LicenseEntity license, boolean allowOptionalToken) {
if (context == null) {
return null;
}
if (license == null) {
return context;
}
Long currentTenantId = context.getTenantId();
context.setTenantId(license.getTenantId());
if (allowOptionalToken && context.getUserId() != null && currentTenantId != null && !currentTenantId.equals(license.getTenantId())) {
context.setAnonymous(true);
context.setAuthMode("NONE");
context.setUserId(null);
context.setUsername(null);
context.setDisplayName(null);
context.setPlatformAdmin(null);
context.setTenantAdmin(null);
context.setPermissions(null);
}
return context;
}
private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId,
String appId, String appVersion, String platform, String accessToken, String appId, String appVersion, String platform, String accessToken,
String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) { String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) {

View File

@ -1,7 +1,9 @@
package com.imeeting.service.android.impl; package com.imeeting.service.android.impl;
import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.mapper.DeviceLoginLogMapper;
import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.entity.biz.DeviceLoginLogEntity;
import com.imeeting.service.android.AndroidDeviceBindingService; import com.imeeting.service.android.AndroidDeviceBindingService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -14,6 +16,7 @@ import java.util.Objects;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingService { public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingService {
private final DeviceInfoMapper deviceInfoMapper; private final DeviceInfoMapper deviceInfoMapper;
private final DeviceLoginLogMapper deviceLoginLogMapper;
@Override @Override
public void bindPrivateDevice(String deviceCode, Long tenantId, Long userId, String appVersion, String platform) { public void bindPrivateDevice(String deviceCode, Long tenantId, Long userId, String appVersion, String platform) {
@ -61,6 +64,20 @@ public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingServ
deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing); deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing);
} }
@Override
public void recordLogin(String deviceCode, Long tenantId, Long userId) {
if (!StringUtils.hasText(deviceCode) || tenantId == null || userId == null) {
return;
}
DeviceLoginLogEntity entity = new DeviceLoginLogEntity();
entity.setTenantId(tenantId);
entity.setStatus(1);
entity.setDeviceCode(deviceCode.trim());
entity.setUserId(userId);
entity.setLoginAt(LocalDateTime.now());
deviceLoginLogMapper.insert(entity);
}
private String normalize(String value) { private String normalize(String value) {
if (!StringUtils.hasText(value)) { if (!StringUtils.hasText(value)) {
return null; return null;

View File

@ -0,0 +1,246 @@
package com.imeeting.service.android.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.RedisKeys;
import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidDeviceHomeStatsVO;
import com.imeeting.dto.android.AndroidDeviceHomeWeatherVO;
import com.imeeting.dto.android.AndroidDeviceWeatherCacheValue;
import com.imeeting.dto.biz.MeetingPointsBalanceVO;
import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.entity.biz.LicenseEntity;
import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.mapper.DeviceLoginLogMapper;
import com.imeeting.mapper.LicenseMapper;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.service.android.AndroidDeviceHomeService;
import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.zip.GZIPInputStream;
@Service
@RequiredArgsConstructor
@Slf4j
public class AndroidDeviceHomeServiceImpl implements AndroidDeviceHomeService {
private static final Duration WEATHER_CACHE_TTL = Duration.ofHours(1);
private static final Duration WEATHER_CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration WEATHER_READ_TIMEOUT = Duration.ofSeconds(8);
private static final String DEFAULT_QWEATHER_BASE_URL = "https://devapi.qweather.com";
private final DeviceInfoMapper deviceInfoMapper;
private final LicenseMapper licenseMapper;
private final MeetingMapper meetingMapper;
private final DeviceLoginLogMapper deviceLoginLogMapper;
private final MeetingPointsService meetingPointsService;
private final RedisSupport redisSupport;
private final com.unisbase.service.SysParamService sysParamService;
private final ObjectMapper objectMapper;
@Value("${imeeting.h5.base-url:}")
private String h5BaseUrl;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(WEATHER_CONNECT_TIMEOUT)
.version(HttpClient.Version.HTTP_1_1)
.build();
@Override
public AndroidDeviceHomeStatsVO getHomeStats(AndroidAuthContext authContext) {
if (authContext == null || !StringUtils.hasText(authContext.getDeviceId())) {
throw new RuntimeException("设备上下文不存在");
}
DeviceInfoEntity device = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(authContext.getDeviceId());
if (device == null) {
throw new RuntimeException("设备未注册,请先完成设备注册");
}
Long tenantId = authContext.getTenantId();
LicenseEntity license = licenseMapper.selectValidBoundByTenantAndDeviceIgnoreTenant(tenantId, authContext.getDeviceId());
if (license == null) {
throw new RuntimeException("设备未绑定有效授权");
}
LocalDateTime resetAt = device.getStatsResetAt();
Long meetingCount = defaultLong(meetingMapper.countByDeviceSince(tenantId, authContext.getDeviceId(), resetAt));
Long totalSeconds = defaultLong(meetingMapper.sumMeetingDurationSecondsByDeviceSince(tenantId, authContext.getDeviceId(), resetAt));
Long loginUserCount = defaultLong(deviceLoginLogMapper.countDistinctUsersSince(tenantId, authContext.getDeviceId(), resetAt));
AndroidDeviceHomeStatsVO vo = new AndroidDeviceHomeStatsVO();
vo.setDeviceName(device.getDeviceName());
vo.setLicenseType(license.getLicenseType());
vo.setMeetingCount(meetingCount);
vo.setMeetingDurationMinutes(toCeilMinutes(totalSeconds));
vo.setLoginUserCount(loginUserCount);
vo.setH5BaseUrl(trimToNull(h5BaseUrl));
vo.setRemainingMinutes(calculateRemainingMinutes(tenantId, authContext.getUserId(), authContext.isAnonymous()));
vo.setWeather(resolveWeather(device.getWeatherCityName()));
vo.setLoggedIn(!authContext.isAnonymous() && authContext.getUserId() != null);
return vo;
}
private Long calculateRemainingMinutes(Long tenantId, Long userId, boolean anonymous) {
if (tenantId == null) {
return 0L;
}
MeetingPointsBalanceVO balance = meetingPointsService.getBalanceView(tenantId, anonymous ? null : userId);
long totalPoints = defaultLong(balance.getPublicBalance());
if (!anonymous && userId != null) {
totalPoints += defaultLong(balance.getPersonalBalance());
}
int unitMinutes = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_UNIT_MINUTES, "1"), 1);
int costPerUnit = positiveInt(sysParamService.getCachedParamValue(SysParamKeys.MEETING_POINTS_COST_PER_UNIT, "1"), 1);
return costPerUnit <= 0 ? 0L : (totalPoints * unitMinutes) / costPerUnit;
}
private AndroidDeviceHomeWeatherVO resolveWeather(String cityName) {
if (!StringUtils.hasText(cityName)) {
return null;
}
String normalizedCity = cityName.trim();
AndroidDeviceWeatherCacheValue cached = redisSupport.getJsonQuietly(RedisKeys.androidDeviceWeatherKey(normalizedCity), AndroidDeviceWeatherCacheValue.class);
if (cached != null) {
return toWeatherVO(cached);
}
String apiKey = sysParamService.getCachedParamValue(SysParamKeys.DEVICE_WEATHER_QWEATHER_KEY, "");
String baseUrl = normalizeBaseUrl(sysParamService.getCachedParamValue(
SysParamKeys.DEVICE_WEATHER_QWEATHER_BASE_URL,
DEFAULT_QWEATHER_BASE_URL
));
if (!StringUtils.hasText(apiKey) || !StringUtils.hasText(baseUrl)) {
return null;
}
try {
String location = URLEncoder.encode(normalizedCity, StandardCharsets.UTF_8);
String url = baseUrl + "/geo/v2/city/lookup?location=" + location + "&key=" + apiKey.trim();
HttpRequest cityRequest = HttpRequest.newBuilder(URI.create(url))
.timeout(WEATHER_READ_TIMEOUT)
.header("Accept-Encoding", "gzip")
.GET()
.build();
HttpResponse<byte[]> cityResponse = httpClient.send(cityRequest, HttpResponse.BodyHandlers.ofByteArray());
String locationId = parseLocationId(readResponseBody(cityResponse));
if (!StringUtils.hasText(locationId)) {
return null;
}
String weatherUrl = baseUrl + "/v7/weather/now?location=" + locationId + "&key=" + apiKey.trim();
HttpRequest weatherRequest = HttpRequest.newBuilder(URI.create(weatherUrl))
.timeout(WEATHER_READ_TIMEOUT)
.header("Accept-Encoding", "gzip")
.GET()
.build();
HttpResponse<byte[]> weatherResponse = httpClient.send(weatherRequest, HttpResponse.BodyHandlers.ofByteArray());
AndroidDeviceWeatherCacheValue value = parseWeatherValue(normalizedCity, readResponseBody(weatherResponse));
if (value == null) {
return null;
}
redisSupport.setJson(RedisKeys.androidDeviceWeatherKey(normalizedCity), value, WEATHER_CACHE_TTL);
return toWeatherVO(value);
} catch (Exception ex) {
log.warn("查询设备天气失败, cityName={}", normalizedCity, ex);
return null;
}
}
private String parseLocationId(String body) throws Exception {
JsonNode root = objectMapper.readTree(body);
JsonNode location = root.path("location");
if (!location.isArray() || location.isEmpty()) {
return null;
}
JsonNode first = location.get(0);
String id = first.path("id").asText(null);
return StringUtils.hasText(id) ? id.trim() : null;
}
private AndroidDeviceWeatherCacheValue parseWeatherValue(String cityName, String body) throws Exception {
JsonNode root = objectMapper.readTree(body);
JsonNode now = root.path("now");
if (now.isMissingNode() || now.isNull()) {
return null;
}
AndroidDeviceWeatherCacheValue value = new AndroidDeviceWeatherCacheValue();
value.setCityName(cityName);
value.setText(trimToNull(now.path("text").asText(null)));
value.setTemperature(trimToNull(now.path("temp").asText(null)));
return value;
}
private AndroidDeviceHomeWeatherVO toWeatherVO(AndroidDeviceWeatherCacheValue cached) {
AndroidDeviceHomeWeatherVO vo = new AndroidDeviceHomeWeatherVO();
vo.setCityName(cached.getCityName());
vo.setText(cached.getText());
vo.setTemperature(cached.getTemperature());
return vo;
}
private long defaultLong(Long value) {
return value == null ? 0L : value;
}
private long toCeilMinutes(long durationSeconds) {
if (durationSeconds <= 0L) {
return 0L;
}
return (long) Math.ceil(durationSeconds / 60.0d);
}
private int positiveInt(String value, int defaultValue) {
try {
int resolved = Integer.parseInt(String.valueOf(value).trim());
return resolved > 0 ? resolved : defaultValue;
} catch (Exception ex) {
return defaultValue;
}
}
private String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private String normalizeBaseUrl(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
String normalized = value.trim();
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private String readResponseBody(HttpResponse<byte[]> response) throws Exception {
if (response == null || response.body() == null) {
return "";
}
String contentEncoding = response.headers().firstValue("Content-Encoding").orElse("");
byte[] payload = response.body();
if (!StringUtils.hasText(contentEncoding) || !contentEncoding.toLowerCase(Locale.ROOT).contains("gzip")) {
return new String(payload, StandardCharsets.UTF_8);
}
try (InputStream inputStream = new GZIPInputStream(new ByteArrayInputStream(payload))) {
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
}
}

View File

@ -1,11 +1,18 @@
package com.imeeting.service.android.impl; package com.imeeting.service.android.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.dto.android.AndroidDeviceRegisterResponse; import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.entity.biz.DeviceInfoEntity;
import com.imeeting.entity.biz.LicenseEntity;
import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.service.android.AndroidDeviceRegistrationService; import com.imeeting.service.android.AndroidDeviceRegistrationService;
import com.imeeting.service.biz.LicenseService;
import com.unisbase.common.exception.BusinessException;
import com.unisbase.entity.SysTenant;
import com.unisbase.mapper.SysTenantMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -14,35 +21,49 @@ import java.time.LocalDateTime;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AndroidDeviceRegistrationServiceImpl implements AndroidDeviceRegistrationService { public class AndroidDeviceRegistrationServiceImpl implements AndroidDeviceRegistrationService {
private final DeviceInfoMapper deviceInfoMapper; private final DeviceInfoMapper deviceInfoMapper;
private final SysTenantMapper sysTenantMapper;
private final LicenseService licenseService;
@Override @Override
public AndroidDeviceRegisterResponse register(String deviceCode, String deviceName, String terminalType, String terminalVersion) { @Transactional(rollbackFor = Exception.class)
if (!StringUtils.hasText(deviceCode)) { public AndroidDeviceRegisterResponse register(String tenantCode, String deviceCode, String deviceName, String terminalType, String terminalVersion) {
throw new RuntimeException("deviceId不能为空"); if (!StringUtils.hasText(tenantCode)) {
throw new BusinessException("400", "tenantCode不能为空");
} }
if (!StringUtils.hasText(deviceCode)) {
throw new BusinessException("400", "deviceId不能为空");
}
SysTenant tenant = requireTenant(tenantCode.trim());
String normalizedDeviceCode = deviceCode.trim(); String normalizedDeviceCode = deviceCode.trim();
licenseService.validateDeviceCanRegisterToTenant(normalizedDeviceCode, tenant.getId());
DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(normalizedDeviceCode); DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(normalizedDeviceCode);
if (existing == null) { if (existing == null) {
existing = new DeviceInfoEntity(); existing = new DeviceInfoEntity();
existing.setDeviceCode(normalizedDeviceCode); existing.setDeviceCode(normalizedDeviceCode);
existing.setDeviceName(normalize(deviceName));
existing.setTerminalType(normalizeTerminalType(terminalType));
existing.setTerminalVersion(normalize(terminalVersion));
existing.setLastOnlineAt(LocalDateTime.now());
existing.setStatus(1); existing.setStatus(1);
}
existing.setTenantId(tenant.getId());
existing.setDeviceName(normalize(deviceName));
existing.setTerminalType(normalizeTerminalType(terminalType));
existing.setTerminalVersion(normalize(terminalVersion));
existing.setLastOnlineAt(LocalDateTime.now());
if (existing.getDeviceId() == null) {
deviceInfoMapper.insert(existing); deviceInfoMapper.insert(existing);
} else { } else {
existing.setDeviceName(normalize(deviceName)); deviceInfoMapper.updateById(existing);
existing.setTerminalType(normalizeTerminalType(terminalType));
existing.setTerminalVersion(normalize(terminalVersion));
deviceInfoMapper.updateBaseInfoByIdIgnoreTenant(existing);
} }
LicenseEntity license = licenseService.allocateForDeviceRegistration(tenant.getId(), tenant.getTenantCode(), normalizedDeviceCode);
AndroidDeviceRegisterResponse response = new AndroidDeviceRegisterResponse(); AndroidDeviceRegisterResponse response = new AndroidDeviceRegisterResponse();
response.setDeviceCode(existing.getDeviceCode()); response.setDeviceCode(existing.getDeviceCode());
response.setDeviceName(existing.getDeviceName()); response.setDeviceName(existing.getDeviceName());
response.setTerminalType(existing.getTerminalType()); response.setTerminalType(existing.getTerminalType());
response.setTerminalVersion(existing.getTerminalVersion()); response.setTerminalVersion(existing.getTerminalVersion());
response.setOccupied(existing.getUserId() != null); response.setOccupied(existing.getUserId() != null);
response.setLicenseType(license.getLicenseType());
return response; return response;
} }
@ -53,6 +74,17 @@ public class AndroidDeviceRegistrationServiceImpl implements AndroidDeviceRegist
} }
} }
private SysTenant requireTenant(String tenantCode) {
SysTenant tenant = sysTenantMapper.selectOne(new LambdaQueryWrapper<SysTenant>()
.eq(SysTenant::getTenantCode, tenantCode)
.eq(SysTenant::getIsDeleted, 0)
.last("LIMIT 1"));
if (tenant == null || tenant.getId() == null) {
throw new BusinessException("400", "租户不存在");
}
return tenant;
}
private String normalize(String value) { private String normalize(String value) {
if (!StringUtils.hasText(value)) { if (!StringUtils.hasText(value)) {
return null; return null;

View File

@ -2,18 +2,16 @@ package com.imeeting.service.android.impl;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.imeeting.dto.android.AndroidPublicLoginConfirmPayload; import com.imeeting.dto.android.AndroidPublicLoginConfirmPayload;
import com.imeeting.dto.biz.MeetingVO; import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingPushTypeEnum; import com.imeeting.enums.MeetingPushTypeEnum;
import com.imeeting.grpc.push.PushMessage; import com.imeeting.grpc.push.PushMessage;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.android.AndroidMeetingPushService; import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.service.biz.MeetingQueryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -26,13 +24,15 @@ import java.util.UUID;
@Service @Service
public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService { public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService {
private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
@Autowired @Autowired
@Lazy private MeetingMapper meetingMapper;
private MeetingQueryService meetingQueryService;
@Autowired @Autowired
private AndroidGatewayPushService androidGatewayPushService; private AndroidGatewayPushService androidGatewayPushService;
@Autowired
private AndroidPushMessageService androidPushMessageService; @Autowired
private AndroidPushMessageService androidPushMessageService;
@Value("${imeeting.android.push.pending-expire-minutes:30}") @Value("${imeeting.android.push.pending-expire-minutes:30}")
private long pendingExpireMinutes; private long pendingExpireMinutes;
@ -42,7 +42,7 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
if (meetingId == null || deviceId == null || deviceId.isBlank()) { if (meetingId == null || deviceId == null || deviceId.isBlank()) {
return; return;
} }
MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId); Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null) { if (meeting == null) {
return; return;
} }
@ -89,7 +89,7 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
if (meetingId == null) { if (meetingId == null) {
return; return;
} }
MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId); Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null || meeting.getTenantId() == null || meeting.getCreatorId() == null) { if (meeting == null || meeting.getTenantId() == null || meeting.getCreatorId() == null) {
return; return;
} }
@ -106,29 +106,51 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
meetingId, meeting.getTenantId(), meeting.getCreatorId(), pushed); meetingId, meeting.getTenantId(), meeting.getCreatorId(), pushed);
} }
private String resolvePendingTitle(MeetingVO meeting) { @Override
public void pushMeetingStatusChanged(Long meetingId, String statusCode) {
if (meetingId == null || statusCode == null || statusCode.isBlank()) {
return;
}
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null || meeting.getSourceDeviceCode() == null || meeting.getSourceDeviceCode().isBlank()) {
return;
}
PushMessage message = PushMessage.newBuilder()
.setMessageId("meeting_status_changed:" + meetingId + ":" + UUID.randomUUID())
.setTimestamp(System.currentTimeMillis())
.setType(MeetingPushTypeEnum.MEETING_STATUS_CHANGED.getCode())
.setTitle("会议状态已更新")
.setContent(buildStatusChangedContent(meetingId, statusCode))
.setNeedAck(false)
.build();
int pushed = androidGatewayPushService.pushToDevice(meeting.getSourceDeviceCode(), message);
log.info("Android meeting status change push finished, meetingId={}, deviceId={}, statusCode={}, pushedConnections={}",
meetingId, meeting.getSourceDeviceCode(), statusCode, pushed);
}
private String resolvePendingTitle(Meeting meeting) {
String title = meeting.getTitle(); String title = meeting.getTitle();
if (title != null && !title.isBlank()) { if (title != null && !title.isBlank()) {
return "待开始会议: " + title.trim(); return "待开始会议 " + title.trim();
} }
LocalDateTime meetingTime = meeting.getMeetingTime(); LocalDateTime meetingTime = meeting.getMeetingTime();
return meetingTime == null return meetingTime == null
? "待开始会议" ? "待开始会议"
: "待开始会议: " + TITLE_TIME_FORMATTER.format(meetingTime); : "待开始会议 " + TITLE_TIME_FORMATTER.format(meetingTime);
} }
private String resolveCompletedTitle(MeetingVO meeting) { private String resolveCompletedTitle(Meeting meeting) {
String title = meeting.getTitle(); String title = meeting.getTitle();
if (title != null && !title.isBlank()) { if (title != null && !title.isBlank()) {
return "会议已完成: " + title.trim(); return "会议已完成 " + title.trim();
} }
LocalDateTime meetingTime = meeting.getMeetingTime(); LocalDateTime meetingTime = meeting.getMeetingTime();
return meetingTime == null return meetingTime == null
? "会议已完成" ? "会议已完成"
: "会议已完成: " + TITLE_TIME_FORMATTER.format(meetingTime); : "会议已完成 " + TITLE_TIME_FORMATTER.format(meetingTime);
} }
private String buildPendingContent(MeetingVO meeting) { private String buildPendingContent(Meeting meeting) {
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("meetingId", meeting.getId()); result.put("meetingId", meeting.getId());
result.put("title", meeting.getTitle()); result.put("title", meeting.getTitle());
@ -139,9 +161,16 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
return JSONUtil.toJsonStr(result); return JSONUtil.toJsonStr(result);
} }
private String buildCompletedContent(MeetingVO meeting) { private String buildCompletedContent(Meeting meeting) {
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("meetingId", meeting.getId()); result.put("meetingId", meeting.getId());
return JSONUtil.toJsonStr(result); return JSONUtil.toJsonStr(result);
} }
private String buildStatusChangedContent(Long meetingId, String statusCode) {
Map<String, Object> result = new HashMap<>();
result.put("meetingId", meetingId);
result.put("statusCode", statusCode);
return JSONUtil.toJsonStr(result);
}
} }

View File

@ -178,19 +178,19 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
String stagingUrl = storeStagingAudio(audioFile); String stagingUrl = storeStagingAudio(audioFile);
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl); String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> { taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
meeting.setAudioUrl(relocatedUrl); meetingDomainSupport.applyMeetingAudioMetadata(meeting, relocatedUrl);
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
meeting.setAudioSaveMessage(null); meeting.setAudioSaveMessage(null);
meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED); meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED);
meeting.setStatus(1); meeting.setStatus(1);
meetingService.updateById(meeting); meetingService.updateById(meeting);
resetOrCreateAsrTask(meetingId, profile); resetOrCreateAsrTask(meetingId, profile);
resetOrCreateChapterTask(meetingId, profile); resetOrCreateChapterTask(meetingId, profile);
resetOrCreateSummaryTask(meetingId, profile); resetOrCreateSummaryTask(meetingId, profile);
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId()); dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
}); });
return new LegacyUploadAudioResponse(meetingId, relocatedUrl); return new LegacyUploadAudioResponse(meetingId, relocatedUrl);
} }
@ -248,7 +248,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
String stagingUrl = storeStagingAudio(audioFile); String stagingUrl = storeStagingAudio(audioFile);
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl); String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> { taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
meeting.setAudioUrl(relocatedUrl); meetingDomainSupport.applyMeetingAudioMetadata(meeting, relocatedUrl);
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
meeting.setAudioSaveMessage(null); meeting.setAudioSaveMessage(null);
meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED); meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED);

View File

@ -18,4 +18,8 @@ public interface DeviceOnlineManagementService {
DeviceOnlineAdminVO update(Long id, DeviceAdminUpdateCommand command, LoginUser loginUser); DeviceOnlineAdminVO update(Long id, DeviceAdminUpdateCommand command, LoginUser loginUser);
boolean kick(Long id, LoginUser loginUser); boolean kick(Long id, LoginUser loginUser);
boolean delete(Long id, LoginUser loginUser);
boolean resetStats(Long id, LoginUser loginUser);
} }

View File

@ -0,0 +1,26 @@
package com.imeeting.service.biz;
import com.imeeting.dto.biz.LicenseImportResultVO;
import com.imeeting.dto.biz.LicenseVO;
import com.imeeting.entity.biz.LicenseEntity;
import com.unisbase.security.LoginUser;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
public interface LicenseService {
void initializeTemporaryLicenses(Long tenantId);
LicenseEntity allocateForDeviceRegistration(Long tenantId, String tenantCode, String deviceCode);
LicenseEntity requireValidBoundLicense(String deviceCode);
void validateDeviceCanRegisterToTenant(String deviceCode, Long tenantId);
void unbindDeviceLicense(String deviceCode);
LicenseImportResultVO importFormalLicenses(MultipartFile file, LoginUser loginUser) throws IOException;
List<LicenseVO> listCurrentTenantLicenses(LoginUser loginUser);
}

View File

@ -0,0 +1,11 @@
package com.imeeting.service.biz;
import com.imeeting.dto.biz.MeetingProgressSnapshot;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusVO;
public interface MeetingUnifiedStatusService {
UnifiedMeetingStatusVO resolve(MeetingVO meeting, MeetingProgressSnapshot snapshot);
UnifiedMeetingStatusVO resolve(Long meetingId);
}

View File

@ -159,7 +159,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
@Override @Override
public void triggerQueuedAsrScheduling() { public void triggerQueuedAsrScheduling() {
scheduleQueuedAsrTasks(); taskSecurityContextRunner.callAsPlatformAdmin(() -> {
scheduleQueuedAsrTasks();
return null;
});
} }
@Override @Override

View File

@ -9,9 +9,11 @@ import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.service.android.AndroidDeviceSessionService; import com.imeeting.service.android.AndroidDeviceSessionService;
import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.biz.DeviceOnlineManagementService; import com.imeeting.service.biz.DeviceOnlineManagementService;
import com.imeeting.service.biz.LicenseService;
import com.unisbase.security.LoginUser; import com.unisbase.security.LoginUser;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.time.Instant; import java.time.Instant;
@ -26,6 +28,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
private final DeviceInfoMapper deviceInfoMapper; private final DeviceInfoMapper deviceInfoMapper;
private final AndroidDeviceSessionService androidDeviceSessionService; private final AndroidDeviceSessionService androidDeviceSessionService;
private final AndroidGatewayPushService androidGatewayPushService; private final AndroidGatewayPushService androidGatewayPushService;
private final LicenseService licenseService;
@Override @Override
public void recordConnected(AndroidAuthContext authContext) { public void recordConnected(AndroidAuthContext authContext) {
@ -43,7 +46,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
existing.setLastOnlineAt(now); existing.setLastOnlineAt(now);
existing.setUserId(authContext.getUserId()); existing.setUserId(authContext.getUserId());
existing.setTenantId(authContext.getTenantId()); existing.setTenantId(authContext.getTenantId());
deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing); deviceInfoMapper.updateById(existing);
} }
@Override @Override
@ -78,6 +81,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
public DeviceOnlineAdminVO update(Long id, DeviceAdminUpdateCommand command, LoginUser loginUser) { public DeviceOnlineAdminVO update(Long id, DeviceAdminUpdateCommand command, LoginUser loginUser) {
DeviceInfoEntity existing = requireVisibleDevice(id, loginUser); DeviceInfoEntity existing = requireVisibleDevice(id, loginUser);
existing.setDeviceName(normalize(command.getDeviceName())); existing.setDeviceName(normalize(command.getDeviceName()));
existing.setWeatherCityName(normalize(command.getWeatherCityName()));
boolean disableAfterUpdate = command.getStatus() != null && command.getStatus() == 0; boolean disableAfterUpdate = command.getStatus() != null && command.getStatus() == 0;
if (command.getStatus() != null) { if (command.getStatus() != null) {
existing.setStatus(command.getStatus()); existing.setStatus(command.getStatus());
@ -99,6 +103,25 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
return true; return true;
} }
@Override
@Transactional(rollbackFor = Exception.class)
public boolean delete(Long id, LoginUser loginUser) {
DeviceInfoEntity existing = requireVisibleDevice(id, loginUser);
disconnectDevice(existing.getDeviceCode());
licenseService.unbindDeviceLicense(existing.getDeviceCode());
existing.setTenantId(null);
existing.setUserId(null);
deviceInfoMapper.updateById(existing);
return deviceInfoMapper.deleteById(existing.getDeviceId()) > 0;
}
@Override
public boolean resetStats(Long id, LoginUser loginUser) {
DeviceInfoEntity existing = requireVisibleDevice(id, loginUser);
existing.setStatsResetAt(LocalDateTime.now());
return deviceInfoMapper.updateById(existing) > 0;
}
private DeviceInfoEntity requireVisibleDevice(Long id, LoginUser loginUser) { private DeviceInfoEntity requireVisibleDevice(Long id, LoginUser loginUser) {
DeviceInfoEntity existing = deviceInfoMapper.selectByIdIgnoreTenant(id); DeviceInfoEntity existing = deviceInfoMapper.selectByIdIgnoreTenant(id);
if (existing == null) { if (existing == null) {

View File

@ -0,0 +1,285 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.common.LicenseConstants;
import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.biz.LicenseImportResultVO;
import com.imeeting.dto.biz.LicenseImportRow;
import com.imeeting.dto.biz.LicenseVO;
import com.imeeting.entity.biz.LicenseEntity;
import com.imeeting.enums.LicenseStatusEnum;
import com.imeeting.enums.LicenseTypeEnum;
import com.imeeting.mapper.LicenseMapper;
import com.imeeting.service.biz.LicenseService;
import com.unisbase.common.exception.BusinessException;
import com.unisbase.security.LoginUser;
import com.unisbase.service.SysParamService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@Service
@RequiredArgsConstructor
public class LicenseServiceImpl extends ServiceImpl<LicenseMapper, LicenseEntity> implements LicenseService {
private static final DateTimeFormatter TEMP_SERIAL_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final DateTimeFormatter IMPORT_BATCH_TIME = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final SysParamService sysParamService;
@Override
@Transactional(rollbackFor = Exception.class)
public void initializeTemporaryLicenses(Long tenantId) {
if (tenantId == null) {
return;
}
int count = parsePositiveInt(sysParamService.getParamValue(SysParamKeys.LICENSE_TEMP_DEFAULT_COUNT, "0"));
if (count <= 0) {
return;
}
int expireMonths = Math.max(1, parsePositiveInt(sysParamService.getParamValue(SysParamKeys.LICENSE_TEMP_DEFAULT_EXPIRE_MONTHS, "3")));
String productCode = normalize(sysParamService.getParamValue(SysParamKeys.LICENSE_DEFAULT_PRODUCT_CODE, ""));
if (!StringUtils.hasText(productCode)) {
throw new BusinessException("500", "未配置临时授权产品编码");
}
LocalDateTime expireTime = LocalDateTime.now().plusMonths(expireMonths);
List<LicenseEntity> entities = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
String serial = buildTemporarySerial(productCode);
LicenseEntity entity = new LicenseEntity();
entity.setTenantId(tenantId);
entity.setLicenseSerial(serial);
entity.setLicenseCode(serial);
entity.setLicenseType(LicenseTypeEnum.TEMPORARY.getCode());
entity.setLicenseStatus(LicenseStatusEnum.UNUSED.getCode());
entity.setProductCode(productCode);
entity.setExpireTime(expireTime);
entities.add(entity);
}
saveBatch(entities);
}
@Override
@Transactional(rollbackFor = Exception.class)
public LicenseEntity allocateForDeviceRegistration(Long tenantId, String tenantCode, String deviceCode) {
expireDueLicenses();
LicenseEntity bound = baseMapper.selectActiveByTenantAndDeviceIgnoreTenant(tenantId, deviceCode);
if (bound != null) {
return bound;
}
LicenseEntity license = baseMapper.selectFirstAssignableLicense(tenantId);
if (license == null) {
throw new BusinessException("402", "无可用授权码");
}
LocalDateTime now = LocalDateTime.now();
baseMapper.bindLicenseById(license.getId(), deviceCode, now, LicenseStatusEnum.IN_USE.getCode());
license.setDeviceCode(deviceCode);
license.setBindTime(now);
license.setLicenseStatus(LicenseStatusEnum.IN_USE.getCode());
return license;
}
@Override
public LicenseEntity requireValidBoundLicense(String deviceCode) {
expireDueLicenses();
LicenseEntity license = baseMapper.selectByDeviceCodeIgnoreTenant(deviceCode);
if (license == null || license.getLicenseStatus() == null || license.getLicenseStatus() != LicenseStatusEnum.IN_USE.getCode()) {
throw new BusinessException("403", "设备未绑定有效授权");
}
if (license.getExpireTime() != null && !license.getExpireTime().isAfter(LocalDateTime.now())) {
baseMapper.clearBindingById(license.getId(), LicenseStatusEnum.EXPIRED.getCode());
throw new BusinessException("403", "设备授权已过期");
}
return license;
}
@Override
public void validateDeviceCanRegisterToTenant(String deviceCode, Long tenantId) {
if (!StringUtils.hasText(deviceCode) || tenantId == null) {
return;
}
LicenseEntity existing = baseMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim());
if (existing == null) {
return;
}
if (!tenantId.equals(existing.getTenantId())) {
throw new BusinessException("403", "该设备已绑定,无法重复绑定");
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void unbindDeviceLicense(String deviceCode) {
if (!StringUtils.hasText(deviceCode)) {
return;
}
expireDueLicenses();
LicenseEntity license = baseMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim());
if (license == null) {
return;
}
int targetStatus = isExpired(license) ? LicenseStatusEnum.EXPIRED.getCode() : LicenseStatusEnum.UNUSED.getCode();
baseMapper.clearBindingById(license.getId(), targetStatus);
}
@Override
@Transactional(rollbackFor = Exception.class)
public LicenseImportResultVO importFormalLicenses(MultipartFile file, LoginUser loginUser) throws IOException {
Long tenantId = currentTenantId(loginUser);
List<LicenseImportRow> rows = parseImportRows(file);
if (rows.isEmpty()) {
throw new BusinessException("400", "导入文件不能为空");
}
expireDueLicenses();
List<LicenseEntity> boundTempLicenses = baseMapper.selectBoundTemporaryLicensesForReplace(tenantId);
if (rows.size() < boundTempLicenses.size()) {
throw new BusinessException("400", "正式授权数量不足,无法完成替换");
}
validateImportRows(tenantId, rows);
String importBatchNo = buildImportBatchNo();
LocalDateTime now = LocalDateTime.now();
List<LicenseEntity> imported = new ArrayList<>(rows.size());
for (LicenseImportRow row : rows) {
LicenseEntity entity = new LicenseEntity();
entity.setTenantId(tenantId);
entity.setLicenseSerial(row.getLicenseSerial());
entity.setLicenseCode(row.getLicenseCode());
entity.setProductCode(normalize(row.getProductCode()));
entity.setRemark(normalize(row.getRemark()));
entity.setLicenseType(LicenseTypeEnum.FORMAL.getCode());
entity.setLicenseStatus(LicenseStatusEnum.UNUSED.getCode());
entity.setImportBatchNo(importBatchNo);
entity.setImportTime(now);
imported.add(entity);
}
saveBatch(imported);
List<LicenseEntity> unusedFormalLicenses = baseMapper.selectUnusedFormalLicenses(tenantId, importBatchNo);
for (int i = 0; i < boundTempLicenses.size(); i++) {
LicenseEntity temp = boundTempLicenses.get(i);
LicenseEntity formal = unusedFormalLicenses.get(i);
baseMapper.bindLicenseById(formal.getId(), temp.getDeviceCode(), now, LicenseStatusEnum.IN_USE.getCode());
baseMapper.invalidateById(temp.getId());
}
int invalidatedTempCount = baseMapper.invalidateAllTemporaryLicenses(tenantId);
LicenseImportResultVO result = new LicenseImportResultVO();
result.setImportBatchNo(importBatchNo);
result.setTotalCount(imported.size());
result.setReplacedCount(boundTempLicenses.size());
result.setUnusedFormalCount(imported.size() - boundTempLicenses.size());
result.setInvalidatedTempCount(invalidatedTempCount);
return result;
}
@Override
public List<LicenseVO> listCurrentTenantLicenses(LoginUser loginUser) {
return baseMapper.selectListByTenant(currentTenantId(loginUser));
}
private void validateImportRows(Long tenantId, List<LicenseImportRow> rows) {
for (LicenseImportRow row : rows) {
if (!StringUtils.hasText(row.getLicenseSerial()) || !StringUtils.hasText(row.getLicenseCode())) {
throw new BusinessException("400", "导入文件缺少授权序列号或授权码");
}
if (baseMapper.selectBySerialIgnoreTenant(row.getLicenseSerial()) != null) {
throw new BusinessException("400", "授权序列号已存在:" + row.getLicenseSerial());
}
if (baseMapper.selectByTenantAndCodeIgnoreTenant(tenantId, row.getLicenseCode()) != null) {
throw new BusinessException("400", "授权码已存在:" + row.getLicenseCode());
}
}
}
private List<LicenseImportRow> parseImportRows(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
throw new BusinessException("400", "导入文件不能为空");
}
List<LicenseImportRow> rows = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
String line;
int lineNo = 0;
while ((line = reader.readLine()) != null) {
lineNo++;
String trimmed = line.trim();
if (trimmed.isEmpty()) {
continue;
}
if (lineNo == 1 && trimmed.toLowerCase(Locale.ROOT).startsWith("license_serial")) {
continue;
}
String[] parts = trimmed.split(",", -1);
if (parts.length < 2) {
throw new BusinessException("400", "第" + lineNo + "行格式错误");
}
LicenseImportRow row = new LicenseImportRow();
row.setLicenseSerial(normalize(parts[0]));
row.setLicenseCode(normalize(parts[1]));
row.setProductCode(parts.length > 2 ? normalize(parts[2]) : null);
row.setRemark(parts.length > 3 ? normalize(parts[3]) : null);
rows.add(row);
}
}
return rows;
}
private String buildTemporarySerial(String productCode) {
Long sequence = baseMapper.nextTempSerialValue();
if (sequence == null) {
throw new BusinessException("500", "临时授权序列号生成失败");
}
return LicenseConstants.TEMP_SERIAL_PREFIX
+ productCode
+ LocalDate.now().format(TEMP_SERIAL_DATE)
+ String.format("%08d", sequence);
}
private String buildImportBatchNo() {
return LicenseConstants.IMPORT_BATCH_PREFIX + LocalDateTime.now().format(IMPORT_BATCH_TIME);
}
private void expireDueLicenses() {
baseMapper.expireDueLicenses();
}
private boolean isExpired(LicenseEntity license) {
return license.getExpireTime() != null && !license.getExpireTime().isAfter(LocalDateTime.now());
}
private String normalize(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private int parsePositiveInt(String value) {
if (!StringUtils.hasText(value)) {
return 0;
}
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException ex) {
return 0;
}
}
private Long currentTenantId(LoginUser loginUser) {
if (loginUser == null || loginUser.getTenantId() == null) {
throw new BusinessException("403", "缺少租户上下文");
}
return loginUser.getTenantId();
}
}

View File

@ -1387,7 +1387,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
command.getUseSpkId(), command.getUseSpkId(),
null, null,
null, null,
command.getEnableTextRefine(), command.getEnableTextRefine() == null || command.getEnableTextRefine(),
null, null,
command.getHotWordGroupId(), command.getHotWordGroupId(),
command.getHotWords() command.getHotWords()

View File

@ -24,6 +24,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import javax.sound.sampled.AudioInputStream; import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem; import javax.sound.sampled.AudioSystem;
import java.io.File; import java.io.File;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@ -35,6 +37,7 @@ import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -55,6 +58,9 @@ public class MeetingDomainSupport {
@Value("${unisbase.app.upload-path}") @Value("${unisbase.app.upload-path}")
private String uploadPath; private String uploadPath;
@Value("${imeeting.audio.ffmpeg-path:ffmpeg}")
private String ffmpegPath;
public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags, public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags,
String audioUrl, String meetingType, String meetingSource, String audioUrl, String meetingType, String meetingSource,
Long tenantId, Long creatorId, String creatorName, Long tenantId, Long creatorId, String creatorName,
@ -526,11 +532,11 @@ public class MeetingDomainSupport {
} }
private Integer resolveAudioDurationSecondsByUrl(String audioUrl) { private Integer resolveAudioDurationSecondsByUrl(String audioUrl) {
Path audioPath = resolvePublicAudioPath(audioUrl);
if (audioPath == null) {
return null;
}
try { try {
Path audioPath = resolvePublicAudioPath(audioUrl);
if (audioPath == null) {
return null;
}
File file = audioPath.toFile(); File file = audioPath.toFile();
if (!file.exists()) { if (!file.exists()) {
return null; return null;
@ -544,9 +550,70 @@ public class MeetingDomainSupport {
return (int) Math.ceil(frameLength / frameRate); return (int) Math.ceil(frameLength / frameRate);
} }
} catch (Exception ex) { } catch (Exception ex) {
log.warn("Failed to resolve audio duration from audioUrl={}, skip effective duration update", audioUrl, ex); log.warn("AudioSystem failed to resolve audio duration from audioUrl={}, fallback to ffprobe", audioUrl, ex);
}
return resolveAudioDurationSecondsByFfprobe(audioPath);
}
private Integer resolveAudioDurationSecondsByFfprobe(Path audioPath) {
if (audioPath == null || !Files.exists(audioPath)) {
return null; return null;
} }
List<String> command = List.of(
resolveFfprobePath(),
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
audioPath.toString()
);
try {
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
byte[] output;
try (InputStream processStream = process.getInputStream()) {
output = processStream.readAllBytes();
}
if (!process.waitFor(30, TimeUnit.SECONDS)) {
process.destroyForcibly();
return null;
}
if (process.exitValue() != 0) {
return null;
}
String raw = new String(output, StandardCharsets.UTF_8).trim();
if (raw.isBlank()) {
return null;
}
double duration = Double.parseDouble(raw);
return duration > 0 ? (int) Math.ceil(duration) : null;
} catch (Exception ex) {
log.warn("ffprobe failed to resolve audio duration from path={}", audioPath, ex);
return null;
}
}
private String resolveFfprobePath() {
if (ffmpegPath == null || ffmpegPath.isBlank()) {
return "ffprobe";
}
String trimmed = ffmpegPath.trim();
try {
Path ffmpeg = Paths.get(trimmed);
Path fileName = ffmpeg.getFileName();
if (fileName != null) {
String normalizedName = fileName.toString().toLowerCase();
if ("ffmpeg".equals(normalizedName) || "ffmpeg.exe".equals(normalizedName)) {
Path sibling = ffmpeg.resolveSibling(normalizedName.endsWith(".exe") ? "ffprobe.exe" : "ffprobe");
if (Files.exists(sibling)) {
return sibling.toString();
}
}
}
} catch (Exception ex) {
log.debug("Failed to derive ffprobe path from ffmpegPath={}", ffmpegPath, ex);
}
return "ffprobe";
} }
private Path resolvePublicAudioPath(String audioUrl) { private Path resolvePublicAudioPath(String audioUrl) {

View File

@ -4,11 +4,15 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingProgressStage; import com.imeeting.common.MeetingProgressStage;
import com.imeeting.dto.biz.MeetingProgressSnapshot; import com.imeeting.dto.biz.MeetingProgressSnapshot;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusVO;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.AiTaskMapper; import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.service.biz.MeetingProgressService; import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingUnifiedStatusService;
import com.imeeting.support.redis.MeetingProgressCache; import com.imeeting.support.redis.MeetingProgressCache;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -29,6 +33,8 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
private final AiTaskMapper aiTaskMapper; private final AiTaskMapper aiTaskMapper;
private final MeetingProgressCache meetingProgressCache; private final MeetingProgressCache meetingProgressCache;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final MeetingUnifiedStatusService meetingUnifiedStatusService;
private final AndroidMeetingPushService androidMeetingPushService;
@Override @Override
public void clear(Long meetingId) { public void clear(Long meetingId) {
@ -116,6 +122,60 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
return; return;
} }
meetingProgressCache.saveSnapshot(snapshot); meetingProgressCache.saveSnapshot(snapshot);
notifyUnifiedStatusChangedIfNeeded(snapshot.getMeetingId(), existing, snapshot);
}
private void notifyUnifiedStatusChangedIfNeeded(Long meetingId,
MeetingProgressSnapshot existing,
MeetingProgressSnapshot candidate) {
if (meetingId == null || candidate == null) {
return;
}
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null) {
return;
}
MeetingVO meetingVO = toMeetingVO(meeting);
UnifiedMeetingStatusVO currentStatus = meetingUnifiedStatusService.resolve(meetingVO, candidate);
if (currentStatus == null || currentStatus.getStatusCode() == null || currentStatus.getStatusCode().isBlank()) {
return;
}
UnifiedMeetingStatusVO previousStatus = existing == null ? null : meetingUnifiedStatusService.resolve(meetingVO, existing);
if (previousStatus != null && currentStatus.getStatusCode().equals(previousStatus.getStatusCode())) {
return;
}
androidMeetingPushService.pushMeetingStatusChanged(meetingId, currentStatus.getStatusCode());
}
private MeetingVO toMeetingVO(Meeting meeting) {
if (meeting == null) {
return null;
}
MeetingVO vo = new MeetingVO();
vo.setId(meeting.getId());
vo.setTenantId(meeting.getTenantId());
vo.setCreatorId(meeting.getCreatorId());
vo.setCreatorName(meeting.getCreatorName());
vo.setHostUserId(meeting.getHostUserId());
vo.setHostName(meeting.getHostName());
vo.setTitle(meeting.getTitle());
vo.setMeetingTime(meeting.getMeetingTime());
vo.setParticipants(meeting.getParticipants());
vo.setTags(meeting.getTags());
vo.setAudioUrl(meeting.getAudioUrl());
vo.setMeetingType(meeting.getMeetingType());
vo.setMeetingSource(meeting.getMeetingSource());
vo.setSourceDeviceCode(meeting.getSourceDeviceCode());
vo.setSourceDeviceMode(meeting.getSourceDeviceMode());
vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus());
vo.setSummaryDetailLevel(meeting.getSummaryDetailLevel());
vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
vo.setAccessPassword(meeting.getAccessPassword());
vo.setEffectiveAudioDurationSeconds(meeting.getEffectiveAudioDurationSeconds());
vo.setStatus(meeting.getStatus());
vo.setCreatedAt(meeting.getCreatedAt());
return vo;
} }
private MeetingProgressSnapshot buildSnapshot(Long meetingId, private MeetingProgressSnapshot buildSnapshot(Long meetingId,

View File

@ -64,7 +64,7 @@ public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileR
profile.setResolvedUseSpkId(useSpkId != null ? useSpkId : 1); profile.setResolvedUseSpkId(useSpkId != null ? useSpkId : 1);
profile.setResolvedEnablePunctuation(enablePunctuation != null ? enablePunctuation : Boolean.TRUE); profile.setResolvedEnablePunctuation(enablePunctuation != null ? enablePunctuation : Boolean.TRUE);
profile.setResolvedEnableItn(enableItn != null ? enableItn : Boolean.TRUE); profile.setResolvedEnableItn(enableItn != null ? enableItn : Boolean.TRUE);
profile.setResolvedEnableTextRefine(Boolean.TRUE.equals(enableTextRefine)); profile.setResolvedEnableTextRefine(enableTextRefine==null|| enableTextRefine);
profile.setResolvedSaveAudio(Boolean.TRUE.equals(saveAudio)); profile.setResolvedSaveAudio(Boolean.TRUE.equals(saveAudio));
profile.setResolvedHotWords(resolveHotWords(resolvedTenantId, promptTemplate, hotWordGroupId, hotWords)); profile.setResolvedHotWords(resolveHotWords(resolvedTenantId, promptTemplate, hotWordGroupId, hotWords));
return profile; return profile;

View File

@ -0,0 +1,281 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.biz.MeetingProgressSnapshot;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusStage;
import com.imeeting.dto.biz.UnifiedMeetingStatusVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptChapterVersionMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.MeetingUnifiedStatusService;
import com.imeeting.support.redis.MeetingProgressCache;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusService {
private final MeetingMapper meetingMapper;
private final AiTaskMapper aiTaskMapper;
private final MeetingTranscriptMapper meetingTranscriptMapper;
private final MeetingTranscriptChapterVersionMapper chapterVersionMapper;
private final MeetingProgressCache meetingProgressCache;
@Override
public UnifiedMeetingStatusVO resolve(MeetingVO meeting, MeetingProgressSnapshot snapshot) {
if (meeting == null || meeting.getId() == null) {
return null;
}
UnifiedMeetingStatusStage stage = resolveStage(meeting, snapshot);
UnifiedMeetingStatusStage failedStage = resolveFailedStage(meeting);
boolean failed = failedStage != null;
UnifiedMeetingStatusStage effectiveStage = failed ? failedStage : stage;
return UnifiedMeetingStatusVO.builder()
.meetingId(meeting.getId())
.statusCode(effectiveStage.getCode())
.statusText(effectiveStage.getText())
.percent(resolvePercent(snapshot, effectiveStage))
.message(resolveMessage(meeting, snapshot, effectiveStage))
.eta(snapshot == null ? null : snapshot.getEta())
.failedStageCode(failedStage == null ? null : failedStage.getCode())
.failedStageText(failedStage == null ? null : failedStage.getText())
.canViewTranscript(canViewTranscript(meeting.getId()))
.canViewAiChapters(canViewAiChapters(meeting.getId()))
.canViewSummary(canViewSummary(meeting))
.build();
}
@Override
public UnifiedMeetingStatusVO resolve(Long meetingId) {
if (meetingId == null) {
return null;
}
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null) {
return null;
}
return resolve(toMeetingVO(meeting), meetingProgressCache.getSnapshot(meetingId));
}
private MeetingUnifiedStageContext buildStageContext(Long meetingId, MeetingProgressSnapshot snapshot) {
AiTask latestAsr = findLatestTask(meetingId, "ASR");
AiTask latestChapter = findLatestTask(meetingId, "CHAPTER");
AiTask latestSummary = findLatestTask(meetingId, "SUMMARY");
return new MeetingUnifiedStageContext(latestAsr, latestChapter, latestSummary, snapshot);
}
private UnifiedMeetingStatusStage resolveStage(MeetingVO meeting, MeetingProgressSnapshot snapshot) {
if (meeting == null) {
return UnifiedMeetingStatusStage.INITIALIZING;
}
if (Integer.valueOf(3).equals(meeting.getStatus())) {
return UnifiedMeetingStatusStage.COMPLETED;
}
UnifiedMeetingStatusStage stageFromSnapshot = resolveStageFromSnapshot(snapshot);
if (stageFromSnapshot != null) {
return stageFromSnapshot;
}
MeetingUnifiedStageContext context = buildStageContext(meeting.getId(), snapshot);
if (isAndroidOfflineMeetingWaitingUpload(meeting)) {
return UnifiedMeetingStatusStage.WAITING_UPLOAD;
}
if (isTranscribing(context)) {
return UnifiedMeetingStatusStage.TRANSCRIBING;
}
if (isSummarizing(context)) {
return UnifiedMeetingStatusStage.SUMMARIZING;
}
return UnifiedMeetingStatusStage.INITIALIZING;
}
private UnifiedMeetingStatusStage resolveStageFromSnapshot(MeetingProgressSnapshot snapshot) {
if (snapshot == null || snapshot.getStage() == null || snapshot.getStage().isBlank()) {
return null;
}
return switch (snapshot.getStage()) {
case "failed" -> null;
case "completed" -> UnifiedMeetingStatusStage.COMPLETED;
case "summary_running", "chapter_running" -> UnifiedMeetingStatusStage.SUMMARIZING;
case "asr_running", "asr_completed", "asr_submitted" -> UnifiedMeetingStatusStage.TRANSCRIBING;
case "queued" -> UnifiedMeetingStatusStage.INITIALIZING;
default -> null;
};
}
private UnifiedMeetingStatusStage resolveFailedStage(MeetingVO meeting) {
if (meeting == null || !Integer.valueOf(4).equals(meeting.getStatus())) {
return null;
}
AiTask summaryTask = findLatestTask(meeting.getId(), "SUMMARY");
if (isTaskFailed(summaryTask)) {
return UnifiedMeetingStatusStage.FAILED_SUMMARIZING;
}
AiTask chapterTask = findLatestTask(meeting.getId(), "CHAPTER");
if (isTaskFailed(chapterTask)) {
return UnifiedMeetingStatusStage.FAILED_SUMMARIZING;
}
AiTask asrTask = findLatestTask(meeting.getId(), "ASR");
if (isTaskFailed(asrTask)) {
return UnifiedMeetingStatusStage.FAILED_TRANSCRIBING;
}
return UnifiedMeetingStatusStage.FAILED_INITIALIZING;
}
private boolean isAndroidOfflineMeetingWaitingUpload(MeetingVO meeting) {
return meeting != null
&& MeetingConstants.TYPE_OFFLINE.equalsIgnoreCase(meeting.getMeetingType())
&& MeetingConstants.SOURCE_ANDROID.equalsIgnoreCase(meeting.getMeetingSource())
&& !MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED.equalsIgnoreCase(meeting.getOfflineRecordingStatus());
}
private boolean isSummarizing(MeetingUnifiedStageContext context) {
return isTaskRunning(context.summaryTask())
|| isTaskRunning(context.chapterTask())
|| isTaskCompleted(context.chapterTask())
|| isTaskCompleted(context.summaryTask());
}
private boolean isTranscribing(MeetingUnifiedStageContext context) {
if (isTaskRunningOrQueued(context.summaryTask()) || isTaskRunningOrQueued(context.chapterTask())) {
return false;
}
return isTaskRunningOrQueued(context.asrTask()) || isTaskCompleted(context.asrTask());
}
private Integer resolvePercent(MeetingProgressSnapshot snapshot, UnifiedMeetingStatusStage stage) {
if (snapshot != null && snapshot.getPercent() != null) {
return snapshot.getPercent();
}
return switch (stage) {
case WAITING_UPLOAD -> 0;
case INITIALIZING -> 5;
case TRANSCRIBING -> 50;
case SUMMARIZING -> 90;
case COMPLETED -> 100;
case FAILED_INITIALIZING, FAILED_TRANSCRIBING, FAILED_SUMMARIZING -> -1;
};
}
private String resolveMessage(MeetingVO meeting, MeetingProgressSnapshot snapshot, UnifiedMeetingStatusStage stage) {
if (snapshot != null && snapshot.getMessage() != null && !snapshot.getMessage().isBlank() && !Objects.equals(snapshot.getMessage(), "Waiting...")) {
return snapshot.getMessage();
}
if (stage == UnifiedMeetingStatusStage.FAILED_INITIALIZING
|| stage == UnifiedMeetingStatusStage.FAILED_TRANSCRIBING
|| stage == UnifiedMeetingStatusStage.FAILED_SUMMARIZING) {
return resolveFailureMessage(meeting);
}
return switch (stage) {
case WAITING_UPLOAD -> "待上传录音文件";
case INITIALIZING -> "数据初始化";
case TRANSCRIBING -> "转译音频";
case SUMMARIZING -> "生成总结";
case COMPLETED -> "处理完成";
case FAILED_INITIALIZING -> "数据初始化失败";
case FAILED_TRANSCRIBING -> "转译音频失败";
case FAILED_SUMMARIZING -> "生成总结失败";
};
}
private String resolveFailureMessage(MeetingVO meeting) {
if (meeting == null) {
return "处理失败";
}
if (meeting.getLatestSummaryAttemptErrorMsg() != null && !meeting.getLatestSummaryAttemptErrorMsg().isBlank()) {
return meeting.getLatestSummaryAttemptErrorMsg();
}
if (meeting.getLatestChapterAttemptErrorMsg() != null && !meeting.getLatestChapterAttemptErrorMsg().isBlank()) {
return meeting.getLatestChapterAttemptErrorMsg();
}
return "处理失败";
}
private boolean canViewTranscript(Long meetingId) {
return meetingId != null && meetingTranscriptMapper.selectCount(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)) > 0;
}
private boolean canViewAiChapters(Long meetingId) {
return meetingId != null && chapterVersionMapper.selectCount(new LambdaQueryWrapper<MeetingTranscriptChapterVersion>()
.eq(MeetingTranscriptChapterVersion::getMeetingId, meetingId)
.eq(MeetingTranscriptChapterVersion::getIsCurrent, 1)
.eq(MeetingTranscriptChapterVersion::getStatus, 2)) > 0;
}
private boolean canViewSummary(MeetingVO meeting) {
return meeting != null && meeting.getSummaryContent() != null && !meeting.getSummaryContent().isBlank();
}
private AiTask findLatestTask(Long meetingId, String taskType) {
return aiTaskMapper.selectOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, taskType)
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
private boolean isTaskRunningOrQueued(AiTask task) {
return task != null && (Integer.valueOf(0).equals(task.getStatus()) || Integer.valueOf(1).equals(task.getStatus()));
}
private boolean isTaskRunning(AiTask task) {
return task != null && Integer.valueOf(1).equals(task.getStatus());
}
private boolean isTaskCompleted(AiTask task) {
return task != null && Integer.valueOf(2).equals(task.getStatus());
}
private boolean isTaskFailed(AiTask task) {
return task != null && Integer.valueOf(3).equals(task.getStatus());
}
private MeetingVO toMeetingVO(Meeting meeting) {
MeetingVO vo = new MeetingVO();
vo.setId(meeting.getId());
vo.setTenantId(meeting.getTenantId());
vo.setCreatorId(meeting.getCreatorId());
vo.setCreatorName(meeting.getCreatorName());
vo.setHostUserId(meeting.getHostUserId());
vo.setHostName(meeting.getHostName());
vo.setTitle(meeting.getTitle());
vo.setMeetingTime(meeting.getMeetingTime());
vo.setParticipants(meeting.getParticipants());
vo.setTags(meeting.getTags());
vo.setAudioUrl(meeting.getAudioUrl());
vo.setMeetingType(meeting.getMeetingType());
vo.setMeetingSource(meeting.getMeetingSource());
vo.setSourceDeviceCode(meeting.getSourceDeviceCode());
vo.setSourceDeviceMode(meeting.getSourceDeviceMode());
vo.setOfflineRecordingStatus(meeting.getOfflineRecordingStatus());
vo.setSummaryDetailLevel(meeting.getSummaryDetailLevel());
vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
vo.setAccessPassword(meeting.getAccessPassword());
vo.setEffectiveAudioDurationSeconds(meeting.getEffectiveAudioDurationSeconds());
vo.setStatus(meeting.getStatus());
vo.setCreatedAt(meeting.getCreatedAt());
return vo;
}
private record MeetingUnifiedStageContext(AiTask asrTask,
AiTask chapterTask,
AiTask summaryTask,
MeetingProgressSnapshot snapshot) {
}
}

View File

@ -52,6 +52,7 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
private static final String SCOPE_PLATFORM = "PLATFORM"; private static final String SCOPE_PLATFORM = "PLATFORM";
private static final String SCOPE_USER = "USER"; private static final String SCOPE_USER = "USER";
private static final String SCOPE_MIXED = "MIXED"; private static final String SCOPE_MIXED = "MIXED";
private static final long PLATFORM_TENANT_ID = 0L;
private static final int DEFAULT_DISPLAY_DURATION_SEC = 15; private static final int DEFAULT_DISPLAY_DURATION_SEC = 15;
private static final int MIN_DISPLAY_DURATION_SEC = 3; private static final int MIN_DISPLAY_DURATION_SEC = 3;
private static final int MAX_DISPLAY_DURATION_SEC = 3600; private static final int MAX_DISPLAY_DURATION_SEC = 3600;
@ -236,7 +237,8 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
Integer displayDurationSec = resolveDisplayDurationSec(tenantId, userId); Integer displayDurationSec = resolveDisplayDurationSec(tenantId, userId);
List<ScreenSaver> platformItems = listActiveByScope(SCOPE_PLATFORM, null); List<ScreenSaver> platformItems = listActiveByScope(SCOPE_PLATFORM, null);
if (userId == null) { if (userId == null) {
return new ScreenSaverSelectionResult(SCOPE_PLATFORM, displayDurationSec, toAdminVOs(platformItems, Map.of(), displayDurationSec)); List<ScreenSaver> selectedPlatformItems = platformItems.isEmpty() ? listGlobalFallbackPlatformItems() : platformItems;
return new ScreenSaverSelectionResult(SCOPE_PLATFORM, displayDurationSec, toAdminVOs(selectedPlatformItems, Map.of(), displayDurationSec));
} }
Map<Long, Integer> userStatusMap = queryUserStatusMap(tenantId, userId, extractPlatformIds(platformItems)); Map<Long, Integer> userStatusMap = queryUserStatusMap(tenantId, userId, extractPlatformIds(platformItems));
@ -249,6 +251,16 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
selected.addAll(effectivePlatformItems); selected.addAll(effectivePlatformItems);
selected.addAll(userItems); selected.addAll(userItems);
selected.sort(SCREEN_SAVER_ORDER); selected.sort(SCREEN_SAVER_ORDER);
if (selected.isEmpty()) {
List<ScreenSaver> fallbackPlatformItems = listGlobalFallbackPlatformItems();
if (!fallbackPlatformItems.isEmpty()) {
return new ScreenSaverSelectionResult(
SCOPE_PLATFORM,
displayDurationSec,
toAdminVOs(fallbackPlatformItems, Map.of(), displayDurationSec)
);
}
}
return new ScreenSaverSelectionResult( return new ScreenSaverSelectionResult(
resolveSourceScope(effectivePlatformItems, userItems), resolveSourceScope(effectivePlatformItems, userItems),
@ -257,6 +269,13 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
); );
} }
protected List<ScreenSaver> listGlobalFallbackPlatformItems() {
if (baseMapper == null) {
return List.of();
}
return baseMapper.selectActivePlatformByTenantIgnoreTenant(PLATFORM_TENANT_ID);
}
private ScreenSaverDTO normalizeCreateDto(ScreenSaverDTO dto, LoginUser loginUser) { private ScreenSaverDTO normalizeCreateDto(ScreenSaverDTO dto, LoginUser loginUser) {
if (dto == null) { if (dto == null) {
return null; return null;

View File

@ -24,6 +24,7 @@ import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@ -51,6 +52,7 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
private final AiModelService aiModelService; private final AiModelService aiModelService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newBuilder() private final HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.build(); .build();
@ -245,12 +247,13 @@ public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> impl
if (speaker.getUserId() != null) { if (speaker.getUserId() != null) {
body.put("user_id", String.valueOf(speaker.getUserId())); body.put("user_id", String.valueOf(speaker.getUserId()));
} }
body.put("file_url", buildFileUrl(speaker.getVoicePath())); body.put("audio_address", buildFileUrl(speaker.getVoicePath()));
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url)) .uri(URI.create(url))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body),
StandardCharsets.UTF_8));
if (asrModel.getApiKey() != null && !asrModel.getApiKey().isEmpty()) { if (asrModel.getApiKey() != null && !asrModel.getApiKey().isEmpty()) {
requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey()); requestBuilder.header("Authorization", "Bearer " + asrModel.getApiKey());

View File

@ -0,0 +1,73 @@
package com.imeeting.service.biz.impl;
import com.unisbase.dto.CreateTenantDTO;
import com.unisbase.dto.PageResult;
import com.unisbase.dto.SysTenantDTO;
import com.unisbase.service.SysTenantService;
import com.unisbase.service.TenantManagementService;
import com.unisbase.service.TenantModeService;
import com.imeeting.service.biz.LicenseService;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Primary
public class TenantManagementServicePrimaryImpl implements TenantManagementService {
private final SysTenantService sysTenantService;
private final TenantModeService tenantModeService;
private final LicenseService licenseService;
public TenantManagementServicePrimaryImpl(SysTenantService sysTenantService,
TenantModeService tenantModeService,
LicenseService licenseService) {
this.sysTenantService = sysTenantService;
this.tenantModeService = tenantModeService;
this.licenseService = licenseService;
}
@Override
public PageResult<List<SysTenantDTO>> listTenants(Integer current, Integer size, String name, String code) {
if (tenantModeService.isSingleTenantMode()) {
SysTenantDTO defaultTenant = sysTenantService.findById(tenantModeService.getDefaultTenantId());
PageResult<List<SysTenantDTO>> result = new PageResult<>();
result.setRecords(defaultTenant == null ? List.of() : List.of(defaultTenant));
result.setTotal(defaultTenant == null ? 0 : 1);
return result;
}
return sysTenantService.page(current, size, name, code);
}
@Override
public SysTenantDTO getTenant(Long tenantId) {
return sysTenantService.findById(tenantId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long createTenant(CreateTenantDTO tenant) {
tenantModeService.assertTenantLifecycleAllowed();
Long tenantId = sysTenantService.createTenantWithAdmin(tenant);
licenseService.initializeTemporaryLicenses(tenantId);
return tenantId;
}
@Override
public boolean updateTenant(Long tenantId, SysTenantDTO tenant) {
tenantModeService.assertDefaultTenantCanBeUpdated(tenantId, tenant == null ? null : tenant.getStatus());
if (tenant == null) {
throw new IllegalArgumentException("租户信息不能为空");
}
tenant.setId(tenantId);
return sysTenantService.update(tenant);
}
@Override
public boolean deleteTenant(Long tenantId) {
tenantModeService.assertTenantLifecycleAllowed();
return sysTenantService.deleteById(tenantId);
}
}

View File

@ -4,6 +4,7 @@ import com.imeeting.entity.biz.AndroidPushMessage;
import com.imeeting.grpc.push.PushMessage; import com.imeeting.grpc.push.PushMessage;
import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.support.TaskSecurityContextRunner;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@ -18,30 +19,34 @@ import java.util.List;
public class AndroidPushMessageRetryTask { public class AndroidPushMessageRetryTask {
private final AndroidPushMessageService androidPushMessageService; private final AndroidPushMessageService androidPushMessageService;
private final AndroidGatewayPushService androidGatewayPushService; private final AndroidGatewayPushService androidGatewayPushService;
private final TaskSecurityContextRunner taskSecurityContextRunner;
@Scheduled(fixedDelayString = "${imeeting.android.push.retry-interval-ms:15000}") @Scheduled(fixedDelayString = "${imeeting.android.push.retry-interval-ms:15000}")
public void retryPendingMessages() { public void retryPendingMessages() {
List<AndroidPushMessage> pendingMessages = androidPushMessageService.listPendingMeetingPushMessages(); taskSecurityContextRunner.callAsPlatformAdmin(() -> {
for (AndroidPushMessage message : pendingMessages) { List<AndroidPushMessage> pendingMessages = androidPushMessageService.listPendingMeetingPushMessages();
if (message.getExpireAt() != null && message.getExpireAt().isBefore(LocalDateTime.now())) { for (AndroidPushMessage message : pendingMessages) {
androidPushMessageService.markExpired(message.getId()); if (message.getExpireAt() != null && message.getExpireAt().isBefore(LocalDateTime.now())) {
continue; androidPushMessageService.markExpired(message.getId());
continue;
}
PushMessage pushMessage = PushMessage.newBuilder()
.setMessageId(message.getMessageId())
.setTimestamp(System.currentTimeMillis())
.setType(message.getMessageType())
.setTitle(resolveMessageTitle(message))
.setContent(message.getPayload() == null ? "" : message.getPayload())
.setNeedAck(true)
.build();
int pushed = androidGatewayPushService.pushToDevice(message.getDeviceCode(), pushMessage);
if (pushed > 0) {
androidPushMessageService.markPushed(message.getId());
log.info("Retried android push message, messageId={}, deviceCode={}, pushCountIncreased=true",
message.getMessageId(), message.getDeviceCode());
}
} }
PushMessage pushMessage = PushMessage.newBuilder() return null;
.setMessageId(message.getMessageId()) });
.setTimestamp(System.currentTimeMillis())
.setType(message.getMessageType())
.setTitle(resolveMessageTitle(message))
.setContent(message.getPayload() == null ? "" : message.getPayload())
.setNeedAck(true)
.build();
int pushed = androidGatewayPushService.pushToDevice(message.getDeviceCode(), pushMessage);
if (pushed > 0) {
androidPushMessageService.markPushed(message.getId());
log.info("Retried android push message, messageId={}, deviceCode={}, pushCountIncreased=true",
message.getMessageId(), message.getDeviceCode());
}
}
} }
private String resolveMessageTitle(AndroidPushMessage message) { private String resolveMessageTitle(AndroidPushMessage message) {

View File

@ -11,14 +11,14 @@ logging:
com.imeeting.service.realtime.impl.AsrUpstreamBridgeServiceImpl: debug com.imeeting.service.realtime.impl.AsrUpstreamBridgeServiceImpl: debug
spring: spring:
datasource: datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://127.0.0.1:5432/imeeting_db} url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://10.100.53.199:5432/imeeting_dev}
username: ${SPRING_DATASOURCE_USERNAME:postgres} username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:postgres} password: ${SPRING_DATASOURCE_PASSWORD:postgres}
data: data:
redis: redis:
host: ${SPRING_DATA_REDIS_HOST:127.0.0.1} host: ${SPRING_DATA_REDIS_HOST:10.100.53.199}
port: ${SPRING_DATA_REDIS_PORT:6379} port: ${SPRING_DATA_REDIS_PORT:6379}
password: ${SPRING_DATA_REDIS_PASSWORD:} password: ${SPRING_DATA_REDIS_PASSWORD:unis@123}
database: ${SPRING_DATA_REDIS_DATABASE:15} database: ${SPRING_DATA_REDIS_DATABASE:15}
mybatis-plus: mybatis-plus:
@ -31,10 +31,10 @@ unisbase:
internal-auth: internal-auth:
secret: ${INTERNAL_AUTH_SECRET:change-me-dev-internal-secret} secret: ${INTERNAL_AUTH_SECRET:change-me-dev-internal-secret}
app: app:
server-base-url: ${APP_SERVER_BASE_URL:http://127.0.0.1:${server.port}} server-base-url: ${APP_SERVER_BASE_URL:http://10.100.52.13:${server.port}}
upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting/uploads/} upload-path: ${APP_UPLOAD_PATH:D:/data/imeeting/uploads/}
imeeting: imeeting:
h5: h5:
base-url: ${IMEETING_H5_BASE_URL:http://127.0.0.1:3000} base-url: ${IMEETING_H5_BASE_URL:http://10.100.52.13:3000}
audio: audio:
ffmpeg-path: D:\tools\exe\ffmpeg-master-latest-win64-gpl-shared\bin\ffmpeg ffmpeg-path: D:\tools\exe\ffmpeg-master-latest-win64-gpl-shared\bin\ffmpeg.exe

View File

@ -63,6 +63,7 @@ unisbase:
- /api/auth/** - /api/auth/**
- /api/static/** - /api/static/**
- /api/public/meetings/** - /api/public/meetings/**
- /api/android/devices/home
- /api/android/auth/login - /api/android/auth/login
- /api/android/auth/refresh - /api/android/auth/refresh
- /api/clients/latest/by-platform - /api/clients/latest/by-platform

View File

@ -1,43 +1,20 @@
//package com.imeeting.service; package com.imeeting.service;
//
//import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.imeeting.service.biz.LicenseService;
// import org.junit.jupiter.api.Test;
//import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired;
//import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.context.SpringBootTest;
//import org.mockito.InjectMocks; import org.springframework.test.context.ActiveProfiles;
//import org.mockito.Mock;
//import org.mockito.junit.jupiter.MockitoExtension; @SpringBootTest
// @ActiveProfiles("dev")
//import java.util.Collections;
//import java.util.List; public class DictItemServiceTest {
//
//import static org.junit.jupiter.api.Assertions.assertEquals; @Autowired
//import static org.mockito.ArgumentMatchers.any; private LicenseService licenseService;
//import static org.mockito.Mockito.when; @Test
// public void main(){
//@ExtendWith(MockitoExtension.class) licenseService.initializeTemporaryLicenses(1L);
//public class DictItemServiceTest { }
// }
// @Mock
// private SysDictItemMapper dictItemMapper;
//
// @InjectMocks
// private SysDictItemServiceImpl dictItemService;
//
// @Test
// void testGetItemsByTypeCode() {
// String typeCode = "gender";
// SysDictItem item = new SysDictItem();
// item.setTypeCode(typeCode);
// item.setItemLabel("Male");
// item.setItemValue("1");
// item.setStatus(1);
// item.setSortOrder(1);
//
// when(dictItemMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(item));
//
// List<SysDictItem> result = dictItemService.getItemsByTypeCode(typeCode);
// assertEquals(1, result.size());
// assertEquals("Male", result.get(0).getItemLabel());
// }
//}

View File

@ -61,6 +61,30 @@ class ScreenSaverServiceImplTest {
assertEquals(List.of(22, 22), result.getItems().stream().map(ScreenSaverAdminVO::getDisplayDurationSec).toList()); assertEquals(List.of(22, 22), result.getItems().stream().map(ScreenSaverAdminVO::getDisplayDurationSec).toList());
} }
@Test
void getActiveSelectionShouldFallbackToPlatformTenantItemsWhenTenantSelectionIsEmpty() {
ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class);
ScreenSaverUserSettingsMapper userSettingsMapper = mock(ScreenSaverUserSettingsMapper.class);
SysUserMapper sysUserMapper = mock(SysUserMapper.class);
when(userConfigMapper.selectList(any())).thenReturn(List.of());
when(userSettingsMapper.selectOne(any())).thenReturn(userSettings(77L, 22));
when(sysUserMapper.selectBatchIds(any())).thenReturn(List.of());
ScreenSaverServiceImpl service = spy(new ScreenSaverServiceImpl(userConfigMapper, userSettingsMapper, sysUserMapper));
doReturn(List.of())
.doReturn(List.of())
.when(service).list(any(LambdaQueryWrapper.class));
doReturn(List.of(screenSaver(301L, "PLATFORM", null, 1, 1)))
.when(service).listGlobalFallbackPlatformItems();
ScreenSaverSelectionResult result = service.getActiveSelection(77L);
assertEquals("PLATFORM", result.getSourceScope());
assertEquals(22, result.getDisplayDurationSec());
assertEquals(List.of(301L), result.getItems().stream().map(ScreenSaverAdminVO::getId).toList());
assertEquals(List.of(1), result.getItems().stream().map(ScreenSaverAdminVO::getStatus).toList());
}
@Test @Test
void listForAdminShouldApplyCurrentUserStatusFilter() { void listForAdminShouldApplyCurrentUserStatusFilter() {
ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class); ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class);

View File

@ -0,0 +1,40 @@
import http from "../http";
export interface LicenseVO {
id: number;
tenantId: number;
licenseSerial: string;
licenseCode: string;
licenseType: number;
licenseStatus: number;
productCode?: string;
deviceCode?: string;
bindTime?: string;
expireTime?: string;
importBatchNo?: string;
importTime?: string;
remark?: string;
}
export interface LicenseImportResultVO {
importBatchNo: string;
totalCount: number;
replacedCount: number;
unusedFormalCount: number;
invalidatedTempCount: number;
}
export async function listLicenses() {
const resp = await http.get("/api/admin/licenses");
return resp.data.data as LicenseVO[];
}
export async function importLicenses(file: File) {
const formData = new FormData();
formData.append("file", file);
const resp = await http.post("/api/admin/licenses/import", formData, {
headers: { "Content-Type": "multipart/form-data" },
timeout: 600000
});
return resp.data.data as LicenseImportResultVO;
}

View File

@ -480,6 +480,19 @@ export interface MeetingProgress {
eta?: number; eta?: number;
queueAheadCount?: number; queueAheadCount?: number;
queuedAt?: string; queuedAt?: string;
unifiedStatus?: {
meetingId: number;
statusCode: string;
statusText: string;
percent?: number;
message?: string;
eta?: number;
failedStageCode?: string;
failedStageText?: string;
canViewTranscript?: boolean;
canViewAiChapters?: boolean;
canViewSummary?: boolean;
};
} }
export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => { export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => {

View File

@ -174,6 +174,16 @@ export async function kickManagedDevice(id: number) {
return resp.data.data as boolean; return resp.data.data as boolean;
} }
export async function deleteManagedDevice(id: number) {
const resp = await http.delete(`/api/admin/devices/${id}`);
return resp.data.data as boolean;
}
export async function resetManagedDeviceStats(id: number) {
const resp = await http.post(`/api/admin/devices/${id}/reset`);
return resp.data.data as boolean;
}
export async function listUserRoles(userId: number) { export async function listUserRoles(userId: number) {
const resp = await http.get(`/sys/api/users/${userId}/roles`); const resp = await http.get(`/sys/api/users/${userId}/roles`);
return resp.data.data as number[]; return resp.data.data as number[];

View File

@ -350,7 +350,12 @@
"kickDevice": "Kick device", "kickDevice": "Kick device",
"kickDeviceConfirm": "Kick this device offline?", "kickDeviceConfirm": "Kick this device offline?",
"kickSucceeded": "Device has been kicked offline", "kickSucceeded": "Device has been kicked offline",
"resetStats": "Reset stats",
"resetStatsConfirm": "Reset this device's homepage statistics?",
"resetStatsSucceeded": "Device statistics reset",
"deleteDevice": "Delete this device?", "deleteDevice": "Delete this device?",
"weatherCityName": "Weather City",
"statsResetAt": "Stats Reset At",
"drawerTitleCreate": "New Device", "drawerTitleCreate": "New Device",
"drawerTitleEdit": "Edit Device", "drawerTitleEdit": "Edit Device",
"owner": "Owner", "owner": "Owner",
@ -358,7 +363,8 @@
"searchSelectUser": "Search and select a user", "searchSelectUser": "Search and select a user",
"deviceCodeRequired": "Enter the device code", "deviceCodeRequired": "Enter the device code",
"deviceCodePlaceholder": "Enter a unique device code", "deviceCodePlaceholder": "Enter a unique device code",
"deviceNamePlaceholder": "Example: Meeting Room A Recorder" "deviceNamePlaceholder": "Example: Meeting Room A Recorder",
"weatherCityNamePlaceholder": "Example: Shenzhen"
}, },
"dashboardExt": { "dashboardExt": {
"processing": "Processing", "processing": "Processing",

View File

@ -0,0 +1,243 @@
import { App, Button, Card, Col, Empty, Input, Row, Space, Table, Tag, Typography, Upload } from "antd";
import type { ColumnsType } from "antd/es/table";
import { CheckCircleOutlined, ClockCircleOutlined, KeyOutlined, LinkOutlined, ReloadOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import PageContainer from "@/components/shared/PageContainer";
import AppPagination from "@/components/shared/AppPagination";
import { importLicenses, listLicenses, type LicenseImportResultVO, type LicenseVO } from "@/api/business/license";
const { Text } = Typography;
function resolveLicenseTypeTag(type?: number) {
if (type === 1) return <Tag color="gold"></Tag>;
if (type === 2) return <Tag color="blue"></Tag>;
return <Tag></Tag>;
}
function resolveLicenseStatusTag(status?: number) {
switch (status) {
case 1:
return <Tag>使</Tag>;
case 2:
return <Tag color="green">使</Tag>;
case 3:
return <Tag color="red"></Tag>;
case 4:
return <Tag></Tag>;
default:
return <Tag></Tag>;
}
}
function formatDateTime(value?: string) {
return value ? value.replace("T", " ").substring(0, 19) : "-";
}
export default function LicenseManagement() {
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [records, setRecords] = useState<LicenseVO[]>([]);
const [searchValue, setSearchValue] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const loadData = useCallback(async () => {
setLoading(true);
try {
const result = await listLicenses();
setRecords(result || []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
const filteredRecords = useMemo(() => {
const keyword = searchValue.trim().toLowerCase();
if (!keyword) {
return records;
}
return records.filter((item) =>
[
item.licenseSerial,
item.licenseCode,
item.productCode,
item.deviceCode,
item.importBatchNo,
item.remark,
].some((field) => String(field || "").toLowerCase().includes(keyword))
);
}, [records, searchValue]);
const pagedRecords = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredRecords.slice(start, start + pageSize);
}, [filteredRecords, page, pageSize]);
useEffect(() => {
setPage(1);
}, [searchValue]);
const stats = useMemo(() => ({
total: records.length,
using: records.filter((item) => item.licenseStatus === 2).length,
formal: records.filter((item) => item.licenseType === 2).length,
available: records.filter((item) => item.licenseStatus === 1).length,
}), [records]);
const handleImport = async (file: File) => {
setUploading(true);
try {
const result: LicenseImportResultVO = await importLicenses(file);
message.success(`导入完成:${result.totalCount} 条,替换 ${result.replacedCount}`);
await loadData();
} finally {
setUploading(false);
}
};
const columns: ColumnsType<LicenseVO> = [
{
title: "授权标识",
key: "license",
width: 320,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text strong className="tabular-nums">{record.licenseSerial}</Text>
{/*<Text type="secondary" className="tabular-nums">{record.licenseCode}</Text>*/}
</Space>
),
},
{
title: "类型",
dataIndex: "licenseType",
key: "licenseType",
width: 100,
render: (value) => resolveLicenseTypeTag(value),
},
{
title: "状态",
dataIndex: "licenseStatus",
key: "licenseStatus",
width: 100,
render: (value) => resolveLicenseStatusTag(value),
},
{
title: "绑定设备",
dataIndex: "deviceCode",
key: "deviceCode",
width: 240,
render: (value) => <Text className="tabular-nums">{value || "-"}</Text>,
},
{
title: "过期时间",
dataIndex: "expireTime",
key: "expireTime",
width: 200,
render: (value) => <Text type="secondary">{formatDateTime(value)}</Text>,
},
];
return (
<PageContainer
title="授权码管理"
subtitle="查看当前租户授权池"
headerExtra={
<Space size={12}>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
</Button>
{/*<Upload showUploadList={false} beforeUpload={(file) => { void handleImport(file as File); return Upload.LIST_IGNORE; }}>*/}
{/* <Button type="primary" icon={<UploadOutlined />} loading={uploading}>*/}
{/* 导入正式授权*/}
{/* </Button>*/}
{/*</Upload>*/}
</Space>
}
toolbar={
<Input
placeholder="搜索序列号、授权码、设备编码"
prefix={<SearchOutlined />}
allowClear
style={{ width: 360 }}
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
/>
}
>
<Row gutter={24} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card bordered={false} style={{ borderRadius: 16 }}>
<Space size={16}>
<KeyOutlined style={{ color: "#1677ff", fontSize: 24 }} />
<div>
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}></div>
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.total}</div>
</div>
</Space>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ borderRadius: 16 }}>
<Space size={16}>
<LinkOutlined style={{ color: "#52c41a", fontSize: 24 }} />
<div>
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}>使</div>
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.using}</div>
</div>
</Space>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ borderRadius: 16 }}>
<Space size={16}>
<CheckCircleOutlined style={{ color: "#722ed1", fontSize: 24 }} />
<div>
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}></div>
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.formal}</div>
</div>
</Space>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ borderRadius: 16 }}>
<Space size={16}>
<ClockCircleOutlined style={{ color: "#fa8c16", fontSize: 24 }} />
<div>
<div style={{ color: "var(--app-text-secondary)", fontSize: 13 }}></div>
<div style={{ fontSize: 30, fontWeight: 800 }}>{stats.available}</div>
</div>
</Space>
</Card>
</Col>
</Row>
<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 flex-1 min-h-0" style={{ padding: "0 24px", overflow: "auto" }}>
<Table
rowKey="id"
columns={columns}
dataSource={pagedRecords}
loading={loading}
pagination={false}
scroll={{ x: 1000, y: "100%" }}
locale={{ emptyText: <Empty description="暂无授权数据" /> }}
/>
</div>
<AppPagination
current={page}
pageSize={pageSize}
total={filteredRecords.length}
onChange={(nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
}}
/>
</Card>
</PageContainer>
);
}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Alert, Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Radio, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd'; import { Alert, Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Radio, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd';
import { import {
@ -241,7 +241,7 @@ const extractSection = (markdown: string, aliases: string[]) => {
const parseBulletList = (content?: string | null) => const parseBulletList = (content?: string | null) =>
splitLines(content) splitLines(content)
.map((line) => line.replace(/^[-*•\s]+/, '').replace(/^\d+[.)]\s*/, '').trim()) .map((line) => line.replace(/^[-*鈥s]+/, '').replace(/^\d+[.)]\s*/, '').trim())
.filter(Boolean); .filter(Boolean);
const parseOverviewSection = (markdown: string) => const parseOverviewSection = (markdown: string) =>
@ -250,7 +250,7 @@ const parseOverviewSection = (markdown: string) =>
const parseKeywordsSection = (markdown: string, tags: string) => { const parseKeywordsSection = (markdown: string, tags: string) => {
const section = extractSection(markdown, ['关键词', '关键字', '标签']); const section = extractSection(markdown, ['关键词', '关键字', '标签']);
const fromSection = parseBulletList(section) const fromSection = parseBulletList(section)
.flatMap((line) => line.split(/[、]/)) .flatMap((line) => line.split(/[,/]/))
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean); .filter(Boolean);
@ -371,8 +371,27 @@ type MeetingProgressPhase = 'queued' | 'asr' | 'chapter' | 'summary' | 'terminal
const meetingProgressTerminalRefreshCache = new Map<number, string>(); const meetingProgressTerminalRefreshCache = new Map<number, string>();
const meetingProgressPhaseRefreshCache = new Map<number, MeetingProgressPhase>(); const meetingProgressPhaseRefreshCache = new Map<number, MeetingProgressPhase>();
const DETAIL_STAGE_STEP_ITEMS = [
{ code: 'INITIALIZING', label: '数据初始化', hint: '完成会议数据准备' },
{ code: 'TRANSCRIBING', label: '转译音频', hint: '完成语音转写' },
{ code: 'SUMMARIZING', label: '生成总结', hint: '完成 AI 内容处理' },
{ code: 'COMPLETED', label: '处理完成', hint: '已全部完成' },
] as const;
const resolveProgressPhase = (progress: MeetingProgress | null | undefined): MeetingProgressPhase => { const resolveProgressPhase = (progress: MeetingProgress | null | undefined): MeetingProgressPhase => {
const unifiedStatusCode = progress?.unifiedStatus?.statusCode;
if (unifiedStatusCode?.startsWith('FAILED_') || unifiedStatusCode === 'COMPLETED') {
return 'terminal';
}
if (unifiedStatusCode === 'SUMMARIZING') {
return 'summary';
}
if (unifiedStatusCode === 'TRANSCRIBING') {
return 'asr';
}
if (unifiedStatusCode === 'INITIALIZING' || unifiedStatusCode === 'WAITING_UPLOAD') {
return 'queued';
}
const percent = progress?.percent ?? 0; const percent = progress?.percent ?? 0;
if (percent < 0 || percent >= 100) { if (percent < 0 || percent >= 100) {
return 'terminal'; return 'terminal';
@ -467,6 +486,9 @@ const MeetingProgressDisplay: React.FC<{
const percent = progress?.percent || 0; const percent = progress?.percent || 0;
const isError = percent < 0; const isError = percent < 0;
const unifiedStatusText = progress?.unifiedStatus?.statusText;
const unifiedStatusMessage = progress?.unifiedStatus?.message;
const primaryStatusText = unifiedStatusText || (isError ? '处理失败' : '处理中');
const formatEta = (seconds?: number) => { const formatEta = (seconds?: number) => {
if (!seconds || seconds <= 0) return '计算中'; if (!seconds || seconds <= 0) return '计算中';
@ -497,7 +519,7 @@ const MeetingProgressDisplay: React.FC<{
}} }}
> >
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5' }}> <Text strong style={{ color: isError ? '#cf1322' : '#4f46e5' }}>
{progress?.message || '正在生成版总结...'} {unifiedStatusMessage || unifiedStatusText || progress?.message || '正在生成新结...'}
</Text> </Text>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5', whiteSpace: 'nowrap' }}> <Text strong style={{ color: isError ? '#cf1322' : '#4f46e5', whiteSpace: 'nowrap' }}>
{isError ? '失败' : `${percent}%`} {isError ? '失败' : `${percent}%`}
@ -543,10 +565,10 @@ const MeetingProgressDisplay: React.FC<{
/> />
<div style={{ marginTop: 24 }}> <div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 16, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 4 }}> <Text strong style={{ fontSize: 16, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 4 }}>
{progress?.message || '正在分析内容...'} {unifiedStatusMessage || unifiedStatusText || progress?.message || '正在分析内容...'}
</Text> </Text>
<Text type="secondary" style={{ fontSize: 13 }}> <Text type="secondary" style={{ fontSize: 13 }}>
{isError ? '--' : formatEta(progress?.eta)} {`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text> </Text>
</div> </div>
</div> </div>
@ -580,7 +602,7 @@ const MeetingProgressDisplay: React.FC<{
/> />
<div style={{ marginTop: 32 }}> <div style={{ marginTop: 32 }}>
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}> <Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}>
{progress?.message || '正在准备计算资源...'} {unifiedStatusMessage || unifiedStatusText || progress?.message || '正在准备计算资源...'}
</Text> </Text>
<Text type="secondary"></Text> <Text type="secondary"></Text>
</div> </div>
@ -606,7 +628,7 @@ const MeetingProgressDisplay: React.FC<{
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Text type="secondary"></Text> <Text type="secondary"></Text>
<Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}> <Title level={4} style={{ margin: 0, color: isError ? '#ff4d4f' : '#52c41a' }}>
{isError ? '已中断' : '正常'} {isError ? '失败' : '正常'}
</Title> </Title>
</Space> </Space>
</Col> </Col>
@ -616,6 +638,358 @@ const MeetingProgressDisplay: React.FC<{
); );
}; };
const DETAIL_STAGE_STEP_DISPLAY_ITEMS = [
{ code: 'INITIALIZING', label: '数据初始化', hint: '完成会议数据准备' },
{ code: 'TRANSCRIBING', label: '转译音频', hint: '完成语音转写' },
{ code: 'SUMMARIZING', label: '生成总结', hint: '生成总结与 AI 目录' },
{ code: 'COMPLETED', label: '处理完成', hint: '全部任务处理完成' },
] as const;
const UnifiedMeetingProgressDisplay: React.FC<{
progress: MeetingProgress | null;
compact?: boolean;
inline?: boolean;
}> = ({ progress, compact, inline }) => {
const percent = progress?.percent || 0;
const isError = percent < 0;
const unifiedStatusCode = progress?.unifiedStatus?.statusCode;
const unifiedStatusText = progress?.unifiedStatus?.statusText;
const unifiedStatusMessage = progress?.unifiedStatus?.message;
const failedStageCode = progress?.unifiedStatus?.failedStageCode;
const primaryStatusText = unifiedStatusText || (isError ? '处理失败' : '处理中');
const formatEta = (seconds?: number) => {
if (!seconds || seconds <= 0) return '计算中';
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
const remainSeconds = seconds % 60;
return remainSeconds > 0 ? `${minutes}${remainSeconds}` : `${minutes} 分钟`;
};
const resolveStageCode = () => {
if (failedStageCode) {
return failedStageCode;
}
if (unifiedStatusCode === 'WAITING_UPLOAD' || unifiedStatusCode === 'INITIALIZING') {
return 'INITIALIZING';
}
if (unifiedStatusCode === 'TRANSCRIBING') {
return 'TRANSCRIBING';
}
if (unifiedStatusCode === 'SUMMARIZING') {
return 'SUMMARIZING';
}
if (unifiedStatusCode === 'COMPLETED') {
return 'COMPLETED';
}
const phase = resolveProgressPhase(progress);
if (phase === 'queued') {
return 'INITIALIZING';
}
if (phase === 'asr') {
return 'TRANSCRIBING';
}
if (phase === 'chapter' || phase === 'summary') {
return 'SUMMARIZING';
}
return 'COMPLETED';
};
const currentStageCode = resolveStageCode();
const currentStageIndex = Math.max(0, DETAIL_STAGE_STEP_DISPLAY_ITEMS.findIndex((item) => item.code === currentStageCode));
const isFailedStage = unifiedStatusCode?.startsWith('FAILED_') || isError;
const isCompletedStage = unifiedStatusCode === 'COMPLETED' || percent === 100;
const progressText = isError ? '--' : `${Math.max(percent, 0)}%`;
const currentStageLabel = DETAIL_STAGE_STEP_DISPLAY_ITEMS[currentStageIndex]?.label || primaryStatusText;
const currentStageDisplay = isFailedStage ? `${currentStageLabel}失败` : currentStageLabel;
const helperText = unifiedStatusMessage || progress?.message || (isError ? '当前阶段执行失败,请稍后重试。' : '阶段状态已与安卓端统一。');
const renderStageTimeline = (options?: {
compact?: boolean;
inline?: boolean;
}) => {
const compactMode = options?.compact;
const inlineMode = options?.inline;
const containerStyle: React.CSSProperties = inlineMode
? {
marginTop: 10,
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 8,
}
: compactMode
? {
marginTop: 18,
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 8,
width: '100%',
maxWidth: 560,
}
: {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
};
return (
<div style={containerStyle}>
{DETAIL_STAGE_STEP_DISPLAY_ITEMS.map((item, index) => {
const isCurrent = index === currentStageIndex;
const isDone = isCompletedStage || (!isFailedStage && index < currentStageIndex);
const isFailed = isFailedStage && isCurrent;
const dotBg = isFailed ? '#ff4d4f' : isDone ? '#52c41a' : isCurrent ? '#4f46e5' : '#d9d9d9';
const textColor = isFailed ? '#cf1322' : isDone || isCurrent ? '#1f1f1f' : '#8c8c8c';
const cardPadding = inlineMode ? '8px 6px' : compactMode ? '10px 8px' : '0';
const cardRadius = inlineMode || compactMode ? 12 : 0;
const cardBorder = inlineMode || compactMode
? `1px solid ${isFailed ? '#ffccc7' : isCurrent ? '#c7d2fe' : '#e5e7eb'}`
: 'none';
const cardBackground = inlineMode || compactMode
? isFailed ? '#fff2f0' : isCurrent ? '#eef2ff' : isDone ? '#f6ffed' : '#fafafa'
: 'transparent';
return (
<React.Fragment key={item.code}>
<div
style={{
flex: 1,
minWidth: 0,
padding: cardPadding,
borderRadius: cardRadius,
border: cardBorder,
background: cardBackground,
textAlign: 'center',
}}
>
<div
style={{
width: compactMode || inlineMode ? 28 : 36,
height: compactMode || inlineMode ? 28 : 36,
margin: `0 auto ${compactMode || inlineMode ? 8 : 12}px`,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: dotBg,
color: '#fff',
fontWeight: 700,
boxShadow: isCurrent ? `0 0 0 ${compactMode || inlineMode ? 4 : 6}px ${isFailed ? 'rgba(255,77,79,0.12)' : 'rgba(79,70,229,0.12)'}` : 'none',
}}
>
{isDone ? '√' : index + 1}
</div>
<div
style={{
fontSize: compactMode || inlineMode ? 12 : 15,
fontWeight: 600,
color: textColor,
marginBottom: compactMode || inlineMode ? 0 : 6,
}}
>
{isFailed && item.code === currentStageCode ? `${item.label}失败` : item.label}
</div>
{!compactMode && !inlineMode ? (
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.6 }}>
{item.hint}
</Text>
) : null}
</div>
{!inlineMode && !compactMode && index < DETAIL_STAGE_STEP_DISPLAY_ITEMS.length - 1 ? (
<div
style={{
flex: 1,
height: 2,
marginTop: 17,
background: (!isFailedStage && index < currentStageIndex) || isCompletedStage ? '#52c41a' : '#e5e7eb',
}}
/>
) : null}
</React.Fragment>
);
})}
</div>
);
};
if (inline) {
return (
<div
style={{
marginTop: 12,
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${isError ? '#ffccc7' : '#d6dbff'}`,
background: isError ? '#fff2f0' : '#f7f8ff',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
marginBottom: 8,
}}
>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5' }}>
{helperText}
</Text>
<Text strong style={{ color: isError ? '#cf1322' : '#4f46e5', whiteSpace: 'nowrap' }}>
{isError ? '失败' : `${percent}%`}
</Text>
</div>
<Progress
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : '#6c73ff'}
showInfo={false}
size="small"
style={{ marginBottom: 6 }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
{renderStageTimeline({ inline: true })}
</div>
);
}
if (compact) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
textAlign: 'center',
}}
>
<Title level={4} style={{ marginBottom: 24 }}>
{primaryStatusText}
</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 }}>
{helperText}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
{renderStageTimeline({ compact: true })}
</div>
</div>
);
}
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
background: '#fff',
borderRadius: 16,
padding: 40,
}}
>
<div style={{ width: '100%', maxWidth: 760, textAlign: 'center' }}>
<Title level={3} style={{ marginBottom: 8 }}>
{primaryStatusText}
</Title>
<Text type="secondary" style={{ fontSize: 15 }}>
{helperText}
</Text>
<div
style={{
marginTop: 28,
marginBottom: 32,
padding: '20px 24px',
borderRadius: 20,
background: isError ? 'linear-gradient(135deg, #fff2f0, #fff7f6)' : 'linear-gradient(135deg, #f5f7ff, #faf8ff)',
border: `1px solid ${isError ? '#ffccc7' : '#d9ddff'}`,
}}
>
<Text type="secondary" style={{ fontSize: 13 }}>
</Text>
<div
style={{
marginTop: 6,
fontSize: 40,
lineHeight: 1,
fontWeight: 700,
color: isError ? '#cf1322' : '#4f46e5',
}}
>
{progressText}
</div>
<Progress
percent={isError ? 100 : percent}
status={isError ? 'exception' : percent === 100 ? 'success' : 'active'}
strokeColor={isError ? '#ff4d4f' : '#6c73ff'}
showInfo={false}
style={{ marginTop: 18, marginBottom: 12 }}
/>
<Text type="secondary" style={{ fontSize: 13 }}>
{`预计剩余:${isError ? '--' : formatEta(progress?.eta)}`}
</Text>
</div>
{renderStageTimeline()}
<Divider style={{ margin: '32px 0 24px' }} />
<Row gutter={24}>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0 }}>
{DETAIL_STAGE_STEP_DISPLAY_ITEMS[currentStageIndex]?.label || primaryStatusText}
</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 ? '失败' : isCompletedStage ? '已完成' : '处理中'}
</Title>
</Space>
</Col>
<Col span={8}>
<Space direction="vertical" size={0}>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: 0, color: '#4f46e5' }}>
{[
progress?.unifiedStatus?.canViewTranscript ? '转录' : null,
progress?.unifiedStatus?.canViewAiChapters ? '目录' : null,
progress?.unifiedStatus?.canViewSummary ? '总结' : null,
].filter(Boolean).join(' / ') || '暂无'}
</Title>
</Space>
</Col>
</Row>
<div style={{ marginTop: 20 }}>
<Text type="secondary"></Text>
</div>
</div>
</div>
);
};
const SpeakerEditor: React.FC<{ const SpeakerEditor: React.FC<{
meetingId: number; meetingId: number;
speakerId: string; speakerId: string;
@ -890,16 +1264,18 @@ const MeetingDetail: React.FC = () => {
const fetchData = useCallback(async (meetingId: number) => { const fetchData = useCallback(async (meetingId: number) => {
try { try {
const [detailRes, transcriptRes, chapterRes, shareConfigRes] = await Promise.all([ const [detailRes, transcriptRes, chapterRes, shareConfigRes, progressRes] = await Promise.all([
getMeetingDetail(meetingId), getMeetingDetail(meetingId),
getTranscripts(meetingId), getTranscripts(meetingId),
getMeetingChapters(meetingId), getMeetingChapters(meetingId),
getMeetingShareConfig().catch(() => null), getMeetingShareConfig().catch(() => null),
getMeetingProgress(meetingId, { suppressErrorToast: true }).catch(() => null),
]); ]);
setMeeting(detailRes.data.data); setMeeting(detailRes.data.data);
setTranscripts(transcriptRes.data.data || []); setTranscripts(transcriptRes.data.data || []);
setMeetingChapters(chapterRes.data.data || []); setMeetingChapters(chapterRes.data.data || []);
setMeetingShareBaseUrl(shareConfigRes?.data?.data?.h5BaseUrl || ''); setMeetingShareBaseUrl(shareConfigRes?.data?.data?.h5BaseUrl || '');
setGenerationProgress(progressRes?.data?.data || null);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {
@ -2138,12 +2514,19 @@ const MeetingDetail: React.FC = () => {
<div className="meeting-detail-workspace"> <div className="meeting-detail-workspace">
{meeting.status === 0 || meeting.status === 1 ? ( {meeting.status === 0 || meeting.status === 1 ? (
<MeetingProgressDisplay <>
meetingId={meeting.id} <div style={{ display: 'none' }}>
onComplete={() => fetchData(meeting.id)} <MeetingProgressDisplay
onRefreshNeeded={() => { void fetchData(meeting.id); }} meetingId={meeting.id}
onProgressChange={setGenerationProgress} onComplete={() => fetchData(meeting.id)}
/> onRefreshNeeded={() => { void fetchData(meeting.id); }}
onProgressChange={setGenerationProgress}
/>
</div>
<UnifiedMeetingProgressDisplay
progress={generationProgress}
/>
</>
) : ( ) : (
<> <>
<Row gutter={24} style={{ height: '100%' }}> <Row gutter={24} style={{ height: '100%' }}>
@ -2179,13 +2562,21 @@ const MeetingDetail: React.FC = () => {
)} )}
<Card className="left-flow-card summary-panel" variant="borderless"> <Card className="left-flow-card summary-panel" variant="borderless">
{meeting.status === 2 && !hasSummaryContent ? ( {(meeting.status === 1 || meeting.status === 2) && (
<div className="summary-progress-shell"> <div style={{ display: 'none' }}>
<MeetingProgressDisplay <MeetingProgressDisplay
meetingId={meeting.id} meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)} onComplete={() => fetchData(meeting.id)}
onRefreshNeeded={() => { void fetchData(meeting.id); }} onRefreshNeeded={() => { void fetchData(meeting.id); }}
onProgressChange={setGenerationProgress} onProgressChange={setGenerationProgress}
/>
</div>
)}
{meeting.status === 2 && !hasSummaryContent ? (
<div className="summary-progress-shell">
<UnifiedMeetingProgressDisplay
progress={generationProgress}
compact compact
/> />
</div> </div>
@ -2201,11 +2592,8 @@ const MeetingDetail: React.FC = () => {
<div> <div>
<div>{summaryPanelNotice.description}</div> <div>{summaryPanelNotice.description}</div>
{meeting.status === 2 ? ( {meeting.status === 2 ? (
<MeetingProgressDisplay <UnifiedMeetingProgressDisplay
meetingId={meeting.id} progress={generationProgress}
onComplete={() => fetchData(meeting.id)}
onRefreshNeeded={() => { void fetchData(meeting.id); }}
onProgressChange={setGenerationProgress}
inline inline
/> />
) : null} ) : null}
@ -2551,13 +2939,13 @@ const MeetingDetail: React.FC = () => {
background: rgba(95, 81, 255, 0.25); background: rgba(95, 81, 255, 0.25);
transform: translateY(-1px); transform: translateY(-1px);
} }
/* 当转录行处于活动状态(紫色背景)时,调整高亮样式以保持可读性 */ /* 当转录行处于活动状态时,调整高亮样式以保持可读性 */
.ant-list-item.transcript-row.active .highlight-text { .ant-list-item.transcript-row.active .highlight-text {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
border-bottom-color: #fff; border-bottom-color: #fff;
color: #fff; color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
animation: none; /* 活动行内不需要脉冲,避免视觉混乱 */ animation: none; /* 活动行内不需要闪烁,避免视觉混乱 */
} }
.summary-keyword-link { .summary-keyword-link {
color: #5f51ff; color: #5f51ff;

View File

@ -9,14 +9,22 @@
--text-main: #1a1f36; --text-main: #1a1f36;
--text-secondary: #6e7695; --text-secondary: #6e7695;
--card-shadow: 0 10px 30px rgba(127, 139, 186, 0.08); --card-shadow: 0 10px 30px rgba(127, 139, 186, 0.08);
min-height: 100vh;
height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-app); background: var(--bg-app);
color: var(--text-main); color: var(--text-main);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: hidden;
} }
/* Glassmorphism Header - Now redundant but kept for safety if needed */
.meeting-preview-header {
display: none;
}
/* Main Container */
.meeting-preview-container { .meeting-preview-container {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@ -29,6 +37,7 @@
padding: 48px 24px; padding: 48px 24px;
} }
/* Hero Section */
.meeting-preview-top-hero { .meeting-preview-top-hero {
display: flex; display: flex;
gap: 24px; gap: 24px;
@ -75,11 +84,17 @@
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
} }
.meeting-preview-status-tag.is-complete { background: #e6f4ea; color: #1e8e3e; } .meeting-preview-status-tag.is-complete { background: #e6f4ea; color: #1e8e3e; }
.meeting-preview-status-tag.is-processing { background: #e8f0fe; color: #1a73e8; } .meeting-preview-status-tag.is-processing { background: #e8f0fe; color: #1a73e8; }
.meeting-preview-status-tag.is-warning { background: #fff4e5; color: #b76e00; } .meeting-preview-status-tag.is-warning { background: #fff4e5; color: #b76e00; }
.meeting-preview-hero-id {
font-size: 13px;
color: var(--text-secondary);
font-family: monospace;
}
/* Collapsible Info */
.meeting-preview-collapsible-section { .meeting-preview-collapsible-section {
background: var(--bg-surface); background: var(--bg-surface);
border-radius: 20px; border-radius: 20px;
@ -118,7 +133,7 @@
} }
.meeting-preview-collapsible-content.is-expanded { .meeting-preview-collapsible-content.is-expanded {
max-height: 420px; max-height: 400px;
} }
.meeting-preview-metrics-grid { .meeting-preview-metrics-grid {
@ -128,6 +143,18 @@
gap: 24px; gap: 24px;
} }
@media (max-width: 992px) {
.meeting-preview-metrics-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.meeting-preview-metrics-grid {
grid-template-columns: 1fr;
}
}
.metric-item { .metric-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -167,36 +194,7 @@
font-weight: 600; font-weight: 600;
} }
.meeting-preview-share-settings { /* Share Bar */
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: 20px;
margin-bottom: 24px;
padding: 20px 24px;
box-shadow: var(--card-shadow);
}
.meeting-preview-share-settings-title {
font-size: 18px;
font-weight: 800;
margin-bottom: 4px;
}
.meeting-preview-share-settings-desc {
color: var(--text-secondary);
margin-bottom: 16px;
}
.meeting-preview-share-settings-row {
display: flex;
gap: 12px;
align-items: center;
}
.meeting-preview-share-settings-row .ant-input-affix-wrapper {
border-radius: 14px;
}
.meeting-preview-share-bar { .meeting-preview-share-bar {
display: flex; display: flex;
gap: 16px; gap: 16px;
@ -224,6 +222,7 @@
background: white !important; background: white !important;
} }
/* Tabs and Content */
.meeting-preview-content-card { .meeting-preview-content-card {
background: var(--bg-surface); background: var(--bg-surface);
border-radius: 24px; border-radius: 24px;
@ -298,6 +297,7 @@
font-size: 14px; font-size: 14px;
} }
/* Catalog List & Timeline */
.meeting-preview-catalog-list { .meeting-preview-catalog-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -415,6 +415,12 @@
visibility: visible; visibility: visible;
} }
.meeting-preview-catalog-item-link:hover {
background: rgba(95, 81, 255, 0.15);
transform: translateY(-1px);
}
/* --- Transcription Original Styles Overhaul --- */
.meeting-preview-transcript-list { .meeting-preview-transcript-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -504,6 +510,7 @@
word-wrap: break-word; word-wrap: break-word;
} }
/* --- Floating Audio Player Overhaul --- */
.meeting-preview-audio-player-inline { .meeting-preview-audio-player-inline {
position: fixed; position: fixed;
bottom: 24px; bottom: 24px;
@ -511,6 +518,7 @@
transform: translateX(-50%); transform: translateX(-50%);
width: calc(100% - 32px); width: calc(100% - 32px);
max-width: 720px; max-width: 720px;
height: auto;
min-height: 80px; min-height: 80px;
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%);
@ -520,7 +528,9 @@
padding: 12px 24px; padding: 12px 24px;
border-radius: 28px; border-radius: 28px;
z-index: 1000; z-index: 1000;
box-shadow: 0 25px 50px -12px rgba(95, 81, 255, 0.25), 0 0 0 1px rgba(95, 81, 255, 0.05); box-shadow:
0 25px 50px -12px rgba(95, 81, 255, 0.25),
0 0 0 1px rgba(95, 81, 255, 0.05);
} }
.audio-player-content { .audio-player-content {
@ -528,6 +538,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
flex-wrap: nowrap;
} }
.audio-play-btn { .audio-play-btn {
@ -543,6 +554,12 @@
font-size: 20px; font-size: 20px;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.audio-play-btn:hover {
transform: scale(1.05);
box-shadow: 0 8px 15px rgba(95, 81, 255, 0.3);
} }
.audio-progress-container { .audio-progress-container {
@ -554,7 +571,7 @@
} }
.audio-time { .audio-time {
font-family: "JetBrains Mono", monospace; font-family: 'JetBrains Mono', monospace;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
color: var(--text-secondary); color: var(--text-secondary);
@ -590,8 +607,253 @@
cursor: pointer; cursor: pointer;
color: var(--primary-blue); color: var(--primary-blue);
flex-shrink: 0; flex-shrink: 0;
transition: all 0.2s;
} }
.audio-speed-btn:hover {
background: #eef1ff;
}
/* --- Password Gate Overhaul --- */
.is-password-gate {
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
}
.password-gate-background {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
}
.bg-blob {
position: absolute;
filter: blur(80px);
opacity: 0.4;
border-radius: 50%;
animation: blob-float 20s infinite alternate cubic-bezier(0.45, 0.05, 0.55, 0.95);
}
.bg-blob-1 {
width: 500px;
height: 500px;
background: #5f51ff;
top: -100px;
left: -100px;
}
.bg-blob-2 {
width: 400px;
height: 400px;
background: #6c8cff;
bottom: -50px;
right: -50px;
animation-delay: -5s;
}
.bg-blob-3 {
width: 300px;
height: 300px;
background: #8e84ff;
top: 40%;
left: 30%;
animation-delay: -10s;
}
@keyframes blob-float {
0% { transform: translate(0, 0) scale(1) rotate(0deg); }
33% { transform: translate(30px, 50px) scale(1.1) rotate(10deg); }
66% { transform: translate(-20px, 20px) scale(0.9) rotate(-10deg); }
100% { transform: translate(0, 0) scale(1) rotate(0deg); }
}
.is-password-gate .meeting-preview-shell {
width: 100%;
max-width: 480px;
padding: 24px;
z-index: 10;
margin: 0;
}
.meeting-preview-password-card {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(32px) saturate(200%);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 32px;
padding: 48px 40px;
box-shadow:
0 40px 100px -20px rgba(95, 81, 255, 0.15),
0 0 0 1px rgba(95, 81, 255, 0.05);
display: flex;
flex-direction: column;
gap: 32px;
}
.password-card-header {
text-align: center;
}
.password-icon-wrapper {
width: 64px;
height: 64px;
background: var(--primary-gradient);
color: white;
font-size: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20px;
margin: 0 auto 24px;
box-shadow: 0 12px 24px rgba(95, 81, 255, 0.25);
}
.password-card-title {
font-size: 24px;
font-weight: 800;
color: var(--text-main);
margin-bottom: 12px;
letter-spacing: -0.02em;
}
.password-card-subtitle {
font-size: 15px;
color: var(--text-secondary);
line-height: 1.6;
max-width: 320px;
margin: 0 auto;
}
.password-card-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.modern-password-input {
border-radius: 16px !important;
border: 2px solid rgba(228, 232, 245, 0.8) !important;
padding: 12px 16px !important;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important;
background: white !important;
}
.modern-password-input:focus,
.modern-password-input-focused {
border-color: var(--primary-blue) !important;
box-shadow: 0 0 0 4px rgba(95, 81, 255, 0.1) !important;
}
.password-submit-btn {
height: 56px !important;
border-radius: 16px !important;
font-size: 16px !important;
font-weight: 700 !important;
letter-spacing: 0.02em;
background: var(--primary-gradient) !important;
border: none !important;
box-shadow: 0 12px 24px rgba(95, 81, 255, 0.2) !important;
transition: all 0.3s ease !important;
}
.password-submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 16px 32px rgba(95, 81, 255, 0.3) !important;
}
.password-error-message {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
.password-gate-footer {
position: absolute;
bottom: 32px;
z-index: 10;
}
.footer-disclaimer {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
opacity: 0.7;
}
@media (max-width: 480px) {
.meeting-preview-password-card {
padding: 40px 24px;
border-radius: 24px;
}
}
/* --- Mobile Optimizations --- */
@media (max-width: 768px) {
.meeting-preview-transcript-list {
gap: 16px;
}
.meeting-preview-transcript-item {
gap: 12px;
padding: 10px;
}
.meeting-preview-transcript-avatar {
width: 36px;
height: 36px;
border-radius: 10px;
font-size: 14px;
}
.meeting-preview-transcript-text {
font-size: 15px;
}
/* Fix audio player deformation on mobile */
.meeting-preview-audio-player-inline {
padding: 10px 16px;
bottom: 16px;
border-radius: 22px;
}
.audio-player-content {
gap: 10px;
}
.audio-play-btn {
width: 40px;
height: 40px;
border-radius: 12px;
font-size: 18px;
}
.audio-time {
display: block;
font-size: 10px;
}
.audio-progress-container {
gap: 0;
}
.audio-speed-btn {
width: 38px;
height: 32px;
font-size: 11px;
}
}
/* Utils */
.meeting-preview-footer { .meeting-preview-footer {
margin-top: 64px; margin-top: 64px;
padding-bottom: 64px; padding-bottom: 64px;
@ -610,57 +872,107 @@
border-radius: 99px; border-radius: 99px;
} }
.meeting-preview-loading,
.meeting-preview-empty {
padding-top: 32px;
}
.meeting-preview-password-card {
max-width: 480px;
margin: 0 auto;
}
@media (max-width: 992px) {
.meeting-preview-metrics-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.meeting-preview-shell { padding: 32px 16px; } .meeting-preview-shell { padding: 32px 16px; }
.meeting-preview-hero-title { font-size: 24px; } .meeting-preview-hero-title { font-size: 24px; }
.meeting-preview-metrics-grid { grid-template-columns: 1fr; gap: 16px; } .meeting-preview-metrics-grid { grid-template-columns: 1fr; gap: 16px; }
.meeting-preview-share-bar,
.meeting-preview-share-settings-row {
flex-direction: column;
}
.meeting-preview-share-bar .ant-btn,
.meeting-preview-share-settings-row .ant-btn,
.meeting-preview-share-settings-row .ant-input-affix-wrapper {
width: 100%;
}
.meeting-preview-tabs-container { .meeting-preview-tabs-container {
padding: 8px 12px 0; padding: 8px 12px 0;
} }
.meeting-preview-tabs-container .ant-tabs-nav::before {
inset-inline: 0;
}
.meeting-preview-tabs-container .ant-tabs-nav-list { .meeting-preview-tabs-container .ant-tabs-nav-list {
width: 100%; width: 100%;
display: grid !important; display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
transform: none !important;
} }
.meeting-preview-tabs-container .ant-tabs-tab { .meeting-preview-tabs-container .ant-tabs-tab {
margin: 0 !important; margin: 0 !important;
padding: 14px 8px !important; padding: 14px 8px !important;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
min-width: 0;
}
.meeting-preview-tabs-container .ant-tabs-tab .ant-tabs-tab-btn {
width: 100%;
font-size: 14px;
line-height: 1.2;
white-space: nowrap;
overflow: visible;
text-overflow: clip;
}
.meeting-preview-tabs-container .ant-tabs-nav-operations {
display: none !important;
} }
.meeting-preview-tab-content { padding: 20px; } .meeting-preview-tab-content { padding: 20px; }
.meeting-preview-transcript-list { gap: 16px; } }
.meeting-preview-transcript-item { gap: 12px; padding: 10px; }
.meeting-preview-transcript-avatar { width: 36px; height: 36px; border-radius: 10px; font-size: 14px; } .meeting-preview-speaker-card {
.meeting-preview-transcript-text { font-size: 15px; } background: #f8faff;
.meeting-preview-audio-player-inline { padding: 10px 16px; bottom: 16px; border-radius: 22px; } border-radius: 16px;
.audio-player-content { gap: 10px; } padding: 20px;
.audio-play-btn { width: 40px; height: 40px; border-radius: 12px; font-size: 18px; } margin-bottom: 16px;
.audio-time { font-size: 10px; } border: 1px solid #eef1f9;
.audio-speed-btn { width: 38px; height: 32px; font-size: 11px; } }
.meeting-preview-speaker-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.meeting-preview-speaker-avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--primary-gradient);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.meeting-preview-speaker-name {
font-weight: 700;
font-size: 15px;
}
.meeting-preview-speaker-role {
font-size: 12px;
color: var(--text-secondary);
}
.meeting-preview-keypoint {
display: flex;
gap: 16px;
padding: 16px;
border-radius: 16px;
background: #fff;
border: 1px solid var(--border-color);
margin-bottom: 12px;
}
.meeting-preview-keypoint-index {
font-size: 18px;
font-weight: 800;
color: #dee2e6;
}
.meeting-preview-todo {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 0;
}
.meeting-preview-todo-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary-blue);
margin-top: 6px;
} }

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Button, Empty, Input, Tabs, message } from "antd"; import { Alert, Button, Empty, Input, Result, Skeleton, Tabs, message } from "antd";
import { useParams, useSearchParams } from "react-router-dom";
import { import {
AudioOutlined, AudioOutlined,
CalendarOutlined, CalendarOutlined,
@ -7,57 +8,53 @@ import {
ClockCircleOutlined, ClockCircleOutlined,
CopyOutlined, CopyOutlined,
FileTextOutlined, FileTextOutlined,
LinkOutlined,
LockOutlined, LockOutlined,
PauseOutlined, PauseOutlined,
RobotOutlined, RobotOutlined,
ShareAltOutlined, ShareAltOutlined,
TeamOutlined, TeamOutlined,
UpOutlined,
UserOutlined, UserOutlined,
DownOutlined, DownOutlined,
UpOutlined,
LinkOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { import {
getMeetingPreviewAccess,
getPublicMeetingPreview, getPublicMeetingPreview,
resolveAudioMimeType, resolveAudioMimeType,
resolveMeetingPlaybackAudioUrl, resolveMeetingPlaybackAudioUrl,
} from "@/api/meeting"; type MeetingChapterVO,
import { buildMeetingAnalysis } from "@/components/preview/meetingAnalysis"; type MeetingTranscriptVO,
import type { MeetingChapterVO, MeetingTranscriptVO, MeetingVO } from "@/types"; type MeetingVO,
import "./MeetingPreviewView.css"; } from "../../api/business/meeting";
import { buildMeetingAnalysis } from "./meetingAnalysis";
import "./MeetingPreview.css";
type AnalysisTab = "chapters" | "speakers" | "actions" | "todos";
type PreviewPageTab = "summary" | "catalog" | "transcript"; type PreviewPageTab = "summary" | "catalog" | "transcript";
type ChapterTranscriptLink = {
key: string;
title: string;
timeLabel: string;
transcriptIds: number[];
firstTranscriptId: number | null;
firstTranscriptStartTime: number | null;
};
interface MeetingPreviewViewProps {
meeting: MeetingVO;
transcripts: MeetingTranscriptVO[];
meetingChapters: MeetingChapterVO[];
shareUrl: string;
editableShare?: boolean;
sharePasswordDraft?: string;
shareSaving?: boolean;
onSharePasswordDraftChange?: (value: string) => void;
onSaveSharePassword?: () => void;
onCopyShareLink?: () => void;
}
const TEXT = { const TEXT = {
statusTranscribing: "转写中", statusTranscribing: "转写中",
statusSummarizing: "总结中", statusSummarizing: "总结中",
statusCompleted: "已完成", statusCompleted: "已完成",
statusPending: "待处理", statusPending: "待处理",
hintTranscribing: "会议内容仍在整理中,预览会持续补全。",
hintSummarizing: "AI 正在生成会议总结,已完成内容会优先展示。",
hintCompleted: "会议纪要、分析和转录内容已生成完成。",
hintPending: "当前会议尚未生成完整内容,请稍后重试。",
missingMeetingId: "未提供会议编号",
loadFailed: "会议预览加载失败",
noMeetingData: "未找到会议数据",
previewLabel: "会议预览",
untitledMeeting: "未命名会议",
meetingTime: "会议时间",
hostCreator: "主持/创建",
participantsCount: "参会人数",
tagsCount: "标签数量",
notSet: "未设置",
notFilled: "未填写",
pageSummary: "AI 纪要", pageSummary: "AI 纪要",
pageCatalog: "AI 目录", pageCatalog: "AI 目录",
pageTranscript: "转录原文", pageTranscript: "转录原文",
@ -66,30 +63,67 @@ const TEXT = {
shareCopied: "预览链接已复制", shareCopied: "预览链接已复制",
shareFallbackCopied: "当前设备不支持系统分享,已为你复制链接", shareFallbackCopied: "当前设备不支持系统分享,已为你复制链接",
shareFailed: "分享失败,请先复制链接", shareFailed: "分享失败,请先复制链接",
accessCheck: "访问校验",
passwordRequired: "该会议需要访问密码",
passwordHint: "请输入会议的 访问密码 后继续访问预览内容。",
passwordPlaceholder: "请输入 访问密码",
openPreview: "进入预览",
invalidPassword: "访问密码错误",
basicInfo: "基本信息", basicInfo: "基本信息",
meetingTime: "会议时间", meetingOverview: "会议概况",
hostCreator: "主持/创建", creator: "创建人",
participantsCount: "参会人数", host: "主持人",
createdAt: "创建时间",
audioStatus: "音频状态",
participants: "人",
tags: "会议标签", tags: "会议标签",
aiAnalysis: "AI 目录",
analysis: "会议分析",
previewExtra: "预览页仅读展示",
audioPlaybackWarning: "音频保存失败,可能影响回放。",
summaryOverview: "全文概要",
summaryEmpty: "暂无概要内容",
analysisChapters: "章节",
analysisSpeakers: "发言人",
analysisKeyPoints: "关键要点",
analysisTodos: "待办事项",
noChapterAnalysis: "暂无章节分析",
noSpeakerAnalysis: "暂无发言人分析",
noKeyPoints: "暂无关键要点",
noTodos: "暂无待办事项",
chapterFallback: "章节",
speakerFallback: "发言人",
speakerSummary: "发言概述",
keyPointFallback: "要点",
noChapterSummary: "暂无章节描述",
noSpeakerSummary: "暂无发言总结",
noKeyPointSummary: "暂无要点说明",
summarySection: "会议纪要",
fullSummary: "完整纪要",
noSummary: "暂无会议纪要", noSummary: "暂无会议纪要",
noCatalog: "暂无 AI 目录", transcriptSection: "会议转录",
noTranscript: "暂无转录内容", transcriptTitle: "逐段转录",
noDuration: "暂无时长", noDuration: "暂无时长",
audioUnavailable: "音频文件不可用,仅展示转录内容。", audioUnavailable: "音频文件不可用,仅展示转录内容。",
transcriptTitle: "逐段转录", noTranscript: "暂无转录内容",
keywordSection: "关键词", unknownSpeaker: "未知发言人",
disclaimer: "智能内容由用户会议内容 + AI 模型生成,我们不对内容准确性和完整性做任何保证,亦不代表我们的观点或态度",
shareText: "我向你分享了一个会议预览链接",
audioSaved: "已保存",
audioSaveFailed: "保存失败",
audioUploaded: "已上传",
audioNotSaved: "未保存",
linkToTranscript: "关联原文", linkToTranscript: "关联原文",
shareSettings: "分享访问设置", noCatalog: "暂无 AI 目录",
shareSettingsHint: "当前登录用户可直接查看,访问密码仅对分享出去的 H5 预览链接生效。",
saveSharePassword: "保存访问密码",
passwordPlaceholder: "为空表示取消访问密码",
disclaimer: "智能内容由用户会议内容与 AI 模型生成,我们不对内容准确性和完整性做任何保证。",
}; };
const STATUS_META: Record<number, { label: string; className: string }> = { type ChapterTranscriptLink = {
1: { label: TEXT.statusTranscribing, className: "is-processing" }, key: string;
2: { label: TEXT.statusSummarizing, className: "is-processing" }, title: string;
3: { label: TEXT.statusCompleted, className: "is-complete" }, timeLabel: string;
transcriptIds: number[];
firstTranscriptId: number | null;
firstTranscriptStartTime: number | null;
}; };
function parseChapterTimeToMs(value?: string) { function parseChapterTimeToMs(value?: string) {
@ -110,6 +144,24 @@ function parseChapterTimeToMs(value?: string) {
return totalSeconds * 1000; return totalSeconds * 1000;
} }
const STATUS_META: Record<number, { label: string; className: string; hint: string }> = {
1: { label: TEXT.statusTranscribing, className: "is-processing", hint: TEXT.hintTranscribing },
2: { label: TEXT.statusSummarizing, className: "is-processing", hint: TEXT.hintSummarizing },
3: { label: TEXT.statusCompleted, className: "is-complete", hint: TEXT.hintCompleted },
};
function formatDurationRange(startTime?: number, endTime?: number) {
const format = (milliseconds?: number) => {
const safeMs = Math.max(0, milliseconds || 0);
const totalSeconds = Math.floor(safeMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
return `${format(startTime)} - ${format(endTime)}`;
}
function splitDisplayItems(value?: string) { function splitDisplayItems(value?: string) {
return (value || "") return (value || "")
.split(",") .split(",")
@ -140,35 +192,24 @@ async function copyText(text: string) {
document.body.removeChild(textarea); document.body.removeChild(textarea);
} }
function formatDurationRange(startTime?: number, endTime?: number) { export default function MeetingPreview() {
const format = (milliseconds?: number) => { const { id } = useParams();
const safeMs = Math.max(0, milliseconds || 0); const [searchParams] = useSearchParams();
const totalSeconds = Math.floor(safeMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
return `${format(startTime)} - ${format(endTime)}`;
}
export default function MeetingPreviewView({
meeting,
transcripts,
meetingChapters,
shareUrl,
editableShare = false,
sharePasswordDraft = "",
shareSaving = false,
onSharePasswordDraftChange,
onSaveSharePassword,
onCopyShareLink,
}: MeetingPreviewViewProps) {
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const audioPlaybackErrorShownRef = useRef<string | null>(null); const audioPlaybackErrorShownRef = useRef<string | null>(null);
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
const [meetingChapters, setMeetingChapters] = useState<MeetingChapterVO[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("speakers");
const [pageTab, setPageTab] = useState<PreviewPageTab>("summary"); const [pageTab, setPageTab] = useState<PreviewPageTab>("summary");
const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(null); const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(null);
const [passwordRequired, setPasswordRequired] = useState(false);
const [passwordVerified, setPasswordVerified] = useState(false);
const [accessPassword, setAccessPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const [audioPlaying, setAudioPlaying] = useState(false); const [audioPlaying, setAudioPlaying] = useState(false);
const [audioCurrentTime, setAudioCurrentTime] = useState(0); const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(0); const [audioDuration, setAudioDuration] = useState(0);
@ -176,23 +217,117 @@ export default function MeetingPreviewView({
const [isMetricsExpanded, setIsMetricsExpanded] = useState(false); const [isMetricsExpanded, setIsMetricsExpanded] = useState(false);
const [linkedTranscriptIds, setLinkedTranscriptIds] = useState<number[]>([]); const [linkedTranscriptIds, setLinkedTranscriptIds] = useState<number[]>([]);
const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(null); const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(null);
const [isMobile, setIsMobile] = useState( const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false, typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false,
); );
const presetAccessPassword = useMemo(() => (searchParams.get("accessPassword") || "").trim(), [searchParams]);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; let mounted = true;
const load = async () => {
if (!id) {
setError(TEXT.missingMeetingId);
setLoading(false);
return;
}
setLoading(true);
setError("");
setMeeting(null);
setTranscripts([]);
setMeetingChapters([]);
setPasswordRequired(false);
setPasswordVerified(false);
setAccessPassword(presetAccessPassword);
setPasswordError("");
try {
const meetingId = Number(id);
const accessRes = await getMeetingPreviewAccess(meetingId);
if (!mounted) {
return;
}
const requiresPassword = !!accessRes.data.data.passwordRequired;
setPasswordRequired(requiresPassword);
if (requiresPassword) {
if (!presetAccessPassword) {
setLoading(false);
return;
}
try {
const previewRes = await getPublicMeetingPreview(meetingId, presetAccessPassword);
if (!mounted) {
return;
}
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
return;
} catch (requestError: any) {
if (!mounted) {
return;
}
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword);
setPasswordVerified(false);
setLoading(false);
return;
}
}
const previewRes = await getPublicMeetingPreview(meetingId);
if (!mounted) {
return;
}
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
} catch (requestError: any) {
if (!mounted) {
return;
}
setError(requestError?.response?.data?.msg || requestError?.msg || TEXT.loadFailed);
} finally {
if (mounted) {
setLoading(false);
}
}
};
load();
return () => {
mounted = false;
};
}, [id, presetAccessPassword]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 767px)"); const mediaQuery = window.matchMedia("(max-width: 767px)");
const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches); const handleChange = (event: MediaQueryListEvent) => {
setIsMobile(event.matches);
};
setIsMobile(mediaQuery.matches); setIsMobile(mediaQuery.matches);
mediaQuery.addEventListener("change", handleChange); mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange); return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []); }, []);
const analysis = useMemo( const analysis = useMemo(
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""), () => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""),
[meeting?.analysis, meeting?.summaryContent, meeting?.tags], [meeting?.analysis, meeting?.summaryContent, meeting?.tags],
); );
const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]); const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]);
const transcriptSpeakers = useMemo(() => { const transcriptSpeakers = useMemo(() => {
const speakers = transcripts const speakers = transcripts
@ -207,7 +342,9 @@ export default function MeetingPreviewView({
const statusMeta = STATUS_META[meeting?.status || 0] || { const statusMeta = STATUS_META[meeting?.status || 0] || {
label: TEXT.statusPending, label: TEXT.statusPending,
className: "is-warning", className: "is-warning",
hint: TEXT.hintPending,
}; };
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
const participantCountValue = const participantCountValue =
isMobile && transcriptSpeakers.length > 0 ? transcriptSpeakers.length : participants.length; isMobile && transcriptSpeakers.length > 0 ? transcriptSpeakers.length : participants.length;
@ -216,11 +353,11 @@ export default function MeetingPreviewView({
const last = transcripts[transcripts.length - 1]; const last = transcripts[transcripts.length - 1];
return last.endTime || 0; return last.endTime || 0;
} }
return meeting.duration || 0; return 0;
}, [meeting.duration, transcripts]); }, [transcripts]);
const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => { const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => {
const transcriptIdToIndex = new Map(transcripts.map((item) => [item.id, transcripts.indexOf(item)])); const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index]));
const sourceChapters: MeetingChapterVO[] = meetingChapters.length const sourceChapters: MeetingChapterVO[] = meetingChapters.length
? meetingChapters ? meetingChapters
: analysis.chapters.map((item) => ({ : analysis.chapters.map((item) => ({
@ -232,13 +369,13 @@ export default function MeetingPreviewView({
let matchedTranscripts: MeetingTranscriptVO[] = []; let matchedTranscripts: MeetingTranscriptVO[] = [];
const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds) const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds)
? chapter.sourceTranscriptIds ? chapter.sourceTranscriptIds
.map((item: number) => Number(item)) .map((item) => Number(item))
.filter((item: number) => Number.isFinite(item) && transcriptIdToIndex.has(item)) .filter((item) => Number.isFinite(item) && transcriptIdToIndex.has(item))
: []; : [];
if (sourceTranscriptIds.length) { if (sourceTranscriptIds.length) {
matchedTranscripts = sourceTranscriptIds matchedTranscripts = sourceTranscriptIds
.map((item: number) => transcripts[transcriptIdToIndex.get(item)!]) .map((item) => transcripts[transcriptIdToIndex.get(item)!])
.filter(Boolean); .filter(Boolean);
} else if (chapter.startTranscriptId && chapter.endTranscriptId) { } else if (chapter.startTranscriptId && chapter.endTranscriptId) {
const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId)); const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId));
@ -254,12 +391,12 @@ export default function MeetingPreviewView({
.find((item): item is number => item !== null && startMs !== null && item > startMs); .find((item): item is number => item !== null && startMs !== null && item > startMs);
if (startMs !== null) { if (startMs !== null) {
const firstTranscriptIndex = transcripts.findIndex((item) => (item.endTime || 0) > startMs); const firstTranscriptIndex = transcripts.findIndex((item) => item.endTime > startMs);
if (firstTranscriptIndex >= 0) { if (firstTranscriptIndex >= 0) {
const lastTranscriptIndex = const lastTranscriptIndex =
nextChapterStartMs === undefined nextChapterStartMs === undefined
? transcripts.length ? transcripts.length
: transcripts.findIndex((item) => (item.startTime || 0) >= nextChapterStartMs); : transcripts.findIndex((item) => item.startTime >= nextChapterStartMs);
matchedTranscripts = transcripts.slice( matchedTranscripts = transcripts.slice(
firstTranscriptIndex, firstTranscriptIndex,
lastTranscriptIndex >= 0 ? lastTranscriptIndex : transcripts.length, lastTranscriptIndex >= 0 ? lastTranscriptIndex : transcripts.length,
@ -280,14 +417,24 @@ export default function MeetingPreviewView({
}, [analysis.chapters, meetingChapters, transcripts]); }, [analysis.chapters, meetingChapters, transcripts]);
useEffect(() => { useEffect(() => {
if (!activeTranscriptId) return; if (!activeTranscriptId) {
return;
}
const target = transcriptItemRefs.current[activeTranscriptId]; const target = transcriptItemRefs.current[activeTranscriptId];
if (!target) return; if (!target) {
return;
}
// 使用 center 模式确保当前说话段落始终位于视口中央,避免被底部的浮动控件遮挡
target.scrollIntoView({ behavior: "smooth", block: "center" }); target.scrollIntoView({ behavior: "smooth", block: "center" });
}, [activeTranscriptId]); }, [activeTranscriptId]);
const handleTranscriptSeek = (item: MeetingTranscriptVO) => { const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
if (!audioRef.current) return; if (!audioRef.current) {
return;
}
audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000); audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000);
audioRef.current.play().catch(() => {}); audioRef.current.play().catch(() => {});
}; };
@ -300,9 +447,13 @@ export default function MeetingPreviewView({
setLinkedChapterKey(link.key); setLinkedChapterKey(link.key);
setActiveTranscriptId(link.firstTranscriptId); setActiveTranscriptId(link.firstTranscriptId);
// 自动跳转并播放音频
if (audioRef.current && link.firstTranscriptStartTime !== null) { if (audioRef.current && link.firstTranscriptStartTime !== null) {
audioRef.current.currentTime = Math.max(0, link.firstTranscriptStartTime / 1000); audioRef.current.currentTime = Math.max(0, link.firstTranscriptStartTime / 1000);
audioRef.current.play().catch(() => {}); audioRef.current.play().catch(() => {
// 部分浏览器(尤其是移动端)可能会拦截非直接交互触发的播放
// 但由于这是由用户点击目录项触发的,通常会被允许
});
} }
} }
}; };
@ -331,17 +482,18 @@ export default function MeetingPreviewView({
}; };
const formatPlayerTime = (seconds: number) => { const formatPlayerTime = (seconds: number) => {
if (!seconds || Number.isNaN(seconds)) return "00:00"; if (!seconds || isNaN(seconds)) return '00:00';
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}; };
const handleAudioTimeUpdate = () => { const handleAudioTimeUpdate = () => {
if (!audioRef.current) return; if (!audioRef.current) return;
const currentSeconds = audioRef.current.currentTime; const currentSeconds = audioRef.current.currentTime;
setAudioCurrentTime(currentSeconds); setAudioCurrentTime(currentSeconds);
// Also update duration if it's available now
if (audioRef.current.duration && audioDuration !== audioRef.current.duration) { if (audioRef.current.duration && audioDuration !== audioRef.current.duration) {
setAudioDuration(audioRef.current.duration); setAudioDuration(audioRef.current.duration);
} }
@ -356,44 +508,55 @@ export default function MeetingPreviewView({
setActiveTranscriptId(currentItem?.id || null); setActiveTranscriptId(currentItem?.id || null);
}; };
const handleAudioLoadedMetadata = () => { const handleAudioEnded = () => {
if (!audioRef.current) return; setAudioPlaying(false);
setAudioDuration(audioRef.current.duration || 0);
}; };
const handleAudioPlay = () => setAudioPlaying(true); const handleAudioPlay = () => setAudioPlaying(true);
const handleAudioPause = () => setAudioPlaying(false); const handleAudioPause = () => setAudioPlaying(false);
const handleAudioEnded = () => setAudioPlaying(false); const handleAudioLoadedMetadata = () => {
if (audioRef.current) {
setAudioDuration(audioRef.current.duration);
}
};
const handleAudioError = async () => { const handleAudioError = () => {
const currentAudioUrl = playbackAudioUrl || ""; const currentAudioUrl = playbackAudioUrl || "";
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) { if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
return; return;
} }
const normalizedUrl = currentAudioUrl.split("#")[0]?.split("?")[0]?.toLowerCase() || "";
const isM4a = normalizedUrl.endsWith(".m4a");
message.warning(
isM4a
? "当前 m4a 文件在本机浏览器中无法直接播放。已确认文件与服务端响应基本正常,更可能是浏览器对该录音参数或容器实现的兼容性问题。建议优先使用 mp3、wav或下载到本地播放。"
: TEXT.audioUnavailable,
);
audioPlaybackErrorShownRef.current = currentAudioUrl; audioPlaybackErrorShownRef.current = currentAudioUrl;
try {
const retryResp = await getPublicMeetingPreview(meeting.id);
const retryUrl = resolveMeetingPlaybackAudioUrl(retryResp.data.data.meeting);
if (retryUrl && retryUrl !== currentAudioUrl && audioRef.current) {
audioRef.current.src = retryUrl;
audioRef.current.load();
audioPlaybackErrorShownRef.current = null;
return;
}
} catch {
// ignore retry failure
}
message.warning(meeting.audioSaveMessage || TEXT.audioUnavailable);
setAudioPlaying(false); setAudioPlaying(false);
}; };
const handleCopyLink = async () => { const handlePasswordSubmit = async () => {
if (onCopyShareLink) { if (!id) {
await onCopyShareLink();
return; return;
} }
setLoading(true);
setPasswordError("");
try {
const previewRes = await getPublicMeetingPreview(Number(id), accessPassword.trim());
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
} catch (requestError: any) {
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword);
} finally {
setLoading(false);
}
};
const handleCopyLink = async () => {
try { try {
await copyText(shareUrl); await copyText(shareUrl);
message.success(TEXT.shareCopied); message.success(TEXT.shareCopied);
@ -406,8 +569,8 @@ export default function MeetingPreviewView({
try { try {
if (navigator.share) { if (navigator.share) {
await navigator.share({ await navigator.share({
title: meeting?.title || "会议预览", title: meeting?.title || TEXT.previewLabel,
text: "我向你分享了一个会议预览链接", text: TEXT.shareText,
url: shareUrl, url: shareUrl,
}); });
return; return;
@ -420,26 +583,136 @@ export default function MeetingPreviewView({
} }
}; };
if (loading && (!passwordRequired || passwordVerified)) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-loading">
<div className="meeting-preview-card meeting-preview-hero">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 10 }} />
</div>
</div>
</div>
);
}
if (passwordRequired && !passwordVerified) {
return (
<div className="meeting-preview-page is-password-gate">
<div className="password-gate-background">
<div className="bg-blob bg-blob-1" />
<div className="bg-blob bg-blob-2" />
<div className="bg-blob bg-blob-3" />
</div>
<div className="meeting-preview-shell">
<div className="meeting-preview-password-card">
<div className="password-card-header">
<div className="password-icon-wrapper">
<LockOutlined />
</div>
<h1 className="password-card-title">{TEXT.passwordRequired}</h1>
<p className="password-card-subtitle">{TEXT.passwordHint}</p>
</div>
<div className="password-card-form">
<div className="password-input-wrapper">
<Input.Password
size="large"
value={accessPassword}
placeholder={TEXT.passwordPlaceholder}
onChange={(event) => setAccessPassword(event.target.value)}
onPressEnter={handlePasswordSubmit}
prefix={<LockOutlined style={{ color: "var(--text-secondary)" }} />}
className="modern-password-input"
/>
</div>
<Button
type="primary"
size="large"
onClick={handlePasswordSubmit}
loading={loading}
disabled={!accessPassword.trim()}
className="password-submit-btn"
block
>
{TEXT.openPreview}
</Button>
</div>
{passwordError && (
<div className="password-error-message">
<Alert type="error" showIcon message={passwordError} />
</div>
)}
</div>
</div>
<div className="password-gate-footer">
<div className="footer-disclaimer">
<RobotOutlined />
<span>Secure Access Powered by iMeeting AI</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Result status="error" title={TEXT.loadFailed} subTitle={error} />
</div>
</div>
</div>
);
}
if (!meeting) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Empty description={TEXT.noMeetingData} />
</div>
</div>
</div>
);
}
const summaryTabContent = ( const summaryTabContent = (
<div className="meeting-preview-tab-panel"> <div className="meeting-preview-tab-panel">
<section className="meeting-preview-section"> <section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-summary-box"> <div className="meeting-preview-summary-box">
<div className="meeting-preview-summary-section-title">{TEXT.keywordSection}</div> <div className="meeting-preview-summary-section">
<div className="meeting-preview-record-tags"> <div className="meeting-preview-summary-section-title"></div>
{keywords.length ? ( <div className="meeting-preview-record-tags">
keywords.map((item) => ( {keywords.length ? (
<div key={item} className="meeting-preview-record-tag"> keywords.map((item) => (
<span>#{item}</span> <div key={item} className="meeting-preview-record-tag">
</div> <span>#{item}</span>
)) </div>
) : ( ))
<span className="meeting-preview-keywords-empty"></span> ) : (
)} <span className="meeting-preview-keywords-empty"></span>
)}
</div>
</div> </div>
</div> </div>
<div className="meeting-preview-markdown"> <div className="meeting-preview-markdown">
{meeting.summaryContent ? <ReactMarkdown>{meeting.summaryContent}</ReactMarkdown> : <Empty description={TEXT.noSummary} />} {meeting.summaryContent ? (
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
) : (
<Empty description={TEXT.noSummary} />
)}
</div> </div>
</section> </section>
</div> </div>
@ -447,19 +720,32 @@ export default function MeetingPreviewView({
const catalogTabContent = ( const catalogTabContent = (
<div className="meeting-preview-tab-panel"> <div className="meeting-preview-tab-panel">
<section className="meeting-preview-section"> <section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
{/*<div className="meeting-preview-section-kicker">*/}
{/* <RobotOutlined />*/}
{/* {TEXT.aiAnalysis}*/}
{/*</div>*/}
<h2 className="meeting-preview-section-title">{TEXT.pageCatalog}</h2>
</div>
</div>
<div className="meeting-preview-catalog-list"> <div className="meeting-preview-catalog-list">
{catalogChapterLinks.length ? ( {catalogChapterLinks.length ? (
catalogChapterLinks.map((chapter, index) => ( catalogChapterLinks.map((chapter, index) => (
<div <div
key={chapter.key} key={chapter.key}
className={`meeting-preview-catalog-item-container ${linkedChapterKey === chapter.key ? "active" : ""}`} className={`meeting-preview-catalog-item-container ${linkedChapterKey === chapter.key ? 'active' : ''}`}
> >
<div className="meeting-preview-catalog-timeline-axis"> <div className="meeting-preview-catalog-timeline-axis">
<div className="meeting-preview-catalog-timeline-dot" /> <div className="meeting-preview-catalog-timeline-dot" />
<div className="meeting-preview-catalog-timeline-line" /> <div className="meeting-preview-catalog-timeline-line" />
</div> </div>
<div className="meeting-preview-catalog-item-card" onClick={() => handleLocateChapterTranscript(index)}> <div
className="meeting-preview-catalog-item-card"
onClick={() => handleLocateChapterTranscript(index)}
>
<div className="meeting-preview-catalog-item-time">{chapter.timeLabel}</div> <div className="meeting-preview-catalog-item-time">{chapter.timeLabel}</div>
<div className="meeting-preview-catalog-item-title-row"> <div className="meeting-preview-catalog-item-title-row">
<div className="meeting-preview-catalog-item-title">{chapter.title}</div> <div className="meeting-preview-catalog-item-title">{chapter.title}</div>
@ -487,9 +773,13 @@ export default function MeetingPreviewView({
const transcriptTabContent = ( const transcriptTabContent = (
<div className="meeting-preview-tab-panel"> <div className="meeting-preview-tab-panel">
<section className="meeting-preview-section"> <section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header"> <div className="meeting-preview-section-header">
<div> <div>
{/*<div className="meeting-preview-section-kicker">*/}
{/* <AudioOutlined />*/}
{/* {TEXT.transcriptSection}*/}
{/*</div>*/}
<h2 className="meeting-preview-section-title">{TEXT.transcriptTitle}</h2> <h2 className="meeting-preview-section-title">{TEXT.transcriptTitle}</h2>
</div> </div>
<div className="meeting-preview-section-extra"> <div className="meeting-preview-section-extra">
@ -499,7 +789,12 @@ export default function MeetingPreviewView({
</div> </div>
{meeting.audioSaveStatus === "FAILED" ? ( {meeting.audioSaveStatus === "FAILED" ? (
<Alert className="meeting-preview-alert" type="warning" showIcon message={meeting.audioSaveMessage || TEXT.audioUnavailable} /> <Alert
className="meeting-preview-alert"
type="warning"
showIcon
message={meeting.audioSaveMessage || TEXT.audioUnavailable}
/>
) : null} ) : null}
<div className="meeting-preview-transcript-list"> <div className="meeting-preview-transcript-list">
@ -508,6 +803,7 @@ export default function MeetingPreviewView({
const speakerKey = item.speakerName || item.speakerLabel || item.speakerId || "speaker"; const speakerKey = item.speakerName || item.speakerLabel || item.speakerId || "speaker";
const isLinked = linkedTranscriptIds.includes(item.id); const isLinked = linkedTranscriptIds.includes(item.id);
const isActive = activeTranscriptId === item.id; const isActive = activeTranscriptId === item.id;
return ( return (
<div <div
key={item.id} key={item.id}
@ -517,7 +813,7 @@ export default function MeetingPreviewView({
className={`meeting-preview-transcript-item ${isActive ? "is-active" : ""} ${isLinked ? "is-linked" : ""}`} className={`meeting-preview-transcript-item ${isActive ? "is-active" : ""} ${isLinked ? "is-linked" : ""}`}
onClick={() => { onClick={() => {
handleTranscriptSeek(item); handleTranscriptSeek(item);
setLinkedTranscriptIds([]); setLinkedTranscriptIds([]); // Clear linked highlight on manual seek
setLinkedChapterKey(null); setLinkedChapterKey(null);
}} }}
> >
@ -534,7 +830,9 @@ export default function MeetingPreviewView({
{formatDurationRange(item.startTime, item.endTime)} {formatDurationRange(item.startTime, item.endTime)}
</span> </span>
</div> </div>
<div className="meeting-preview-transcript-text">{item.content || TEXT.noTranscript}</div> <div className="meeting-preview-transcript-text">
{item.content || TEXT.noTranscript}
</div>
</div> </div>
</div> </div>
); );
@ -563,101 +861,104 @@ export default function MeetingPreviewView({
<div className={`meeting-preview-page ${isMobile ? "is-mobile" : "is-desktop"}`}> <div className={`meeting-preview-page ${isMobile ? "is-mobile" : "is-desktop"}`}>
<div className="meeting-preview-container"> <div className="meeting-preview-container">
<div className="meeting-preview-shell"> <div className="meeting-preview-shell">
{/* Header Title Section */}
<div className="meeting-preview-top-hero"> <div className="meeting-preview-top-hero">
<div className="meeting-preview-hero-logo"> <div className="meeting-preview-hero-logo">
<RobotOutlined /> <RobotOutlined />
</div> </div>
<div className="meeting-preview-hero-content"> <div className="meeting-preview-hero-content">
<h1 className="meeting-preview-hero-title">{meeting.title || "未命名会议"}</h1> <h1 className="meeting-preview-hero-title">{meeting.title || TEXT.untitledMeeting}</h1>
<div className="meeting-preview-hero-meta"> <div className="meeting-preview-hero-meta">
<span className={`meeting-preview-status-tag ${statusMeta.className}`}>{statusMeta.label}</span> <span className={`meeting-preview-status-tag ${statusMeta.className}`}>
{statusMeta.label}
</span>
{/*<span className="meeting-preview-hero-id">ID: {meeting.id}</span>*/}
</div> </div>
</div> </div>
</div> </div>
{/* Collapsible Basic Info Section */}
<div className="meeting-preview-collapsible-section"> <div className="meeting-preview-collapsible-section">
<div className="meeting-preview-collapsible-trigger" onClick={() => setIsMetricsExpanded(!isMetricsExpanded)}> <div
className="meeting-preview-collapsible-trigger"
onClick={() => setIsMetricsExpanded(!isMetricsExpanded)}
>
<div className="trigger-left"> <div className="trigger-left">
<FileTextOutlined /> <FileTextOutlined />
<span>{TEXT.basicInfo}</span> <span>{TEXT.basicInfo}</span>
</div> </div>
<div className="trigger-right">{isMetricsExpanded ? <UpOutlined /> : <DownOutlined />}</div> <div className="trigger-right">
{isMetricsExpanded ? <UpOutlined /> : <DownOutlined />}
</div>
</div> </div>
<div className={`meeting-preview-collapsible-content ${isMetricsExpanded ? "is-expanded" : ""}`}> <div className={`meeting-preview-collapsible-content ${isMetricsExpanded ? 'is-expanded' : ''}`}>
<div className="meeting-preview-metrics-grid"> <div className="meeting-preview-metrics-grid">
<div className="metric-item"> <div className="metric-item">
<div className="metric-label">{TEXT.meetingTime}</div> <div className="metric-label">{TEXT.meetingTime}</div>
<div className="metric-value"> <div className="metric-value">
<CalendarOutlined style={{ marginRight: 8, color: "var(--primary-blue)" }} /> <CalendarOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : "未设置"} {meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : TEXT.notSet}
</div> </div>
</div> </div>
<div className="metric-item"> <div className="metric-item">
<div className="metric-label">{TEXT.hostCreator}</div> <div className="metric-label">{TEXT.hostCreator}</div>
<div className="metric-value"> <div className="metric-value">
<UserOutlined style={{ marginRight: 8, color: "var(--primary-blue)" }} /> <UserOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meeting.creatorName || "未设置"} {meeting.creatorName || TEXT.notSet}
</div> </div>
</div> </div>
<div className="metric-item"> <div className="metric-item">
<div className="metric-label">{TEXT.participantsCount}</div> <div className="metric-label">{TEXT.participantsCount}</div>
<div className="metric-value"> <div className="metric-value">
<TeamOutlined style={{ marginRight: 8, color: "var(--primary-blue)" }} /> <TeamOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{participantCountValue} {participantCountValue} {TEXT.participants}
</div> </div>
</div> </div>
<div className="metric-item"> <div className="metric-item">
<div className="metric-label"></div> <div className="metric-label"></div>
<div className="metric-value"> <div className="metric-value">
<ClockCircleOutlined style={{ marginRight: 8, color: "var(--primary-blue)" }} /> <ClockCircleOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meetingDuration > 0 ? formatTotalDuration(meetingDuration) : "未设置"} {meetingDuration > 0 ? formatTotalDuration(meetingDuration) : TEXT.notSet}
</div> </div>
</div> </div>
{tags.length > 0 ? ( {tags.length > 0 && (
<div className="metric-item metric-item-full"> <div className="metric-item metric-item-full">
<div className="metric-label">{TEXT.tags}</div> <div className="metric-label">{TEXT.tags}</div>
<div className="metric-tags"> <div className="metric-tags">
{tags.map((tag) => ( {tags.map(tag => (
<span key={tag} className="metric-tag"> <span key={tag} className="metric-tag">#{tag}</span>
#{tag}
</span>
))} ))}
</div> </div>
</div> </div>
) : null} )}
</div> </div>
</div> </div>
</div> </div>
{editableShare ? ( {/* Sharing Buttons Bar */}
<div className="meeting-preview-share-settings">
<div className="meeting-preview-share-settings-title">{TEXT.shareSettings}</div>
<div className="meeting-preview-share-settings-desc">{TEXT.shareSettingsHint}</div>
<div className="meeting-preview-share-settings-row">
<Input.Password
value={sharePasswordDraft}
placeholder={TEXT.passwordPlaceholder}
prefix={<LockOutlined />}
onChange={(event) => onSharePasswordDraftChange?.(event.target.value)}
/>
<Button type="primary" loading={shareSaving} onClick={onSaveSharePassword}>
{TEXT.saveSharePassword}
</Button>
</div>
</div>
) : null}
<div className="meeting-preview-share-bar"> <div className="meeting-preview-share-bar">
<Button type="primary" size="large" icon={<ShareAltOutlined />} onClick={handleShareNow} className="share-btn-primary"> <Button
type="primary"
size="large"
icon={<ShareAltOutlined />}
onClick={handleShareNow}
className="share-btn-primary"
>
{TEXT.shareNow} {TEXT.shareNow}
</Button> </Button>
<Button size="large" icon={<CopyOutlined />} onClick={handleCopyLink} className="share-btn-ghost"> <Button
size="large"
icon={<CopyOutlined />}
onClick={handleCopyLink}
className="share-btn-ghost"
>
{TEXT.copyLink} {TEXT.copyLink}
</Button> </Button>
</div> </div>
<div className="meeting-preview-layout-full"> <div className="meeting-preview-layout-full">
{/* Main Content Area */}
<main className="meeting-preview-main"> <main className="meeting-preview-main">
<div className="meeting-preview-content-card"> <div className="meeting-preview-content-card">
<div className="meeting-preview-tabs-container"> <div className="meeting-preview-tabs-container">
@ -690,8 +991,12 @@ export default function MeetingPreviewView({
</div> </div>
</div> </div>
{playbackAudioUrl ? ( {/* Floating Audio Player - Permanent mount, visibility controlled */}
<div className="meeting-preview-audio-player-inline" style={{ display: pageTab === "transcript" ? "flex" : "none" }}> {playbackAudioUrl && (
<div
className="meeting-preview-audio-player-inline"
style={{ display: pageTab === "transcript" ? "flex" : "none" }}
>
<audio <audio
ref={audioRef} ref={audioRef}
onTimeUpdate={handleAudioTimeUpdate} onTimeUpdate={handleAudioTimeUpdate}
@ -729,7 +1034,7 @@ export default function MeetingPreviewView({
</button> </button>
</div> </div>
</div> </div>
) : null} )}
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import { import {
AppstoreOutlined, AppstoreOutlined,
AudioOutlined, AudioOutlined,
CalendarOutlined, CalendarOutlined,
@ -76,11 +76,11 @@ const ALL_STATUS_FILTER = "all";
const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000; const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000;
const MEETING_STATUS_FILTER_OPTIONS = [ const MEETING_STATUS_FILTER_OPTIONS = [
{ label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" }, { label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" },
{ label: "排队中", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" }, { label: "数据初始化", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" },
{ label: "识别中", value: "1", color: "#1890ff", bgColor: "#e6f7ff" }, { label: "转译音频", value: "1", color: "#1890ff", bgColor: "#e6f7ff" },
{ label: "总结", value: "2", color: "#faad14", bgColor: "#fff7e6" }, { label: "生成总结", value: "2", color: "#faad14", bgColor: "#fff7e6" },
{ label: "完成", value: "3", color: "#52c41a", bgColor: "#f6ffed" }, { label: "处理完成", value: "3", color: "#52c41a", bgColor: "#f6ffed" },
{ label: "失败", value: "4", color: "#ff4d4f", bgColor: "#fff1f0" }, { label: "处理失败", value: "4", color: "#ff4d4f", bgColor: "#fff1f0" },
] as const; ] as const;
const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = { const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
offlineEnabled: true, offlineEnabled: true,
@ -122,12 +122,34 @@ const shouldPollMeetingCard = (item: MeetingVO) =>
|| item.realtimeSessionStatus === "ACTIVE" || item.realtimeSessionStatus === "ACTIVE"
|| isPausedRealtimeSessionStatus(item.realtimeSessionStatus); || isPausedRealtimeSessionStatus(item.realtimeSessionStatus);
const getUnifiedStatusCode = (progress: MeetingProgress | null | undefined) =>
progress?.unifiedStatus?.statusCode;
const getEffectiveStatus = (item: MeetingVO, progress: MeetingProgress | null) => { const getEffectiveStatus = (item: MeetingVO, progress: MeetingProgress | null) => {
const unifiedStatusCode = getUnifiedStatusCode(progress);
if (unifiedStatusCode === "WAITING_UPLOAD") {
return 8;
}
if (unifiedStatusCode === "INITIALIZING") {
return 0;
}
if (unifiedStatusCode === "TRANSCRIBING") {
return 1;
}
if (unifiedStatusCode === "SUMMARIZING") {
return 2;
}
if (unifiedStatusCode === "COMPLETED") {
return 3;
}
if (unifiedStatusCode?.startsWith("FAILED_")) {
return 4;
}
if (hasLatestGenerationFailure(item)) { if (hasLatestGenerationFailure(item)) {
return 4; return 4;
} }
const status = item.displayStatus ?? item.status; const status = item.displayStatus ?? item.status;
// 如果是排队中但已有进度,则视为识别中 // 如果处于初始化中但已经有进度,则视为转译音频
if (status === 0 && progress && progress.percent > 0) { if (status === 0 && progress && progress.percent > 0) {
return 1; return 1;
} }
@ -176,16 +198,20 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee
const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => { const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
const effectiveStatus = getEffectiveStatus(meeting, progress); const effectiveStatus = getEffectiveStatus(meeting, progress);
const statusConfig: Record<number, { text: string; color: string; bgColor: string; icon: React.ReactNode }> = { const statusConfig: Record<number, { text: string; color: string; bgColor: string; icon: React.ReactNode }> = {
0: { text: "排队中", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: <SyncOutlined spin /> }, 0: { text: "数据初始化", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: <SyncOutlined spin /> },
1: { text: "识别中", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: <SyncOutlined spin /> }, 1: { text: "转译音频", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: <SyncOutlined spin /> },
2: { text: "总结", color: "#faad14", bgColor: "rgba(250, 173, 20, 0.1)", icon: <SyncOutlined spin /> }, 2: { text: "生成总结", color: "#faad14", bgColor: "rgba(250, 173, 20, 0.1)", icon: <SyncOutlined spin /> },
3: { text: "完成", color: "#52c41a", bgColor: "rgba(82, 196, 26, 0.1)", icon: <CheckOutlined /> }, 3: { text: "处理完成", color: "#52c41a", bgColor: "rgba(82, 196, 26, 0.1)", icon: <CheckOutlined /> },
4: { text: "失败", color: "#ff4d4f", bgColor: "rgba(255, 77, 79, 0.1)", icon: <InfoCircleOutlined /> }, 4: { text: "处理失败", color: "#ff4d4f", bgColor: "rgba(255, 77, 79, 0.1)", icon: <InfoCircleOutlined /> },
5: { text: "暂停中", color: "#d48806", bgColor: "rgba(212, 136, 6, 0.1)", icon: <PauseCircleOutlined /> }, 5: { text: "暂停中", color: "#d48806", bgColor: "rgba(212, 136, 6, 0.1)", icon: <PauseCircleOutlined /> },
6: { text: "进行中", color: "#5f51ff", bgColor: "rgba(95, 81, 255, 0.1)", icon: <SyncOutlined spin /> }, 6: { text: "进行中", color: "#5f51ff", bgColor: "rgba(95, 81, 255, 0.1)", icon: <SyncOutlined spin /> },
7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: <InfoCircleOutlined /> }, 7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: <InfoCircleOutlined /> },
8: { text: "待上传录音文件", color: "#13a8a8", bgColor: "rgba(19, 168, 168, 0.1)", icon: <CloudUploadOutlined /> },
}; };
const config = statusConfig[effectiveStatus] || statusConfig[0]; const config = statusConfig[effectiveStatus] || statusConfig[0];
const displayConfig = progress?.unifiedStatus?.statusText
? { ...config, text: progress.unifiedStatus.statusText }
: config;
const isProcessing = shouldTrackGenerationProgress(meeting); const isProcessing = shouldTrackGenerationProgress(meeting);
const percent = isProcessing ? progress?.percent || 0 : 0; const percent = isProcessing ? progress?.percent || 0 : 0;
@ -198,9 +224,9 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
borderRadius: "8px", borderRadius: "8px",
fontSize: "12px", fontSize: "12px",
fontWeight: 700, fontWeight: 700,
color: config.color, color: displayConfig.color,
background: config.bgColor, background: displayConfig.bgColor,
border: `1px solid ${config.color}20`, border: `1px solid ${displayConfig.color}20`,
gap: "4px", gap: "4px",
position: "relative", position: "relative",
overflow: "hidden" overflow: "hidden"
@ -214,16 +240,16 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
bottom: 0, bottom: 0,
height: "2px", height: "2px",
width: `${percent}%`, width: `${percent}%`,
background: config.color, background: displayConfig.color,
transition: "width 0.4s ease-out", transition: "width 0.4s ease-out",
boxShadow: `0 0 8px ${config.color}` boxShadow: `0 0 8px ${displayConfig.color}`
}} }}
/> />
)} )}
<span style={{ display: "flex", alignItems: "center" }}> <span style={{ display: "flex", alignItems: "center" }}>
{isProcessing ? <SyncOutlined spin style={{ fontSize: 11 }} /> : config.icon} {isProcessing ? <SyncOutlined spin style={{ fontSize: 11 }} /> : displayConfig.icon}
</span> </span>
<span>{config.text}</span> <span>{displayConfig.text}</span>
{isProcessing && <span style={{ opacity: 0.8, fontSize: "11px", fontWeight: 500 }}>{percent}%</span>} {isProcessing && <span style={{ opacity: 0.8, fontSize: "11px", fontWeight: 500 }}>{percent}%</span>}
</div> </div>
); );
@ -248,7 +274,7 @@ const MeetingCardItem: React.FC<{
const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS; const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS;
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS; const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item); const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item);
const crossPlatformHint = `${getRealtimeSourceLabel(item)}继续`; const crossPlatformHint = ` ${getRealtimeSourceLabel(item)} 继续`;
const canRetry = canRetryQueuedMeeting(item, progress); const canRetry = canRetryQueuedMeeting(item, progress);
const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6"; const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6";
@ -686,7 +712,7 @@ const Meetings: React.FC = () => {
return; return;
} }
if (!canControlRealtimeFromCurrentPlatform(meeting)) { if (!canControlRealtimeFromCurrentPlatform(meeting)) {
message.info("该实时会议需在" + getRealtimeSourceLabel(meeting) + "继续,当前仅支持查看详情"); message.info(`该实时会议需在${getRealtimeSourceLabel(meeting)}继续,当前仅支持查看详情`);
navigate("/meetings/" + meeting.id); navigate("/meetings/" + meeting.id);
return; return;
} }
@ -736,7 +762,7 @@ const Meetings: React.FC = () => {
render: (text: string) => <Text type="secondary">{text || "未知"}</Text>, render: (text: string) => <Text type="secondary">{text || "未知"}</Text>,
}, },
{ {
title: "来源", title: "鏉ユ簮",
dataIndex: "meetingSource", dataIndex: "meetingSource",
key: "meetingSource", key: "meetingSource",
width: 80, width: 80,
@ -746,10 +772,10 @@ const Meetings: React.FC = () => {
title: "参会人", title: "参会人",
dataIndex: "participants", dataIndex: "participants",
key: "participants", key: "participants",
render: (text: string) => <Text type="secondary" ellipsis style={{ maxWidth: 180 }}>{text || "无参人员"}</Text>, render: (text: string) => <Text type="secondary" ellipsis style={{ maxWidth: 180 }}>{text || "无参人员"}</Text>,
}, },
{ {
title: "操作", title: "鎿嶄綔",
key: "action", key: "action",
width: 220, width: 220,
render: (_: unknown, record: MeetingVO) => ( render: (_: unknown, record: MeetingVO) => (
@ -779,11 +805,11 @@ const Meetings: React.FC = () => {
]; ];
const statusConfig: Record<number, { text: string; color: string; bgColor: string }> = { const statusConfig: Record<number, { text: string; color: string; bgColor: string }> = {
0: { text: "排队中", color: "#8c8c8c", bgColor: "#f5f5f5" }, 0: { text: "数据初始化", color: "#8c8c8c", bgColor: "#f5f5f5" },
1: { text: "识别中", color: "#1890ff", bgColor: "#e6f7ff" }, 1: { text: "转译音频", color: "#1890ff", bgColor: "#e6f7ff" },
2: { text: "总结", color: "#faad14", bgColor: "#fff7e6" }, 2: { text: "生成总结", color: "#faad14", bgColor: "#fff7e6" },
3: { text: "完成", color: "#52c41a", bgColor: "#f6ffed" }, 3: { text: "处理完成", color: "#52c41a", bgColor: "#f6ffed" },
4: { text: "失败", color: "#ff4d4f", bgColor: "#fff1f0" }, 4: { text: "处理失败", color: "#ff4d4f", bgColor: "#fff1f0" },
5: { text: "会议暂停", color: "#d48806", bgColor: "#fff7e6" }, 5: { text: "会议暂停", color: "#d48806", bgColor: "#fff7e6" },
6: { text: "实时进行中", color: "#1677ff", bgColor: "#e6f4ff" }, 6: { text: "实时进行中", color: "#1677ff", bgColor: "#e6f4ff" },
7: { text: "待开始", color: "#595959", bgColor: "#f5f5f5" }, 7: { text: "待开始", color: "#595959", bgColor: "#f5f5f5" },

View File

@ -1,8 +1,8 @@
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Tag, Typography, message } from "antd"; import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Tag, Typography, message } from "antd";
import { CheckCircleOutlined, DesktopOutlined, DisconnectOutlined, EditOutlined, ReloadOutlined, SearchOutlined, ThunderboltOutlined, UserOutlined } from "@ant-design/icons"; import { CheckCircleOutlined, DeleteOutlined, DesktopOutlined, DisconnectOutlined, EditOutlined, ReloadOutlined, SearchOutlined, ThunderboltOutlined, UndoOutlined, UserOutlined } from "@ant-design/icons";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { kickManagedDevice, listManagedDevices, updateManagedDevice } from "@/api"; import { deleteManagedDevice, kickManagedDevice, listManagedDevices, resetManagedDeviceStats, updateManagedDevice } from "@/api";
import PageHeader from "@/components/shared/PageHeader"; import PageHeader from "@/components/shared/PageHeader";
import PageContainer from "@/components/shared/PageContainer"; import PageContainer from "@/components/shared/PageContainer";
import ListTable from "@/components/shared/ListTable/ListTable"; import ListTable from "@/components/shared/ListTable/ListTable";
@ -17,6 +17,7 @@ const { Text } = Typography;
type DeviceFormValues = { type DeviceFormValues = {
deviceName?: string; deviceName?: string;
status: number; status: number;
weatherCityName?: string;
}; };
export default function Devices() { export default function Devices() {
@ -82,7 +83,8 @@ export default function Devices() {
setEditing(record); setEditing(record);
form.setFieldsValue({ form.setFieldsValue({
deviceName: record.deviceName, deviceName: record.deviceName,
status: record.status ?? 1 status: record.status ?? 1,
weatherCityName: record.weatherCityName
}); });
setOpen(true); setOpen(true);
}; };
@ -96,7 +98,8 @@ export default function Devices() {
try { try {
await updateManagedDevice(editing.deviceId, { await updateManagedDevice(editing.deviceId, {
deviceName: values.deviceName, deviceName: values.deviceName,
status: values.status status: values.status,
weatherCityName: values.weatherCityName
}); });
message.success(t("devicesExt.operationSucceeded")); message.success(t("devicesExt.operationSucceeded"));
setOpen(false); setOpen(false);
@ -112,6 +115,18 @@ export default function Devices() {
await loadData(); await loadData();
}; };
const remove = async (record: DeviceInfo) => {
await deleteManagedDevice(record.deviceId);
message.success(t("devicesExt.deleteSucceeded"));
await loadData();
};
const resetStats = async (record: DeviceInfo) => {
await resetManagedDeviceStats(record.deviceId);
message.success(t("devicesExt.resetStatsSucceeded"));
await loadData();
};
const handlePageChange = (page: number, pageSize: number) => { const handlePageChange = (page: number, pageSize: number) => {
setPagination({ current: page, pageSize }); setPagination({ current: page, pageSize });
}; };
@ -187,6 +202,22 @@ export default function Devices() {
</Text> </Text>
) )
}, },
{
title: t("devicesExt.statsResetAt"),
dataIndex: "statsResetAt",
width: 180,
render: (text: string) => (
<Text type="secondary" className="tabular-nums">
{text ? text.replace("T", " ").substring(0, 19) : "-"}
</Text>
)
},
{
title: t("devicesExt.weatherCityName"),
dataIndex: "weatherCityName",
width: 140,
render: (text: string) => <Text>{text || "-"}</Text>
},
{ {
title: t("common.status"), title: t("common.status"),
dataIndex: "status", dataIndex: "status",
@ -209,7 +240,7 @@ export default function Devices() {
{ {
title: t("common.action"), title: t("common.action"),
key: "action", key: "action",
width: 140, width: 220,
fixed: "right" as const, fixed: "right" as const,
render: (_value: unknown, record: DeviceInfo) => ( render: (_value: unknown, record: DeviceInfo) => (
<Space> <Space>
@ -226,6 +257,25 @@ export default function Devices() {
/> />
</Popconfirm> </Popconfirm>
) : null} ) : null}
{can("device:update") ? (
<Popconfirm title={t("devicesExt.resetStatsConfirm")} onConfirm={() => resetStats(record)}>
<Button
type="text"
icon={<UndoOutlined aria-hidden="true" />}
aria-label={t("devicesExt.resetStats")}
/>
</Popconfirm>
) : null}
{can("device:update") ? (
<Popconfirm title={t("devicesExt.deleteDevice")} onConfirm={() => remove(record)}>
<Button
type="text"
danger
icon={<DeleteOutlined aria-hidden="true" />}
aria-label={t("devicesExt.deleteDevice")}
/>
</Popconfirm>
) : null}
</Space> </Space>
) )
} }
@ -334,6 +384,9 @@ export default function Devices() {
<Form.Item label={t("devices.deviceName")} name="deviceName"> <Form.Item label={t("devices.deviceName")} name="deviceName">
<Input placeholder={t("devicesExt.deviceNamePlaceholder")} /> <Input placeholder={t("devicesExt.deviceNamePlaceholder")} />
</Form.Item> </Form.Item>
<Form.Item label={t("devicesExt.weatherCityName")} name="weatherCityName">
<Input placeholder={t("devicesExt.weatherCityNamePlaceholder")} />
</Form.Item>
<Form.Item label={t("common.status")} name="status" initialValue={1}> <Form.Item label={t("common.status")} name="status" initialValue={1}>
<Select options={statusDict.map((item) => ({ value: Number(item.itemValue), label: item.itemLabel }))} /> <Select options={statusDict.map((item) => ({ value: Number(item.itemValue), label: item.itemLabel }))} />
</Form.Item> </Form.Item>

View File

@ -6,6 +6,7 @@ import { menuRoutes,extraRoutes } from "./routes";
const Login = lazy(() => import("@/pages/auth/login")); const Login = lazy(() => import("@/pages/auth/login"));
const ResetPassword = lazy(() => import("@/pages/auth/reset-password")); const ResetPassword = lazy(() => import("@/pages/auth/reset-password"));
const MeetingPreview = lazy(() => import("@/pages/business/MeetingPreview"));
const PublicDeviceMeetingCreate = lazy(() => import("@/pages/business/PublicDeviceMeetingCreate")); const PublicDeviceMeetingCreate = lazy(() => import("@/pages/business/PublicDeviceMeetingCreate"));
function RouteFallback() { function RouteFallback() {
@ -70,6 +71,10 @@ export default function AppRoutes() {
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/reset-password" element={<ResetPassword />} /> <Route path="/reset-password" element={<ResetPassword />} />
<Route
path="/meetings/:id/preview"
element={<MeetingPreview />}
/>
<Route <Route
path="/public-device-meetings/:sessionId/create" path="/public-device-meetings/:sessionId/create"
element={ element={

View File

@ -21,6 +21,7 @@ const ClientManagement = lazy(() => import("@/pages/business/ClientManagement"))
const ExternalAppManagement = lazy(() => import("@/pages/business/ExternalAppManagement")); const ExternalAppManagement = lazy(() => import("@/pages/business/ExternalAppManagement"));
const ScreenSaverManagement = lazy(() => import("@/pages/business/ScreenSaverManagement")); const ScreenSaverManagement = lazy(() => import("@/pages/business/ScreenSaverManagement"));
const MeetingPointsManagement = lazy(() => import("@/pages/business/MeetingPointsManagement")); const MeetingPointsManagement = lazy(() => import("@/pages/business/MeetingPointsManagement"));
const LicenseManagement = lazy(() => import("@/pages/business/LicenseManagement"));
import SpeakerReg from "../pages/business/SpeakerReg"; import SpeakerReg from "../pages/business/SpeakerReg";
const RealtimeAsrSession = lazy(async () => { const RealtimeAsrSession = lazy(async () => {
@ -65,6 +66,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/prompts", label: "总结模板", element: <LazyPage><PromptTemplates /></LazyPage>, perm: "menu:prompt" }, { path: "/prompts", label: "总结模板", element: <LazyPage><PromptTemplates /></LazyPage>, perm: "menu:prompt" },
{ path: "/aimodels", label: "模型配置", element: <LazyPage><AiModels /></LazyPage>, perm: "menu:aimodel" }, { path: "/aimodels", label: "模型配置", element: <LazyPage><AiModels /></LazyPage>, perm: "menu:aimodel" },
{ path: "/clients", label: "客户端管理", element: <LazyPage><ClientManagement /></LazyPage>, perm: "menu:clients" }, { path: "/clients", label: "客户端管理", element: <LazyPage><ClientManagement /></LazyPage>, perm: "menu:clients" },
{ path: "/licenses", label: "授权码管理", element: <LazyPage><LicenseManagement /></LazyPage>, perm: "menu:licenses" },
{ path: "/external-apps", label: "外部应用管理", element: <LazyPage><ExternalAppManagement /></LazyPage>, perm: "menu:external-apps" }, { path: "/external-apps", label: "外部应用管理", element: <LazyPage><ExternalAppManagement /></LazyPage>, perm: "menu:external-apps" },
{ path: "/screen-savers", label: "屏保管理", element: <LazyPage><ScreenSaverManagement /></LazyPage>, perm: "menu:screen-savers" }, { path: "/screen-savers", label: "屏保管理", element: <LazyPage><ScreenSaverManagement /></LazyPage>, perm: "menu:screen-savers" },
{ path: "/meeting-points", label: "积分管理", element: <LazyPage><MeetingPointsManagement /></LazyPage>, perm: "menu:meeting-points" }, { path: "/meeting-points", label: "积分管理", element: <LazyPage><MeetingPointsManagement /></LazyPage>, perm: "menu:meeting-points" },

View File

@ -92,6 +92,10 @@ export interface DeviceInfo extends BaseEntity {
terminalVersion?: string; terminalVersion?: string;
online?: boolean; online?: boolean;
lastOnlineAt?: string; lastOnlineAt?: string;
statsResetAt?: string;
weatherCityName?: string;
username?: string;
displayName?: string;
} }
export interface SysDictType extends BaseEntity { export interface SysDictType extends BaseEntity {