From 21b3ab3afc5858952b1e6b0e993cf174bd580b1a Mon Sep 17 00:00:00 2001 From: chenhao Date: Mon, 2 Mar 2026 19:59:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(business):=20=E6=B7=BB=E5=8A=A0AI=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE=E5=8A=9F=E8=83=BD=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增AiModel实体类定义数据库表结构 - 实现AI模型的增删改查REST API接口 - 添加前端AI模型管理页面支持配置展示 - 实现ASR和LLM两种模型类型的区分管理 - 添加模型远程列表获取和验证功能 - 实现默认模型设置和租户权限控制 - 新增AiTask实体用于AI任务调度管理 - 实现AI任务异步处理服务逻辑 - 添加会议转录和总结的完整处理流程 --- backend/design/db_schema.md | 56 +++ backend/design/db_schema_pgsql.sql | 106 +++++- .../imeeting/config/MybatisPlusConfig.java | 2 +- .../java/com/imeeting/config/WebConfig.java | 16 + .../controller/biz/AiModelController.java | 90 +++++ .../controller/biz/HotWordController.java | 133 +++++++ .../controller/biz/MeetingController.java | 127 +++++++ .../biz/PromptTemplateController.java | 115 ++++++ .../controller/biz/SpeakerController.java | 76 ++++ .../com/imeeting/dto/CreateTenantDTO.java | 14 + .../com/imeeting/dto/PasswordUpdateDTO.java | 9 + .../java/com/imeeting/dto/biz/AiModelDTO.java | 24 ++ .../java/com/imeeting/dto/biz/AiModelVO.java | 27 ++ .../java/com/imeeting/dto/biz/HotWordDTO.java | 17 + .../java/com/imeeting/dto/biz/HotWordVO.java | 22 ++ .../java/com/imeeting/dto/biz/MeetingDTO.java | 26 ++ .../imeeting/dto/biz/MeetingTranscriptVO.java | 15 + .../java/com/imeeting/dto/biz/MeetingVO.java | 25 ++ .../imeeting/dto/biz/PromptTemplateDTO.java | 15 + .../imeeting/dto/biz/PromptTemplateVO.java | 21 ++ .../imeeting/dto/biz/SpeakerRegisterDTO.java | 12 + .../java/com/imeeting/dto/biz/SpeakerVO.java | 17 + .../java/com/imeeting/entity/biz/AiModel.java | 48 +++ .../java/com/imeeting/entity/biz/AiTask.java | 36 ++ .../java/com/imeeting/entity/biz/HotWord.java | 39 ++ .../java/com/imeeting/entity/biz/Meeting.java | 46 +++ .../entity/biz/MeetingTranscript.java | 33 ++ .../imeeting/entity/biz/PromptTemplate.java | 33 ++ .../java/com/imeeting/entity/biz/Speaker.java | 35 ++ .../imeeting/mapper/biz/AiModelMapper.java | 9 + .../com/imeeting/mapper/biz/AiTaskMapper.java | 9 + .../imeeting/mapper/biz/HotWordMapper.java | 9 + .../imeeting/mapper/biz/MeetingMapper.java | 9 + .../mapper/biz/MeetingTranscriptMapper.java | 9 + .../mapper/biz/PromptTemplateMapper.java | 9 + .../imeeting/mapper/biz/SpeakerMapper.java | 9 + .../imeeting/service/biz/AiModelService.java | 17 + .../imeeting/service/biz/AiTaskService.java | 9 + .../imeeting/service/biz/HotWordService.java | 14 + .../imeeting/service/biz/MeetingService.java | 21 ++ .../service/biz/PromptTemplateService.java | 16 + .../imeeting/service/biz/SpeakerService.java | 10 + .../service/biz/impl/AiModelServiceImpl.java | 211 +++++++++++ .../service/biz/impl/AiTaskServiceImpl.java | 256 +++++++++++++ .../service/biz/impl/HotWordServiceImpl.java | 132 +++++++ .../service/biz/impl/MeetingServiceImpl.java | 215 +++++++++++ .../biz/impl/PromptTemplateServiceImpl.java | 90 +++++ .../service/biz/impl/SpeakerServiceImpl.java | 99 +++++ backend/src/main/resources/application.yml | 11 + frontend/src/api/business/aimodel.ts | 85 +++++ frontend/src/api/business/hotword.ts | 74 ++++ frontend/src/api/business/meeting.ts | 104 ++++++ frontend/src/api/business/prompt.ts | 68 ++++ frontend/src/api/business/speaker.ts | 44 +++ frontend/src/layouts/AppLayout.tsx | 4 +- frontend/src/pages/business/AiModels.tsx | 339 ++++++++++++++++++ frontend/src/pages/business/HotWords.tsx | 307 ++++++++++++++++ frontend/src/pages/business/MeetingCreate.tsx | 254 +++++++++++++ frontend/src/pages/business/MeetingDetail.tsx | 299 +++++++++++++++ frontend/src/pages/business/Meetings.tsx | 245 +++++++++++++ .../src/pages/business/PromptTemplates.tsx | 313 ++++++++++++++++ frontend/src/pages/business/SpeakerReg.tsx | 203 +++++++++++ frontend/src/routes/index.tsx | 7 +- frontend/src/routes/routes.tsx | 13 +- 64 files changed, 4752 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/com/imeeting/config/WebConfig.java create mode 100644 backend/src/main/java/com/imeeting/controller/biz/AiModelController.java create mode 100644 backend/src/main/java/com/imeeting/controller/biz/HotWordController.java create mode 100644 backend/src/main/java/com/imeeting/controller/biz/MeetingController.java create mode 100644 backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java create mode 100644 backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java create mode 100644 backend/src/main/java/com/imeeting/dto/CreateTenantDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/PasswordUpdateDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/HotWordDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/HotWordVO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/MeetingDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptVO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/AiModel.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/AiTask.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/HotWord.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/Meeting.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/MeetingTranscript.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/Speaker.java create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/AiModelMapper.java create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/AiTaskMapper.java create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/HotWordMapper.java create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/MeetingMapper.java create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptMapper.java create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/PromptTemplateMapper.java create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/SpeakerMapper.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/AiModelService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/AiTaskService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/HotWordService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/MeetingService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/SpeakerService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java create mode 100644 frontend/src/api/business/aimodel.ts create mode 100644 frontend/src/api/business/hotword.ts create mode 100644 frontend/src/api/business/meeting.ts create mode 100644 frontend/src/api/business/prompt.ts create mode 100644 frontend/src/api/business/speaker.ts create mode 100644 frontend/src/pages/business/AiModels.tsx create mode 100644 frontend/src/pages/business/HotWords.tsx create mode 100644 frontend/src/pages/business/MeetingCreate.tsx create mode 100644 frontend/src/pages/business/MeetingDetail.tsx create mode 100644 frontend/src/pages/business/Meetings.tsx create mode 100644 frontend/src/pages/business/PromptTemplates.tsx create mode 100644 frontend/src/pages/business/SpeakerReg.tsx diff --git a/backend/design/db_schema.md b/backend/design/db_schema.md index 214eeeb..1ebb54a 100644 --- a/backend/design/db_schema.md +++ b/backend/design/db_schema.md @@ -273,6 +273,9 @@ | template_name | VARCHAR(100) | NOT NULL | 模板名称 | | category | VARCHAR(20) | | 分类 (字典: biz_prompt_category) | | is_system | SMALLINT | DEFAULT 0 | 是否预置 (1:是, 0:否) | +| creator_id | BIGINT | | 创建人ID | +| tags | JSONB | | 标签数组 | +| usage_count | INTEGER | DEFAULT 0 | 使用次数 | | prompt_content | TEXT | NOT NULL | 提示词内容 | | status | SMALLINT | DEFAULT 1 | 状态 (1:启用, 0:禁用) | | remark | VARCHAR(255) | | 备注 | @@ -284,3 +287,56 @@ - `idx_prompt_tenant`: `(tenant_id)` - `idx_prompt_system`: `(is_system) WHERE is_deleted = 0` +### 5.4 `biz_ai_models`(AI 模型管理表) +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| id | BIGSERIAL | PK | 主键ID | +| tenant_id | BIGINT | NOT NULL | 租户ID | +| model_type | VARCHAR(20) | NOT NULL | ASR (语音) 或 LLM (总结) | +| model_name | VARCHAR(100) | NOT NULL | 自定义名称 | +| provider | VARCHAR(50) | | 提供商 (Aliyun, OpenAI等) | +| base_url | VARCHAR(255) | | 基础请求地址 | +| model_code | VARCHAR(100) | | 模型代码 | +| ws_url | VARCHAR(255) | | WebSocket 地址 (ASR) | +| temperature | DECIMAL | DEFAULT 0.7 | 随机性 (LLM) | +| media_config | JSONB | | 采样率、协议等 | +| is_default | SMALLINT | DEFAULT 0 | 默认模型标记 | +| status | SMALLINT | DEFAULT 1 | 启用状态 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | +| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 | + +索引: +- `idx_aimodel_tenant`: `(tenant_id)` +- `idx_aimodel_type`: `(model_type, is_default) WHERE is_deleted = 0` + +### 5.5 `biz_meetings`(会议主表) +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| id | BIGSERIAL | PK | 主键ID | +| tenant_id | BIGINT | NOT NULL | 租户ID | +| title | VARCHAR(200) | NOT NULL | 会议标题 | +| asr_model_id | BIGINT | | 使用的 ASR 模型 | +| summary_model_id | BIGINT | | 使用的 LLM 模型 | +| prompt_content | TEXT | | **[快照]** 发起任务时的提示词模板内容 | +| summary_content | TEXT | | **[固化]** 最终生成的 Markdown 总结内容 | +| status | SMALLINT | DEFAULT 0 | 0:待处理, 1:识别中, 2:总结中, 3:已完成, 4:失败 | + +### 5.6 `biz_meeting_transcripts`(转录明细表) +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| id | BIGSERIAL | PK | 主键ID | +| meeting_id | BIGINT | NOT NULL | 关联会议ID | +| speaker_label | VARCHAR(50) | | 发言人标签 | +| content | TEXT | | 转录文字 | +| start_time | INTEGER | | 开始时间 (ms) | + +### 5.7 `biz_ai_tasks`(AI 任务流水表) +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| id | BIGSERIAL | PK | 主键ID | +| task_type | VARCHAR(20) | | ASR / SUMMARY | +| request_data | JSONB | | 请求原始数据 | +| response_data | JSONB | | 响应原始数据 | +| status | SMALLINT | | 0:排队, 1:处理中, 2:成功, 3:失败 | + diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index ca42ff3..d5fb438 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -251,7 +251,10 @@ CREATE TABLE biz_hot_words ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, -- 租户ID (强制隔离) word VARCHAR(100) NOT NULL, -- 热词原文 - pinyin_list JSONB, -- 拼音数组 (支持多音字, 如 ["i mi ting", "i mei ting"]) + is_public SMALLINT DEFAULT 0, -- 1:租户公开, 0:个人私有 + creator_id BIGINT, -- 创建者ID + pinyin_list JSONB, -- 拼音数组 + (支持多音字, 如 ["i mi ting", "i mei ting"]) match_strategy SMALLINT DEFAULT 1, -- 匹配策略: 1:精确匹配, 2:拼音模糊匹配 category VARCHAR(50), -- 类别 (人名、术语、地名) weight INTEGER DEFAULT 10, -- 权重 (1-100) @@ -278,6 +281,9 @@ CREATE TABLE biz_prompt_templates ( template_name VARCHAR(100) NOT NULL, -- 模板名称 category VARCHAR(20), -- 分类 (字典: biz_prompt_category) is_system SMALLINT DEFAULT 0, -- 是否系统预置 (1:是, 0:否) + creator_id BIGINT, -- 创建人ID + tags JSONB, -- 标签数组 (JSONB) + usage_count INTEGER DEFAULT 0, -- 使用次数 prompt_content TEXT NOT NULL, -- 提示词内容 status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用 remark VARCHAR(255), -- 备注 @@ -291,6 +297,104 @@ CREATE INDEX idx_prompt_system ON biz_prompt_templates (is_system) WHERE is_dele COMMENT ON TABLE biz_prompt_templates IS '会议总结提示词模板表'; +-- ---------------------------- +-- 9. 业务模块 - AI 模型管理 +-- ---------------------------- +DROP TABLE IF EXISTS biz_ai_models CASCADE; +CREATE TABLE biz_ai_models ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID + model_type VARCHAR(20) NOT NULL, -- 类型: ASR, LLM + model_name VARCHAR(100) NOT NULL, -- 模型显示名称 + provider VARCHAR(50), -- 提供商 (Aliyun, OpenAI, Tencent等) + base_url VARCHAR(255), -- 接口基础地址 + api_path VARCHAR(100), -- API路径 + api_key VARCHAR(255), -- API密钥 (加密存储) + model_code VARCHAR(100), -- 模型真实编码 (如 gpt-4o) + ws_url VARCHAR(255), -- WebSocket 地址 (ASR 专用) + temperature DECIMAL(3,2) DEFAULT 0.7, -- LLM 温度 + top_p DECIMAL(3,2) DEFAULT 0.9, -- LLM 核采样 + media_config JSONB, -- 媒体参数 (采样率、声道等) + is_default SMALLINT DEFAULT 0, -- 是否默认 + status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用 + remark VARCHAR(255), -- 备注 + created_at TIMESTAMP(6) NOT NULL DEFAULT now(), + updated_at TIMESTAMP(6) NOT NULL DEFAULT now(), + is_deleted SMALLINT NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_aimodel_tenant ON biz_ai_models (tenant_id); +CREATE INDEX idx_aimodel_type ON biz_ai_models (model_type, is_default) WHERE is_deleted = 0; + +COMMENT ON TABLE biz_ai_models IS 'AI 识别与总结模型配置表'; + +-- ---------------------------- +-- 10. 业务模块 - 会议主表 +-- ---------------------------- +DROP TABLE IF EXISTS biz_meetings CASCADE; +CREATE TABLE biz_meetings ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL DEFAULT 0, + title VARCHAR(200) NOT NULL, + meeting_time TIMESTAMP(6), + participants TEXT, + tags VARCHAR(255), + audio_url VARCHAR(500), + creator_id BIGINT, -- 发起人ID + creator_name VARCHAR(100), -- 发起人姓名 + asr_model_id BIGINT, -- ASR模型ID + summary_model_id BIGINT, -- LLM模型ID + prompt_content TEXT, -- 发起任务时的提示词模板快照 + hot_words JSONB, -- 任务发起时的热词快照 + summary_content TEXT, -- Markdown 总结结果 + status SMALLINT DEFAULT 0, -- 0:待处理, 1:处理中, 2:成功, 3:失败 + created_at TIMESTAMP(6) NOT NULL DEFAULT now(), + updated_at TIMESTAMP(6) NOT NULL DEFAULT now(), + is_deleted SMALLINT NOT NULL DEFAULT 0 +); + +-- ---------------------------- +-- 11. 业务模块 - 转录明细表 +-- ---------------------------- +DROP TABLE IF EXISTS biz_meeting_transcripts CASCADE; +CREATE TABLE biz_meeting_transcripts ( + id BIGSERIAL PRIMARY KEY, + meeting_id BIGINT NOT NULL, + speaker_id VARCHAR(50), -- ASR返回的发言人标识 + speaker_name VARCHAR(100), -- 修改后的发言人姓名 + speaker_label VARCHAR(50), -- 发言人标签 + content TEXT, -- 转录内容 + start_time INTEGER, -- 开始时间(ms) + end_time INTEGER, -- 结束时间(ms) + sort_order INTEGER, + created_at TIMESTAMP(6) NOT NULL DEFAULT now() +); + +-- ---------------------------- +-- 12. 业务模块 - AI 异步任务日志表 +-- ---------------------------- +DROP TABLE IF EXISTS biz_ai_tasks CASCADE; +CREATE TABLE biz_ai_tasks ( + id BIGSERIAL PRIMARY KEY, + meeting_id BIGINT NOT NULL, + task_type VARCHAR(20), -- ASR / SUMMARY + status SMALLINT DEFAULT 0, -- 0:排队, 1:执行中, 2:成功, 3:失败 + request_data JSONB, -- 请求三方原始JSON + response_data JSONB, -- 三方返回原始JSON + error_msg TEXT, -- 错误堆栈 + started_at TIMESTAMP(6), + completed_at TIMESTAMP(6) +); + +CREATE INDEX idx_meeting_tenant ON biz_meetings (tenant_id); +CREATE INDEX idx_transcript_meeting ON biz_meeting_transcripts (meeting_id); +CREATE INDEX idx_aitask_meeting ON biz_ai_tasks (meeting_id); + +COMMENT ON TABLE biz_meetings IS '会议管理主表'; +COMMENT ON TABLE biz_meeting_transcripts IS '会议转录明细表'; +COMMENT ON TABLE biz_ai_tasks IS 'AI 任务流水日志表'; + + -- ---------------------------- -- 5. 基础初始化数据 -- ---------------------------- diff --git a/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java b/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java index 98d0d90..d248c26 100644 --- a/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java +++ b/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java @@ -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()); diff --git a/backend/src/main/java/com/imeeting/config/WebConfig.java b/backend/src/main/java/com/imeeting/config/WebConfig.java new file mode 100644 index 0000000..a304f05 --- /dev/null +++ b/backend/src/main/java/com/imeeting/config/WebConfig.java @@ -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/"); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java new file mode 100644 index 0000000..5969971 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java @@ -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 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 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 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>> 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> 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 getDefault(@RequestParam String type) { + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return ApiResponse.ok(aiModelService.getDefaultModel(type, loginUser.getTenantId())); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java b/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java new file mode 100644 index 0000000..8f4fb5d --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/HotWordController.java @@ -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 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 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 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>> 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 page = hotWordService.page(new Page<>(current, size), + new LambdaQueryWrapper() + .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 vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList()); + PageResult> result = new PageResult<>(); + result.setTotal(page.getTotal()); + result.setRecords(vos); + return ApiResponse.ok(result); + } + + @GetMapping("/pinyin") + @PreAuthorize("isAuthenticated()") + public ApiResponse> 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; + } +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java new file mode 100644 index 0000000..dac31be --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -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 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 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>> 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 getDetail(@PathVariable Long id) { + return ApiResponse.ok(meetingService.getDetail(id)); + } + + @GetMapping("/transcripts/{id}") + @PreAuthorize("isAuthenticated()") + public ApiResponse> getTranscripts(@PathVariable Long id) { + return ApiResponse.ok(meetingService.getTranscripts(id)); + } + + @PutMapping("/speaker") + @PreAuthorize("isAuthenticated()") + public ApiResponse updateSpeaker(@RequestBody Map 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 reSummary(@RequestBody Map 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 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 delete(@PathVariable Long id) { + meetingService.deleteMeeting(id); + return ApiResponse.ok(true); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java b/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java new file mode 100644 index 0000000..3c33094 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/PromptTemplateController.java @@ -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 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 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 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 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>> 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())); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java new file mode 100644 index 0000000..455b1e0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/SpeakerController.java @@ -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 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() { + 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 list = speakerService.list(new LambdaQueryWrapper() + .eq(Speaker::getUserId, loginUser.getUserId()) + .orderByDesc(Speaker::getCreatedAt)); + + List 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; + } +} diff --git a/backend/src/main/java/com/imeeting/dto/CreateTenantDTO.java b/backend/src/main/java/com/imeeting/dto/CreateTenantDTO.java new file mode 100644 index 0000000..0853c9a --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/CreateTenantDTO.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/dto/PasswordUpdateDTO.java b/backend/src/main/java/com/imeeting/dto/PasswordUpdateDTO.java new file mode 100644 index 0000000..cdba6fd --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/PasswordUpdateDTO.java @@ -0,0 +1,9 @@ +package com.imeeting.dto; + +import lombok.Data; + +@Data +public class PasswordUpdateDTO { + private String oldPassword; + private String newPassword; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java b/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java new file mode 100644 index 0000000..8ca4c9b --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java @@ -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 mediaConfig; + private Integer isDefault; + private Integer status; + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java b/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java new file mode 100644 index 0000000..9fac1b9 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java @@ -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 mediaConfig; + private Integer isDefault; + private Integer status; + private String remark; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/HotWordDTO.java b/backend/src/main/java/com/imeeting/dto/biz/HotWordDTO.java new file mode 100644 index 0000000..6f6fc58 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/HotWordDTO.java @@ -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 pinyinList; + private Integer matchStrategy; + private String category; + private Integer weight; + private Integer status; + private Integer isPublic; + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/HotWordVO.java b/backend/src/main/java/com/imeeting/dto/biz/HotWordVO.java new file mode 100644 index 0000000..ea87e72 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/HotWordVO.java @@ -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 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; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingDTO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingDTO.java new file mode 100644 index 0000000..cb6aab5 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingDTO.java @@ -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 hotWords; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptVO.java new file mode 100644 index 0000000..d650a72 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptVO.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java new file mode 100644 index 0000000..a7a9651 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java new file mode 100644 index 0000000..34c70b3 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateDTO.java @@ -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 tags; + private String promptContent; + private Integer status; + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java new file mode 100644 index 0000000..c18334a --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java @@ -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 tags; + private Integer usageCount; + private String promptContent; + private Integer status; + private String remark; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java b/backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java new file mode 100644 index 0000000..4d0340f --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/SpeakerRegisterDTO.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java b/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java new file mode 100644 index 0000000..5990d0b --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/SpeakerVO.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/AiModel.java b/backend/src/main/java/com/imeeting/entity/biz/AiModel.java new file mode 100644 index 0000000..0769411 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/AiModel.java @@ -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 mediaConfig; + + private Integer isDefault; + + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/AiTask.java b/backend/src/main/java/com/imeeting/entity/biz/AiTask.java new file mode 100644 index 0000000..7c79be0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/AiTask.java @@ -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 requestData; + + @TableField(typeHandler = JacksonTypeHandler.class) + private Map responseData; + + private String errorMsg; + + private LocalDateTime startedAt; + + private LocalDateTime completedAt; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/HotWord.java b/backend/src/main/java/com/imeeting/entity/biz/HotWord.java new file mode 100644 index 0000000..88a3efb --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/HotWord.java @@ -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 pinyinList; + + private Integer matchStrategy; + + private String category; + + private Integer weight; + + private Integer isSynced; + + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java new file mode 100644 index 0000000..5a57f88 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java @@ -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 hotWords; + + private String summaryContent; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscript.java b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscript.java new file mode 100644 index 0000000..c21064a --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscript.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java b/backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java new file mode 100644 index 0000000..37fd5df --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/PromptTemplate.java @@ -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 tags; + + private Integer usageCount; + + private String promptContent; + + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/Speaker.java b/backend/src/main/java/com/imeeting/entity/biz/Speaker.java new file mode 100644 index 0000000..3f67416 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/Speaker.java @@ -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 +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/AiModelMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/AiModelMapper.java new file mode 100644 index 0000000..aadec7a --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/AiModelMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/AiTaskMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/AiTaskMapper.java new file mode 100644 index 0000000..5fae2f2 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/AiTaskMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/HotWordMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/HotWordMapper.java new file mode 100644 index 0000000..a723ca7 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/HotWordMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/MeetingMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/MeetingMapper.java new file mode 100644 index 0000000..95e283a --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/MeetingMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptMapper.java new file mode 100644 index 0000000..88263d9 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/PromptTemplateMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/PromptTemplateMapper.java new file mode 100644 index 0000000..d6b2cb5 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/PromptTemplateMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/SpeakerMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/SpeakerMapper.java new file mode 100644 index 0000000..3d4c86f --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/SpeakerMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/service/biz/AiModelService.java b/backend/src/main/java/com/imeeting/service/biz/AiModelService.java new file mode 100644 index 0000000..bfede83 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/AiModelService.java @@ -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 { + AiModelVO saveModel(AiModelDTO dto); + AiModelVO updateModel(AiModelDTO dto); + PageResult> pageModels(Integer current, Integer size, String name, String type, Long tenantId); + List fetchRemoteModels(String provider, String baseUrl, String apiKey); + AiModelVO getDefaultModel(String type, Long tenantId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/AiTaskService.java b/backend/src/main/java/com/imeeting/service/biz/AiTaskService.java new file mode 100644 index 0000000..710c492 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/AiTaskService.java @@ -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 { + void dispatchTasks(Long meetingId); + void dispatchSummaryTask(Long meetingId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/HotWordService.java b/backend/src/main/java/com/imeeting/service/biz/HotWordService.java new file mode 100644 index 0000000..e8f6fc0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/HotWordService.java @@ -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 { + HotWordVO saveHotWord(HotWordDTO hotWordDTO, Long userId); + HotWordVO updateHotWord(HotWordDTO hotWordDTO); + List generatePinyin(String word); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingService.java new file mode 100644 index 0000000..3f743ff --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingService.java @@ -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 { + MeetingVO createMeeting(MeetingDTO dto); + PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType); + void deleteMeeting(Long id); + MeetingVO getDetail(Long id); + List getTranscripts(Long meetingId); + void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label); + void reSummary(Long meetingId, Long summaryModelId, Long promptId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java b/backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java new file mode 100644 index 0000000..464805e --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/PromptTemplateService.java @@ -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 { + PromptTemplateVO saveTemplate(PromptTemplateDTO dto, Long userId); + PromptTemplateVO updateTemplate(PromptTemplateDTO dto); + PageResult> pageTemplates(Integer current, Integer size, String name, String category, Long tenantId, Long userId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java b/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java new file mode 100644 index 0000000..96b4866 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/SpeakerService.java @@ -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 { + SpeakerVO register(SpeakerRegisterDTO registerDTO); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java new file mode 100644 index 0000000..cfee23d --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java @@ -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 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> pageModels(Integer current, Integer size, String name, String type, Long tenantId) { + Page page = this.page(new Page<>(current, size), + new LambdaQueryWrapper() + .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 vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList()); + PageResult> result = new PageResult<>(); + result.setTotal(page.getTotal()); + result.setRecords(vos); + return result; + } + + @Override + public List 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 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 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() + .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() + .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; + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java new file mode 100644 index 0000000..b10c1b3 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -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 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 transcripts = transcriptMapper.selectList(new LambdaQueryWrapper() + .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 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 req = new HashMap<>(); + req.put("model", llmModel.getModelCode()); + req.put("temperature", llmModel.getTemperature()); + + List> 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 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 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); + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java new file mode 100644 index 0000000..9601c90 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/HotWordServiceImpl.java @@ -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 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 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> pinyinMatrix = new ArrayList<>(); + for (char c : word.toCharArray()) { + List 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 combinations = new ArrayList<>(); + generateCombinations(pinyinMatrix, 0, "", combinations); + return combinations.stream().limit(5).collect(Collectors.toList()); + } + + private void generateCombinations(List> matrix, int index, String current, List 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; + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java new file mode 100644 index 0000000..279b809 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java @@ -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 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 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 hotwordServiceList(Long tenantId) { + return hotWordService.list(new LambdaQueryWrapper() + .eq(HotWord::getTenantId, tenantId) + .eq(HotWord::getStatus, 1)) + .stream().map(HotWord::getWord).collect(Collectors.toList()); + } + + @Override + public PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, Long userId, String userName, String viewType) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 page = this.page(new Page<>(current, size), wrapper); + List vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList()); + + PageResult> 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 getTranscripts(Long meetingId) { + return transcriptMapper.selectList(new LambdaQueryWrapper() + .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() + .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 userIds = Arrays.stream(meeting.getParticipants().split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Long::valueOf) + .collect(Collectors.toList()); + + if (!userIds.isEmpty()) { + List 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; + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java new file mode 100644 index 0000000..46a89c8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java @@ -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 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> pageTemplates(Integer current, Integer size, String name, String category, Long tenantId, Long userId) { + Page page = this.page(new Page<>(current, size), + new LambdaQueryWrapper() + .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 vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList()); + + PageResult> 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; + } +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java new file mode 100644 index 0000000..ed28196 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/SpeakerServiceImpl.java @@ -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 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() + .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; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 4bf52d5..e1b5d85 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -14,6 +14,16 @@ spring: database: 15 cache: type: redis + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + + jackson: + date-format: yyyy-MM-dd HH:mm:ss + serialization: + write-dates-as-timestamps: false + time-zone: GMT+8 mybatis-plus: configuration: @@ -30,6 +40,7 @@ security: secret: change-me-please-change-me-32bytes app: + server-base-url: http://10.100.52.13:8080 # 本地应用对外暴露的 IP 和端口 upload-path: D:/data/imeeting/uploads/ resource-prefix: /api/static/ captcha: diff --git a/frontend/src/api/business/aimodel.ts b/frontend/src/api/business/aimodel.ts new file mode 100644 index 0000000..6178741 --- /dev/null +++ b/frontend/src/api/business/aimodel.ts @@ -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; + 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; + isDefault: number; + status: number; + remark?: string; +} + +export const getAiModelPage = (params: { + current: number; + size: number; + name?: string; + type?: string; +}) => { + return http.get( + "/api/biz/aimodel/page", + { params } + ); +}; + +export const saveAiModel = (data: AiModelDTO) => { + return http.post( + "/api/biz/aimodel", + data + ); +}; + +export const updateAiModel = (data: AiModelDTO) => { + return http.put( + "/api/biz/aimodel", + data + ); +}; + +export const deleteAiModel = (id: number) => { + return http.delete( + `/api/biz/aimodel/${id}` + ); +}; + +export const getRemoteModelList = (params: { provider: string; baseUrl: string; apiKey?: string }) => { + return http.get( + "/api/biz/aimodel/remote-list", + { params } + ); +}; + +export const getAiModelDefault = (type: 'ASR' | 'LLM') => { + return http.get( + "/api/biz/aimodel/default", + { params: { type } } + ); +}; diff --git a/frontend/src/api/business/hotword.ts b/frontend/src/api/business/hotword.ts new file mode 100644 index 0000000..857d9b4 --- /dev/null +++ b/frontend/src/api/business/hotword.ts @@ -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( + "/api/biz/hotword/page", + { params } + ); +}; + +export const syncHotWord = (id: number) => { + return http.post( + `/api/biz/hotword/${id}/sync` + ); +}; + +export const saveHotWord = (data: HotWordDTO) => { + return http.post( + "/api/biz/hotword", + data + ); +}; + +export const updateHotWord = (data: HotWordDTO) => { + return http.put( + "/api/biz/hotword", + data + ); +}; + +export const deleteHotWord = (id: number) => { + return http.delete( + `/api/biz/hotword/${id}` + ); +}; + +export const getPinyinSuggestion = (word: string) => { + return http.get( + "/api/biz/hotword/pinyin", + { params: { word } } + ); +}; diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts new file mode 100644 index 0000000..edce621 --- /dev/null +++ b/frontend/src/api/business/meeting.ts @@ -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( + "/api/biz/meeting/page", + { params } + ); +}; + +export const createMeeting = (data: MeetingDTO) => { + return http.post( + "/api/biz/meeting", + data + ); +}; + +export const deleteMeeting = (id: number) => { + return http.delete( + `/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( + `/api/biz/meeting/detail/${id}` + ); +}; + +export const getTranscripts = (id: number) => { + return http.get( + `/api/biz/meeting/transcripts/${id}` + ); +}; + +export const updateSpeakerInfo = (params: { meetingId: number; speakerId: string; newName: string; label: string }) => { + return http.put( + "/api/biz/meeting/speaker", + params + ); +}; + +export const reSummary = (params: { meetingId: number; summaryModelId: number; promptId: number }) => { + return http.post( + "/api/biz/meeting/re-summary", + params + ); +}; + +export const updateMeeting = (data: Partial) => { + return http.put( + "/api/biz/meeting", + data + ); +}; + +export const uploadAudio = (file: File) => { + const formData = new FormData(); + formData.append("file", file); + return http.post( + "/api/biz/meeting/upload", + formData, + { headers: { "Content-Type": "multipart/form-data" } } + ); +}; diff --git a/frontend/src/api/business/prompt.ts b/frontend/src/api/business/prompt.ts new file mode 100644 index 0000000..2630f9b --- /dev/null +++ b/frontend/src/api/business/prompt.ts @@ -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( + "/api/biz/prompt/page", + { params } + ); +}; + +export const savePromptTemplate = (data: PromptTemplateDTO) => { + return http.post( + "/api/biz/prompt", + data + ); +}; + +export const updatePromptTemplate = (data: PromptTemplateDTO) => { + return http.put( + "/api/biz/prompt", + data + ); +}; + +export const deletePromptTemplate = (id: number) => { + return http.delete( + `/api/biz/prompt/${id}` + ); +}; + +export const updatePromptStatus = (id: number, status: number) => { + return http.put( + `/api/biz/prompt/${id}/status`, + null, + { params: { status } } + ); +}; diff --git a/frontend/src/api/business/speaker.ts b/frontend/src/api/business/speaker.ts new file mode 100644 index 0000000..de301ec --- /dev/null +++ b/frontend/src/api/business/speaker.ts @@ -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( + "/api/biz/speaker/register", + formData, + { + headers: { + "Content-Type": "multipart/form-data" + } + } + ); +}; + +export const getSpeakerList = () => { + return http.get( + "/api/biz/speaker/list" + ); +}; diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index 9539636..353e40f 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -18,7 +18,8 @@ import { ShopOutlined, AudioOutlined, TagsOutlined, - BulbOutlined + BulbOutlined, + ApiOutlined } from "@ant-design/icons"; import { useAuth } from "../hooks/useAuth"; import { usePermission } from "../hooks/usePermission"; @@ -38,6 +39,7 @@ const iconMap: Record = { "audio": , "hotword": , "prompt": , + "aimodel": , }; export default function AppLayout() { diff --git a/frontend/src/pages/business/AiModels.tsx b/frontend/src/pages/business/AiModels.tsx new file mode 100644 index 0000000..7343078 --- /dev/null +++ b/frontend/src/pages/business/AiModels.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [current, setCurrent] = useState(1); + const [size, setSize] = useState(10); + const [searchName, setSearchName] = useState(''); + const [searchType, setSearchType] = useState(undefined); + + const [drawerVisible, setDrawerVisible] = useState(false); + const [editingId, setEditingId] = useState(null); + const [submitLoading, setSubmitLoading] = useState(false); + const [fetchLoading, setFetchLoading] = useState(false); + const [remoteModels, setRemoteModels] = useState([]); + + 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) => ( + + {text} + {record.isDefault === 1 && 默认} + {record.tenantId === 0 && } + + ) + }, + { + title: '类型', + dataIndex: 'modelType', + key: 'modelType', + render: (type: string) => {type === 'ASR' ? '语音识别' : '会议总结'} + }, + { + title: '提供商', + dataIndex: 'provider', + key: 'provider', + render: (val: string) => { + const item = providers.find(i => i.itemValue === val); + return item ? {item.itemLabel} : val; + } + }, + { + title: '模型代码', + dataIndex: 'modelCode', + key: 'modelCode', + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (status: number) => status === 1 ? 启用 : 禁用 + }, + { + title: '操作', + key: 'action', + render: (_: any, record: AiModelVO) => { + const canEdit = record.tenantId !== 0 || isPlatformAdmin; + return ( + + {canEdit && } + {canEdit && ( + deleteAiModel(record.id).then(() => fetchData())}> + + + )} + + ); + } + } + ]; + + return ( +
+ + setSearchType(e.target.value)} buttonStyle="solid"> + 全部 + 语音识别 + 会议总结 + + } + allowClear + onPressEnter={(e) => setSearchName((e.target as any).value)} + style={{ width: 180 }} + /> + + + }> + { setCurrent(p); setSize(s); }}} + /> + + + {editingId ? '编辑模型配置' : '添加模型配置'}} + width={600} + onClose={() => setDrawerVisible(false)} + open={drawerVisible} + extra={ + + + + + } + > +
+ + setModelType(e.target.value)} disabled={!!editingId}> + 语音识别 (ASR) + 会议总结 (LLM) + + + + +
+ + + + + + + + + + + + + + + + + + + + 业务参数 + + + + { + // 如果是数组(tags模式返回数组),取最后一个值作为最终模型编码 + return Array.isArray(value) ? value[value.length - 1] : value; + }} + > + + + + + + + {modelType === 'ASR' && ( + + + + )} + + {modelType === 'LLM' && ( + <> + + + + + + + + + + + + + + + + + )} + + + + + form.setFieldsValue({ isDefault: checked ? 1 : 0 })} + checked={form.getFieldValue('isDefault') === 1} + /> + + + + + form.setFieldsValue({ status: checked ? 1 : 0 })} + checked={form.getFieldValue('status') === 1} + /> + + + + + + + + + + + ); +}; + +export default AiModels; diff --git a/frontend/src/pages/business/HotWords.tsx b/frontend/src/pages/business/HotWords.tsx new file mode 100644 index 0000000..7db5739 --- /dev/null +++ b/frontend/src/pages/business/HotWords.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [current, setCurrent] = useState(1); + const [size, setSize] = useState(10); + const [searchWord, setSearchWord] = useState(''); + const [searchCategory, setSearchCategory] = useState(undefined); + + const [modalVisible, setModalVisible] = useState(false); + const [editingId, setEditingId] = useState(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) => { + 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) => ( + + {text} + {record.isPublic === 1 ? ( + + ) : ( + + )} + + ) + }, + { + title: '拼音', + dataIndex: 'pinyinList', + key: 'pinyinList', + render: (list: string[]) => ( + + {list?.map(p => {p})} + + ) + }, + { + 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 ? 公开 : 私有 + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (status: number) => status === 1 ? : + }, + { + title: '操作', + key: 'action', + render: (_: any, record: HotWordVO) => { + const canEdit = record.isPublic === 1 ? isPlatformAdmin : record.creatorId === userProfile.userId; + return ( + + {canEdit ? ( + <> + + deleteHotWord(record.id).then(fetchData)}> + + + + ) : ( + 无权操作 + )} + + ); + } + } + ]; + + return ( +
+ + + } + allowClear + onPressEnter={(e) => {setSearchWord((e.target as any).value); setCurrent(1);}} + style={{ width: 200 }} + /> + + + } + > +
`共 ${t} 条`, + onChange: (p, s) => { setCurrent(p); setSize(s); } + }} + /> + + + setModalVisible(false)} + confirmLoading={submitLoading} + width={550} + > +
+ + + + + + + + + +
+ + + + + + + + + + + + + + + + + + {isPlatformAdmin && ( + + + + + + + + + )} + + + + + + + + + ); +}; + +export default HotWords; diff --git a/frontend/src/pages/business/MeetingCreate.tsx b/frontend/src/pages/business/MeetingCreate.tsx new file mode 100644 index 0000000..f44a17c --- /dev/null +++ b/frontend/src/pages/business/MeetingCreate.tsx @@ -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([]); + const [llmModels, setLlmModels] = useState([]); + const [prompts, setPrompts] = useState([]); + const [hotwordList, setHotwordList] = useState([]); + const [userList, setUserList] = useState([]); + + const [fileList, setFileList] = useState([]); + 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 ( +
+
+ + {/* 头部导航 - 紧凑化 */} +
+ +
+ 发起新会议分析 + 请配置录音文件及 AI 模型参数 +
+
+ +
+ + {/* 左侧:文件与基础信息 */} +
+ + + 录音上传} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)' }}> + setFileList(info.fileList.slice(-1))} + maxCount={1} + style={{ borderRadius: 8, padding: '16px 0' }} + > +

+

点击或拖拽录音文件

+ {uploadProgress > 0 && uploadProgress < 100 && } + {audioUrl && 就绪: {audioUrl.split('/').pop()}} +
+ + + 基础信息} bordered={false} style={{ borderRadius: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.03)' }}> + + + + + + + + + + + + + {userList.map(u => ( + + ))} + + + + + + + {/* 右侧:AI 配置 - 固定且不滚动 */} + + AI 分析配置} + 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' }} + > +
+ + + + + + + + + + + + + 纠错热词 } + style={{ marginBottom: 16 }} + > + + +
+ +
+
+ + + 系统将自动执行:转录固化 + 智能总结。 + +
+ + +
+
+ + + + + + ); +}; + +export default MeetingCreate; diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx new file mode 100644 index 0000000..4e81754 --- /dev/null +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -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 ( +
e.stopPropagation()}> +
+ 发言人姓名 + setName(e.target.value)} placeholder="输入姓名" size="small" style={{ marginTop: 4 }} /> +
+
+ 角色标签 + +
+ +
+ ); +}; + +const MeetingDetail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [form] = Form.useForm(); + const [summaryForm] = Form.useForm(); + + const [meeting, setMeeting] = useState(null); + const [transcripts, setTranscripts] = useState([]); + const [loading, setLoading] = useState(true); + const [editVisible, setEditVisible] = useState(false); + const [summaryVisible, setSummaryVisible] = useState(false); + const [actionLoading, setActionLoading] = useState(false); + + const [llmModels, setLlmModels] = useState([]); + const [prompts, setPrompts] = useState([]); + const [userList, setUserList] = useState([]); + const { items: speakerLabels } = useDict('biz_speaker_label'); + + const audioRef = useRef(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
; + if (!meeting) return
; + + return ( +
+ + navigate('/meetings')}>会议中心 + 会议详情 + + + + +
+ + + {meeting.title} {isOwner && <EditOutlined style={{ fontSize: 16, cursor: 'pointer', color: '#1890ff' }} onClick={handleEditMeeting} />} + + }> + {dayjs(meeting.meetingTime).format('YYYY-MM-DD HH:mm')} + + {meeting.tags?.split(',').filter(Boolean).map(t => {t})} + + {meeting.participants || '未指定'} + + + + + + {isOwner && } + + + + + + +
+ +
+ 语音转录} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '16px', minHeight: 0 }} + extra={meeting.audioUrl && + + + AI 总结} style={{ height: '100%', display: 'flex', flexDirection: 'column' }} bodyStyle={{ flex: 1, overflowY: 'auto', padding: '24px', minHeight: 0 }}> + {meeting.summaryContent ?
{meeting.summaryContent}
: +
{meeting.status === 2 ? 正在重新总结... : }
} +
+ + + + + {/* 修改基础信息弹窗 - 仅限 Owner */} + {isOwner && ( + setEditVisible(false)} confirmLoading={actionLoading} width={600}> +
+ + + {llmModels.map(m => )} + + + + + + + 提示:重新总结将基于当前的语音转录全文重新生成纪要,原有的总结内容将被覆盖。 + + + )} + + ); +}; + +export default MeetingDetail; diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx new file mode 100644 index 0000000..f16afab --- /dev/null +++ b/frontend/src/pages/business/Meetings.tsx @@ -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([]); + 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 = { + 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 ( +
+
+ + {/* 固定头部 - 极简白卡 */} + + +
+ +
+ 会议中心 +
+ + + + { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid"> + 全部 + 我发起 + 我参与 + + } + allowClear + onPressEnter={(e) => { setSearchTitle((e.target as any).value); setCurrent(1); }} + style={{ width: 220, borderRadius: 8 }} + /> + + + + + + + {/* 列表区 */} +
+ + { + const config = statusConfig[item.status] || statusConfig[0]; + return ( + + 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%' }} + > + {/* 左侧状态装饰条 */} +
+ +
+ + {/* 右上角醒目图标 */} +
e.stopPropagation()}> + + +
navigate(`/meetings/${item.id}`)}> + +
+
+ deleteMeeting(item.id).then(fetchData)}> + +
+ +
+
+
+
+
+ + {/* 内容排版 */} +
+
+ + {item.status === 1 || item.status === 2 ? : null} + {config.text} + +
+ +
+ + {item.title} + +
+ + +
+ + {dayjs(item.meetingTime).format('YYYY-MM-DD HH:mm')} +
+
+ + {item.participants || '无参与人员'} +
+
+
+ + {/* 底部详情提示 */} +
+
+ {item.tags?.split(',').slice(0, 2).map(t => ( + {t} + ))} +
+ +
+
+
+
+ ); + }} + locale={{ emptyText: }} + /> +
+
+ + {/* 精美底部分页 */} + {total > 0 && ( +
+ { setCurrent(p); setSize(s); }} + showTotal={(total) => 为您找到 {total} 场会议} + size="small" + /> +
+ )} + + + + ); +}; + +export default Meetings; diff --git a/frontend/src/pages/business/PromptTemplates.tsx b/frontend/src/pages/business/PromptTemplates.tsx new file mode 100644 index 0000000..fabaf68 --- /dev/null +++ b/frontend/src/pages/business/PromptTemplates.tsx @@ -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([]); + + const [drawerVisible, setDrawerVisible] = useState(false); + const [editingId, setEditingId] = useState(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: ( +
+ {record.promptContent} +
+ ), + 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 = {}; + 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 ( + showDetail(item)} + style={{ width: 320, borderRadius: 12, border: '1px solid #f0f0f0', position: 'relative', overflow: 'hidden' }} + bodyStyle={{ padding: '24px' }} + > +
+
+ +
+ e.stopPropagation()}> + {canEdit && handleOpenDrawer(item)} />} + handleStatusChange(item.id, checked)} + disabled={!canEdit} + /> + +
+ +
+ {item.templateName} + 使用次数: {item.usageCount || 0} +
+ +
+ {item.tags?.map(tag => { + const dictItem = dictTags.find(dt => dt.itemValue === tag); + return ( + + {dictItem ? dictItem.itemLabel : tag} + + ); + })} +
+ +
+ e.stopPropagation()}> + + handleOpenDrawer(item, true)} /> + + {canEdit && ( + deletePromptTemplate(item.id).then(fetchData)}> + + + + + )} + +
+
+ ); + }; + + return ( +
+
+
+ 提示词模板 + +
+ + +
+ + + + + + + + + + + +
+ + + {Object.keys(groupedData).length === 0 ? ( + + ) : ( + Object.keys(groupedData).map(catKey => { + const catLabel = categories.find(c => c.itemValue === catKey)?.itemLabel || catKey; + return ( +
+ {catLabel} +
+ {groupedData[catKey].map(renderCard)} +
+
+ ); + }) + )} +
+
+ + {editingId ? '编辑模板' : '创建新模板'}} + width="80%" + onClose={() => setDrawerVisible(false)} + open={drawerVisible} + extra={ + + + + + } + destroyOnClose + > +
+ +
+ + + + + + + + 提示词编辑器 (Markdown 实时预览) + + + + setPreviewContent(e.target.value)} + style={{ height: '100%', fontFamily: 'monospace', resize: 'none', border: '1px solid #d9d9d9', borderRadius: 8, padding: 12 }} + placeholder="在此输入 Markdown 指令..." + /> + + + +
{previewContent}
+ + + + + + ); +}; + +export default PromptTemplates; diff --git a/frontend/src/pages/business/SpeakerReg.tsx b/frontend/src/pages/business/SpeakerReg.tsx new file mode 100644 index 0000000..823c61e --- /dev/null +++ b/frontend/src/pages/business/SpeakerReg.tsx @@ -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(null); + const [audioUrl, setAudioUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [existingSpeaker, setExistingSpeaker] = useState(null); + const [listLoading, setListLoading] = useState(false); + + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + + // 获取资源前缀 + 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 ( +
+
+ 我的声纹注册 + 注册后可用于会议发言人自动识别。您可以随时重新录制以更新。 + + +
+ + {existingSpeaker ? ( +
+ } style={{ marginBottom: '8px' }}>已完成注册 +
+ 当前在线声纹: + + (更新于 {new Date(existingSpeaker.createdAt).toLocaleString()}) + +
+
+ ) : ( +
+ 尚未录入 +

请录制一段您的声音(建议朗读 5-10 秒)

+
+ )} + + {existingSpeaker ? '更新声纹' : '开始采集'} + +
+ {!recording ? ( +
+ + {audioUrl && ( +
+ 新录制音频试听 +
+ )} + + +
+
+
+
+ ); +}; + +export default SpeakerReg; diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index fcfa07c..84bad25 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -1,8 +1,8 @@ -import { Navigate, Route, Routes } from "react-router-dom"; +import { Navigate, Route, Routes } from "react-router-dom"; import Login from "../pages/Login"; import ResetPassword from "../pages/ResetPassword"; import AppLayout from "../layouts/AppLayout"; -import { menuRoutes } from "./routes"; +import { menuRoutes, extraRoutes } from "./routes"; import { useAuth } from "../hooks/useAuth"; function RequireAuth({ children }: { children: JSX.Element }) { @@ -33,6 +33,9 @@ export default function AppRoutes() { {menuRoutes.map((route) => ( ))} + {extraRoutes && extraRoutes.map((route) => ( + + ))} } /> diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 8258731..c6186f4 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -15,6 +15,10 @@ import Profile from "../pages/Profile"; import SpeakerReg from "../pages/business/SpeakerReg"; import HotWords from "../pages/business/HotWords"; import PromptTemplates from "../pages/business/PromptTemplates"; +import AiModels from "../pages/business/AiModels"; +import Meetings from "../pages/business/Meetings"; +import MeetingDetail from "../pages/business/MeetingDetail"; +import MeetingCreate from "../pages/business/MeetingCreate"; import type { MenuRoute } from "../types"; @@ -35,5 +39,12 @@ export const menuRoutes: MenuRoute[] = [ { path: "/role-permissions", label: "角色权限绑定", element: , perm: "menu:role-permissions" }, { path: "/speaker-reg", label: "声纹注册", element: , perm: "menu:speaker" }, { path: "/hotwords", label: "热词管理", element: , perm: "menu:hotword" }, - { path: "/prompts", label: "总结模板", element: , perm: "menu:prompt" } + { path: "/prompts", label: "总结模板", element: , perm: "menu:prompt" }, + { path: "/aimodels", label: "模型配置", element: , perm: "menu:aimodel" }, + { path: "/meetings", label: "会议中心", element: , perm: "menu:meeting" }, + { path: "/meeting-create", label: "发起会议", element: , perm: "menu:meeting:create" } +]; + +export const extraRoutes = [ + { path: "/meetings/:id", element: , perm: "menu:meeting" } ];