From 4e38580258c602a2d57650a7ee63e2cfa2331c17 Mon Sep 17 00:00:00 2001 From: chenhao Date: Thu, 23 Apr 2026 15:47:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E7=8A=B6=E6=80=81=E5=A4=84=E7=90=86=E5=92=8C?= =?UTF-8?q?=E8=BD=AC=E5=BD=95=E5=86=85=E5=AE=B9=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `AiTaskServiceImpl` 中添加 `buildTranscriptText` 和 `failPendingSummaryTask` 方法,用于构建转录文本和处理失败的摘要任务 - 更新 `doDispatchSummaryTask` 和 `dispatchTasks` 方法,以在转录内容为空时处理失败情况 - 在前端 `Meetings.tsx` 中添加实时会议状态处理逻辑,支持实时会议的暂停、进行中和待开始状态 - 更新测试类 `AiTaskServiceImplTest` 以包含新的测试用例,验证转录内容为空时的任务处理逻辑 --- backend/design/db_schema.md | 385 ++++++++---------- backend/design/db_schema_pgsql.sql | 6 + .../legacy/LegacyLlmModelItemResponse.java | 4 + .../java/com/imeeting/dto/biz/AiModelDTO.java | 20 + .../java/com/imeeting/dto/biz/AiModelVO.java | 2 + .../com/imeeting/entity/biz/AsrModel.java | 3 + .../com/imeeting/entity/biz/LlmModel.java | 3 + .../service/biz/impl/AiModelServiceImpl.java | 41 +- .../biz/impl/PromptTemplateServiceImpl.java | 2 +- backend/src/main/resources/application.yml | 2 + .../biz/impl/AiModelServiceImplTest.java | 91 ++++- ...MeetingRuntimeProfileResolverImplTest.java | 59 +++ frontend/src/api/business/aimodel.ts | 2 + frontend/src/api/business/hotwordGroup.ts | 61 +++ frontend/src/locales/en-US.json | 24 +- frontend/src/locales/zh-CN.json | 31 +- frontend/src/pages/access/users/index.less | 4 +- frontend/src/pages/access/users/index.tsx | 15 +- .../pages/bindings/role-permission/index.tsx | 4 +- frontend/src/pages/business/AiModels.tsx | 33 +- frontend/src/pages/business/HotWords.tsx | 318 ++++++++------- .../src/pages/organization/tenants/index.tsx | 18 +- 22 files changed, 731 insertions(+), 397 deletions(-) create mode 100644 frontend/src/api/business/hotwordGroup.ts diff --git a/backend/design/db_schema.md b/backend/design/db_schema.md index 410c236..152b624 100644 --- a/backend/design/db_schema.md +++ b/backend/design/db_schema.md @@ -1,11 +1,7 @@ -# 鏁版嵁搴撶粨鏋勬枃妗o紙PostgreSQL锛? - +# 鏁版嵁搴撶粨鏋勬枃妗o紙PostgreSQL锛? 鏈枃妗f牴鎹?`backend/design/db_schema_pgsql.sql` 鐢熸垚锛屾弿杩板綋鍓嶆牳蹇冭〃缁撴瀯銆佸瓧娈点€佺害鏉熶笌绱㈠紩銆? - ## 0. 绉熸埛涓庣粍缁? - -### 0.1 `sys_tenant`锛堢鎴疯〃锛? -| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +### 0.1 `sys_tenant`锛堢鎴疯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | | id | BIGSERIAL | PK | 绉熸埛ID | | tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 绉熸埛缂栫爜 | @@ -19,11 +15,9 @@ | 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 | @@ -37,17 +31,13 @@ | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 | | is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 | -澶栭敭锛? -- `fk_org_parent`锛歚parent_id -> sys_org(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 `sys_user`锛堢敤鎴疯〃锛? -| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +### 1.1 `sys_user`锛堢敤鎴疯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | | user_id | BIGSERIAL | PK | 鐢ㄦ埛ID | | username | VARCHAR(50) | NOT NULL, UNIQUE | 鐧诲綍鍚?| @@ -62,11 +52,9 @@ | 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 | @@ -78,8 +66,7 @@ | created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | -绱㈠紩锛? -- `idx_sys_role_tenant`锛歚(tenant_id)` +绱㈠紩锛?- `idx_sys_role_tenant`锛歚(tenant_id)` - `uk_role_code`锛歚UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE` ### 1.3 `sys_user_role`锛堢敤鎴?瑙掕壊鍏宠仈琛紝绉熸埛寮虹害鏉燂級 @@ -93,11 +80,9 @@ | 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` +鍞竴绾︽潫锛?- `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 | @@ -108,13 +93,10 @@ | 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.1 `sys_permission`锛堟潈闄愯〃锛? -| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +### 2.1 `sys_permission`锛堟潈闄愯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | | perm_id | BIGSERIAL | PK | 鏉冮檺ID | | parent_id | BIGINT | | 鐖剁骇鏉冮檺ID | @@ -134,8 +116,7 @@ | 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 | 绫诲瀷缂栫爜 | @@ -168,12 +149,10 @@ | updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 | | is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 | -绱㈠紩锛? -- `idx_dict_item_type`锛歚(type_code)` +绱㈠紩锛?- `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 | 鍙傛暟閿?| @@ -186,8 +165,7 @@ ## 3. 鏃ュ織锛堢鎴烽殧绂伙級 -### 3.1 `sys_log`锛堢郴缁熸棩蹇楄〃锛? -| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +### 3.1 `sys_log`锛堢郴缁熸棩蹇楄〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | | id | BIGSERIAL | PK | 鏃ュ織ID | | tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID | @@ -202,13 +180,11 @@ | 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.1 `sys_platform_config`锛堝钩鍙扮鐞嗚〃锛? -| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +### 4.1 `sys_platform_config`锛堝钩鍙扮鐞嗚〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | | id | BIGINT | PK | 鍥哄畾涓?1 | | project_name | VARCHAR(128) | NOT NULL | 椤圭洰鍚嶇О | @@ -223,225 +199,192 @@ ## 5. 涓氬姟妯″潡 -### 5.1 `biz_speakers`锛堝0绾瑰彂瑷€浜鸿〃锛? +### 5.1 `biz_speakers`锛堝0绾瑰彂瑷€浜鸿〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +| --- | --- | --- | --- | +| id | BIGSERIAL | PK | 涓婚敭ID | +| tenant_id | BIGINT | NOT NULL | 绉熸埛ID | +| creator_id | BIGINT | NOT NULL | 鍒涘缓浜篒D锛岀敤浜庡0绾瑰簱褰掑睘 | +| user_id | BIGINT | | 鍏宠仈绯荤粺鐢ㄦ埛ID | +| external_speaker_id | VARCHAR(100) | | 绗笁鏂瑰0绾瑰簱涓殑浜哄憳ID | +| name | VARCHAR(100) | NOT NULL | 鍙戣█浜哄鍚?| +| voice_path | VARCHAR(512) | | 鍘熷澹扮汗鏂囦欢璺緞 | +| voice_ext | VARCHAR(10) | | 鏂囦欢鍚庣紑 | +| voice_size | BIGINT | | 鏂囦欢澶у皬 | +| status | SMALLINT | DEFAULT 1 | 鐘舵€侊紙1=宸蹭繚瀛橈紝2=娉ㄥ唽涓紝3=宸叉敞鍐岋紝4=澶辫触锛?| +| embedding | VECTOR(512) | | 澹扮汗鐗瑰緛鍚戦噺 | +| remark | TEXT | | 澶囨敞 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | +| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎 | + +绱㈠紩锛?- `idx_speaker_tenant`锛歚(tenant_id) WHERE is_deleted = 0` +- `idx_speaker_creator`锛歚(creator_id) WHERE is_deleted = 0` +- `idx_speaker_user`锛歚(user_id) WHERE is_deleted = 0` +- `idx_speaker_external`锛歚(external_speaker_id) WHERE is_deleted = 0` +- `uk_speaker_tenant_name`锛歚UNIQUE (tenant_id, name) WHERE is_deleted = 0` + +### 5.2 `biz_hot_word_groups`锛堢儹璇嶇粍琛級 | 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | | 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 | | 澶囨敞 | +| group_name | VARCHAR(100) | NOT NULL | 鐑瘝缁勫悕绉?| +| creator_id | BIGINT | | 鍒涘缓浜篒D | +| status | SMALLINT | DEFAULT 1 | 鐘舵€侊紙1:鍚敤锛?:绂佺敤锛?| +| remark | VARCHAR(255) | | 澶囨敞 | | created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | -| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 | +| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎 | -绱㈠紩锛? -- `idx_speaker_tenant`: `(tenant_id)` -- `idx_speaker_user`: `(user_id) WHERE is_deleted = 0` +绱㈠紩锛?- `idx_hot_word_group_tenant`锛歚(tenant_id) WHERE is_deleted = 0` +- `uk_hot_word_group_name_scope`锛歚UNIQUE (tenant_id, group_name) WHERE is_deleted = 0` -### 5.2 `biz_hot_words`锛堢儹璇嶇鐞嗚〃锛? -| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +### 5.3 `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) | | 绫诲埆 (浜哄悕銆佹湳璇瓑) | -| hot_word_group_id | BIGINT | | 所属热词组 ID | -| weight | INTEGER | DEFAULT 10 | 鏉冮噸 (1-100) | -| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:鍚敤, 0:绂佺敤) | -| is_synced | SMALLINT | DEFAULT 0 | 宸插悓姝ョ涓夋柟鏍囪 | +| is_public | SMALLINT | DEFAULT 0 | 鏄惁绉熸埛鍏紑锛?:鍏紑锛?:涓汉绉佹湁锛?| +| creator_id | BIGINT | | 鍒涘缓鑰匢D | +| pinyin_list | TEXT | | 鎷奸煶鏁扮粍 | +| match_strategy | SMALLINT | DEFAULT 1 | 鍖归厤绛栫暐锛?:绮剧‘鍖归厤锛?:鎷奸煶妯$硦鍖归厤锛?| +| category | VARCHAR(50) | | 绫诲埆锛堜汉鍚嶃€佹湳璇€佸湴鍚嶏級 | +| hot_word_group_id | BIGINT | | 鎵€灞炵儹璇嶇粍ID | +| weight | INTEGER | DEFAULT 10 | 鏉冮噸锛?-100锛?| +| status | SMALLINT | DEFAULT 1 | 鐘舵€侊紙1:鍚敤锛?:绂佺敤锛?| +| is_synced | SMALLINT | DEFAULT 0 | 鏄惁宸插悓姝ョ涓夋柟寮曟搸锛?:鏈悓姝ワ紝1:宸插悓姝ワ級 | | remark | TEXT | | 澶囨敞 | | created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | -| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 | +| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎 | -绱㈠紩锛? -- `idx_hotword_tenant`: `(tenant_id)` -- `idx_hotword_word`: `(word) WHERE is_deleted = 0` -- `idx_hotword_group`: `(hot_word_group_id) WHERE is_deleted = 0` - -### 5.3 `biz_hot_word_groups`(热词组表) -| 字段 | 类型 | 约束 | 说明 | -| --- | --- | --- | --- | -| id | BIGSERIAL | PK | 主键ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| group_name | VARCHAR(100) | NOT NULL | 热词组名称 | -| creator_id | BIGINT | | 创建人ID | -| 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_hot_word_group_tenant`: `(tenant_id) WHERE is_deleted = 0` -- `uk_hot_word_group_name_scope`: `(tenant_id, group_name) WHERE is_deleted = 0` +绱㈠紩锛?- `idx_hotword_tenant`锛歚(tenant_id)` +- `idx_hotword_word`锛歚(word) WHERE is_deleted = 0` +- `idx_hotword_group`锛歚(hot_word_group_id) WHERE is_deleted = 0` ### 5.4 `biz_prompt_templates`锛堟彁绀鸿瘝妯℃澘琛級 | 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | | id | BIGSERIAL | PK | 涓婚敭ID | -| tenant_id | BIGINT | NOT NULL | 绉熸埛ID | +| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID锛? 涓虹郴缁熺骇锛?| | template_name | VARCHAR(100) | NOT NULL | 妯℃澘鍚嶇О | -| category | VARCHAR(20) | | 鍒嗙被 (瀛楀吀: biz_prompt_category) | -| is_system | SMALLINT | DEFAULT 0 | 鏄惁棰勭疆 (1:鏄? 0:鍚? | +| description | VARCHAR(255) | | 妯℃澘鎻忚堪 | +| category | VARCHAR(20) | | 鍒嗙被锛堝瓧鍏革細`biz_prompt_category`锛?| +| is_system | SMALLINT | DEFAULT 0 | 鏄惁绯荤粺棰勭疆锛?:鏄紝0:鍚︼級 | | creator_id | BIGINT | | 鍒涘缓浜篒D | -| tags | JSONB | | 鏍囩鏁扮粍 | -| hot_word_group_id | BIGINT | | 绑定热词组 ID | +| tags | TEXT | | 鏍囩鏁扮粍 | +| hot_word_group_id | BIGINT | | 缁戝畾鐑瘝缁処D | | usage_count | INTEGER | DEFAULT 0 | 浣跨敤娆℃暟 | | prompt_content | TEXT | NOT NULL | 鎻愮ず璇嶅唴瀹?| -| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:鍚敤, 0:绂佺敤) | +| status | SMALLINT | DEFAULT 1 | 鐘舵€侊紙1:鍚敤锛?:绂佺敤锛?| | remark | VARCHAR(255) | | 澶囨敞 | | created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | -| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 | +| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎 | -绱㈠紩锛? -- `idx_prompt_tenant`: `(tenant_id)` -- `idx_prompt_system`: `(is_system) WHERE is_deleted = 0` -- `idx_prompt_group`: `(hot_word_group_id) WHERE is_deleted = 0` +绱㈠紩锛?- `idx_prompt_tenant`锛歚(tenant_id)` +- `idx_prompt_system`锛歚(is_system) WHERE is_deleted = 0` +- `idx_prompt_group`锛歚(hot_word_group_id) WHERE is_deleted = 0` -### 5.5 `biz_asr_models`(ASR 模型管理表) -| 字段 | 类型 | 约束 | 说明 | -| --- | --- | --- | --- | -| id | BIGSERIAL | PK | 主键ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| 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 地址 | -| media_config | JSON/TEXT | | 媒体参数 | -| 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 | 逻辑删除 | - -索引: -- `idx_asr_model_tenant`: `(tenant_id)` -- `idx_asr_model_default`: `(is_default) WHERE is_deleted = 0` - -### 5.5 `biz_llm_models`(LLM 模型管理表) -| 字段 | 类型 | 约束 | 说明 | -| --- | --- | --- | --- | -| id | BIGSERIAL | PK | 主键ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| 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 | 逻辑删除 | - -索引: -- `idx_llm_model_tenant`: `(tenant_id)` -- `idx_llm_model_default`: `(is_default) WHERE is_deleted = 0` - -### 5.6 `biz_meetings`锛堜細璁富琛級 +### 5.5 `biz_asr_models`锛圓SR 妯″瀷閰嶇疆琛級 | 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | | id | BIGSERIAL | PK | 涓婚敭ID | -| tenant_id | BIGINT | NOT NULL | 绉熸埛ID | +| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID | +| model_name | VARCHAR(100) | NOT NULL | 妯″瀷鏄剧ず鍚嶇О | +| provider | VARCHAR(50) | | 鎻愪緵鍟?| +| base_url | VARCHAR(255) | | 鎺ュ彛鍩虹鍦板潃 | +| api_key | VARCHAR(255) | | API 瀵嗛挜 | +| model_code | VARCHAR(100) | | 妯″瀷浠g爜 | +| ws_url | VARCHAR(255) | | WebSocket 鍦板潃 | +| media_config | TEXT | | 濯掍綋鍙傛暟 | +| is_default | SMALLINT | DEFAULT 0 | 榛樿妯″瀷鏍囪 | +| sort_order | INTEGER | NOT NULL, 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 | 閫昏緫鍒犻櫎 | + +绱㈠紩锛?- `idx_asr_model_tenant`锛歚(tenant_id)` +- `idx_asr_model_default`锛歚(is_default) WHERE is_deleted = 0` +- `idx_asr_model_sort_order`锛歚(tenant_id, is_default, sort_order) WHERE is_deleted = 0` +- `uk_asr_model_default_enabled_tenant`锛歚(tenant_id) WHERE is_deleted = 0 AND status = 1 AND is_default = 1` + +### 5.6 `biz_llm_models`锛圠LM 妯″瀷閰嶇疆琛級 +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +| --- | --- | --- | --- | +| id | BIGSERIAL | PK | 涓婚敭ID | +| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID | +| 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) | | 妯″瀷浠g爜 | +| temperature | DECIMAL(3,2) | DEFAULT 0.7 | 娓╁害鍙傛暟 | +| top_p | DECIMAL(3,2) | DEFAULT 0.9 | Top P 鍙傛暟 | +| is_default | SMALLINT | DEFAULT 0 | 榛樿妯″瀷鏍囪 | +| sort_order | INTEGER | NOT NULL, 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 | 閫昏緫鍒犻櫎 | + +绱㈠紩锛?- `idx_llm_model_tenant`锛歚(tenant_id)` +- `idx_llm_model_default`锛歚(is_default) WHERE is_deleted = 0` +- `idx_llm_model_sort_order`锛歚(tenant_id, is_default, sort_order) WHERE is_deleted = 0` +- `uk_llm_model_default_enabled_tenant`锛歚(tenant_id) WHERE is_deleted = 0 AND status = 1 AND is_default = 1` + +### 5.7 `biz_meetings`锛堜細璁富琛級 +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +| --- | --- | --- | --- | +| id | BIGSERIAL | PK | 涓婚敭ID | +| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛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:澶辫触 | +| meeting_time | TIMESTAMP(6) | | 浼氳鏃堕棿 | +| participants | TEXT | | 鍙備細浜轰俊鎭?| +| tags | VARCHAR(255) | | 鏍囩 | +| audio_url | VARCHAR(500) | | 涓撳睘闊抽璺緞 | +| creator_id | BIGINT | | 鍙戣捣浜篒D | +| creator_name | VARCHAR(100) | | 鍙戣捣浜哄鍚?| +| latest_summary_task_id | BIGINT | | 鏈€鏂版垚鍔熸€荤粨浠诲姟ID | +| status | SMALLINT | DEFAULT 0 | 鐘舵€侊紙0:寰呭鐞嗭紝1:澶勭悊涓紝2:鎴愬姛锛?:澶辫触锛?| +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | +| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎 | -### 5.6 `biz_meeting_transcripts`锛堣浆褰曟槑缁嗚〃锛? -| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +绱㈠紩锛?- `idx_meeting_tenant`锛歚(tenant_id)` + +### 5.8 `biz_meeting_transcripts`锛堣浆褰曟槑缁嗚〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | | id | BIGSERIAL | PK | 涓婚敭ID | | meeting_id | BIGINT | NOT NULL | 鍏宠仈浼氳ID | +| speaker_id | VARCHAR(50) | | ASR 杩斿洖鐨勫彂瑷€浜烘爣璇?| +| speaker_name | VARCHAR(100) | | 淇敼鍚庣殑鍙戣█浜哄鍚?| | speaker_label | VARCHAR(50) | | 鍙戣█浜烘爣绛?| -| content | TEXT | | 杞綍鏂囧瓧 | -| start_time | INTEGER | | 寮€濮嬫椂闂?(ms) | +| content | TEXT | | 杞綍鍐呭 | +| start_time | INTEGER | | 寮€濮嬫椂闂达紙ms锛?| +| end_time | INTEGER | | 缁撴潫鏃堕棿锛坢s锛?| +| sort_order | INTEGER | | 鎺掑簭 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | -### 5.7 `biz_ai_tasks`锛圓I 浠诲姟娴佹按琛級 +绱㈠紩锛?- `idx_transcript_meeting`锛歚(meeting_id)` + +### 5.9 `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) | | 缁撴灉鏂囦欢鐩稿璺緞 (濡侻D鎬荤粨鏂囦欢) | -| status | SMALLINT | | 0:鎺掗槦, 1:澶勭悊涓? 2:鎴愬姛, 3:澶辫触 | - -## 6. 屏保模块 - -### 6.1 `biz_screen_savers`(屏保素材表) -| 字段 | 类型 | 约束 | 说明 | -| --- | --- | --- | --- | -| id | BIGSERIAL | PK | 屏保ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| scope_type | VARCHAR(32) | NOT NULL, DEFAULT 'PLATFORM' | 作用域:PLATFORM / USER | -| owner_user_id | BIGINT | | 当作用域为 USER 时的归属用户 | -| name | VARCHAR(128) | NOT NULL | 屏保名称 | -| image_url | VARCHAR(512) | NOT NULL | 图片地址 | -| description | VARCHAR(255) | | 屏保描述 | -| display_duration_sec | INTEGER | NOT NULL, DEFAULT 15 | 旧版素材级展示时长,兼容期保留 | -| image_width | INTEGER | | 图片宽度 | -| image_height | INTEGER | | 图片高度 | -| image_format | VARCHAR(16) | | 图片格式 | -| sort_order | INTEGER | NOT NULL, DEFAULT 0 | 排序值 | -| created_by | BIGINT | | 创建人ID | -| status | SMALLINT | NOT NULL, DEFAULT 1 | 启用状态 | -| remark | VARCHAR(255) | | 备注 | -| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 | -| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 | - -索引: -- `idx_screen_savers_status_sort`: `(status, sort_order)` -- `idx_screen_savers_scope_owner_status_sort`: `(scope_type, owner_user_id, status, sort_order)` - -### 6.2 `biz_screen_saver_user_config`(屏保用户状态覆盖表) -| 字段 | 类型 | 约束 | 说明 | -| --- | --- | --- | --- | -| id | BIGSERIAL | PK | 配置ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| user_id | BIGINT | NOT NULL | 用户ID | -| screen_saver_id | BIGINT | NOT NULL | 屏保素材ID | -| status | SMALLINT | NOT NULL, DEFAULT 1 | 用户覆盖启停状态 | -| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 | -| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 | - -索引: -- `uk_screen_saver_user_cfg_user_item`: `UNIQUE (tenant_id, user_id, screen_saver_id) WHERE is_deleted = 0` -- `idx_screen_saver_user_cfg_item`: `(screen_saver_id) WHERE is_deleted = 0` - -### 6.3 `biz_screen_saver_user_settings`(屏保用户播放设置表) -| 字段 | 类型 | 约束 | 说明 | -| --- | --- | --- | --- | -| id | BIGSERIAL | PK | 设置ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| user_id | BIGINT | NOT NULL | 用户ID | -| display_duration_sec | INTEGER | NOT NULL, DEFAULT 15 | 当前用户统一屏保展示时长(秒) | -| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 | -| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 | - -索引: -- `uk_screen_saver_user_settings_user`: `UNIQUE (tenant_id, user_id) WHERE is_deleted = 0` - +| task_type | VARCHAR(20) | | 浠诲姟绫诲瀷锛圓SR / SUMMARY锛?| +| status | SMALLINT | DEFAULT 0 | 鐘舵€侊紙0:鎺掗槦锛?:鎵ц涓紝2:鎴愬姛锛?:澶辫触锛?| +| request_data | TEXT | | 璇锋眰涓夋柟鍘熷 JSON | +| response_data | TEXT | | 涓夋柟杩斿洖鍘熷 JSON | +| task_config | TEXT | | 浠诲姟閰嶇疆鍙傛暟蹇収 | +| result_file_path | VARCHAR(500) | | 缁撴灉鏂囦欢璺緞 | +| error_msg | TEXT | | 閿欒鍫嗘爤 | +| started_at | TIMESTAMP(6) | | 寮€濮嬫椂闂?| +| completed_at | TIMESTAMP(6) | | 瀹屾垚鏃堕棿 | +绱㈠紩锛?- `idx_aitask_meeting`锛歚(meeting_id)` diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index db4181b..baa5655 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -350,6 +350,7 @@ CREATE TABLE biz_asr_models ( ws_url VARCHAR(255), media_config text, is_default SMALLINT DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, status SMALLINT DEFAULT 1, remark VARCHAR(255), created_at TIMESTAMP(6) NOT NULL DEFAULT now(), @@ -370,6 +371,7 @@ CREATE TABLE biz_llm_models ( temperature DECIMAL(3,2) DEFAULT 0.7, top_p DECIMAL(3,2) DEFAULT 0.9, is_default SMALLINT DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, status SMALLINT DEFAULT 1, remark VARCHAR(255), created_at TIMESTAMP(6) NOT NULL DEFAULT now(), @@ -379,8 +381,12 @@ CREATE TABLE biz_llm_models ( 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_asr_model_sort_order ON biz_asr_models (tenant_id, is_default, sort_order) WHERE is_deleted = 0; +CREATE UNIQUE INDEX uk_asr_model_default_enabled_tenant ON biz_asr_models (tenant_id) WHERE is_deleted = 0 AND status = 1 AND is_default = 1; 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; +CREATE INDEX idx_llm_model_sort_order ON biz_llm_models (tenant_id, is_default, sort_order) WHERE is_deleted = 0; +CREATE UNIQUE INDEX uk_llm_model_default_enabled_tenant ON biz_llm_models (tenant_id) WHERE is_deleted = 0 AND status = 1 AND is_default = 1; COMMENT ON TABLE biz_asr_models IS 'ASR 模型配置表'; COMMENT ON TABLE biz_llm_models IS 'LLM 模型配置表'; diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLlmModelItemResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLlmModelItemResponse.java index 06d6cdd..c656caa 100644 --- a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLlmModelItemResponse.java +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyLlmModelItemResponse.java @@ -17,12 +17,16 @@ public class LegacyLlmModelItemResponse { @JsonProperty("is_default") private Integer isDefault; + @JsonProperty("sort_order") + private Integer sortOrder; + public static LegacyLlmModelItemResponse from(AiModelVO source, boolean defaultItem) { LegacyLlmModelItemResponse response = new LegacyLlmModelItemResponse(); response.setModelCode(source.getModelCode()); response.setModelName(source.getModelName()); response.setProvider(source.getProvider()); response.setIsDefault(defaultItem ? 1 : 0); + response.setSortOrder(source.getSortOrder()); return response; } } diff --git a/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java b/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java index 69a6e8f..e64fd7e 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/AiModelDTO.java @@ -1,25 +1,45 @@ package com.imeeting.dto.biz; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.math.BigDecimal; import java.util.Map; +@Schema(description = "AI 模型配置请求") @Data public class AiModelDTO { + @Schema(description = "模型 ID") private Long id; + @Schema(description = "模型类型") private String modelType; + @Schema(description = "模型显示名称") private String modelName; + @Schema(description = "提供商") private String provider; + @Schema(description = "基础地址") private String baseUrl; + @Schema(description = "接口路径") private String apiPath; + @Schema(description = "接口密钥") private String apiKey; + @Schema(description = "连通性测试消息") private String testMessage; + @Schema(description = "模型编码") private String modelCode; + @Schema(description = "WebSocket 地址") private String wsUrl; + @Schema(description = "温度参数") private BigDecimal temperature; + @Schema(description = "TopP 参数") private BigDecimal topP; + @Schema(description = "媒体配置") private Map mediaConfig; + @Schema(description = "是否默认") private Integer isDefault; + @Schema(description = "状态") private Integer status; + @Schema(description = "排序值,越小越靠前") + private Integer sortOrder; + @Schema(description = "备注") private String remark; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java b/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java index aa7a553..a1ede25 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java @@ -39,6 +39,8 @@ public class AiModelVO { private Integer isDefault; @Schema(description = "启用状态") private Integer status; + @Schema(description = "排序值,越小越靠前") + private Integer sortOrder; @Schema(description = "备注") private String remark; @Schema(description = "创建时间") diff --git a/backend/src/main/java/com/imeeting/entity/biz/AsrModel.java b/backend/src/main/java/com/imeeting/entity/biz/AsrModel.java index 2b532e9..c83ad2f 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/AsrModel.java +++ b/backend/src/main/java/com/imeeting/entity/biz/AsrModel.java @@ -46,6 +46,9 @@ public class AsrModel extends BaseEntity { @Schema(description = "是否默认模型") private Integer isDefault; + @Schema(description = "排序值,越小越靠前") + private Integer sortOrder; + @Schema(description = "备注") private String remark; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java b/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java index 2d99048..4dacfdc 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java +++ b/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java @@ -46,6 +46,9 @@ public class LlmModel extends BaseEntity { @Schema(description = "是否默认模型") private Integer isDefault; + @Schema(description = "排序值,越小越靠前") + private Integer sortOrder; + @Schema(description = "备注") private String remark; } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java index 366e635..6073382 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java @@ -44,6 +44,7 @@ public class AiModelServiceImpl implements AiModelService { private static final String TYPE_ASR = "ASR"; private static final String TYPE_LLM = "LLM"; + private static final int DEFAULT_SORT_ORDER = 0; private static final String DEFAULT_LLM_API_PATH = "/v1/chat/completions"; private static final String DEFAULT_ANTHROPIC_API_PATH = "/messages"; private static final String CONNECTIVITY_TEST_SYSTEM_PROMPT = """ @@ -126,6 +127,8 @@ public class AiModelServiceImpl implements AiModelService { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L)) .like(name != null && !name.isBlank(), AsrModel::getModelName, name) + .orderByDesc(AsrModel::getIsDefault) + .orderByAsc(AsrModel::getSortOrder) .orderByDesc(AsrModel::getTenantId) .orderByDesc(AsrModel::getCreatedAt); Page resultPage = asrModelMapper.selectPage(page, wrapper); @@ -143,6 +146,8 @@ public class AiModelServiceImpl implements AiModelService { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L)) .like(name != null && !name.isBlank(), LlmModel::getModelName, name) + .orderByDesc(LlmModel::getIsDefault) + .orderByAsc(LlmModel::getSortOrder) .orderByDesc(LlmModel::getTenantId) .orderByDesc(LlmModel::getCreatedAt); Page resultPage = llmModelMapper.selectPage(page, wrapper); @@ -438,10 +443,10 @@ public class AiModelServiceImpl implements AiModelService { HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() < 200 || response.statusCode() >= 300) { - throw new RuntimeException("闂佸搫鐗滈崜娆忥耿閺夋嚦鐔煎灳瀹曞洠鍋撻悜鑺ョ厐鐎广儱娲ㄩ弸鍌毲庨崶銊х畵闁宦板妽瀵板嫭娼忛銉? HTTP " + response.statusCode()); + throw new RuntimeException("本地模型配置保存失败: HTTP " + response.statusCode()); } } catch (Exception e) { - throw new RuntimeException("闂佸搫鐗滈崜娆忥耿閺夋嚦鐔煎灳瀹曞洠鍋撻悜鑺ョ厐鐎广儱娲ㄩ弸鍌毲庨崶銊х畵闁宦板妽瀵板嫭娼忛銉? " + e.getMessage(), e); + throw new RuntimeException("本地模型配置保存失败: " + e.getMessage(), e); } } @@ -564,6 +569,21 @@ public class AiModelServiceImpl implements AiModelService { return chatCompletionContent; } + String chatCompletionReasoning = root.path("choices").path(0).path("message").path("reasoning").asText(null); + if (chatCompletionReasoning != null && !chatCompletionReasoning.isBlank()) { + return chatCompletionReasoning; + } + + JsonNode reasoningContentNode = root.path("choices").path(0).path("message").path("reasoning_content"); + if (reasoningContentNode.isArray()) { + for (JsonNode reasoningItem : reasoningContentNode) { + String text = reasoningItem.path("text").asText(null); + if (text != null && !text.isBlank()) { + return text; + } + } + } + String textCompletionContent = root.path("choices").path(0).path("text").asText(null); if (textCompletionContent != null && !textCompletionContent.isBlank()) { return textCompletionContent; @@ -720,6 +740,9 @@ public class AiModelServiceImpl implements AiModelService { if (dto == null) { throw new RuntimeException("模型配置不能为空"); } + if (Integer.valueOf(1).equals(dto.getIsDefault()) && !Integer.valueOf(1).equals(dto.getStatus())) { + throw new RuntimeException("默认模型必须为启用状态"); + } if ("custom".equals(normalizeProvider(dto.getProvider()))) { if (TYPE_ASR.equals(normalizeType(dto.getModelType()))) { Map mediaConfig = dto.getMediaConfig() == null ? Collections.emptyMap() : dto.getMediaConfig(); @@ -738,17 +761,23 @@ public class AiModelServiceImpl implements AiModelService { String resolvedType = normalizeType(type); if (TYPE_ASR.equals(resolvedType)) { AsrModel model = asrModelMapper.selectOne(new LambdaQueryWrapper() + .eq(AsrModel::getStatus, 1) .eq(AsrModel::getIsDefault, 1) .and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L)) .orderByDesc(AsrModel::getTenantId) + .orderByAsc(AsrModel::getSortOrder) + .orderByDesc(AsrModel::getCreatedAt) .last("LIMIT 1")); return model == null ? null : toAsrVO(model); } LlmModel model = llmModelMapper.selectOne(new LambdaQueryWrapper() + .eq(LlmModel::getStatus, 1) .eq(LlmModel::getIsDefault, 1) .and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L)) .orderByDesc(LlmModel::getTenantId) + .orderByAsc(LlmModel::getSortOrder) + .orderByDesc(LlmModel::getCreatedAt) .last("LIMIT 1")); return model == null ? null : toLlmVO(model); } @@ -855,6 +884,7 @@ public class AiModelServiceImpl implements AiModelService { entity.setMediaConfig(dto.getMediaConfig()); entity.setIsDefault(dto.getIsDefault()); entity.setStatus(dto.getStatus()); + entity.setSortOrder(normalizeSortOrder(dto.getSortOrder())); entity.setRemark(dto.getRemark()); } @@ -914,6 +944,7 @@ public class AiModelServiceImpl implements AiModelService { entity.setTopP(dto.getTopP() == null ? BigDecimal.valueOf(0.9) : dto.getTopP()); entity.setIsDefault(dto.getIsDefault()); entity.setStatus(dto.getStatus()); + entity.setSortOrder(normalizeSortOrder(dto.getSortOrder())); entity.setRemark(dto.getRemark()); } @@ -931,6 +962,7 @@ public class AiModelServiceImpl implements AiModelService { vo.setMediaConfig(entity.getMediaConfig()); vo.setIsDefault(entity.getIsDefault()); vo.setStatus(entity.getStatus()); + vo.setSortOrder(entity.getSortOrder()); vo.setRemark(entity.getRemark()); vo.setCreatedAt(entity.getCreatedAt()); return vo; @@ -951,11 +983,16 @@ public class AiModelServiceImpl implements AiModelService { vo.setTopP(entity.getTopP()); vo.setIsDefault(entity.getIsDefault()); vo.setStatus(entity.getStatus()); + vo.setSortOrder(entity.getSortOrder()); vo.setRemark(entity.getRemark()); vo.setCreatedAt(entity.getCreatedAt()); return vo; } + private Integer normalizeSortOrder(Integer sortOrder) { + return sortOrder == null ? DEFAULT_SORT_ORDER : sortOrder; + } + private String normalizeType(String type) { if (type == null || type.isBlank()) { return TYPE_ASR; diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java index 2f45851..80b1a7a 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/PromptTemplateServiceImpl.java @@ -96,7 +96,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl hotWordGroupMap = queryHotWordGroupMap(List.of(template.getHotWordGroupId())); + Map hotWordGroupMap = queryHotWordGroupMap(java.util.Collections.singletonList(template.getHotWordGroupId())); PromptTemplateVO vo = toVO(template, template.getStatus(), hotWordGroupMap); vo.setHotWords(resolveHotWords(template.getHotWordGroupId())); return vo; diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 16d1fa5..ababc0d 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -61,6 +61,8 @@ unisbase: - /api/auth/** - /api/static/** - /api/public/meetings/** + - /api/android/auth/login + - /api/android/auth/refresh - /api/android/screensavers/active - /api/screensavers/active - /v3/api-docs/** diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java index bcc0044..48429c9 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java @@ -25,6 +25,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -67,7 +68,7 @@ class AiModelServiceImplTest { dto.setApiPath("/v1/chat/completions"); dto.setApiKey("test-key"); dto.setModelCode("gpt-test"); - dto.setTestMessage("璇峰洖澶嶏細杩炴帴姝e父"); + dto.setTestMessage("请回复:连接正常"); service.testLlmConnectivity(dto); @@ -84,7 +85,7 @@ class AiModelServiceImplTest { .orElse("") .trim() ); - assertEquals("璇峰洖澶嶏細杩炴帴姝e父", requestJson.path("messages").path(1).path("content").asText()); + assertEquals("请回复:连接正常", requestJson.path("messages").path(1).path("content").asText()); } @Test @@ -137,6 +138,41 @@ class AiModelServiceImplTest { service.testLlmConnectivity(dto); } + @Test + void testLlmConnectivityShouldAcceptReasoningWhenContentMissing() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/v1/chat/completions", exchange -> { + captureRequest(exchange, new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>()); + writeJson(exchange, 200, """ + { + "choices": [ + { + "message": { + "content": null, + "reasoning": "The model produced reasoning text before emitting the final answer." + }, + "finish_reason": "length" + } + ] + } + """); + }); + server.start(); + + AiModelServiceImpl service = new AiModelServiceImpl( + objectMapper, + mock(AsrModelMapper.class), + mock(LlmModelMapper.class) + ); + + AiModelDTO dto = new AiModelDTO(); + dto.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort()); + dto.setApiPath("/v1/chat/completions"); + dto.setModelCode("gpt-test"); + + service.testLlmConnectivity(dto); + } + @Test void testLlmConnectivityShouldUseHttp11ClientForCompatibility() throws Exception { AiModelServiceImpl service = new AiModelServiceImpl( @@ -181,6 +217,57 @@ class AiModelServiceImplTest { assertNull(captor.getValue().getApiKey()); } + @Test + void saveModelShouldPersistSortOrderForLlm() { + AsrModelMapper asrModelMapper = mock(AsrModelMapper.class); + LlmModelMapper llmModelMapper = mock(LlmModelMapper.class); + when(llmModelMapper.insert(any(LlmModel.class))).thenReturn(1); + + AiModelServiceImpl service = new AiModelServiceImpl( + objectMapper, + asrModelMapper, + llmModelMapper + ); + + AiModelDTO dto = new AiModelDTO(); + dto.setModelType("LLM"); + dto.setModelName("ordered-llm"); + dto.setProvider("openai"); + dto.setBaseUrl("http://127.0.0.1:9000"); + dto.setApiPath("/v1/chat/completions"); + dto.setModelCode("gpt-test"); + dto.setIsDefault(0); + dto.setStatus(1); + dto.setSortOrder(7); + + service.saveModel(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LlmModel.class); + verify(llmModelMapper, times(1)).insert(captor.capture()); + assertEquals(7, captor.getValue().getSortOrder()); + } + + @Test + void saveModelShouldRejectDisabledDefaultModel() { + AiModelServiceImpl service = new AiModelServiceImpl( + objectMapper, + mock(AsrModelMapper.class), + mock(LlmModelMapper.class) + ); + + AiModelDTO dto = new AiModelDTO(); + dto.setModelType("LLM"); + dto.setModelName("invalid-default"); + dto.setProvider("openai"); + dto.setBaseUrl("http://127.0.0.1:9000"); + dto.setApiPath("/v1/chat/completions"); + dto.setModelCode("gpt-test"); + dto.setIsDefault(1); + dto.setStatus(0); + + assertThrows(RuntimeException.class, () -> service.saveModel(dto)); + } + @Test void saveModelShouldAllowCustomAsrWithoutApiKeyAndSkipSync() { AsrModelMapper asrModelMapper = mock(AsrModelMapper.class); diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java index 8a24605..e96d9d7 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingRuntimeProfileResolverImplTest.java @@ -3,7 +3,9 @@ package com.imeeting.service.biz.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile; +import com.imeeting.entity.biz.AsrModel; import com.imeeting.entity.biz.HotWord; +import com.imeeting.entity.biz.LlmModel; import com.imeeting.entity.biz.PromptTemplate; import com.imeeting.mapper.biz.AsrModelMapper; import com.imeeting.mapper.biz.LlmModelMapper; @@ -18,6 +20,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertIterableEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -146,6 +149,48 @@ class MeetingRuntimeProfileResolverImplTest { assertIterableEquals(List.of("OpenAI", "Codex"), profile.getResolvedHotWords()); } + @Test + void resolveShouldFallbackToFirstEnabledModelUsingSortOrder() { + AiModelService aiModelService = mock(AiModelService.class); + PromptTemplateService promptTemplateService = mock(PromptTemplateService.class); + HotWordService hotWordService = mock(HotWordService.class); + AsrModelMapper asrModelMapper = mock(AsrModelMapper.class); + LlmModelMapper llmModelMapper = mock(LlmModelMapper.class); + MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl( + aiModelService, + promptTemplateService, + hotWordService, + asrModelMapper, + llmModelMapper + ); + + when(aiModelService.getDefaultModel("ASR", 1L)).thenReturn(null); + when(aiModelService.getDefaultModel("LLM", 1L)).thenReturn(null); + when(asrModelMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(asrEntity(11L)); + when(llmModelMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(llmEntity(22L)); + when(aiModelService.getModelById(11L, "ASR")).thenReturn(enabledModel(11L, 1L, "ASR-Model")); + when(aiModelService.getModelById(22L, "LLM")).thenReturn(enabledModel(22L, 1L, "LLM-Model")); + when(promptTemplateService.getOne(any(LambdaQueryWrapper.class))).thenReturn(enabledPrompt(33L, 1L, "Default Prompt")); + + RealtimeMeetingRuntimeProfile profile = resolver.resolve( + 1L, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + List.of() + ); + + assertEquals(11L, profile.getResolvedAsrModelId()); + assertEquals(22L, profile.getResolvedSummaryModelId()); + } + private AiModelVO enabledModel(Long id, Long tenantId, String name) { AiModelVO model = new AiModelVO(); model.setId(id); @@ -163,4 +208,18 @@ class MeetingRuntimeProfileResolverImplTest { template.setStatus(1); return template; } + + private AsrModel asrEntity(Long id) { + AsrModel entity = new AsrModel(); + entity.setId(id); + entity.setStatus(1); + return entity; + } + + private LlmModel llmEntity(Long id) { + LlmModel entity = new LlmModel(); + entity.setId(id); + entity.setStatus(1); + return entity; + } } diff --git a/frontend/src/api/business/aimodel.ts b/frontend/src/api/business/aimodel.ts index 765bc60..b856e1f 100644 --- a/frontend/src/api/business/aimodel.ts +++ b/frontend/src/api/business/aimodel.ts @@ -16,6 +16,7 @@ export interface AiModelVO { mediaConfig?: Record; isDefault: number; status: number; + sortOrder?: number; remark?: string; createdAt: string; } @@ -45,6 +46,7 @@ export interface AiModelDTO { mediaConfig?: Record; isDefault: number; status: number; + sortOrder?: number; remark?: string; } diff --git a/frontend/src/api/business/hotwordGroup.ts b/frontend/src/api/business/hotwordGroup.ts new file mode 100644 index 0000000..9c43baa --- /dev/null +++ b/frontend/src/api/business/hotwordGroup.ts @@ -0,0 +1,61 @@ +import http from "../http"; + +export interface HotWordGroupVO { + id: number; + tenantId: number; + groupName: string; + creatorId: number; + status: number; + hotWordCount: number; + remark?: string; + createdAt: string; + updatedAt: string; +} + +export interface HotWordGroupDTO { + id?: number; + tenantId?: number; + groupName: string; + status: number; + remark?: string; +} + +export const getHotWordGroupPage = (params: { + current: number; + size: number; + name?: string; + tenantId?: number; +}) => { + return http.get<{ code: string; data: { records: HotWordGroupVO[]; total: number }; msg: string }>( + "/api/biz/hotword-group/page", + { params } + ); +}; + +export const getHotWordGroupOptions = (tenantId?: number) => { + return http.get<{ code: string; data: HotWordGroupVO[]; msg: string }>( + "/api/biz/hotword-group/options", + { params: { tenantId } } + ); +}; + +export const saveHotWordGroup = (data: HotWordGroupDTO) => { + return http.post<{ code: string; data: HotWordGroupVO; msg: string }>( + "/api/biz/hotword-group", + data + ); +}; + +export const updateHotWordGroup = (data: HotWordGroupDTO) => { + return http.put<{ code: string; data: HotWordGroupVO; msg: string }>( + "/api/biz/hotword-group", + data + ); +}; + +export const deleteHotWordGroup = (id: number, tenantId?: number) => { + return http.delete<{ code: string; data: boolean; msg: string }>( + `/api/biz/hotword-group/${id}`, + { params: { tenantId } } + ); +}; diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index f9a8a32..d2f2985 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -16,7 +16,8 @@ "error": "Error", "total": "Total {{total}} items", "loading": "Loading...", - "view": "View" + "view": "View", + "maxLength": "exceeds the maximum length the maximum length is{{length}}" }, "layout": { "profile": "Profile", @@ -170,6 +171,7 @@ "drawerTitleEdit": "Edit Tenant", "tenantName": "Tenant Name", "tenantCode": "Tenant Code", + "defaultAdminUsername": "Default Admin Username", "contactName": "Contact Name", "contactPhone": "Phone" }, @@ -231,7 +233,8 @@ "copyright": "Copyright", "desc": "System Description", "uploadHint": "Click or drag to upload", - "uploadLimit": "Recommend 1:1 ratio, max 2MB", + "uploadLimit": "Recommend 1:1 ratio, max {{size}}", + "uploadTooLarge": "Image size must not exceed {{size}}", "basicInfo": "Basic Information", "brandAssets": "Brand Assets", "complianceFooter": "Compliance and Footer", @@ -261,13 +264,13 @@ "updatePassword": "Update Password", "passwordsDoNotMatch": "Passwords do not match.", "standardUser": "Standard User", + "avatarUrl": "Avatar URL", + "avatarUrlPlaceholder": "Enter avatar image URL", + "uploadAvatar": "Upload Avatar", "botCredentialTab": "Bot Credential", "botCredentialHint": "Use this credential pair to access /mcp with X-Bot-Id and X-Bot-Secret.", "botCredentialHintDesc": "The secret is shown only after generation. Store it securely after copying.", "botBindStatus": "Binding Status", - "avatarUrl": "Avatar URL", - "avatarUrlPlaceholder": "Enter avatar image URL", - "uploadAvatar": "Upload Avatar", "botBound": "Bound", "botUnbound": "Not Generated", "botSecretHidden": "Hidden. Generate or reset to get a new secret.", @@ -310,7 +313,11 @@ "failed": "Failed", "system": "System", "platform": "Platform", - "close": "Close" + "close": "Close", + "cleanCurrent": "Clear {{type}}", + "cleanConfirmTitle": "Clear {{type}}?", + "cleanConfirmDescription": "This will remove all records of the current log type and ignores the active filters.", + "cleanSuccess": "{{type}} cleared" }, "devicesExt": { "operationSucceeded": "Operation succeeded", @@ -358,6 +365,9 @@ "expired": "Expired", "tenantNamePlaceholder": "Example: Cloud Intelligence", "tenantCodePlaceholder": "Example: UNIS", + "defaultAdminUsernamePlaceholder": "Default: amdin@tenantcode", + "defaultAdminUsernameTip": "The default value is generated from the lowercase tenant code and can be changed manually. Username uniqueness is validated on save.", + "defaultAdminUsernameFormatTip": "Username may contain only lowercase letters, numbers, @, and _.", "contactNamePlaceholder": "Contact person", "contactPhonePlaceholder": "Mobile or phone", "remarkPlaceholder": "Optional tenant description", @@ -374,6 +384,8 @@ "emailPlaceholder": "example@domain.com", "passwordKeepPlaceholder": "Leave blank to keep current password", "passwordInitPlaceholder": "Set initial password", + "confirmPassword": "Confirm Password", + "confirmPasswordPlaceholder": "Please confirm the password", "selectOrgPlaceholder": "Select organization or department", "membershipsTitle": "Tenant Memberships", "membershipTitle": "Membership #{{index}}", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index f9f9cd8..03fad42 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -14,9 +14,10 @@ "refresh": "刷新", "success": "操作成功", "error": "操作失败", - "total": "共{{total}}条数据", + "total": "共 {{total}} 条数据", "loading": "加载中...", - "view": "查看" + "view": "查看", + "maxLength": "超出最大长度,最大长度为{{length}}" }, "layout": { "profile": "个人信息", @@ -170,6 +171,7 @@ "drawerTitleEdit": "编辑租户信息", "tenantName": "租户名称", "tenantCode": "租户编码", + "defaultAdminUsername": "默认管理员用户名", "contactName": "联系人姓名", "contactPhone": "联系电话" }, @@ -231,7 +233,8 @@ "copyright": "版权信息", "desc": "系统描述", "uploadHint": "点击或拖拽上传图片", - "uploadLimit": "建议比例 1:1,大小不超过 2MB", + "uploadLimit": "建议比例 1:1,大小不超过 {{size}}", + "uploadTooLarge": "上传图片不能超过 {{size}}", "basicInfo": "基础信息", "brandAssets": "品牌资源", "complianceFooter": "合规与页脚", @@ -260,15 +263,14 @@ "saveChanges": "保存修改", "updatePassword": "更新密码", "passwordsDoNotMatch": "两次输入的密码不一致。", - "standardUser": "普通用户", - "botCredentialTab": "Bot 凭证", - "botCredentialHint": "使用这组凭证通过 X-Bot-Id 和 X-Bot-Secret 访问 /mcp。", - "botCredentialHintDesc": "Secret 只会在生成后显示一次,请复制后妥善保管。", - "botBindStatus": "绑定状态", + "standardUser": "\u666e\u901a\u7528\u6237", "avatarUrl": "\u5934\u50cf URL", "avatarUrlPlaceholder": "\u8bf7\u8f93\u5165\u5934\u50cf\u56fe\u7247\u5730\u5740", "uploadAvatar": "\u4e0a\u4f20\u5934\u50cf", - + "botCredentialTab": "Bot \u51ed\u8bc1", + "botCredentialHint": "使用这组凭证通过 X-Bot-Id 和 X-Bot-Secret 访问 /mcp。", + "botCredentialHintDesc": "Secret 只会在生成后显示一次,请复制后妥善保管。", + "botBindStatus": "绑定状态", "botBound": "已绑定", "botUnbound": "未生成", "botSecretHidden": "已隐藏。如需查看新的 Secret,请重新生成。", @@ -311,7 +313,11 @@ "failed": "失败", "system": "系统", "platform": "平台", - "close": "关闭" + "close": "关闭", + "cleanCurrent": "清空{{type}}", + "cleanConfirmTitle": "确认清空{{type}}?", + "cleanConfirmDescription": "将清空当前日志类型的全部记录,不受当前筛选条件影响。", + "cleanSuccess": "{{type}}已清空" }, "devicesExt": { "operationSucceeded": "操作成功", @@ -359,6 +365,9 @@ "expired": "已过期", "tenantNamePlaceholder": "例如:云智协同", "tenantCodePlaceholder": "例如:UNIS", + "defaultAdminUsernamePlaceholder": "默认:amdin@租户编码小写", + "defaultAdminUsernameTip": "默认值会根据租户编码自动生成,并且可以手动修改;保存时会校验用户名是否重复。", + "defaultAdminUsernameFormatTip": "用户名只能输入小写字母、数字、@ 和 _。", "contactNamePlaceholder": "请输入联系人姓名", "contactPhonePlaceholder": "请输入手机号或座机号", "remarkPlaceholder": "选填,填写租户说明", @@ -375,6 +384,8 @@ "emailPlaceholder": "example@domain.com", "passwordKeepPlaceholder": "不填写则保留当前密码", "passwordInitPlaceholder": "请设置初始密码", + "confirmPassword": "确认密码", + "confirmPasswordPlaceholder": "请再次输入密码", "selectOrgPlaceholder": "请选择组织或部门", "membershipsTitle": "租户归属", "membershipTitle": "归属 #{{index}}", diff --git a/frontend/src/pages/access/users/index.less b/frontend/src/pages/access/users/index.less index ac60247..f301df3 100644 --- a/frontend/src/pages/access/users/index.less +++ b/frontend/src/pages/access/users/index.less @@ -1,5 +1,5 @@ .users-page { - padding: 24px; + min-height: 100%; } .users-header { @@ -19,7 +19,7 @@ } .users-table-toolbar { - margin-bottom: 20px; + margin-bottom: 0; } .users-search-input { diff --git a/frontend/src/pages/access/users/index.tsx b/frontend/src/pages/access/users/index.tsx index 7ab549a..a221e99 100644 --- a/frontend/src/pages/access/users/index.tsx +++ b/frontend/src/pages/access/users/index.tsx @@ -397,9 +397,18 @@ export default function Users() { -
- { setCurrent(page); setPageSize(size); })} /> - +
{ + setCurrent(page); + setPageSize(size); + })} + /> + + + + + + @@ -618,12 +639,20 @@ const AiModels: React.FC = () => { - + { + if (checked) { + form.setFieldValue("statusChecked", true); + } + }} + /> - + diff --git a/frontend/src/pages/business/HotWords.tsx b/frontend/src/pages/business/HotWords.tsx index 9c278d0..e6e5089 100644 --- a/frontend/src/pages/business/HotWords.tsx +++ b/frontend/src/pages/business/HotWords.tsx @@ -8,6 +8,7 @@ import { Form, Input, InputNumber, + List, Modal, Popconfirm, Row, @@ -20,9 +21,7 @@ import { import { DeleteOutlined, EditOutlined, - FolderOpenOutlined, PlusOutlined, - SearchOutlined, } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import { useDict } from "../../hooks/useDict"; @@ -43,6 +42,7 @@ import { type HotWordGroupVO, } from "../../api/business/hotwordGroup"; import AppPagination from "../../components/shared/AppPagination"; +import ListActionBar from "../../components/shared/ListActionBar/ListActionBar"; const { Option } = Select; const { Text } = Typography; @@ -90,13 +90,14 @@ const HotWords: React.FC = () => { const [submitLoading, setSubmitLoading] = useState(false); const [groupOptions, setGroupOptions] = useState([]); - const [groupManageVisible, setGroupManageVisible] = useState(false); const [groupEditorVisible, setGroupEditorVisible] = useState(false); const [groupLoading, setGroupLoading] = useState(false); const [groupSubmitLoading, setGroupSubmitLoading] = useState(false); const [groupData, setGroupData] = useState([]); const [editingGroupId, setEditingGroupId] = useState(null); + const [filterVisible, setFilterVisible] = useState(false); + const groupNameMap = useMemo( () => Object.fromEntries(groupOptions.map((item) => [item.id, item.groupName])) as Record, [groupOptions] @@ -104,10 +105,11 @@ const HotWords: React.FC = () => { useEffect(() => { void fetchData(); - }, [current, searchCategory, searchGroupId, searchWord, size]); + }, [current, searchCategory, searchGroupId, size]); useEffect(() => { void loadGroupOptions(); + void loadGroupPage(); }, []); const fetchData = async () => { @@ -165,7 +167,7 @@ const HotWords: React.FC = () => { } else { setEditingId(null); form.resetFields(); - form.setFieldsValue({ weight: 2, status: 1 }); + form.setFieldsValue({ weight: 2, status: 1, hotWordGroupId: searchGroupId }); } setModalVisible(true); }; @@ -174,6 +176,7 @@ const HotWords: React.FC = () => { await deleteHotWord(id); message.success("删除成功"); await fetchData(); + await loadGroupPage(); }; const handleSubmit = async () => { @@ -194,7 +197,7 @@ const HotWords: React.FC = () => { message.success("新增成功"); } setModalVisible(false); - await Promise.all([fetchData(), loadGroupOptions(), groupManageVisible ? loadGroupPage() : Promise.resolve()]); + await Promise.all([fetchData(), loadGroupOptions(), loadGroupPage()]); } finally { setSubmitLoading(false); } @@ -216,12 +219,8 @@ const HotWords: React.FC = () => { } }; - const openGroupManager = async () => { - setGroupManageVisible(true); - await loadGroupPage(); - }; - - const openGroupEditor = (record?: HotWordGroupVO) => { + const openGroupEditor = (record?: HotWordGroupVO, e?: React.MouseEvent) => { + e?.stopPropagation(); if (record) { setEditingGroupId(record.id); groupForm.setFieldsValue({ @@ -255,9 +254,13 @@ const HotWords: React.FC = () => { } }; - const handleDeleteGroup = async (id: number) => { + const handleDeleteGroup = async (id: number, e?: React.MouseEvent) => { + e?.stopPropagation(); await deleteHotWordGroup(id, isPlatformAdmin ? activeTenantId : undefined); message.success("热词组删除成功"); + if (searchGroupId === id) { + setSearchGroupId(undefined); + } await Promise.all([loadGroupOptions(), loadGroupPage(), fetchData()]); }; @@ -325,121 +328,178 @@ const HotWords: React.FC = () => { }, ]; - const groupColumns = [ - { - title: "热词组名称", - dataIndex: "groupName", - key: "groupName", - render: (value: string) => {value}, - }, - { - title: "热词数量", - dataIndex: "hotWordCount", - key: "hotWordCount", - render: (value: number) => = 200 ? "red" : "processing"}>{value}/200, - }, - { - title: "状态", - dataIndex: "status", - key: "status", - render: (value: number) => value === 1 ? : , - }, - { - title: "备注", - dataIndex: "remark", - key: "remark", - render: (value?: string) => value || "-", - }, - { - title: "操作", - key: "action", - render: (_: unknown, record: HotWordGroupVO) => ( - - - handleDeleteGroup(record.id)} - okText={t("common.confirm")} - cancelText={t("common.cancel")} - > - - - - ), - }, - ]; + const filterContent = ( +
+
分类筛选
+ +
+ ); return ( -
- - - } - allowClear - onPressEnter={(e) => { - setSearchWord((e.target as HTMLInputElement).value); - setCurrent(1); - }} - style={{ width: 200 }} +
+
+
- - - - } - > -
-
- - { - setCurrent(page); - setSize(pageSize); - }} - /> - + + { + setCurrent(page); + setSize(pageSize); + }} + /> + + { - setGroupManageVisible(false)} - footer={null} - width={900} - destroyOnHidden - > -
- -
-
- - - } style={{ width: 220, borderRadius: "6px" }} value={params.name} onChange={(event) => setParams({ ...params, name: event.target.value })} allowClear /> - setParams({ ...params, code: event.target.value })} allowClear /> - - + } style={{ width: 220, borderRadius: "6px" }} value={params.name} onChange={(event) => setParams({ ...params, name: event.target.value })} allowClear /> + setParams({ ...params, code: event.target.value })} allowClear /> + + {can("sys_tenant:create") && } @@ -202,23 +202,23 @@ export default function Tenants() { {!editing && ( { setAdminAccountTouched(true); return sanitizeLoginName(event?.target?.value); }} - extra={t("tenantsExt.defaultAdminUsernameTip", { defaultValue: "默认值会根据租户编码自动生成,可手动修改;保存时会校验登录名是否重复。登录名只能输入数字、小写英文、@ 和 _。" })} + extra={t("tenantsExt.defaultAdminUsernameTip")} >