feat: 添加用户提示支持和优化会议访问逻辑
- 在 `MeetingCommandService` 和 `MeetingDomainSupport` 中添加 `userPrompt` 参数 - 在 `MeetingAccessService` 和 `MeetingQueryService` 中添加忽略租户的会议查询方法 - 更新前端API和组件,支持用户提示功能 - 优化会议访问逻辑,包括预览密码验证和角色管理页面 - 添加相关单元测试以验证新功能的正确性dev_na
parent
d4424a157b
commit
27ae0a3def
|
|
@ -4,4 +4,5 @@ public final class SysParamKeys {
|
|||
private SysParamKeys() {}
|
||||
|
||||
public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
|
||||
public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -356,13 +356,13 @@ public class MeetingController {
|
|||
|
||||
@PostMapping("/{id}/summary/regenerate")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ApiResponse<Boolean> reSummary(@PathVariable Long id, @RequestBody MeetingResummaryDTO dto) {
|
||||
public ApiResponse<Boolean> reSummary(@PathVariable Long id, @Valid @RequestBody MeetingResummaryDTO dto) {
|
||||
LoginUser loginUser = currentLoginUser();
|
||||
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
||||
dto.setMeetingId(id);
|
||||
assertPromptAvailable(dto.getPromptId(), loginUser);
|
||||
meetingCommandService.reSummary(dto.getMeetingId(), dto.getSummaryModelId(), dto.getPromptId());
|
||||
meetingCommandService.reSummary(dto.getMeetingId(), dto.getSummaryModelId(), dto.getPromptId(), dto.getUserPrompt());
|
||||
return ApiResponse.ok(true);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public class MeetingPublicPreviewController {
|
|||
@GetMapping("/{id}/preview/access")
|
||||
public ApiResponse<MeetingPreviewAccessVO> getPreviewAccess(@PathVariable Long id) {
|
||||
try {
|
||||
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(id);
|
||||
return ApiResponse.ok(new MeetingPreviewAccessVO(meetingAccessService.isPreviewPasswordRequired(meeting)));
|
||||
} catch (RuntimeException ex) {
|
||||
return ApiResponse.error(ex.getMessage());
|
||||
|
|
@ -39,11 +39,11 @@ public class MeetingPublicPreviewController {
|
|||
public ApiResponse<PublicMeetingPreviewVO> getPreview(@PathVariable Long id,
|
||||
@RequestParam(required = false) String accessPassword) {
|
||||
try {
|
||||
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(id);
|
||||
meetingAccessService.assertCanPreviewMeeting(meeting, accessPassword);
|
||||
|
||||
PublicMeetingPreviewVO data = new PublicMeetingPreviewVO();
|
||||
data.setMeeting(meetingQueryService.getDetail(id));
|
||||
data.setMeeting(meetingQueryService.getDetailIgnoreTenant(id));
|
||||
if (data.getMeeting() != null) {
|
||||
data.getMeeting().setAccessPassword(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.imeeting.dto.biz;
|
|||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
|
@ -34,6 +35,9 @@ public class CreateMeetingCommand {
|
|||
@NotNull(message = "promptId must not be null")
|
||||
private Long promptId;
|
||||
|
||||
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
||||
private String userPrompt;
|
||||
|
||||
private Integer useSpkId;
|
||||
private Boolean enableTextRefine;
|
||||
private List<String> hotWords;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.imeeting.dto.biz;
|
|||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
|
@ -31,6 +32,9 @@ public class CreateRealtimeMeetingCommand {
|
|||
@NotNull(message = "promptId must not be null")
|
||||
private Long promptId;
|
||||
|
||||
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
||||
private String userPrompt;
|
||||
|
||||
private String mode;
|
||||
private String language;
|
||||
private Integer useSpkId;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
package com.imeeting.dto.biz;
|
||||
|
||||
import lombok.Data;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
@Data
|
||||
public class MeetingResummaryDTO {
|
||||
private Long meetingId;
|
||||
private Long summaryModelId;
|
||||
private Long promptId;
|
||||
}
|
||||
|
||||
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
||||
private String userPrompt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ public class MeetingVO {
|
|||
private String accessPassword;
|
||||
private Integer duration;
|
||||
private String summaryContent;
|
||||
private String lastUserPrompt;
|
||||
private Map<String, Object> analysis;
|
||||
private Integer status;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
package com.imeeting.mapper.biz;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface MeetingMapper extends BaseMapper<Meeting> {
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("SELECT * FROM biz_meetings WHERE id = #{id} AND is_deleted = 0")
|
||||
Meeting selectByIdIgnoreTenant(@Param("id") Long id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
|||
import com.imeeting.service.biz.MeetingService;
|
||||
import com.imeeting.service.biz.PromptTemplateService;
|
||||
import com.imeeting.service.biz.impl.MeetingDomainSupport;
|
||||
import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler;
|
||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
|
@ -54,6 +55,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
private final MeetingDomainSupport meetingDomainSupport;
|
||||
private final MeetingRuntimeProfileResolver runtimeProfileResolver;
|
||||
private final PromptTemplateService promptTemplateService;
|
||||
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||
private final AiTaskService aiTaskService;
|
||||
private final MeetingTranscriptMapper transcriptMapper;
|
||||
private final LlmModelMapper llmModelMapper;
|
||||
|
|
@ -272,13 +274,11 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
|
||||
private void resetOrCreateSummaryTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) {
|
||||
AiTask task = findLatestTask(meetingId, "SUMMARY");
|
||||
Map<String, Object> taskConfig = new HashMap<>();
|
||||
taskConfig.put("summaryModelId", profile.getResolvedSummaryModelId());
|
||||
taskConfig.put("promptId", profile.getResolvedPromptId());
|
||||
PromptTemplate template = promptTemplateService.getById(profile.getResolvedPromptId());
|
||||
if (template != null) {
|
||||
taskConfig.put("promptContent", template.getPromptContent());
|
||||
}
|
||||
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
|
||||
profile.getResolvedSummaryModelId(),
|
||||
profile.getResolvedPromptId(),
|
||||
null
|
||||
);
|
||||
resetOrCreateTask(task, meetingId, "SUMMARY", taskConfig);
|
||||
}
|
||||
|
||||
|
|
@ -327,4 +327,4 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
|||
private String resolveCreatorName(LoginUser loginUser) {
|
||||
return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import com.unisbase.security.LoginUser;
|
|||
public interface MeetingAccessService {
|
||||
Meeting requireMeeting(Long meetingId);
|
||||
|
||||
Meeting requireMeetingIgnoreTenant(Long meetingId);
|
||||
|
||||
boolean isPreviewPasswordRequired(Meeting meeting);
|
||||
|
||||
void assertCanPreviewMeeting(Meeting meeting, String accessPassword);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ public interface MeetingCommandService {
|
|||
|
||||
void updateSummaryContent(Long meetingId, String summaryContent);
|
||||
|
||||
void reSummary(Long meetingId, Long summaryModelId, Long promptId);
|
||||
void reSummary(Long meetingId, Long summaryModelId, Long promptId, String userPrompt);
|
||||
|
||||
void retryTranscription(Long meetingId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ public interface MeetingQueryService {
|
|||
|
||||
MeetingVO getDetail(Long id);
|
||||
|
||||
MeetingVO getDetailIgnoreTenant(Long id);
|
||||
|
||||
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
|
||||
|
||||
Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
private final HotWordService hotWordService;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||
|
||||
@Value("${unisbase.app.server-base-url}")
|
||||
|
|
@ -206,7 +207,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
|
||||
String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId);
|
||||
|
||||
// 轮询逻辑 (带防卡死防护)
|
||||
// 轮询逻辑(带防卡死防护)
|
||||
JsonNode resultNode = null;
|
||||
int lastPercent = -1;
|
||||
int unchangedCount = 0;
|
||||
|
|
@ -241,7 +242,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
|
||||
if (resultNode == null) throw new RuntimeException("ASR轮询超时");
|
||||
|
||||
// 解析并入库 (防御性清理旧数据)
|
||||
// 解析并入库(防御性清理旧数据)
|
||||
return saveTranscripts(meeting, resultNode);
|
||||
}
|
||||
|
||||
|
|
@ -432,15 +433,15 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
throw new RuntimeException("LLM模型未启用");
|
||||
}
|
||||
|
||||
String promptContent = taskRecord.getTaskConfig().get("promptContent") != null
|
||||
? taskRecord.getTaskConfig().get("promptContent").toString() : "";
|
||||
String userPrompt = taskRecord.getTaskConfig().get("userPrompt") != null
|
||||
? taskRecord.getTaskConfig().get("userPrompt").toString() : null;
|
||||
|
||||
Map<String, Object> req = new HashMap<>();
|
||||
req.put("model", llmModel.getModelCode());
|
||||
req.put("temperature", llmModel.getTemperature());
|
||||
req.put("messages", List.of(
|
||||
Map.of("role", "system", "content", buildSummarySystemPrompt(promptContent)),
|
||||
Map.of("role", "user", "content", buildSummaryUserPrompt(meeting, asrText))
|
||||
Map.of("role", "system", "content", meetingSummaryPromptAssembler.buildSystemMessage(taskRecord.getTaskConfig())),
|
||||
Map.of("role", "user", "content", meetingSummaryPromptAssembler.buildUserMessage(meeting, asrText, userPrompt))
|
||||
));
|
||||
|
||||
taskRecord.setRequestData(req);
|
||||
|
|
@ -587,88 +588,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
return normalized.substring(firstLineEnd + 1, lastFence).trim();
|
||||
}
|
||||
|
||||
private String buildSummarySystemPrompt(String promptContent) {
|
||||
String basePrompt = (promptContent == null || promptContent.isBlank())
|
||||
? "你是一个擅长总结会议、提炼章节、聚合发言人观点和整理待办事项的中文助手。"
|
||||
: promptContent.trim();
|
||||
return String.join("\n\n",
|
||||
"你是一个擅长总结会议、提炼章节、聚合发言人观点和整理待办事项的中文助手。",
|
||||
"对于 summaryContent,你必须严格遵循以下会议总结模板/风格要求,不要被后续结构化字段说明覆盖:\n" + basePrompt,
|
||||
"如果模板中已经定义了标题层级、章节顺序、栏目名称、语气、措辞风格、段落组织方式,你必须保持一致。",
|
||||
"summaryContent 不允许退化成关键词罗列、JSON 翻译或结构化字段拼接,必须是一篇可直接阅读、可直接导出的正式会议纪要正文。",
|
||||
"analysis 是附加产物,只服务于页面结构化展示;analysis 不能改变 summaryContent 的模板风格和写法。",
|
||||
"你需要一次性返回一个 JSON 对象,其中同时包含原会议纪要正文和结构化 analysis 结果。",
|
||||
"最终只返回 JSON,不要输出 markdown 代码围栏、解释、前后缀或额外说明。"
|
||||
);
|
||||
}
|
||||
|
||||
private String buildSummaryUserPrompt(Meeting meeting, String asrText) {
|
||||
String participants = meeting.getParticipants() == null || meeting.getParticipants().isBlank()
|
||||
? "未填写"
|
||||
: meeting.getParticipants();
|
||||
Integer durationMs = resolveMeetingDuration(meeting.getId());
|
||||
String durationText = durationMs == null || durationMs <= 0 ? "未知" : formatDuration(durationMs);
|
||||
String meetingTime = meeting.getMeetingTime() == null ? "未知" : meeting.getMeetingTime().toString();
|
||||
|
||||
return String.join("\n",
|
||||
"请基于以下会议转写,一次性生成会议纪要正文和结构化分析结果。",
|
||||
"会议基础信息:",
|
||||
"标题:" + (meeting.getTitle() == null || meeting.getTitle().isBlank() ? "未命名会议" : meeting.getTitle()),
|
||||
"会议时间:" + meetingTime,
|
||||
"参会人员:" + participants,
|
||||
"会议时长:" + durationText,
|
||||
"返回 JSON,字段结构固定如下:",
|
||||
"{",
|
||||
" \"summaryContent\": \"原会议纪要正文,使用 markdown,保持自然完整的纪要写法,而不是关键词列表拼接\",",
|
||||
" \"analysis\": {",
|
||||
" \"overview\": \"基于整场会议内容生成的中文全文概要,控制在300字内,需尽量覆盖完整讨论内容\",",
|
||||
" \"keywords\": [\"关键词1\", \"关键词2\"],",
|
||||
" \"chapters\": [{\"time\":\"00:00\",\"title\":\"章节标题\",\"summary\":\"章节摘要\"}],",
|
||||
" \"speakerSummaries\": [{\"speaker\":\"发言人 1\",\"summary\":\"该发言人在整场会议中的主要观点总结\"}],",
|
||||
" \"keyPoints\": [{\"title\":\"重点问题或结论\",\"summary\":\"具体说明\",\"speaker\":\"发言人 1\"}],",
|
||||
" \"todos\": [\"待办事项1\", \"待办事项2\"]",
|
||||
" }",
|
||||
"}",
|
||||
"要求:",
|
||||
"1. summaryContent 必须是完整会议纪要正文,优先遵循提示词模板中的标题层级、章节顺序、栏目名称、措辞风格和段落组织方式。",
|
||||
"2. 如果模板里已经定义了固定标题或固定分节顺序,summaryContent 必须严格复用,不要自行改写成别的结构。",
|
||||
"3. summaryContent 必须保持自然完整、适合阅读和导出,不能写成关键词清单,也不能直接把 analysis 内容原样展开。",
|
||||
"4. analysis 是给页面结构化展示使用的单独结果。",
|
||||
"5. analysis.overview 必须基于所有会话内容,控制在 300 字内。",
|
||||
"6. analysis.speakerSummaries 必须按发言人聚合,每个发言人只出现一次。",
|
||||
"7. analysis.chapters 按时间顺序输出,time 使用 mm:ss 或 hh:mm:ss。time需要与讨论内容相对应",
|
||||
"8. analysis.keyPoints 提炼关键问题、决定、结论或争议点。",
|
||||
"9. analysis.todos 尽量写成可执行动作;没有就返回空数组。",
|
||||
"10. 只返回 JSON。",
|
||||
"",
|
||||
"会议转写如下:",
|
||||
asrText
|
||||
);
|
||||
}
|
||||
|
||||
private Integer resolveMeetingDuration(Long meetingId) {
|
||||
MeetingTranscript latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper<MeetingTranscript>()
|
||||
.eq(MeetingTranscript::getMeetingId, meetingId)
|
||||
.isNotNull(MeetingTranscript::getEndTime)
|
||||
.orderByDesc(MeetingTranscript::getEndTime)
|
||||
.last("LIMIT 1"));
|
||||
if (latestTranscript != null && latestTranscript.getEndTime() != null && latestTranscript.getEndTime() > 0) {
|
||||
return latestTranscript.getEndTime();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String formatDuration(int durationMs) {
|
||||
int totalSeconds = Math.max(durationMs / 1000, 0);
|
||||
int hours = totalSeconds / 3600;
|
||||
int minutes = (totalSeconds % 3600) / 60;
|
||||
int seconds = totalSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return String.format("%02d:%02d:%02d", hours, minutes, seconds);
|
||||
}
|
||||
return String.format("%02d:%02d", minutes, seconds);
|
||||
}
|
||||
|
||||
private void updateMeetingStatus(Long id, int status) {
|
||||
Meeting m = new Meeting();
|
||||
m.setId(id);
|
||||
|
|
@ -701,3 +620,4 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
this.updateById(task);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,15 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
|
|||
return meeting;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Meeting requireMeetingIgnoreTenant(Long meetingId) {
|
||||
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
|
||||
if (meeting == null) {
|
||||
throw new RuntimeException("Meeting not found");
|
||||
}
|
||||
return meeting;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPreviewPasswordRequired(Meeting meeting) {
|
||||
return normalizePreviewPassword(meeting == null ? null : meeting.getAccessPassword()) != null;
|
||||
|
|
|
|||
|
|
@ -90,7 +90,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
asrTask.setTaskConfig(asrConfig);
|
||||
aiTaskService.save(asrTask);
|
||||
|
||||
meetingDomainSupport.createSummaryTask(meeting.getId(), runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId());
|
||||
meetingDomainSupport.createSummaryTask(
|
||||
meeting.getId(),
|
||||
runtimeProfile.getResolvedSummaryModelId(),
|
||||
runtimeProfile.getResolvedPromptId(),
|
||||
command.getUserPrompt()
|
||||
);
|
||||
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl()));
|
||||
meetingService.updateById(meeting);
|
||||
meetingDomainSupport.publishMeetingCreated(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId());
|
||||
|
|
@ -109,7 +114,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
|
||||
null, tenantId, creatorId, creatorName, hostUserId, hostName, 0);
|
||||
meetingService.save(meeting);
|
||||
meetingDomainSupport.createSummaryTask(meeting.getId(), runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId());
|
||||
meetingDomainSupport.createSummaryTask(
|
||||
meeting.getId(),
|
||||
runtimeProfile.getResolvedSummaryModelId(),
|
||||
runtimeProfile.getResolvedPromptId(),
|
||||
command.getUserPrompt()
|
||||
);
|
||||
realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
|
||||
realtimeMeetingSessionStateService.rememberResumeConfig(meeting.getId(), buildRealtimeResumeConfig(command, tenantId, runtimeProfile));
|
||||
|
||||
|
|
@ -455,13 +465,13 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
|||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reSummary(Long meetingId, Long summaryModelId, Long promptId) {
|
||||
public void reSummary(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) {
|
||||
Meeting meeting = meetingService.getById(meetingId);
|
||||
if (meeting == null) {
|
||||
throw new RuntimeException("Meeting not found");
|
||||
}
|
||||
|
||||
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId);
|
||||
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId, userPrompt);
|
||||
meeting.setStatus(2);
|
||||
meetingService.updateById(meeting);
|
||||
dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|||
import com.imeeting.entity.biz.AiTask;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.entity.biz.MeetingTranscript;
|
||||
import com.imeeting.entity.biz.PromptTemplate;
|
||||
import com.imeeting.event.MeetingCreatedEvent;
|
||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
import com.imeeting.service.biz.AiTaskService;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import com.imeeting.service.biz.PromptTemplateService;
|
||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||
import com.unisbase.entity.SysUser;
|
||||
import com.unisbase.mapper.SysUserMapper;
|
||||
|
|
@ -39,7 +37,7 @@ import java.util.stream.Collectors;
|
|||
@RequiredArgsConstructor
|
||||
public class MeetingDomainSupport {
|
||||
|
||||
private final PromptTemplateService promptTemplateService;
|
||||
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||
private final AiTaskService aiTaskService;
|
||||
private final MeetingTranscriptMapper transcriptMapper;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
|
|
@ -68,22 +66,12 @@ public class MeetingDomainSupport {
|
|||
return meeting;
|
||||
}
|
||||
|
||||
public void createSummaryTask(Long meetingId, Long summaryModelId, Long promptId) {
|
||||
public void createSummaryTask(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) {
|
||||
AiTask sumTask = new AiTask();
|
||||
sumTask.setMeetingId(meetingId);
|
||||
sumTask.setTaskType("SUMMARY");
|
||||
sumTask.setStatus(0);
|
||||
|
||||
Map<String, Object> sumConfig = new HashMap<>();
|
||||
sumConfig.put("summaryModelId", summaryModelId);
|
||||
if (promptId != null) {
|
||||
sumConfig.put("promptId", promptId);
|
||||
PromptTemplate template = promptTemplateService.getById(promptId);
|
||||
if (template != null) {
|
||||
sumConfig.put("promptContent", template.getPromptContent());
|
||||
}
|
||||
}
|
||||
sumTask.setTaskConfig(sumConfig);
|
||||
sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, promptId, userPrompt));
|
||||
aiTaskService.save(sumTask);
|
||||
}
|
||||
|
||||
|
|
@ -271,9 +259,47 @@ public class MeetingDomainSupport {
|
|||
if (includeSummary) {
|
||||
vo.setSummaryContent(meetingSummaryFileService.loadSummaryContent(meeting));
|
||||
vo.setAnalysis(meetingSummaryFileService.loadSummaryAnalysis(meeting));
|
||||
vo.setLastUserPrompt(resolveLastSummaryUserPrompt(meeting));
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveLastSummaryUserPrompt(Meeting meeting) {
|
||||
AiTask latestSummaryTask = resolveLatestSummaryTask(meeting);
|
||||
if (latestSummaryTask == null || latestSummaryTask.getTaskConfig() == null) {
|
||||
return null;
|
||||
}
|
||||
Object userPrompt = latestSummaryTask.getTaskConfig().get("userPrompt");
|
||||
return userPrompt == null ? null : meetingSummaryPromptAssembler.normalizeOptionalText(String.valueOf(userPrompt));
|
||||
}
|
||||
|
||||
private AiTask resolveLatestSummaryTask(Meeting meeting) {
|
||||
if (meeting == null || meeting.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
if (meeting.getLatestSummaryTaskId() != null) {
|
||||
AiTask task = aiTaskService.getById(meeting.getLatestSummaryTaskId());
|
||||
if (task != null && "SUMMARY".equals(task.getTaskType()) && meeting.getId().equals(task.getMeetingId())) {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
AiTask latestSuccessfulTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||
.eq(AiTask::getMeetingId, meeting.getId())
|
||||
.eq(AiTask::getTaskType, "SUMMARY")
|
||||
.eq(AiTask::getStatus, 2)
|
||||
.orderByDesc(AiTask::getId)
|
||||
.last("LIMIT 1"));
|
||||
if (latestSuccessfulTask != null) {
|
||||
return latestSuccessfulTask;
|
||||
}
|
||||
|
||||
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
|
||||
.eq(AiTask::getMeetingId, meeting.getId())
|
||||
.eq(AiTask::getTaskType, "SUMMARY")
|
||||
.orderByDesc(AiTask::getId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private record AudioRelocationPlan(Path sourcePath, Path targetPath, Path backupPath, String relocatedUrl) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.imeeting.dto.biz.MeetingTranscriptVO;
|
|||
import com.imeeting.dto.biz.MeetingVO;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.entity.biz.MeetingTranscript;
|
||||
import com.imeeting.mapper.biz.MeetingMapper;
|
||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
import com.imeeting.service.biz.MeetingQueryService;
|
||||
import com.imeeting.service.biz.MeetingService;
|
||||
|
|
@ -24,6 +25,7 @@ import java.util.stream.Collectors;
|
|||
public class MeetingQueryServiceImpl implements MeetingQueryService {
|
||||
|
||||
private final MeetingService meetingService;
|
||||
private final MeetingMapper meetingMapper;
|
||||
private final MeetingTranscriptMapper transcriptMapper;
|
||||
private final MeetingDomainSupport meetingDomainSupport;
|
||||
|
||||
|
|
@ -68,6 +70,12 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
|
|||
return meeting != null ? toVO(meeting, true) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeetingVO getDetailIgnoreTenant(Long id) {
|
||||
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(id);
|
||||
return meeting != null ? toVO(meeting, true) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MeetingTranscriptVO> getTranscripts(Long meetingId) {
|
||||
return transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package com.imeeting.db;
|
||||
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@SpringBootTest
|
||||
@Disabled("Requires a live local database and full application context; not part of automated regression.")
|
||||
public class DbAlterTest {
|
||||
|
||||
@Autowired
|
||||
|
|
|
|||
|
|
@ -60,4 +60,19 @@ class MeetingCreateCommandValidationTest {
|
|||
|
||||
assertTrue(validator.validate(command).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectTooLongResummaryUserPrompt() {
|
||||
MeetingResummaryDTO dto = new MeetingResummaryDTO();
|
||||
dto.setMeetingId(1L);
|
||||
dto.setSummaryModelId(2L);
|
||||
dto.setPromptId(3L);
|
||||
dto.setUserPrompt("x".repeat(2001));
|
||||
|
||||
Set<String> invalidFields = validator.validate(dto).stream()
|
||||
.map(violation -> violation.getPropertyPath().toString())
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
|
||||
assertEquals(Set.of("userPrompt"), invalidFields);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.imeeting.grpc.realtime.RealtimeServerPacket;
|
|||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
|
@ -34,6 +35,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@Disabled("Manual realtime integration test; requires a running local backend, gRPC service, and PCM fixture.")
|
||||
public class AndroidRealtimeGrpcManualTest {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
|||
import com.imeeting.service.biz.MeetingService;
|
||||
import com.imeeting.service.biz.PromptTemplateService;
|
||||
import com.imeeting.service.biz.impl.MeetingDomainSupport;
|
||||
import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler;
|
||||
import com.unisbase.security.LoginUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
|
@ -63,6 +64,7 @@ class LegacyMeetingAdapterServiceImplTest {
|
|||
meetingDomainSupport,
|
||||
mock(MeetingRuntimeProfileResolver.class),
|
||||
mock(PromptTemplateService.class),
|
||||
mock(MeetingSummaryPromptAssembler.class),
|
||||
mock(AiTaskService.class),
|
||||
mock(MeetingTranscriptMapper.class),
|
||||
mock(LlmModelMapper.class)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import org.mockito.ArgumentCaptor;
|
|||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
|
@ -51,7 +52,7 @@ class MeetingAuthorizationServiceImplTest {
|
|||
service.assertCanManageRealtimeMeeting(meeting, authContext);
|
||||
|
||||
ArgumentCaptor<LoginUser> loginUserCaptor = ArgumentCaptor.forClass(LoginUser.class);
|
||||
verify(meetingAccessService).assertCanManageRealtimeMeeting(meeting, loginUserCaptor.capture());
|
||||
verify(meetingAccessService).assertCanManageRealtimeMeeting(eq(meeting), loginUserCaptor.capture());
|
||||
assertEquals(7L, loginUserCaptor.getValue().getUserId());
|
||||
assertEquals(1L, loginUserCaptor.getValue().getTenantId());
|
||||
assertEquals("alice", loginUserCaptor.getValue().getUsername());
|
||||
|
|
|
|||
|
|
@ -319,9 +319,9 @@ class MeetingCommandServiceImplTest {
|
|||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
service.reSummary(301L, 22L, 33L);
|
||||
service.reSummary(301L, 22L, 33L, null);
|
||||
|
||||
verify(meetingDomainSupport).createSummaryTask(301L, 22L, 33L);
|
||||
verify(meetingDomainSupport).createSummaryTask(301L, 22L, 33L, null);
|
||||
assertEquals(2, meeting.getStatus());
|
||||
verify(meetingService).updateById(meeting);
|
||||
verify(aiTaskService, never()).dispatchSummaryTask(301L, null, null);
|
||||
|
|
@ -484,6 +484,7 @@ class MeetingCommandServiceImplTest {
|
|||
command.setAsrModelId(11L);
|
||||
command.setSummaryModelId(22L);
|
||||
command.setPromptId(33L);
|
||||
command.setUserPrompt("聚焦关键风险");
|
||||
|
||||
service.createMeeting(command, 1L, 7L, "creator");
|
||||
|
||||
|
|
@ -494,7 +495,7 @@ class MeetingCommandServiceImplTest {
|
|||
Object asrModelId = task.getTaskConfig().get("asrModelId");
|
||||
return Long.valueOf(101L).equals(asrModelId);
|
||||
}));
|
||||
verify(meetingDomainSupport).createSummaryTask(808L, 202L, 303L);
|
||||
verify(meetingDomainSupport).createSummaryTask(808L, 202L, 303L, "聚焦关键风险");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -551,10 +552,11 @@ class MeetingCommandServiceImplTest {
|
|||
command.setEnableItn(false);
|
||||
command.setEnableTextRefine(true);
|
||||
command.setSaveAudio(true);
|
||||
command.setUserPrompt("关注待办事项");
|
||||
|
||||
service.createRealtimeMeeting(command, 1L, 7L, "creator");
|
||||
|
||||
verify(meetingDomainSupport).createSummaryTask(909L, 222L, 333L);
|
||||
verify(meetingDomainSupport).createSummaryTask(909L, 222L, 333L, "关注待办事项");
|
||||
verify(sessionStateService).rememberResumeConfig(eq(909L), argThat(config ->
|
||||
Long.valueOf(111L).equals(config.getAsrModelId())
|
||||
&& "online".equals(config.getMode())
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.imeeting.entity.biz.AiTask;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||
import com.imeeting.service.biz.AiTaskService;
|
||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import com.imeeting.service.biz.PromptTemplateService;
|
||||
import com.unisbase.mapper.SysUserMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
|
|
@ -17,11 +19,15 @@ import java.io.IOException;
|
|||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MeetingDomainSupportTest {
|
||||
|
||||
|
|
@ -74,10 +80,107 @@ class MeetingDomainSupportTest {
|
|||
assertFalse(hasBackupFile(tempDir.resolve("uploads/meetings/102")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreferLatestSummaryTaskIdWhenResolvingLastUserPrompt() {
|
||||
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(201L);
|
||||
meeting.setLatestSummaryTaskId(501L);
|
||||
|
||||
AiTask latestSummaryTask = new AiTask();
|
||||
latestSummaryTask.setTaskType("SUMMARY");
|
||||
latestSummaryTask.setMeetingId(201L);
|
||||
latestSummaryTask.setTaskConfig(Map.of("userPrompt", " 已发布提示词 "));
|
||||
|
||||
AiTask fallbackTask = new AiTask();
|
||||
fallbackTask.setTaskType("SUMMARY");
|
||||
fallbackTask.setMeetingId(201L);
|
||||
fallbackTask.setTaskConfig(Map.of("userPrompt", " 最新草稿提示词 "));
|
||||
|
||||
when(aiTaskService.getById(501L)).thenReturn(latestSummaryTask);
|
||||
when(assembler.normalizeOptionalText(" 已发布提示词 ")).thenReturn("已发布提示词");
|
||||
when(aiTaskService.getOne(any())).thenReturn(fallbackTask);
|
||||
|
||||
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
|
||||
assertEquals("已发布提示词", resolved);
|
||||
Mockito.verify(aiTaskService, Mockito.never()).getOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFallbackToLatestSummaryTaskWhenLatestSummaryTaskIdIsUnavailable() {
|
||||
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(202L);
|
||||
meeting.setLatestSummaryTaskId(502L);
|
||||
|
||||
AiTask latestSuccessfulTask = new AiTask();
|
||||
latestSuccessfulTask.setTaskType("SUMMARY");
|
||||
latestSuccessfulTask.setMeetingId(202L);
|
||||
latestSuccessfulTask.setStatus(2);
|
||||
latestSuccessfulTask.setTaskConfig(Map.of("userPrompt", " 成功提示词 "));
|
||||
|
||||
when(aiTaskService.getById(502L)).thenReturn(null);
|
||||
when(aiTaskService.getOne(any())).thenReturn(latestSuccessfulTask);
|
||||
when(assembler.normalizeOptionalText(" 成功提示词 ")).thenReturn("成功提示词");
|
||||
|
||||
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
|
||||
assertEquals("成功提示词", resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFallbackToLatestSummaryTaskWhenNoSuccessfulTaskExists() {
|
||||
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(203L);
|
||||
|
||||
AiTask latestTask = new AiTask();
|
||||
latestTask.setTaskType("SUMMARY");
|
||||
latestTask.setMeetingId(203L);
|
||||
latestTask.setTaskConfig(Map.of("userPrompt", " 最新任务提示词 "));
|
||||
|
||||
when(aiTaskService.getOne(any())).thenReturn(null).thenReturn(latestTask);
|
||||
when(assembler.normalizeOptionalText(" 最新任务提示词 ")).thenReturn("最新任务提示词");
|
||||
|
||||
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
|
||||
assertEquals("最新任务提示词", resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnNullWhenNoSummaryTaskExists() {
|
||||
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
|
||||
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
|
||||
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(204L);
|
||||
|
||||
when(aiTaskService.getOne(any())).thenReturn(null, null);
|
||||
|
||||
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
|
||||
|
||||
assertNull(resolved);
|
||||
}
|
||||
|
||||
private MeetingDomainSupport newSupport() {
|
||||
return newSupport(mock(AiTaskService.class), mock(MeetingSummaryPromptAssembler.class));
|
||||
}
|
||||
|
||||
private MeetingDomainSupport newSupport(AiTaskService aiTaskService, MeetingSummaryPromptAssembler assembler) {
|
||||
MeetingDomainSupport support = new MeetingDomainSupport(
|
||||
mock(PromptTemplateService.class),
|
||||
mock(AiTaskService.class),
|
||||
assembler,
|
||||
aiTaskService,
|
||||
mock(MeetingTranscriptMapper.class),
|
||||
mock(SysUserMapper.class),
|
||||
mock(ApplicationEventPublisher.class),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.imeeting.service.biz.AiModelService;
|
|||
import com.imeeting.service.biz.PromptTemplateService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
|
@ -46,7 +47,7 @@ class MeetingRuntimeProfileResolverImplTest {
|
|||
null,
|
||||
Boolean.TRUE,
|
||||
Boolean.TRUE,
|
||||
List.of(" alpha ", "", "alpha", "beta", null)
|
||||
Arrays.asList(" alpha ", "", "alpha", "beta", null)
|
||||
);
|
||||
|
||||
assertEquals(11L, profile.getResolvedAsrModelId());
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export async function uploadClientPackage(platformCode: string, file: File) {
|
|||
formData.append("file", file);
|
||||
const resp = await http.post("/api/clients/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 600000
|
||||
});
|
||||
return resp.data.data as ClientUploadResult;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export async function uploadExternalAppApk(file: File) {
|
|||
formData.append("apkFile", file);
|
||||
const resp = await http.post("/api/external-apps/upload-apk", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 600000
|
||||
});
|
||||
return resp.data.data as ExternalAppApkUploadResult;
|
||||
}
|
||||
|
|
@ -79,4 +80,4 @@ export async function uploadExternalAppIcon(file: File) {
|
|||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return resp.data.data as ExternalAppIconUploadResult;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface MeetingVO {
|
|||
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
|
||||
audioSaveMessage?: string;
|
||||
accessPassword?: string;
|
||||
lastUserPrompt?: string;
|
||||
summaryContent: string;
|
||||
analysis?: {
|
||||
overview?: string;
|
||||
|
|
@ -46,6 +47,7 @@ export interface CreateMeetingCommand {
|
|||
asrModelId: number;
|
||||
summaryModelId?: number;
|
||||
promptId: number;
|
||||
userPrompt?: string;
|
||||
useSpkId?: number;
|
||||
enableTextRefine?: boolean;
|
||||
hotWords?: string[];
|
||||
|
|
@ -63,6 +65,7 @@ export interface CreateRealtimeMeetingCommand {
|
|||
asrModelId: number;
|
||||
summaryModelId?: number;
|
||||
promptId: number;
|
||||
userPrompt?: string;
|
||||
mode?: string;
|
||||
language?: string;
|
||||
useSpkId?: number;
|
||||
|
|
@ -284,6 +287,7 @@ export interface MeetingResummaryDTO {
|
|||
meetingId: number;
|
||||
summaryModelId: number;
|
||||
promptId: number;
|
||||
userPrompt?: string;
|
||||
}
|
||||
|
||||
export const reSummary = (params: MeetingResummaryDTO) => {
|
||||
|
|
|
|||
|
|
@ -436,6 +436,18 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
|||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
<Form.Item
|
||||
name="userPrompt"
|
||||
label="用户提示词"
|
||||
extra="可选,用于补充本次会议总结的关注重点、表达偏好或输出侧重点"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="例如:请重点关注待办事项、风险点,并用适合汇报的表达方式输出"
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
showCount
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -595,4 +595,4 @@ export default function Permissions() {
|
|||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,10 +207,11 @@
|
|||
|
||||
.role-list-pagination {
|
||||
flex-shrink: 0;
|
||||
padding-top: 16px;
|
||||
margin-top: auto;
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
margin-bottom: -16px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Detail Card Adjustments */
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import AppPagination from "@/components/shared/AppPagination";
|
|||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { SysOrg, SysRole, SysTenant, SysUser } from "@/types";
|
||||
import "./index.less";
|
||||
|
||||
|
|
@ -236,6 +238,9 @@ export default function Users() {
|
|||
const url = await uploadPlatformAsset(file);
|
||||
form.setFieldValue("avatarUrl", url);
|
||||
message.success(t("common.success"));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : t("common.error"));
|
||||
return Upload.LIST_IGNORE;
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
|
|
@ -253,7 +258,8 @@ export default function Users() {
|
|||
phone: values.phone,
|
||||
avatarUrl: values.avatarUrl,
|
||||
status: values.status,
|
||||
isPlatformAdmin: values.isPlatformAdmin
|
||||
isPlatformAdmin: values.isPlatformAdmin,
|
||||
roleIds: values.roleIds || []
|
||||
};
|
||||
|
||||
if (!isPlatformMode) {
|
||||
|
|
@ -266,18 +272,10 @@ export default function Users() {
|
|||
userPayload.password = values.password;
|
||||
}
|
||||
|
||||
let userId = editing?.userId;
|
||||
if (editing) {
|
||||
await updateUser(editing.userId, userPayload);
|
||||
} else {
|
||||
await createUser(userPayload);
|
||||
const updatedList = await listUsers();
|
||||
const newUser = updatedList.find((user) => user.username === userPayload.username);
|
||||
userId = newUser?.userId;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
await saveUserRoles(userId, values.roleIds || []);
|
||||
}
|
||||
|
||||
message.success(t("common.success"));
|
||||
|
|
@ -307,24 +305,24 @@ export default function Users() {
|
|||
},
|
||||
...(isPlatformMode
|
||||
? [{
|
||||
title: t("users.tenant"),
|
||||
key: "tenant",
|
||||
render: (_: any, record: SysUser) => {
|
||||
if (record.memberships && record.memberships.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{record.memberships.slice(0, 2).map((membership: any) => (
|
||||
<Tag key={membership.tenantId} color="blue" style={{ margin: 0, padding: "0 4px", fontSize: 11 }}>
|
||||
{tenantMap[membership.tenantId] || `Tenant ${membership.tenantId}`}
|
||||
</Tag>
|
||||
))}
|
||||
{record.memberships.length > 2 && <Text type="secondary" style={{ fontSize: 11 }}>+{record.memberships.length - 2} more</Text>}
|
||||
</div>
|
||||
);
|
||||
title: t("users.tenant"),
|
||||
key: "tenant",
|
||||
render: (_: any, record: SysUser) => {
|
||||
if (record.memberships && record.memberships.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{record.memberships.slice(0, 2).map((membership: any) => (
|
||||
<Tag key={membership.tenantId} color="blue" style={{ margin: 0, padding: "0 4px", fontSize: 11 }}>
|
||||
{tenantMap[membership.tenantId] || `Tenant ${membership.tenantId}`}
|
||||
</Tag>
|
||||
))}
|
||||
{record.memberships.length > 2 && <Text type="secondary" style={{ fontSize: 11 }}>+{record.memberships.length - 2} more</Text>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary">{t("usersExt.noTenant")}</Text>;
|
||||
}
|
||||
return <Text type="secondary">{t("usersExt.noTenant")}</Text>;
|
||||
}
|
||||
}]
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
title: t("users.orgNode"),
|
||||
|
|
@ -388,10 +386,10 @@ export default function Users() {
|
|||
<div className="users-table-toolbar">
|
||||
<Space size="middle" wrap className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
|
||||
<Space size="middle" wrap className="app-page__toolbar">
|
||||
{isPlatformMode && <Select placeholder={t("users.tenantFilter")} style={{ width: 200 }} allowClear value={filterTenantId} onChange={setFilterTenantId} options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} suffixIcon={<ShopOutlined aria-hidden="true" />} />}
|
||||
<Input placeholder={t("users.searchPlaceholder")} prefix={<SearchOutlined aria-hidden="true" />} className="users-search-input" style={{ width: 300 }} value={searchText} onChange={(event) => { setSearchText(event.target.value); setCurrent(1); }} allowClear aria-label={t("common.search")} />
|
||||
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
|
||||
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
|
||||
{isPlatformMode && <Select placeholder={t("users.tenantFilter")} style={{ width: 200 }} allowClear value={filterTenantId} onChange={setFilterTenantId} options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} suffixIcon={<ShopOutlined aria-hidden="true" />} />}
|
||||
<Input placeholder={t("users.searchPlaceholder")} prefix={<SearchOutlined aria-hidden="true" />} className="users-search-input" style={{ width: 300 }} value={searchText} onChange={(event) => { setSearchText(event.target.value); setCurrent(1); }} allowClear aria-label={t("common.search")} />
|
||||
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
|
||||
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
|
||||
</Space>
|
||||
{can("sys:user:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>{t("common.create")}</Button>}
|
||||
</Space>
|
||||
|
|
@ -405,11 +403,11 @@ export default function Users() {
|
|||
<AppPagination current={current} pageSize={pageSize} total={filteredData.length} onChange={(page, size) => { setCurrent(page); setPageSize(size); }} />
|
||||
</Card>
|
||||
|
||||
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical" className="user-form">
|
||||
<Title level={5} style={{ marginBottom: 16 }}>{t("usersExt.basicInfo")}</Title>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}><Form.Item label={t("users.username")} name="username" rules={[{ required: true, message: t("users.username") }]}><Input placeholder={t("users.username")} disabled={!!editing} className="tabular-nums" /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item label={t("users.username")} name="username" rules={[{ required: true, message: t("users.username") }, { pattern: LOGIN_NAME_PATTERN, message: t("usersExt.usernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" }) }]} getValueFromEvent={(event) => sanitizeLoginName(event?.target?.value)} extra={t("usersExt.usernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" })}><Input placeholder={t("usersExt.usernamePlaceholder", { defaultValue: "仅支持 a-z、0-9、@、_" })} disabled={!!editing} className="tabular-nums" /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true, message: t("users.displayName") }]}><Input placeholder={t("users.displayName")} /></Form.Item></Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { App, Button, Checkbox, Form, Input, Typography } from "antd";
|
||||
import { Button, Checkbox, Form, Input, Typography, message } from "antd";
|
||||
import { LockOutlined, ReloadOutlined, SafetyOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -24,7 +24,6 @@ export default function Login() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [platformConfig, setPlatformConfig] = useState<SysPlatformConfig | null>(null);
|
||||
const [form] = Form.useForm<LoginFormValues>();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const loadCaptcha = useCallback(async () => {
|
||||
if (!captchaEnabled) {
|
||||
|
|
@ -87,11 +86,8 @@ export default function Login() {
|
|||
try {
|
||||
const profile = await getCurrentUser();
|
||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||
localStorage.setItem("displayName", profile.displayName || profile.username || values.username);
|
||||
localStorage.setItem("username", profile.username || values.username);
|
||||
} catch {
|
||||
sessionStorage.removeItem("userProfile");
|
||||
localStorage.removeItem("displayName");
|
||||
}
|
||||
|
||||
message.success(t("common.success"));
|
||||
|
|
@ -228,8 +224,8 @@ export default function Login() {
|
|||
|
||||
<div className="login-footer">
|
||||
<Text type="secondary">
|
||||
{/*{t("login.demoAccount")} <Text strong className="tabular-nums">admin</Text> / {t("login.password")}{" "}*/}
|
||||
{/*<Text strong className="tabular-nums">123456</Text>*/}
|
||||
{t("login.demoAccount")} <Text strong className="tabular-nums">admin</Text> / {t("login.password")}{" "}
|
||||
<Text strong className="tabular-nums">123456</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Card, Form, Input, Layout, Typography, App } from 'antd';
|
||||
import { Button, Card, Form, Input, Layout, Typography, message } from "antd";
|
||||
import { LockOutlined, LogoutOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
|
@ -13,7 +13,6 @@ type ResetPasswordFormValues = {
|
|||
};
|
||||
|
||||
export default function ResetPassword() {
|
||||
const { message } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<ResetPasswordFormValues>();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import { Button, Card, Col, Empty, Input, Row, Space, Table, Tag, Tree, Typography, App } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Input,
|
||||
message,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tree,
|
||||
Typography
|
||||
} from "antd";
|
||||
import type { DataNode } from "antd/es/tree";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClusterOutlined, KeyOutlined, SafetyCertificateOutlined, SaveOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "@/api";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { SysPermission, SysRole } from "@/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
|
@ -64,7 +76,6 @@ function toTreeData(nodes: PermissionNode[]): DataNode[] {
|
|||
}
|
||||
|
||||
export default function RolePermissionBinding() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [roles, setRoles] = useState<SysRole[]>([]);
|
||||
const [permissions, setPermissions] = useState<SysPermission[]>([]);
|
||||
|
|
@ -82,7 +93,7 @@ export default function RolePermissionBinding() {
|
|||
return false;
|
||||
}
|
||||
const profile = JSON.parse(profileStr);
|
||||
return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0";
|
||||
return !!profile.isPlatformAdmin;
|
||||
}, []);
|
||||
|
||||
const selectedRole = useMemo(
|
||||
|
|
@ -182,7 +193,7 @@ export default function RolePermissionBinding() {
|
|||
|
||||
<Row gutter={24} className="app-page__split" style={{ height: "calc(100vh - 180px)" }}>
|
||||
<Col xs={24} lg={10} style={{ height: "100%" }}>
|
||||
<Card title={<Space><SafetyCertificateOutlined aria-hidden="true" /><span>{t("rolePerm.roleList")}</span></Space>} className="app-page__panel-card full-height-card" styles={{ body: { height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<Card title={<Space><SafetyCertificateOutlined aria-hidden="true" /><span>{t("rolePerm.roleList")}</span></Space>} className="app-page__panel-card full-height-card">
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
placeholder={t("rolePerm.searchRole")}
|
||||
|
|
@ -193,13 +204,12 @@ export default function RolePermissionBinding() {
|
|||
aria-label={t("rolePerm.searchRole")}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<div style={{ height: "calc(100% - 60px)", overflowY: "auto" }}>
|
||||
<Table
|
||||
rowKey="roleId"
|
||||
size="middle"
|
||||
loading={loadingRoles}
|
||||
dataSource={filteredRoles}
|
||||
scroll={{ y: "calc(100vh - 370px)" }}
|
||||
rowSelection={{
|
||||
type: "radio",
|
||||
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
|
||||
|
|
@ -209,7 +219,7 @@ export default function RolePermissionBinding() {
|
|||
onClick: () => setSelectedRoleId(record.roleId),
|
||||
className: "cursor-pointer"
|
||||
})}
|
||||
pagination={getStandardPagination(filteredRoles.length, 1, 10)}
|
||||
pagination={{ pageSize: 10, showTotal: (total) => t("common.total", { total }) }}
|
||||
columns={[
|
||||
{
|
||||
title: t("roles.roleName"),
|
||||
|
|
@ -237,7 +247,6 @@ export default function RolePermissionBinding() {
|
|||
<Card
|
||||
title={<Space><KeyOutlined aria-hidden="true" /><span>{t("rolePerm.permConfig")}</span></Space>}
|
||||
className="app-page__panel-card full-height-card"
|
||||
styles={{ body: { height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" } }}
|
||||
extra={selectedRole ? <Tag color="blue">{t("rolePerm.currentRole")}: {selectedRole.roleName}</Tag> : null}
|
||||
>
|
||||
{selectedRoleId ? (
|
||||
|
|
@ -270,3 +279,4 @@ export default function RolePermissionBinding() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
import { Button, Card, Checkbox, Col, Empty, Input, Row, Space, Table, Tag, Typography, App } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Empty,
|
||||
Input,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message
|
||||
} from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "@/api";
|
||||
import { SaveOutlined, SearchOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { getStandardPagination } from "@/utils/pagination";
|
||||
import type { SysRole, SysUser } from "@/types";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function UserRoleBinding() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const [users, setUsers] = useState<SysUser[]>([]);
|
||||
const [roles, setRoles] = useState<SysRole[]>([]);
|
||||
|
|
@ -102,7 +113,7 @@ export default function UserRoleBinding() {
|
|||
|
||||
<Row gutter={24} className="app-page__split" style={{ height: "calc(100vh - 180px)" }}>
|
||||
<Col xs={24} lg={12} style={{ height: "100%" }}>
|
||||
<Card title={<Space><UserOutlined aria-hidden="true" /><span>{t("userRole.userList")}</span></Space>} className="app-page__panel-card full-height-card" styles={{ body: { height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<Card title={<Space><UserOutlined aria-hidden="true" /><span>{t("userRole.userList")}</span></Space>} className="app-page__panel-card full-height-card">
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
placeholder={t("userRole.searchUser")}
|
||||
|
|
@ -113,13 +124,12 @@ export default function UserRoleBinding() {
|
|||
aria-label={t("userRole.searchUser")}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<div style={{ height: "calc(100% - 60px)", overflowY: "auto" }}>
|
||||
<Table
|
||||
rowKey="userId"
|
||||
size="middle"
|
||||
loading={loadingUsers}
|
||||
dataSource={filteredUsers}
|
||||
scroll={{ y: "calc(100vh - 370px)" }}
|
||||
rowSelection={{
|
||||
type: "radio",
|
||||
selectedRowKeys: selectedUserId ? [selectedUserId] : [],
|
||||
|
|
@ -129,7 +139,7 @@ export default function UserRoleBinding() {
|
|||
onClick: () => setSelectedUserId(record.userId),
|
||||
className: "cursor-pointer"
|
||||
})}
|
||||
pagination={getStandardPagination(filteredUsers.length, 1, 10)}
|
||||
pagination={{ pageSize: 10, showTotal: (total) => t("common.total", { total }) }}
|
||||
columns={[
|
||||
{
|
||||
title: t("users.userInfo"),
|
||||
|
|
@ -157,11 +167,10 @@ export default function UserRoleBinding() {
|
|||
<Card
|
||||
title={<Space><TeamOutlined aria-hidden="true" /><span>{t("userRole.grantRoles")}</span></Space>}
|
||||
className="app-page__panel-card full-height-card"
|
||||
styles={{ body: { height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" } }}
|
||||
extra={selectedUser ? <Tag color="blue">{t("userRole.editing")}: {selectedUser.displayName}</Tag> : null}
|
||||
>
|
||||
{selectedUserId ? (
|
||||
<div style={{ padding: "8px 0", flex: 1, minHeight: 0, overflowY: "auto" }}>
|
||||
<div style={{ padding: "8px 0", height: "100%", overflowY: "auto" }}>
|
||||
<Checkbox.Group style={{ width: "100%" }} value={checkedRoleIds} onChange={(values) => setCheckedRoleIds(values as number[])} disabled={loadingRoles}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{roles.map((role) => (
|
||||
|
|
@ -191,4 +200,4 @@ export default function UserRoleBinding() {
|
|||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Alert, Avatar, Breadcrumb, Button, Card, Checkbox, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Row, Select, Skeleton, Space, Switch, Tag, Typography, App } from 'antd';
|
||||
import { Alert, Avatar, Breadcrumb, Button, Card, Checkbox, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd';
|
||||
import {
|
||||
AudioOutlined,
|
||||
CaretRightFilled,
|
||||
|
|
@ -19,6 +19,10 @@ import {
|
|||
UserOutlined,
|
||||
PlusOutlined,
|
||||
CheckCircleFilled,
|
||||
EllipsisOutlined,
|
||||
FilePdfOutlined,
|
||||
FileWordOutlined,
|
||||
ShareAltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
|
@ -775,6 +779,7 @@ const MeetingDetail: React.FC = () => {
|
|||
meetingId: Number(id),
|
||||
summaryModelId: values.summaryModelId,
|
||||
promptId: values.promptId,
|
||||
userPrompt: values.userPrompt,
|
||||
});
|
||||
message.success('已重新发起总结任务');
|
||||
setSummaryVisible(false);
|
||||
|
|
@ -786,6 +791,18 @@ const MeetingDetail: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleOpenSummaryDrawer = () => {
|
||||
summaryForm.setFieldsValue({
|
||||
summaryModelId:
|
||||
summaryForm.getFieldValue('summaryModelId') ??
|
||||
llmModels.find((model) => model.isDefault === 1)?.id ??
|
||||
llmModels[0]?.id,
|
||||
promptId: summaryForm.getFieldValue('promptId') ?? prompts[0]?.id,
|
||||
userPrompt: meeting?.lastUserPrompt ?? '',
|
||||
});
|
||||
setSummaryVisible(true);
|
||||
};
|
||||
|
||||
const handleRetryTranscription = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
|
|
@ -1198,31 +1215,46 @@ const MeetingDetail: React.FC = () => {
|
|||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Space size={8}>
|
||||
{canRetrySummary && (
|
||||
<Button icon={<SyncOutlined />} type="primary" ghost onClick={handleOpenSummaryDrawer} disabled={actionLoading}>
|
||||
重新总结
|
||||
</Button>
|
||||
)}
|
||||
{canRetryTranscription && (
|
||||
<Button icon={<SyncOutlined />} type="primary" onClick={handleRetryTranscription} loading={actionLoading}>
|
||||
重新识别
|
||||
</Button>
|
||||
)}
|
||||
{canRetrySummary && (
|
||||
<Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>
|
||||
重新总结
|
||||
</Button>
|
||||
)}
|
||||
{isOwner && meeting.status === 2 && (
|
||||
<Button icon={<LoadingOutlined />} type="primary" ghost disabled loading>
|
||||
正在总结
|
||||
</Button>
|
||||
)}
|
||||
{meeting.status === 3 && !!meeting.summaryContent && (
|
||||
<>
|
||||
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('pdf')} loading={downloadLoading === 'pdf'}>
|
||||
下载 PDF
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('word')} loading={downloadLoading === 'word'}>
|
||||
下载 Word
|
||||
</Button>
|
||||
</>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'pdf',
|
||||
label: '下载 PDF',
|
||||
icon: <FilePdfOutlined />,
|
||||
onClick: () => handleDownloadSummary('pdf'),
|
||||
disabled: downloadLoading === 'pdf'
|
||||
},
|
||||
{
|
||||
key: 'word',
|
||||
label: '下载 Word',
|
||||
icon: <FileWordOutlined />,
|
||||
onClick: () => handleDownloadSummary('word'),
|
||||
disabled: downloadLoading === 'word'
|
||||
}
|
||||
]
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button icon={<DownloadOutlined />} loading={!!downloadLoading}>下载</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
{shareQrContent ? (
|
||||
<Popover
|
||||
|
|
@ -1233,13 +1265,13 @@ const MeetingDetail: React.FC = () => {
|
|||
placement="bottomRight"
|
||||
overlayClassName="meeting-share-popover"
|
||||
>
|
||||
<Button icon={<QrcodeOutlined />}>
|
||||
二维码
|
||||
<Button icon={<ShareAltOutlined />}>
|
||||
分享
|
||||
</Button>
|
||||
</Popover>
|
||||
) : null}
|
||||
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
|
||||
返回列表
|
||||
返回
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
|
|
@ -2363,6 +2395,18 @@ const MeetingDetail: React.FC = () => {
|
|||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="userPrompt"
|
||||
label="用户提示词"
|
||||
extra="可选,用于补充本次重新总结的关注重点、表达偏好或输出侧重点"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="例如:请重点突出结论、待办事项和风险点"
|
||||
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||
showCount
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<Text type="secondary">重新总结会基于当前语音转录全文重新生成纪要,原有总结内容将被覆盖。</Text>
|
||||
</Form>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import {
|
|||
SearchOutlined,
|
||||
SyncOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined
|
||||
UserOutlined,
|
||||
AppstoreOutlined,
|
||||
UnorderedListOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
App,
|
||||
|
|
@ -28,7 +30,8 @@ import {
|
|||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography
|
||||
Typography,
|
||||
Table
|
||||
} from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
|
|
@ -97,7 +100,6 @@ const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
|
|||
const res = await getMeetingProgress(meeting.id);
|
||||
if (res.data && res.data.data) {
|
||||
setProgress(res.data.data);
|
||||
// 当达到 100% 时触发完成回调
|
||||
if (res.data.data.percent === 100 && onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
|
|
@ -127,7 +129,6 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgr
|
|||
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2;
|
||||
return (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 10px', borderRadius: 6, fontSize: 11, fontWeight: 600, color: config.color, background: config.bgColor, position: 'relative', overflow: 'hidden', border: `1px solid ${isProcessing ? 'transparent' : '#eee'}`, minWidth: 80, justifyContent: 'center' }}>
|
||||
{/* 进度填充背景 */}
|
||||
{isProcessing && percent > 0 && (
|
||||
<div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${percent}%`, background: effectiveStatus === 1 ? 'rgba(24, 144, 255, 0.2)' : 'rgba(250, 173, 20, 0.2)', transition: 'width 0.5s cubic-bezier(0.4, 0, 0.2, 1)', zIndex: 0 }} />
|
||||
)}
|
||||
|
|
@ -140,11 +141,14 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgr
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
// --- 表格状态单元格 ---
|
||||
const TableStatusCell: React.FC<{ meeting: MeetingVO, fetchData: () => void }> = ({ meeting, fetchData }) => {
|
||||
const progress = useMeetingProgress(meeting, () => fetchData());
|
||||
return <IntegratedStatusTag meeting={meeting} progress={progress} />;
|
||||
};
|
||||
|
||||
// --- 卡片项组件 ---
|
||||
const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void, onOpenMeeting: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants, onOpenMeeting }) => {
|
||||
// 注入自动刷新回调
|
||||
const progress = useMeetingProgress(item, () => fetchData());
|
||||
const effectiveStatus = item.displayStatus ?? item.status;
|
||||
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2;
|
||||
|
|
@ -160,11 +164,12 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
|||
<Tooltip title="编辑参会人"><div className="icon-btn edit"><EditOutlined onClick={() => onEditParticipants(item)} /></div></Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除?"
|
||||
onConfirm={() => deleteMeeting(item.id).then(fetchData)}
|
||||
onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(item.id).then(fetchData); }}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<Tooltip title="删除"><div className="icon-btn delete"><DeleteOutlined /></div></Tooltip>
|
||||
<Tooltip title="删除"><div className="icon-btn delete" onClick={e => e.stopPropagation()}><DeleteOutlined /></div></Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
|
|
@ -252,10 +257,10 @@ const Meetings: React.FC = () => {
|
|||
const { can } = usePermission();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [data, setData] = useState<MeetingVO[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [current, setCurrent] = useState(1);
|
||||
const [displayMode, setDisplayMode] = useState<'card' | 'list'>('card');
|
||||
const [size, setSize] = useState(8);
|
||||
const [searchTitle, setSearchTitle] = useState('');
|
||||
const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all');
|
||||
|
|
@ -271,6 +276,12 @@ const Meetings: React.FC = () => {
|
|||
return effectiveStatus === 0 || effectiveStatus === 1 || effectiveStatus === 2;
|
||||
});
|
||||
|
||||
const handleDisplayModeChange = (mode: 'card' | 'list') => {
|
||||
setDisplayMode(mode);
|
||||
setSize(mode === 'card' ? 8 : 10);
|
||||
setCurrent(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const action = searchParams.get('action');
|
||||
const type = searchParams.get('type') as MeetingCreateType;
|
||||
|
|
@ -326,8 +337,6 @@ const Meetings: React.FC = () => {
|
|||
navigate(`/meetings/${meeting.id}`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const openEditParticipants = (meeting: MeetingVO) => {
|
||||
setEditingMeeting(meeting);
|
||||
participantsEditForm.setFieldsValue({
|
||||
|
|
@ -365,6 +374,59 @@ const Meetings: React.FC = () => {
|
|||
5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' }
|
||||
};
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
title: '会议标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (text: string, record: MeetingVO) => (
|
||||
<a style={{ fontWeight: 500 }} onClick={() => handleOpenMeeting(record)}>{text}</a>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 150,
|
||||
render: (_: any, record: MeetingVO) => (
|
||||
<TableStatusCell meeting={record} fetchData={fetchData} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '会议时间',
|
||||
dataIndex: 'meetingTime',
|
||||
key: 'meetingTime',
|
||||
width: 180,
|
||||
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
{
|
||||
title: '参会人',
|
||||
dataIndex: 'participants',
|
||||
key: 'participants',
|
||||
render: (text: string) => (
|
||||
<Text type="secondary" ellipsis style={{ maxWidth: 200 }}>{text || '无参与人员'}</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
render: (_: any, record: MeetingVO) => (
|
||||
<Space size="middle">
|
||||
<Button type="link" size="small" onClick={(e) => { e.stopPropagation(); openEditParticipants(record); }}>编辑</Button>
|
||||
<Popconfirm
|
||||
title="确定删除?"
|
||||
onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(record.id).then(fetchData); }}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<Button type="link" danger size="small" onClick={(e) => e.stopPropagation()}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<Card
|
||||
|
|
@ -379,6 +441,10 @@ const Meetings: React.FC = () => {
|
|||
}
|
||||
extra={
|
||||
<Space size={16} wrap>
|
||||
<Radio.Group value={displayMode} onChange={e => handleDisplayModeChange(e.target.value)} buttonStyle="solid">
|
||||
<Radio.Button value="card"><AppstoreOutlined /></Radio.Button>
|
||||
<Radio.Button value="list"><UnorderedListOutlined /></Radio.Button>
|
||||
</Radio.Group>
|
||||
<Radio.Group value={viewType} onChange={e => { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid">
|
||||
<Radio.Button value="all">全部</Radio.Button><Radio.Button value="created">我发起</Radio.Button><Radio.Button value="involved">我参与</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
|
@ -391,12 +457,27 @@ const Meetings: React.FC = () => {
|
|||
}
|
||||
>
|
||||
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflowX: "hidden", overflowY: "auto", padding: "24px" }}>
|
||||
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
|
||||
<List grid={{ gutter: [24, 24], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} dataSource={data} renderItem={(item) => {
|
||||
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0];
|
||||
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} onOpenMeeting={handleOpenMeeting} />;
|
||||
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
|
||||
</Skeleton>
|
||||
{displayMode === 'card' ? (
|
||||
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
|
||||
<List grid={{ gutter: [24, 24], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} dataSource={data} renderItem={(item) => {
|
||||
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0];
|
||||
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} onOpenMeeting={handleOpenMeeting} />;
|
||||
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
|
||||
</Skeleton>
|
||||
) : (
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleOpenMeeting(record),
|
||||
style: { cursor: 'pointer' }
|
||||
})}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AppPagination current={current} pageSize={size} total={total} onChange={(p, s) => { setCurrent(p); setSize(s); }} />
|
||||
|
|
|
|||
|
|
@ -1,224 +1,113 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
|
||||
import { Row, Col, Card, Typography, Table, Tag, Skeleton, Button } from "antd";
|
||||
import {
|
||||
HistoryOutlined,
|
||||
CheckCircleOutlined,
|
||||
LoadingOutlined,
|
||||
AudioOutlined,
|
||||
RobotOutlined,
|
||||
CalendarOutlined,
|
||||
TeamOutlined,
|
||||
RiseOutlined,
|
||||
VideoCameraOutlined,
|
||||
DesktopOutlined,
|
||||
UserOutlined,
|
||||
ClockCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { getDashboardStats, getRecentTasks, DashboardStats } from '@/api/business/dashboard';
|
||||
import { MeetingVO, getMeetingProgress, MeetingProgress } from '@/api/business/meeting';
|
||||
CheckCircleOutlined,
|
||||
SyncOutlined,
|
||||
ArrowRightOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import StatCard from "@/components/shared/StatCard/StatCard";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
|
||||
const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => {
|
||||
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (meeting.status !== 1 && meeting.status !== 2) return;
|
||||
const recentMeetings = [
|
||||
{ key: "1", name: "Product Sync", time: "2024-02-10 14:00", duration: "45min", status: "processing" },
|
||||
{ key: "2", name: "Tech Review", time: "2024-02-10 10:00", duration: "60min", status: "success" },
|
||||
{ key: "3", name: "Daily Standup", time: "2024-02-10 09:00", duration: "15min", status: "success" },
|
||||
{ key: "4", name: "Client Call", time: "2024-02-10 16:30", duration: "30min", status: "default" }
|
||||
];
|
||||
|
||||
const fetchProgress = async () => {
|
||||
try {
|
||||
const res = await getMeetingProgress(meeting.id);
|
||||
if (res.data?.data) {
|
||||
setProgress(res.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
fetchProgress();
|
||||
const timer = setInterval(fetchProgress, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [meeting.id, meeting.status]);
|
||||
|
||||
if (meeting.status !== 1 && meeting.status !== 2) return null;
|
||||
|
||||
const percent = progress?.percent || 0;
|
||||
const isError = percent < 0;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 12, padding: '12px 16px', background: 'var(--app-bg-surface-soft)', borderRadius: 8, border: '1px solid var(--app-border-color)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
|
||||
{progress?.message || '准备分析中...'}
|
||||
</Text>
|
||||
{!isError && <Text strong style={{ color: '#1890ff' }}>{percent}%</Text>}
|
||||
</div>
|
||||
<Progress
|
||||
percent={isError ? 100 : percent}
|
||||
size="small"
|
||||
status={isError ? 'exception' : (percent === 100 ? 'success' : 'active')}
|
||||
showInfo={false}
|
||||
strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const processingCount = Number(stats?.processingTasks || 0);
|
||||
const dashboardLoading = loading && processingCount > 0;
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
const timer = setInterval(fetchDashboardData, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const [statsRes, tasksRes] = await Promise.all([getDashboardStats(), getRecentTasks()]);
|
||||
setStats(statsRes.data.data);
|
||||
setRecentTasks(tasksRes.data.data || []);
|
||||
} catch (err) {
|
||||
console.error('Dashboard data load failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTaskProgress = (item: MeetingVO) => {
|
||||
const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: 450 }}>
|
||||
<Steps
|
||||
size="small"
|
||||
current={currentStep}
|
||||
status={item.status === 4 ? 'error' : (item.status === 3 ? 'finish' : 'process')}
|
||||
items={[
|
||||
{
|
||||
title: '语音转录',
|
||||
icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />,
|
||||
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转录中' : '排队中')
|
||||
},
|
||||
{
|
||||
title: '智能总结',
|
||||
icon: item.status === 2 ? <LoadingOutlined spin /> : <RobotOutlined />,
|
||||
description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在生成' : '待执行')
|
||||
},
|
||||
{
|
||||
title: '分析完成',
|
||||
icon: item.status === 3 ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : <FileTextOutlined />,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
{ label: '累计会议记录', value: stats?.totalMeetings, icon: <HistoryOutlined />, color: '#1890ff' },
|
||||
const columns = [
|
||||
{
|
||||
label: '当前分析中任务',
|
||||
value: stats?.processingTasks,
|
||||
icon: processingCount > 0 ? <LoadingOutlined spin /> : <ClockCircleOutlined />,
|
||||
color: '#faad14'
|
||||
title: t("dashboard.meetingName"),
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (text: string) => <Text strong>{text}</Text>
|
||||
},
|
||||
{ label: '今日新增分析', value: stats?.todayNew, icon: <RiseOutlined />, color: '#52c41a' },
|
||||
{ label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: <CheckCircleOutlined />, color: '#13c2c2' },
|
||||
{
|
||||
title: t("dashboard.startTime"),
|
||||
dataIndex: "time",
|
||||
key: "time",
|
||||
className: "tabular-nums",
|
||||
render: (text: string) => <Text type="secondary">{text}</Text>
|
||||
},
|
||||
{
|
||||
title: t("dashboard.duration"),
|
||||
dataIndex: "duration",
|
||||
key: "duration",
|
||||
width: 100,
|
||||
className: "tabular-nums"
|
||||
},
|
||||
{
|
||||
title: t("common.status"),
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: string) => {
|
||||
if (status === "processing") return <Tag icon={<SyncOutlined spin aria-hidden="true" />} color="processing">{t("dashboardExt.processing")}</Tag>;
|
||||
if (status === "success") return <Tag icon={<CheckCircleOutlined aria-hidden="true" />} color="success">{t("dashboardExt.completed")}</Tag>;
|
||||
return <Tag color="default">{t("dashboardExt.pending")}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("common.action"),
|
||||
key: "action",
|
||||
width: 80,
|
||||
render: () => <Button type="link" size="small" icon={<ArrowRightOutlined aria-hidden="true" />} aria-label={t("dashboard.viewAll")} />
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', background: 'var(--app-bg-page)', minHeight: '100%', overflowY: 'auto' }}>
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
{statCards.map((s, idx) => (
|
||||
<Col span={6} key={idx}>
|
||||
<Card variant="borderless" style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
|
||||
<Statistic
|
||||
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
|
||||
value={s.value || 0}
|
||||
valueStyle={{ color: s.color, fontWeight: 700 }}
|
||||
prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<div className="app-page dashboard-page">
|
||||
<PageHeader
|
||||
title={t("dashboard.title")}
|
||||
subtitle={t("dashboard.subtitle")}
|
||||
/>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space><ClockCircleOutlined /> 最近任务动态</Space>
|
||||
<Button type="link" onClick={() => navigate('/meetings')}>查看历史记录</Button>
|
||||
</div>
|
||||
}
|
||||
variant="borderless"
|
||||
style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}
|
||||
>
|
||||
<List
|
||||
loading={dashboardLoading}
|
||||
dataSource={recentTasks}
|
||||
renderItem={(item) => (
|
||||
<List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Row gutter={32} align="middle">
|
||||
<Col span={8}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Title level={5} style={{ margin: 0, cursor: 'pointer' }} onClick={() => navigate(`/meetings/${item.id}`)}>
|
||||
{item.title}
|
||||
</Title>
|
||||
<Space size={12} split={<Divider type="vertical" style={{ margin: 0 }} />}>
|
||||
<Text type="secondary"><CalendarOutlined /> {dayjs(item.meetingTime).format('MM-DD HH:mm')}</Text>
|
||||
<Text type="secondary"><TeamOutlined /> {item.participants || item.creatorName || '未指定'}</Text>
|
||||
</Space>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{item.tags?.split(',').filter(Boolean).map((t) => (
|
||||
<Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
{renderTaskProgress(item)}
|
||||
</Col>
|
||||
|
||||
<Col span={4} style={{ textAlign: 'right' }}>
|
||||
<Button
|
||||
type={item.status === 3 ? 'primary' : 'default'}
|
||||
ghost={item.status === 3}
|
||||
icon={item.status === 3 ? <FileTextOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={() => navigate(`/meetings/${item.id}`)}
|
||||
>
|
||||
{item.status === 3 ? '查看纪要' : '监控详情'}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<MeetingProgressDisplay meeting={item} />
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
locale={{ emptyText: <Empty description="暂无近期分析任务" /> }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
|
||||
.ant-steps-item-description { font-size: 11px !important; }
|
||||
`}</style>
|
||||
<div className="app-page__page-actions">
|
||||
<Button icon={<SyncOutlined aria-hidden="true" />} size="small">{t("common.refresh")}</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatCard title={t("dashboard.todayMeetings")} value={12} icon={<VideoCameraOutlined aria-hidden="true" />} color="blue" trend={{ value: 8, direction: "up" }} />
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatCard title={t("dashboard.activeDevices")} value={45} icon={<DesktopOutlined aria-hidden="true" />} color="green" trend={{ value: 2, direction: "up" }} />
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatCard title={t("dashboard.transcriptionDuration")} value={1280} suffix="min" icon={<ClockCircleOutlined aria-hidden="true" />} color="orange" trend={{ value: 5, direction: "down" }} />
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatCard title={t("dashboard.totalUsers")} value={320} icon={<UserOutlined aria-hidden="true" />} color="purple" trend={{ value: 12, direction: "up" }} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[24, 24]} className="mt-6">
|
||||
<Col xs={24} xl={16}>
|
||||
<Card title={t("dashboard.recentMeetings")} bordered={false} className="app-page__content-card" extra={<Button type="link" size="small">{t("dashboard.viewAll")}</Button>} styles={{ body: { padding: 0 } }}>
|
||||
<Table dataSource={recentMeetings} columns={columns} pagination={false} size="middle" className="roles-table" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={8}>
|
||||
<Card title={t("dashboard.deviceLoad")} bordered={false} className="app-page__content-card">
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
<div className="mt-4 text-gray-400 flex items-center gap-2">
|
||||
<SyncOutlined spin aria-hidden="true" />
|
||||
<span>{t("dashboardExt.chartLoading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Card, Drawer, Form, Input, Popconfirm, Select, Space, Table, Tag, Typography, App } from 'antd';
|
||||
import { Button, Card, Drawer, Form, Input, Popconfirm, Select, Space, Table, Tag, Typography, message } from "antd";
|
||||
import { DeleteOutlined, DesktopOutlined, EditOutlined, PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -20,7 +20,6 @@ type DeviceFormValues = {
|
|||
};
|
||||
|
||||
export default function Devices() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
|
|
@ -155,88 +154,86 @@ export default function Devices() {
|
|||
</Card>
|
||||
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<div className="app-page__table-wrap">
|
||||
<Table<DeviceInfo>
|
||||
rowKey="deviceId"
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
size="middle"
|
||||
scroll={{ x: "max-content", y: "calc(100vh - 350px)" }}
|
||||
pagination={getStandardPagination(filteredData.length, 1, 1000)}
|
||||
columns={[
|
||||
{
|
||||
title: t("devicesExt.device"),
|
||||
key: "device",
|
||||
render: (_value: unknown, record) => (
|
||||
rowKey="deviceId"
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
size="middle"
|
||||
scroll={{ y: "calc(100vh - 350px)" }}
|
||||
pagination={getStandardPagination(filteredData.length, 1, 1000)}
|
||||
columns={[
|
||||
{
|
||||
title: t("devicesExt.device"),
|
||||
key: "device",
|
||||
render: (_value: unknown, record) => (
|
||||
<Space>
|
||||
<div className="device-icon-placeholder">
|
||||
<DesktopOutlined aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="device-name font-medium">{record.deviceName || t("devicesExt.unnamedDevice")}</div>
|
||||
<div className="device-code text-xs text-gray-400 tabular-nums">{record.deviceCode}</div>
|
||||
</div>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t("devices.owner"),
|
||||
key: "user",
|
||||
render: (_value: unknown, record) => {
|
||||
const owner = userMap[record.userId];
|
||||
return owner ? (
|
||||
<Space>
|
||||
<div className="device-icon-placeholder">
|
||||
<DesktopOutlined aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="device-name font-medium">{record.deviceName || t("devicesExt.unnamedDevice")}</div>
|
||||
<div className="device-code text-xs text-gray-400 tabular-nums">{record.deviceCode}</div>
|
||||
</div>
|
||||
<UserOutlined aria-hidden="true" style={{ color: "#8c8c8c" }} />
|
||||
<span>{owner.displayName}</span>
|
||||
<Text type="secondary" style={{ fontSize: "12px" }} className="tabular-nums">
|
||||
({t("devicesExt.ownerId")}: {record.userId})
|
||||
</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t("devices.owner"),
|
||||
key: "user",
|
||||
render: (_value: unknown, record) => {
|
||||
const owner = userMap[record.userId];
|
||||
return owner ? (
|
||||
<Space>
|
||||
<UserOutlined aria-hidden="true" style={{ color: "#8c8c8c" }} />
|
||||
<span>{owner.displayName}</span>
|
||||
<Text type="secondary" style={{ fontSize: "12px" }} className="tabular-nums">
|
||||
({t("devicesExt.ownerId")}: {record.userId})
|
||||
</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<span className="tabular-nums">{t("devicesExt.ownerId")}: {record.userId}</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("common.status"),
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
render: (status: number) => {
|
||||
const item = statusDict.find((dictItem) => dictItem.itemValue === String(status));
|
||||
return <Tag color={status === 1 ? "green" : "red"}>{item?.itemLabel || (status === 1 ? t("devicesExt.enabled") : t("devicesExt.disabled"))}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("devices.updateTime"),
|
||||
dataIndex: "updatedAt",
|
||||
width: 180,
|
||||
render: (text: string) => (
|
||||
<Text type="secondary" className="tabular-nums">
|
||||
{text?.replace("T", " ").substring(0, 19)}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t("common.action"),
|
||||
key: "action",
|
||||
width: 120,
|
||||
fixed: "right",
|
||||
render: (_value: unknown, record) => (
|
||||
<Space>
|
||||
{can("device:update") ? (
|
||||
<Button type="text" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label={t("devicesExt.editDevice")} />
|
||||
) : null}
|
||||
{can("device:delete") ? (
|
||||
<Popconfirm title={t("devicesExt.deleteDevice")} onConfirm={() => remove(record.deviceId)}>
|
||||
<Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t("common.delete")} />
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
) : (
|
||||
<span className="tabular-nums">{t("devicesExt.ownerId")}: {record.userId}</span>
|
||||
);
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
},
|
||||
{
|
||||
title: t("common.status"),
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
render: (status: number) => {
|
||||
const item = statusDict.find((dictItem) => dictItem.itemValue === String(status));
|
||||
return <Tag color={status === 1 ? "green" : "red"}>{item?.itemLabel || (status === 1 ? t("devicesExt.enabled") : t("devicesExt.disabled"))}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("devices.updateTime"),
|
||||
dataIndex: "updatedAt",
|
||||
width: 180,
|
||||
render: (text: string) => (
|
||||
<Text type="secondary" className="tabular-nums">
|
||||
{text?.replace("T", " ").substring(0, 19)}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t("common.action"),
|
||||
key: "action",
|
||||
width: 120,
|
||||
fixed: "right",
|
||||
render: (_value: unknown, record) => (
|
||||
<Space>
|
||||
{can("device:update") ? (
|
||||
<Button type="text" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label={t("devicesExt.editDevice")} />
|
||||
) : null}
|
||||
{can("device:delete") ? (
|
||||
<Popconfirm title={t("devicesExt.deleteDevice")} onConfirm={() => remove(record.deviceId)}>
|
||||
<Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t("common.delete")} />
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
|
|
@ -249,7 +246,7 @@ export default function Devices() {
|
|||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
width={420}
|
||||
destroyOnHidden
|
||||
destroyOnClose
|
||||
footer={
|
||||
<div className="app-page__drawer-footer">
|
||||
<Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button>
|
||||
|
|
@ -281,4 +278,4 @@ export default function Devices() {
|
|||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import { Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ApartmentOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, ShopOutlined } from "@ant-design/icons";
|
||||
|
|
@ -32,7 +32,6 @@ function buildOrgTree(list: SysOrg[]): OrgNode[] {
|
|||
}
|
||||
|
||||
export default function Orgs() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
|
|
@ -185,7 +184,7 @@ export default function Orgs() {
|
|||
)}
|
||||
</Card>
|
||||
|
||||
<Drawer title={<Space><ApartmentOutlined aria-hidden="true" /><span>{editing ? t("orgs.drawerTitleEdit") : t("orgs.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><ApartmentOutlined aria-hidden="true" /><span>{editing ? t("orgs.drawerTitleEdit") : t("orgs.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label={t("users.tenant")} name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}>
|
||||
<Select disabled options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} />
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
import { Avatar, Button, Card, Col, DatePicker, Divider, Drawer, Empty, Form, Input, List, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import { Avatar, Button, Card, Col, DatePicker, Divider, Drawer, Empty, Form, Input, List, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import { createTenant, deleteTenant, listTenants, updateTenant } from "@/api";
|
||||
import AppPagination from "@/components/shared/AppPagination";
|
||||
import { useDict } from "@/hooks/useDict";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import PageHeader from "@/components/shared/PageHeader";
|
||||
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
|
||||
import type { SysTenant } from "@/types";
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
export default function Tenants() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
|
|
@ -24,7 +23,9 @@ export default function Tenants() {
|
|||
const [params, setParams] = useState({ current: 1, size: 12, name: "", code: "" });
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SysTenant | null>(null);
|
||||
const [adminAccountTouched, setAdminAccountTouched] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const watchedTenantCode = Form.useWatch("tenantCode", form);
|
||||
|
||||
const loadData = async (currentParams = params) => {
|
||||
setLoading(true);
|
||||
|
|
@ -55,6 +56,7 @@ export default function Tenants() {
|
|||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setAdminAccountTouched(false);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 1 });
|
||||
setDrawerOpen(true);
|
||||
|
|
@ -62,10 +64,19 @@ export default function Tenants() {
|
|||
|
||||
const openEdit = (record: SysTenant) => {
|
||||
setEditing(record);
|
||||
setAdminAccountTouched(false);
|
||||
form.setFieldsValue({ ...record, expireTime: record.expireTime ? dayjs(record.expireTime) : null });
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerOpen || editing || adminAccountTouched) {
|
||||
return;
|
||||
}
|
||||
const normalizedTenantCode = watchedTenantCode?.trim().toLowerCase();
|
||||
form.setFieldValue("defaultAdminUsername", normalizedTenantCode ? sanitizeLoginName(`admin@${normalizedTenantCode}`) : undefined);
|
||||
}, [adminAccountTouched, drawerOpen, editing, form, watchedTenantCode]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await deleteTenant(id);
|
||||
message.success(t("common.success"));
|
||||
|
|
@ -76,7 +87,11 @@ export default function Tenants() {
|
|||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { ...values, expireTime: values.expireTime ? values.expireTime.format("YYYY-MM-DD HH:mm:ss") : null };
|
||||
const payload = {
|
||||
...values,
|
||||
defaultAdminUsername: values.defaultAdminUsername?.trim(),
|
||||
expireTime: values.expireTime ? values.expireTime.format("YYYY-MM-DD HH:mm:ss") : null
|
||||
};
|
||||
if (editing) {
|
||||
await updateTenant(editing.id, payload);
|
||||
} else {
|
||||
|
|
@ -147,26 +162,28 @@ export default function Tenants() {
|
|||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto" style={{ padding: "24px 24px 0" }}>
|
||||
<List
|
||||
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
|
||||
loading={loading}
|
||||
dataSource={data}
|
||||
renderItem={renderTenantCard}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} /> }}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination
|
||||
current={params.current}
|
||||
pageSize={params.size}
|
||||
total={total}
|
||||
onChange={(page, size) => setParams({ ...params, current: page, size: size || params.size })}
|
||||
<div className="flex-1 overflow-y-auto pr-2">
|
||||
<List
|
||||
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
|
||||
loading={loading}
|
||||
dataSource={data}
|
||||
renderItem={renderTenantCard}
|
||||
pagination={{
|
||||
total,
|
||||
current: params.current,
|
||||
pageSize: params.size,
|
||||
onChange: (page, size) => setParams({ ...params, current: page, size: size || params.size }),
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (count) => t("common.total", { total: count }),
|
||||
pageSizeOptions: ["10", "20", "50", "100"],
|
||||
style: { marginTop: "24px", marginBottom: "24px" }
|
||||
}}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} /> }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Drawer title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
|
|
@ -180,6 +197,29 @@ export default function Tenants() {
|
|||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
{!editing && (
|
||||
<Form.Item
|
||||
label={t("tenants.defaultAdminUsername", { defaultValue: "默认管理员账户" })}
|
||||
name="defaultAdminUsername"
|
||||
rules={[
|
||||
{ required: true, message: t("tenants.defaultAdminUsername", { defaultValue: "默认管理员账户" }) },
|
||||
{
|
||||
pattern: LOGIN_NAME_PATTERN,
|
||||
message: t("tenantsExt.defaultAdminUsernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" })
|
||||
}
|
||||
]}
|
||||
getValueFromEvent={(event) => {
|
||||
setAdminAccountTouched(true);
|
||||
return sanitizeLoginName(event?.target?.value);
|
||||
}}
|
||||
extra={t("tenantsExt.defaultAdminUsernameTip", { defaultValue: "默认值会根据租户编码自动生成,可手动修改;保存时会校验登录名是否重复。登录名只能输入数字、小写英文、@ 和 _。" })}
|
||||
>
|
||||
<Input
|
||||
placeholder={t("tenantsExt.defaultAdminUsernamePlaceholder", { defaultValue: "默认:admin@租户编码小写" })}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label={t("tenants.contactName")} name="contactName">
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ export default function Profile() {
|
|||
const url = await uploadPlatformAsset(file);
|
||||
profileForm.setFieldValue("avatarUrl", url);
|
||||
message.success(t("common.success"));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : t("common.error"));
|
||||
return Upload.LIST_IGNORE;
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
|
|
@ -128,7 +131,7 @@ export default function Profile() {
|
|||
label: <span><SolutionOutlined /> {t("profile.basicInfo")}</span>,
|
||||
children: (
|
||||
<Form form={profileForm} layout="vertical" onFinish={handleUpdateProfile} style={{ marginTop: 16 }}>
|
||||
<Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true }]}>
|
||||
<Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("users.email")} name="email">
|
||||
|
|
@ -207,42 +210,42 @@ export default function Profile() {
|
|||
bordered
|
||||
size="middle"
|
||||
column={1} items={[
|
||||
{
|
||||
key: "bind-status",
|
||||
label: t("profile.botBindStatus"),
|
||||
children: credential?.bound
|
||||
? <Tag color="success">{t("profile.botBound")}</Tag>
|
||||
: <Tag>{t("profile.botUnbound")}</Tag>
|
||||
},
|
||||
{
|
||||
key: "bot-id",
|
||||
label: "X-Bot-Id",
|
||||
children: credential?.botId ? (
|
||||
<Paragraph copyable={{ text: credential.botId }} style={{ marginBottom: 0 }}>
|
||||
{credential.botId}
|
||||
</Paragraph>
|
||||
) : "-"
|
||||
},
|
||||
{
|
||||
key: "bot-secret",
|
||||
label: "X-Bot-Secret",
|
||||
children: credential?.botSecret ? (
|
||||
<Paragraph copyable={{ text: credential.botSecret }} style={{ marginBottom: 0 }}>
|
||||
{credential.botSecret}
|
||||
</Paragraph>
|
||||
) : t("profile.botSecretHidden")
|
||||
},
|
||||
{
|
||||
key: "last-access-time",
|
||||
label: t("profile.botLastAccessTime"),
|
||||
children: renderValue(credential?.lastAccessTime)
|
||||
},
|
||||
{
|
||||
key: "last-access-ip",
|
||||
label: t("profile.botLastAccessIp"),
|
||||
children: renderValue(credential?.lastAccessIp)
|
||||
}
|
||||
]}
|
||||
{
|
||||
key: "bind-status",
|
||||
label: t("profile.botBindStatus"),
|
||||
children: credential?.bound
|
||||
? <Tag color="success">{t("profile.botBound")}</Tag>
|
||||
: <Tag>{t("profile.botUnbound")}</Tag>
|
||||
},
|
||||
{
|
||||
key: "bot-id",
|
||||
label: "X-Bot-Id",
|
||||
children: credential?.botId ? (
|
||||
<Paragraph copyable={{ text: credential.botId }} style={{ marginBottom: 0 }}>
|
||||
{credential.botId}
|
||||
</Paragraph>
|
||||
) : "-"
|
||||
},
|
||||
{
|
||||
key: "bot-secret",
|
||||
label: "X-Bot-Secret",
|
||||
children: credential?.botSecret ? (
|
||||
<Paragraph copyable={{ text: credential.botSecret }} style={{ marginBottom: 0 }}>
|
||||
{credential.botSecret}
|
||||
</Paragraph>
|
||||
) : t("profile.botSecretHidden")
|
||||
},
|
||||
{
|
||||
key: "last-access-time",
|
||||
label: t("profile.botLastAccessTime"),
|
||||
children: renderValue(credential?.lastAccessTime)
|
||||
},
|
||||
{
|
||||
key: "last-access-ip",
|
||||
label: t("profile.botLastAccessIp"),
|
||||
children: renderValue(credential?.lastAccessIp)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
|
||||
|
|
|
|||
|
|
@ -248,10 +248,10 @@ export default function Dictionaries() {
|
|||
<Drawer title={<Space><BookOutlined aria-hidden="true" /><span>{editingType ? t("dicts.drawerTitleTypeEdit") : t("dicts.drawerTitleTypeCreate")}</span></Space>} open={typeDrawerVisible} onClose={() => setTypeDrawerVisible(false)} width={400} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setTypeDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleTypeSubmit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={typeForm} layout="vertical">
|
||||
<Form.Item label={t("dicts.typeCode")} name="typeCode" rules={[{ required: true, message: t("dicts.typeCode") }]}>
|
||||
<Input disabled={!!editingType} placeholder={t("dicts.typeCode")} className="tabular-nums" />
|
||||
<Input disabled={!!editingType} placeholder={t("dictsExt.typeCodePlaceholder")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("dicts.typeName")} name="typeName" rules={[{ required: true, message: t("dicts.typeName") }]}>
|
||||
<Input placeholder={t("dicts.typeName")} />
|
||||
<Input placeholder={t("dictsExt.typeNamePlaceholder")} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("common.remark")} name="remark">
|
||||
<Input.TextArea placeholder={t("dictsExt.typeRemarkPlaceholder")} rows={3} />
|
||||
|
|
@ -259,7 +259,7 @@ export default function Dictionaries() {
|
|||
</Form>
|
||||
</Drawer>
|
||||
|
||||
<Drawer title={<Space><ProfileOutlined aria-hidden="true" /><span>{editingItem ? t("dicts.drawerTitleItemEdit") : t("dicts.drawerTitleItemCreate")}</span></Space>} open={itemDrawerVisible} onClose={() => setItemDrawerVisible(false)} width={400} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setItemDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleItemSubmit}>{t("common.save")}</Button></div>}>
|
||||
<Drawer title={<Space><ProfileOutlined aria-hidden="true" /><span>{editingItem ? t("dicts.drawerTitleItemEdit") : t("dicts.drawerTitleItemCreate")}</span></Space>} open={itemDrawerVisible} onClose={() => setItemDrawerVisible(false)} width={400} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setItemDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleItemSubmit}>{t("common.save")}</Button></div>}>
|
||||
<Form form={itemForm} layout="vertical">
|
||||
<Form.Item label={t("dicts.typeCode")} name="typeCode"><Input disabled className="tabular-nums" /></Form.Item>
|
||||
<Form.Item label={t("dicts.itemLabel")} name="itemLabel" rules={[{ required: true, message: t("dicts.itemLabel") }]}><Input placeholder={t("dictsExt.itemLabelPlaceholder")} /></Form.Item>
|
||||
|
|
|
|||
|
|
@ -49,9 +49,14 @@ export default function PlatformSettings() {
|
|||
}, []);
|
||||
|
||||
const handleUpload = async (file: File, fieldName: keyof SysPlatformConfig) => {
|
||||
const url = await uploadPlatformAsset(file);
|
||||
form.setFieldValue(fieldName, url);
|
||||
message.success(t("common.success"));
|
||||
try {
|
||||
const url = await uploadPlatformAsset(file);
|
||||
form.setFieldValue(fieldName, url);
|
||||
message.success(t("common.success"));
|
||||
} catch (error) {
|
||||
// message.error(error instanceof Error ? error.message : t("common.error"));
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
|
|
@ -146,4 +151,4 @@ export default function PlatformSettings() {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, App } from 'antd';
|
||||
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from "antd";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
|
|
@ -13,7 +13,6 @@ import "./index.less";
|
|||
const { Text } = Typography;
|
||||
|
||||
export default function SysParams() {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermission();
|
||||
const { items: statusDict } = useDict("sys_common_status");
|
||||
|
|
@ -58,7 +57,7 @@ export default function SysParams() {
|
|||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ isSystem: false, status: 1 });
|
||||
form.setFieldsValue({ isSystem: 0, status: 1 });
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -110,8 +109,24 @@ export default function SysParams() {
|
|||
title: t("sysParams.paramValue"),
|
||||
dataIndex: "paramValue",
|
||||
key: "paramValue",
|
||||
ellipsis: true,
|
||||
render: (text: string) => <Tooltip title={text}><Text code>{text}</Text></Tooltip>
|
||||
width: 360,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (text: string) => (
|
||||
<Tooltip title={text}>
|
||||
<Text
|
||||
code
|
||||
style={{
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t("sysParams.paramType"),
|
||||
|
|
@ -173,18 +188,18 @@ export default function SysParams() {
|
|||
</Card>
|
||||
|
||||
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
||||
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
|
||||
<Table
|
||||
rowKey="paramId"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
size="middle"
|
||||
scroll={{ x: "max-content" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
<AppPagination current={queryParams.pageNum || 1} pageSize={queryParams.pageSize || 10} total={total} onChange={handlePageChange} />
|
||||
<Table
|
||||
rowKey="paramId"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
size="middle"
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: "max-content" }}
|
||||
pagination={false}
|
||||
/>
|
||||
<AppPagination current={queryParams.pageNum || 1} pageSize={queryParams.pageSize || 10} total={total} onChange={handlePageChange} />
|
||||
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
|
|
@ -192,8 +207,7 @@ export default function SysParams() {
|
|||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={500}
|
||||
destroyOnHidden
|
||||
forceRender
|
||||
destroyOnClose
|
||||
footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
export const LOGIN_NAME_PATTERN = /^[a-z0-9@_]+$/;
|
||||
|
||||
export function sanitizeLoginName(value?: string) {
|
||||
return (value || "").toLowerCase().replace(/[^a-z0-9@_]/g, "");
|
||||
}
|
||||
Loading…
Reference in New Issue