feat: 添加用户提示支持和优化会议访问逻辑
- 在 `MeetingCommandService` 和 `MeetingDomainSupport` 中添加 `userPrompt` 参数 - 在 `MeetingAccessService` 和 `MeetingQueryService` 中添加忽略租户的会议查询方法 - 更新前端API和组件,支持用户提示功能 - 优化会议访问逻辑,包括预览密码验证和角色管理页面 - 添加相关单元测试以验证新功能的正确性dev_na
parent
d4424a157b
commit
27ae0a3def
|
|
@ -4,4 +4,5 @@ public final class SysParamKeys {
|
||||||
private SysParamKeys() {}
|
private SysParamKeys() {}
|
||||||
|
|
||||||
public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
|
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")
|
@PostMapping("/{id}/summary/regenerate")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@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();
|
LoginUser loginUser = currentLoginUser();
|
||||||
Meeting meeting = meetingAccessService.requireMeeting(id);
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
||||||
dto.setMeetingId(id);
|
dto.setMeetingId(id);
|
||||||
assertPromptAvailable(dto.getPromptId(), loginUser);
|
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);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public class MeetingPublicPreviewController {
|
||||||
@GetMapping("/{id}/preview/access")
|
@GetMapping("/{id}/preview/access")
|
||||||
public ApiResponse<MeetingPreviewAccessVO> getPreviewAccess(@PathVariable Long id) {
|
public ApiResponse<MeetingPreviewAccessVO> getPreviewAccess(@PathVariable Long id) {
|
||||||
try {
|
try {
|
||||||
Meeting meeting = meetingAccessService.requireMeeting(id);
|
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(id);
|
||||||
return ApiResponse.ok(new MeetingPreviewAccessVO(meetingAccessService.isPreviewPasswordRequired(meeting)));
|
return ApiResponse.ok(new MeetingPreviewAccessVO(meetingAccessService.isPreviewPasswordRequired(meeting)));
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
return ApiResponse.error(ex.getMessage());
|
return ApiResponse.error(ex.getMessage());
|
||||||
|
|
@ -39,11 +39,11 @@ public class MeetingPublicPreviewController {
|
||||||
public ApiResponse<PublicMeetingPreviewVO> getPreview(@PathVariable Long id,
|
public ApiResponse<PublicMeetingPreviewVO> getPreview(@PathVariable Long id,
|
||||||
@RequestParam(required = false) String accessPassword) {
|
@RequestParam(required = false) String accessPassword) {
|
||||||
try {
|
try {
|
||||||
Meeting meeting = meetingAccessService.requireMeeting(id);
|
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(id);
|
||||||
meetingAccessService.assertCanPreviewMeeting(meeting, accessPassword);
|
meetingAccessService.assertCanPreviewMeeting(meeting, accessPassword);
|
||||||
|
|
||||||
PublicMeetingPreviewVO data = new PublicMeetingPreviewVO();
|
PublicMeetingPreviewVO data = new PublicMeetingPreviewVO();
|
||||||
data.setMeeting(meetingQueryService.getDetail(id));
|
data.setMeeting(meetingQueryService.getDetailIgnoreTenant(id));
|
||||||
if (data.getMeeting() != null) {
|
if (data.getMeeting() != null) {
|
||||||
data.getMeeting().setAccessPassword(null);
|
data.getMeeting().setAccessPassword(null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.imeeting.dto.biz;
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
@ -34,6 +35,9 @@ public class CreateMeetingCommand {
|
||||||
@NotNull(message = "promptId must not be null")
|
@NotNull(message = "promptId must not be null")
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
|
||||||
|
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
||||||
|
private String userPrompt;
|
||||||
|
|
||||||
private Integer useSpkId;
|
private Integer useSpkId;
|
||||||
private Boolean enableTextRefine;
|
private Boolean enableTextRefine;
|
||||||
private List<String> hotWords;
|
private List<String> hotWords;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.imeeting.dto.biz;
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
@ -31,6 +32,9 @@ public class CreateRealtimeMeetingCommand {
|
||||||
@NotNull(message = "promptId must not be null")
|
@NotNull(message = "promptId must not be null")
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
|
||||||
|
@Size(max = 2000, message = "userPrompt length must be <= 2000")
|
||||||
|
private String userPrompt;
|
||||||
|
|
||||||
private String mode;
|
private String mode;
|
||||||
private String language;
|
private String language;
|
||||||
private Integer useSpkId;
|
private Integer useSpkId;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
package com.imeeting.dto.biz;
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class MeetingResummaryDTO {
|
public class MeetingResummaryDTO {
|
||||||
private Long meetingId;
|
private Long meetingId;
|
||||||
private Long summaryModelId;
|
private Long summaryModelId;
|
||||||
private Long promptId;
|
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 String accessPassword;
|
||||||
private Integer duration;
|
private Integer duration;
|
||||||
private String summaryContent;
|
private String summaryContent;
|
||||||
|
private String lastUserPrompt;
|
||||||
private Map<String, Object> analysis;
|
private Map<String, Object> analysis;
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
package com.imeeting.mapper.biz;
|
package com.imeeting.mapper.biz;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface MeetingMapper extends BaseMapper<Meeting> {
|
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.MeetingService;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
import com.imeeting.service.biz.impl.MeetingDomainSupport;
|
import com.imeeting.service.biz.impl.MeetingDomainSupport;
|
||||||
|
import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler;
|
||||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||||
import com.unisbase.security.LoginUser;
|
import com.unisbase.security.LoginUser;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -54,6 +55,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
||||||
private final MeetingDomainSupport meetingDomainSupport;
|
private final MeetingDomainSupport meetingDomainSupport;
|
||||||
private final MeetingRuntimeProfileResolver runtimeProfileResolver;
|
private final MeetingRuntimeProfileResolver runtimeProfileResolver;
|
||||||
private final PromptTemplateService promptTemplateService;
|
private final PromptTemplateService promptTemplateService;
|
||||||
|
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||||
private final AiTaskService aiTaskService;
|
private final AiTaskService aiTaskService;
|
||||||
private final MeetingTranscriptMapper transcriptMapper;
|
private final MeetingTranscriptMapper transcriptMapper;
|
||||||
private final LlmModelMapper llmModelMapper;
|
private final LlmModelMapper llmModelMapper;
|
||||||
|
|
@ -272,13 +274,11 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
||||||
|
|
||||||
private void resetOrCreateSummaryTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) {
|
private void resetOrCreateSummaryTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) {
|
||||||
AiTask task = findLatestTask(meetingId, "SUMMARY");
|
AiTask task = findLatestTask(meetingId, "SUMMARY");
|
||||||
Map<String, Object> taskConfig = new HashMap<>();
|
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
|
||||||
taskConfig.put("summaryModelId", profile.getResolvedSummaryModelId());
|
profile.getResolvedSummaryModelId(),
|
||||||
taskConfig.put("promptId", profile.getResolvedPromptId());
|
profile.getResolvedPromptId(),
|
||||||
PromptTemplate template = promptTemplateService.getById(profile.getResolvedPromptId());
|
null
|
||||||
if (template != null) {
|
);
|
||||||
taskConfig.put("promptContent", template.getPromptContent());
|
|
||||||
}
|
|
||||||
resetOrCreateTask(task, meetingId, "SUMMARY", taskConfig);
|
resetOrCreateTask(task, meetingId, "SUMMARY", taskConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import com.unisbase.security.LoginUser;
|
||||||
public interface MeetingAccessService {
|
public interface MeetingAccessService {
|
||||||
Meeting requireMeeting(Long meetingId);
|
Meeting requireMeeting(Long meetingId);
|
||||||
|
|
||||||
|
Meeting requireMeetingIgnoreTenant(Long meetingId);
|
||||||
|
|
||||||
boolean isPreviewPasswordRequired(Meeting meeting);
|
boolean isPreviewPasswordRequired(Meeting meeting);
|
||||||
|
|
||||||
void assertCanPreviewMeeting(Meeting meeting, String accessPassword);
|
void assertCanPreviewMeeting(Meeting meeting, String accessPassword);
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ public interface MeetingCommandService {
|
||||||
|
|
||||||
void updateSummaryContent(Long meetingId, String summaryContent);
|
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);
|
void retryTranscription(Long meetingId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ public interface MeetingQueryService {
|
||||||
|
|
||||||
MeetingVO getDetail(Long id);
|
MeetingVO getDetail(Long id);
|
||||||
|
|
||||||
|
MeetingVO getDetailIgnoreTenant(Long id);
|
||||||
|
|
||||||
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
|
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
|
||||||
|
|
||||||
Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
|
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 HotWordService hotWordService;
|
||||||
private final StringRedisTemplate redisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||||
|
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||||
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||||
|
|
||||||
@Value("${unisbase.app.server-base-url}")
|
@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);
|
String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId);
|
||||||
|
|
||||||
// 轮询逻辑 (带防卡死防护)
|
// 轮询逻辑(带防卡死防护)
|
||||||
JsonNode resultNode = null;
|
JsonNode resultNode = null;
|
||||||
int lastPercent = -1;
|
int lastPercent = -1;
|
||||||
int unchangedCount = 0;
|
int unchangedCount = 0;
|
||||||
|
|
@ -241,7 +242,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
|
|
||||||
if (resultNode == null) throw new RuntimeException("ASR轮询超时");
|
if (resultNode == null) throw new RuntimeException("ASR轮询超时");
|
||||||
|
|
||||||
// 解析并入库 (防御性清理旧数据)
|
// 解析并入库(防御性清理旧数据)
|
||||||
return saveTranscripts(meeting, resultNode);
|
return saveTranscripts(meeting, resultNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -432,15 +433,15 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
throw new RuntimeException("LLM模型未启用");
|
throw new RuntimeException("LLM模型未启用");
|
||||||
}
|
}
|
||||||
|
|
||||||
String promptContent = taskRecord.getTaskConfig().get("promptContent") != null
|
String userPrompt = taskRecord.getTaskConfig().get("userPrompt") != null
|
||||||
? taskRecord.getTaskConfig().get("promptContent").toString() : "";
|
? taskRecord.getTaskConfig().get("userPrompt").toString() : null;
|
||||||
|
|
||||||
Map<String, Object> req = new HashMap<>();
|
Map<String, Object> req = new HashMap<>();
|
||||||
req.put("model", llmModel.getModelCode());
|
req.put("model", llmModel.getModelCode());
|
||||||
req.put("temperature", llmModel.getTemperature());
|
req.put("temperature", llmModel.getTemperature());
|
||||||
req.put("messages", List.of(
|
req.put("messages", List.of(
|
||||||
Map.of("role", "system", "content", buildSummarySystemPrompt(promptContent)),
|
Map.of("role", "system", "content", meetingSummaryPromptAssembler.buildSystemMessage(taskRecord.getTaskConfig())),
|
||||||
Map.of("role", "user", "content", buildSummaryUserPrompt(meeting, asrText))
|
Map.of("role", "user", "content", meetingSummaryPromptAssembler.buildUserMessage(meeting, asrText, userPrompt))
|
||||||
));
|
));
|
||||||
|
|
||||||
taskRecord.setRequestData(req);
|
taskRecord.setRequestData(req);
|
||||||
|
|
@ -587,88 +588,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
return normalized.substring(firstLineEnd + 1, lastFence).trim();
|
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) {
|
private void updateMeetingStatus(Long id, int status) {
|
||||||
Meeting m = new Meeting();
|
Meeting m = new Meeting();
|
||||||
m.setId(id);
|
m.setId(id);
|
||||||
|
|
@ -701,3 +620,4 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
this.updateById(task);
|
this.updateById(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,15 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
|
||||||
return meeting;
|
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
|
@Override
|
||||||
public boolean isPreviewPasswordRequired(Meeting meeting) {
|
public boolean isPreviewPasswordRequired(Meeting meeting) {
|
||||||
return normalizePreviewPassword(meeting == null ? null : meeting.getAccessPassword()) != null;
|
return normalizePreviewPassword(meeting == null ? null : meeting.getAccessPassword()) != null;
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
asrTask.setTaskConfig(asrConfig);
|
asrTask.setTaskConfig(asrConfig);
|
||||||
aiTaskService.save(asrTask);
|
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()));
|
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl()));
|
||||||
meetingService.updateById(meeting);
|
meetingService.updateById(meeting);
|
||||||
meetingDomainSupport.publishMeetingCreated(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId());
|
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(),
|
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
|
||||||
null, tenantId, creatorId, creatorName, hostUserId, hostName, 0);
|
null, tenantId, creatorId, creatorName, hostUserId, hostName, 0);
|
||||||
meetingService.save(meeting);
|
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.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
|
||||||
realtimeMeetingSessionStateService.rememberResumeConfig(meeting.getId(), buildRealtimeResumeConfig(command, tenantId, runtimeProfile));
|
realtimeMeetingSessionStateService.rememberResumeConfig(meeting.getId(), buildRealtimeResumeConfig(command, tenantId, runtimeProfile));
|
||||||
|
|
||||||
|
|
@ -455,13 +465,13 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@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);
|
Meeting meeting = meetingService.getById(meetingId);
|
||||||
if (meeting == null) {
|
if (meeting == null) {
|
||||||
throw new RuntimeException("Meeting not found");
|
throw new RuntimeException("Meeting not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId);
|
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId, userPrompt);
|
||||||
meeting.setStatus(2);
|
meeting.setStatus(2);
|
||||||
meetingService.updateById(meeting);
|
meetingService.updateById(meeting);
|
||||||
dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
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.AiTask;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.entity.biz.MeetingTranscript;
|
import com.imeeting.entity.biz.MeetingTranscript;
|
||||||
import com.imeeting.entity.biz.PromptTemplate;
|
|
||||||
import com.imeeting.event.MeetingCreatedEvent;
|
import com.imeeting.event.MeetingCreatedEvent;
|
||||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||||
import com.imeeting.service.biz.AiTaskService;
|
import com.imeeting.service.biz.AiTaskService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
|
||||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||||
import com.unisbase.entity.SysUser;
|
import com.unisbase.entity.SysUser;
|
||||||
import com.unisbase.mapper.SysUserMapper;
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
|
|
@ -39,7 +37,7 @@ import java.util.stream.Collectors;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class MeetingDomainSupport {
|
public class MeetingDomainSupport {
|
||||||
|
|
||||||
private final PromptTemplateService promptTemplateService;
|
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
|
||||||
private final AiTaskService aiTaskService;
|
private final AiTaskService aiTaskService;
|
||||||
private final MeetingTranscriptMapper transcriptMapper;
|
private final MeetingTranscriptMapper transcriptMapper;
|
||||||
private final SysUserMapper sysUserMapper;
|
private final SysUserMapper sysUserMapper;
|
||||||
|
|
@ -68,22 +66,12 @@ public class MeetingDomainSupport {
|
||||||
return meeting;
|
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();
|
AiTask sumTask = new AiTask();
|
||||||
sumTask.setMeetingId(meetingId);
|
sumTask.setMeetingId(meetingId);
|
||||||
sumTask.setTaskType("SUMMARY");
|
sumTask.setTaskType("SUMMARY");
|
||||||
sumTask.setStatus(0);
|
sumTask.setStatus(0);
|
||||||
|
sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, promptId, userPrompt));
|
||||||
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);
|
|
||||||
aiTaskService.save(sumTask);
|
aiTaskService.save(sumTask);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,9 +259,47 @@ public class MeetingDomainSupport {
|
||||||
if (includeSummary) {
|
if (includeSummary) {
|
||||||
vo.setSummaryContent(meetingSummaryFileService.loadSummaryContent(meeting));
|
vo.setSummaryContent(meetingSummaryFileService.loadSummaryContent(meeting));
|
||||||
vo.setAnalysis(meetingSummaryFileService.loadSummaryAnalysis(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) {
|
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.dto.biz.MeetingVO;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.entity.biz.MeetingTranscript;
|
import com.imeeting.entity.biz.MeetingTranscript;
|
||||||
|
import com.imeeting.mapper.biz.MeetingMapper;
|
||||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||||
import com.imeeting.service.biz.MeetingQueryService;
|
import com.imeeting.service.biz.MeetingQueryService;
|
||||||
import com.imeeting.service.biz.MeetingService;
|
import com.imeeting.service.biz.MeetingService;
|
||||||
|
|
@ -24,6 +25,7 @@ import java.util.stream.Collectors;
|
||||||
public class MeetingQueryServiceImpl implements MeetingQueryService {
|
public class MeetingQueryServiceImpl implements MeetingQueryService {
|
||||||
|
|
||||||
private final MeetingService meetingService;
|
private final MeetingService meetingService;
|
||||||
|
private final MeetingMapper meetingMapper;
|
||||||
private final MeetingTranscriptMapper transcriptMapper;
|
private final MeetingTranscriptMapper transcriptMapper;
|
||||||
private final MeetingDomainSupport meetingDomainSupport;
|
private final MeetingDomainSupport meetingDomainSupport;
|
||||||
|
|
||||||
|
|
@ -68,6 +70,12 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
|
||||||
return meeting != null ? toVO(meeting, true) : null;
|
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
|
@Override
|
||||||
public List<MeetingTranscriptVO> getTranscripts(Long meetingId) {
|
public List<MeetingTranscriptVO> getTranscripts(Long meetingId) {
|
||||||
return transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
return transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
package com.imeeting.db;
|
package com.imeeting.db;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Disabled;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
|
@Disabled("Requires a live local database and full application context; not part of automated regression.")
|
||||||
public class DbAlterTest {
|
public class DbAlterTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|
|
||||||
|
|
@ -60,4 +60,19 @@ class MeetingCreateCommandValidationTest {
|
||||||
|
|
||||||
assertTrue(validator.validate(command).isEmpty());
|
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.ManagedChannel;
|
||||||
import io.grpc.ManagedChannelBuilder;
|
import io.grpc.ManagedChannelBuilder;
|
||||||
import io.grpc.stub.StreamObserver;
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import org.junit.jupiter.api.Disabled;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.io.InputStream;
|
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.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
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 {
|
public class AndroidRealtimeGrpcManualTest {
|
||||||
|
|
||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
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.MeetingService;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
import com.imeeting.service.biz.impl.MeetingDomainSupport;
|
import com.imeeting.service.biz.impl.MeetingDomainSupport;
|
||||||
|
import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler;
|
||||||
import com.unisbase.security.LoginUser;
|
import com.unisbase.security.LoginUser;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
@ -63,6 +64,7 @@ class LegacyMeetingAdapterServiceImplTest {
|
||||||
meetingDomainSupport,
|
meetingDomainSupport,
|
||||||
mock(MeetingRuntimeProfileResolver.class),
|
mock(MeetingRuntimeProfileResolver.class),
|
||||||
mock(PromptTemplateService.class),
|
mock(PromptTemplateService.class),
|
||||||
|
mock(MeetingSummaryPromptAssembler.class),
|
||||||
mock(AiTaskService.class),
|
mock(AiTaskService.class),
|
||||||
mock(MeetingTranscriptMapper.class),
|
mock(MeetingTranscriptMapper.class),
|
||||||
mock(LlmModelMapper.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.assertDoesNotThrow;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
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.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.verifyNoInteractions;
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
|
@ -51,7 +52,7 @@ class MeetingAuthorizationServiceImplTest {
|
||||||
service.assertCanManageRealtimeMeeting(meeting, authContext);
|
service.assertCanManageRealtimeMeeting(meeting, authContext);
|
||||||
|
|
||||||
ArgumentCaptor<LoginUser> loginUserCaptor = ArgumentCaptor.forClass(LoginUser.class);
|
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(7L, loginUserCaptor.getValue().getUserId());
|
||||||
assertEquals(1L, loginUserCaptor.getValue().getTenantId());
|
assertEquals(1L, loginUserCaptor.getValue().getTenantId());
|
||||||
assertEquals("alice", loginUserCaptor.getValue().getUsername());
|
assertEquals("alice", loginUserCaptor.getValue().getUsername());
|
||||||
|
|
|
||||||
|
|
@ -319,9 +319,9 @@ class MeetingCommandServiceImplTest {
|
||||||
|
|
||||||
TransactionSynchronizationManager.initSynchronization();
|
TransactionSynchronizationManager.initSynchronization();
|
||||||
try {
|
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());
|
assertEquals(2, meeting.getStatus());
|
||||||
verify(meetingService).updateById(meeting);
|
verify(meetingService).updateById(meeting);
|
||||||
verify(aiTaskService, never()).dispatchSummaryTask(301L, null, null);
|
verify(aiTaskService, never()).dispatchSummaryTask(301L, null, null);
|
||||||
|
|
@ -484,6 +484,7 @@ class MeetingCommandServiceImplTest {
|
||||||
command.setAsrModelId(11L);
|
command.setAsrModelId(11L);
|
||||||
command.setSummaryModelId(22L);
|
command.setSummaryModelId(22L);
|
||||||
command.setPromptId(33L);
|
command.setPromptId(33L);
|
||||||
|
command.setUserPrompt("聚焦关键风险");
|
||||||
|
|
||||||
service.createMeeting(command, 1L, 7L, "creator");
|
service.createMeeting(command, 1L, 7L, "creator");
|
||||||
|
|
||||||
|
|
@ -494,7 +495,7 @@ class MeetingCommandServiceImplTest {
|
||||||
Object asrModelId = task.getTaskConfig().get("asrModelId");
|
Object asrModelId = task.getTaskConfig().get("asrModelId");
|
||||||
return Long.valueOf(101L).equals(asrModelId);
|
return Long.valueOf(101L).equals(asrModelId);
|
||||||
}));
|
}));
|
||||||
verify(meetingDomainSupport).createSummaryTask(808L, 202L, 303L);
|
verify(meetingDomainSupport).createSummaryTask(808L, 202L, 303L, "聚焦关键风险");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -551,10 +552,11 @@ class MeetingCommandServiceImplTest {
|
||||||
command.setEnableItn(false);
|
command.setEnableItn(false);
|
||||||
command.setEnableTextRefine(true);
|
command.setEnableTextRefine(true);
|
||||||
command.setSaveAudio(true);
|
command.setSaveAudio(true);
|
||||||
|
command.setUserPrompt("关注待办事项");
|
||||||
|
|
||||||
service.createRealtimeMeeting(command, 1L, 7L, "creator");
|
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 ->
|
verify(sessionStateService).rememberResumeConfig(eq(909L), argThat(config ->
|
||||||
Long.valueOf(111L).equals(config.getAsrModelId())
|
Long.valueOf(111L).equals(config.getAsrModelId())
|
||||||
&& "online".equals(config.getMode())
|
&& "online".equals(config.getMode())
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
package com.imeeting.service.biz.impl;
|
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.mapper.biz.MeetingTranscriptMapper;
|
||||||
import com.imeeting.service.biz.AiTaskService;
|
import com.imeeting.service.biz.AiTaskService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
|
||||||
import com.unisbase.mapper.SysUserMapper;
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.Mockito;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
|
@ -17,11 +19,15 @@ import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class MeetingDomainSupportTest {
|
class MeetingDomainSupportTest {
|
||||||
|
|
||||||
|
|
@ -74,10 +80,107 @@ class MeetingDomainSupportTest {
|
||||||
assertFalse(hasBackupFile(tempDir.resolve("uploads/meetings/102")));
|
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() {
|
private MeetingDomainSupport newSupport() {
|
||||||
|
return newSupport(mock(AiTaskService.class), mock(MeetingSummaryPromptAssembler.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingDomainSupport newSupport(AiTaskService aiTaskService, MeetingSummaryPromptAssembler assembler) {
|
||||||
MeetingDomainSupport support = new MeetingDomainSupport(
|
MeetingDomainSupport support = new MeetingDomainSupport(
|
||||||
mock(PromptTemplateService.class),
|
assembler,
|
||||||
mock(AiTaskService.class),
|
aiTaskService,
|
||||||
mock(MeetingTranscriptMapper.class),
|
mock(MeetingTranscriptMapper.class),
|
||||||
mock(SysUserMapper.class),
|
mock(SysUserMapper.class),
|
||||||
mock(ApplicationEventPublisher.class),
|
mock(ApplicationEventPublisher.class),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
@ -46,7 +47,7 @@ class MeetingRuntimeProfileResolverImplTest {
|
||||||
null,
|
null,
|
||||||
Boolean.TRUE,
|
Boolean.TRUE,
|
||||||
Boolean.TRUE,
|
Boolean.TRUE,
|
||||||
List.of(" alpha ", "", "alpha", "beta", null)
|
Arrays.asList(" alpha ", "", "alpha", "beta", null)
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(11L, profile.getResolvedAsrModelId());
|
assertEquals(11L, profile.getResolvedAsrModelId());
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ export async function uploadClientPackage(platformCode: string, file: File) {
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
const resp = await http.post("/api/clients/upload", formData, {
|
const resp = await http.post("/api/clients/upload", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
timeout: 600000
|
||||||
});
|
});
|
||||||
return resp.data.data as ClientUploadResult;
|
return resp.data.data as ClientUploadResult;
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +68,7 @@ export async function uploadExternalAppApk(file: File) {
|
||||||
formData.append("apkFile", file);
|
formData.append("apkFile", file);
|
||||||
const resp = await http.post("/api/external-apps/upload-apk", formData, {
|
const resp = await http.post("/api/external-apps/upload-apk", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
timeout: 600000
|
||||||
});
|
});
|
||||||
return resp.data.data as ExternalAppApkUploadResult;
|
return resp.data.data as ExternalAppApkUploadResult;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export interface MeetingVO {
|
||||||
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
|
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
|
||||||
audioSaveMessage?: string;
|
audioSaveMessage?: string;
|
||||||
accessPassword?: string;
|
accessPassword?: string;
|
||||||
|
lastUserPrompt?: string;
|
||||||
summaryContent: string;
|
summaryContent: string;
|
||||||
analysis?: {
|
analysis?: {
|
||||||
overview?: string;
|
overview?: string;
|
||||||
|
|
@ -46,6 +47,7 @@ export interface CreateMeetingCommand {
|
||||||
asrModelId: number;
|
asrModelId: number;
|
||||||
summaryModelId?: number;
|
summaryModelId?: number;
|
||||||
promptId: number;
|
promptId: number;
|
||||||
|
userPrompt?: string;
|
||||||
useSpkId?: number;
|
useSpkId?: number;
|
||||||
enableTextRefine?: boolean;
|
enableTextRefine?: boolean;
|
||||||
hotWords?: string[];
|
hotWords?: string[];
|
||||||
|
|
@ -63,6 +65,7 @@ export interface CreateRealtimeMeetingCommand {
|
||||||
asrModelId: number;
|
asrModelId: number;
|
||||||
summaryModelId?: number;
|
summaryModelId?: number;
|
||||||
promptId: number;
|
promptId: number;
|
||||||
|
userPrompt?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
useSpkId?: number;
|
useSpkId?: number;
|
||||||
|
|
@ -284,6 +287,7 @@ export interface MeetingResummaryDTO {
|
||||||
meetingId: number;
|
meetingId: number;
|
||||||
summaryModelId: number;
|
summaryModelId: number;
|
||||||
promptId: number;
|
promptId: number;
|
||||||
|
userPrompt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reSummary = (params: MeetingResummaryDTO) => {
|
export const reSummary = (params: MeetingResummaryDTO) => {
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,18 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
<Form.Item
|
||||||
|
name="userPrompt"
|
||||||
|
label="用户提示词"
|
||||||
|
extra="可选,用于补充本次会议总结的关注重点、表达偏好或输出侧重点"
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="例如:请重点关注待办事项、风险点,并用适合汇报的表达方式输出"
|
||||||
|
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||||
|
showCount
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,10 +207,11 @@
|
||||||
|
|
||||||
.role-list-pagination {
|
.role-list-pagination {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
padding-top: 16px;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
margin-left: -16px;
|
border-top: 1px solid #f1f5f9;
|
||||||
margin-right: -16px;
|
display: flex;
|
||||||
margin-bottom: -16px;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail Card Adjustments */
|
/* Detail Card Adjustments */
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import AppPagination from "@/components/shared/AppPagination";
|
||||||
import { useDict } from "@/hooks/useDict";
|
import { useDict } from "@/hooks/useDict";
|
||||||
import { usePermission } from "@/hooks/usePermission";
|
import { usePermission } from "@/hooks/usePermission";
|
||||||
import PageHeader from "@/components/shared/PageHeader";
|
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 type { SysOrg, SysRole, SysTenant, SysUser } from "@/types";
|
||||||
import "./index.less";
|
import "./index.less";
|
||||||
|
|
||||||
|
|
@ -236,6 +238,9 @@ export default function Users() {
|
||||||
const url = await uploadPlatformAsset(file);
|
const url = await uploadPlatformAsset(file);
|
||||||
form.setFieldValue("avatarUrl", url);
|
form.setFieldValue("avatarUrl", url);
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : t("common.error"));
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarUploading(false);
|
setAvatarUploading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -253,7 +258,8 @@ export default function Users() {
|
||||||
phone: values.phone,
|
phone: values.phone,
|
||||||
avatarUrl: values.avatarUrl,
|
avatarUrl: values.avatarUrl,
|
||||||
status: values.status,
|
status: values.status,
|
||||||
isPlatformAdmin: values.isPlatformAdmin
|
isPlatformAdmin: values.isPlatformAdmin,
|
||||||
|
roleIds: values.roleIds || []
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isPlatformMode) {
|
if (!isPlatformMode) {
|
||||||
|
|
@ -266,18 +272,10 @@ export default function Users() {
|
||||||
userPayload.password = values.password;
|
userPayload.password = values.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
let userId = editing?.userId;
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await updateUser(editing.userId, userPayload);
|
await updateUser(editing.userId, userPayload);
|
||||||
} else {
|
} else {
|
||||||
await createUser(userPayload);
|
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"));
|
message.success(t("common.success"));
|
||||||
|
|
@ -405,11 +403,11 @@ export default function Users() {
|
||||||
<AppPagination current={current} pageSize={pageSize} total={filteredData.length} onChange={(page, size) => { setCurrent(page); setPageSize(size); }} />
|
<AppPagination current={current} pageSize={pageSize} total={filteredData.length} onChange={(page, size) => { setCurrent(page); setPageSize(size); }} />
|
||||||
</Card>
|
</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">
|
<Form form={form} layout="vertical" className="user-form">
|
||||||
<Title level={5} style={{ marginBottom: 16 }}>{t("usersExt.basicInfo")}</Title>
|
<Title level={5} style={{ marginBottom: 16 }}>{t("usersExt.basicInfo")}</Title>
|
||||||
<Row gutter={16}>
|
<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>
|
<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>
|
||||||
<Row gutter={16}>
|
<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 { LockOutlined, ReloadOutlined, SafetyOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
@ -24,7 +24,6 @@ export default function Login() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [platformConfig, setPlatformConfig] = useState<SysPlatformConfig | null>(null);
|
const [platformConfig, setPlatformConfig] = useState<SysPlatformConfig | null>(null);
|
||||||
const [form] = Form.useForm<LoginFormValues>();
|
const [form] = Form.useForm<LoginFormValues>();
|
||||||
const { message } = App.useApp();
|
|
||||||
|
|
||||||
const loadCaptcha = useCallback(async () => {
|
const loadCaptcha = useCallback(async () => {
|
||||||
if (!captchaEnabled) {
|
if (!captchaEnabled) {
|
||||||
|
|
@ -87,11 +86,8 @@ export default function Login() {
|
||||||
try {
|
try {
|
||||||
const profile = await getCurrentUser();
|
const profile = await getCurrentUser();
|
||||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||||
localStorage.setItem("displayName", profile.displayName || profile.username || values.username);
|
|
||||||
localStorage.setItem("username", profile.username || values.username);
|
|
||||||
} catch {
|
} catch {
|
||||||
sessionStorage.removeItem("userProfile");
|
sessionStorage.removeItem("userProfile");
|
||||||
localStorage.removeItem("displayName");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
|
|
@ -228,8 +224,8 @@ export default function Login() {
|
||||||
|
|
||||||
<div className="login-footer">
|
<div className="login-footer">
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
{/*{t("login.demoAccount")} <Text strong className="tabular-nums">admin</Text> / {t("login.password")}{" "}*/}
|
{t("login.demoAccount")} <Text strong className="tabular-nums">admin</Text> / {t("login.password")}{" "}
|
||||||
{/*<Text strong className="tabular-nums">123456</Text>*/}
|
<Text strong className="tabular-nums">123456</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</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 { LockOutlined, LogoutOutlined } from "@ant-design/icons";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
@ -13,7 +13,6 @@ type ResetPasswordFormValues = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ResetPassword() {
|
export default function ResetPassword() {
|
||||||
const { message } = App.useApp();
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm<ResetPasswordFormValues>();
|
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 type { DataNode } from "antd/es/tree";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ClusterOutlined, KeyOutlined, SafetyCertificateOutlined, SaveOutlined, SearchOutlined } from "@ant-design/icons";
|
import { ClusterOutlined, KeyOutlined, SafetyCertificateOutlined, SaveOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "@/api";
|
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "@/api";
|
||||||
import PageHeader from "@/components/shared/PageHeader";
|
import PageHeader from "@/components/shared/PageHeader";
|
||||||
import { getStandardPagination } from "@/utils/pagination";
|
|
||||||
import type { SysPermission, SysRole } from "@/types";
|
import type { SysPermission, SysRole } from "@/types";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
@ -64,7 +76,6 @@ function toTreeData(nodes: PermissionNode[]): DataNode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RolePermissionBinding() {
|
export default function RolePermissionBinding() {
|
||||||
const { message } = App.useApp();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [roles, setRoles] = useState<SysRole[]>([]);
|
const [roles, setRoles] = useState<SysRole[]>([]);
|
||||||
const [permissions, setPermissions] = useState<SysPermission[]>([]);
|
const [permissions, setPermissions] = useState<SysPermission[]>([]);
|
||||||
|
|
@ -82,7 +93,7 @@ export default function RolePermissionBinding() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const profile = JSON.parse(profileStr);
|
const profile = JSON.parse(profileStr);
|
||||||
return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0";
|
return !!profile.isPlatformAdmin;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedRole = useMemo(
|
const selectedRole = useMemo(
|
||||||
|
|
@ -182,7 +193,7 @@ export default function RolePermissionBinding() {
|
||||||
|
|
||||||
<Row gutter={24} className="app-page__split" style={{ height: "calc(100vh - 180px)" }}>
|
<Row gutter={24} className="app-page__split" style={{ height: "calc(100vh - 180px)" }}>
|
||||||
<Col xs={24} lg={10} style={{ height: "100%" }}>
|
<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">
|
<div className="mb-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("rolePerm.searchRole")}
|
placeholder={t("rolePerm.searchRole")}
|
||||||
|
|
@ -193,13 +204,12 @@ export default function RolePermissionBinding() {
|
||||||
aria-label={t("rolePerm.searchRole")}
|
aria-label={t("rolePerm.searchRole")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ height: "calc(100% - 60px)", overflowY: "auto" }}>
|
||||||
<Table
|
<Table
|
||||||
rowKey="roleId"
|
rowKey="roleId"
|
||||||
size="middle"
|
size="middle"
|
||||||
loading={loadingRoles}
|
loading={loadingRoles}
|
||||||
dataSource={filteredRoles}
|
dataSource={filteredRoles}
|
||||||
scroll={{ y: "calc(100vh - 370px)" }}
|
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
type: "radio",
|
type: "radio",
|
||||||
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
|
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
|
||||||
|
|
@ -209,7 +219,7 @@ export default function RolePermissionBinding() {
|
||||||
onClick: () => setSelectedRoleId(record.roleId),
|
onClick: () => setSelectedRoleId(record.roleId),
|
||||||
className: "cursor-pointer"
|
className: "cursor-pointer"
|
||||||
})}
|
})}
|
||||||
pagination={getStandardPagination(filteredRoles.length, 1, 10)}
|
pagination={{ pageSize: 10, showTotal: (total) => t("common.total", { total }) }}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: t("roles.roleName"),
|
title: t("roles.roleName"),
|
||||||
|
|
@ -237,7 +247,6 @@ export default function RolePermissionBinding() {
|
||||||
<Card
|
<Card
|
||||||
title={<Space><KeyOutlined aria-hidden="true" /><span>{t("rolePerm.permConfig")}</span></Space>}
|
title={<Space><KeyOutlined aria-hidden="true" /><span>{t("rolePerm.permConfig")}</span></Space>}
|
||||||
className="app-page__panel-card full-height-card"
|
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}
|
extra={selectedRole ? <Tag color="blue">{t("rolePerm.currentRole")}: {selectedRole.roleName}</Tag> : null}
|
||||||
>
|
>
|
||||||
{selectedRoleId ? (
|
{selectedRoleId ? (
|
||||||
|
|
@ -270,3 +279,4 @@ export default function RolePermissionBinding() {
|
||||||
</div>
|
</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 { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "@/api";
|
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "@/api";
|
||||||
import { SaveOutlined, SearchOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
|
import { SaveOutlined, SearchOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
import { getStandardPagination } from "@/utils/pagination";
|
|
||||||
import type { SysRole, SysUser } from "@/types";
|
import type { SysRole, SysUser } from "@/types";
|
||||||
import PageHeader from "@/components/shared/PageHeader";
|
import PageHeader from "@/components/shared/PageHeader";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
export default function UserRoleBinding() {
|
export default function UserRoleBinding() {
|
||||||
const { message } = App.useApp();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [users, setUsers] = useState<SysUser[]>([]);
|
const [users, setUsers] = useState<SysUser[]>([]);
|
||||||
const [roles, setRoles] = useState<SysRole[]>([]);
|
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)" }}>
|
<Row gutter={24} className="app-page__split" style={{ height: "calc(100vh - 180px)" }}>
|
||||||
<Col xs={24} lg={12} style={{ height: "100%" }}>
|
<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">
|
<div className="mb-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("userRole.searchUser")}
|
placeholder={t("userRole.searchUser")}
|
||||||
|
|
@ -113,13 +124,12 @@ export default function UserRoleBinding() {
|
||||||
aria-label={t("userRole.searchUser")}
|
aria-label={t("userRole.searchUser")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ height: "calc(100% - 60px)", overflowY: "auto" }}>
|
||||||
<Table
|
<Table
|
||||||
rowKey="userId"
|
rowKey="userId"
|
||||||
size="middle"
|
size="middle"
|
||||||
loading={loadingUsers}
|
loading={loadingUsers}
|
||||||
dataSource={filteredUsers}
|
dataSource={filteredUsers}
|
||||||
scroll={{ y: "calc(100vh - 370px)" }}
|
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
type: "radio",
|
type: "radio",
|
||||||
selectedRowKeys: selectedUserId ? [selectedUserId] : [],
|
selectedRowKeys: selectedUserId ? [selectedUserId] : [],
|
||||||
|
|
@ -129,7 +139,7 @@ export default function UserRoleBinding() {
|
||||||
onClick: () => setSelectedUserId(record.userId),
|
onClick: () => setSelectedUserId(record.userId),
|
||||||
className: "cursor-pointer"
|
className: "cursor-pointer"
|
||||||
})}
|
})}
|
||||||
pagination={getStandardPagination(filteredUsers.length, 1, 10)}
|
pagination={{ pageSize: 10, showTotal: (total) => t("common.total", { total }) }}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: t("users.userInfo"),
|
title: t("users.userInfo"),
|
||||||
|
|
@ -157,11 +167,10 @@ export default function UserRoleBinding() {
|
||||||
<Card
|
<Card
|
||||||
title={<Space><TeamOutlined aria-hidden="true" /><span>{t("userRole.grantRoles")}</span></Space>}
|
title={<Space><TeamOutlined aria-hidden="true" /><span>{t("userRole.grantRoles")}</span></Space>}
|
||||||
className="app-page__panel-card full-height-card"
|
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}
|
extra={selectedUser ? <Tag color="blue">{t("userRole.editing")}: {selectedUser.displayName}</Tag> : null}
|
||||||
>
|
>
|
||||||
{selectedUserId ? (
|
{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}>
|
<Checkbox.Group style={{ width: "100%" }} value={checkedRoleIds} onChange={(values) => setCheckedRoleIds(values as number[])} disabled={loadingRoles}>
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Alert, Avatar, 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 {
|
import {
|
||||||
AudioOutlined,
|
AudioOutlined,
|
||||||
CaretRightFilled,
|
CaretRightFilled,
|
||||||
|
|
@ -19,6 +19,10 @@ import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
CheckCircleFilled,
|
CheckCircleFilled,
|
||||||
|
EllipsisOutlined,
|
||||||
|
FilePdfOutlined,
|
||||||
|
FileWordOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
@ -775,6 +779,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
meetingId: Number(id),
|
meetingId: Number(id),
|
||||||
summaryModelId: values.summaryModelId,
|
summaryModelId: values.summaryModelId,
|
||||||
promptId: values.promptId,
|
promptId: values.promptId,
|
||||||
|
userPrompt: values.userPrompt,
|
||||||
});
|
});
|
||||||
message.success('已重新发起总结任务');
|
message.success('已重新发起总结任务');
|
||||||
setSummaryVisible(false);
|
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 () => {
|
const handleRetryTranscription = async () => {
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -1198,31 +1215,46 @@ const MeetingDetail: React.FC = () => {
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<Space>
|
<Space size={8}>
|
||||||
|
{canRetrySummary && (
|
||||||
|
<Button icon={<SyncOutlined />} type="primary" ghost onClick={handleOpenSummaryDrawer} disabled={actionLoading}>
|
||||||
|
重新总结
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canRetryTranscription && (
|
{canRetryTranscription && (
|
||||||
<Button icon={<SyncOutlined />} type="primary" onClick={handleRetryTranscription} loading={actionLoading}>
|
<Button icon={<SyncOutlined />} type="primary" onClick={handleRetryTranscription} loading={actionLoading}>
|
||||||
重新识别
|
重新识别
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canRetrySummary && (
|
|
||||||
<Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>
|
|
||||||
重新总结
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isOwner && meeting.status === 2 && (
|
{isOwner && meeting.status === 2 && (
|
||||||
<Button icon={<LoadingOutlined />} type="primary" ghost disabled loading>
|
<Button icon={<LoadingOutlined />} type="primary" ghost disabled loading>
|
||||||
正在总结
|
正在总结
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{meeting.status === 3 && !!meeting.summaryContent && (
|
{meeting.status === 3 && !!meeting.summaryContent && (
|
||||||
<>
|
<Dropdown
|
||||||
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('pdf')} loading={downloadLoading === 'pdf'}>
|
menu={{
|
||||||
下载 PDF
|
items: [
|
||||||
</Button>
|
{
|
||||||
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('word')} loading={downloadLoading === 'word'}>
|
key: 'pdf',
|
||||||
下载 Word
|
label: '下载 PDF',
|
||||||
</Button>
|
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 ? (
|
{shareQrContent ? (
|
||||||
<Popover
|
<Popover
|
||||||
|
|
@ -1233,13 +1265,13 @@ const MeetingDetail: React.FC = () => {
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
overlayClassName="meeting-share-popover"
|
overlayClassName="meeting-share-popover"
|
||||||
>
|
>
|
||||||
<Button icon={<QrcodeOutlined />}>
|
<Button icon={<ShareAltOutlined />}>
|
||||||
二维码
|
分享
|
||||||
</Button>
|
</Button>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : null}
|
) : null}
|
||||||
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
|
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
|
||||||
返回列表
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -2363,6 +2395,18 @@ const MeetingDetail: React.FC = () => {
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="userPrompt"
|
||||||
|
label="用户提示词"
|
||||||
|
extra="可选,用于补充本次重新总结的关注重点、表达偏好或输出侧重点"
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="例如:请重点突出结论、待办事项和风险点"
|
||||||
|
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||||
|
showCount
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Text type="secondary">重新总结会基于当前语音转录全文重新生成纪要,原有总结内容将被覆盖。</Text>
|
<Text type="secondary">重新总结会基于当前语音转录全文重新生成纪要,原有总结内容将被覆盖。</Text>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
UserOutlined
|
UserOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
UnorderedListOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
|
|
@ -28,7 +30,8 @@ import {
|
||||||
Space,
|
Space,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography
|
Typography,
|
||||||
|
Table
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
|
|
@ -97,7 +100,6 @@ const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
|
||||||
const res = await getMeetingProgress(meeting.id);
|
const res = await getMeetingProgress(meeting.id);
|
||||||
if (res.data && res.data.data) {
|
if (res.data && res.data.data) {
|
||||||
setProgress(res.data.data);
|
setProgress(res.data.data);
|
||||||
// 当达到 100% 时触发完成回调
|
|
||||||
if (res.data.data.percent === 100 && onComplete) {
|
if (res.data.data.percent === 100 && onComplete) {
|
||||||
onComplete();
|
onComplete();
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +129,6 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgr
|
||||||
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2;
|
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2;
|
||||||
return (
|
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' }}>
|
<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 && (
|
{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 }} />
|
<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 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 progress = useMeetingProgress(item, () => fetchData());
|
||||||
const effectiveStatus = item.displayStatus ?? item.status;
|
const effectiveStatus = item.displayStatus ?? item.status;
|
||||||
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2;
|
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>
|
<Tooltip title="编辑参会人"><div className="icon-btn edit"><EditOutlined onClick={() => onEditParticipants(item)} /></div></Tooltip>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定删除?"
|
title="确定删除?"
|
||||||
onConfirm={() => deleteMeeting(item.id).then(fetchData)}
|
onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(item.id).then(fetchData); }}
|
||||||
okText={t('common.confirm')}
|
okText={t('common.confirm')}
|
||||||
cancelText={t('common.cancel')}
|
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>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -252,10 +257,10 @@ const Meetings: React.FC = () => {
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
|
||||||
const [data, setData] = useState<MeetingVO[]>([]);
|
const [data, setData] = useState<MeetingVO[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [current, setCurrent] = useState(1);
|
const [current, setCurrent] = useState(1);
|
||||||
|
const [displayMode, setDisplayMode] = useState<'card' | 'list'>('card');
|
||||||
const [size, setSize] = useState(8);
|
const [size, setSize] = useState(8);
|
||||||
const [searchTitle, setSearchTitle] = useState('');
|
const [searchTitle, setSearchTitle] = useState('');
|
||||||
const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all');
|
const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all');
|
||||||
|
|
@ -271,6 +276,12 @@ const Meetings: React.FC = () => {
|
||||||
return effectiveStatus === 0 || effectiveStatus === 1 || effectiveStatus === 2;
|
return effectiveStatus === 0 || effectiveStatus === 1 || effectiveStatus === 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleDisplayModeChange = (mode: 'card' | 'list') => {
|
||||||
|
setDisplayMode(mode);
|
||||||
|
setSize(mode === 'card' ? 8 : 10);
|
||||||
|
setCurrent(1);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const action = searchParams.get('action');
|
const action = searchParams.get('action');
|
||||||
const type = searchParams.get('type') as MeetingCreateType;
|
const type = searchParams.get('type') as MeetingCreateType;
|
||||||
|
|
@ -326,8 +337,6 @@ const Meetings: React.FC = () => {
|
||||||
navigate(`/meetings/${meeting.id}`);
|
navigate(`/meetings/${meeting.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const openEditParticipants = (meeting: MeetingVO) => {
|
const openEditParticipants = (meeting: MeetingVO) => {
|
||||||
setEditingMeeting(meeting);
|
setEditingMeeting(meeting);
|
||||||
participantsEditForm.setFieldsValue({
|
participantsEditForm.setFieldsValue({
|
||||||
|
|
@ -365,6 +374,59 @@ const Meetings: React.FC = () => {
|
||||||
5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' }
|
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 (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<Card
|
<Card
|
||||||
|
|
@ -379,6 +441,10 @@ const Meetings: React.FC = () => {
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Space size={16} wrap>
|
<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.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.Button value="all">全部</Radio.Button><Radio.Button value="created">我发起</Radio.Button><Radio.Button value="involved">我参与</Radio.Button>
|
||||||
</Radio.Group>
|
</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" }}>
|
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflowX: "hidden", overflowY: "auto", padding: "24px" }}>
|
||||||
|
{displayMode === 'card' ? (
|
||||||
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
|
<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) => {
|
<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];
|
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0];
|
||||||
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} onOpenMeeting={handleOpenMeeting} />;
|
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} onOpenMeeting={handleOpenMeeting} />;
|
||||||
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
|
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
|
||||||
</Skeleton>
|
</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>
|
</div>
|
||||||
|
|
||||||
<AppPagination current={current} pageSize={size} total={total} onChange={(p, s) => { setCurrent(p); setSize(s); }} />
|
<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, Typography, Table, Tag, Skeleton, Button } from "antd";
|
||||||
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
|
|
||||||
import {
|
import {
|
||||||
HistoryOutlined,
|
VideoCameraOutlined,
|
||||||
CheckCircleOutlined,
|
DesktopOutlined,
|
||||||
LoadingOutlined,
|
UserOutlined,
|
||||||
AudioOutlined,
|
|
||||||
RobotOutlined,
|
|
||||||
CalendarOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
RiseOutlined,
|
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
PlayCircleOutlined,
|
CheckCircleOutlined,
|
||||||
FileTextOutlined,
|
SyncOutlined,
|
||||||
} from '@ant-design/icons';
|
ArrowRightOutlined
|
||||||
import { useNavigate } from 'react-router-dom';
|
} from "@ant-design/icons";
|
||||||
import dayjs from 'dayjs';
|
import { useTranslation } from "react-i18next";
|
||||||
import { getDashboardStats, getRecentTasks, DashboardStats } from '@/api/business/dashboard';
|
import StatCard from "@/components/shared/StatCard/StatCard";
|
||||||
import { MeetingVO, getMeetingProgress, MeetingProgress } from '@/api/business/meeting';
|
import PageHeader from "@/components/shared/PageHeader";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => {
|
export default function Dashboard() {
|
||||||
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
const recentMeetings = [
|
||||||
if (meeting.status !== 1 && meeting.status !== 2) return;
|
{ 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 () => {
|
const columns = [
|
||||||
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: '语音转录',
|
title: t("dashboard.meetingName"),
|
||||||
icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />,
|
dataIndex: "name",
|
||||||
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转录中' : '排队中')
|
key: "name",
|
||||||
|
render: (text: string) => <Text strong>{text}</Text>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '智能总结',
|
title: t("dashboard.startTime"),
|
||||||
icon: item.status === 2 ? <LoadingOutlined spin /> : <RobotOutlined />,
|
dataIndex: "time",
|
||||||
description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在生成' : '待执行')
|
key: "time",
|
||||||
|
className: "tabular-nums",
|
||||||
|
render: (text: string) => <Text type="secondary">{text}</Text>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '分析完成',
|
title: t("dashboard.duration"),
|
||||||
icon: item.status === 3 ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : <FileTextOutlined />,
|
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>;
|
||||||
}
|
}
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{ label: '累计会议记录', value: stats?.totalMeetings, icon: <HistoryOutlined />, color: '#1890ff' },
|
|
||||||
{
|
|
||||||
label: '当前分析中任务',
|
|
||||||
value: stats?.processingTasks,
|
|
||||||
icon: processingCount > 0 ? <LoadingOutlined spin /> : <ClockCircleOutlined />,
|
|
||||||
color: '#faad14'
|
|
||||||
},
|
},
|
||||||
{ label: '今日新增分析', value: stats?.todayNew, icon: <RiseOutlined />, color: '#52c41a' },
|
{
|
||||||
{ label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: <CheckCircleOutlined />, color: '#13c2c2' },
|
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 (
|
return (
|
||||||
<div style={{ padding: '24px', background: 'var(--app-bg-page)', minHeight: '100%', overflowY: 'auto' }}>
|
<div className="app-page dashboard-page">
|
||||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
<PageHeader
|
||||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
title={t("dashboard.title")}
|
||||||
{statCards.map((s, idx) => (
|
subtitle={t("dashboard.subtitle")}
|
||||||
<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>
|
|
||||||
|
|
||||||
<Card
|
<div className="app-page__page-actions">
|
||||||
title={
|
<Button icon={<SyncOutlined aria-hidden="true" />} size="small">{t("common.refresh")}</Button>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Space><ClockCircleOutlined /> 最近任务动态</Space>
|
|
||||||
<Button type="link" onClick={() => navigate('/meetings')}>查看历史记录</Button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
variant="borderless"
|
|
||||||
style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}
|
|
||||||
>
|
|
||||||
<List
|
|
||||||
loading={dashboardLoading}
|
|
||||||
dataSource={recentTasks}
|
|
||||||
renderItem={(item) => (
|
|
||||||
<List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}>
|
|
||||||
<div style={{ width: '100%' }}>
|
|
||||||
<Row gutter={32} align="middle">
|
|
||||||
<Col span={8}>
|
|
||||||
<Space direction="vertical" size={4}>
|
|
||||||
<Title level={5} style={{ margin: 0, cursor: 'pointer' }} onClick={() => navigate(`/meetings/${item.id}`)}>
|
|
||||||
{item.title}
|
|
||||||
</Title>
|
|
||||||
<Space size={12} split={<Divider type="vertical" style={{ margin: 0 }} />}>
|
|
||||||
<Text type="secondary"><CalendarOutlined /> {dayjs(item.meetingTime).format('MM-DD HH:mm')}</Text>
|
|
||||||
<Text type="secondary"><TeamOutlined /> {item.participants || item.creatorName || '未指定'}</Text>
|
|
||||||
</Space>
|
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
{item.tags?.split(',').filter(Boolean).map((t) => (
|
|
||||||
<Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col span={12}>
|
<Row gutter={[24, 24]}>
|
||||||
{renderTaskProgress(item)}
|
<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>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Col span={4} style={{ textAlign: 'right' }}>
|
<StatCard title={t("dashboard.activeDevices")} value={45} icon={<DesktopOutlined aria-hidden="true" />} color="green" trend={{ value: 2, direction: "up" }} />
|
||||||
<Button
|
</Col>
|
||||||
type={item.status === 3 ? 'primary' : 'default'}
|
<Col xs={24} sm={12} lg={6}>
|
||||||
ghost={item.status === 3}
|
<StatCard title={t("dashboard.transcriptionDuration")} value={1280} suffix="min" icon={<ClockCircleOutlined aria-hidden="true" />} color="orange" trend={{ value: 5, direction: "down" }} />
|
||||||
icon={item.status === 3 ? <FileTextOutlined /> : <PlayCircleOutlined />}
|
</Col>
|
||||||
onClick={() => navigate(`/meetings/${item.id}`)}
|
<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" }} />
|
||||||
{item.status === 3 ? '查看纪要' : '监控详情'}
|
|
||||||
</Button>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<MeetingProgressDisplay meeting={item} />
|
<Row gutter={[24, 24]} className="mt-6">
|
||||||
</div>
|
<Col xs={24} xl={16}>
|
||||||
</List.Item>
|
<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" />
|
||||||
locale={{ emptyText: <Empty description="暂无近期分析任务" /> }}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} xl={8}>
|
||||||
|
<Card title={t("dashboard.deviceLoad")} bordered={false} className="app-page__content-card">
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
|
<div className="mt-4 text-gray-400 flex items-center gap-2">
|
||||||
|
<SyncOutlined spin aria-hidden="true" />
|
||||||
|
<span>{t("dashboardExt.chartLoading")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<style>{`
|
</Card>
|
||||||
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
|
</Col>
|
||||||
.ant-steps-item-description { font-size: 11px !important; }
|
</Row>
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Dashboard;
|
|
||||||
|
|
@ -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 { DeleteOutlined, DesktopOutlined, EditOutlined, PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
@ -20,7 +20,6 @@ type DeviceFormValues = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Devices() {
|
export default function Devices() {
|
||||||
const { message } = App.useApp();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const { items: statusDict } = useDict("sys_common_status");
|
const { items: statusDict } = useDict("sys_common_status");
|
||||||
|
|
@ -155,13 +154,12 @@ export default function Devices() {
|
||||||
</Card>
|
</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" } }}>
|
<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>
|
<Table<DeviceInfo>
|
||||||
rowKey="deviceId"
|
rowKey="deviceId"
|
||||||
dataSource={filteredData}
|
dataSource={filteredData}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
size="middle"
|
size="middle"
|
||||||
scroll={{ x: "max-content", y: "calc(100vh - 350px)" }}
|
scroll={{ y: "calc(100vh - 350px)" }}
|
||||||
pagination={getStandardPagination(filteredData.length, 1, 1000)}
|
pagination={getStandardPagination(filteredData.length, 1, 1000)}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
|
|
@ -236,7 +234,6 @@ export default function Devices() {
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
|
|
@ -249,7 +246,7 @@ export default function Devices() {
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
width={420}
|
width={420}
|
||||||
destroyOnHidden
|
destroyOnClose
|
||||||
footer={
|
footer={
|
||||||
<div className="app-page__drawer-footer">
|
<div className="app-page__drawer-footer">
|
||||||
<Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button>
|
<Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button>
|
||||||
|
|
|
||||||
|
|
@ -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 { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApartmentOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, ShopOutlined } from "@ant-design/icons";
|
import { ApartmentOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, ShopOutlined } from "@ant-design/icons";
|
||||||
|
|
@ -32,7 +32,6 @@ function buildOrgTree(list: SysOrg[]): OrgNode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Orgs() {
|
export default function Orgs() {
|
||||||
const { message } = App.useApp();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const { items: statusDict } = useDict("sys_common_status");
|
const { items: statusDict } = useDict("sys_common_status");
|
||||||
|
|
@ -185,7 +184,7 @@ export default function Orgs() {
|
||||||
)}
|
)}
|
||||||
</Card>
|
</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 form={form} layout="vertical">
|
||||||
<Form.Item label={t("users.tenant")} name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}>
|
<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 }))} />
|
<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 { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
|
import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { createTenant, deleteTenant, listTenants, updateTenant } from "@/api";
|
import { createTenant, deleteTenant, listTenants, updateTenant } from "@/api";
|
||||||
import AppPagination from "@/components/shared/AppPagination";
|
|
||||||
import { useDict } from "@/hooks/useDict";
|
import { useDict } from "@/hooks/useDict";
|
||||||
import { usePermission } from "@/hooks/usePermission";
|
import { usePermission } from "@/hooks/usePermission";
|
||||||
import PageHeader from "@/components/shared/PageHeader";
|
import PageHeader from "@/components/shared/PageHeader";
|
||||||
|
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
|
||||||
import type { SysTenant } from "@/types";
|
import type { SysTenant } from "@/types";
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
export default function Tenants() {
|
export default function Tenants() {
|
||||||
const { message } = App.useApp();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const { items: statusDict } = useDict("sys_common_status");
|
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 [params, setParams] = useState({ current: 1, size: 12, name: "", code: "" });
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<SysTenant | null>(null);
|
const [editing, setEditing] = useState<SysTenant | null>(null);
|
||||||
|
const [adminAccountTouched, setAdminAccountTouched] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const watchedTenantCode = Form.useWatch("tenantCode", form);
|
||||||
|
|
||||||
const loadData = async (currentParams = params) => {
|
const loadData = async (currentParams = params) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -55,6 +56,7 @@ export default function Tenants() {
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
|
setAdminAccountTouched(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({ status: 1 });
|
form.setFieldsValue({ status: 1 });
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
|
|
@ -62,10 +64,19 @@ export default function Tenants() {
|
||||||
|
|
||||||
const openEdit = (record: SysTenant) => {
|
const openEdit = (record: SysTenant) => {
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
|
setAdminAccountTouched(false);
|
||||||
form.setFieldsValue({ ...record, expireTime: record.expireTime ? dayjs(record.expireTime) : null });
|
form.setFieldsValue({ ...record, expireTime: record.expireTime ? dayjs(record.expireTime) : null });
|
||||||
setDrawerOpen(true);
|
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) => {
|
const handleDelete = async (id: number) => {
|
||||||
await deleteTenant(id);
|
await deleteTenant(id);
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
|
|
@ -76,7 +87,11 @@ export default function Tenants() {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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) {
|
if (editing) {
|
||||||
await updateTenant(editing.id, payload);
|
await updateTenant(editing.id, payload);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -147,26 +162,28 @@ export default function Tenants() {
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</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 overflow-y-auto pr-2">
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto" style={{ padding: "24px 24px 0" }}>
|
|
||||||
<List
|
<List
|
||||||
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
|
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
renderItem={renderTenantCard}
|
renderItem={renderTenantCard}
|
||||||
pagination={false}
|
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")} /> }}
|
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} /> }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AppPagination
|
|
||||||
current={params.current}
|
|
||||||
pageSize={params.size}
|
|
||||||
total={total}
|
|
||||||
onChange={(page, size) => setParams({ ...params, current: page, size: size || params.size })}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Drawer title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} 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">
|
<Form form={form} layout="vertical">
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
|
|
@ -180,6 +197,29 @@ export default function Tenants() {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item label={t("tenants.contactName")} name="contactName">
|
<Form.Item label={t("tenants.contactName")} name="contactName">
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ export default function Profile() {
|
||||||
const url = await uploadPlatformAsset(file);
|
const url = await uploadPlatformAsset(file);
|
||||||
profileForm.setFieldValue("avatarUrl", url);
|
profileForm.setFieldValue("avatarUrl", url);
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : t("common.error"));
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarUploading(false);
|
setAvatarUploading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>}>
|
<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 form={typeForm} layout="vertical">
|
||||||
<Form.Item label={t("dicts.typeCode")} name="typeCode" rules={[{ required: true, message: t("dicts.typeCode") }]}>
|
<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>
|
||||||
<Form.Item label={t("dicts.typeName")} name="typeName" rules={[{ required: true, message: t("dicts.typeName") }]}>
|
<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>
|
||||||
<Form.Item label={t("common.remark")} name="remark">
|
<Form.Item label={t("common.remark")} name="remark">
|
||||||
<Input.TextArea placeholder={t("dictsExt.typeRemarkPlaceholder")} rows={3} />
|
<Input.TextArea placeholder={t("dictsExt.typeRemarkPlaceholder")} rows={3} />
|
||||||
|
|
@ -259,7 +259,7 @@ export default function Dictionaries() {
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</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 form={itemForm} layout="vertical">
|
||||||
<Form.Item label={t("dicts.typeCode")} name="typeCode"><Input disabled className="tabular-nums" /></Form.Item>
|
<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>
|
<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 handleUpload = async (file: File, fieldName: keyof SysPlatformConfig) => {
|
||||||
|
try {
|
||||||
const url = await uploadPlatformAsset(file);
|
const url = await uploadPlatformAsset(file);
|
||||||
form.setFieldValue(fieldName, url);
|
form.setFieldValue(fieldName, url);
|
||||||
message.success(t("common.success"));
|
message.success(t("common.success"));
|
||||||
|
} catch (error) {
|
||||||
|
// message.error(error instanceof Error ? error.message : t("common.error"));
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from "@ant-design/icons";
|
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from "@ant-design/icons";
|
||||||
|
|
@ -13,7 +13,6 @@ import "./index.less";
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
export default function SysParams() {
|
export default function SysParams() {
|
||||||
const { message } = App.useApp();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const { items: statusDict } = useDict("sys_common_status");
|
const { items: statusDict } = useDict("sys_common_status");
|
||||||
|
|
@ -58,7 +57,7 @@ export default function SysParams() {
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({ isSystem: false, status: 1 });
|
form.setFieldsValue({ isSystem: 0, status: 1 });
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -110,8 +109,24 @@ export default function SysParams() {
|
||||||
title: t("sysParams.paramValue"),
|
title: t("sysParams.paramValue"),
|
||||||
dataIndex: "paramValue",
|
dataIndex: "paramValue",
|
||||||
key: "paramValue",
|
key: "paramValue",
|
||||||
ellipsis: true,
|
width: 360,
|
||||||
render: (text: string) => <Tooltip title={text}><Text code>{text}</Text></Tooltip>
|
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"),
|
title: t("sysParams.paramType"),
|
||||||
|
|
@ -173,18 +188,18 @@ export default function SysParams() {
|
||||||
</Card>
|
</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" } }}>
|
<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
|
<Table
|
||||||
rowKey="paramId"
|
rowKey="paramId"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
size="middle"
|
size="middle"
|
||||||
|
tableLayout="fixed"
|
||||||
scroll={{ x: "max-content" }}
|
scroll={{ x: "max-content" }}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<AppPagination current={queryParams.pageNum || 1} pageSize={queryParams.pageSize || 10} total={total} onChange={handlePageChange} />
|
<AppPagination current={queryParams.pageNum || 1} pageSize={queryParams.pageSize || 10} total={total} onChange={handlePageChange} />
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
|
|
@ -192,8 +207,7 @@ export default function SysParams() {
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
width={500}
|
width={500}
|
||||||
destroyOnHidden
|
destroyOnClose
|
||||||
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>}
|
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 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