feat: 重构AI模型控制器和服务,支持ASR和LLM模型类型

- 更新 `AiModelController` 中的保存、更新和删除方法,增加模型类型校验
- 重构 `AiModelServiceImpl`,分离ASR和LLM模型处理逻辑
- 更新数据库表结构,新增 `biz_asr_models` 和 `biz_llm_models` 表
- 添加前端API接口,支持按类型删除AI模型
dev_na
chenhao 2026-03-09 16:10:48 +08:00
parent 86b3616786
commit 364e49b3df
24 changed files with 1370 additions and 663 deletions

View File

@ -1,343 +1,368 @@
# 数据库结构文档PostgreSQL
# 鏁版嵁搴撶粨鏋勬枃妗紙PostgreSQL锛?
本文档根据 `backend/design/db_schema_pgsql.sql` 生成,描述当前核心表结构、字段、约束与索引。
鏈枃妗f牴鎹?`backend/design/db_schema_pgsql.sql` 鐢熸垚锛屾弿杩板綋鍓嶆牳蹇冭〃缁撴瀯銆佸瓧娈点€佺害鏉熶笌绱㈠紩銆?
## 0. 租户与组织
## 0. 绉熸埛涓庣粍缁?
### 0.1 `sys_tenant`(租户表)
| 字段 | 类型 | 约束 | 说明 |
### 0.1 `sys_tenant`锛堢鎴疯〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 租户ID |
| tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 租户编码 |
| tenant_name | VARCHAR(128) | NOT NULL | 租户名称 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
| expire_time | TIMESTAMP(6) | | 过期时间 |
| contact_name | VARCHAR(64) | | 联系人 |
| contact_phone | VARCHAR(32) | | 联系电话 |
| remark | VARCHAR(255) | | 备注 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
| id | BIGSERIAL | PK | 绉熸埛ID |
| tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 绉熸埛缂栫爜 |
| tenant_name | VARCHAR(128) | NOT NULL | 绉熸埛鍚嶇О |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| expire_time | TIMESTAMP(6) | | 杩囨湡鏃堕棿 |
| contact_name | VARCHAR(64) | | 鑱旂郴浜?|
| contact_phone | VARCHAR(32) | | 鑱旂郴鐢佃瘽 |
| remark | VARCHAR(255) | | 澶囨敞 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
索引:
- `uk_tenant_code``UNIQUE (tenant_code) WHERE is_deleted = FALSE`
绱㈠紩锛?
- `uk_tenant_code`锛歚UNIQUE (tenant_code) WHERE is_deleted = FALSE`
### 0.2 `sys_org`(组织架构表)
| 字段 | 类型 | 约束 | 说明 |
### 0.2 `sys_org`锛堢粍缁囨灦鏋勮〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 组织ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| parent_id | BIGINT | | 父级组织ID |
| org_name | VARCHAR(128) | NOT NULL | 组织名称 |
| org_code | VARCHAR(64) | | 组织编码 |
| org_path | VARCHAR(512) | | 组织路径 |
| sort_order | INTEGER | DEFAULT 0 | 排序 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
| id | BIGSERIAL | PK | 缁勭粐ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| parent_id | BIGINT | | 鐖剁骇缁勭粐ID |
| org_name | VARCHAR(128) | NOT NULL | 缁勭粐鍚嶇О |
| org_code | VARCHAR(64) | | 缁勭粐缂栫爜 |
| org_path | VARCHAR(512) | | 缁勭粐璺緞 |
| sort_order | INTEGER | DEFAULT 0 | 鎺掑簭 |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
外键:
- `fk_org_parent``parent_id -> sys_org(id)`
- `fk_org_tenant``tenant_id -> sys_tenant(id)`
澶栭敭锛?
- `fk_org_parent`锛歚parent_id -> sys_org(id)`
- `fk_org_tenant`锛歚tenant_id -> sys_tenant(id)`
索引:
- `idx_org_tenant``(tenant_id)`
绱㈠紩锛?
- `idx_org_tenant`锛歚(tenant_id)`
## 1. 用户与角色
## 1. 鐢ㄦ埛涓庤鑹?
### 1.1 `sys_user`(用户表)
| 字段 | 类型 | 约束 | 说明 |
### 1.1 `sys_user`锛堢敤鎴疯〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| user_id | BIGSERIAL | PK | 用户ID |
| username | VARCHAR(50) | NOT NULL, UNIQUE | 登录名 |
| display_name | VARCHAR(50) | NOT NULL | 显示名 |
| email | VARCHAR(100) | | 邮箱 |
| phone | VARCHAR(30) | UNIQUE | 手机号 |
| password_hash | VARCHAR(255) | NOT NULL | 密码哈希 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
| pwd_reset_required | SMALLINT | DEFAULT 1 | 首次登录是否需改密 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| is_platform_admin | BOOLEAN | DEFAULT false | 是否平台管理员 |
| user_id | BIGSERIAL | PK | 鐢ㄦ埛ID |
| username | VARCHAR(50) | NOT NULL, UNIQUE | 鐧诲綍鍚?|
| display_name | VARCHAR(50) | NOT NULL | 鏄剧ず鍚?|
| email | VARCHAR(100) | | 閭 |
| phone | VARCHAR(30) | UNIQUE | 鎵嬫満鍙?|
| password_hash | VARCHAR(255) | NOT NULL | 瀵嗙爜鍝堝笇 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| pwd_reset_required | SMALLINT | DEFAULT 1 | 棣栨鐧诲綍鏄惁闇€鏀瑰瘑 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
| is_platform_admin | BOOLEAN | DEFAULT false | 鏄惁骞冲彴绠$悊鍛?|
索引:
- `uk_user_username``UNIQUE (username) WHERE is_deleted = FALSE`
绱㈠紩锛?
- `uk_user_username`锛歚UNIQUE (username) WHERE is_deleted = FALSE`
### 1.2 `sys_role`(角色表)
| 字段 | 类型 | 约束 | 说明 |
### 1.2 `sys_role`锛堣鑹茶〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| role_id | BIGSERIAL | PK | 角色ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| role_code | VARCHAR(50) | NOT NULL | 角色编码(租户内唯一) |
| role_name | VARCHAR(50) | NOT NULL | 角色名称 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
| remark | TEXT | | 备注 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| role_id | BIGSERIAL | PK | 瑙掕壊ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| role_code | VARCHAR(50) | NOT NULL | 瑙掕壊缂栫爜锛堢鎴峰唴鍞竴锛?|
| role_name | VARCHAR(50) | NOT NULL | 瑙掕壊鍚嶇О |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| remark | TEXT | | 澶囨敞 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
索引:
- `idx_sys_role_tenant``(tenant_id)`
- `uk_role_code``UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE`
绱㈠紩锛?
- `idx_sys_role_tenant`锛歚(tenant_id)`
- `uk_role_code`锛歚UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE`
### 1.3 `sys_user_role`(用户-角色关联表,租户强约束)
| 字段 | 类型 | 约束 | 说明 |
### 1.3 `sys_user_role`锛堢敤鎴?瑙掕壊鍏宠仈琛紝绉熸埛寮虹害鏉燂級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 关联ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| user_id | BIGINT | NOT NULL | 用户ID |
| role_id | BIGINT | NOT NULL | 角色ID |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| id | BIGSERIAL | PK | 鍏宠仈ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| user_id | BIGINT | NOT NULL | 鐢ㄦ埛ID |
| role_id | BIGINT | NOT NULL | 瑙掕壊ID |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
唯一约束:
鍞竴绾︽潫锛?
- `UNIQUE (tenant_id, user_id, role_id) WHERE is_deleted = 0`
### 1.4 `sys_tenant_user`(租户成员关联表)
| 字段 | 类型 | 约束 | 说明 |
### 1.4 `sys_tenant_user`锛堢鎴锋垚鍛樺叧鑱旇〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 关联ID |
| user_id | BIGINT | NOT NULL | 用户ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| org_id | BIGINT | | 组织ID |
| status | SMALLINT | DEFAULT 1 | 状态 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| id | BIGSERIAL | PK | 鍏宠仈ID |
| user_id | BIGINT | NOT NULL | 鐢ㄦ埛ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| org_id | BIGINT | | 缁勭粐ID |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
索引:
- `uk_tenant_user``UNIQUE (user_id, tenant_id) WHERE is_deleted = 0`
绱㈠紩锛?
- `uk_tenant_user`锛歚UNIQUE (user_id, tenant_id) WHERE is_deleted = 0`
## 2. 权限/字典/参数(全局共享)
## 2. 鏉冮檺/瀛楀吀/鍙傛暟锛堝叏灞€鍏变韩锛?
### 2.1 `sys_permission`(权限表)
| 字段 | 类型 | 约束 | 说明 |
### 2.1 `sys_permission`锛堟潈闄愯〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| perm_id | BIGSERIAL | PK | 权限ID |
| parent_id | BIGINT | | 父级权限ID |
| name | VARCHAR(100) | NOT NULL | 权限名称 |
| code | VARCHAR(100) | NOT NULL, UNIQUE | 权限编码 |
| perm_type | VARCHAR(20) | NOT NULL | 权限类型 |
| level | INTEGER | NOT NULL | 层级 |
| path | VARCHAR(255) | | 路径 |
| component | VARCHAR(255) | | 组件 |
| icon | VARCHAR(100) | | 图标 |
| sort_order | INTEGER | NOT NULL, DEFAULT 0 | 排序 |
| is_visible | SMALLINT | NOT NULL, DEFAULT 1 | 是否可见 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
| description | TEXT | | 描述 |
| meta | JSONB | | 扩展信息 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| perm_id | BIGSERIAL | PK | 鏉冮檺ID |
| parent_id | BIGINT | | 鐖剁骇鏉冮檺ID |
| name | VARCHAR(100) | NOT NULL | 鏉冮檺鍚嶇О |
| code | VARCHAR(100) | NOT NULL, UNIQUE | 鏉冮檺缂栫爜 |
| perm_type | VARCHAR(20) | NOT NULL | 鏉冮檺绫诲瀷 |
| level | INTEGER | NOT NULL | 灞傜骇 |
| path | VARCHAR(255) | | 璺緞 |
| component | VARCHAR(255) | | 缁勪欢 |
| icon | VARCHAR(100) | | 鍥炬爣 |
| sort_order | INTEGER | NOT NULL, DEFAULT 0 | 鎺掑簭 |
| is_visible | SMALLINT | NOT NULL, DEFAULT 1 | 鏄惁鍙 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| description | TEXT | | 鎻忚堪 |
| meta | JSONB | | 鎵╁睍淇℃伅 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
### 2.2 `sys_dict_type`(字典类型表)
| 字段 | 类型 | 约束 | 说明 |
### 2.2 `sys_dict_type`锛堝瓧鍏哥被鍨嬭〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| dict_type_id | BIGSERIAL | PK | 类型ID |
| type_code | VARCHAR(50) | NOT NULL, UNIQUE | 类型编码 |
| type_name | VARCHAR(50) | NOT NULL | 类型名称 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| remark | TEXT | | 备注 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
| dict_type_id | BIGSERIAL | PK | 绫诲瀷ID |
| type_code | VARCHAR(50) | NOT NULL, UNIQUE | 绫诲瀷缂栫爜 |
| type_name | VARCHAR(50) | NOT NULL | 绫诲瀷鍚嶇О |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| remark | TEXT | | 澶囨敞 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
**初始化数据:**
- `sys_common_status`: 通用状态 (启用/禁用)
- `sys_permission_type`: 权限类型 (目录/菜单/按钮)
- `sys_common_visibility`: 可见性 (显示/隐藏)
- `sys_permission_level`: 权限层级 (1, 2, 3)
- `sys_log_type`: 日志类型 (LOGIN/OPERATION)
- `sys_param_type`: 参数类型 (String/Number/Boolean/JSON)
- `sys_log_status`: 操作状态 (成功/失败)
**鍒濆鍖栨暟鎹細**
- `sys_common_status`: 閫氱敤鐘舵€?(鍚敤/绂佺敤)
- `sys_permission_type`: 鏉冮檺绫诲瀷 (鐩綍/鑿滃崟/鎸夐挳)
- `sys_common_visibility`: 鍙鎬?(鏄剧ず/闅愯棌)
- `sys_permission_level`: 鏉冮檺灞傜骇 (1, 2, 3)
- `sys_log_type`: 鏃ュ織绫诲瀷 (LOGIN/OPERATION)
- `sys_param_type`: 鍙傛暟绫诲瀷 (String/Number/Boolean/JSON)
- `sys_log_status`: 鎿嶄綔鐘舵€?(鎴愬姛/澶辫触)
### 2.3 `sys_dict_item`(字典项表)
| 字段 | 类型 | 约束 | 说明 |
### 2.3 `sys_dict_item`锛堝瓧鍏搁」琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| dict_item_id | BIGSERIAL | PK | 字典项ID |
| type_code | VARCHAR(50) | NOT NULL | 字典类型编码 |
| item_label | VARCHAR(100) | NOT NULL | 展示文本 |
| item_value | VARCHAR(100) | NOT NULL | 存储值 |
| sort_order | INT | DEFAULT 0 | 排序 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
| dict_item_id | BIGSERIAL | PK | 瀛楀吀椤笽D |
| type_code | VARCHAR(50) | NOT NULL | 瀛楀吀绫诲瀷缂栫爜 |
| item_label | VARCHAR(100) | NOT NULL | 灞曠ず鏂囨湰 |
| item_value | VARCHAR(100) | NOT NULL | 瀛樺偍鍊?|
| sort_order | INT | DEFAULT 0 | 鎺掑簭 |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
索引:
- `idx_dict_item_type``(type_code)`
- `uk_dict_item_value``UNIQUE (type_code, item_value)`
绱㈠紩锛?
- `idx_dict_item_type`锛歚(type_code)`
- `uk_dict_item_value`锛歚UNIQUE (type_code, item_value)`
### 2.4 `sys_param`(系统参数表)
| 字段 | 类型 | 约束 | 说明 |
### 2.4 `sys_param`锛堢郴缁熷弬鏁拌〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 参数ID |
| param_key | VARCHAR(100) | NOT NULL, UNIQUE | 参数键 |
| param_value | TEXT | NOT NULL | 参数值 |
| param_type | VARCHAR(20) | NOT NULL | 参数类型 |
| is_system | SMALLINT | DEFAULT 0 | 是否系统内置 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| description | TEXT | | 描述 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
| id | BIGSERIAL | PK | 鍙傛暟ID |
| param_key | VARCHAR(100) | NOT NULL, UNIQUE | 鍙傛暟閿?|
| param_value | TEXT | NOT NULL | 鍙傛暟鍊?|
| param_type | VARCHAR(20) | NOT NULL | 鍙傛暟绫诲瀷 |
| is_system | SMALLINT | DEFAULT 0 | 鏄惁绯荤粺鍐呯疆 |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| description | TEXT | | 鎻忚堪 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
## 3. 日志(租户隔离)
## 3. 鏃ュ織锛堢鎴烽殧绂伙級
### 3.1 `sys_log`(系统日志表)
| 字段 | 类型 | 约束 | 说明 |
### 3.1 `sys_log`锛堢郴缁熸棩蹇楄〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 日志ID |
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 租户ID |
| user_id | BIGINT | | 用户ID |
| username | VARCHAR(50) | | 用户名 |
| log_type | VARCHAR(20) | | 日志类型(如 LOGIN、OPERATION |
| operation | VARCHAR(100) | NOT NULL | 操作描述 |
| method | VARCHAR(200) | | 方法 |
| params | TEXT | | 请求参数 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| id | BIGSERIAL | PK | 鏃ュ織ID |
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID |
| user_id | BIGINT | | 鐢ㄦ埛ID |
| username | VARCHAR(50) | | 鐢ㄦ埛鍚?|
| log_type | VARCHAR(20) | | 鏃ュ織绫诲瀷锛堝 LOGIN銆丱PERATION锛?|
| operation | VARCHAR(100) | NOT NULL | 鎿嶄綔鎻忚堪 |
| method | VARCHAR(200) | | 鏂规硶 |
| params | TEXT | | 璇锋眰鍙傛暟 |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| ip | VARCHAR(50) | | IP |
| duration | BIGINT | | 耗时ms |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
| duration | BIGINT | | 鑰楁椂锛坢s锛?|
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
索引:
- `idx_log_tenant_type``(tenant_id, log_type, created_at)`
绱㈠紩锛?
- `idx_log_tenant_type`锛歚(tenant_id, log_type, created_at)`
## 4. 平台配置
## 4. 骞冲彴閰嶇疆
### 4.1 `sys_platform_config`(平台管理表)
| 字段 | 类型 | 约束 | 说明 |
### 4.1 `sys_platform_config`锛堝钩鍙扮鐞嗚〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGINT | PK | 固定为 1 |
| project_name | VARCHAR(128) | NOT NULL | 项目名称 |
| id | BIGINT | PK | 鍥哄畾涓?1 |
| project_name | VARCHAR(128) | NOT NULL | 椤圭洰鍚嶇О |
| logo_url | VARCHAR(512) | | Logo URL |
| icon_url | VARCHAR(512) | | Icon URL |
| login_bg_url | VARCHAR(512) | | 登录页背景 |
| icp_info | VARCHAR(128) | | 备案信息 |
| copyright_info | VARCHAR(255) | | 版权信息 |
| system_description | TEXT | | 系统描述 |
| created_at | TIMESTAMP | NOT NULL | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL | 更新时间 |
| login_bg_url | VARCHAR(512) | | 鐧诲綍椤佃儗鏅?|
| icp_info | VARCHAR(128) | | 澶囨淇℃伅 |
| copyright_info | VARCHAR(255) | | 鐗堟潈淇℃伅 |
| system_description | TEXT | | 绯荤粺鎻忚堪 |
| created_at | TIMESTAMP | NOT NULL | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP | NOT NULL | 鏇存柊鏃堕棿 |
## 5. 业务模块
## 5. 涓氬姟妯″潡
### 5.1 `biz_speakers`(声纹发言人表)
| 字段 | 类型 | 约束 | 说明 |
### 5.1 `biz_speakers`锛堝0绾瑰彂瑷€浜鸿〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| user_id | BIGINT | | 关联系统用户ID |
| name | VARCHAR(100) | NOT NULL | 发言人姓名 |
| voice_path | VARCHAR(512) | | 原始文件路径 |
| voice_ext | VARCHAR(10) | | 文件后缀 |
| voice_size | BIGINT | | 文件大小 |
| status | SMALLINT | DEFAULT 1 | 状态 (1:已保存, 2:注册中, 3:已注册) |
| embedding | VECTOR | | 声纹特征向量 |
| remark | TEXT | | 备注 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| user_id | BIGINT | | 鍏宠仈绯荤粺鐢ㄦ埛ID |
| name | VARCHAR(100) | NOT NULL | 鍙戣█浜哄鍚?|
| voice_path | VARCHAR(512) | | 鍘熷鏂囦欢璺緞 |
| voice_ext | VARCHAR(10) | | 鏂囦欢鍚庣紑 |
| voice_size | BIGINT | | 鏂囦欢澶у皬 |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:宸蹭繚瀛? 2:娉ㄥ唽涓? 3:宸叉敞鍐? |
| embedding | VECTOR | | 澹扮汗鐗瑰緛鍚戦噺 |
| remark | TEXT | | 澶囨敞 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 |
索引:
绱㈠紩锛?
- `idx_speaker_tenant`: `(tenant_id)`
- `idx_speaker_user`: `(user_id) WHERE is_deleted = 0`
### 5.2 `biz_hot_words`(热词管理表)
| 字段 | 类型 | 约束 | 说明 |
### 5.2 `biz_hot_words`锛堢儹璇嶇鐞嗚〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| word | VARCHAR(100) | NOT NULL | 热词原文 |
| pinyin_list | JSONB | | 拼音数组 |
| match_strategy | SMALLINT | DEFAULT 1 | 匹配策略 (1:精确, 2:模糊) |
| category | VARCHAR(50) | | 类别 (人名、术语等) |
| weight | INTEGER | DEFAULT 10 | 权重 (1-100) |
| status | SMALLINT | DEFAULT 1 | 状态 (1:启用, 0:禁用) |
| is_synced | SMALLINT | DEFAULT 0 | 已同步第三方标记 |
| remark | TEXT | | 备注 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| word | VARCHAR(100) | NOT NULL | 鐑瘝鍘熸枃 |
| pinyin_list | JSONB | | 鎷奸煶鏁扮粍 |
| match_strategy | SMALLINT | DEFAULT 1 | 鍖归厤绛栫暐 (1:绮剧‘, 2:妯$硦) |
| category | VARCHAR(50) | | 绫诲埆 (浜哄悕銆佹湳璇瓑) |
| weight | INTEGER | DEFAULT 10 | 鏉冮噸 (1-100) |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:鍚敤, 0:绂佺敤) |
| is_synced | SMALLINT | DEFAULT 0 | 宸插悓姝ョ涓夋柟鏍囪 |
| remark | TEXT | | 澶囨敞 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 |
索引:
绱㈠紩锛?
- `idx_hotword_tenant`: `(tenant_id)`
- `idx_hotword_word`: `(word) WHERE is_deleted = 0`
### 5.3 `biz_prompt_templates`(提示词模板表)
| 字段 | 类型 | 约束 | 说明 |
### 5.3 `biz_prompt_templates`锛堟彁绀鸿瘝妯℃澘琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| 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) | | 备注 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| template_name | VARCHAR(100) | NOT NULL | 妯℃澘鍚嶇О |
| category | VARCHAR(20) | | 鍒嗙被 (瀛楀吀: biz_prompt_category) |
| is_system | SMALLINT | DEFAULT 0 | 鏄惁棰勭疆 (1:鏄? 0:鍚? |
| creator_id | BIGINT | | 鍒涘缓浜篒D |
| tags | JSONB | | 鏍囩鏁扮粍 |
| usage_count | INTEGER | DEFAULT 0 | 浣跨敤娆℃暟 |
| prompt_content | TEXT | NOT NULL | 鎻愮ず璇嶅唴瀹?|
| 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 | DEFAULT 0 | 閫昏緫鍒犻櫎 |
索引:
绱㈠紩锛?
- `idx_prompt_tenant`: `(tenant_id)`
- `idx_prompt_system`: `(is_system) WHERE is_deleted = 0`
### 5.4 `biz_ai_models`AI 模型管理表)
### 5.4 `biz_asr_models`ASR 模型管理表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| 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_name | VARCHAR(100) | NOT NULL | 模型显示名称 |
| provider | VARCHAR(50) | | 提供商 |
| base_url | VARCHAR(255) | | 接口基础地址 |
| api_key | VARCHAR(255) | | API 密钥 |
| model_code | VARCHAR(100) | | 模型代码 |
| ws_url | VARCHAR(255) | | WebSocket 地址 (ASR) |
| temperature | DECIMAL | DEFAULT 0.7 | 随机性 (LLM) |
| media_config | JSONB | | 采样率、协议等 |
| ws_url | VARCHAR(255) | | WebSocket 地址 |
| media_config | JSON/TEXT | | 媒体参数 |
| 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() | 更新时间 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| remark | VARCHAR(255) | | 备注 |
| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
索引:
- `idx_aimodel_tenant`: `(tenant_id)`
- `idx_aimodel_type`: `(model_type, is_default) WHERE is_deleted = 0`
- `idx_asr_model_tenant`: `(tenant_id)`
- `idx_asr_model_default`: `(is_default) WHERE is_deleted = 0`
### 5.5 `biz_meetings`(会议主表)
### 5.5 `biz_llm_models`LLM 模型管理表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| title | VARCHAR(200) | NOT NULL | 会议标题 |
| audio_url | VARCHAR(500) | | 专属音频路径 |
| latest_summary_task_id | BIGINT | | 最新成功的总结任务ID |
| status | SMALLINT | DEFAULT 0 | 0:待处理, 1:识别中, 2:总结中, 3:已完成, 4:失败 |
| model_name | VARCHAR(100) | NOT NULL | 模型显示名称 |
| provider | VARCHAR(50) | | 提供商 |
| base_url | VARCHAR(255) | | 接口基础地址 |
| api_path | VARCHAR(100) | | API 路径 |
| api_key | VARCHAR(255) | | API 密钥 |
| model_code | VARCHAR(100) | | 模型代码 |
| temperature | DECIMAL | DEFAULT 0.7 | 随机性 |
| top_p | DECIMAL | DEFAULT 0.9 | 核采样 |
| is_default | SMALLINT | DEFAULT 0 | 默认模型标记 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| remark | VARCHAR(255) | | 备注 |
| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
### 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) |
索引:
- `idx_llm_model_tenant`: `(tenant_id)`
- `idx_llm_model_default`: `(is_default) WHERE is_deleted = 0`
### 5.7 `biz_ai_tasks`AI 任务流水表)
| 字段 | 类型 | 约束 | 说明 |
### 5.6 `biz_meetings`锛堜細璁富琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| meeting_id | BIGINT | NOT NULL | 关联会议ID |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| title | VARCHAR(200) | NOT NULL | 浼氳鏍囬 |
| audio_url | VARCHAR(500) | | 涓撳睘闊抽璺緞 |
| latest_summary_task_id | BIGINT | | 鏈€鏂版垚鍔熺殑鎬荤粨浠诲姟ID |
| 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`锛圓I 浠诲姟娴佹按琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| meeting_id | BIGINT | NOT NULL | 鍏宠仈浼氳ID |
| task_type | VARCHAR(20) | | ASR / SUMMARY |
| request_data | JSONB | | 请求原始数据 |
| response_data | JSONB | | 响应原始数据 |
| task_config | TEXT | | **[快照]** 任务配置(模型ID、提示词模板等) |
| result_file_path | VARCHAR(500) | | 结果文件相对路径 (如MD总结文件) |
| status | SMALLINT | | 0:排队, 1:处理中, 2:成功, 3:失败 |
| request_data | JSONB | | 璇锋眰鍘熷鏁版嵁 |
| response_data | JSONB | | 鍝嶅簲鍘熷鏁版嵁 |
| task_config | TEXT | | **[蹇収]** 浠诲姟閰嶇疆(妯″瀷ID銆佹彁绀鸿瘝妯℃澘绛? |
| result_file_path | VARCHAR(500) | | 缁撴灉鏂囦欢鐩稿璺緞 (濡侻D鎬荤粨鏂囦欢) |
| status | SMALLINT | | 0:鎺掗槦, 1:澶勭悊涓? 2:鎴愬姛, 3:澶辫触 |

View File

@ -1,4 +1,4 @@
-- PostgreSQL Database Schema for iMeeting (Multi-tenant)
-- PostgreSQL Database Schema for iMeeting (Multi-tenant)
-- 0 为系统预留租户 ID
-- ----------------------------
@ -299,33 +299,52 @@ COMMENT ON TABLE biz_prompt_templates IS '会议总结提示词模板表';
-- ----------------------------
-- 9. 业务模块 - AI 模型管理
-- ----------------------------
DROP TABLE IF EXISTS biz_ai_models CASCADE;
CREATE TABLE biz_ai_models (
DROP TABLE IF EXISTS biz_asr_models CASCADE;
CREATE TABLE biz_asr_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 text, -- 媒体参数 (采样率、声道等)
is_default SMALLINT DEFAULT 0, -- 是否默认
status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用
remark VARCHAR(255), -- 备注
tenant_id BIGINT NOT NULL DEFAULT 0,
model_name VARCHAR(100) NOT NULL,
provider VARCHAR(50),
base_url VARCHAR(255),
api_key VARCHAR(255),
model_code VARCHAR(100),
ws_url VARCHAR(255),
media_config text,
is_default SMALLINT DEFAULT 0,
status SMALLINT DEFAULT 1,
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;
DROP TABLE IF EXISTS biz_llm_models CASCADE;
CREATE TABLE biz_llm_models (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
model_name VARCHAR(100) NOT NULL,
provider VARCHAR(50),
base_url VARCHAR(255),
api_path VARCHAR(100),
api_key VARCHAR(255),
model_code VARCHAR(100),
temperature DECIMAL(3,2) DEFAULT 0.7,
top_p DECIMAL(3,2) DEFAULT 0.9,
is_default SMALLINT DEFAULT 0,
status SMALLINT DEFAULT 1,
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
);
COMMENT ON TABLE biz_ai_models IS 'AI 识别与总结模型配置表';
CREATE INDEX idx_asr_model_tenant ON biz_asr_models (tenant_id);
CREATE INDEX idx_asr_model_default ON biz_asr_models (is_default) WHERE is_deleted = 0;
CREATE INDEX idx_llm_model_tenant ON biz_llm_models (tenant_id);
CREATE INDEX idx_llm_model_default ON biz_llm_models (is_default) WHERE is_deleted = 0;
COMMENT ON TABLE biz_asr_models IS 'ASR 模型配置表';
COMMENT ON TABLE biz_llm_models IS 'LLM 模型配置表';
-- ----------------------------
-- 10. 业务模块 - 会议主表

View File

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

View File

@ -4,7 +4,6 @@ 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;
@ -26,38 +25,50 @@ public class AiModelController {
@PostMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<AiModelVO> save(@RequestBody AiModelDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// Permission Check: only platform admin can save system models (tenantId=0 implicit via dto field or context)
// If normal user tries to set isSystem or similar (handled by tenantId in service)
return ApiResponse.ok(aiModelService.saveModel(dto));
}
@PutMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<AiModelVO> update(@RequestBody AiModelDTO dto) {
if (dto.getId() == null) {
return ApiResponse.error("模型ID不能为空");
}
if (dto.getModelType() == null || dto.getModelType().isBlank()) {
return ApiResponse.error("模型类型不能为空");
}
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())) {
AiModelVO existing = aiModelService.getModelById(dto.getId(), dto.getModelType());
if (existing == null) {
return ApiResponse.error("模型不存在");
}
if (Long.valueOf(0L).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权修改系统级模型");
}
return ApiResponse.ok(aiModelService.updateModel(dto));
}
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
AiModel existing = aiModelService.getById(id);
if (existing == null) return ApiResponse.ok(true);
public ApiResponse<Boolean> delete(@PathVariable Long id, @RequestParam String type) {
if (type == null || type.isBlank()) {
return ApiResponse.error("模型类型不能为空");
}
if (Long.valueOf(0).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
AiModelVO existing = aiModelService.getModelById(id, type);
if (existing == null) {
return ApiResponse.ok(true);
}
if (Long.valueOf(0L).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权删除系统级模型");
}
return ApiResponse.ok(aiModelService.removeById(id));
return ApiResponse.ok(aiModelService.removeModelById(id, type));
}
@GetMapping("/page")
@ -67,7 +78,6 @@ public class AiModelController {
@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()));
}

View File

@ -218,6 +218,28 @@ public class MeetingController {
return ApiResponse.ok(true);
}
@PutMapping("/participants")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> updateParticipants(@RequestBody Map<String, Object> params) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long meetingId = Long.valueOf(params.get("meetingId").toString());
String participants = params.get("participants") != null ? params.get("participants").toString() : "";
Meeting existing = meetingService.getById(meetingId);
if (existing == null) {
return ApiResponse.error("会议不存在");
}
if (!existing.getCreatorId().equals(loginUser.getUserId())
&& !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())
&& !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
return ApiResponse.error("无权修改此会议参会人");
}
meetingService.updateMeetingParticipants(meetingId, participants);
return ApiResponse.ok(true);
}
@PostMapping("/re-summary")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> reSummary(@RequestBody Map<String, Object> params) {

View File

@ -3,6 +3,7 @@ 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 MeetingVO {
@ -16,6 +17,7 @@ public class MeetingVO {
private LocalDateTime meetingTime;
private String participants;
private List<Long> participantIds;
private String tags;
private String audioUrl;
private String summaryContent;

View File

@ -9,36 +9,27 @@ 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 {
@TableName(value = "biz_asr_models", autoResultMap = true)
public class AsrModel extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String modelType;
private String modelName;
private String provider;
private String baseUrl;
private String apiPath;
private String apiKey;
private String modelCode;
private String wsUrl;
private BigDecimal temperature;
private BigDecimal topP;
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> mediaConfig;

View File

@ -0,0 +1,38 @@
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;
import java.math.BigDecimal;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("biz_llm_models")
public class LlmModel extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String modelName;
private String provider;
private String baseUrl;
private String apiPath;
private String apiKey;
private String modelCode;
private BigDecimal temperature;
private BigDecimal topP;
private Integer isDefault;
private String remark;
}

View File

@ -0,0 +1,22 @@
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("biz_prompt_template_user_config")
public class PromptTemplateUserConfig extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long userId;
private Long templateId;
}

View File

@ -0,0 +1,91 @@
package com.imeeting.listener;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.RedisKeys;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.service.biz.AiTaskService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* AI
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class MeetingTaskRecoveryListener implements ApplicationRunner {
private final MeetingMapper meetingMapper;
private final AiTaskService aiTaskService;
private final StringRedisTemplate redisTemplate;
@Override
public void run(ApplicationArguments args) {
log.info("Starting meeting task self-healing check...");
// 1. 查询状态为 1(识别中) 或 2(总结中) 且未删除的会议
List<Meeting> pendingMeetings = meetingMapper.selectList(new LambdaQueryWrapper<Meeting>()
.in(Meeting::getStatus, 1, 2)
.eq(Meeting::getIsDeleted, 0));
if (pendingMeetings.isEmpty()) {
log.info("No pending tasks found. Recovery check completed.");
return;
}
LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
for (Meeting meeting : pendingMeetings) {
try {
// 处理 1 小时以上的死任务
if (meeting.getUpdatedAt() != null && meeting.getUpdatedAt().isBefore(oneHourAgo)) {
log.warn("Meeting {} is stuck for more than 1 hour, marking as failed", meeting.getId());
markAsFailed(meeting, "任务因系统维护超时,请重新发起分析");
continue;
}
// 2. 清理旧的 Redis 锁和进度缓存,确保恢复线程能拿到控制权
redisTemplate.delete(RedisKeys.meetingPollingLockKey(meeting.getId()));
// 3. 根据状态重新派发任务 (平滑拉起)
if (meeting.getStatus() == 1) {
log.info("Resuming ASR task for meeting {}", meeting.getId());
aiTaskService.dispatchTasks(meeting.getId());
} else if (meeting.getStatus() == 2) {
log.info("Resuming Summary task for meeting {}", meeting.getId());
aiTaskService.dispatchSummaryTask(meeting.getId());
}
// 增加小延迟防止惊群效应
TimeUnit.MILLISECONDS.sleep(200);
} catch (Exception e) {
log.error("Failed to recover meeting task {}", meeting.getId(), e);
}
}
log.info("Successfully processed {} pending tasks for recovery.", pendingMeetings.size());
}
private void markAsFailed(Meeting m, String reason) {
Meeting update = new Meeting();
update.setId(m.getId());
update.setStatus(4); // 失败
meetingMapper.updateById(update);
// 同步 Redis 进度为失败
String progressKey = RedisKeys.meetingProgressKey(m.getId());
redisTemplate.opsForValue().set(progressKey,
"{\"percent\":-1, \"message\":\"" + reason + "\", \"updateAt\":" + System.currentTimeMillis() + "}",
1, TimeUnit.HOURS);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@ public interface MeetingService extends IService<Meeting> {
MeetingVO getDetail(Long id);
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label);
void updateMeetingParticipants(Long meetingId, String participants);
void reSummary(Long meetingId, Long summaryModelId, Long promptId);
java.util.Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit);

View File

@ -3,36 +3,49 @@ 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.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.entity.biz.AsrModel;
import com.imeeting.entity.biz.LlmModel;
import com.imeeting.mapper.biz.AsrModelMapper;
import com.imeeting.mapper.biz.LlmModelMapper;
import com.imeeting.service.biz.AiModelService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.Set;
@Service
@Slf4j
@RequiredArgsConstructor
public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> implements AiModelService {
public class AiModelServiceImpl implements AiModelService {
private static final String TYPE_ASR = "ASR";
private static final String TYPE_LLM = "LLM";
private final ObjectMapper objectMapper;
private final AsrModelMapper asrModelMapper;
private final LlmModelMapper llmModelMapper;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
@ -40,158 +53,296 @@ public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> impl
@Override
@Transactional(rollbackFor = Exception.class)
public AiModelVO saveModel(AiModelDTO dto) {
AiModel entity = new AiModel();
copyProperties(dto, entity);
String type = normalizeType(dto.getModelType());
if (TYPE_ASR.equals(type)) {
AsrModel entity = new AsrModel();
copyAsrProperties(dto, entity);
pushAsrConfig(entity);
handleAsrWsUrl(entity);
handleAsrDefaultLogic(entity);
asrModelMapper.insert(entity);
return toAsrVO(entity);
}
pushAsrConfig(entity);
handleAsrWsUrl(entity);
handleDefaultLogic(entity);
this.save(entity);
return toVO(entity);
LlmModel entity = new LlmModel();
copyLlmProperties(dto, entity);
handleLlmDefaultLogic(entity);
llmModelMapper.insert(entity);
return toLlmVO(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");
String type = normalizeType(dto.getModelType());
if (TYPE_ASR.equals(type)) {
AsrModel entity = asrModelMapper.selectById(dto.getId());
if (entity == null) {
throw new RuntimeException("Model not found");
}
copyAsrProperties(dto, entity);
pushAsrConfig(entity);
handleAsrWsUrl(entity);
handleAsrDefaultLogic(entity);
asrModelMapper.updateById(entity);
return toAsrVO(entity);
}
copyProperties(dto, entity);
pushAsrConfig(entity);
handleAsrWsUrl(entity);
handleDefaultLogic(entity);
this.updateById(entity);
return toVO(entity);
LlmModel entity = llmModelMapper.selectById(dto.getId());
if (entity == null) {
throw new RuntimeException("Model not found");
}
copyLlmProperties(dto, entity);
handleLlmDefaultLogic(entity);
llmModelMapper.updateById(entity);
return toLlmVO(entity);
}
@Override
public PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId) {
Page<AiModel> page = this.page(new Page<>(current, size),
new LambdaQueryWrapper<AiModel>()
.and(wrapper -> wrapper.eq(AiModel::getTenantId, tenantId).or().eq(AiModel::getTenantId, 0L))
.eq(type != null && !type.isEmpty(), AiModel::getModelType, type)
.like(name != null && !name.isEmpty(), AiModel::getModelName, name)
.orderByDesc(AiModel::getTenantId)
.orderByDesc(AiModel::getCreatedAt));
String resolvedType = normalizeType(type);
if (TYPE_ASR.equals(resolvedType)) {
Page<AsrModel> page = new Page<>(current, size);
LambdaQueryWrapper<AsrModel> wrapper = new LambdaQueryWrapper<AsrModel>()
.and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
.like(name != null && !name.isBlank(), AsrModel::getModelName, name)
.orderByDesc(AsrModel::getTenantId)
.orderByDesc(AsrModel::getCreatedAt);
Page<AsrModel> resultPage = asrModelMapper.selectPage(page, wrapper);
List<AiModelVO> records = new ArrayList<>();
for (AsrModel entity : resultPage.getRecords()) {
records.add(toAsrVO(entity));
}
PageResult<List<AiModelVO>> result = new PageResult<>();
result.setTotal(resultPage.getTotal());
result.setRecords(records);
return result;
}
List<AiModelVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
Page<LlmModel> page = new Page<>(current, size);
LambdaQueryWrapper<LlmModel> wrapper = new LambdaQueryWrapper<LlmModel>()
.and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L))
.like(name != null && !name.isBlank(), LlmModel::getModelName, name)
.orderByDesc(LlmModel::getTenantId)
.orderByDesc(LlmModel::getCreatedAt);
Page<LlmModel> resultPage = llmModelMapper.selectPage(page, wrapper);
List<AiModelVO> records = new ArrayList<>();
for (LlmModel entity : resultPage.getRecords()) {
records.add(toLlmVO(entity));
}
PageResult<List<AiModelVO>> result = new PageResult<>();
result.setTotal(page.getTotal());
result.setRecords(vos);
result.setTotal(resultPage.getTotal());
result.setRecords(records);
return result;
}
@Override
public List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey) {
try {
// 针对 ASR 模型,优先使用指定的 3050 地址进行探测
String targetUrl;
if (baseUrl != null && baseUrl.contains("3050")) {
targetUrl = "http://10.100.51.199:3050/api/asrconfig";
} else if (baseUrl != null && !baseUrl.isEmpty()) {
// LLM 类型:通用 OpenAI 风格探测
targetUrl = baseUrl.endsWith("/") ? baseUrl + "models" : baseUrl + "/models";
} else {
String providerKey = normalizeProvider(provider);
String resolvedBaseUrl = resolveBaseUrl(providerKey, baseUrl);
if (resolvedBaseUrl == null || resolvedBaseUrl.isBlank()) {
return Collections.emptyList();
}
String targetUrl = resolveModelListUrl(providerKey, resolvedBaseUrl, apiKey);
if (targetUrl == null || targetUrl.isBlank()) {
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))
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(targetUrl))
.timeout(Duration.ofSeconds(10))
.GET();
if (apiKey != null && !apiKey.isEmpty()) {
if ("anthropic".equals(providerKey)) {
if (apiKey != null && !apiKey.isBlank()) {
requestBuilder.header("x-api-key", apiKey);
}
requestBuilder.header("anthropic-version", "2023-06-01");
} else if (!"gemini".equals(providerKey) && apiKey != null && !apiKey.isBlank()) {
requestBuilder.header("Authorization", "Bearer " + apiKey);
}
java.net.http.HttpResponse<String> response = client.send(requestBuilder.build(),
java.net.http.HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
String body = response.body();
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode node = mapper.readTree(body);
List<String> models = new java.util.ArrayList<>();
// 1. 适配特定的 ASR 接口格式: { "data": { "available_models": [...] } }
if (node.has("data") && node.get("data").has("available_models") && node.get("data").get("available_models").isArray()) {
for (com.fasterxml.jackson.databind.JsonNode m : node.get("data").get("available_models")) {
models.add(m.asText());
}
}
// 2. 适配标准 OpenAI 格式: { "data": [ { "id": "..." } ] }
else if (node.has("data") && node.get("data").isArray()) {
for (com.fasterxml.jackson.databind.JsonNode m : node.get("data")) {
models.add(m.has("id") ? m.get("id").asText() : m.asText());
}
}
// 3. 适配简单数组格式: [ "..." ]
else if (node.isArray()) {
for (com.fasterxml.jackson.databind.JsonNode m : node) {
models.add(m.asText());
}
}
// 4. 适配带有 models 字段的格式
else if (node.has("models") && node.get("models").isArray()) {
for (com.fasterxml.jackson.databind.JsonNode m : node.get("models")) {
models.add(m.asText());
}
}
return models;
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
return Collections.emptyList();
}
JsonNode node = objectMapper.readTree(response.body());
Set<String> models = new LinkedHashSet<>();
if (node.has("data") && node.get("data").has("available_models") && node.get("data").get("available_models").isArray()) {
for (JsonNode item : node.get("data").get("available_models")) {
models.add(item.asText());
}
return new ArrayList<>(models);
}
if (node.has("data") && node.get("data").isArray()) {
for (JsonNode item : node.get("data")) {
if (item.has("id")) {
models.add(item.get("id").asText());
} else if (item.has("name")) {
models.add(sanitizeModelName(item.get("name").asText()));
} else {
models.add(item.asText());
}
}
return new ArrayList<>(models);
}
if (node.isArray()) {
for (JsonNode item : node) {
models.add(item.asText());
}
return new ArrayList<>(models);
}
if (node.has("models") && node.get("models").isArray()) {
for (JsonNode item : node.get("models")) {
if (item.has("name")) {
models.add(sanitizeModelName(item.get("name").asText()));
} else if (item.has("id")) {
models.add(item.get("id").asText());
} else {
models.add(item.asText());
}
}
}
return new ArrayList<>(models);
} catch (Exception e) {
log.error("Fetch remote models error: {}", e.getMessage());
log.error("Fetch remote models error: {}", e.getMessage(), e);
return Collections.emptyList();
}
return Collections.emptyList();
}
private String resolveBaseUrl(String providerKey, String baseUrl) {
if (baseUrl != null && !baseUrl.isBlank()) {
return baseUrl;
}
return switch (providerKey) {
case "openai" -> "https://api.openai.com/v1";
case "deepseek" -> "https://api.deepseek.com";
case "aliyun", "qwen", "dashscope" -> "https://dashscope.aliyuncs.com/compatible-mode/v1";
case "moonshot", "kimi" -> "https://api.moonshot.cn/v1";
case "groq" -> "https://api.groq.com/openai/v1";
case "anthropic" -> "https://api.anthropic.com/v1";
case "gemini", "google" -> "https://generativelanguage.googleapis.com/v1beta";
default -> "";
};
}
private String resolveModelListUrl(String providerKey, String baseUrl, String apiKey) {
if (baseUrl.contains("3050")) {
return "http://10.100.51.199:3050/api/asrconfig";
}
if ("gemini".equals(providerKey) || "google".equals(providerKey)) {
if (apiKey == null || apiKey.isBlank()) {
return "";
}
String key = URLEncoder.encode(apiKey, StandardCharsets.UTF_8);
return appendPath(baseUrl, "models") + "?key=" + key;
}
return appendPath(baseUrl, "models");
}
private String appendPath(String baseUrl, String path) {
return baseUrl.endsWith("/") ? baseUrl + path : baseUrl + "/" + path;
}
private String sanitizeModelName(String rawName) {
if (rawName == null) {
return "";
}
if (rawName.startsWith("models/")) {
return rawName.substring("models/".length());
}
return rawName;
}
private String normalizeProvider(String provider) {
if (provider == null) {
return "";
}
return provider.trim().toLowerCase();
}
@Override
public AiModelVO getDefaultModel(String type, Long tenantId) {
AiModel model = this.getOne(new LambdaQueryWrapper<AiModel>()
.eq(AiModel::getModelType, type)
.eq(AiModel::getIsDefault, 1)
.and(w -> w.eq(AiModel::getTenantId, tenantId).or().eq(AiModel::getTenantId, 0L))
.orderByDesc(AiModel::getTenantId) // 租户优先
String resolvedType = normalizeType(type);
if (TYPE_ASR.equals(resolvedType)) {
AsrModel model = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
.eq(AsrModel::getIsDefault, 1)
.and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
.orderByDesc(AsrModel::getTenantId)
.last("LIMIT 1"));
return model == null ? null : toAsrVO(model);
}
LlmModel model = llmModelMapper.selectOne(new LambdaQueryWrapper<LlmModel>()
.eq(LlmModel::getIsDefault, 1)
.and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L))
.orderByDesc(LlmModel::getTenantId)
.last("LIMIT 1"));
return model != null ? toVO(model) : null;
return model == null ? null : toLlmVO(model);
}
private void handleDefaultLogic(AiModel entity) {
if (Integer.valueOf(1).equals(entity.getIsDefault())) {
// Unset other defaults for the same tenant and type
this.update(new LambdaUpdateWrapper<AiModel>()
.set(AiModel::getIsDefault, 0)
.eq(AiModel::getTenantId, entity.getTenantId())
.eq(AiModel::getModelType, entity.getModelType())
.eq(AiModel::getIsDefault, 1));
@Override
public AiModelVO getModelById(Long id, String type) {
String resolvedType = normalizeType(type);
if (TYPE_ASR.equals(resolvedType)) {
AsrModel entity = asrModelMapper.selectById(id);
return entity == null ? null : toAsrVO(entity);
}
LlmModel entity = llmModelMapper.selectById(id);
return entity == null ? null : toLlmVO(entity);
}
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);
}
@Override
public boolean removeModelById(Long id, String type) {
String resolvedType = normalizeType(type);
if (TYPE_ASR.equals(resolvedType)) {
return asrModelMapper.deleteById(id) > 0;
}
return llmModelMapper.deleteById(id) > 0;
}
private void pushAsrConfig(AiModel entity) {
if (!"ASR".equals(entity.getModelType())) {
private void handleAsrDefaultLogic(AsrModel entity) {
if (!Integer.valueOf(1).equals(entity.getIsDefault())) {
return;
}
if (entity.getBaseUrl() == null || entity.getBaseUrl().trim().isEmpty()) {
asrModelMapper.update(null, new LambdaUpdateWrapper<AsrModel>()
.set(AsrModel::getIsDefault, 0)
.eq(AsrModel::getTenantId, entity.getTenantId())
.eq(AsrModel::getIsDefault, 1));
}
private void handleLlmDefaultLogic(LlmModel entity) {
if (!Integer.valueOf(1).equals(entity.getIsDefault())) {
return;
}
llmModelMapper.update(null, new LambdaUpdateWrapper<LlmModel>()
.set(LlmModel::getIsDefault, 0)
.eq(LlmModel::getTenantId, entity.getTenantId())
.eq(LlmModel::getIsDefault, 1));
}
private void handleAsrWsUrl(AsrModel entity) {
if (entity.getWsUrl() != null && !entity.getWsUrl().isBlank()) {
return;
}
if (entity.getBaseUrl() == null || entity.getBaseUrl().isBlank()) {
return;
}
String ws = entity.getBaseUrl().replace("http://", "ws://").replace("https://", "wss://");
entity.setWsUrl(ws);
}
private void pushAsrConfig(AsrModel entity) {
if (entity.getBaseUrl() == null || entity.getBaseUrl().isBlank()) {
throw new RuntimeException("baseUrl is required for ASR model");
}
if (entity.getModelCode() == null || entity.getModelCode().trim().isEmpty()) {
if (entity.getModelCode() == null || entity.getModelCode().isBlank()) {
throw new RuntimeException("modelCode is required for ASR model");
}
@ -212,49 +363,51 @@ public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> impl
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
log.error("Push ASR config failed, url={}, code={}, body={}", targetUrl, response.statusCode(), response.body());
throw new RuntimeException("Third-party ASR config save failed: HTTP " + response.statusCode());
}
} catch (Exception e) {
log.error("Push ASR config error, url={}, msg={}", targetUrl, e.getMessage(), e);
throw new RuntimeException("Third-party ASR config save failed: " + e.getMessage(), e);
}
}
private void copyProperties(AiModelDTO dto, AiModel entity) {
entity.setModelType(dto.getModelType());
private void copyAsrProperties(AiModelDTO dto, AsrModel entity) {
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) {
private void copyLlmProperties(AiModelDTO dto, LlmModel entity) {
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.setTemperature(dto.getTemperature() == null ? BigDecimal.valueOf(0.7) : dto.getTemperature());
entity.setTopP(dto.getTopP() == null ? BigDecimal.valueOf(0.9) : dto.getTopP());
entity.setIsDefault(dto.getIsDefault());
entity.setStatus(dto.getStatus());
entity.setRemark(dto.getRemark());
}
private AiModelVO toAsrVO(AsrModel entity) {
AiModelVO vo = new AiModelVO();
vo.setId(entity.getId());
vo.setTenantId(entity.getTenantId());
vo.setModelType(entity.getModelType());
vo.setModelType(TYPE_ASR);
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.setApiKey(entity.getApiKey());
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());
@ -262,4 +415,35 @@ public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> impl
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
private AiModelVO toLlmVO(LlmModel entity) {
AiModelVO vo = new AiModelVO();
vo.setId(entity.getId());
vo.setTenantId(entity.getTenantId());
vo.setModelType(TYPE_LLM);
vo.setModelName(entity.getModelName());
vo.setProvider(entity.getProvider());
vo.setBaseUrl(entity.getBaseUrl());
vo.setApiPath(entity.getApiPath());
vo.setApiKey(entity.getApiKey());
vo.setModelCode(entity.getModelCode());
vo.setTemperature(entity.getTemperature());
vo.setTopP(entity.getTopP());
vo.setIsDefault(entity.getIsDefault());
vo.setStatus(entity.getStatus());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
private String normalizeType(String type) {
if (type == null || type.isBlank()) {
return TYPE_ASR;
}
String normalized = type.trim().toUpperCase();
if (!TYPE_ASR.equals(normalized) && !TYPE_LLM.equals(normalized)) {
throw new RuntimeException("Unsupported model type: " + type);
}
return normalized;
}
}

View File

@ -5,8 +5,8 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.SysUser;
import com.imeeting.entity.biz.AiModel;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.Meeting;
@ -158,7 +158,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.updateById(taskRecord);
Long asrModelId = Long.valueOf(taskRecord.getTaskConfig().get("asrModelId").toString());
AiModel asrModel = aiModelService.getById(asrModelId);
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
if (asrModel == null) throw new RuntimeException("ASR模型配置不存在");
String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition";
@ -298,7 +298,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.updateById(taskRecord);
Long summaryModelId = Long.valueOf(taskRecord.getTaskConfig().get("summaryModelId").toString());
AiModel llmModel = aiModelService.getById(summaryModelId);
AiModelVO llmModel = aiModelService.getModelById(summaryModelId, "LLM");
if (llmModel == null) return;
String promptContent = taskRecord.getTaskConfig().get("promptContent") != null ?

View File

@ -9,12 +9,12 @@ 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.AiTask;
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.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.mapper.SysUserMapper;
@ -53,6 +53,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
private final AiModelService aiModelService;
private final PromptTemplateService promptTemplateService;
private final AiTaskService aiTaskService;
private final AiTaskMapper aiTaskMapper;
private final MeetingTranscriptMapper transcriptMapper;
private final HotWordService hotWordService;
private final SysUserMapper sysUserMapper;
@ -144,7 +145,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
aiTaskService.save(sumTask);
eventPublisher.publishEvent(new MeetingCreatedEvent(meeting.getId()));
return toVO(meeting);
return toVO(meeting, false);
}
@Override
@ -175,7 +176,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
wrapper.orderByDesc(Meeting::getCreatedAt);
Page<Meeting> page = this.page(new Page<>(current, size), wrapper);
List<MeetingVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
List<MeetingVO> vos = page.getRecords().stream().map(m -> toVO(m, false)).collect(Collectors.toList());
PageResult<List<MeetingVO>> result = new PageResult<>();
result.setTotal(page.getTotal());
@ -192,7 +193,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
@Override
public MeetingVO getDetail(Long id) {
Meeting meeting = this.getById(id);
return meeting != null ? toVO(meeting) : null;
return meeting != null ? toVO(meeting, true) : null;
}
@Override
@ -223,6 +224,14 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
.set(label != null, MeetingTranscript::getSpeakerLabel, label));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateMeetingParticipants(Long meetingId, String participants) {
this.update(new LambdaUpdateWrapper<Meeting>()
.eq(Meeting::getId, meetingId)
.set(Meeting::getParticipants, participants == null ? "" : participants));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void reSummary(Long meetingId, Long summaryModelId, Long promptId) {
@ -255,7 +264,10 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
java.util.Map<String, Object> stats = new java.util.HashMap<>();
LambdaQueryWrapper<Meeting> baseWrapper = new LambdaQueryWrapper<Meeting>().eq(Meeting::getTenantId, tenantId);
if (!isAdmin) {
baseWrapper.and(w -> w.eq(Meeting::getCreatorId, userId).or().like(Meeting::getParticipants, String.valueOf(userId)));
String userIdStr = String.valueOf(userId);
baseWrapper.and(w -> w.eq(Meeting::getCreatorId, userId)
.or()
.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr));
}
stats.put("totalMeetings", this.count(baseWrapper.clone()));
@ -273,13 +285,16 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
public List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit) {
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>().eq(Meeting::getTenantId, tenantId);
if (!isAdmin) {
wrapper.and(w -> w.eq(Meeting::getCreatorId, userId).or().like(Meeting::getParticipants, String.valueOf(userId)));
String userIdStr = String.valueOf(userId);
wrapper.and(w -> w.eq(Meeting::getCreatorId, userId)
.or()
.apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr));
}
wrapper.orderByDesc(Meeting::getCreatedAt).last("LIMIT " + limit);
return this.list(wrapper).stream().map(this::toVO).collect(Collectors.toList());
return this.list(wrapper).stream().map(m -> toVO(m, false)).collect(Collectors.toList());
}
private MeetingVO toVO(Meeting meeting) {
private MeetingVO toVO(Meeting meeting, boolean includeSummary) {
MeetingVO vo = new MeetingVO();
vo.setId(meeting.getId());
vo.setTenantId(meeting.getTenantId());
@ -296,15 +311,73 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
try {
List<Long> userIds = Arrays.stream(meeting.getParticipants().split(","))
.map(String::trim).filter(s -> !s.isEmpty()).map(Long::valueOf).collect(Collectors.toList());
vo.setParticipantIds(userIds);
if (!userIds.isEmpty()) {
List<SysUser> users = sysUserMapper.selectBatchIds(userIds);
String names = users.stream().map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()).collect(Collectors.joining(", "));
vo.setParticipants(names);
}
} catch (Exception e) {
vo.setParticipantIds(Collections.emptyList());
vo.setParticipants(meeting.getParticipants());
}
} else {
vo.setParticipantIds(Collections.emptyList());
}
if (includeSummary) {
vo.setSummaryContent(loadSummaryContent(meeting));
}
return vo;
}
private String loadSummaryContent(Meeting meeting) {
try {
AiTask summaryTask = null;
if (meeting.getLatestSummaryTaskId() != null) {
summaryTask = aiTaskMapper.selectById(meeting.getLatestSummaryTaskId());
}
if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) {
summaryTask = aiTaskMapper.selectOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meeting.getId())
.eq(AiTask::getTaskType, "SUMMARY")
.eq(AiTask::getStatus, 2)
.isNotNull(AiTask::getResultFilePath)
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) {
return null;
}
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
Path summaryPath = Paths.get(basePath, summaryTask.getResultFilePath().replace("\\", "/"));
if (!Files.exists(summaryPath)) {
return null;
}
String content = Files.readString(summaryPath, StandardCharsets.UTF_8);
return stripFrontMatter(content);
} catch (Exception e) {
log.warn("Load summary content failed for meeting {}", meeting.getId(), e);
return null;
}
}
private String stripFrontMatter(String markdown) {
if (markdown == null || markdown.isBlank()) {
return markdown;
}
if (!markdown.startsWith("---")) {
return markdown;
}
int second = markdown.indexOf("\n---", 3);
if (second < 0) {
return markdown;
}
int contentStart = second + 4;
if (contentStart < markdown.length() && markdown.charAt(contentStart) == '\n') {
contentStart++;
}
return markdown.substring(contentStart).trim();
}
}

View File

@ -70,6 +70,13 @@ export const deleteAiModel = (id: number) => {
);
};
export const deleteAiModelByType = (id: number, type: 'ASR' | 'LLM') => {
return http.delete<any, { code: string; data: boolean; msg: string }>(
`/api/biz/aimodel/${id}`,
{ params: { type } }
);
};
export const getRemoteModelList = (params: { provider: string; baseUrl: string; apiKey?: string }) => {
return http.get<any, { code: string; data: string[]; msg: string }>(
"/api/biz/aimodel/remote-list",

View File

@ -9,6 +9,7 @@ export interface MeetingVO {
title: string;
meetingTime: string;
participants: string;
participantIds?: number[];
tags: string;
audioUrl: string;
summaryContent: string;
@ -95,6 +96,13 @@ export const updateMeeting = (data: Partial<MeetingVO>) => {
);
};
export const updateMeetingParticipants = (params: { meetingId: number; participants: string }) => {
return http.put<any, { code: string; data: boolean; msg: string }>(
"/api/biz/meeting/participants",
params
);
};
export const uploadAudio = (file: File) => {
const formData = new FormData();
formData.append("file", file);

View File

@ -59,6 +59,40 @@ export default function AppLayout() {
const { logout } = useAuth();
const { load: loadPermissions, can } = usePermission();
const buildPermissionTree = (list: SysPermission[]) => {
const map = new Map<number, SysPermission & { children?: SysPermission[] }>();
const roots: (SysPermission & { children?: SysPermission[] })[] = [];
list.forEach((m) => map.set(m.permId, { ...m, children: [] }));
map.forEach((node) => {
if (node.parentId && map.has(node.parentId)) {
map.get(node.parentId)!.children!.push(node);
} else {
roots.push(node);
}
});
const sortNodes = (nodes: (SysPermission & { children?: SysPermission[] })[]) => {
nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
nodes.forEach((n) => n.children && sortNodes(n.children));
};
sortNodes(roots);
return roots;
};
const findFirstMenuPath = (nodes: (SysPermission & { children?: SysPermission[] })[]): string | null => {
for (const node of nodes) {
if (node.permType === "menu" && node.path) {
return node.path;
}
if (node.children && node.children.length > 0) {
const firstChildPath = findFirstMenuPath(node.children as any);
if (firstChildPath) {
return firstChildPath;
}
}
}
return null;
};
const fetchInitialData = async () => {
try {
// Load tenants from localStorage
@ -91,10 +125,10 @@ export default function AppLayout() {
// 如果当前是根路径,自动跳转到第一个有权限的菜单
if (location.pathname === '/' && filtered.length > 0) {
// 查找第一个类型为 'menu' 且有路径的项
const firstMenu = filtered.find(m => m.permType === 'menu' && m.path);
if (firstMenu && firstMenu.path !== '/') {
navigate(firstMenu.path, { replace: true });
const menuTree = buildPermissionTree(filtered);
const firstPath = findFirstMenuPath(menuTree);
if (firstPath && firstPath !== '/') {
navigate(firstPath, { replace: true });
}
}
} catch (e) {
@ -138,24 +172,7 @@ export default function AppLayout() {
message.success(lng === 'zh-CN' ? '已切换至中文' : 'Switched to English');
}, [i18n]);
const buildMenuTree = useCallback((list: SysPermission[]) => {
const map = new Map<number, SysPermission & { children?: SysPermission[] }>();
const roots: (SysPermission & { children?: SysPermission[] })[] = [];
list.forEach((m) => map.set(m.permId, { ...m, children: [] }));
map.forEach((node) => {
if (node.parentId && map.has(node.parentId)) {
map.get(node.parentId)!.children!.push(node);
} else {
roots.push(node);
}
});
const sortNodes = (nodes: (SysPermission & { children?: SysPermission[] })[]) => {
nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
nodes.forEach((n) => n.children && sortNodes(n.children));
};
sortNodes(roots);
return roots;
}, []);
const buildMenuTree = useCallback((list: SysPermission[]) => buildPermissionTree(list), []);
const menuItems = useMemo(() => {
const toMenuItems = (nodes: (SysPermission & { children?: SysPermission[] })[]): any[] => {

View File

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

View File

@ -189,7 +189,7 @@ const MeetingCreate: React.FC = () => {
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children">
{userList.map(u => (
<Option key={u.userId} value={u.displayName || u.username}>
<Option key={u.userId} value={u.userId}>
<Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space>
</Option>
))}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Card, Button, Input, Space, Tag, message, Popconfirm, Typography, Row, Col, List, Badge, Empty, Skeleton, Tooltip, Radio, Pagination, Progress, Drawer, Form, DatePicker, Upload, Avatar, Divider, Switch, Select } from 'antd';
import { Card, Button, Input, Space, Tag, message, Popconfirm, Typography, Row, Col, List, Badge, Empty, Skeleton, Tooltip, Radio, Pagination, Progress, Drawer, Form, DatePicker, Upload, Avatar, Divider, Switch, Select, Modal } from 'antd';
import {
PlusOutlined, DeleteOutlined, SearchOutlined, CheckCircleOutlined,
LoadingOutlined, UserOutlined, CalendarOutlined, PlayCircleOutlined,
@ -9,7 +9,7 @@ import {
AudioOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio } from '../../api/business/meeting';
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants } 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';
@ -151,7 +151,7 @@ const MeetingCreateForm: React.FC<{
</Row>
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children" size="large">
{userList.map(u => (<Option key={u.userId} value={u.displayName || u.username}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
</Select>
</Form.Item>
</Card>
@ -254,7 +254,7 @@ const MeetingCreateForm: React.FC<{
};
// --- 卡片项组件 ---
const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any }> = ({ item, config, fetchData, t }) => {
const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants }) => {
const navigate = useNavigate();
// 注入自动刷新回调
const progress = useMeetingProgress(item, () => fetchData());
@ -267,7 +267,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
<Space size={8}>
<Tooltip title="编辑"><div className="icon-btn edit" onClick={() => navigate(`/meetings/${item.id}`)}><EditOutlined /></div></Tooltip>
<Tooltip title="编辑参会人"><div className="icon-btn edit" onClick={() => onEditParticipants(item)}><EditOutlined /></div></Tooltip>
<Popconfirm
title="确定删除?"
onConfirm={() => deleteMeeting(item.id).then(fetchData)}
@ -354,6 +354,11 @@ const Meetings: React.FC = () => {
const [audioUrl, setAudioUrl] = useState('');
const [uploadProgress, setUploadProgress] = useState(0);
const [fileList, setFileList] = useState<any[]>([]);
const [userList, setUserList] = useState<SysUser[]>([]);
const [participantsEditVisible, setParticipantsEditVisible] = useState(false);
const [editingMeeting, setEditingMeeting] = useState<MeetingVO | null>(null);
const [participantsEditLoading, setParticipantsEditLoading] = useState(false);
const [participantsEditForm] = Form.useForm();
const hasRunningTasks = data.some(item => item.status === 0 || item.status === 1 || item.status === 2);
useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]);
@ -362,6 +367,9 @@ const Meetings: React.FC = () => {
const timer = setInterval(() => fetchData(true), 5000);
return () => clearInterval(timer);
}, [hasRunningTasks, current, size, searchTitle, viewType]);
useEffect(() => {
listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([]));
}, []);
const fetchData = async (silent = false) => {
if (!silent) setLoading(true);
@ -394,6 +402,34 @@ const Meetings: React.FC = () => {
}
};
const openEditParticipants = (meeting: MeetingVO) => {
setEditingMeeting(meeting);
participantsEditForm.setFieldsValue({
participantIds: meeting.participantIds || []
});
setParticipantsEditVisible(true);
};
const handleUpdateParticipants = async () => {
if (!editingMeeting) {
return;
}
const values = await participantsEditForm.validateFields();
const participantIds: number[] = values.participantIds || [];
setParticipantsEditLoading(true);
try {
await updateMeetingParticipants({
meetingId: editingMeeting.id,
participants: participantIds.join(",")
});
message.success("参会人已更新");
setParticipantsEditVisible(false);
fetchData();
} finally {
setParticipantsEditLoading(false);
}
};
const statusConfig: Record<number, { text: string; color: string; bgColor: string }> = {
0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' },
1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' },
@ -430,7 +466,7 @@ const Meetings: React.FC = () => {
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
<List grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} dataSource={data} renderItem={(item) => {
const config = statusConfig[item.status] || statusConfig[0];
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} />;
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} />;
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
</Skeleton>
</div>
@ -471,6 +507,33 @@ const Meetings: React.FC = () => {
/>
</Drawer>
<Modal
title="编辑参会人"
open={participantsEditVisible}
onCancel={() => setParticipantsEditVisible(false)}
onOk={handleUpdateParticipants}
confirmLoading={participantsEditLoading}
destroyOnClose
>
<Form form={participantsEditForm} layout="vertical">
<Form.Item
name="participantIds"
label="参会人员"
>
<Select mode="multiple" placeholder="请选择参会人" showSearch optionFilterProp="children">
{userList.map(u => (
<Option key={u.userId} value={u.userId}>
<Space>
<Avatar size="small" icon={<UserOutlined />} />
{u.displayName || u.username}
</Space>
</Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
<style>{`
.meeting-card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0,0,0,0.08) !important; }
.status-bar-active { animation: statusBreathing 2s infinite ease-in-out; }