feat: 添加用户提示支持和优化会议访问逻辑

- 在 `MeetingCommandService` 和 `MeetingDomainSupport` 中添加 `userPrompt` 参数
- 在 `MeetingAccessService` 和 `MeetingQueryService` 中添加忽略租户的会议查询方法
- 更新前端API和组件,支持用户提示功能
- 优化会议访问逻辑,包括预览密码验证和角色管理页面
- 添加相关单元测试以验证新功能的正确性
dev_na
chenhao 2026-04-17 10:08:40 +08:00
parent d4424a157b
commit 27ae0a3def
47 changed files with 845 additions and 612 deletions

View File

@ -4,4 +4,5 @@ public final class SysParamKeys {
private SysParamKeys() {}
public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt";
}

View File

@ -356,13 +356,13 @@ public class MeetingController {
@PostMapping("/{id}/summary/regenerate")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> reSummary(@PathVariable Long id, @RequestBody MeetingResummaryDTO dto) {
public ApiResponse<Boolean> reSummary(@PathVariable Long id, @Valid @RequestBody MeetingResummaryDTO dto) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
dto.setMeetingId(id);
assertPromptAvailable(dto.getPromptId(), loginUser);
meetingCommandService.reSummary(dto.getMeetingId(), dto.getSummaryModelId(), dto.getPromptId());
meetingCommandService.reSummary(dto.getMeetingId(), dto.getSummaryModelId(), dto.getPromptId(), dto.getUserPrompt());
return ApiResponse.ok(true);
}

View File

@ -28,7 +28,7 @@ public class MeetingPublicPreviewController {
@GetMapping("/{id}/preview/access")
public ApiResponse<MeetingPreviewAccessVO> getPreviewAccess(@PathVariable Long id) {
try {
Meeting meeting = meetingAccessService.requireMeeting(id);
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(id);
return ApiResponse.ok(new MeetingPreviewAccessVO(meetingAccessService.isPreviewPasswordRequired(meeting)));
} catch (RuntimeException ex) {
return ApiResponse.error(ex.getMessage());
@ -39,11 +39,11 @@ public class MeetingPublicPreviewController {
public ApiResponse<PublicMeetingPreviewVO> getPreview(@PathVariable Long id,
@RequestParam(required = false) String accessPassword) {
try {
Meeting meeting = meetingAccessService.requireMeeting(id);
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(id);
meetingAccessService.assertCanPreviewMeeting(meeting, accessPassword);
PublicMeetingPreviewVO data = new PublicMeetingPreviewVO();
data.setMeeting(meetingQueryService.getDetail(id));
data.setMeeting(meetingQueryService.getDetailIgnoreTenant(id));
if (data.getMeeting() != null) {
data.getMeeting().setAccessPassword(null);
}

View File

@ -3,6 +3,7 @@ package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
@ -34,6 +35,9 @@ public class CreateMeetingCommand {
@NotNull(message = "promptId must not be null")
private Long promptId;
@Size(max = 2000, message = "userPrompt length must be <= 2000")
private String userPrompt;
private Integer useSpkId;
private Boolean enableTextRefine;
private List<String> hotWords;

View File

@ -3,6 +3,7 @@ package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
@ -31,6 +32,9 @@ public class CreateRealtimeMeetingCommand {
@NotNull(message = "promptId must not be null")
private Long promptId;
@Size(max = 2000, message = "userPrompt length must be <= 2000")
private String userPrompt;
private String mode;
private String language;
private Integer useSpkId;

View File

@ -1,10 +1,14 @@
package com.imeeting.dto.biz;
import lombok.Data;
import jakarta.validation.constraints.Size;
@Data
public class MeetingResummaryDTO {
private Long meetingId;
private Long summaryModelId;
private Long promptId;
}
@Size(max = 2000, message = "userPrompt length must be <= 2000")
private String userPrompt;
}

View File

@ -28,6 +28,7 @@ public class MeetingVO {
private String accessPassword;
private Integer duration;
private String summaryContent;
private String lastUserPrompt;
private Map<String, Object> analysis;
private Integer status;

View File

@ -1,9 +1,15 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.Meeting;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface MeetingMapper extends BaseMapper<Meeting> {
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM biz_meetings WHERE id = #{id} AND is_deleted = 0")
Meeting selectByIdIgnoreTenant(@Param("id") Long id);
}

View File

@ -19,6 +19,7 @@ import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.impl.MeetingDomainSupport;
import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.unisbase.security.LoginUser;
import lombok.RequiredArgsConstructor;
@ -54,6 +55,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
private final MeetingDomainSupport meetingDomainSupport;
private final MeetingRuntimeProfileResolver runtimeProfileResolver;
private final PromptTemplateService promptTemplateService;
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
private final AiTaskService aiTaskService;
private final MeetingTranscriptMapper transcriptMapper;
private final LlmModelMapper llmModelMapper;
@ -272,13 +274,11 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
private void resetOrCreateSummaryTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) {
AiTask task = findLatestTask(meetingId, "SUMMARY");
Map<String, Object> taskConfig = new HashMap<>();
taskConfig.put("summaryModelId", profile.getResolvedSummaryModelId());
taskConfig.put("promptId", profile.getResolvedPromptId());
PromptTemplate template = promptTemplateService.getById(profile.getResolvedPromptId());
if (template != null) {
taskConfig.put("promptContent", template.getPromptContent());
}
Map<String, Object> taskConfig = meetingSummaryPromptAssembler.buildTaskConfig(
profile.getResolvedSummaryModelId(),
profile.getResolvedPromptId(),
null
);
resetOrCreateTask(task, meetingId, "SUMMARY", taskConfig);
}
@ -327,4 +327,4 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
private String resolveCreatorName(LoginUser loginUser) {
return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername();
}
}
}

View File

@ -6,6 +6,8 @@ import com.unisbase.security.LoginUser;
public interface MeetingAccessService {
Meeting requireMeeting(Long meetingId);
Meeting requireMeetingIgnoreTenant(Long meetingId);
boolean isPreviewPasswordRequired(Meeting meeting);
void assertCanPreviewMeeting(Meeting meeting, String accessPassword);

View File

@ -32,7 +32,7 @@ public interface MeetingCommandService {
void updateSummaryContent(Long meetingId, String summaryContent);
void reSummary(Long meetingId, Long summaryModelId, Long promptId);
void reSummary(Long meetingId, Long summaryModelId, Long promptId, String userPrompt);
void retryTranscription(Long meetingId);
}

View File

@ -13,6 +13,8 @@ public interface MeetingQueryService {
MeetingVO getDetail(Long id);
MeetingVO getDetailIgnoreTenant(Long id);
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);

View File

@ -58,6 +58,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private final HotWordService hotWordService;
private final StringRedisTemplate redisTemplate;
private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
private final TaskSecurityContextRunner taskSecurityContextRunner;
@Value("${unisbase.app.server-base-url}")
@ -206,7 +207,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId);
// 轮询逻辑 (带防卡死防护)
// 轮询逻辑(带防卡死防护)
JsonNode resultNode = null;
int lastPercent = -1;
int unchangedCount = 0;
@ -241,7 +242,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
if (resultNode == null) throw new RuntimeException("ASR轮询超时");
// 解析并入库 (防御性清理旧数据)
// 解析并入库(防御性清理旧数据)
return saveTranscripts(meeting, resultNode);
}
@ -432,15 +433,15 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
throw new RuntimeException("LLM模型未启用");
}
String promptContent = taskRecord.getTaskConfig().get("promptContent") != null
? taskRecord.getTaskConfig().get("promptContent").toString() : "";
String userPrompt = taskRecord.getTaskConfig().get("userPrompt") != null
? taskRecord.getTaskConfig().get("userPrompt").toString() : null;
Map<String, Object> req = new HashMap<>();
req.put("model", llmModel.getModelCode());
req.put("temperature", llmModel.getTemperature());
req.put("messages", List.of(
Map.of("role", "system", "content", buildSummarySystemPrompt(promptContent)),
Map.of("role", "user", "content", buildSummaryUserPrompt(meeting, asrText))
Map.of("role", "system", "content", meetingSummaryPromptAssembler.buildSystemMessage(taskRecord.getTaskConfig())),
Map.of("role", "user", "content", meetingSummaryPromptAssembler.buildUserMessage(meeting, asrText, userPrompt))
));
taskRecord.setRequestData(req);
@ -587,88 +588,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return normalized.substring(firstLineEnd + 1, lastFence).trim();
}
private String buildSummarySystemPrompt(String promptContent) {
String basePrompt = (promptContent == null || promptContent.isBlank())
? "你是一个擅长总结会议、提炼章节、聚合发言人观点和整理待办事项的中文助手。"
: promptContent.trim();
return String.join("\n\n",
"你是一个擅长总结会议、提炼章节、聚合发言人观点和整理待办事项的中文助手。",
"对于 summaryContent你必须严格遵循以下会议总结模板/风格要求,不要被后续结构化字段说明覆盖:\n" + basePrompt,
"如果模板中已经定义了标题层级、章节顺序、栏目名称、语气、措辞风格、段落组织方式,你必须保持一致。",
"summaryContent 不允许退化成关键词罗列、JSON 翻译或结构化字段拼接,必须是一篇可直接阅读、可直接导出的正式会议纪要正文。",
"analysis 是附加产物只服务于页面结构化展示analysis 不能改变 summaryContent 的模板风格和写法。",
"你需要一次性返回一个 JSON 对象,其中同时包含原会议纪要正文和结构化 analysis 结果。",
"最终只返回 JSON不要输出 markdown 代码围栏、解释、前后缀或额外说明。"
);
}
private String buildSummaryUserPrompt(Meeting meeting, String asrText) {
String participants = meeting.getParticipants() == null || meeting.getParticipants().isBlank()
? "未填写"
: meeting.getParticipants();
Integer durationMs = resolveMeetingDuration(meeting.getId());
String durationText = durationMs == null || durationMs <= 0 ? "未知" : formatDuration(durationMs);
String meetingTime = meeting.getMeetingTime() == null ? "未知" : meeting.getMeetingTime().toString();
return String.join("\n",
"请基于以下会议转写,一次性生成会议纪要正文和结构化分析结果。",
"会议基础信息:",
"标题:" + (meeting.getTitle() == null || meeting.getTitle().isBlank() ? "未命名会议" : meeting.getTitle()),
"会议时间:" + meetingTime,
"参会人员:" + participants,
"会议时长:" + durationText,
"返回 JSON字段结构固定如下",
"{",
" \"summaryContent\": \"原会议纪要正文,使用 markdown保持自然完整的纪要写法而不是关键词列表拼接\",",
" \"analysis\": {",
" \"overview\": \"基于整场会议内容生成的中文全文概要控制在300字内需尽量覆盖完整讨论内容\",",
" \"keywords\": [\"关键词1\", \"关键词2\"],",
" \"chapters\": [{\"time\":\"00:00\",\"title\":\"章节标题\",\"summary\":\"章节摘要\"}],",
" \"speakerSummaries\": [{\"speaker\":\"发言人 1\",\"summary\":\"该发言人在整场会议中的主要观点总结\"}],",
" \"keyPoints\": [{\"title\":\"重点问题或结论\",\"summary\":\"具体说明\",\"speaker\":\"发言人 1\"}],",
" \"todos\": [\"待办事项1\", \"待办事项2\"]",
" }",
"}",
"要求:",
"1. summaryContent 必须是完整会议纪要正文,优先遵循提示词模板中的标题层级、章节顺序、栏目名称、措辞风格和段落组织方式。",
"2. 如果模板里已经定义了固定标题或固定分节顺序summaryContent 必须严格复用,不要自行改写成别的结构。",
"3. summaryContent 必须保持自然完整、适合阅读和导出,不能写成关键词清单,也不能直接把 analysis 内容原样展开。",
"4. analysis 是给页面结构化展示使用的单独结果。",
"5. analysis.overview 必须基于所有会话内容,控制在 300 字内。",
"6. analysis.speakerSummaries 必须按发言人聚合,每个发言人只出现一次。",
"7. analysis.chapters 按时间顺序输出time 使用 mm:ss 或 hh:mm:ss。time需要与讨论内容相对应",
"8. analysis.keyPoints 提炼关键问题、决定、结论或争议点。",
"9. analysis.todos 尽量写成可执行动作;没有就返回空数组。",
"10. 只返回 JSON。",
"",
"会议转写如下:",
asrText
);
}
private Integer resolveMeetingDuration(Long meetingId) {
MeetingTranscript latestTranscript = transcriptMapper.selectOne(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.isNotNull(MeetingTranscript::getEndTime)
.orderByDesc(MeetingTranscript::getEndTime)
.last("LIMIT 1"));
if (latestTranscript != null && latestTranscript.getEndTime() != null && latestTranscript.getEndTime() > 0) {
return latestTranscript.getEndTime();
}
return null;
}
private String formatDuration(int durationMs) {
int totalSeconds = Math.max(durationMs / 1000, 0);
int hours = totalSeconds / 3600;
int minutes = (totalSeconds % 3600) / 60;
int seconds = totalSeconds % 60;
if (hours > 0) {
return String.format("%02d:%02d:%02d", hours, minutes, seconds);
}
return String.format("%02d:%02d", minutes, seconds);
}
private void updateMeetingStatus(Long id, int status) {
Meeting m = new Meeting();
m.setId(id);
@ -701,3 +620,4 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.updateById(task);
}
}

View File

@ -22,6 +22,15 @@ public class MeetingAccessServiceImpl implements MeetingAccessService {
return meeting;
}
@Override
public Meeting requireMeetingIgnoreTenant(Long meetingId) {
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null) {
throw new RuntimeException("Meeting not found");
}
return meeting;
}
@Override
public boolean isPreviewPasswordRequired(Meeting meeting) {
return normalizePreviewPassword(meeting == null ? null : meeting.getAccessPassword()) != null;

View File

@ -90,7 +90,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
asrTask.setTaskConfig(asrConfig);
aiTaskService.save(asrTask);
meetingDomainSupport.createSummaryTask(meeting.getId(), runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId());
meetingDomainSupport.createSummaryTask(
meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(),
runtimeProfile.getResolvedPromptId(),
command.getUserPrompt()
);
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl()));
meetingService.updateById(meeting);
meetingDomainSupport.publishMeetingCreated(meeting.getId(), meeting.getTenantId(), meeting.getCreatorId());
@ -109,7 +114,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
null, tenantId, creatorId, creatorName, hostUserId, hostName, 0);
meetingService.save(meeting);
meetingDomainSupport.createSummaryTask(meeting.getId(), runtimeProfile.getResolvedSummaryModelId(), runtimeProfile.getResolvedPromptId());
meetingDomainSupport.createSummaryTask(
meeting.getId(),
runtimeProfile.getResolvedSummaryModelId(),
runtimeProfile.getResolvedPromptId(),
command.getUserPrompt()
);
realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
realtimeMeetingSessionStateService.rememberResumeConfig(meeting.getId(), buildRealtimeResumeConfig(command, tenantId, runtimeProfile));
@ -455,13 +465,13 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override
@Transactional(rollbackFor = Exception.class)
public void reSummary(Long meetingId, Long summaryModelId, Long promptId) {
public void reSummary(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) {
Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("Meeting not found");
}
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId);
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId, userPrompt);
meeting.setStatus(2);
meetingService.updateById(meeting);
dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());

View File

@ -4,12 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.event.MeetingCreatedEvent;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper;
@ -39,7 +37,7 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class MeetingDomainSupport {
private final PromptTemplateService promptTemplateService;
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
private final AiTaskService aiTaskService;
private final MeetingTranscriptMapper transcriptMapper;
private final SysUserMapper sysUserMapper;
@ -68,22 +66,12 @@ public class MeetingDomainSupport {
return meeting;
}
public void createSummaryTask(Long meetingId, Long summaryModelId, Long promptId) {
public void createSummaryTask(Long meetingId, Long summaryModelId, Long promptId, String userPrompt) {
AiTask sumTask = new AiTask();
sumTask.setMeetingId(meetingId);
sumTask.setTaskType("SUMMARY");
sumTask.setStatus(0);
Map<String, Object> sumConfig = new HashMap<>();
sumConfig.put("summaryModelId", summaryModelId);
if (promptId != null) {
sumConfig.put("promptId", promptId);
PromptTemplate template = promptTemplateService.getById(promptId);
if (template != null) {
sumConfig.put("promptContent", template.getPromptContent());
}
}
sumTask.setTaskConfig(sumConfig);
sumTask.setTaskConfig(meetingSummaryPromptAssembler.buildTaskConfig(summaryModelId, promptId, userPrompt));
aiTaskService.save(sumTask);
}
@ -271,9 +259,47 @@ public class MeetingDomainSupport {
if (includeSummary) {
vo.setSummaryContent(meetingSummaryFileService.loadSummaryContent(meeting));
vo.setAnalysis(meetingSummaryFileService.loadSummaryAnalysis(meeting));
vo.setLastUserPrompt(resolveLastSummaryUserPrompt(meeting));
}
}
private String resolveLastSummaryUserPrompt(Meeting meeting) {
AiTask latestSummaryTask = resolveLatestSummaryTask(meeting);
if (latestSummaryTask == null || latestSummaryTask.getTaskConfig() == null) {
return null;
}
Object userPrompt = latestSummaryTask.getTaskConfig().get("userPrompt");
return userPrompt == null ? null : meetingSummaryPromptAssembler.normalizeOptionalText(String.valueOf(userPrompt));
}
private AiTask resolveLatestSummaryTask(Meeting meeting) {
if (meeting == null || meeting.getId() == null) {
return null;
}
if (meeting.getLatestSummaryTaskId() != null) {
AiTask task = aiTaskService.getById(meeting.getLatestSummaryTaskId());
if (task != null && "SUMMARY".equals(task.getTaskType()) && meeting.getId().equals(task.getMeetingId())) {
return task;
}
}
AiTask latestSuccessfulTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meeting.getId())
.eq(AiTask::getTaskType, "SUMMARY")
.eq(AiTask::getStatus, 2)
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (latestSuccessfulTask != null) {
return latestSuccessfulTask;
}
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meeting.getId())
.eq(AiTask::getTaskType, "SUMMARY")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
private record AudioRelocationPlan(Path sourcePath, Path targetPath, Path backupPath, String relocatedUrl) {
}
}

View File

@ -6,6 +6,7 @@ import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingService;
@ -24,6 +25,7 @@ import java.util.stream.Collectors;
public class MeetingQueryServiceImpl implements MeetingQueryService {
private final MeetingService meetingService;
private final MeetingMapper meetingMapper;
private final MeetingTranscriptMapper transcriptMapper;
private final MeetingDomainSupport meetingDomainSupport;
@ -68,6 +70,12 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
return meeting != null ? toVO(meeting, true) : null;
}
@Override
public MeetingVO getDetailIgnoreTenant(Long id) {
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(id);
return meeting != null ? toVO(meeting, true) : null;
}
@Override
public List<MeetingTranscriptVO> getTranscripts(Long meetingId) {
return transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()

View File

@ -1,11 +1,13 @@
package com.imeeting.db;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
@SpringBootTest
@Disabled("Requires a live local database and full application context; not part of automated regression.")
public class DbAlterTest {
@Autowired

View File

@ -60,4 +60,19 @@ class MeetingCreateCommandValidationTest {
assertTrue(validator.validate(command).isEmpty());
}
@Test
void shouldRejectTooLongResummaryUserPrompt() {
MeetingResummaryDTO dto = new MeetingResummaryDTO();
dto.setMeetingId(1L);
dto.setSummaryModelId(2L);
dto.setPromptId(3L);
dto.setUserPrompt("x".repeat(2001));
Set<String> invalidFields = validator.validate(dto).stream()
.map(violation -> violation.getPropertyPath().toString())
.collect(java.util.stream.Collectors.toSet());
assertEquals(Set.of("userPrompt"), invalidFields);
}
}

View File

@ -13,6 +13,7 @@ import com.imeeting.grpc.realtime.RealtimeServerPacket;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.stub.StreamObserver;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
@ -34,6 +35,7 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Disabled("Manual realtime integration test; requires a running local backend, gRPC service, and PCM fixture.")
public class AndroidRealtimeGrpcManualTest {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

View File

@ -12,6 +12,7 @@ import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.impl.MeetingDomainSupport;
import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler;
import com.unisbase.security.LoginUser;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@ -63,6 +64,7 @@ class LegacyMeetingAdapterServiceImplTest {
meetingDomainSupport,
mock(MeetingRuntimeProfileResolver.class),
mock(PromptTemplateService.class),
mock(MeetingSummaryPromptAssembler.class),
mock(AiTaskService.class),
mock(MeetingTranscriptMapper.class),
mock(LlmModelMapper.class)

View File

@ -10,6 +10,7 @@ import org.mockito.ArgumentCaptor;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@ -51,7 +52,7 @@ class MeetingAuthorizationServiceImplTest {
service.assertCanManageRealtimeMeeting(meeting, authContext);
ArgumentCaptor<LoginUser> loginUserCaptor = ArgumentCaptor.forClass(LoginUser.class);
verify(meetingAccessService).assertCanManageRealtimeMeeting(meeting, loginUserCaptor.capture());
verify(meetingAccessService).assertCanManageRealtimeMeeting(eq(meeting), loginUserCaptor.capture());
assertEquals(7L, loginUserCaptor.getValue().getUserId());
assertEquals(1L, loginUserCaptor.getValue().getTenantId());
assertEquals("alice", loginUserCaptor.getValue().getUsername());

View File

@ -319,9 +319,9 @@ class MeetingCommandServiceImplTest {
TransactionSynchronizationManager.initSynchronization();
try {
service.reSummary(301L, 22L, 33L);
service.reSummary(301L, 22L, 33L, null);
verify(meetingDomainSupport).createSummaryTask(301L, 22L, 33L);
verify(meetingDomainSupport).createSummaryTask(301L, 22L, 33L, null);
assertEquals(2, meeting.getStatus());
verify(meetingService).updateById(meeting);
verify(aiTaskService, never()).dispatchSummaryTask(301L, null, null);
@ -484,6 +484,7 @@ class MeetingCommandServiceImplTest {
command.setAsrModelId(11L);
command.setSummaryModelId(22L);
command.setPromptId(33L);
command.setUserPrompt("聚焦关键风险");
service.createMeeting(command, 1L, 7L, "creator");
@ -494,7 +495,7 @@ class MeetingCommandServiceImplTest {
Object asrModelId = task.getTaskConfig().get("asrModelId");
return Long.valueOf(101L).equals(asrModelId);
}));
verify(meetingDomainSupport).createSummaryTask(808L, 202L, 303L);
verify(meetingDomainSupport).createSummaryTask(808L, 202L, 303L, "聚焦关键风险");
}
@Test
@ -551,10 +552,11 @@ class MeetingCommandServiceImplTest {
command.setEnableItn(false);
command.setEnableTextRefine(true);
command.setSaveAudio(true);
command.setUserPrompt("关注待办事项");
service.createRealtimeMeeting(command, 1L, 7L, "creator");
verify(meetingDomainSupport).createSummaryTask(909L, 222L, 333L);
verify(meetingDomainSupport).createSummaryTask(909L, 222L, 333L, "关注待办事项");
verify(sessionStateService).rememberResumeConfig(eq(909L), argThat(config ->
Long.valueOf(111L).equals(config.getAsrModelId())
&& "online".equals(config.getMode())

View File

@ -1,13 +1,15 @@
package com.imeeting.service.biz.impl;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.PromptTemplateService;
import com.unisbase.mapper.SysUserMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronization;
@ -17,11 +19,15 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class MeetingDomainSupportTest {
@ -74,10 +80,107 @@ class MeetingDomainSupportTest {
assertFalse(hasBackupFile(tempDir.resolve("uploads/meetings/102")));
}
@Test
void shouldPreferLatestSummaryTaskIdWhenResolvingLastUserPrompt() {
AiTaskService aiTaskService = mock(AiTaskService.class);
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
Meeting meeting = new Meeting();
meeting.setId(201L);
meeting.setLatestSummaryTaskId(501L);
AiTask latestSummaryTask = new AiTask();
latestSummaryTask.setTaskType("SUMMARY");
latestSummaryTask.setMeetingId(201L);
latestSummaryTask.setTaskConfig(Map.of("userPrompt", " 已发布提示词 "));
AiTask fallbackTask = new AiTask();
fallbackTask.setTaskType("SUMMARY");
fallbackTask.setMeetingId(201L);
fallbackTask.setTaskConfig(Map.of("userPrompt", " 最新草稿提示词 "));
when(aiTaskService.getById(501L)).thenReturn(latestSummaryTask);
when(assembler.normalizeOptionalText(" 已发布提示词 ")).thenReturn("已发布提示词");
when(aiTaskService.getOne(any())).thenReturn(fallbackTask);
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
assertEquals("已发布提示词", resolved);
Mockito.verify(aiTaskService, Mockito.never()).getOne(any());
}
@Test
void shouldFallbackToLatestSummaryTaskWhenLatestSummaryTaskIdIsUnavailable() {
AiTaskService aiTaskService = mock(AiTaskService.class);
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
Meeting meeting = new Meeting();
meeting.setId(202L);
meeting.setLatestSummaryTaskId(502L);
AiTask latestSuccessfulTask = new AiTask();
latestSuccessfulTask.setTaskType("SUMMARY");
latestSuccessfulTask.setMeetingId(202L);
latestSuccessfulTask.setStatus(2);
latestSuccessfulTask.setTaskConfig(Map.of("userPrompt", " 成功提示词 "));
when(aiTaskService.getById(502L)).thenReturn(null);
when(aiTaskService.getOne(any())).thenReturn(latestSuccessfulTask);
when(assembler.normalizeOptionalText(" 成功提示词 ")).thenReturn("成功提示词");
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
assertEquals("成功提示词", resolved);
}
@Test
void shouldFallbackToLatestSummaryTaskWhenNoSuccessfulTaskExists() {
AiTaskService aiTaskService = mock(AiTaskService.class);
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
Meeting meeting = new Meeting();
meeting.setId(203L);
AiTask latestTask = new AiTask();
latestTask.setTaskType("SUMMARY");
latestTask.setMeetingId(203L);
latestTask.setTaskConfig(Map.of("userPrompt", " 最新任务提示词 "));
when(aiTaskService.getOne(any())).thenReturn(null).thenReturn(latestTask);
when(assembler.normalizeOptionalText(" 最新任务提示词 ")).thenReturn("最新任务提示词");
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
assertEquals("最新任务提示词", resolved);
}
@Test
void shouldReturnNullWhenNoSummaryTaskExists() {
AiTaskService aiTaskService = mock(AiTaskService.class);
MeetingSummaryPromptAssembler assembler = mock(MeetingSummaryPromptAssembler.class);
MeetingDomainSupport support = newSupport(aiTaskService, assembler);
Meeting meeting = new Meeting();
meeting.setId(204L);
when(aiTaskService.getOne(any())).thenReturn(null, null);
String resolved = ReflectionTestUtils.invokeMethod(support, "resolveLastSummaryUserPrompt", meeting);
assertNull(resolved);
}
private MeetingDomainSupport newSupport() {
return newSupport(mock(AiTaskService.class), mock(MeetingSummaryPromptAssembler.class));
}
private MeetingDomainSupport newSupport(AiTaskService aiTaskService, MeetingSummaryPromptAssembler assembler) {
MeetingDomainSupport support = new MeetingDomainSupport(
mock(PromptTemplateService.class),
mock(AiTaskService.class),
assembler,
aiTaskService,
mock(MeetingTranscriptMapper.class),
mock(SysUserMapper.class),
mock(ApplicationEventPublisher.class),

View File

@ -9,6 +9,7 @@ import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.PromptTemplateService;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ -46,7 +47,7 @@ class MeetingRuntimeProfileResolverImplTest {
null,
Boolean.TRUE,
Boolean.TRUE,
List.of(" alpha ", "", "alpha", "beta", null)
Arrays.asList(" alpha ", "", "alpha", "beta", null)
);
assertEquals(11L, profile.getResolvedAsrModelId());

View File

@ -72,6 +72,7 @@ export async function uploadClientPackage(platformCode: string, file: File) {
formData.append("file", file);
const resp = await http.post("/api/clients/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
timeout: 600000
});
return resp.data.data as ClientUploadResult;
}
}

View File

@ -68,6 +68,7 @@ export async function uploadExternalAppApk(file: File) {
formData.append("apkFile", file);
const resp = await http.post("/api/external-apps/upload-apk", formData, {
headers: { "Content-Type": "multipart/form-data" },
timeout: 600000
});
return resp.data.data as ExternalAppApkUploadResult;
}
@ -79,4 +80,4 @@ export async function uploadExternalAppIcon(file: File) {
headers: { "Content-Type": "multipart/form-data" },
});
return resp.data.data as ExternalAppIconUploadResult;
}
}

View File

@ -19,6 +19,7 @@ export interface MeetingVO {
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
audioSaveMessage?: string;
accessPassword?: string;
lastUserPrompt?: string;
summaryContent: string;
analysis?: {
overview?: string;
@ -46,6 +47,7 @@ export interface CreateMeetingCommand {
asrModelId: number;
summaryModelId?: number;
promptId: number;
userPrompt?: string;
useSpkId?: number;
enableTextRefine?: boolean;
hotWords?: string[];
@ -63,6 +65,7 @@ export interface CreateRealtimeMeetingCommand {
asrModelId: number;
summaryModelId?: number;
promptId: number;
userPrompt?: string;
mode?: string;
language?: string;
useSpkId?: number;
@ -284,6 +287,7 @@ export interface MeetingResummaryDTO {
meetingId: number;
summaryModelId: number;
promptId: number;
userPrompt?: string;
}
export const reSummary = (params: MeetingResummaryDTO) => {

View File

@ -436,6 +436,18 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
</Col>
)}
</Row>
<Form.Item
name="userPrompt"
label="用户提示词"
extra="可选,用于补充本次会议总结的关注重点、表达偏好或输出侧重点"
>
<Input.TextArea
placeholder="例如:请重点关注待办事项、风险点,并用适合汇报的表达方式输出"
autoSize={{ minRows: 3, maxRows: 6 }}
showCount
maxLength={1000}
/>
</Form.Item>
</div>
),
}

View File

@ -595,4 +595,4 @@ export default function Permissions() {
</Drawer>
</div>
);
}
}

View File

@ -207,10 +207,11 @@
.role-list-pagination {
flex-shrink: 0;
padding-top: 16px;
margin-top: auto;
margin-left: -16px;
margin-right: -16px;
margin-bottom: -16px;
border-top: 1px solid #f1f5f9;
display: flex;
justify-content: flex-end;
}
/* Detail Card Adjustments */

View File

@ -8,6 +8,8 @@ import AppPagination from "@/components/shared/AppPagination";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
import { getStandardPagination } from "@/utils/pagination";
import type { SysOrg, SysRole, SysTenant, SysUser } from "@/types";
import "./index.less";
@ -236,6 +238,9 @@ export default function Users() {
const url = await uploadPlatformAsset(file);
form.setFieldValue("avatarUrl", url);
message.success(t("common.success"));
} catch (error) {
message.error(error instanceof Error ? error.message : t("common.error"));
return Upload.LIST_IGNORE;
} finally {
setAvatarUploading(false);
}
@ -253,7 +258,8 @@ export default function Users() {
phone: values.phone,
avatarUrl: values.avatarUrl,
status: values.status,
isPlatformAdmin: values.isPlatformAdmin
isPlatformAdmin: values.isPlatformAdmin,
roleIds: values.roleIds || []
};
if (!isPlatformMode) {
@ -266,18 +272,10 @@ export default function Users() {
userPayload.password = values.password;
}
let userId = editing?.userId;
if (editing) {
await updateUser(editing.userId, userPayload);
} else {
await createUser(userPayload);
const updatedList = await listUsers();
const newUser = updatedList.find((user) => user.username === userPayload.username);
userId = newUser?.userId;
}
if (userId) {
await saveUserRoles(userId, values.roleIds || []);
}
message.success(t("common.success"));
@ -307,24 +305,24 @@ export default function Users() {
},
...(isPlatformMode
? [{
title: t("users.tenant"),
key: "tenant",
render: (_: any, record: SysUser) => {
if (record.memberships && record.memberships.length > 0) {
return (
<div className="flex flex-col gap-1">
{record.memberships.slice(0, 2).map((membership: any) => (
<Tag key={membership.tenantId} color="blue" style={{ margin: 0, padding: "0 4px", fontSize: 11 }}>
{tenantMap[membership.tenantId] || `Tenant ${membership.tenantId}`}
</Tag>
))}
{record.memberships.length > 2 && <Text type="secondary" style={{ fontSize: 11 }}>+{record.memberships.length - 2} more</Text>}
</div>
);
title: t("users.tenant"),
key: "tenant",
render: (_: any, record: SysUser) => {
if (record.memberships && record.memberships.length > 0) {
return (
<div className="flex flex-col gap-1">
{record.memberships.slice(0, 2).map((membership: any) => (
<Tag key={membership.tenantId} color="blue" style={{ margin: 0, padding: "0 4px", fontSize: 11 }}>
{tenantMap[membership.tenantId] || `Tenant ${membership.tenantId}`}
</Tag>
))}
{record.memberships.length > 2 && <Text type="secondary" style={{ fontSize: 11 }}>+{record.memberships.length - 2} more</Text>}
</div>
);
}
return <Text type="secondary">{t("usersExt.noTenant")}</Text>;
}
return <Text type="secondary">{t("usersExt.noTenant")}</Text>;
}
}]
}]
: []),
{
title: t("users.orgNode"),
@ -388,10 +386,10 @@ export default function Users() {
<div className="users-table-toolbar">
<Space size="middle" wrap className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
<Space size="middle" wrap className="app-page__toolbar">
{isPlatformMode && <Select placeholder={t("users.tenantFilter")} style={{ width: 200 }} allowClear value={filterTenantId} onChange={setFilterTenantId} options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} suffixIcon={<ShopOutlined aria-hidden="true" />} />}
<Input placeholder={t("users.searchPlaceholder")} prefix={<SearchOutlined aria-hidden="true" />} className="users-search-input" style={{ width: 300 }} value={searchText} onChange={(event) => { setSearchText(event.target.value); setCurrent(1); }} allowClear aria-label={t("common.search")} />
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
{isPlatformMode && <Select placeholder={t("users.tenantFilter")} style={{ width: 200 }} allowClear value={filterTenantId} onChange={setFilterTenantId} options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} suffixIcon={<ShopOutlined aria-hidden="true" />} />}
<Input placeholder={t("users.searchPlaceholder")} prefix={<SearchOutlined aria-hidden="true" />} className="users-search-input" style={{ width: 300 }} value={searchText} onChange={(event) => { setSearchText(event.target.value); setCurrent(1); }} allowClear aria-label={t("common.search")} />
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
</Space>
{can("sys:user:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>{t("common.create")}</Button>}
</Space>
@ -405,11 +403,11 @@ export default function Users() {
<AppPagination current={current} pageSize={pageSize} total={filteredData.length} onChange={(page, size) => { setCurrent(page); setPageSize(size); }} />
</Card>
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
<Form form={form} layout="vertical" className="user-form">
<Title level={5} style={{ marginBottom: 16 }}>{t("usersExt.basicInfo")}</Title>
<Row gutter={16}>
<Col span={12}><Form.Item label={t("users.username")} name="username" rules={[{ required: true, message: t("users.username") }]}><Input placeholder={t("users.username")} disabled={!!editing} className="tabular-nums" /></Form.Item></Col>
<Col span={12}><Form.Item label={t("users.username")} name="username" rules={[{ required: true, message: t("users.username") }, { pattern: LOGIN_NAME_PATTERN, message: t("usersExt.usernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" }) }]} getValueFromEvent={(event) => sanitizeLoginName(event?.target?.value)} extra={t("usersExt.usernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" })}><Input placeholder={t("usersExt.usernamePlaceholder", { defaultValue: "仅支持 a-z、0-9、@、_" })} disabled={!!editing} className="tabular-nums" /></Form.Item></Col>
<Col span={12}><Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true, message: t("users.displayName") }]}><Input placeholder={t("users.displayName")} /></Form.Item></Col>
</Row>
<Row gutter={16}>

View File

@ -1,4 +1,4 @@
import { App, Button, Checkbox, Form, Input, Typography } from "antd";
import { Button, Checkbox, Form, Input, Typography, message } from "antd";
import { LockOutlined, ReloadOutlined, SafetyOutlined, UserOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@ -24,7 +24,6 @@ export default function Login() {
const [loading, setLoading] = useState(false);
const [platformConfig, setPlatformConfig] = useState<SysPlatformConfig | null>(null);
const [form] = Form.useForm<LoginFormValues>();
const { message } = App.useApp();
const loadCaptcha = useCallback(async () => {
if (!captchaEnabled) {
@ -87,11 +86,8 @@ export default function Login() {
try {
const profile = await getCurrentUser();
sessionStorage.setItem("userProfile", JSON.stringify(profile));
localStorage.setItem("displayName", profile.displayName || profile.username || values.username);
localStorage.setItem("username", profile.username || values.username);
} catch {
sessionStorage.removeItem("userProfile");
localStorage.removeItem("displayName");
}
message.success(t("common.success"));
@ -228,8 +224,8 @@ export default function Login() {
<div className="login-footer">
<Text type="secondary">
{/*{t("login.demoAccount")} <Text strong className="tabular-nums">admin</Text> / {t("login.password")}{" "}*/}
{/*<Text strong className="tabular-nums">123456</Text>*/}
{t("login.demoAccount")} <Text strong className="tabular-nums">admin</Text> / {t("login.password")}{" "}
<Text strong className="tabular-nums">123456</Text>
</Text>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { Button, Card, Form, Input, Layout, Typography, App } from 'antd';
import { Button, Card, Form, Input, Layout, Typography, message } from "antd";
import { LockOutlined, LogoutOutlined } from "@ant-design/icons";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
@ -13,7 +13,6 @@ type ResetPasswordFormValues = {
};
export default function ResetPassword() {
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const [form] = Form.useForm<ResetPasswordFormValues>();

View File

@ -1,11 +1,23 @@
import { Button, Card, Col, Empty, Input, Row, Space, Table, Tag, Tree, Typography, App } from 'antd';
import {
Button,
Card,
Col,
Empty,
Input,
message,
Row,
Space,
Table,
Tag,
Tree,
Typography
} from "antd";
import type { DataNode } from "antd/es/tree";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ClusterOutlined, KeyOutlined, SafetyCertificateOutlined, SaveOutlined, SearchOutlined } from "@ant-design/icons";
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "@/api";
import PageHeader from "@/components/shared/PageHeader";
import { getStandardPagination } from "@/utils/pagination";
import type { SysPermission, SysRole } from "@/types";
const { Text } = Typography;
@ -64,7 +76,6 @@ function toTreeData(nodes: PermissionNode[]): DataNode[] {
}
export default function RolePermissionBinding() {
const { message } = App.useApp();
const { t } = useTranslation();
const [roles, setRoles] = useState<SysRole[]>([]);
const [permissions, setPermissions] = useState<SysPermission[]>([]);
@ -82,7 +93,7 @@ export default function RolePermissionBinding() {
return false;
}
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0";
return !!profile.isPlatformAdmin;
}, []);
const selectedRole = useMemo(
@ -182,7 +193,7 @@ export default function RolePermissionBinding() {
<Row gutter={24} className="app-page__split" style={{ height: "calc(100vh - 180px)" }}>
<Col xs={24} lg={10} style={{ height: "100%" }}>
<Card title={<Space><SafetyCertificateOutlined aria-hidden="true" /><span>{t("rolePerm.roleList")}</span></Space>} className="app-page__panel-card full-height-card" styles={{ body: { height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<Card title={<Space><SafetyCertificateOutlined aria-hidden="true" /><span>{t("rolePerm.roleList")}</span></Space>} className="app-page__panel-card full-height-card">
<div className="mb-4">
<Input
placeholder={t("rolePerm.searchRole")}
@ -193,13 +204,12 @@ export default function RolePermissionBinding() {
aria-label={t("rolePerm.searchRole")}
/>
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<div style={{ height: "calc(100% - 60px)", overflowY: "auto" }}>
<Table
rowKey="roleId"
size="middle"
loading={loadingRoles}
dataSource={filteredRoles}
scroll={{ y: "calc(100vh - 370px)" }}
rowSelection={{
type: "radio",
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
@ -209,7 +219,7 @@ export default function RolePermissionBinding() {
onClick: () => setSelectedRoleId(record.roleId),
className: "cursor-pointer"
})}
pagination={getStandardPagination(filteredRoles.length, 1, 10)}
pagination={{ pageSize: 10, showTotal: (total) => t("common.total", { total }) }}
columns={[
{
title: t("roles.roleName"),
@ -237,7 +247,6 @@ export default function RolePermissionBinding() {
<Card
title={<Space><KeyOutlined aria-hidden="true" /><span>{t("rolePerm.permConfig")}</span></Space>}
className="app-page__panel-card full-height-card"
styles={{ body: { height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" } }}
extra={selectedRole ? <Tag color="blue">{t("rolePerm.currentRole")}: {selectedRole.roleName}</Tag> : null}
>
{selectedRoleId ? (
@ -270,3 +279,4 @@ export default function RolePermissionBinding() {
</div>
);
}

View File

@ -1,16 +1,27 @@
import { Button, Card, Checkbox, Col, Empty, Input, Row, Space, Table, Tag, Typography, App } from 'antd';
import {
Button,
Card,
Checkbox,
Col,
Empty,
Input,
Row,
Space,
Table,
Tag,
Typography,
message
} from "antd";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "@/api";
import { SaveOutlined, SearchOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
import { getStandardPagination } from "@/utils/pagination";
import type { SysRole, SysUser } from "@/types";
import PageHeader from "@/components/shared/PageHeader";
const { Text } = Typography;
export default function UserRoleBinding() {
const { message } = App.useApp();
const { t } = useTranslation();
const [users, setUsers] = useState<SysUser[]>([]);
const [roles, setRoles] = useState<SysRole[]>([]);
@ -102,7 +113,7 @@ export default function UserRoleBinding() {
<Row gutter={24} className="app-page__split" style={{ height: "calc(100vh - 180px)" }}>
<Col xs={24} lg={12} style={{ height: "100%" }}>
<Card title={<Space><UserOutlined aria-hidden="true" /><span>{t("userRole.userList")}</span></Space>} className="app-page__panel-card full-height-card" styles={{ body: { height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<Card title={<Space><UserOutlined aria-hidden="true" /><span>{t("userRole.userList")}</span></Space>} className="app-page__panel-card full-height-card">
<div className="mb-4">
<Input
placeholder={t("userRole.searchUser")}
@ -113,13 +124,12 @@ export default function UserRoleBinding() {
aria-label={t("userRole.searchUser")}
/>
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<div style={{ height: "calc(100% - 60px)", overflowY: "auto" }}>
<Table
rowKey="userId"
size="middle"
loading={loadingUsers}
dataSource={filteredUsers}
scroll={{ y: "calc(100vh - 370px)" }}
rowSelection={{
type: "radio",
selectedRowKeys: selectedUserId ? [selectedUserId] : [],
@ -129,7 +139,7 @@ export default function UserRoleBinding() {
onClick: () => setSelectedUserId(record.userId),
className: "cursor-pointer"
})}
pagination={getStandardPagination(filteredUsers.length, 1, 10)}
pagination={{ pageSize: 10, showTotal: (total) => t("common.total", { total }) }}
columns={[
{
title: t("users.userInfo"),
@ -157,11 +167,10 @@ export default function UserRoleBinding() {
<Card
title={<Space><TeamOutlined aria-hidden="true" /><span>{t("userRole.grantRoles")}</span></Space>}
className="app-page__panel-card full-height-card"
styles={{ body: { height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" } }}
extra={selectedUser ? <Tag color="blue">{t("userRole.editing")}: {selectedUser.displayName}</Tag> : null}
>
{selectedUserId ? (
<div style={{ padding: "8px 0", flex: 1, minHeight: 0, overflowY: "auto" }}>
<div style={{ padding: "8px 0", height: "100%", overflowY: "auto" }}>
<Checkbox.Group style={{ width: "100%" }} value={checkedRoleIds} onChange={(values) => setCheckedRoleIds(values as number[])} disabled={loadingRoles}>
<Row gutter={[16, 16]}>
{roles.map((role) => (
@ -191,4 +200,4 @@ export default function UserRoleBinding() {
</Row>
</div>
);
}
}

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Alert, Avatar, Breadcrumb, Button, Card, Checkbox, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Row, Select, Skeleton, Space, Switch, Tag, Typography, App } from 'antd';
import { Alert, Avatar, Breadcrumb, Button, Card, Checkbox, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd';
import {
AudioOutlined,
CaretRightFilled,
@ -19,6 +19,10 @@ import {
UserOutlined,
PlusOutlined,
CheckCircleFilled,
EllipsisOutlined,
FilePdfOutlined,
FileWordOutlined,
ShareAltOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown';
@ -775,6 +779,7 @@ const MeetingDetail: React.FC = () => {
meetingId: Number(id),
summaryModelId: values.summaryModelId,
promptId: values.promptId,
userPrompt: values.userPrompt,
});
message.success('已重新发起总结任务');
setSummaryVisible(false);
@ -786,6 +791,18 @@ const MeetingDetail: React.FC = () => {
}
};
const handleOpenSummaryDrawer = () => {
summaryForm.setFieldsValue({
summaryModelId:
summaryForm.getFieldValue('summaryModelId') ??
llmModels.find((model) => model.isDefault === 1)?.id ??
llmModels[0]?.id,
promptId: summaryForm.getFieldValue('promptId') ?? prompts[0]?.id,
userPrompt: meeting?.lastUserPrompt ?? '',
});
setSummaryVisible(true);
};
const handleRetryTranscription = async () => {
setActionLoading(true);
try {
@ -1198,31 +1215,46 @@ const MeetingDetail: React.FC = () => {
</Space>
</Col>
<Col>
<Space>
<Space size={8}>
{canRetrySummary && (
<Button icon={<SyncOutlined />} type="primary" ghost onClick={handleOpenSummaryDrawer} disabled={actionLoading}>
</Button>
)}
{canRetryTranscription && (
<Button icon={<SyncOutlined />} type="primary" onClick={handleRetryTranscription} loading={actionLoading}>
</Button>
)}
{canRetrySummary && (
<Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>
</Button>
)}
{isOwner && meeting.status === 2 && (
<Button icon={<LoadingOutlined />} type="primary" ghost disabled loading>
</Button>
)}
{meeting.status === 3 && !!meeting.summaryContent && (
<>
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('pdf')} loading={downloadLoading === 'pdf'}>
PDF
</Button>
<Button icon={<DownloadOutlined />} onClick={() => handleDownloadSummary('word')} loading={downloadLoading === 'word'}>
Word
</Button>
</>
<Dropdown
menu={{
items: [
{
key: 'pdf',
label: '下载 PDF',
icon: <FilePdfOutlined />,
onClick: () => handleDownloadSummary('pdf'),
disabled: downloadLoading === 'pdf'
},
{
key: 'word',
label: '下载 Word',
icon: <FileWordOutlined />,
onClick: () => handleDownloadSummary('word'),
disabled: downloadLoading === 'word'
}
]
}}
placement="bottomRight"
>
<Button icon={<DownloadOutlined />} loading={!!downloadLoading}></Button>
</Dropdown>
)}
{shareQrContent ? (
<Popover
@ -1233,13 +1265,13 @@ const MeetingDetail: React.FC = () => {
placement="bottomRight"
overlayClassName="meeting-share-popover"
>
<Button icon={<QrcodeOutlined />}>
<Button icon={<ShareAltOutlined />}>
</Button>
</Popover>
) : null}
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
</Button>
</Space>
</Col>
@ -2363,6 +2395,18 @@ const MeetingDetail: React.FC = () => {
))}
</Select>
</Form.Item>
<Form.Item
name="userPrompt"
label="用户提示词"
extra="可选,用于补充本次重新总结的关注重点、表达偏好或输出侧重点"
>
<Input.TextArea
placeholder="例如:请重点突出结论、待办事项和风险点"
autoSize={{ minRows: 4, maxRows: 8 }}
showCount
maxLength={1000}
/>
</Form.Item>
<Divider />
<Text type="secondary"></Text>
</Form>

View File

@ -9,7 +9,9 @@ import {
SearchOutlined,
SyncOutlined,
TeamOutlined,
UserOutlined
UserOutlined,
AppstoreOutlined,
UnorderedListOutlined
} from '@ant-design/icons';
import {
App,
@ -28,7 +30,8 @@ import {
Space,
Tag,
Tooltip,
Typography
Typography,
Table
} from 'antd';
import dayjs from 'dayjs';
import React, {useEffect, useState} from 'react';
@ -97,7 +100,6 @@ const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
const res = await getMeetingProgress(meeting.id);
if (res.data && res.data.data) {
setProgress(res.data.data);
// 当达到 100% 时触发完成回调
if (res.data.data.percent === 100 && onComplete) {
onComplete();
}
@ -127,7 +129,6 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgr
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2;
return (
<div style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 10px', borderRadius: 6, fontSize: 11, fontWeight: 600, color: config.color, background: config.bgColor, position: 'relative', overflow: 'hidden', border: `1px solid ${isProcessing ? 'transparent' : '#eee'}`, minWidth: 80, justifyContent: 'center' }}>
{/* 进度填充背景 */}
{isProcessing && percent > 0 && (
<div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${percent}%`, background: effectiveStatus === 1 ? 'rgba(24, 144, 255, 0.2)' : 'rgba(250, 173, 20, 0.2)', transition: 'width 0.5s cubic-bezier(0.4, 0, 0.2, 1)', zIndex: 0 }} />
)}
@ -140,11 +141,14 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgr
);
};
// --- 表格状态单元格 ---
const TableStatusCell: React.FC<{ meeting: MeetingVO, fetchData: () => void }> = ({ meeting, fetchData }) => {
const progress = useMeetingProgress(meeting, () => fetchData());
return <IntegratedStatusTag meeting={meeting} progress={progress} />;
};
// --- 卡片项组件 ---
const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void, onOpenMeeting: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants, onOpenMeeting }) => {
// 注入自动刷新回调
const progress = useMeetingProgress(item, () => fetchData());
const effectiveStatus = item.displayStatus ?? item.status;
const isProcessing = effectiveStatus === 1 || effectiveStatus === 2;
@ -160,11 +164,12 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
<Tooltip title="编辑参会人"><div className="icon-btn edit"><EditOutlined onClick={() => onEditParticipants(item)} /></div></Tooltip>
<Popconfirm
title="确定删除?"
onConfirm={() => deleteMeeting(item.id).then(fetchData)}
onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(item.id).then(fetchData); }}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onCancel={(e) => e?.stopPropagation()}
>
<Tooltip title="删除"><div className="icon-btn delete"><DeleteOutlined /></div></Tooltip>
<Tooltip title="删除"><div className="icon-btn delete" onClick={e => e.stopPropagation()}><DeleteOutlined /></div></Tooltip>
</Popconfirm>
</Space>
</div>
@ -252,10 +257,10 @@ const Meetings: React.FC = () => {
const { can } = usePermission();
const [searchParams, setSearchParams] = useSearchParams();
const [loading, setLoading] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [data, setData] = useState<MeetingVO[]>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [displayMode, setDisplayMode] = useState<'card' | 'list'>('card');
const [size, setSize] = useState(8);
const [searchTitle, setSearchTitle] = useState('');
const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all');
@ -271,6 +276,12 @@ const Meetings: React.FC = () => {
return effectiveStatus === 0 || effectiveStatus === 1 || effectiveStatus === 2;
});
const handleDisplayModeChange = (mode: 'card' | 'list') => {
setDisplayMode(mode);
setSize(mode === 'card' ? 8 : 10);
setCurrent(1);
};
useEffect(() => {
const action = searchParams.get('action');
const type = searchParams.get('type') as MeetingCreateType;
@ -326,8 +337,6 @@ const Meetings: React.FC = () => {
navigate(`/meetings/${meeting.id}`);
};
const openEditParticipants = (meeting: MeetingVO) => {
setEditingMeeting(meeting);
participantsEditForm.setFieldsValue({
@ -365,6 +374,59 @@ const Meetings: React.FC = () => {
5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' }
};
const tableColumns = [
{
title: '会议标题',
dataIndex: 'title',
key: 'title',
render: (text: string, record: MeetingVO) => (
<a style={{ fontWeight: 500 }} onClick={() => handleOpenMeeting(record)}>{text}</a>
)
},
{
title: '状态',
key: 'status',
width: 150,
render: (_: any, record: MeetingVO) => (
<TableStatusCell meeting={record} fetchData={fetchData} />
)
},
{
title: '会议时间',
dataIndex: 'meetingTime',
key: 'meetingTime',
width: 180,
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm')
},
{
title: '参会人',
dataIndex: 'participants',
key: 'participants',
render: (text: string) => (
<Text type="secondary" ellipsis style={{ maxWidth: 200 }}>{text || '无参与人员'}</Text>
)
},
{
title: '操作',
key: 'action',
width: 160,
render: (_: any, record: MeetingVO) => (
<Space size="middle">
<Button type="link" size="small" onClick={(e) => { e.stopPropagation(); openEditParticipants(record); }}></Button>
<Popconfirm
title="确定删除?"
onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(record.id).then(fetchData); }}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onCancel={(e) => e?.stopPropagation()}
>
<Button type="link" danger size="small" onClick={(e) => e.stopPropagation()}></Button>
</Popconfirm>
</Space>
)
}
];
return (
<div className="app-page">
<Card
@ -379,6 +441,10 @@ const Meetings: React.FC = () => {
}
extra={
<Space size={16} wrap>
<Radio.Group value={displayMode} onChange={e => handleDisplayModeChange(e.target.value)} buttonStyle="solid">
<Radio.Button value="card"><AppstoreOutlined /></Radio.Button>
<Radio.Button value="list"><UnorderedListOutlined /></Radio.Button>
</Radio.Group>
<Radio.Group value={viewType} onChange={e => { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid">
<Radio.Button value="all"></Radio.Button><Radio.Button value="created"></Radio.Button><Radio.Button value="involved"></Radio.Button>
</Radio.Group>
@ -391,12 +457,27 @@ const Meetings: React.FC = () => {
}
>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflowX: "hidden", overflowY: "auto", padding: "24px" }}>
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
<List grid={{ gutter: [24, 24], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} dataSource={data} renderItem={(item) => {
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0];
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} onOpenMeeting={handleOpenMeeting} />;
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
</Skeleton>
{displayMode === 'card' ? (
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
<List grid={{ gutter: [24, 24], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} dataSource={data} renderItem={(item) => {
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0];
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} onOpenMeeting={handleOpenMeeting} />;
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
</Skeleton>
) : (
<Table
columns={tableColumns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
onRow={(record) => ({
onClick: () => handleOpenMeeting(record),
style: { cursor: 'pointer' }
})}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
/>
)}
</div>
<AppPagination current={current} pageSize={size} total={total} onChange={(p, s) => { setCurrent(p); setSize(s); }} />

View File

@ -1,224 +1,113 @@
import React, { useState, useEffect } from 'react';
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
import { Row, Col, Card, Typography, Table, Tag, Skeleton, Button } from "antd";
import {
HistoryOutlined,
CheckCircleOutlined,
LoadingOutlined,
AudioOutlined,
RobotOutlined,
CalendarOutlined,
TeamOutlined,
RiseOutlined,
VideoCameraOutlined,
DesktopOutlined,
UserOutlined,
ClockCircleOutlined,
PlayCircleOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { getDashboardStats, getRecentTasks, DashboardStats } from '@/api/business/dashboard';
import { MeetingVO, getMeetingProgress, MeetingProgress } from '@/api/business/meeting';
CheckCircleOutlined,
SyncOutlined,
ArrowRightOutlined
} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import StatCard from "@/components/shared/StatCard/StatCard";
import PageHeader from "@/components/shared/PageHeader";
const { Title, Text } = Typography;
const { Text } = Typography;
const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
export default function Dashboard() {
const { t } = useTranslation();
useEffect(() => {
if (meeting.status !== 1 && meeting.status !== 2) return;
const recentMeetings = [
{ key: "1", name: "Product Sync", time: "2024-02-10 14:00", duration: "45min", status: "processing" },
{ key: "2", name: "Tech Review", time: "2024-02-10 10:00", duration: "60min", status: "success" },
{ key: "3", name: "Daily Standup", time: "2024-02-10 09:00", duration: "15min", status: "success" },
{ key: "4", name: "Client Call", time: "2024-02-10 16:30", duration: "30min", status: "default" }
];
const fetchProgress = async () => {
try {
const res = await getMeetingProgress(meeting.id);
if (res.data?.data) {
setProgress(res.data.data);
}
} catch (err) {
// ignore
}
};
fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meeting.id, meeting.status]);
if (meeting.status !== 1 && meeting.status !== 2) return null;
const percent = progress?.percent || 0;
const isError = percent < 0;
return (
<div style={{ marginTop: 12, padding: '12px 16px', background: 'var(--app-bg-surface-soft)', borderRadius: 8, border: '1px solid var(--app-border-color)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
{progress?.message || '准备分析中...'}
</Text>
{!isError && <Text strong style={{ color: '#1890ff' }}>{percent}%</Text>}
</div>
<Progress
percent={isError ? 100 : percent}
size="small"
status={isError ? 'exception' : (percent === 100 ? 'success' : 'active')}
showInfo={false}
strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }}
/>
</div>
);
};
export const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
const [loading, setLoading] = useState(true);
const processingCount = Number(stats?.processingTasks || 0);
const dashboardLoading = loading && processingCount > 0;
useEffect(() => {
fetchDashboardData();
const timer = setInterval(fetchDashboardData, 5000);
return () => clearInterval(timer);
}, []);
const fetchDashboardData = async () => {
try {
const [statsRes, tasksRes] = await Promise.all([getDashboardStats(), getRecentTasks()]);
setStats(statsRes.data.data);
setRecentTasks(tasksRes.data.data || []);
} catch (err) {
console.error('Dashboard data load failed', err);
} finally {
setLoading(false);
}
};
const renderTaskProgress = (item: MeetingVO) => {
const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status);
return (
<div style={{ width: '100%', maxWidth: 450 }}>
<Steps
size="small"
current={currentStep}
status={item.status === 4 ? 'error' : (item.status === 3 ? 'finish' : 'process')}
items={[
{
title: '语音转录',
icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />,
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转录中' : '排队中')
},
{
title: '智能总结',
icon: item.status === 2 ? <LoadingOutlined spin /> : <RobotOutlined />,
description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在生成' : '待执行')
},
{
title: '分析完成',
icon: item.status === 3 ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : <FileTextOutlined />,
}
]}
/>
</div>
);
};
const statCards = [
{ label: '累计会议记录', value: stats?.totalMeetings, icon: <HistoryOutlined />, color: '#1890ff' },
const columns = [
{
label: '当前分析中任务',
value: stats?.processingTasks,
icon: processingCount > 0 ? <LoadingOutlined spin /> : <ClockCircleOutlined />,
color: '#faad14'
title: t("dashboard.meetingName"),
dataIndex: "name",
key: "name",
render: (text: string) => <Text strong>{text}</Text>
},
{ label: '今日新增分析', value: stats?.todayNew, icon: <RiseOutlined />, color: '#52c41a' },
{ label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: <CheckCircleOutlined />, color: '#13c2c2' },
{
title: t("dashboard.startTime"),
dataIndex: "time",
key: "time",
className: "tabular-nums",
render: (text: string) => <Text type="secondary">{text}</Text>
},
{
title: t("dashboard.duration"),
dataIndex: "duration",
key: "duration",
width: 100,
className: "tabular-nums"
},
{
title: t("common.status"),
dataIndex: "status",
key: "status",
width: 120,
render: (status: string) => {
if (status === "processing") return <Tag icon={<SyncOutlined spin aria-hidden="true" />} color="processing">{t("dashboardExt.processing")}</Tag>;
if (status === "success") return <Tag icon={<CheckCircleOutlined aria-hidden="true" />} color="success">{t("dashboardExt.completed")}</Tag>;
return <Tag color="default">{t("dashboardExt.pending")}</Tag>;
}
},
{
title: t("common.action"),
key: "action",
width: 80,
render: () => <Button type="link" size="small" icon={<ArrowRightOutlined aria-hidden="true" />} aria-label={t("dashboard.viewAll")} />
}
];
return (
<div style={{ padding: '24px', background: 'var(--app-bg-page)', minHeight: '100%', overflowY: 'auto' }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
<Row gutter={24} style={{ marginBottom: 24 }}>
{statCards.map((s, idx) => (
<Col span={6} key={idx}>
<Card variant="borderless" style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
<Statistic
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
value={s.value || 0}
valueStyle={{ color: s.color, fontWeight: 700 }}
prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })}
/>
</Card>
</Col>
))}
</Row>
<div className="app-page dashboard-page">
<PageHeader
title={t("dashboard.title")}
subtitle={t("dashboard.subtitle")}
/>
<Card
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space><ClockCircleOutlined /> </Space>
<Button type="link" onClick={() => navigate('/meetings')}></Button>
</div>
}
variant="borderless"
style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}
>
<List
loading={dashboardLoading}
dataSource={recentTasks}
renderItem={(item) => (
<List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}>
<div style={{ width: '100%' }}>
<Row gutter={32} align="middle">
<Col span={8}>
<Space direction="vertical" size={4}>
<Title level={5} style={{ margin: 0, cursor: 'pointer' }} onClick={() => navigate(`/meetings/${item.id}`)}>
{item.title}
</Title>
<Space size={12} split={<Divider type="vertical" style={{ margin: 0 }} />}>
<Text type="secondary"><CalendarOutlined /> {dayjs(item.meetingTime).format('MM-DD HH:mm')}</Text>
<Text type="secondary"><TeamOutlined /> {item.participants || item.creatorName || '未指定'}</Text>
</Space>
<div style={{ marginTop: 8 }}>
{item.tags?.split(',').filter(Boolean).map((t) => (
<Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
))}
</div>
</Space>
</Col>
<Col span={12}>
{renderTaskProgress(item)}
</Col>
<Col span={4} style={{ textAlign: 'right' }}>
<Button
type={item.status === 3 ? 'primary' : 'default'}
ghost={item.status === 3}
icon={item.status === 3 ? <FileTextOutlined /> : <PlayCircleOutlined />}
onClick={() => navigate(`/meetings/${item.id}`)}
>
{item.status === 3 ? '查看纪要' : '监控详情'}
</Button>
</Col>
</Row>
<MeetingProgressDisplay meeting={item} />
</div>
</List.Item>
)}
locale={{ emptyText: <Empty description="暂无近期分析任务" /> }}
/>
</Card>
</div>
<style>{`
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
.ant-steps-item-description { font-size: 11px !important; }
`}</style>
<div className="app-page__page-actions">
<Button icon={<SyncOutlined aria-hidden="true" />} size="small">{t("common.refresh")}</Button>
</div>
);
};
export default Dashboard;
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={6}>
<StatCard title={t("dashboard.todayMeetings")} value={12} icon={<VideoCameraOutlined aria-hidden="true" />} color="blue" trend={{ value: 8, direction: "up" }} />
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard title={t("dashboard.activeDevices")} value={45} icon={<DesktopOutlined aria-hidden="true" />} color="green" trend={{ value: 2, direction: "up" }} />
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard title={t("dashboard.transcriptionDuration")} value={1280} suffix="min" icon={<ClockCircleOutlined aria-hidden="true" />} color="orange" trend={{ value: 5, direction: "down" }} />
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard title={t("dashboard.totalUsers")} value={320} icon={<UserOutlined aria-hidden="true" />} color="purple" trend={{ value: 12, direction: "up" }} />
</Col>
</Row>
<Row gutter={[24, 24]} className="mt-6">
<Col xs={24} xl={16}>
<Card title={t("dashboard.recentMeetings")} bordered={false} className="app-page__content-card" extra={<Button type="link" size="small">{t("dashboard.viewAll")}</Button>} styles={{ body: { padding: 0 } }}>
<Table dataSource={recentMeetings} columns={columns} pagination={false} size="middle" className="roles-table" />
</Card>
</Col>
<Col xs={24} xl={8}>
<Card title={t("dashboard.deviceLoad")} bordered={false} className="app-page__content-card">
<div className="flex flex-col items-center justify-center py-12">
<Skeleton active paragraph={{ rows: 4 }} />
<div className="mt-4 text-gray-400 flex items-center gap-2">
<SyncOutlined spin aria-hidden="true" />
<span>{t("dashboardExt.chartLoading")}</span>
</div>
</div>
</Card>
</Col>
</Row>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { Button, Card, Drawer, Form, Input, Popconfirm, Select, Space, Table, Tag, Typography, App } from 'antd';
import { Button, Card, Drawer, Form, Input, Popconfirm, Select, Space, Table, Tag, Typography, message } from "antd";
import { DeleteOutlined, DesktopOutlined, EditOutlined, PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@ -20,7 +20,6 @@ type DeviceFormValues = {
};
export default function Devices() {
const { message } = App.useApp();
const { t } = useTranslation();
const { can } = usePermission();
const { items: statusDict } = useDict("sys_common_status");
@ -155,88 +154,86 @@ export default function Devices() {
</Card>
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<div className="app-page__table-wrap">
<Table<DeviceInfo>
rowKey="deviceId"
dataSource={filteredData}
loading={loading}
size="middle"
scroll={{ x: "max-content", y: "calc(100vh - 350px)" }}
pagination={getStandardPagination(filteredData.length, 1, 1000)}
columns={[
{
title: t("devicesExt.device"),
key: "device",
render: (_value: unknown, record) => (
rowKey="deviceId"
dataSource={filteredData}
loading={loading}
size="middle"
scroll={{ y: "calc(100vh - 350px)" }}
pagination={getStandardPagination(filteredData.length, 1, 1000)}
columns={[
{
title: t("devicesExt.device"),
key: "device",
render: (_value: unknown, record) => (
<Space>
<div className="device-icon-placeholder">
<DesktopOutlined aria-hidden="true" />
</div>
<div>
<div className="device-name font-medium">{record.deviceName || t("devicesExt.unnamedDevice")}</div>
<div className="device-code text-xs text-gray-400 tabular-nums">{record.deviceCode}</div>
</div>
</Space>
)
},
{
title: t("devices.owner"),
key: "user",
render: (_value: unknown, record) => {
const owner = userMap[record.userId];
return owner ? (
<Space>
<div className="device-icon-placeholder">
<DesktopOutlined aria-hidden="true" />
</div>
<div>
<div className="device-name font-medium">{record.deviceName || t("devicesExt.unnamedDevice")}</div>
<div className="device-code text-xs text-gray-400 tabular-nums">{record.deviceCode}</div>
</div>
<UserOutlined aria-hidden="true" style={{ color: "#8c8c8c" }} />
<span>{owner.displayName}</span>
<Text type="secondary" style={{ fontSize: "12px" }} className="tabular-nums">
({t("devicesExt.ownerId")}: {record.userId})
</Text>
</Space>
)
},
{
title: t("devices.owner"),
key: "user",
render: (_value: unknown, record) => {
const owner = userMap[record.userId];
return owner ? (
<Space>
<UserOutlined aria-hidden="true" style={{ color: "#8c8c8c" }} />
<span>{owner.displayName}</span>
<Text type="secondary" style={{ fontSize: "12px" }} className="tabular-nums">
({t("devicesExt.ownerId")}: {record.userId})
</Text>
</Space>
) : (
<span className="tabular-nums">{t("devicesExt.ownerId")}: {record.userId}</span>
);
}
},
{
title: t("common.status"),
dataIndex: "status",
width: 100,
render: (status: number) => {
const item = statusDict.find((dictItem) => dictItem.itemValue === String(status));
return <Tag color={status === 1 ? "green" : "red"}>{item?.itemLabel || (status === 1 ? t("devicesExt.enabled") : t("devicesExt.disabled"))}</Tag>;
}
},
{
title: t("devices.updateTime"),
dataIndex: "updatedAt",
width: 180,
render: (text: string) => (
<Text type="secondary" className="tabular-nums">
{text?.replace("T", " ").substring(0, 19)}
</Text>
)
},
{
title: t("common.action"),
key: "action",
width: 120,
fixed: "right",
render: (_value: unknown, record) => (
<Space>
{can("device:update") ? (
<Button type="text" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label={t("devicesExt.editDevice")} />
) : null}
{can("device:delete") ? (
<Popconfirm title={t("devicesExt.deleteDevice")} onConfirm={() => remove(record.deviceId)}>
<Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t("common.delete")} />
</Popconfirm>
) : null}
</Space>
)
) : (
<span className="tabular-nums">{t("devicesExt.ownerId")}: {record.userId}</span>
);
}
]}
/>
</div>
},
{
title: t("common.status"),
dataIndex: "status",
width: 100,
render: (status: number) => {
const item = statusDict.find((dictItem) => dictItem.itemValue === String(status));
return <Tag color={status === 1 ? "green" : "red"}>{item?.itemLabel || (status === 1 ? t("devicesExt.enabled") : t("devicesExt.disabled"))}</Tag>;
}
},
{
title: t("devices.updateTime"),
dataIndex: "updatedAt",
width: 180,
render: (text: string) => (
<Text type="secondary" className="tabular-nums">
{text?.replace("T", " ").substring(0, 19)}
</Text>
)
},
{
title: t("common.action"),
key: "action",
width: 120,
fixed: "right",
render: (_value: unknown, record) => (
<Space>
{can("device:update") ? (
<Button type="text" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label={t("devicesExt.editDevice")} />
) : null}
{can("device:delete") ? (
<Popconfirm title={t("devicesExt.deleteDevice")} onConfirm={() => remove(record.deviceId)}>
<Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t("common.delete")} />
</Popconfirm>
) : null}
</Space>
)
}
]}
/>
</Card>
<Drawer
@ -249,7 +246,7 @@ export default function Devices() {
open={open}
onClose={() => setOpen(false)}
width={420}
destroyOnHidden
destroyOnClose
footer={
<div className="app-page__drawer-footer">
<Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button>
@ -281,4 +278,4 @@ export default function Devices() {
</Drawer>
</div>
);
}
}

View File

@ -1,4 +1,4 @@
import { Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
import { Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, message } from "antd";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApartmentOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, ShopOutlined } from "@ant-design/icons";
@ -32,7 +32,6 @@ function buildOrgTree(list: SysOrg[]): OrgNode[] {
}
export default function Orgs() {
const { message } = App.useApp();
const { t } = useTranslation();
const { can } = usePermission();
const { items: statusDict } = useDict("sys_common_status");
@ -185,7 +184,7 @@ export default function Orgs() {
)}
</Card>
<Drawer title={<Space><ApartmentOutlined aria-hidden="true" /><span>{editing ? t("orgs.drawerTitleEdit") : t("orgs.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
<Drawer title={<Space><ApartmentOutlined aria-hidden="true" /><span>{editing ? t("orgs.drawerTitleEdit") : t("orgs.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
<Form form={form} layout="vertical">
<Form.Item label={t("users.tenant")} name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}>
<Select disabled options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} />

View File

@ -1,19 +1,18 @@
import { Avatar, Button, Card, Col, DatePicker, Divider, Drawer, Empty, Form, Input, List, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, App } from 'antd';
import { Avatar, Button, Card, Col, DatePicker, Divider, Drawer, Empty, Form, Input, List, Popconfirm, Row, Select, Space, Tag, Tooltip, Typography, message } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import { createTenant, deleteTenant, listTenants, updateTenant } from "@/api";
import AppPagination from "@/components/shared/AppPagination";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
import type { SysTenant } from "@/types";
const { Title, Text, Paragraph } = Typography;
export default function Tenants() {
const { message } = App.useApp();
const { t } = useTranslation();
const { can } = usePermission();
const { items: statusDict } = useDict("sys_common_status");
@ -24,7 +23,9 @@ export default function Tenants() {
const [params, setParams] = useState({ current: 1, size: 12, name: "", code: "" });
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysTenant | null>(null);
const [adminAccountTouched, setAdminAccountTouched] = useState(false);
const [form] = Form.useForm();
const watchedTenantCode = Form.useWatch("tenantCode", form);
const loadData = async (currentParams = params) => {
setLoading(true);
@ -55,6 +56,7 @@ export default function Tenants() {
const openCreate = () => {
setEditing(null);
setAdminAccountTouched(false);
form.resetFields();
form.setFieldsValue({ status: 1 });
setDrawerOpen(true);
@ -62,10 +64,19 @@ export default function Tenants() {
const openEdit = (record: SysTenant) => {
setEditing(record);
setAdminAccountTouched(false);
form.setFieldsValue({ ...record, expireTime: record.expireTime ? dayjs(record.expireTime) : null });
setDrawerOpen(true);
};
useEffect(() => {
if (!drawerOpen || editing || adminAccountTouched) {
return;
}
const normalizedTenantCode = watchedTenantCode?.trim().toLowerCase();
form.setFieldValue("defaultAdminUsername", normalizedTenantCode ? sanitizeLoginName(`admin@${normalizedTenantCode}`) : undefined);
}, [adminAccountTouched, drawerOpen, editing, form, watchedTenantCode]);
const handleDelete = async (id: number) => {
await deleteTenant(id);
message.success(t("common.success"));
@ -76,7 +87,11 @@ export default function Tenants() {
const values = await form.validateFields();
setSaving(true);
try {
const payload = { ...values, expireTime: values.expireTime ? values.expireTime.format("YYYY-MM-DD HH:mm:ss") : null };
const payload = {
...values,
defaultAdminUsername: values.defaultAdminUsername?.trim(),
expireTime: values.expireTime ? values.expireTime.format("YYYY-MM-DD HH:mm:ss") : null
};
if (editing) {
await updateTenant(editing.id, payload);
} else {
@ -147,26 +162,28 @@ export default function Tenants() {
</Space>
</Card>
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<div className="flex-1 min-h-0 overflow-y-auto" style={{ padding: "24px 24px 0" }}>
<List
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
loading={loading}
dataSource={data}
renderItem={renderTenantCard}
pagination={false}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} /> }}
/>
</div>
<AppPagination
current={params.current}
pageSize={params.size}
total={total}
onChange={(page, size) => setParams({ ...params, current: page, size: size || params.size })}
<div className="flex-1 overflow-y-auto pr-2">
<List
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
loading={loading}
dataSource={data}
renderItem={renderTenantCard}
pagination={{
total,
current: params.current,
pageSize: params.size,
onChange: (page, size) => setParams({ ...params, current: page, size: size || params.size }),
showSizeChanger: true,
showQuickJumper: true,
showTotal: (count) => t("common.total", { total: count }),
pageSizeOptions: ["10", "20", "50", "100"],
style: { marginTop: "24px", marginBottom: "24px" }
}}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("tenantsExt.emptyText")} /> }}
/>
</Card>
</div>
<Drawer title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
<Drawer title={<Space><ShopOutlined aria-hidden="true" /><span>{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}</span></Space>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
@ -180,6 +197,29 @@ export default function Tenants() {
</Form.Item>
</Col>
</Row>
{!editing && (
<Form.Item
label={t("tenants.defaultAdminUsername", { defaultValue: "默认管理员账户" })}
name="defaultAdminUsername"
rules={[
{ required: true, message: t("tenants.defaultAdminUsername", { defaultValue: "默认管理员账户" }) },
{
pattern: LOGIN_NAME_PATTERN,
message: t("tenantsExt.defaultAdminUsernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" })
}
]}
getValueFromEvent={(event) => {
setAdminAccountTouched(true);
return sanitizeLoginName(event?.target?.value);
}}
extra={t("tenantsExt.defaultAdminUsernameTip", { defaultValue: "默认值会根据租户编码自动生成,可手动修改;保存时会校验登录名是否重复。登录名只能输入数字、小写英文、@ 和 _。" })}
>
<Input
placeholder={t("tenantsExt.defaultAdminUsernamePlaceholder", { defaultValue: "默认admin@租户编码小写" })}
className="tabular-nums"
/>
</Form.Item>
)}
<Row gutter={16}>
<Col span={12}>
<Form.Item label={t("tenants.contactName")} name="contactName">

View File

@ -66,6 +66,9 @@ export default function Profile() {
const url = await uploadPlatformAsset(file);
profileForm.setFieldValue("avatarUrl", url);
message.success(t("common.success"));
} catch (error) {
message.error(error instanceof Error ? error.message : t("common.error"));
return Upload.LIST_IGNORE;
} finally {
setAvatarUploading(false);
}
@ -128,7 +131,7 @@ export default function Profile() {
label: <span><SolutionOutlined /> {t("profile.basicInfo")}</span>,
children: (
<Form form={profileForm} layout="vertical" onFinish={handleUpdateProfile} style={{ marginTop: 16 }}>
<Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true }]}>
<Form.Item label={t("users.displayName")} name="displayName" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label={t("users.email")} name="email">
@ -207,42 +210,42 @@ export default function Profile() {
bordered
size="middle"
column={1} items={[
{
key: "bind-status",
label: t("profile.botBindStatus"),
children: credential?.bound
? <Tag color="success">{t("profile.botBound")}</Tag>
: <Tag>{t("profile.botUnbound")}</Tag>
},
{
key: "bot-id",
label: "X-Bot-Id",
children: credential?.botId ? (
<Paragraph copyable={{ text: credential.botId }} style={{ marginBottom: 0 }}>
{credential.botId}
</Paragraph>
) : "-"
},
{
key: "bot-secret",
label: "X-Bot-Secret",
children: credential?.botSecret ? (
<Paragraph copyable={{ text: credential.botSecret }} style={{ marginBottom: 0 }}>
{credential.botSecret}
</Paragraph>
) : t("profile.botSecretHidden")
},
{
key: "last-access-time",
label: t("profile.botLastAccessTime"),
children: renderValue(credential?.lastAccessTime)
},
{
key: "last-access-ip",
label: t("profile.botLastAccessIp"),
children: renderValue(credential?.lastAccessIp)
}
]}
{
key: "bind-status",
label: t("profile.botBindStatus"),
children: credential?.bound
? <Tag color="success">{t("profile.botBound")}</Tag>
: <Tag>{t("profile.botUnbound")}</Tag>
},
{
key: "bot-id",
label: "X-Bot-Id",
children: credential?.botId ? (
<Paragraph copyable={{ text: credential.botId }} style={{ marginBottom: 0 }}>
{credential.botId}
</Paragraph>
) : "-"
},
{
key: "bot-secret",
label: "X-Bot-Secret",
children: credential?.botSecret ? (
<Paragraph copyable={{ text: credential.botSecret }} style={{ marginBottom: 0 }}>
{credential.botSecret}
</Paragraph>
) : t("profile.botSecretHidden")
},
{
key: "last-access-time",
label: t("profile.botLastAccessTime"),
children: renderValue(credential?.lastAccessTime)
},
{
key: "last-access-ip",
label: t("profile.botLastAccessIp"),
children: renderValue(credential?.lastAccessIp)
}
]}
/>
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>

View File

@ -248,10 +248,10 @@ export default function Dictionaries() {
<Drawer title={<Space><BookOutlined aria-hidden="true" /><span>{editingType ? t("dicts.drawerTitleTypeEdit") : t("dicts.drawerTitleTypeCreate")}</span></Space>} open={typeDrawerVisible} onClose={() => setTypeDrawerVisible(false)} width={400} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setTypeDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleTypeSubmit}>{t("common.save")}</Button></div>}>
<Form form={typeForm} layout="vertical">
<Form.Item label={t("dicts.typeCode")} name="typeCode" rules={[{ required: true, message: t("dicts.typeCode") }]}>
<Input disabled={!!editingType} placeholder={t("dicts.typeCode")} className="tabular-nums" />
<Input disabled={!!editingType} placeholder={t("dictsExt.typeCodePlaceholder")} />
</Form.Item>
<Form.Item label={t("dicts.typeName")} name="typeName" rules={[{ required: true, message: t("dicts.typeName") }]}>
<Input placeholder={t("dicts.typeName")} />
<Input placeholder={t("dictsExt.typeNamePlaceholder")} />
</Form.Item>
<Form.Item label={t("common.remark")} name="remark">
<Input.TextArea placeholder={t("dictsExt.typeRemarkPlaceholder")} rows={3} />
@ -259,7 +259,7 @@ export default function Dictionaries() {
</Form>
</Drawer>
<Drawer title={<Space><ProfileOutlined aria-hidden="true" /><span>{editingItem ? t("dicts.drawerTitleItemEdit") : t("dicts.drawerTitleItemCreate")}</span></Space>} open={itemDrawerVisible} onClose={() => setItemDrawerVisible(false)} width={400} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setItemDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleItemSubmit}>{t("common.save")}</Button></div>}>
<Drawer title={<Space><ProfileOutlined aria-hidden="true" /><span>{editingItem ? t("dicts.drawerTitleItemEdit") : t("dicts.drawerTitleItemCreate")}</span></Space>} open={itemDrawerVisible} onClose={() => setItemDrawerVisible(false)} width={400} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setItemDrawerVisible(false)}>{t("common.cancel")}</Button><Button type="primary" onClick={handleItemSubmit}>{t("common.save")}</Button></div>}>
<Form form={itemForm} layout="vertical">
<Form.Item label={t("dicts.typeCode")} name="typeCode"><Input disabled className="tabular-nums" /></Form.Item>
<Form.Item label={t("dicts.itemLabel")} name="itemLabel" rules={[{ required: true, message: t("dicts.itemLabel") }]}><Input placeholder={t("dictsExt.itemLabelPlaceholder")} /></Form.Item>

View File

@ -49,9 +49,14 @@ export default function PlatformSettings() {
}, []);
const handleUpload = async (file: File, fieldName: keyof SysPlatformConfig) => {
const url = await uploadPlatformAsset(file);
form.setFieldValue(fieldName, url);
message.success(t("common.success"));
try {
const url = await uploadPlatformAsset(file);
form.setFieldValue(fieldName, url);
message.success(t("common.success"));
} catch (error) {
// message.error(error instanceof Error ? error.message : t("common.error"));
return Upload.LIST_IGNORE;
}
return false;
};
@ -146,4 +151,4 @@ export default function PlatformSettings() {
</div>
</div>
);
}
}

View File

@ -1,4 +1,4 @@
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, App } from 'antd';
import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from "antd";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from "@ant-design/icons";
@ -13,7 +13,6 @@ import "./index.less";
const { Text } = Typography;
export default function SysParams() {
const { message } = App.useApp();
const { t } = useTranslation();
const { can } = usePermission();
const { items: statusDict } = useDict("sys_common_status");
@ -58,7 +57,7 @@ export default function SysParams() {
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ isSystem: false, status: 1 });
form.setFieldsValue({ isSystem: 0, status: 1 });
setDrawerOpen(true);
};
@ -110,8 +109,24 @@ export default function SysParams() {
title: t("sysParams.paramValue"),
dataIndex: "paramValue",
key: "paramValue",
ellipsis: true,
render: (text: string) => <Tooltip title={text}><Text code>{text}</Text></Tooltip>
width: 360,
ellipsis: { showTitle: false },
render: (text: string) => (
<Tooltip title={text}>
<Text
code
style={{
display: "block",
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{text}
</Text>
</Tooltip>
)
},
{
title: t("sysParams.paramType"),
@ -173,18 +188,18 @@ export default function SysParams() {
</Card>
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
<Table
rowKey="paramId"
columns={columns}
dataSource={data}
loading={loading}
size="middle"
scroll={{ x: "max-content" }}
pagination={false}
/>
</div>
<AppPagination current={queryParams.pageNum || 1} pageSize={queryParams.pageSize || 10} total={total} onChange={handlePageChange} />
<Table
rowKey="paramId"
columns={columns}
dataSource={data}
loading={loading}
size="middle"
tableLayout="fixed"
scroll={{ x: "max-content" }}
pagination={false}
/>
<AppPagination current={queryParams.pageNum || 1} pageSize={queryParams.pageSize || 10} total={total} onChange={handlePageChange} />
</Card>
<Drawer
@ -192,8 +207,7 @@ export default function SysParams() {
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={500}
destroyOnHidden
forceRender
destroyOnClose
footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}
>
<Form form={form} layout="vertical">

View File

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