feat(business): 添加AI模型配置功能实现
- 新增AiModel实体类定义数据库表结构 - 实现AI模型的增删改查REST API接口 - 添加前端AI模型管理页面支持配置展示 - 实现ASR和LLM两种模型类型的区分管理 - 添加模型远程列表获取和验证功能 - 实现默认模型设置和租户权限控制 - 新增AiTask实体用于AI任务调度管理 - 实现AI任务异步处理服务逻辑 - 添加会议转录和总结的完整处理流程dev_na
parent
1a392d96b9
commit
21b3ab3afc
|
|
@ -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:失败 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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. 基础初始化数据
|
||||
-- ----------------------------
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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/");
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.imeeting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PasswordUpdateDTO {
|
||||
private String oldPassword;
|
||||
private String newPassword;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
);
|
||||
};
|
||||
|
|
@ -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 } }
|
||||
);
|
||||
};
|
||||
|
|
@ -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" } }
|
||||
);
|
||||
};
|
||||
|
|
@ -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 } }
|
||||
);
|
||||
};
|
||||
|
|
@ -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"
|
||||
);
|
||||
};
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in New Issue