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 | 模板名称 |
|
| template_name | VARCHAR(100) | NOT NULL | 模板名称 |
|
||||||
| category | VARCHAR(20) | | 分类 (字典: biz_prompt_category) |
|
| category | VARCHAR(20) | | 分类 (字典: biz_prompt_category) |
|
||||||
| is_system | SMALLINT | DEFAULT 0 | 是否预置 (1:是, 0:否) |
|
| is_system | SMALLINT | DEFAULT 0 | 是否预置 (1:是, 0:否) |
|
||||||
|
| creator_id | BIGINT | | 创建人ID |
|
||||||
|
| tags | JSONB | | 标签数组 |
|
||||||
|
| usage_count | INTEGER | DEFAULT 0 | 使用次数 |
|
||||||
| prompt_content | TEXT | NOT NULL | 提示词内容 |
|
| prompt_content | TEXT | NOT NULL | 提示词内容 |
|
||||||
| status | SMALLINT | DEFAULT 1 | 状态 (1:启用, 0:禁用) |
|
| status | SMALLINT | DEFAULT 1 | 状态 (1:启用, 0:禁用) |
|
||||||
| remark | VARCHAR(255) | | 备注 |
|
| remark | VARCHAR(255) | | 备注 |
|
||||||
|
|
@ -284,3 +287,56 @@
|
||||||
- `idx_prompt_tenant`: `(tenant_id)`
|
- `idx_prompt_tenant`: `(tenant_id)`
|
||||||
- `idx_prompt_system`: `(is_system) WHERE is_deleted = 0`
|
- `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,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
tenant_id BIGINT NOT NULL, -- 租户ID (强制隔离)
|
tenant_id BIGINT NOT NULL, -- 租户ID (强制隔离)
|
||||||
word VARCHAR(100) NOT NULL, -- 热词原文
|
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:拼音模糊匹配
|
match_strategy SMALLINT DEFAULT 1, -- 匹配策略: 1:精确匹配, 2:拼音模糊匹配
|
||||||
category VARCHAR(50), -- 类别 (人名、术语、地名)
|
category VARCHAR(50), -- 类别 (人名、术语、地名)
|
||||||
weight INTEGER DEFAULT 10, -- 权重 (1-100)
|
weight INTEGER DEFAULT 10, -- 权重 (1-100)
|
||||||
|
|
@ -278,6 +281,9 @@ CREATE TABLE biz_prompt_templates (
|
||||||
template_name VARCHAR(100) NOT NULL, -- 模板名称
|
template_name VARCHAR(100) NOT NULL, -- 模板名称
|
||||||
category VARCHAR(20), -- 分类 (字典: biz_prompt_category)
|
category VARCHAR(20), -- 分类 (字典: biz_prompt_category)
|
||||||
is_system SMALLINT DEFAULT 0, -- 是否系统预置 (1:是, 0:否)
|
is_system SMALLINT DEFAULT 0, -- 是否系统预置 (1:是, 0:否)
|
||||||
|
creator_id BIGINT, -- 创建人ID
|
||||||
|
tags JSONB, -- 标签数组 (JSONB)
|
||||||
|
usage_count INTEGER DEFAULT 0, -- 使用次数
|
||||||
prompt_content TEXT NOT NULL, -- 提示词内容
|
prompt_content TEXT NOT NULL, -- 提示词内容
|
||||||
status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用
|
status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用
|
||||||
remark VARCHAR(255), -- 备注
|
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 '会议总结提示词模板表';
|
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. 基础初始化数据
|
-- 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());
|
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
|
database: 15
|
||||||
cache:
|
cache:
|
||||||
type: redis
|
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:
|
mybatis-plus:
|
||||||
configuration:
|
configuration:
|
||||||
|
|
@ -30,6 +40,7 @@ security:
|
||||||
secret: change-me-please-change-me-32bytes
|
secret: change-me-please-change-me-32bytes
|
||||||
|
|
||||||
app:
|
app:
|
||||||
|
server-base-url: http://10.100.52.13:8080 # 本地应用对外暴露的 IP 和端口
|
||||||
upload-path: D:/data/imeeting/uploads/
|
upload-path: D:/data/imeeting/uploads/
|
||||||
resource-prefix: /api/static/
|
resource-prefix: /api/static/
|
||||||
captcha:
|
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,
|
ShopOutlined,
|
||||||
AudioOutlined,
|
AudioOutlined,
|
||||||
TagsOutlined,
|
TagsOutlined,
|
||||||
BulbOutlined
|
BulbOutlined,
|
||||||
|
ApiOutlined
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { usePermission } from "../hooks/usePermission";
|
import { usePermission } from "../hooks/usePermission";
|
||||||
|
|
@ -38,6 +39,7 @@ const iconMap: Record<string, any> = {
|
||||||
"audio": <AudioOutlined />,
|
"audio": <AudioOutlined />,
|
||||||
"hotword": <TagsOutlined />,
|
"hotword": <TagsOutlined />,
|
||||||
"prompt": <BulbOutlined />,
|
"prompt": <BulbOutlined />,
|
||||||
|
"aimodel": <ApiOutlined />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AppLayout() {
|
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 Login from "../pages/Login";
|
||||||
import ResetPassword from "../pages/ResetPassword";
|
import ResetPassword from "../pages/ResetPassword";
|
||||||
import AppLayout from "../layouts/AppLayout";
|
import AppLayout from "../layouts/AppLayout";
|
||||||
import { menuRoutes } from "./routes";
|
import { menuRoutes, extraRoutes } from "./routes";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||||
|
|
@ -33,6 +33,9 @@ export default function AppRoutes() {
|
||||||
{menuRoutes.map((route) => (
|
{menuRoutes.map((route) => (
|
||||||
<Route key={route.path} index={route.path === "/"} path={route.path === "/" ? undefined : route.path.slice(1)} element={route.element} />
|
<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>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ import Profile from "../pages/Profile";
|
||||||
import SpeakerReg from "../pages/business/SpeakerReg";
|
import SpeakerReg from "../pages/business/SpeakerReg";
|
||||||
import HotWords from "../pages/business/HotWords";
|
import HotWords from "../pages/business/HotWords";
|
||||||
import PromptTemplates from "../pages/business/PromptTemplates";
|
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";
|
import type { MenuRoute } from "../types";
|
||||||
|
|
||||||
|
|
@ -35,5 +39,12 @@ export const menuRoutes: MenuRoute[] = [
|
||||||
{ path: "/role-permissions", label: "角色权限绑定", element: <RolePermissionBinding />, perm: "menu:role-permissions" },
|
{ path: "/role-permissions", label: "角色权限绑定", element: <RolePermissionBinding />, perm: "menu:role-permissions" },
|
||||||
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg />, perm: "menu:speaker" },
|
{ path: "/speaker-reg", label: "声纹注册", element: <SpeakerReg />, perm: "menu:speaker" },
|
||||||
{ path: "/hotwords", label: "热词管理", element: <HotWords />, perm: "menu:hotword" },
|
{ 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