feat(business): 添加AI模型配置功能实现

- 新增AiModel实体类定义数据库表结构
- 实现AI模型的增删改查REST API接口
- 添加前端AI模型管理页面支持配置展示
- 实现ASR和LLM两种模型类型的区分管理
- 添加模型远程列表获取和验证功能
- 实现默认模型设置和租户权限控制
- 新增AiTask实体用于AI任务调度管理
- 实现AI任务异步处理服务逻辑
- 添加会议转录和总结的完整处理流程
dev_na
chenhao 2026-03-02 19:59:47 +08:00
parent 1a392d96b9
commit 21b3ab3afc
64 changed files with 4752 additions and 6 deletions

View File

@ -273,6 +273,9 @@
| template_name | VARCHAR(100) | NOT NULL | 模板名称 |
| category | VARCHAR(20) | | 分类 (字典: biz_prompt_category) |
| is_system | SMALLINT | DEFAULT 0 | 是否预置 (1:是, 0:否) |
| creator_id | BIGINT | | 创建人ID |
| tags | JSONB | | 标签数组 |
| usage_count | INTEGER | DEFAULT 0 | 使用次数 |
| prompt_content | TEXT | NOT NULL | 提示词内容 |
| status | SMALLINT | DEFAULT 1 | 状态 (1:启用, 0:禁用) |
| remark | VARCHAR(255) | | 备注 |
@ -284,3 +287,56 @@
- `idx_prompt_tenant`: `(tenant_id)`
- `idx_prompt_system`: `(is_system) WHERE is_deleted = 0`
### 5.4 `biz_ai_models`AI 模型管理表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| model_type | VARCHAR(20) | NOT NULL | ASR (语音) 或 LLM (总结) |
| model_name | VARCHAR(100) | NOT NULL | 自定义名称 |
| provider | VARCHAR(50) | | 提供商 (Aliyun, OpenAI等) |
| base_url | VARCHAR(255) | | 基础请求地址 |
| model_code | VARCHAR(100) | | 模型代码 |
| ws_url | VARCHAR(255) | | WebSocket 地址 (ASR) |
| temperature | DECIMAL | DEFAULT 0.7 | 随机性 (LLM) |
| media_config | JSONB | | 采样率、协议等 |
| is_default | SMALLINT | DEFAULT 0 | 默认模型标记 |
| status | SMALLINT | DEFAULT 1 | 启用状态 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
索引:
- `idx_aimodel_tenant`: `(tenant_id)`
- `idx_aimodel_type`: `(model_type, is_default) WHERE is_deleted = 0`
### 5.5 `biz_meetings`(会议主表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| title | VARCHAR(200) | NOT NULL | 会议标题 |
| asr_model_id | BIGINT | | 使用的 ASR 模型 |
| summary_model_id | BIGINT | | 使用的 LLM 模型 |
| prompt_content | TEXT | | **[快照]** 发起任务时的提示词模板内容 |
| summary_content | TEXT | | **[固化]** 最终生成的 Markdown 总结内容 |
| status | SMALLINT | DEFAULT 0 | 0:待处理, 1:识别中, 2:总结中, 3:已完成, 4:失败 |
### 5.6 `biz_meeting_transcripts`(转录明细表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| meeting_id | BIGINT | NOT NULL | 关联会议ID |
| speaker_label | VARCHAR(50) | | 发言人标签 |
| content | TEXT | | 转录文字 |
| start_time | INTEGER | | 开始时间 (ms) |
### 5.7 `biz_ai_tasks`AI 任务流水表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| task_type | VARCHAR(20) | | ASR / SUMMARY |
| request_data | JSONB | | 请求原始数据 |
| response_data | JSONB | | 响应原始数据 |
| status | SMALLINT | | 0:排队, 1:处理中, 2:成功, 3:失败 |

View File

@ -251,7 +251,10 @@ CREATE TABLE biz_hot_words (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL, -- 租户ID (强制隔离)
word VARCHAR(100) NOT NULL, -- 热词原文
pinyin_list JSONB, -- 拼音数组 (支持多音字, 如 ["i mi ting", "i mei ting"])
is_public SMALLINT DEFAULT 0, -- 1:租户公开, 0:个人私有
creator_id BIGINT, -- 创建者ID
pinyin_list JSONB, -- 拼音数组
(, ["i mi ting", "i mei ting"])
match_strategy SMALLINT DEFAULT 1, -- 匹配策略: 1:精确匹配, 2:拼音模糊匹配
category VARCHAR(50), -- 类别 (人名、术语、地名)
weight INTEGER DEFAULT 10, -- 权重 (1-100)
@ -278,6 +281,9 @@ CREATE TABLE biz_prompt_templates (
template_name VARCHAR(100) NOT NULL, -- 模板名称
category VARCHAR(20), -- 分类 (字典: biz_prompt_category)
is_system SMALLINT DEFAULT 0, -- 是否系统预置 (1:是, 0:否)
creator_id BIGINT, -- 创建人ID
tags JSONB, -- 标签数组 (JSONB)
usage_count INTEGER DEFAULT 0, -- 使用次数
prompt_content TEXT NOT NULL, -- 提示词内容
status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用
remark VARCHAR(255), -- 备注
@ -291,6 +297,104 @@ CREATE INDEX idx_prompt_system ON biz_prompt_templates (is_system) WHERE is_dele
COMMENT ON TABLE biz_prompt_templates IS '会议总结提示词模板表';
-- ----------------------------
-- 9. 业务模块 - AI 模型管理
-- ----------------------------
DROP TABLE IF EXISTS biz_ai_models CASCADE;
CREATE TABLE biz_ai_models (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID
model_type VARCHAR(20) NOT NULL, -- 类型: ASR, LLM
model_name VARCHAR(100) NOT NULL, -- 模型显示名称
provider VARCHAR(50), -- 提供商 (Aliyun, OpenAI, Tencent等)
base_url VARCHAR(255), -- 接口基础地址
api_path VARCHAR(100), -- API路径
api_key VARCHAR(255), -- API密钥 (加密存储)
model_code VARCHAR(100), -- 模型真实编码 (如 gpt-4o)
ws_url VARCHAR(255), -- WebSocket 地址 (ASR 专用)
temperature DECIMAL(3,2) DEFAULT 0.7, -- LLM 温度
top_p DECIMAL(3,2) DEFAULT 0.9, -- LLM 核采样
media_config JSONB, -- 媒体参数 (采样率、声道等)
is_default SMALLINT DEFAULT 0, -- 是否默认
status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用
remark VARCHAR(255), -- 备注
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
is_deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_aimodel_tenant ON biz_ai_models (tenant_id);
CREATE INDEX idx_aimodel_type ON biz_ai_models (model_type, is_default) WHERE is_deleted = 0;
COMMENT ON TABLE biz_ai_models IS 'AI 识别与总结模型配置表';
-- ----------------------------
-- 10. 业务模块 - 会议主表
-- ----------------------------
DROP TABLE IF EXISTS biz_meetings CASCADE;
CREATE TABLE biz_meetings (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
title VARCHAR(200) NOT NULL,
meeting_time TIMESTAMP(6),
participants TEXT,
tags VARCHAR(255),
audio_url VARCHAR(500),
creator_id BIGINT, -- 发起人ID
creator_name VARCHAR(100), -- 发起人姓名
asr_model_id BIGINT, -- ASR模型ID
summary_model_id BIGINT, -- LLM模型ID
prompt_content TEXT, -- 发起任务时的提示词模板快照
hot_words JSONB, -- 任务发起时的热词快照
summary_content TEXT, -- Markdown 总结结果
status SMALLINT DEFAULT 0, -- 0:待处理, 1:处理中, 2:成功, 3:失败
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
is_deleted SMALLINT NOT NULL DEFAULT 0
);
-- ----------------------------
-- 11. 业务模块 - 转录明细表
-- ----------------------------
DROP TABLE IF EXISTS biz_meeting_transcripts CASCADE;
CREATE TABLE biz_meeting_transcripts (
id BIGSERIAL PRIMARY KEY,
meeting_id BIGINT NOT NULL,
speaker_id VARCHAR(50), -- ASR返回的发言人标识
speaker_name VARCHAR(100), -- 修改后的发言人姓名
speaker_label VARCHAR(50), -- 发言人标签
content TEXT, -- 转录内容
start_time INTEGER, -- 开始时间(ms)
end_time INTEGER, -- 结束时间(ms)
sort_order INTEGER,
created_at TIMESTAMP(6) NOT NULL DEFAULT now()
);
-- ----------------------------
-- 12. 业务模块 - AI 异步任务日志表
-- ----------------------------
DROP TABLE IF EXISTS biz_ai_tasks CASCADE;
CREATE TABLE biz_ai_tasks (
id BIGSERIAL PRIMARY KEY,
meeting_id BIGINT NOT NULL,
task_type VARCHAR(20), -- ASR / SUMMARY
status SMALLINT DEFAULT 0, -- 0:排队, 1:执行中, 2:成功, 3:失败
request_data JSONB, -- 请求三方原始JSON
response_data JSONB, -- 三方返回原始JSON
error_msg TEXT, -- 错误堆栈
started_at TIMESTAMP(6),
completed_at TIMESTAMP(6)
);
CREATE INDEX idx_meeting_tenant ON biz_meetings (tenant_id);
CREATE INDEX idx_transcript_meeting ON biz_meeting_transcripts (meeting_id);
CREATE INDEX idx_aitask_meeting ON biz_ai_tasks (meeting_id);
COMMENT ON TABLE biz_meetings IS '会议管理主表';
COMMENT ON TABLE biz_meeting_transcripts IS '会议转录明细表';
COMMENT ON TABLE biz_ai_tasks IS 'AI 任务流水日志表';
-- ----------------------------
-- 5. 基础初始化数据
-- ----------------------------

View File

@ -55,7 +55,7 @@ public class MybatisPlusConfig {
}
// 公共表始终忽略过滤
return List.of("sys_tenant","sys_platform_config", "sys_user", "sys_tenant_user", "sys_permission", "sys_role_permission", "sys_user_role", "sys_dict_type", "sys_dict_item", "sys_param", "biz_speakers", "biz_prompt_templates").contains(tableName.toLowerCase());
return List.of("sys_tenant","sys_platform_config", "sys_user", "sys_tenant_user", "sys_permission", "sys_role_permission", "sys_user_role", "sys_dict_type", "sys_dict_item", "sys_param", "biz_speakers", "biz_prompt_templates", "biz_ai_models", "biz_meetings", "biz_meeting_transcripts", "biz_ai_tasks").contains(tableName.toLowerCase());
}
}));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());

View File

@ -0,0 +1,16 @@
package com.imeeting.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Map /api/static/audio/** to local directory
registry.addResourceHandler("/api/static/audio/**")
.addResourceLocations("file:D:/data/imeeting/uploads/audio/");
}
}

View File

@ -0,0 +1,90 @@
package com.imeeting.controller.biz;
import com.imeeting.common.ApiResponse;
import com.imeeting.common.PageResult;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.biz.AiModel;
import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.AiModelService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/biz/aimodel")
public class AiModelController {
private final AiModelService aiModelService;
public AiModelController(AiModelService aiModelService) {
this.aiModelService = aiModelService;
}
@PostMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<AiModelVO> save(@RequestBody AiModelDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// Permission Check: only platform admin can save system models (tenantId=0 implicit via dto field or context)
// If normal user tries to set isSystem or similar (handled by tenantId in service)
return ApiResponse.ok(aiModelService.saveModel(dto));
}
@PutMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<AiModelVO> update(@RequestBody AiModelDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
AiModel existing = aiModelService.getById(dto.getId());
if (existing == null) return ApiResponse.error("模型不存在");
if (Long.valueOf(0).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权修改系统级模型");
}
return ApiResponse.ok(aiModelService.updateModel(dto));
}
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
AiModel existing = aiModelService.getById(id);
if (existing == null) return ApiResponse.ok(true);
if (Long.valueOf(0).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权删除系统级模型");
}
return ApiResponse.ok(aiModelService.removeById(id));
}
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<AiModelVO>>> page(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String name,
@RequestParam(required = false) String type) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(aiModelService.pageModels(current, size, name, type, loginUser.getTenantId()));
}
@GetMapping("/remote-list")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<String>> remoteList(
@RequestParam String provider,
@RequestParam String baseUrl,
@RequestParam(required = false) String apiKey) {
return ApiResponse.ok(aiModelService.fetchRemoteModels(provider, baseUrl, apiKey));
}
@GetMapping("/default")
@PreAuthorize("isAuthenticated()")
public ApiResponse<AiModelVO> getDefault(@RequestParam String type) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(aiModelService.getDefaultModel(type, loginUser.getTenantId()));
}
}

View File

@ -0,0 +1,133 @@
package com.imeeting.controller.biz;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.common.ApiResponse;
import com.imeeting.common.PageResult;
import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.HotWordService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/biz/hotword")
public class HotWordController {
private final HotWordService hotWordService;
public HotWordController(HotWordService hotWordService) {
this.hotWordService = hotWordService;
}
@PostMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<HotWordVO> save(@RequestBody HotWordDTO hotWordDTO) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// 只有管理员可以创建公开热词,普通用户强制为私有
if (Integer.valueOf(1).equals(hotWordDTO.getIsPublic()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
// 这里根据需求:租户内管理员可编辑。由于目前权限模型暂未细化到租户管理员字段,
// 我们暂定具有 ADMIN 角色或 PlatformAdmin 的为管理员。
// 简单处理:普通用户强制设为 0。
hotWordDTO.setIsPublic(0);
}
return ApiResponse.ok(hotWordService.saveHotWord(hotWordDTO, loginUser.getUserId()));
}
@PutMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<HotWordVO> update(@RequestBody HotWordDTO hotWordDTO) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
HotWord existing = hotWordService.getById(hotWordDTO.getId());
if (existing == null) return ApiResponse.error("热词不存在");
// 权限校验:公开热词仅管理员可改,私有热词仅本人可改
if (Integer.valueOf(1).equals(existing.getIsPublic())) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权修改公开热词");
}
} else {
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
return ApiResponse.error("无权修改他人私有热词");
}
}
return ApiResponse.ok(hotWordService.updateHotWord(hotWordDTO));
}
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
HotWord existing = hotWordService.getById(id);
if (existing == null) return ApiResponse.ok(true);
if (Integer.valueOf(1).equals(existing.getIsPublic())) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权删除公开热词");
}
} else {
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
return ApiResponse.error("无权删除他人私有热词");
}
}
return ApiResponse.ok(hotWordService.removeById(id));
}
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<HotWordVO>>> page(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String word,
@RequestParam(required = false) String category) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Page<HotWord> page = hotWordService.page(new Page<>(current, size),
new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, loginUser.getTenantId())
.and(w -> w.eq(HotWord::getCreatorId, loginUser.getUserId()).or().eq(HotWord::getIsPublic, 1))
.like(word != null && !word.isEmpty(), HotWord::getWord, word)
.eq(category != null && !category.isEmpty(), HotWord::getCategory, category)
.orderByDesc(HotWord::getIsPublic) // 公开的排在前面
.orderByDesc(HotWord::getCreatedAt));
List<HotWordVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
PageResult<List<HotWordVO>> result = new PageResult<>();
result.setTotal(page.getTotal());
result.setRecords(vos);
return ApiResponse.ok(result);
}
@GetMapping("/pinyin")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<String>> getPinyin(@RequestParam String word) {
return ApiResponse.ok(hotWordService.generatePinyin(word));
}
private HotWordVO toVO(HotWord entity) {
HotWordVO vo = new HotWordVO();
vo.setId(entity.getId());
vo.setWord(entity.getWord());
vo.setPinyinList(entity.getPinyinList());
vo.setMatchStrategy(entity.getMatchStrategy());
vo.setCategory(entity.getCategory());
vo.setWeight(entity.getWeight());
vo.setStatus(entity.getStatus());
vo.setIsPublic(entity.getIsPublic());
vo.setCreatorId(entity.getCreatorId());
vo.setIsSynced(entity.getIsSynced());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
}

View File

@ -0,0 +1,127 @@
package com.imeeting.controller.biz;
import com.imeeting.common.ApiResponse;
import com.imeeting.common.PageResult;
import com.imeeting.dto.biz.MeetingDTO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.MeetingService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/biz/meeting")
public class MeetingController {
private final MeetingService meetingService;
public MeetingController(MeetingService meetingService) {
this.meetingService = meetingService;
}
@PostMapping("/upload")
@PreAuthorize("isAuthenticated()")
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
String uploadDir = "D:/data/imeeting/uploads/audio/";
File dir = new File(uploadDir);
if (!dir.exists()) dir.mkdirs();
String fileName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename();
file.transferTo(new File(uploadDir + fileName));
return ApiResponse.ok("/api/static/audio/" + fileName);
}
@PostMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingVO> create(@RequestBody MeetingDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
dto.setTenantId(loginUser.getTenantId());
dto.setCreatorId(loginUser.getUserId());
dto.setCreatorName(loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername());
return ApiResponse.ok(meetingService.createMeeting(dto));
}
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<MeetingVO>>> page(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String title,
@RequestParam(defaultValue = "all") String viewType) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(meetingService.pageMeetings(current, size, title,
loginUser.getTenantId(), loginUser.getUserId(),
loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(),
viewType));
}
@GetMapping("/detail/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingVO> getDetail(@PathVariable Long id) {
return ApiResponse.ok(meetingService.getDetail(id));
}
@GetMapping("/transcripts/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id) {
return ApiResponse.ok(meetingService.getTranscripts(id));
}
@PutMapping("/speaker")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> updateSpeaker(@RequestBody Map<String, Object> params) {
Long meetingId = Long.valueOf(params.get("meetingId").toString());
String speakerId = params.get("speakerId").toString();
String newName = params.get("newName") != null ? params.get("newName").toString() : null;
String label = params.get("label") != null ? params.get("label").toString() : null;
meetingService.updateSpeakerInfo(meetingId, speakerId, newName, label);
return ApiResponse.ok(true);
}
@PostMapping("/re-summary")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> reSummary(@RequestBody Map<String, Object> params) {
Long meetingId = Long.valueOf(params.get("meetingId").toString());
Long summaryModelId = Long.valueOf(params.get("summaryModelId").toString());
Long promptId = Long.valueOf(params.get("promptId").toString());
meetingService.reSummary(meetingId, summaryModelId, promptId);
return ApiResponse.ok(true);
}
@PutMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> update(@RequestBody Meeting meeting) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Meeting existing = meetingService.getById(meeting.getId());
if (existing == null) return ApiResponse.error("会议不存在");
// 权限校验:仅发起人或管理员可修改
if (!existing.getCreatorId().equals(loginUser.getUserId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权修改此会议信息");
}
// 仅允许修改标题、人员、标签等基本信息
return ApiResponse.ok(meetingService.updateById(meeting));
}
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
meetingService.deleteMeeting(id);
return ApiResponse.ok(true);
}
}

View File

@ -0,0 +1,115 @@
package com.imeeting.controller.biz;
import com.imeeting.common.ApiResponse;
import com.imeeting.common.PageResult;
import com.imeeting.dto.biz.PromptTemplateDTO;
import com.imeeting.dto.biz.PromptTemplateVO;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.PromptTemplateService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/biz/prompt")
public class PromptTemplateController {
private final PromptTemplateService promptTemplateService;
public PromptTemplateController(PromptTemplateService promptTemplateService) {
this.promptTemplateService = promptTemplateService;
}
@PostMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<PromptTemplateVO> save(@RequestBody PromptTemplateDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// Only platform admin can create system templates
if (Integer.valueOf(1).equals(dto.getIsSystem()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权创建系统模板");
}
return ApiResponse.ok(promptTemplateService.saveTemplate(dto, loginUser.getUserId()));
}
@PutMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<PromptTemplateVO> update(@RequestBody PromptTemplateDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
PromptTemplate existing = promptTemplateService.getById(dto.getId());
if (existing == null) {
return ApiResponse.error("模板不存在");
}
// System template protection
if (Integer.valueOf(1).equals(existing.getIsSystem())) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权修改系统模板");
}
} else {
// Personal template protection
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
return ApiResponse.error("无权修改他人模板");
}
}
return ApiResponse.ok(promptTemplateService.updateTemplate(dto));
}
@PutMapping("/{id}/status")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
PromptTemplate existing = promptTemplateService.getById(id);
if (existing == null) return ApiResponse.error("模板不存在");
if (Integer.valueOf(1).equals(existing.getIsSystem())) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权修改系统模板");
}
} else {
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
return ApiResponse.error("无权修改他人模板");
}
}
existing.setStatus(status);
return ApiResponse.ok(promptTemplateService.updateById(existing));
}
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
PromptTemplate existing = promptTemplateService.getById(id);
if (existing == null) {
return ApiResponse.ok(true);
}
if (Integer.valueOf(1).equals(existing.getIsSystem())) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权删除系统模板");
}
} else {
if (!existing.getCreatorId().equals(loginUser.getUserId())) {
return ApiResponse.error("无权删除他人模板");
}
}
return ApiResponse.ok(promptTemplateService.removeById(id));
}
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<PromptTemplateVO>>> page(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String name,
@RequestParam(required = false) String category) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(promptTemplateService.pageTemplates(current, size, name, category, loginUser.getTenantId(), loginUser.getUserId()));
}
}

View File

@ -0,0 +1,76 @@
package com.imeeting.controller.biz;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.ApiResponse;
import com.imeeting.dto.biz.SpeakerRegisterDTO;
import com.imeeting.dto.biz.SpeakerVO;
import com.imeeting.entity.biz.Speaker;
import com.imeeting.security.LoginUser;
import com.imeeting.service.biz.SpeakerService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/biz/speaker")
public class SpeakerController {
private final SpeakerService speakerService;
public SpeakerController(SpeakerService speakerService) {
this.speakerService = speakerService;
}
@PostMapping("/register")
@PreAuthorize("isAuthenticated()")
public ApiResponse<SpeakerVO> register(@ModelAttribute SpeakerRegisterDTO registerDTO) {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (!(principal instanceof LoginUser)) {
return ApiResponse.error("未获取到用户信息");
}
LoginUser loginUser = (LoginUser) principal;
registerDTO.setUserId(loginUser.getUserId());
// 自动取当前登录人姓名,如果没有,可以用登录名兜底
registerDTO.setName(loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername());
return ApiResponse.ok(speakerService.register(registerDTO));
}
@GetMapping("/list")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<SpeakerVO>> list() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (!(principal instanceof LoginUser)) {
return ApiResponse.error("未获取到用户信息");
}
LoginUser loginUser = (LoginUser) principal;
if (loginUser.getUserId() == null) {
return ApiResponse.error("无效的用户ID");
}
List<Speaker> list = speakerService.list(new LambdaQueryWrapper<Speaker>()
.eq(Speaker::getUserId, loginUser.getUserId())
.orderByDesc(Speaker::getCreatedAt));
List<SpeakerVO> vos = list.stream().map(this::toVO).collect(Collectors.toList());
return ApiResponse.ok(vos);
}
private SpeakerVO toVO(Speaker speaker) {
SpeakerVO vo = new SpeakerVO();
vo.setId(speaker.getId());
vo.setName(speaker.getName());
vo.setUserId(speaker.getUserId());
vo.setVoicePath(speaker.getVoicePath());
vo.setVoiceExt(speaker.getVoiceExt());
vo.setVoiceSize(speaker.getVoiceSize());
vo.setStatus(speaker.getStatus());
vo.setRemark(speaker.getRemark());
vo.setCreatedAt(speaker.getCreatedAt());
return vo;
}
}

View File

@ -0,0 +1,14 @@
package com.imeeting.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class CreateTenantDTO {
private String tenantCode;
private String tenantName;
private String contactName;
private String contactPhone;
private String remark;
private LocalDateTime expireTime;
}

View File

@ -0,0 +1,9 @@
package com.imeeting.dto;
import lombok.Data;
@Data
public class PasswordUpdateDTO {
private String oldPassword;
private String newPassword;
}

View File

@ -0,0 +1,24 @@
package com.imeeting.dto.biz;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Map;
@Data
public class AiModelDTO {
private Long id;
private String modelType;
private String modelName;
private String provider;
private String baseUrl;
private String apiPath;
private String apiKey;
private String modelCode;
private String wsUrl;
private BigDecimal temperature;
private BigDecimal topP;
private Map<String, Object> mediaConfig;
private Integer isDefault;
private Integer status;
private String remark;
}

View File

@ -0,0 +1,27 @@
package com.imeeting.dto.biz;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
@Data
public class AiModelVO {
private Long id;
private Long tenantId;
private String modelType;
private String modelName;
private String provider;
private String baseUrl;
private String apiPath;
private String apiKey; // Will be masked in actual implementation
private String modelCode;
private String wsUrl;
private BigDecimal temperature;
private BigDecimal topP;
private Map<String, Object> mediaConfig;
private Integer isDefault;
private Integer status;
private String remark;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,17 @@
package com.imeeting.dto.biz;
import lombok.Data;
import java.util.List;
@Data
public class HotWordDTO {
private Long id;
private String word;
private List<String> pinyinList;
private Integer matchStrategy;
private String category;
private Integer weight;
private Integer status;
private Integer isPublic;
private String remark;
}

View File

@ -0,0 +1,22 @@
package com.imeeting.dto.biz;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class HotWordVO {
private Long id;
private String word;
private List<String> pinyinList;
private Integer isPublic;
private Long creatorId;
private Integer matchStrategy;
private String category;
private Integer weight;
private Integer status;
private Integer isSynced;
private String remark;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,26 @@
package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class MeetingDTO {
private Long id;
private Long tenantId;
private String title;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime meetingTime;
private String participants;
private String tags;
private String audioUrl;
private Long creatorId;
private String creatorName;
private Long asrModelId;
private Long summaryModelId;
private Long promptId;
private List<String> hotWords;
}

View File

@ -0,0 +1,15 @@
package com.imeeting.dto.biz;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class MeetingTranscriptVO {
private Long id;
private String speakerId;
private String speakerName;
private String speakerLabel;
private String content;
private Integer startTime;
private Integer endTime;
}

View File

@ -0,0 +1,25 @@
package com.imeeting.dto.biz;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class MeetingVO {
private Long id;
private Long tenantId;
private Long creatorId;
private String title;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime meetingTime;
private String participants;
private String tags;
private String audioUrl;
private String summaryContent;
private Integer status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,15 @@
package com.imeeting.dto.biz;
import lombok.Data;
@Data
public class PromptTemplateDTO {
private Long id;
private String templateName;
private String category;
private Integer isSystem;
private java.util.List<String> tags;
private String promptContent;
private Integer status;
private String remark;
}

View File

@ -0,0 +1,21 @@
package com.imeeting.dto.biz;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class PromptTemplateVO {
private Long id;
private Long tenantId;
private Long creatorId;
private String templateName;
private String category;
private Integer isSystem;
private java.util.List<String> tags;
private Integer usageCount;
private String promptContent;
private Integer status;
private String remark;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,12 @@
package com.imeeting.dto.biz;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
@Data
public class SpeakerRegisterDTO {
private String name;
private Long userId;
private String remark;
private MultipartFile file;
}

View File

@ -0,0 +1,17 @@
package com.imeeting.dto.biz;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class SpeakerVO {
private Long id;
private String name;
private Long userId;
private String voicePath;
private String voiceExt;
private Long voiceSize;
private Integer status;
private String remark;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,48 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.imeeting.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.util.Map;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "biz_ai_models", autoResultMap = true)
public class AiModel extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String modelType;
private String modelName;
private String provider;
private String baseUrl;
private String apiPath;
private String apiKey;
private String modelCode;
private String wsUrl;
private BigDecimal temperature;
private BigDecimal topP;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> mediaConfig;
private Integer isDefault;
private String remark;
}

View File

@ -0,0 +1,36 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
@Data
@TableName(value = "biz_ai_tasks", autoResultMap = true)
public class AiTask {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long meetingId;
private String taskType;
private Integer status;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> requestData;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> responseData;
private String errorMsg;
private LocalDateTime startedAt;
private LocalDateTime completedAt;
}

View File

@ -0,0 +1,39 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.imeeting.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "biz_hot_words", autoResultMap = true)
public class HotWord extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String word;
private Integer isPublic;
private Long creatorId;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<String> pinyinList;
private Integer matchStrategy;
private String category;
private Integer weight;
private Integer isSynced;
private String remark;
}

View File

@ -0,0 +1,46 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.imeeting.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "biz_meetings", autoResultMap = true)
public class Meeting extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String title;
private LocalDateTime meetingTime;
private String participants;
private String tags;
private String audioUrl;
private Long creatorId;
private String creatorName;
private Long asrModelId;
private Long summaryModelId;
private String promptContent;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<String> hotWords;
private String summaryContent;
}

View File

@ -0,0 +1,33 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_meeting_transcripts")
public class MeetingTranscript {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long meetingId;
private String speakerId;
private String speakerName;
private String speakerLabel;
private String content;
private Integer startTime;
private Integer endTime;
private Integer sortOrder;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,33 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.imeeting.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "biz_prompt_templates", autoResultMap = true)
public class PromptTemplate extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String templateName;
private String category;
private Integer isSystem;
private Long creatorId;
@com.baomidou.mybatisplus.annotation.TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private java.util.List<String> tags;
private Integer usageCount;
private String promptContent;
private String remark;
}

View File

@ -0,0 +1,35 @@
package com.imeeting.entity.biz;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.imeeting.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("biz_speakers")
public class Speaker extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long userId;
private String name;
private String voicePath;
private String voiceExt;
private Long voiceSize;
private String remark;
@TableField(exist = false)
private Long tenantId;
// Note: status, createdAt, updatedAt, isDeleted are in BaseEntity
// embedding is reserved for future pgvector use
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.AiModel;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiModelMapper extends BaseMapper<AiModel> {
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.AiTask;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiTaskMapper extends BaseMapper<AiTask> {
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.HotWord;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HotWordMapper extends BaseMapper<HotWord> {
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.Meeting;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MeetingMapper extends BaseMapper<Meeting> {
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.MeetingTranscript;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MeetingTranscriptMapper extends BaseMapper<MeetingTranscript> {
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.PromptTemplate;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PromptTemplateMapper extends BaseMapper<PromptTemplate> {
}

View File

@ -0,0 +1,9 @@
package com.imeeting.mapper.biz;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.biz.Speaker;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SpeakerMapper extends BaseMapper<Speaker> {
}

View File

@ -0,0 +1,17 @@
package com.imeeting.service.biz;
import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.common.PageResult;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.biz.AiModel;
import java.util.List;
public interface AiModelService extends IService<AiModel> {
AiModelVO saveModel(AiModelDTO dto);
AiModelVO updateModel(AiModelDTO dto);
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId);
List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey);
AiModelVO getDefaultModel(String type, Long tenantId);
}

View File

@ -0,0 +1,9 @@
package com.imeeting.service.biz;
import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.entity.biz.AiTask;
public interface AiTaskService extends IService<AiTask> {
void dispatchTasks(Long meetingId);
void dispatchSummaryTask(Long meetingId);
}

View File

@ -0,0 +1,14 @@
package com.imeeting.service.biz;
import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO;
import com.imeeting.entity.biz.HotWord;
import java.util.List;
public interface HotWordService extends IService<HotWord> {
HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId);
HotWordVO updateHotWord(HotWordDTO hotWordDTO);
List<String> generatePinyin(String word);
}

View File

@ -0,0 +1,21 @@
package com.imeeting.service.biz;
import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.common.PageResult;
import com.imeeting.dto.biz.MeetingDTO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import java.util.List;
public interface MeetingService extends IService<Meeting> {
MeetingVO createMeeting(MeetingDTO dto);
PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType);
void deleteMeeting(Long id);
MeetingVO getDetail(Long id);
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label);
void reSummary(Long meetingId, Long summaryModelId, Long promptId);
}

View File

@ -0,0 +1,16 @@
package com.imeeting.service.biz;
import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.dto.biz.PromptTemplateDTO;
import com.imeeting.dto.biz.PromptTemplateVO;
import com.imeeting.entity.biz.PromptTemplate;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.common.PageResult;
import java.util.List;
public interface PromptTemplateService extends IService<PromptTemplate> {
PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId);
PromptTemplateVO updateTemplate(PromptTemplateDTO dto);
PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category, Long tenantId, Long userId);
}

View File

@ -0,0 +1,10 @@
package com.imeeting.service.biz;
import com.baomidou.mybatisplus.extension.service.IService;
import com.imeeting.dto.biz.SpeakerRegisterDTO;
import com.imeeting.dto.biz.SpeakerVO;
import com.imeeting.entity.biz.Speaker;
public interface SpeakerService extends IService<Speaker> {
SpeakerVO register(SpeakerRegisterDTO registerDTO);
}

View File

@ -0,0 +1,211 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.common.PageResult;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.biz.AiModel;
import com.imeeting.mapper.biz.AiModelMapper;
import com.imeeting.service.biz.AiModelService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Slf4j
public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> implements AiModelService {
@Override
@Transactional(rollbackFor = Exception.class)
public AiModelVO saveModel(AiModelDTO dto) {
AiModel entity = new AiModel();
copyProperties(dto, entity);
handleAsrWsUrl(entity);
handleDefaultLogic(entity);
this.save(entity);
return toVO(entity);
}
@Override
@Transactional(rollbackFor = Exception.class)
public AiModelVO updateModel(AiModelDTO dto) {
AiModel entity = this.getById(dto.getId());
if (entity == null) throw new RuntimeException("Model not found");
copyProperties(dto, entity);
handleAsrWsUrl(entity);
handleDefaultLogic(entity);
this.updateById(entity);
return toVO(entity);
}
@Override
public PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId) {
Page<AiModel> page = this.page(new Page<>(current, size),
new LambdaQueryWrapper<AiModel>()
.and(wrapper -> wrapper.eq(AiModel::getTenantId, tenantId).or().eq(AiModel::getTenantId, 0L))
.eq(type != null && !type.isEmpty(), AiModel::getModelType, type)
.like(name != null && !name.isEmpty(), AiModel::getModelName, name)
.orderByDesc(AiModel::getTenantId)
.orderByDesc(AiModel::getCreatedAt));
List<AiModelVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
PageResult<List<AiModelVO>> result = new PageResult<>();
result.setTotal(page.getTotal());
result.setRecords(vos);
return result;
}
@Override
public List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey) {
try {
// 针对 ASR 模型,优先使用指定的 3050 地址进行探测
String targetUrl;
if (baseUrl != null && baseUrl.contains("3050")) {
targetUrl = "http://10.100.51.199:3050/api/asrconfig";
} else if (baseUrl != null && !baseUrl.isEmpty()) {
// LLM 类型:通用 OpenAI 风格探测
targetUrl = baseUrl.endsWith("/") ? baseUrl + "models" : baseUrl + "/models";
} else {
return Collections.emptyList();
}
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder()
.followRedirects(java.net.http.HttpClient.Redirect.ALWAYS)
.build();
java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(targetUrl))
.timeout(java.time.Duration.ofSeconds(10))
.GET();
if (apiKey != null && !apiKey.isEmpty()) {
requestBuilder.header("Authorization", "Bearer " + apiKey);
}
java.net.http.HttpResponse<String> response = client.send(requestBuilder.build(),
java.net.http.HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
String body = response.body();
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode node = mapper.readTree(body);
List<String> models = new java.util.ArrayList<>();
// 1. 适配特定的 ASR 接口格式: { "data": { "available_models": [...] } }
if (node.has("data") && node.get("data").has("available_models") && node.get("data").get("available_models").isArray()) {
for (com.fasterxml.jackson.databind.JsonNode m : node.get("data").get("available_models")) {
models.add(m.asText());
}
}
// 2. 适配标准 OpenAI 格式: { "data": [ { "id": "..." } ] }
else if (node.has("data") && node.get("data").isArray()) {
for (com.fasterxml.jackson.databind.JsonNode m : node.get("data")) {
models.add(m.has("id") ? m.get("id").asText() : m.asText());
}
}
// 3. 适配简单数组格式: [ "..." ]
else if (node.isArray()) {
for (com.fasterxml.jackson.databind.JsonNode m : node) {
models.add(m.asText());
}
}
// 4. 适配带有 models 字段的格式
else if (node.has("models") && node.get("models").isArray()) {
for (com.fasterxml.jackson.databind.JsonNode m : node.get("models")) {
models.add(m.asText());
}
}
return models;
}
} catch (Exception e) {
log.error("Fetch remote models error: {}", e.getMessage());
}
return Collections.emptyList();
}
@Override
public AiModelVO getDefaultModel(String type, Long tenantId) {
AiModel model = this.getOne(new LambdaQueryWrapper<AiModel>()
.eq(AiModel::getModelType, type)
.eq(AiModel::getIsDefault, 1)
.and(w -> w.eq(AiModel::getTenantId, tenantId).or().eq(AiModel::getTenantId, 0L))
.orderByDesc(AiModel::getTenantId) // 租户优先
.last("LIMIT 1"));
return model != null ? toVO(model) : null;
}
private void handleDefaultLogic(AiModel entity) {
if (Integer.valueOf(1).equals(entity.getIsDefault())) {
// Unset other defaults for the same tenant and type
this.update(new LambdaUpdateWrapper<AiModel>()
.set(AiModel::getIsDefault, 0)
.eq(AiModel::getTenantId, entity.getTenantId())
.eq(AiModel::getModelType, entity.getModelType())
.eq(AiModel::getIsDefault, 1));
}
}
private void handleAsrWsUrl(AiModel entity) {
if ("ASR".equals(entity.getModelType()) && (entity.getWsUrl() == null || entity.getWsUrl().isEmpty())) {
if (entity.getBaseUrl() != null) {
String ws = entity.getBaseUrl().replace("http://", "ws://").replace("https://", "wss://");
entity.setWsUrl(ws);
}
}
}
private void copyProperties(AiModelDTO dto, AiModel entity) {
entity.setModelType(dto.getModelType());
entity.setModelName(dto.getModelName());
entity.setProvider(dto.getProvider());
entity.setBaseUrl(dto.getBaseUrl());
entity.setApiPath(dto.getApiPath());
entity.setApiKey(dto.getApiKey());
entity.setModelCode(dto.getModelCode());
entity.setWsUrl(dto.getWsUrl());
entity.setTemperature(dto.getTemperature());
entity.setTopP(dto.getTopP());
entity.setMediaConfig(dto.getMediaConfig());
entity.setIsDefault(dto.getIsDefault());
entity.setStatus(dto.getStatus());
entity.setRemark(dto.getRemark());
}
private AiModelVO toVO(AiModel entity) {
AiModelVO vo = new AiModelVO();
vo.setId(entity.getId());
vo.setTenantId(entity.getTenantId());
vo.setModelType(entity.getModelType());
vo.setModelName(entity.getModelName());
vo.setProvider(entity.getProvider());
vo.setBaseUrl(entity.getBaseUrl());
vo.setApiPath(entity.getApiPath());
// Mask ApiKey
if (entity.getApiKey() != null && entity.getApiKey().length() > 8) {
vo.setApiKey(entity.getApiKey().substring(0, 4) + "****" + entity.getApiKey().substring(entity.getApiKey().length() - 4));
}
vo.setModelCode(entity.getModelCode());
vo.setWsUrl(entity.getWsUrl());
vo.setTemperature(entity.getTemperature());
vo.setTopP(entity.getTopP());
vo.setMediaConfig(entity.getMediaConfig());
vo.setIsDefault(entity.getIsDefault());
vo.setStatus(entity.getStatus());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
}

View File

@ -0,0 +1,256 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.entity.biz.AiModel;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.AiTaskService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
@RequiredArgsConstructor
public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> implements AiTaskService {
private final MeetingMapper meetingMapper;
private final MeetingTranscriptMapper transcriptMapper;
private final AiModelService aiModelService;
private final ObjectMapper objectMapper;
@Value("${app.server-base-url}")
private String serverBaseUrl;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
@Override
@Async
public void dispatchTasks(Long meetingId) {
log.info("Starting real AI processing for meeting ID: {}", meetingId);
Meeting meeting = meetingMapper.selectById(meetingId);
if (meeting == null) return;
try {
// 1. 执行 ASR 识别
String asrText = processAsrTask(meeting);
// 2. 执行 LLM 总结
processSummaryTask(meeting, asrText);
} catch (Exception e) {
log.error("Meeting {} AI Task Flow failed", meetingId, e);
Meeting updateMeeting = new Meeting();
updateMeeting.setId(meetingId);
updateMeeting.setStatus(4); // Overall Failed
meetingMapper.updateById(updateMeeting);
}
}
@Override
@Async
public void dispatchSummaryTask(Long meetingId) {
Meeting meeting = meetingMapper.selectById(meetingId);
if (meeting == null) return;
try {
// 获取已有转录全文
List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.orderByAsc(MeetingTranscript::getStartTime));
String asrText = transcripts.stream()
.map(t -> (t.getSpeakerName() != null ? t.getSpeakerName() : t.getSpeakerId()) + ": " + t.getContent())
.collect(Collectors.joining("\n"));
processSummaryTask(meeting, asrText);
} catch (Exception e) {
log.error("Re-summary failed for meeting {}", meetingId, e);
Meeting updateMeeting = new Meeting();
updateMeeting.setId(meetingId);
updateMeeting.setStatus(4); // Failed
meetingMapper.updateById(updateMeeting);
}
}
private String processAsrTask(Meeting meeting) throws Exception {
updateMeetingStatus(meeting.getId(), 1); // 识别中
AiModel asrModel = aiModelService.getById(meeting.getAsrModelId());
if (asrModel == null) throw new RuntimeException("ASR Model config not found");
// 构建请求参数
Map<String, Object> req = new HashMap<>();
String fullAudioUrl = serverBaseUrl + meeting.getAudioUrl();
req.put("file_path", fullAudioUrl);
req.put("hotwords", meeting.getHotWords() != null ? meeting.getHotWords() : Collections.emptyList());
req.put("use_spk_id", true);
AiTask taskRecord = createAiTask(meeting.getId(), "ASR", req);
// 提交任务
String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition";
String respBody = postJson(submitUrl, req);
JsonNode submitNode = objectMapper.readTree(respBody);
if (submitNode.get("code").asInt() != 200) {
updateAiTaskFail(taskRecord, "Submission Failed: " + respBody);
throw new RuntimeException("ASR submission failed");
}
String taskId = submitNode.get("data").get("task_id").asText();
// 轮询状态
String queryUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/" + taskId : asrModel.getBaseUrl() + "/api/tasks/" + taskId;
JsonNode resultNode = null;
for (int i = 0; i < 300; i++) { // Max 10 minutes
Thread.sleep(2000);
String queryResp = get(queryUrl);
JsonNode statusNode = objectMapper.readTree(queryResp);
String status = statusNode.get("data").get("status").asText();
if ("completed".equalsIgnoreCase(status)) {
resultNode = statusNode.get("data").get("result");
updateAiTaskSuccess(taskRecord, statusNode);
break;
} else if ("failed".equalsIgnoreCase(status)) {
updateAiTaskFail(taskRecord, "ASR Engine reported failure: " + queryResp);
throw new RuntimeException("ASR processing failed at engine");
}
}
if (resultNode == null) throw new RuntimeException("ASR polling timeout");
// 解析并入库转录明细
StringBuilder sb = new StringBuilder();
if (resultNode.has("segments")) {
int order = 0;
for (JsonNode seg : resultNode.get("segments")) {
MeetingTranscript mt = new MeetingTranscript();
mt.setMeetingId(meeting.getId());
mt.setSpeakerId(seg.has("speaker") ? seg.get("speaker").asText() : "spk_0");
mt.setSpeakerName(mt.getSpeakerId());
mt.setContent(seg.get("text").asText());
if (seg.has("timestamp")) {
mt.setStartTime(seg.get("timestamp").get(0).asInt());
mt.setEndTime(seg.get("timestamp").get(1).asInt());
}
mt.setSortOrder(order++);
transcriptMapper.insert(mt);
sb.append(mt.getSpeakerName()).append(": ").append(mt.getContent()).append("\n");
}
}
return sb.toString();
}
private void processSummaryTask(Meeting meeting, String asrText) throws Exception {
updateMeetingStatus(meeting.getId(), 2); // 总结中
AiModel llmModel = aiModelService.getById(meeting.getSummaryModelId());
if (llmModel == null) return;
Map<String, Object> req = new HashMap<>();
req.put("model", llmModel.getModelCode());
req.put("temperature", llmModel.getTemperature());
List<Map<String, String>> messages = new ArrayList<>();
messages.add(Map.of("role", "system", "content", meeting.getPromptContent()));
messages.add(Map.of("role", "user", "content", "请总结以下内容:\n" + asrText));
req.put("messages", messages);
AiTask taskRecord = createAiTask(meeting.getId(), "SUMMARY", req);
String url = llmModel.getBaseUrl() + (llmModel.getApiPath() != null ? llmModel.getApiPath() : "/v1/chat/completions");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + llmModel.getApiKey())
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(req)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode respNode = objectMapper.readTree(response.body());
if (response.statusCode() == 200 && respNode.has("choices")) {
String content = respNode.get("choices").get(0).get("message").get("content").asText();
meeting.setSummaryContent(content);
meeting.setStatus(3); // Finished
meetingMapper.updateById(meeting);
updateAiTaskSuccess(taskRecord, respNode);
} else {
updateAiTaskFail(taskRecord, "LLM failed: " + response.body());
throw new RuntimeException("LLM processing failed");
}
}
// --- Helpers ---
private String postJson(String url, Object body) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
return httpClient.send(request, HttpResponse.BodyHandlers.ofString()).body();
}
private String get(String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
return httpClient.send(request, HttpResponse.BodyHandlers.ofString()).body();
}
private void updateMeetingStatus(Long id, int status) {
Meeting m = new Meeting();
m.setId(id);
m.setStatus(status);
meetingMapper.updateById(m);
}
private AiTask createAiTask(Long meetingId, String type, Map<String, Object> req) {
AiTask task = new AiTask();
task.setMeetingId(meetingId);
task.setTaskType(type);
task.setStatus(1); // Processing
task.setRequestData(req);
task.setStartedAt(LocalDateTime.now());
this.save(task);
return task;
}
private void updateAiTaskSuccess(AiTask task, JsonNode resp) {
task.setStatus(2);
task.setResponseData(objectMapper.convertValue(resp, Map.class));
task.setCompletedAt(LocalDateTime.now());
this.updateById(task);
}
private void updateAiTaskFail(AiTask task, String error) {
task.setStatus(3);
task.setErrorMsg(error);
task.setCompletedAt(LocalDateTime.now());
this.updateById(task);
}
}

View File

@ -0,0 +1,132 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.mapper.biz.HotWordMapper;
import com.imeeting.service.biz.HotWordService;
import lombok.extern.slf4j.Slf4j;
import net.sourceforge.pinyin4j.PinyinHelper;
import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType;
import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat;
import net.sourceforge.pinyin4j.format.HanyuPinyinToneType;
import net.sourceforge.pinyin4j.format.exception.BadHanyuPinyinOutputFormatCombination;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
public class HotWordServiceImpl extends ServiceImpl<HotWordMapper, HotWord> implements HotWordService {
@Override
@Transactional(rollbackFor = Exception.class)
public HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId) {
HotWord hotWord = new HotWord();
copyProperties(hotWordDTO, hotWord);
hotWord.setCreatorId(userId);
if (hotWord.getPinyinList() == null || hotWord.getPinyinList().isEmpty()) {
hotWord.setPinyinList(generatePinyin(hotWord.getWord()));
}
this.save(hotWord);
return toVO(hotWord);
}
@Override
@Transactional(rollbackFor = Exception.class)
public HotWordVO updateHotWord(HotWordDTO hotWordDTO) {
HotWord hotWord = this.getById(hotWordDTO.getId());
if (hotWord == null) {
throw new RuntimeException("Hotword not found");
}
String oldWord = hotWord.getWord();
copyProperties(hotWordDTO, hotWord);
if (!oldWord.equals(hotWord.getWord()) && (hotWordDTO.getPinyinList() == null || hotWordDTO.getPinyinList().isEmpty())) {
hotWord.setPinyinList(generatePinyin(hotWord.getWord()));
}
this.updateById(hotWord);
return toVO(hotWord);
}
@Override
public List<String> generatePinyin(String word) {
if (word == null || word.isEmpty()) return Collections.emptyList();
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
format.setCaseType(HanyuPinyinCaseType.LOWERCASE);
format.setToneType(HanyuPinyinToneType.WITHOUT_TONE);
List<List<String>> pinyinMatrix = new ArrayList<>();
for (char c : word.toCharArray()) {
List<String> charPinyins = new ArrayList<>();
try {
String[] pinyins = PinyinHelper.toHanyuPinyinStringArray(c, format);
if (pinyins != null) {
for (String py : pinyins) {
if (!charPinyins.contains(py)) charPinyins.add(py);
}
} else {
charPinyins.add(String.valueOf(c));
}
} catch (BadHanyuPinyinOutputFormatCombination e) {
charPinyins.add(String.valueOf(c));
}
pinyinMatrix.add(charPinyins);
}
List<String> combinations = new ArrayList<>();
generateCombinations(pinyinMatrix, 0, "", combinations);
return combinations.stream().limit(5).collect(Collectors.toList());
}
private void generateCombinations(List<List<String>> matrix, int index, String current, List<String> result) {
if (index == matrix.size()) {
result.add(current.trim());
return;
}
for (String py : matrix.get(index)) {
generateCombinations(matrix, index + 1, current + " " + py, result);
}
}
private void copyProperties(HotWordDTO dto, HotWord entity) {
entity.setWord(dto.getWord());
entity.setPinyinList(dto.getPinyinList());
entity.setMatchStrategy(dto.getMatchStrategy());
entity.setCategory(dto.getCategory());
entity.setWeight(dto.getWeight());
entity.setStatus(dto.getStatus());
entity.setIsPublic(dto.getIsPublic());
entity.setRemark(dto.getRemark());
}
private HotWordVO toVO(HotWord entity) {
HotWordVO vo = new HotWordVO();
vo.setId(entity.getId());
vo.setWord(entity.getWord());
vo.setPinyinList(entity.getPinyinList());
vo.setMatchStrategy(entity.getMatchStrategy());
vo.setCategory(entity.getCategory());
vo.setWeight(entity.getWeight());
vo.setStatus(entity.getStatus());
vo.setIsPublic(entity.getIsPublic());
vo.setCreatorId(entity.getCreatorId());
vo.setIsSynced(entity.getIsSynced());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
}

View File

@ -0,0 +1,215 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.common.PageResult;
import com.imeeting.dto.biz.MeetingDTO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.AiModel;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.SysUser;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.mapper.SysUserMapper;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.HotWordService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> implements MeetingService {
private final AiModelService aiModelService;
private final PromptTemplateService promptTemplateService;
private final AiTaskService aiTaskService;
private final MeetingTranscriptMapper transcriptMapper;
private final HotWordService hotWordService;
private final SysUserMapper sysUserMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createMeeting(MeetingDTO dto) {
Meeting meeting = new Meeting();
meeting.setTitle(dto.getTitle());
meeting.setMeetingTime(dto.getMeetingTime());
// 存储 User ID 字符串
meeting.setParticipants(dto.getParticipants());
meeting.setTags(dto.getTags());
meeting.setAudioUrl(dto.getAudioUrl());
meeting.setAsrModelId(dto.getAsrModelId());
meeting.setSummaryModelId(dto.getSummaryModelId());
meeting.setCreatorId(dto.getCreatorId());
meeting.setCreatorName(dto.getCreatorName());
if (dto.getPromptId() != null) {
PromptTemplate template = promptTemplateService.getById(dto.getPromptId());
if (template != null) {
meeting.setPromptContent(template.getPromptContent());
}
}
List<String> finalHotWords = dto.getHotWords();
if (finalHotWords == null || finalHotWords.isEmpty()) {
finalHotWords = hotwordServiceList(dto.getTenantId());
}
meeting.setHotWords(finalHotWords);
meeting.setStatus(0);
this.save(meeting);
aiTaskService.dispatchTasks(meeting.getId());
return toVO(meeting);
}
private List<String> hotwordServiceList(Long tenantId) {
return hotWordService.list(new LambdaQueryWrapper<HotWord>()
.eq(HotWord::getTenantId, tenantId)
.eq(HotWord::getStatus, 1))
.stream().map(HotWord::getWord).collect(Collectors.toList());
}
@Override
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType) {
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>()
.eq(Meeting::getTenantId, tenantId);
String userIdStr = String.valueOf(userId);
if ("created".equals(viewType)) {
wrapper.eq(Meeting::getCreatorId, userId);
} else if ("involved".equals(viewType)) {
// 匹配包含 ,ID, 的结构
wrapper.and(w -> w.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr))
.ne(Meeting::getCreatorId, userId);
} else {
wrapper.and(w -> w.eq(Meeting::getCreatorId, userId)
.or()
.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr));
}
if (title != null && !title.isEmpty()) {
wrapper.like(Meeting::getTitle, title);
}
wrapper.orderByDesc(Meeting::getCreatedAt);
Page<Meeting> page = this.page(new Page<>(current, size), wrapper);
List<MeetingVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
PageResult<List<MeetingVO>> result = new PageResult<>();
result.setTotal(page.getTotal());
result.setRecords(vos);
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteMeeting(Long id) {
this.removeById(id);
}
@Override
public MeetingVO getDetail(Long id) {
Meeting meeting = this.getById(id);
return meeting != null ? toVO(meeting) : null;
}
@Override
public List<MeetingTranscriptVO> getTranscripts(Long meetingId) {
return transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.orderByAsc(MeetingTranscript::getStartTime))
.stream().map(t -> {
MeetingTranscriptVO vo = new MeetingTranscriptVO();
vo.setId(t.getId());
vo.setSpeakerId(t.getSpeakerId());
vo.setSpeakerName(t.getSpeakerName());
vo.setSpeakerLabel(t.getSpeakerLabel());
vo.setContent(t.getContent());
vo.setStartTime(t.getStartTime());
vo.setEndTime(t.getEndTime());
return vo;
}).collect(Collectors.toList());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label) {
transcriptMapper.update(null, new LambdaUpdateWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.eq(MeetingTranscript::getSpeakerId, speakerId)
.set(newName != null, MeetingTranscript::getSpeakerName, newName)
.set(label != null, MeetingTranscript::getSpeakerLabel, label));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void reSummary(Long meetingId, Long summaryModelId, Long promptId) {
Meeting meeting = this.getById(meetingId);
if (meeting == null) throw new RuntimeException("Meeting not found");
meeting.setSummaryModelId(summaryModelId);
if (promptId != null) {
PromptTemplate template = promptTemplateService.getById(promptId);
if (template != null) {
meeting.setPromptContent(template.getPromptContent());
}
}
meeting.setStatus(2);
this.updateById(meeting);
aiTaskService.dispatchSummaryTask(meetingId);
}
private MeetingVO toVO(Meeting meeting) {
MeetingVO vo = new MeetingVO();
vo.setId(meeting.getId());
vo.setTenantId(meeting.getTenantId());
vo.setTitle(meeting.getTitle());
vo.setMeetingTime(meeting.getMeetingTime());
vo.setTags(meeting.getTags());
vo.setAudioUrl(meeting.getAudioUrl());
vo.setStatus(meeting.getStatus());
vo.setSummaryContent(meeting.getSummaryContent());
vo.setCreatedAt(meeting.getCreatedAt());
// 解析参与者 ID 列表为 姓名
if (meeting.getParticipants() != null && !meeting.getParticipants().isEmpty()) {
try {
List<Long> userIds = Arrays.stream(meeting.getParticipants().split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Long::valueOf)
.collect(Collectors.toList());
if (!userIds.isEmpty()) {
List<SysUser> users = sysUserMapper.selectBatchIds(userIds);
String names = users.stream()
.map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername())
.collect(Collectors.joining(", "));
vo.setParticipants(names);
}
} catch (Exception e) {
// 兼容老数据(如果以前存的是姓名)
vo.setParticipants(meeting.getParticipants());
}
}
return vo;
}
}

View File

@ -0,0 +1,90 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.common.PageResult;
import com.imeeting.dto.biz.PromptTemplateDTO;
import com.imeeting.dto.biz.PromptTemplateVO;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.mapper.biz.PromptTemplateMapper;
import com.imeeting.service.biz.PromptTemplateService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper, PromptTemplate> implements PromptTemplateService {
@Override
@Transactional(rollbackFor = Exception.class)
public PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId) {
PromptTemplate entity = new PromptTemplate();
copyProperties(dto, entity);
entity.setCreatorId(userId);
entity.setUsageCount(0);
this.save(entity);
return toVO(entity);
}
@Override
@Transactional(rollbackFor = Exception.class)
public PromptTemplateVO updateTemplate(PromptTemplateDTO dto) {
PromptTemplate entity = this.getById(dto.getId());
if (entity == null) {
throw new RuntimeException("Template not found");
}
copyProperties(dto, entity);
this.updateById(entity);
return toVO(entity);
}
@Override
public PageResult<List<PromptTemplateVO>> pageTemplates(Integer current, Integer size, String name, String category, Long tenantId, Long userId) {
Page<PromptTemplate> page = this.page(new Page<>(current, size),
new LambdaQueryWrapper<PromptTemplate>()
.and(wrapper -> wrapper.eq(PromptTemplate::getCreatorId, userId)
.or()
.eq(PromptTemplate::getIsSystem, 1))
.like(name != null && !name.isEmpty(), PromptTemplate::getTemplateName, name)
.eq(category != null && !category.isEmpty(), PromptTemplate::getCategory, category)
.orderByDesc(PromptTemplate::getIsSystem)
.orderByDesc(PromptTemplate::getCreatedAt));
List<PromptTemplateVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
PageResult<List<PromptTemplateVO>> result = new PageResult<>();
result.setTotal(page.getTotal());
result.setRecords(vos);
return result;
}
private void copyProperties(PromptTemplateDTO dto, PromptTemplate entity) {
entity.setTemplateName(dto.getTemplateName());
entity.setCategory(dto.getCategory());
entity.setIsSystem(dto.getIsSystem());
entity.setPromptContent(dto.getPromptContent());
entity.setTags(dto.getTags());
entity.setStatus(dto.getStatus());
entity.setRemark(dto.getRemark());
}
private PromptTemplateVO toVO(PromptTemplate entity) {
PromptTemplateVO vo = new PromptTemplateVO();
vo.setId(entity.getId());
vo.setTenantId(entity.getTenantId());
vo.setTemplateName(entity.getTemplateName());
vo.setCategory(entity.getCategory());
vo.setIsSystem(entity.getIsSystem());
vo.setTags(entity.getTags());
vo.setUsageCount(entity.getUsageCount());
vo.setPromptContent(entity.getPromptContent());
vo.setStatus(entity.getStatus());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
}

View File

@ -0,0 +1,99 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.imeeting.dto.biz.SpeakerRegisterDTO;
import com.imeeting.dto.biz.SpeakerVO;
import com.imeeting.entity.biz.Speaker;
import com.imeeting.mapper.biz.SpeakerMapper;
import com.imeeting.service.biz.SpeakerService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;
@Slf4j
@Service
public class SpeakerServiceImpl extends ServiceImpl<SpeakerMapper, Speaker> implements SpeakerService {
@Value("${app.upload-path}")
private String uploadPath;
@Override
@Transactional(rollbackFor = Exception.class)
public SpeakerVO register(SpeakerRegisterDTO registerDTO) {
MultipartFile file = registerDTO.getFile();
if (file == null || file.isEmpty()) {
throw new RuntimeException("Voice file is required");
}
// 1. 检查是否已存在该用户的声纹记录
Speaker speaker = this.getOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Speaker>()
.eq(Speaker::getUserId, registerDTO.getUserId()));
boolean isNew = (speaker == null);
if (isNew) {
speaker = new Speaker();
speaker.setUserId(registerDTO.getUserId());
}
String originalFilename = file.getOriginalFilename();
String extension = "";
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
}
// Ensure directory exists
Path voiceprintDir = Paths.get(uploadPath, "voiceprints");
try {
if (!Files.exists(voiceprintDir)) {
Files.createDirectories(voiceprintDir);
}
} catch (IOException e) {
log.error("Create voiceprints directory error", e);
throw new RuntimeException("Failed to initialize storage");
}
// 2. 生成文件名如果是更新可以考虑删除旧文件这里简单起见生成新UUID
String fileName = UUID.randomUUID().toString() + extension;
Path filePath = voiceprintDir.resolve(fileName);
try {
Files.copy(file.getInputStream(), filePath);
} catch (IOException e) {
log.error("Save voice file error", e);
throw new RuntimeException("Failed to save voice file");
}
// 3. 更新实体信息
speaker.setName(registerDTO.getName()); // 由 Controller 传入当前登录人姓名
speaker.setVoicePath("voiceprints/" + fileName);
speaker.setVoiceExt(extension.replace(".", ""));
speaker.setVoiceSize(file.getSize());
speaker.setStatus(1); // 已保存
this.saveOrUpdate(speaker);
return toVO(speaker);
}
private SpeakerVO toVO(Speaker speaker) {
SpeakerVO vo = new SpeakerVO();
vo.setId(speaker.getId());
vo.setName(speaker.getName());
vo.setUserId(speaker.getUserId());
vo.setVoicePath(speaker.getVoicePath());
vo.setVoiceExt(speaker.getVoiceExt());
vo.setVoiceSize(speaker.getVoiceSize());
vo.setStatus(speaker.getStatus());
vo.setRemark(speaker.getRemark());
vo.setCreatedAt(speaker.getCreatedAt());
return vo;
}
}

View File

@ -14,6 +14,16 @@ spring:
database: 15
cache:
type: redis
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
jackson:
date-format: yyyy-MM-dd HH:mm:ss
serialization:
write-dates-as-timestamps: false
time-zone: GMT+8
mybatis-plus:
configuration:
@ -30,6 +40,7 @@ security:
secret: change-me-please-change-me-32bytes
app:
server-base-url: http://10.100.52.13:8080 # 本地应用对外暴露的 IP 和端口
upload-path: D:/data/imeeting/uploads/
resource-prefix: /api/static/
captcha:

View File

@ -0,0 +1,85 @@
import http from "../http";
export interface AiModelVO {
id: number;
tenantId: number;
modelType: 'ASR' | 'LLM';
modelName: string;
provider?: string;
baseUrl?: string;
apiPath?: string;
apiKey?: string;
modelCode?: string;
wsUrl?: string;
temperature?: number;
topP?: number;
mediaConfig?: Record<string, any>;
isDefault: number;
status: number;
remark?: string;
createdAt: string;
}
export interface AiModelDTO {
id?: number;
modelType: string;
modelName: string;
provider?: string;
baseUrl?: string;
apiPath?: string;
apiKey?: string;
modelCode?: string;
wsUrl?: string;
temperature?: number;
topP?: number;
mediaConfig?: Record<string, any>;
isDefault: number;
status: number;
remark?: string;
}
export const getAiModelPage = (params: {
current: number;
size: number;
name?: string;
type?: string;
}) => {
return http.get<any, { code: string; data: { records: AiModelVO[]; total: number }; msg: string }>(
"/api/biz/aimodel/page",
{ params }
);
};
export const saveAiModel = (data: AiModelDTO) => {
return http.post<any, { code: string; data: AiModelVO; msg: string }>(
"/api/biz/aimodel",
data
);
};
export const updateAiModel = (data: AiModelDTO) => {
return http.put<any, { code: string; data: AiModelVO; msg: string }>(
"/api/biz/aimodel",
data
);
};
export const deleteAiModel = (id: number) => {
return http.delete<any, { code: string; data: boolean; msg: string }>(
`/api/biz/aimodel/${id}`
);
};
export const getRemoteModelList = (params: { provider: string; baseUrl: string; apiKey?: string }) => {
return http.get<any, { code: string; data: string[]; msg: string }>(
"/api/biz/aimodel/remote-list",
{ params }
);
};
export const getAiModelDefault = (type: 'ASR' | 'LLM') => {
return http.get<any, { code: string; data: AiModelVO; msg: string }>(
"/api/biz/aimodel/default",
{ params: { type } }
);
};

View File

@ -0,0 +1,74 @@
import http from "../http";
export interface HotWordVO {
id: number;
word: string;
pinyinList: string[];
isPublic: number;
creatorId: number;
matchStrategy: number;
category: string;
weight: number;
status: number;
isSynced: number;
remark?: string;
createdAt: string;
updatedAt: string;
}
export interface HotWordDTO {
id?: number;
word: string;
pinyinList?: string[];
matchStrategy: number;
category?: string;
weight: number;
status: number;
remark?: string;
}
export const getHotWordPage = (params: {
current: number;
size: number;
word?: string;
category?: string;
matchStrategy?: number
}) => {
return http.get<any, { code: string; data: { records: HotWordVO[]; total: number }; msg: string }>(
"/api/biz/hotword/page",
{ params }
);
};
export const syncHotWord = (id: number) => {
return http.post<any, { code: string; data: boolean; msg: string }>(
`/api/biz/hotword/${id}/sync`
);
};
export const saveHotWord = (data: HotWordDTO) => {
return http.post<any, { code: string; data: HotWordVO; msg: string }>(
"/api/biz/hotword",
data
);
};
export const updateHotWord = (data: HotWordDTO) => {
return http.put<any, { code: string; data: HotWordVO; msg: string }>(
"/api/biz/hotword",
data
);
};
export const deleteHotWord = (id: number) => {
return http.delete<any, { code: string; data: boolean; msg: string }>(
`/api/biz/hotword/${id}`
);
};
export const getPinyinSuggestion = (word: string) => {
return http.get<any, { code: string; data: string[]; msg: string }>(
"/api/biz/hotword/pinyin",
{ params: { word } }
);
};

View File

@ -0,0 +1,104 @@
import http from "../http";
export interface MeetingVO {
id: number;
tenantId: number;
creatorId: number;
title: string;
meetingTime: string;
participants: string;
tags: string;
audioUrl: string;
summaryContent: string;
status: number;
createdAt: string;
}
export interface MeetingDTO {
id?: number;
title: string;
meetingTime: string;
participants: string;
tags: string;
audioUrl: string;
asrModelId: number;
promptId: number;
}
export const getMeetingPage = (params: {
current: number;
size: number;
title?: string;
viewType?: 'all' | 'created' | 'involved';
}) => {
return http.get<any, { code: string; data: { records: MeetingVO[]; total: number }; msg: string }>(
"/api/biz/meeting/page",
{ params }
);
};
export const createMeeting = (data: MeetingDTO) => {
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
"/api/biz/meeting",
data
);
};
export const deleteMeeting = (id: number) => {
return http.delete<any, { code: string; data: boolean; msg: string }>(
`/api/biz/meeting/${id}`
);
};
export interface MeetingTranscriptVO {
id: number;
speakerId: string;
speakerName: string;
speakerLabel: string;
content: string;
startTime: number;
endTime: number;
}
export const getMeetingDetail = (id: number) => {
return http.get<any, { code: string; data: MeetingVO; msg: string }>(
`/api/biz/meeting/detail/${id}`
);
};
export const getTranscripts = (id: number) => {
return http.get<any, { code: string; data: MeetingTranscriptVO[]; msg: string }>(
`/api/biz/meeting/transcripts/${id}`
);
};
export const updateSpeakerInfo = (params: { meetingId: number; speakerId: string; newName: string; label: string }) => {
return http.put<any, { code: string; data: boolean; msg: string }>(
"/api/biz/meeting/speaker",
params
);
};
export const reSummary = (params: { meetingId: number; summaryModelId: number; promptId: number }) => {
return http.post<any, { code: string; data: boolean; msg: string }>(
"/api/biz/meeting/re-summary",
params
);
};
export const updateMeeting = (data: Partial<MeetingVO>) => {
return http.put<any, { code: string; data: boolean; msg: string }>(
"/api/biz/meeting",
data
);
};
export const uploadAudio = (file: File) => {
const formData = new FormData();
formData.append("file", file);
return http.post<any, { code: string; data: string; msg: string }>(
"/api/biz/meeting/upload",
formData,
{ headers: { "Content-Type": "multipart/form-data" } }
);
};

View File

@ -0,0 +1,68 @@
import http from "../http";
export interface PromptTemplateVO {
id: number;
tenantId: number;
creatorId: number;
templateName: string;
category: string;
isSystem: number;
tags?: string[];
usageCount: number;
promptContent: string;
status: number;
remark?: string;
createdAt: string;
updatedAt: string;
}
export interface PromptTemplateDTO {
id?: number;
templateName: string;
category: string;
isSystem: number;
tags?: string[];
promptContent: string;
status: number;
remark?: string;
}
export const getPromptPage = (params: {
current: number;
size: number;
name?: string;
category?: string;
}) => {
return http.get<any, { code: string; data: { records: PromptTemplateVO[]; total: number }; msg: string }>(
"/api/biz/prompt/page",
{ params }
);
};
export const savePromptTemplate = (data: PromptTemplateDTO) => {
return http.post<any, { code: string; data: PromptTemplateVO; msg: string }>(
"/api/biz/prompt",
data
);
};
export const updatePromptTemplate = (data: PromptTemplateDTO) => {
return http.put<any, { code: string; data: PromptTemplateVO; msg: string }>(
"/api/biz/prompt",
data
);
};
export const deletePromptTemplate = (id: number) => {
return http.delete<any, { code: string; data: boolean; msg: string }>(
`/api/biz/prompt/${id}`
);
};
export const updatePromptStatus = (id: number, status: number) => {
return http.put<any, { code: string; data: boolean; msg: string }>(
`/api/biz/prompt/${id}/status`,
null,
{ params: { status } }
);
};

View File

@ -0,0 +1,44 @@
import http from "../http";
export interface SpeakerVO {
id: number;
name: string;
userId?: number;
voicePath: string;
voiceExt: string;
voiceSize: number;
status: number;
remark?: string;
createdAt: string;
}
export interface SpeakerRegisterParams {
name: string;
userId?: number;
remark?: string;
file: File | Blob;
}
export const registerSpeaker = (params: SpeakerRegisterParams) => {
const formData = new FormData();
formData.append("name", params.name);
if (params.userId) formData.append("userId", params.userId.toString());
if (params.remark) formData.append("remark", params.remark);
formData.append("file", params.file, "voice.wav");
return http.post<any, { code: string; data: SpeakerVO; msg: string }>(
"/api/biz/speaker/register",
formData,
{
headers: {
"Content-Type": "multipart/form-data"
}
}
);
};
export const getSpeakerList = () => {
return http.get<any, { code: string; data: SpeakerVO[]; msg: string }>(
"/api/biz/speaker/list"
);
};

View File

@ -18,7 +18,8 @@ import {
ShopOutlined,
AudioOutlined,
TagsOutlined,
BulbOutlined
BulbOutlined,
ApiOutlined
} from "@ant-design/icons";
import { useAuth } from "../hooks/useAuth";
import { usePermission } from "../hooks/usePermission";
@ -38,6 +39,7 @@ const iconMap: Record<string, any> = {
"audio": <AudioOutlined />,
"hotword": <TagsOutlined />,
"prompt": <BulbOutlined />,
"aimodel": <ApiOutlined />,
};
export default function AppLayout() {

View File

@ -0,0 +1,339 @@
import React, { useState, useEffect } from 'react';
import { Table, Card, Button, Input, Space, Drawer, Form, Select, Tag, message, Popconfirm, Typography, Divider, Tooltip, Row, Col, InputNumber, Switch, Radio } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, SyncOutlined, SearchOutlined, SafetyCertificateOutlined, SaveOutlined, ApiOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useDict } from '../../hooks/useDict';
import {
getAiModelPage,
saveAiModel,
updateAiModel,
deleteAiModel,
getRemoteModelList,
AiModelVO,
AiModelDTO
} from '../../api/business/aimodel';
const { Option } = Select;
const { Text, Title } = Typography;
const AiModels: React.FC = () => {
const [form] = Form.useForm();
const { items: providers } = useDict('biz_ai_provider');
const [loading, setLoading] = useState(false);
const [data, setData] = useState<AiModelVO[]>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [size, setSize] = useState(10);
const [searchName, setSearchName] = useState('');
const [searchType, setSearchType] = useState<string | undefined>(undefined);
const [drawerVisible, setDrawerVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [fetchLoading, setFetchLoading] = useState(false);
const [remoteModels, setRemoteModels] = useState<string[]>([]);
const [modelType, setModelType] = useState<'ASR' | 'LLM'>('ASR');
// Check if current user is platform admin
const isPlatformAdmin = React.useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
if (profileStr) {
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true;
}
return false;
}, []);
useEffect(() => {
fetchData();
}, [current, size, searchName, searchType]);
const fetchData = async () => {
setLoading(true);
try {
const res = await getAiModelPage({ current, size, name: searchName, type: searchType });
if (res.data && res.data.data && res.data.data.records) {
setData(res.data.data.records);
setTotal(res.data.data.total);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleOpenDrawer = (record?: AiModelVO) => {
setRemoteModels([]);
if (record) {
setEditingId(record.id);
setModelType(record.modelType);
form.setFieldsValue(record);
if (record.modelCode) setRemoteModels([record.modelCode]);
} else {
setEditingId(null);
setModelType('ASR');
form.resetFields();
form.setFieldsValue({ status: 1, isDefault: 0, temperature: 0.7, topP: 0.9, modelType: 'ASR' });
}
setDrawerVisible(true);
};
const handleFetchRemote = async () => {
const vals = form.getFieldsValue(['provider', 'baseUrl', 'apiKey']);
if (!vals.provider || !vals.baseUrl) {
message.warning('请先填写提供商和基础地址');
return;
}
setFetchLoading(true);
try {
const res = await getRemoteModelList(vals);
// res.data 是后端的 ApiResponse, res.data.data 才是模型字符串数组
if (res.data && Array.isArray(res.data.data)) {
setRemoteModels(res.data.data);
message.success(`成功获取 ${res.data.data.length} 个模型`);
} else {
setRemoteModels([]);
}
} catch (err) {
console.error(err);
} finally {
setFetchLoading(false);
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitLoading(true);
if (editingId) {
await updateAiModel({ ...values, id: editingId });
message.success('更新成功');
} else {
await saveAiModel(values);
message.success('添加成功');
}
setDrawerVisible(false);
fetchData();
} catch (err) {
console.error(err);
} finally {
setSubmitLoading(false);
}
};
const columns = [
{
title: '模型名称',
dataIndex: 'modelName',
key: 'modelName',
render: (text: string, record: AiModelVO) => (
<Space>
{text}
{record.isDefault === 1 && <Tag color="gold"></Tag>}
{record.tenantId === 0 && <Tooltip title="系统预置"><SafetyCertificateOutlined style={{ color: '#52c41a' }} /></Tooltip>}
</Space>
)
},
{
title: '类型',
dataIndex: 'modelType',
key: 'modelType',
render: (type: string) => <Tag color={type === 'ASR' ? 'blue' : 'purple'}>{type === 'ASR' ? '语音识别' : '会议总结'}</Tag>
},
{
title: '提供商',
dataIndex: 'provider',
key: 'provider',
render: (val: string) => {
const item = providers.find(i => i.itemValue === val);
return item ? <Tag>{item.itemLabel}</Tag> : val;
}
},
{
title: '模型代码',
dataIndex: 'modelCode',
key: 'modelCode',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: number) => status === 1 ? <Tag color="green"></Tag> : <Tag color="default"></Tag>
},
{
title: '操作',
key: 'action',
render: (_: any, record: AiModelVO) => {
const canEdit = record.tenantId !== 0 || isPlatformAdmin;
return (
<Space size="middle">
{canEdit && <Button type="link" icon={<EditOutlined />} onClick={() => handleOpenDrawer(record)}></Button>}
{canEdit && (
<Popconfirm title="确定删除吗?" onConfirm={() => deleteAiModel(record.id).then(() => fetchData())}>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
)}
</Space>
);
}
}
];
return (
<div style={{ padding: '24px' }}>
<Card title="AI 模型配置" extra={
<Space wrap>
<Radio.Group value={searchType} onChange={e => setSearchType(e.target.value)} buttonStyle="solid">
<Radio.Button value={undefined}></Radio.Button>
<Radio.Button value="ASR"></Radio.Button>
<Radio.Button value="LLM"></Radio.Button>
</Radio.Group>
<Input
placeholder="搜索模型名称"
prefix={<SearchOutlined />}
allowClear
onPressEnter={(e) => setSearchName((e.target as any).value)}
style={{ width: 180 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenDrawer()}></Button>
</Space>
}>
<Table columns={columns} dataSource={data} rowKey="id" loading={loading}
pagination={{ current, pageSize: size, total, onChange: (p, s) => { setCurrent(p); setSize(s); }}}
/>
</Card>
<Drawer
title={<Title level={4} style={{ margin: 0 }}>{editingId ? '编辑模型配置' : '添加模型配置'}</Title>}
width={600}
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
extra={
<Space>
<Button onClick={() => setDrawerVisible(false)}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={handleSubmit}></Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item name="modelType" label="模型用途" rules={[{ required: true }]}>
<Radio.Group onChange={e => setModelType(e.target.value)} disabled={!!editingId}>
<Radio.Button value="ASR"> (ASR)</Radio.Button>
<Radio.Button value="LLM"> (LLM)</Radio.Button>
</Radio.Group>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="modelName" label="配置显示名称" rules={[{ required: true, message: '请输入显示名称' }]}>
<Input placeholder="如: 阿里云语音-高速版" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
<Select placeholder="选择厂商" allowClear>
{providers.map(item => (
<Option key={item.itemValue} value={item.itemValue}>{item.itemLabel}</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="baseUrl" label="API 基础地址 (Base URL)" rules={[{ required: true }]}>
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
<Form.Item name="apiKey" label="API Key / Secret" tooltip="密钥将加密存储,仅在更新时需重新输入">
<Input.Password placeholder="输入您的 API 密钥" />
</Form.Item>
<Divider orientation="left" style={{ fontSize: '14px', color: '#999' }}></Divider>
<Form.Item label="模型编码 (Model Code)" required>
<Space.Compact style={{ width: '100%' }}>
<Form.Item
name="modelCode"
noStyle
rules={[{ required: true, message: '请输入或选择模型编码' }]}
getValueFromEvent={(value) => {
// 如果是数组tags模式返回数组取最后一个值作为最终模型编码
return Array.isArray(value) ? value[value.length - 1] : value;
}}
>
<Select
placeholder="选择建议或直接输入 (回车确认)"
mode="tags"
maxCount={1}
showSearch
allowClear
style={{ width: 'calc(100% - 100px)' }}
filterOption={(input, option) =>
(option?.children as unknown as string).toLowerCase().includes(input.toLowerCase())
}
>
{remoteModels.map(m => <Option key={m} value={m}>{m}</Option>)}
</Select>
</Form.Item>
<Button icon={<SyncOutlined spin={fetchLoading} />} onClick={handleFetchRemote} style={{ width: 100 }}></Button>
</Space.Compact>
</Form.Item>
{modelType === 'ASR' && (
<Form.Item name="wsUrl" label="WebSocket 地址" tooltip="留空则根据 Base URL 自动推断 (http->ws)">
<Input placeholder="wss://api.example.com/v1/ws" />
</Form.Item>
)}
{modelType === 'LLM' && (
<>
<Form.Item name="apiPath" label="API 路径" initialValue="/chat/completions">
<Input placeholder="/chat/completions" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="temperature" label="Temperature (随机性)">
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="topP" label="Top P (核采样)">
<InputNumber min={0} max={1} step={0.1} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
</>
)}
<Row gutter={16}>
<Col span={8}>
<Form.Item name="isDefault" label="设为默认" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否"
onChange={checked => form.setFieldsValue({ isDefault: checked ? 1 : 0 })}
checked={form.getFieldValue('isDefault') === 1}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="status" label="启用状态" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关"
onChange={checked => form.setFieldsValue({ status: checked ? 1 : 0 })}
checked={form.getFieldValue('status') === 1}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="备注说明">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Drawer>
</div>
);
};
export default AiModels;

View File

@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react';
import {
Table, Card, Button, Input, Space, Modal, Form, Select,
InputNumber, Tag, message, Popconfirm, Divider, Tooltip,
Radio, Row, Col, Typography, Badge
} from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined,
UserOutlined, GlobalOutlined
} from '@ant-design/icons';
import { useDict } from '../../hooks/useDict';
import {
getHotWordPage,
saveHotWord,
updateHotWord,
deleteHotWord,
getPinyinSuggestion,
HotWordVO,
HotWordDTO
} from '../../api/business/hotword';
const { Option } = Select;
const { Text } = Typography;
const HotWords: React.FC = () => {
const [form] = Form.useForm();
const [searchForm] = Form.useForm();
const { items: categories, loading: dictLoading } = useDict('biz_hotword_category');
const [loading, setLoading] = useState(false);
const [data, setData] = useState<HotWordVO[]>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [size, setSize] = useState(10);
const [searchWord, setSearchWord] = useState('');
const [searchCategory, setSearchCategory] = useState<string | undefined>(undefined);
const [modalVisible, setModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
// 获取当前用户信息
const userProfile = React.useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? JSON.parse(profileStr) : {};
}, []);
const isPlatformAdmin = userProfile.isPlatformAdmin === true;
useEffect(() => {
fetchData();
}, [current, size, searchWord, searchCategory]);
const fetchData = async () => {
setLoading(true);
try {
const res = await getHotWordPage({
current,
size,
word: searchWord,
category: searchCategory
});
if (res.data && res.data.data) {
setData(res.data.data.records);
setTotal(res.data.data.total);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleOpenModal = (record?: HotWordVO) => {
if (record) {
// 权限校验
if (record.isPublic === 1 && !isPlatformAdmin) {
message.error('公开热词仅限管理员修改');
return;
}
setEditingId(record.id);
form.setFieldsValue({
...record,
pinyinList: record.pinyinList.join(', ')
});
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({ weight: 10, status: 1, isPublic: 0 });
}
setModalVisible(true);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitLoading(true);
const payload: any = {
...values,
pinyinList: values.pinyinList
? (Array.isArray(values.pinyinList) ? values.pinyinList : values.pinyinList.split(',').map((s: string) => s.trim()).filter(Boolean))
: []
};
if (editingId) {
await updateHotWord({ ...payload, id: editingId });
message.success('更新成功');
} else {
await saveHotWord(payload);
message.success('添加成功');
}
setModalVisible(false);
fetchData();
} catch (err) {
console.error(err);
} finally {
setSubmitLoading(false);
}
};
const handleWordBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
const word = e.target.value;
if (word) {
try {
const res = await getPinyinSuggestion(word);
if (res.data && res.data.data) {
form.setFieldsValue({ pinyinList: res.data.data.join(', ') });
}
} catch (err) {
console.error(err);
}
}
};
const columns = [
{
title: '热词原文',
dataIndex: 'word',
key: 'word',
render: (text: string, record: HotWordVO) => (
<Space>
<Text strong>{text}</Text>
{record.isPublic === 1 ? (
<Tooltip title="全租户公开"><GlobalOutlined style={{ color: '#52c41a' }} /></Tooltip>
) : (
<Tooltip title="仅个人私有"><UserOutlined style={{ color: '#1890ff' }} /></Tooltip>
)}
</Space>
)
},
{
title: '拼音',
dataIndex: 'pinyinList',
key: 'pinyinList',
render: (list: string[]) => (
<Space size={[0, 4]} wrap>
{list?.map(p => <Tag key={p} style={{ fontSize: '11px', borderRadius: 4 }}>{p}</Tag>)}
</Space>
)
},
{
title: '类别',
dataIndex: 'category',
key: 'category',
render: (val: string) => categories.find(i => i.itemValue === val)?.itemLabel || val
},
{
title: '类型',
dataIndex: 'isPublic',
key: 'isPublic',
render: (val: number) => val === 1 ? <Tag color="green"></Tag> : <Tag color="blue"></Tag>
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: number) => status === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />
},
{
title: '操作',
key: 'action',
render: (_: any, record: HotWordVO) => {
const canEdit = record.isPublic === 1 ? isPlatformAdmin : record.creatorId === userProfile.userId;
return (
<Space size="middle">
{canEdit ? (
<>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(record)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => deleteHotWord(record.id).then(fetchData)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</>
) : (
<Text type="secondary" size="small"></Text>
)}
</Space>
);
}
}
];
return (
<div style={{ padding: '24px' }}>
<Card
title="热词库管理"
extra={
<Space>
<Select
placeholder="按类别筛选"
style={{ width: 140 }}
allowClear
onChange={setSearchCategory}
>
{categories.map(c => <Option key={c.itemValue} value={c.itemValue}>{c.itemLabel}</Option>)}
</Select>
<Input
placeholder="搜索热词原文..."
prefix={<SearchOutlined />}
allowClear
onPressEnter={(e) => {setSearchWord((e.target as any).value); setCurrent(1);}}
style={{ width: 200 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
</Button>
</Space>
}
>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current,
pageSize: size,
total,
showTotal: (t) => `${t}`,
onChange: (p, s) => { setCurrent(p); setSize(s); }
}}
/>
</Card>
<Modal
title={editingId ? '编辑热词' : '新增热词'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
confirmLoading={submitLoading}
width={550}
>
<Form form={form} layout="vertical" style={{ marginTop: '16px' }}>
<Form.Item name="word" label="热词原文" rules={[{ required: true, message: '请输入热词原文' }]}>
<Input placeholder="输入中文或英文关键词" onBlur={handleWordBlur} />
</Form.Item>
<Form.Item name="pinyinList" label="拼音 (多音字用逗号分隔)" tooltip="留空将根据原文自动生成">
<Input.TextArea placeholder="例如: chong qing, zhong qing" rows={2} />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="category" label="热词分类">
<Select placeholder="请选择分类" allowClear>
{categories.map(item => <Option key={item.itemValue} value={item.itemValue}>{item.itemLabel}</Option>)}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="weight" label="识别权重 (1-100)">
<InputNumber min={1} max={100} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="status" label="使用状态">
<Select>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
</Col>
{isPlatformAdmin && (
<Col span={12}>
<Form.Item name="isPublic" label="租户公开" tooltip="开启后,租户内所有成员均可共享此热词">
<Radio.Group>
<Radio value={1}></Radio>
<Radio value={0}></Radio>
</Radio.Group>
</Form.Item>
</Col>
)}
</Row>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="记录热词的来源或用途" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default HotWords;

View File

@ -0,0 +1,254 @@
import React, { useState, useEffect } from 'react';
import { Card, Button, Form, Input, Space, Select, Tag, message, Typography, Divider, Row, Col, DatePicker, Upload, Progress, Tooltip, Avatar } from 'antd';
import {
AudioOutlined, CheckCircleOutlined, UserOutlined, CloudUploadOutlined,
LeftOutlined, SettingOutlined, QuestionCircleOutlined, InfoCircleOutlined,
CalendarOutlined, TeamOutlined, RobotOutlined, RocketOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { createMeeting, uploadAudio } from '../../api/business/meeting';
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
import { listUsers } from '../../api';
import { SysUser } from '../../types';
const { Title, Text } = Typography;
const { Dragger } = Upload;
const { Option } = Select;
const MeetingCreate: React.FC = () => {
const navigate = useNavigate();
const [form] = Form.useForm();
const [submitLoading, setSubmitLoading] = useState(false);
const [asrModels, setAsrModels] = useState<AiModelVO[]>([]);
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
const [userList, setUserList] = useState<SysUser[]>([]);
const [fileList, setFileList] = useState<any[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [audioUrl, setAudioUrl] = useState('');
useEffect(() => {
loadInitialData();
}, []);
const loadInitialData = async () => {
try {
const [asrRes, llmRes, promptRes, hotwordRes, users] = await Promise.all([
getAiModelPage({ current: 1, size: 100, type: 'ASR' }),
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
getPromptPage({ current: 1, size: 100 }),
getHotWordPage({ current: 1, size: 1000 }),
listUsers()
]);
setAsrModels(asrRes.data.data.records.filter(m => m.status === 1));
setLlmModels(llmRes.data.data.records.filter(m => m.status === 1));
setPrompts(promptRes.data.data.records.filter(p => p.status === 1));
setHotwordList(hotwordRes.data.data.records.filter(h => h.status === 1));
setUserList(users || []);
const defaultAsr = await getAiModelDefault('ASR');
const defaultLlm = await getAiModelDefault('LLM');
form.setFieldsValue({
asrModelId: defaultAsr.data.data?.id,
summaryModelId: defaultLlm.data.data?.id,
meetingTime: dayjs()
});
} catch (err) {}
};
const customUpload = async (options: any) => {
const { file, onSuccess, onError } = options;
setUploadProgress(0);
try {
const interval = setInterval(() => {
setUploadProgress(prev => (prev < 95 ? prev + 5 : prev));
}, 300);
const res = await uploadAudio(file);
clearInterval(interval);
setUploadProgress(100);
setAudioUrl(res.data.data);
onSuccess(res.data.data);
message.success('录音上传成功');
} catch (err) {
onError(err);
message.error('文件上传失败');
}
};
const onFinish = async (values: any) => {
if (!audioUrl) {
message.error('请先上传录音文件');
return;
}
setSubmitLoading(true);
try {
await createMeeting({
...values,
meetingTime: values.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
audioUrl,
participants: values.participants?.join(','),
tags: values.tags?.join(',')
});
message.success('会议发起成功');
navigate('/meetings');
} catch (err) {
console.error(err);
} finally {
setSubmitLoading(false);
}
};
return (
<div style={{
height: 'calc(100vh - 64px)',
backgroundColor: '#f4f7f9',
padding: '20px 24px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<div style={{ maxWidth: 1300, margin: '0 auto', width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 头部导航 - 紧凑化 */}
<div style={{ marginBottom: 16, flexShrink: 0 }}>
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')} type="link" style={{ padding: 0, fontSize: '13px' }}></Button>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 4 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary" size="small" style={{ marginLeft: 12 }}> AI </Text>
</div>
</div>
<Form form={form} layout="vertical" onFinish={onFinish} style={{ flex: 1, minHeight: 0 }}>
<Row gutter={24} style={{ height: '100%' }}>
{/* 左侧:文件与基础信息 */}
<Col span={14} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Space direction="vertical" size={16} style={{ width: '100%', flex: 1, overflowY: 'auto', paddingRight: 8 }}>
<Card size="small" title={<Space><AudioOutlined /> </Space>} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)' }}>
<Dragger
accept=".mp3,.wav,.m4a"
fileList={fileList}
customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))}
maxCount={1}
style={{ borderRadius: 8, padding: '16px 0' }}
>
<p className="ant-upload-drag-icon" style={{ marginBottom: 4 }}><CloudUploadOutlined style={{ fontSize: 32 }} /></p>
<p className="ant-upload-text" style={{ fontSize: 14 }}></p>
{uploadProgress > 0 && uploadProgress < 100 && <Progress percent={uploadProgress} size="small" style={{ width: '60%', margin: '0 auto' }} />}
{audioUrl && <Tag color="success" style={{ marginTop: 4 }} size="small">: {audioUrl.split('/').pop()}</Tag>}
</Dragger>
</Card>
<Card size="small" title={<Space><InfoCircleOutlined /> </Space>} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)' }}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]} style={{ marginBottom: 12 }}>
<Input placeholder="输入标题" />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]} style={{ marginBottom: 12 }}>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="tags" label="会议标签" style={{ marginBottom: 12 }}>
<Select mode="tags" placeholder="输入标签" />
</Form.Item>
</Col>
</Row>
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children">
{userList.map(u => (
<Option key={u.userId} value={u.displayName || u.username}>
<Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space>
</Option>
))}
</Select>
</Form.Item>
</Card>
</Space>
</Col>
{/* 右侧AI 配置 - 固定且不滚动 */}
<Col span={10} style={{ height: '100%' }}>
<Card
size="small"
title={<Space><SettingOutlined /> AI </Space>}
bordered={false}
style={{ borderRadius: 12, height: '100%', display: 'flex', flexDirection: 'column', boxShadow: '0 2px 8px rgba(0,0,0,0.03)' }}
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px' }}
>
<div style={{ flex: 1 }}>
<Form.Item name="asrModelId" label="语音识别 (ASR)" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
<Select placeholder="选择 ASR 模型">
{asrModels.map(m => (
<Option key={m.id} value={m.id}>{m.modelName} {m.isDefault === 1 && <Tag color="gold" size="small"></Tag>}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="summaryModelId" label="内容总结 (LLM)" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
<Select placeholder="选择总结模型">
{llmModels.map(m => (
<Option key={m.id} value={m.id}>{m.modelName} {m.isDefault === 1 && <Tag color="gold" size="small"></Tag>}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]} style={{ marginBottom: 16 }}>
<Select placeholder="选择模板">
{prompts.map(p => (
<Option key={p.id} value={p.id}>{p.templateName}</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="hotWords"
label={<span> <Tooltip title="不选默认应用全部启用热词"><QuestionCircleOutlined /></Tooltip></span>}
style={{ marginBottom: 16 }}
>
<Select mode="multiple" placeholder="可选热词" allowClear maxTagCount="responsive">
{hotwordList.map(h => <Option key={h.word} value={h.word}>{h.word}</Option>)}
</Select>
</Form.Item>
</div>
<div style={{ flexShrink: 0 }}>
<div style={{ backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', padding: '10px 12px', borderRadius: 8, marginBottom: 16 }}>
<Text type="secondary" style={{ fontSize: '12px' }}>
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 6 }} />
+
</Text>
</div>
<Button
type="primary"
size="large"
block
icon={<RocketOutlined />}
htmlType="submit"
loading={submitLoading}
style={{ height: 48, borderRadius: 8, fontSize: 16, fontWeight: 600, boxShadow: '0 4px 12px rgba(24, 144, 255, 0.3)' }}
>
</Button>
</div>
</Card>
</Col>
</Row>
</Form>
</div>
</div>
);
};
export default MeetingCreate;

View File

@ -0,0 +1,299 @@
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Row, Col, Typography, Tag, Space, Divider, Button, Skeleton, Empty, List, Avatar, Breadcrumb, Popover, Input, Select, message, Drawer, Form, Modal } from 'antd';
import { LeftOutlined, UserOutlined, ClockCircleOutlined, AudioOutlined, RobotOutlined, LoadingOutlined, EditOutlined, SyncOutlined, SettingOutlined } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import dayjs from 'dayjs';
import { getMeetingDetail, getTranscripts, updateSpeakerInfo, reSummary, updateMeeting, MeetingVO, MeetingTranscriptVO } from '../../api/business/meeting';
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { useDict } from '../../hooks/useDict';
import { listUsers } from '../../api';
import { SysUser } from '../../types';
const { Title, Text } = Typography;
const { Option } = Select;
const SpeakerEditor: React.FC<{
meetingId: number;
speakerId: string;
initialName: string;
initialLabel: string;
onSuccess: () => void;
}> = ({ meetingId, speakerId, initialName, initialLabel, onSuccess }) => {
const [name, setName] = useState(initialName || speakerId);
const [label, setLabel] = useState(initialLabel);
const [loading, setLoading] = useState(false);
const { items: speakerLabels } = useDict('biz_speaker_label');
const handleSave = async (e: React.MouseEvent) => {
e.stopPropagation();
setLoading(true);
try {
await updateSpeakerInfo({ meetingId, speakerId, newName: name, label });
message.success('发言人信息已全局更新');
onSuccess();
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
return (
<div style={{ width: 250, padding: '8px 4px' }} onClick={e => e.stopPropagation()}>
<div style={{ marginBottom: 12 }}>
<Text type="secondary" size="small"></Text>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} />
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary" size="small"></Text>
<Select value={label} onChange={setLabel} placeholder="选择角色" style={{ width: '100%', marginTop: 4 }} size="small" allowClear>
{speakerLabels.map(item => <Select.Option key={item.itemValue} value={item.itemValue}>{item.itemLabel}</Select.Option>)}
</Select>
</div>
<Button type="primary" size="small" block onClick={handleSave} loading={loading}></Button>
</div>
);
};
const MeetingDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [form] = Form.useForm();
const [summaryForm] = Form.useForm();
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
const [loading, setLoading] = useState(true);
const [editVisible, setEditVisible] = useState(false);
const [summaryVisible, setSummaryVisible] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [userList, setUserList] = useState<SysUser[]>([]);
const { items: speakerLabels } = useDict('biz_speaker_label');
const audioRef = useRef<HTMLAudioElement>(null);
// 核心权限判断
const isOwner = React.useMemo(() => {
if (!meeting) return false;
const profileStr = sessionStorage.getItem("userProfile");
if (profileStr) {
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true || profile.userId === meeting.creatorId;
}
return false;
}, [meeting]);
useEffect(() => {
if (id) {
fetchData(Number(id));
loadAiConfigs();
loadUsers();
}
}, [id]);
const fetchData = async (meetingId: number) => {
try {
const [detailRes, transcriptRes] = await Promise.all([
getMeetingDetail(meetingId),
getTranscripts(meetingId)
]);
setMeeting(detailRes.data.data);
setTranscripts(transcriptRes.data.data || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const loadAiConfigs = async () => {
try {
const [mRes, pRes, dRes] = await Promise.all([
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
getPromptPage({ current: 1, size: 100 }),
getAiModelDefault('LLM')
]);
setLlmModels(mRes.data.data.records.filter(m => m.status === 1));
setPrompts(pRes.data.data.records.filter(p => p.status === 1));
summaryForm.setFieldsValue({ summaryModelId: dRes.data.data?.id });
} catch (e) {}
};
const loadUsers = async () => {
try {
const users = await listUsers();
setUserList(users || []);
} catch (err) {}
};
const handleEditMeeting = () => {
if (!meeting || !isOwner) return;
// 由于后端存储的是姓名字符串,而我们现在需要 ID 匹配,
// 这里简单处理:让发起人依然可以修改基础元数据。
// 如果需要修改参会人 ID需要前端存储 ID 列表快照。
form.setFieldsValue({
...meeting,
tags: meeting.tags?.split(',').filter(Boolean)
});
setEditVisible(true);
};
const handleUpdateBasic = async () => {
const vals = await form.validateFields();
setActionLoading(true);
try {
await updateMeeting({
...vals,
id: meeting?.id,
tags: vals.tags?.join(',')
});
message.success('会议信息已更新');
setEditVisible(false);
fetchData(Number(id));
} catch (err) {
console.error(err);
} finally {
setActionLoading(false);
}
};
const handleReSummary = async () => {
const vals = await summaryForm.validateFields();
setActionLoading(true);
try {
await reSummary({
meetingId: Number(id),
summaryModelId: vals.summaryModelId,
promptId: vals.promptId
});
message.success('已重新发起总结任务');
setSummaryVisible(false);
fetchData(Number(id));
} catch (err) {
console.error(err);
} finally {
setActionLoading(false);
}
};
const formatTime = (ms: number) => {
const seconds = Math.floor(ms / 1000);
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
const seekTo = (timeMs: number) => {
if (audioRef.current) {
audioRef.current.currentTime = timeMs / 1000;
audioRef.current.play();
}
};
if (loading) return <div style={{ padding: '24px' }}><Skeleton active /></div>;
if (!meeting) return <div style={{ padding: '24px' }}><Empty description="会议不存在" /></div>;
return (
<div style={{ padding: '24px', height: 'calc(100vh - 64px)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Breadcrumb style={{ marginBottom: '16px' }}>
<Breadcrumb.Item><a onClick={() => navigate('/meetings')}></a></Breadcrumb.Item>
<Breadcrumb.Item></Breadcrumb.Item>
</Breadcrumb>
<Card style={{ marginBottom: '16px', flexShrink: 0 }} bodyStyle={{ padding: '16px 24px' }}>
<Row justify="space-between" align="middle">
<Col>
<Space direction="vertical" size={4}>
<Title level={4} style={{ margin: 0 }}>
{meeting.title} {isOwner && <EditOutlined style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff' }} onClick={handleEditMeeting} />}
</Title>
<Space split={<Divider type="vertical" />}>
<Text type="secondary"><ClockCircleOutlined /> {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')}</Text>
<Space>
{meeting.tags?.split(',').filter(Boolean).map(t => <Tag key={t} color="blue">{t}</Tag>)}
</Space>
<Text type="secondary"><UserOutlined /> {meeting.participants || '未指定'}</Text>
</Space>
</Space>
</Col>
<Col>
<Space>
{isOwner && <Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)}></Button>}
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}></Button>
</Space>
</Col>
</Row>
</Card>
<div style={{ flex: 1, minHeight: 0 }}>
<Row gutter={24} style={{ height: '100%' }}>
<Col span={12} style={{ height: '100%' }}>
<Card title={<span><AudioOutlined /> </span>} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }}
extra={meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} controls style={{ height: '32px' }} />}>
<List dataSource={transcripts} renderItem={(item) => (
<List.Item style={{ borderBottom: '1px solid #f0f0f0', padding: '12px 0', cursor: 'pointer' }} onClick={() => seekTo(item.startTime)}>
<List.Item.Meta avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />}
title={<Space>
{isOwner ? (
<Popover content={<SpeakerEditor meetingId={meeting.id} speakerId={item.speakerId} initialName={item.speakerName} initialLabel={item.speakerLabel} onSuccess={() => fetchData(meeting.id)} />} title="编辑发言人" trigger="click">
<span style={{ color: '#1890ff', cursor: 'pointer' }} onClick={e => e.stopPropagation()}>{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: '12px' }} /></span>
</Popover>
) : (
<Text strong>{item.speakerName || item.speakerId || '发言人'}</Text>
)}
{item.speakerLabel && <Tag color="blue">{speakerLabels.find(l => l.itemValue === item.speakerLabel)?.itemLabel || item.speakerLabel}</Tag>}
<Text type="secondary" size="small" style={{ fontSize: '12px' }}>{formatTime(item.startTime)}</Text>
</Space>} description={<Text style={{ color: '#333' }}>{item.content}</Text>} />
</List.Item>
)} locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} />
</Card>
</Col>
<Col span={12} style={{ height: '100%' }}>
<Card title={<span><RobotOutlined /> AI </span>} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }}>
{meeting.summaryContent ? <div className="markdown-body"><ReactMarkdown>{meeting.summaryContent}</ReactMarkdown></div> :
<div style={{ textAlign: 'center', marginTop: '100px' }}>{meeting.status === 2 ? <Space direction="vertical"><LoadingOutlined style={{ fontSize: 24 }} spin /><Text type="secondary">...</Text></Space> : <Empty description="暂无总结" />}</div>}
</Card>
</Col>
</Row>
</div>
{/* 修改基础信息弹窗 - 仅限 Owner */}
{isOwner && (
<Modal title="编辑会议信息" open={editVisible} onOk={handleUpdateBasic} onCancel={() => setEditVisible(false)} confirmLoading={actionLoading} width={600}>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="tags" label="业务标签"><Select mode="tags" placeholder="输入标签按回车" /></Form.Item>
<Text type="warning" size="small"> ID </Text>
</Form>
</Modal>
)}
{/* 重新总结抽屉 - 仅限 Owner */}
{isOwner && (
<Drawer title="重新生成 AI 总结" width={400} onClose={() => setSummaryVisible(false)} open={summaryVisible} extra={<Button type="primary" onClick={handleReSummary} loading={actionLoading}></Button>}>
<Form form={summaryForm} layout="vertical">
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
<Select placeholder="选择 LLM 模型">
{llmModels.map(m => <Option key={m.id} value={m.id}>{m.modelName} {m.isDefault === 1 && <Tag color="gold" style={{ marginLeft: 4 }}></Tag>}</Option>)}
</Select>
</Form.Item>
<Form.Item name="promptId" label="提示词模板" rules={[{ required: true }]}>
<Select placeholder="选择新模板">
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
</Select>
</Form.Item>
<Divider />
<Text type="secondary" size="small"></Text>
</Form>
</Drawer>
)}
</div>
);
};
export default MeetingDetail;

View File

@ -0,0 +1,245 @@
import React, { useState, useEffect } from 'react';
import { Card, Button, Input, Space, Tag, message, Popconfirm, Typography, Row, Col, List, Badge, Empty, Skeleton, Tooltip, Radio, Pagination } from 'antd';
import {
PlusOutlined, DeleteOutlined, SearchOutlined, CheckCircleOutlined,
LoadingOutlined, UserOutlined, CalendarOutlined, PlayCircleOutlined,
TeamOutlined, ClockCircleOutlined, EditOutlined, RightOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getMeetingPage, deleteMeeting, MeetingVO } from '../../api/business/meeting';
import dayjs from 'dayjs';
const { Text, Title } = Typography;
const Meetings: React.FC = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<MeetingVO[]>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [size, setSize] = useState(8);
const [searchTitle, setSearchTitle] = useState('');
const [viewType, setViewType] = useState<'all' | 'created' | 'involved'>('all');
useEffect(() => {
fetchData();
}, [current, size, searchTitle, viewType]);
const fetchData = async () => {
setLoading(true);
try {
const res = await getMeetingPage({ current, size, title: searchTitle, viewType });
if (res.data && res.data.data) {
setData(res.data.data.records);
setTotal(res.data.data.total);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const statusConfig: Record<number, { text: string; color: string; bgColor: string }> = {
0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' },
1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' },
2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' },
3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' },
4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' }
};
return (
<div style={{
height: 'calc(100vh - 64px)',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#f4f7f9',
padding: '24px',
overflow: 'hidden'
}}>
<div style={{ maxWidth: 1600, margin: '0 auto', width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 固定头部 - 极简白卡 */}
<Card bordered={false} style={{ marginBottom: 20, borderRadius: 16, flexShrink: 0, boxShadow: '0 4px 12px rgba(0,0,0,0.03)' }} bodyStyle={{ padding: '16px 28px' }}>
<Row justify="space-between" align="middle">
<Col>
<Space size={12}>
<div style={{ width: 8, height: 24, background: '#1890ff', borderRadius: 4 }}></div>
<Title level={4} style={{ margin: 0 }}></Title>
</Space>
</Col>
<Col>
<Space size={16}>
<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>
<Input
placeholder="搜索会议标题"
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
allowClear
onPressEnter={(e) => { setSearchTitle((e.target as any).value); setCurrent(1); }}
style={{ width: 220, borderRadius: 8 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/meeting-create')}
style={{ borderRadius: 8, height: 36, fontWeight: 500 }}
>
</Button>
</Space>
</Col>
</Row>
</Card>
{/* 列表区 */}
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '4px 8px 4px 4px' }}>
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
<List
grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
dataSource={data}
renderItem={(item) => {
const config = statusConfig[item.status] || statusConfig[0];
return (
<List.Item style={{ marginBottom: 24 }}>
<Card
hoverable
onClick={() => navigate(`/meetings/${item.id}`)}
className="meeting-card"
style={{
borderRadius: 16,
border: 'none',
height: '220px',
position: 'relative',
boxShadow: '0 6px 16px rgba(0,0,0,0.04)',
transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)'
}}
bodyStyle={{ padding: 0, display: 'flex', height: '100%' }}
>
{/* 左侧状态装饰条 */}
<div style={{ width: 6, height: '100%', backgroundColor: config.color, borderRadius: '16px 0 0 16px' }}></div>
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
{/* 右上角醒目图标 */}
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
<Space size={8}>
<Tooltip title="编辑">
<div className="icon-btn edit" onClick={() => navigate(`/meetings/${item.id}`)}>
<EditOutlined />
</div>
</Tooltip>
<Popconfirm title="确定删除?" onConfirm={() => deleteMeeting(item.id).then(fetchData)}>
<Tooltip title="删除">
<div className="icon-btn delete">
<DeleteOutlined />
</div>
</Tooltip>
</Popconfirm>
</Space>
</div>
{/* 内容排版 */}
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 12 }}>
<Tag color={config.bgColor} style={{ color: config.color, border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 11 }}>
{item.status === 1 || item.status === 2 ? <LoadingOutlined spin style={{ marginRight: 4 }} /> : null}
{config.text}
</Tag>
</div>
<div style={{ marginBottom: 16, paddingRight: 40, height: '44px', overflow: 'hidden' }}>
<Text strong style={{ fontSize: 16, color: '#262626', lineHeight: '22px' }} ellipsis={{ tooltip: item.title }}>
{item.title}
</Text>
</div>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}>
<CalendarOutlined style={{ marginRight: 10 }} />
{dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')}
</div>
<div style={{ fontSize: '13px', color: '#8c8c8c', display: 'flex', alignItems: 'center' }}>
<TeamOutlined style={{ marginRight: 10 }} />
<Text type="secondary" ellipsis style={{ maxWidth: '85%' }}>{item.participants || '无参与人员'}</Text>
</div>
</Space>
</div>
{/* 底部详情提示 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
<div style={{ display: 'flex', gap: 4 }}>
{item.tags?.split(',').slice(0, 2).map(t => (
<Tag key={t} style={{ border: '1px solid #f0f0f0', backgroundColor: '#fff', fontSize: 10, margin: 0, borderRadius: 4 }}>{t}</Tag>
))}
</div>
<RightOutlined style={{ color: '#bfbfbf', fontSize: 12 }} />
</div>
</div>
</Card>
</List.Item>
);
}}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
/>
</Skeleton>
</div>
{/* 精美底部分页 */}
{total > 0 && (
<div style={{ flexShrink: 0, display: 'flex', justifyContent: 'center', padding: '16px 0 8px 0' }}>
<Pagination
current={current}
pageSize={size}
total={total}
onChange={(p, s) => { setCurrent(p); setSize(s); }}
showTotal={(total) => <Text type="secondary" size="small"> {total} </Text>}
size="small"
/>
</div>
)}
</div>
<style>{`
.meeting-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0,0,0,0.08) !important;
}
.icon-btn {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255,255,255,0.9);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
transition: all 0.2s;
color: #8c8c8c;
}
.icon-btn:hover {
transform: scale(1.1);
}
.icon-btn.edit:hover {
color: #1890ff;
background: #e6f7ff;
}
.icon-btn.delete:hover {
color: #ff4d4f;
background: #fff1f0;
}
.card-actions {
opacity: 0.6;
transition: opacity 0.3s;
}
.meeting-card:hover .card-actions {
opacity: 1;
}
`}</style>
</div>
);
};
export default Meetings;

View File

@ -0,0 +1,313 @@
import React, { useState, useEffect } from 'react';
import { Card, Button, Input, Space, Drawer, Form, Select, Tag, message, Popconfirm, Typography, Divider, Tooltip, Row, Col, List, Empty, Skeleton, Switch, Modal } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, SaveOutlined, StarFilled } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import { useDict } from '../../hooks/useDict';
import {
getPromptPage,
savePromptTemplate,
updatePromptTemplate,
deletePromptTemplate,
updatePromptStatus,
PromptTemplateVO,
PromptTemplateDTO
} from '../../api/business/prompt';
const { Option } = Select;
const { Text, Title } = Typography;
const PromptTemplates: React.FC = () => {
const [form] = Form.useForm();
const [searchForm] = Form.useForm();
const { items: categories, loading: dictLoading } = useDict('biz_prompt_category');
const { items: dictTags } = useDict('biz_prompt_tag');
const [loading, setLoading] = useState(false);
const [data, setData] = useState<PromptTemplateVO[]>([]);
const [drawerVisible, setDrawerVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [previewContent, setPreviewContent] = useState('');
const userProfile = React.useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? JSON.parse(profileStr) : {};
}, []);
const isPlatformAdmin = userProfile.isPlatformAdmin === true;
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
const values = searchForm.getFieldsValue();
setLoading(true);
try {
const res = await getPromptPage({
current: 1,
size: 1000,
name: values.name,
category: values.category
});
if (res.data && res.data.data) {
setData(res.data.data.records);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleStatusChange = async (id: number, checked: boolean) => {
try {
await updatePromptStatus(id, checked ? 1 : 0);
message.success(checked ? '模板已启用' : '模板已停用');
fetchData();
} catch (err) {
console.error(err);
}
};
const handleOpenDrawer = (record?: PromptTemplateVO, isClone = false) => {
if (record) {
if (isClone) {
setEditingId(null);
form.setFieldsValue({
...record,
templateName: `${record.templateName} (副本)`,
isSystem: 0,
id: undefined
});
setPreviewContent(record.promptContent);
} else {
if (record.isSystem === 1 && !isPlatformAdmin) {
message.error('无权编辑系统模板');
return;
}
if (record.isSystem === 0 && record.creatorId !== userProfile.userId) {
message.error('无权编辑他人模板');
return;
}
setEditingId(record.id);
form.setFieldsValue(record);
setPreviewContent(record.promptContent);
}
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({ status: 1, isSystem: 0 });
setPreviewContent('');
}
setDrawerVisible(true);
};
const showDetail = (record: PromptTemplateVO) => {
Modal.info({
title: record.templateName,
width: 800,
icon: null,
content: (
<div style={{ maxHeight: '65vh', overflowY: 'auto', padding: '12px 0' }}>
<ReactMarkdown>{record.promptContent}</ReactMarkdown>
</div>
),
okText: '关闭',
maskClosable: true
});
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitLoading(true);
if (editingId) {
await updatePromptTemplate({ ...values, id: editingId });
message.success('更新成功');
} else {
await savePromptTemplate(values);
message.success('模板已创建');
}
setDrawerVisible(false);
fetchData();
} catch (err) {
console.error(err);
} finally {
setSubmitLoading(false);
}
};
const groupedData = React.useMemo(() => {
const groups: Record<string, PromptTemplateVO[]> = {};
data.forEach(item => {
const cat = item.category || 'default';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(item);
});
return groups;
}, [data]);
const renderCard = (item: PromptTemplateVO) => {
const isMine = item.creatorId === userProfile.userId;
const isSystem = item.isSystem === 1;
const canEdit = isSystem ? isPlatformAdmin : isMine;
return (
<Card
key={item.id}
hoverable
onClick={() => showDetail(item)}
style={{ width: 320, borderRadius: 12, border: '1px solid #f0f0f0', position: 'relative', overflow: 'hidden' }}
bodyStyle={{ padding: '24px' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{
width: 40, height: 40, borderRadius: 10, backgroundColor: '#e6f7ff',
display: 'flex', justifyContent: 'center', alignItems: 'center'
}}>
<StarFilled style={{ fontSize: 20, color: '#1890ff' }} />
</div>
<Space onClick={e => e.stopPropagation()}>
{canEdit && <EditOutlined style={{ fontSize: 18, color: '#bfbfbf', cursor: 'pointer' }} onClick={() => handleOpenDrawer(item)} />}
<Switch
size="small"
checked={item.status === 1}
onChange={(checked) => handleStatusChange(item.id, checked)}
disabled={!canEdit}
/>
</Space>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong style={{ fontSize: 16, display: 'block' }} ellipsis={{ tooltip: item.templateName }}>{item.templateName}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>使: {item.usageCount || 0}</Text>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20, height: 22, overflow: 'hidden' }}>
{item.tags?.map(tag => {
const dictItem = dictTags.find(dt => dt.itemValue === tag);
return (
<Tag key={tag} style={{ margin: 0, border: 'none', backgroundColor: '#f0f2f5', color: '#595959', borderRadius: 4, fontSize: 10 }}>
{dictItem ? dictItem.itemLabel : tag}
</Tag>
);
})}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', paddingTop: 12, borderTop: '1px solid #f5f5f5' }}>
<Space onClick={e => e.stopPropagation()}>
<Tooltip title="以此创建">
<CopyOutlined style={{ color: '#bfbfbf', cursor: 'pointer', fontSize: 16 }} onClick={() => handleOpenDrawer(item, true)} />
</Tooltip>
{canEdit && (
<Popconfirm title="确定删除?" onConfirm={() => deletePromptTemplate(item.id).then(fetchData)}>
<Tooltip title="删除">
<DeleteOutlined style={{ color: '#bfbfbf', cursor: 'pointer', fontSize: 16 }} />
</Tooltip>
</Popconfirm>
)}
</Space>
</div>
</Card>
);
};
return (
<div style={{ padding: '32px', backgroundColor: '#fff', minHeight: '100%', overflowY: 'auto' }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 32 }}>
<Title level={3} style={{ margin: 0 }}></Title>
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => handleOpenDrawer()} style={{ borderRadius: 6 }}>
</Button>
</div>
<Card bordered={false} bodyStyle={{ padding: '20px 24px', backgroundColor: '#f9f9f9', borderRadius: 12, marginBottom: 32 }}>
<Form form={searchForm} layout="inline" onFinish={fetchData}>
<Form.Item name="name" label="模板名称"><Input placeholder="请输入..." style={{ width: 180 }} /></Form.Item>
<Form.Item name="category" label="分类">
<Select placeholder="选择分类" style={{ width: 160 }} allowClear>
{categories.map(c => <Option key={c.itemValue} value={c.itemValue}>{c.itemLabel}</Option>)}
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { searchForm.resetFields(); fetchData(); }}></Button>
</Space>
</Form.Item>
</Form>
</Card>
<Skeleton loading={loading} active>
{Object.keys(groupedData).length === 0 ? (
<Empty description="暂无可用模板" />
) : (
Object.keys(groupedData).map(catKey => {
const catLabel = categories.find(c => c.itemValue === catKey)?.itemLabel || catKey;
return (
<div key={catKey} style={{ marginBottom: 40 }}>
<Title level={4} style={{ marginBottom: 24, paddingLeft: 8, borderLeft: '4px solid #1890ff' }}>{catLabel}</Title>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24 }}>
{groupedData[catKey].map(renderCard)}
</div>
</div>
);
})
)}
</Skeleton>
</div>
<Drawer
title={<Title level={4} style={{ margin: 0 }}>{editingId ? '编辑模板' : '创建新模板'}</Title>}
width="80%"
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
extra={
<Space>
<Button onClick={() => setDrawerVisible(false)}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={handleSubmit}></Button>
</Space>
}
destroyOnClose
>
<Form form={form} layout="vertical">
<Row gutter={24}>
<Col span={12}><Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
<Col span={6}><Form.Item name="category" label="分类" rules={[{ required: true }]}><Select loading={dictLoading}>{categories.map(i => <Option key={i.itemValue} value={i.itemValue}>{i.itemLabel}</Option>)}</Select></Form.Item></Col>
<Col span={6}><Form.Item name="status" label="状态"><Select><Option value={1}></Option><Option value={0}></Option></Select></Form.Item></Col>
</Row>
<Form.Item name="tags" label="业务标签" tooltip="可从现有标签中选择,也可输入新内容按回车保存">
<Select
mode="tags"
placeholder="选择或输入新标签"
allowClear
tokenSeparators={[',', ' ', ';']}
>
{dictTags.map(t => <Option key={t.itemValue} value={t.itemValue}>{t.itemLabel}</Option>)}
</Select>
</Form.Item>
<Divider orientation="left"> (Markdown )</Divider>
<Row gutter={24} style={{ height: 'calc(100vh - 400px)' }}>
<Col span={12} style={{ height: '100%' }}>
<Form.Item name="promptContent" noStyle rules={[{ required: true }]}>
<Input.TextArea
onChange={e => setPreviewContent(e.target.value)}
style={{ height: '100%', fontFamily: 'monospace', resize: 'none', border: '1px solid #d9d9d9', borderRadius: 8, padding: 12 }}
placeholder="在此输入 Markdown 指令..."
/>
</Form.Item>
</Col>
<Col span={12} style={{ height: '100%', overflowY: 'auto', background: '#fafafa', border: '1px solid #f0f0f0', borderRadius: 8, padding: '16px 24px' }}>
<div className="markdown-preview"><ReactMarkdown>{previewContent}</ReactMarkdown></div>
</Col>
</Row>
</Form>
</Drawer>
</div>
);
};
export default PromptTemplates;

View File

@ -0,0 +1,203 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Card, Button, Form, Input, Space, message, Typography, Divider, List, Tag } from 'antd';
import { AudioOutlined, StopOutlined, CloudUploadOutlined, DeleteOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { registerSpeaker, getSpeakerList, SpeakerVO } from '../../api/business/speaker';
const { Title, Text } = Typography;
const SpeakerReg: React.FC = () => {
const [recording, setRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [existingSpeaker, setExistingSpeaker] = useState<SpeakerVO | null>(null);
const [listLoading, setListLoading] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
// 获取资源前缀
const resourcePrefix = useMemo(() => {
const configStr = sessionStorage.getItem("platformConfig");
if (configStr) {
const config = JSON.parse(configStr);
return config.resourcePrefix || '/api/static/';
}
return '/api/static/';
}, []);
useEffect(() => {
fetchSpeakers();
}, []);
const fetchSpeakers = async () => {
setListLoading(true);
try {
const res = await getSpeakerList();
if (res.data && Array.isArray(res.data.data) && res.data.data.length > 0) {
setExistingSpeaker(res.data.data[0]);
} else {
setExistingSpeaker(null);
}
} catch (err) {
console.error(err);
} finally {
setListLoading(false);
}
};
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: 'audio/wav' });
setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob));
};
mediaRecorder.start();
setRecording(true);
setAudioBlob(null);
setAudioUrl(null);
} catch (err) {
message.error('无法访问麦克风,请检查权限设置');
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && recording) {
mediaRecorderRef.current.stop();
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
setRecording(false);
}
};
const handleSubmit = async () => {
if (!audioBlob) {
message.warning('请先录制声纹文件');
return;
}
setLoading(true);
try {
// 后端会自动获取当前登录人作为 name 和 userId
await registerSpeaker({
name: '', // 后端会覆盖此值
file: audioBlob
});
message.success(existingSpeaker ? '声纹更新成功' : '声纹注册成功');
setAudioBlob(null);
setAudioUrl(null);
fetchSpeakers();
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: '24px', height: '100%', overflowY: 'auto' }}>
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<Title level={3}></Title>
<Text type="secondary"></Text>
<Card style={{ marginTop: '24px', boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }} loading={listLoading}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{existingSpeaker ? (
<div style={{ textAlign: 'center', marginBottom: '24px', width: '100%', background: '#f6ffed', padding: '16px', borderRadius: '8px', border: '1px solid #b7eb8f' }}>
<Tag color="success" icon={<CheckCircleOutlined />} style={{ marginBottom: '8px' }}></Tag>
<div style={{ marginBottom: '8px' }}>
<Text strong>线</Text>
<Text type="secondary" size="small">
( {new Date(existingSpeaker.createdAt).toLocaleString()})
</Text>
</div>
<audio
src={`${resourcePrefix}${existingSpeaker.voicePath}`}
controls
style={{ width: '100%', height: '32px' }}
/>
</div>
) : (
<div style={{ textAlign: 'center', marginBottom: '24px', padding: '16px', background: '#fff7e6', borderRadius: '8px', border: '1px solid #ffd591', width: '100%' }}>
<Tag color="warning" style={{ marginBottom: '8px' }}></Tag>
<p style={{ margin: 0 }}> 5-10 </p>
</div>
)}
<Divider style={{ margin: '12px 0' }}>{existingSpeaker ? '更新声纹' : '开始采集'}</Divider>
<div style={{ textAlign: 'center', margin: '20px 0' }}>
{!recording ? (
<Button
type="primary"
danger
shape="circle"
style={{ width: 70, height: 70, boxShadow: '0 4px 10px rgba(255, 77, 79, 0.3)' }}
icon={<AudioOutlined style={{ fontSize: 28 }} />}
onClick={startRecording}
/>
) : (
<Button
type="primary"
shape="circle"
style={{ width: 70, height: 70, boxShadow: '0 4px 10px rgba(22, 119, 255, 0.3)' }}
icon={<StopOutlined style={{ fontSize: 28 }} />}
onClick={stopRecording}
/>
)}
<div style={{ marginTop: '12px' }}>
<Text strong type={recording ? "danger" : "secondary"}>
{recording ? "录制中,请持续说话..." : "点击红色图标开始录音"}
</Text>
</div>
</div>
{audioUrl && (
<div style={{ width: '100%', textAlign: 'center', background: '#f0f5ff', padding: '16px', borderRadius: '8px', marginBottom: '20px', border: '1px solid #adc6ff' }}>
<Text strong></Text>
<audio src={audioUrl} controls style={{ width: '100%', marginTop: '8px', height: '32px' }} />
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => { setAudioBlob(null); setAudioUrl(null); }}
style={{ marginTop: '4px' }}
>
</Button>
</div>
)}
<Button
type="primary"
size="large"
icon={<CloudUploadOutlined />}
onClick={handleSubmit}
loading={loading}
disabled={recording || !audioBlob}
style={{ width: '100%', height: '45px', marginTop: '10px' }}
>
{existingSpeaker ? '立即覆盖原有声纹' : '提交保存声纹'}
</Button>
</div>
</Card>
</div>
</div>
);
};
export default SpeakerReg;

View File

@ -1,8 +1,8 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes } from "react-router-dom";
import Login from "../pages/Login";
import ResetPassword from "../pages/ResetPassword";
import AppLayout from "../layouts/AppLayout";
import { menuRoutes } from "./routes";
import { menuRoutes, extraRoutes } from "./routes";
import { useAuth } from "../hooks/useAuth";
function RequireAuth({ children }: { children: JSX.Element }) {
@ -33,6 +33,9 @@ export default function AppRoutes() {
{menuRoutes.map((route) => (
<Route key={route.path} index={route.path === "/"} path={route.path === "/" ? undefined : route.path.slice(1)} element={route.element} />
))}
{extraRoutes && extraRoutes.map((route) => (
<Route key={route.path} path={route.path.startsWith('/') ? route.path.slice(1) : route.path} element={route.element} />
))}
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@ -15,6 +15,10 @@ import Profile from "../pages/Profile";
import SpeakerReg from "../pages/business/SpeakerReg";
import HotWords from "../pages/business/HotWords";
import PromptTemplates from "../pages/business/PromptTemplates";
import AiModels from "../pages/business/AiModels";
import Meetings from "../pages/business/Meetings";
import MeetingDetail from "../pages/business/MeetingDetail";
import MeetingCreate from "../pages/business/MeetingCreate";
import type { MenuRoute } from "../types";
@ -35,5 +39,12 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/role-permissions", label: "角色权限绑定", element: <RolePermissionBinding />, perm: "menu:role-permissions" },
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg />, perm: "menu:speaker" },
{ path: "/hotwords", label: "热词管理", element: <HotWords />, perm: "menu:hotword" },
{ path: "/prompts", label: "总结模板", element: <PromptTemplates />, perm: "menu:prompt" }
{ path: "/prompts", label: "总结模板", element: <PromptTemplates />, perm: "menu:prompt" },
{ path: "/aimodels", label: "模型配置", element: <AiModels />, perm: "menu:aimodel" },
{ path: "/meetings", label: "会议中心", element: <Meetings />, perm: "menu:meeting" },
{ path: "/meeting-create", label: "发起会议", element: <MeetingCreate />, perm: "menu:meeting:create" }
];
export const extraRoutes = [
{ path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" }
];