From 4715cd4a86214329c66cceebdaf11a2e698c8073 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Thu, 26 Mar 2026 14:55:12 +0800 Subject: [PATCH] chore: release 1.1.0 --- IMPLEMENTATION_PLAN.md | 27 + backend/app/api/endpoints/admin.py | 524 +++- backend/app/api/endpoints/admin_dashboard.py | 83 +- backend/app/api/endpoints/admin_settings.py | 835 ++++++ backend/app/api/endpoints/auth.py | 36 +- backend/app/api/endpoints/client_downloads.py | 5 +- backend/app/api/endpoints/dict_data.py | 20 +- backend/app/api/endpoints/external_apps.py | 2 +- backend/app/api/endpoints/hot_words.py | 279 +- backend/app/api/endpoints/knowledge_base.py | 4 +- backend/app/api/endpoints/meetings.py | 66 +- backend/app/api/endpoints/prompts.py | 494 +-- backend/app/api/endpoints/users.py | 177 +- backend/app/api/endpoints/voiceprint.py | 2 +- backend/app/core/auth.py | 4 +- backend/app/core/config.py | 22 +- backend/app/main.py | 21 +- backend/app/models/models.py | 251 +- backend/app/services/async_meeting_service.py | 116 +- .../services/async_transcription_service.py | 127 +- backend/app/services/llm_service.py | 219 +- backend/app/services/system_config_service.py | 372 ++- backend/app/services/terminal_service.py | 17 +- backend/scripts/migrate_user_asset_layout.py | 97 + backend/scripts/run_sql_migration.py | 165 + .../scripts/run_sql_migration_connector.py | 139 + backend/sql/add_menu_permissions_system.sql | 99 +- backend/sql/imeeting-init.sql | 25 +- ...add_extra_config_to_audio_model_config.sql | 24 + .../migrations/add_personal_meeting_menus.sql | 58 + .../create_parameter_and_model_management.sql | 173 ++ .../create_system_management_menu_group.sql | 25 + .../sql/migrations/create_user_mcp_table.sql | 15 + .../expand_ai_model_configs_columns.sql | 52 + .../expand_menu_permission_tables.sql | 87 + .../fix_dashboard_desktop_visibility.sql | 8 + .../grant_dashboard_desktop_to_all_roles.sql | 24 + ...onal_prompt_library_into_prompt_config.sql | 12 + ...normalize_dashboard_desktop_menu_rules.sql | 88 + .../optimize_menu_loading_performance.sql | 24 + .../optimize_user_management_query.sql | 12 + .../refactor_hot_words_to_group.sql | 67 + ...ame_history_meetings_to_meeting_center.sql | 12 + .../rename_model_tables_to_singular.sql | 157 + .../rename_user_prompt_config_table.sql | 31 + .../split_llm_audio_model_tables.sql | 170 ++ .../standardize_sys_table_prefix.sql | 121 + .../update_admin_menu_hierarchy.sql | 176 ++ .../upgrade_prompt_library_and_config.sql | 113 + frontend/package-lock.json | 4 +- frontend/package.json | 3 +- frontend/src/App.css | 230 +- frontend/src/App.jsx | 259 +- frontend/src/components/AdminModuleShell.jsx | 63 + frontend/src/components/BrandLogo.jsx | 44 + frontend/src/components/Breadcrumb.css | 79 - frontend/src/components/Breadcrumb.jsx | 35 - frontend/src/components/CenterPager.jsx | 39 + frontend/src/components/ClientDownloads.css | 150 - frontend/src/components/ClientDownloads.jsx | 239 +- frontend/src/components/ConfirmDialog.css | 188 -- frontend/src/components/ConfirmDialog.jsx | 53 - frontend/src/components/ContentViewer.css | 165 - frontend/src/components/ContentViewer.jsx | 137 +- frontend/src/components/DataTable.css | 176 -- frontend/src/components/DataTable.jsx | 108 - frontend/src/components/DateTimePicker.css | 258 -- frontend/src/components/DateTimePicker.jsx | 268 +- frontend/src/components/Dropdown.css | 101 - frontend/src/components/Dropdown.jsx | 81 - frontend/src/components/ExpandSearchBox.css | 104 - frontend/src/components/ExpandSearchBox.jsx | 123 +- frontend/src/components/FormModal.css | 196 -- frontend/src/components/FormModal.jsx | 67 - frontend/src/components/Header.css | 12 - frontend/src/components/Header.jsx | 38 +- frontend/src/components/ListTable.css | 237 -- frontend/src/components/ListTable.jsx | 169 -- frontend/src/components/MainLayout.jsx | 417 +++ frontend/src/components/MarkdownEditor.css | 280 -- frontend/src/components/MarkdownEditor.jsx | 227 +- frontend/src/components/MarkdownRenderer.css | 285 -- frontend/src/components/MarkdownRenderer.jsx | 54 +- frontend/src/components/MeetingFormDrawer.jsx | 150 + frontend/src/components/MeetingTimeline.css | 629 ---- frontend/src/components/MeetingTimeline.jsx | 342 +-- frontend/src/components/MindMap.jsx | 228 +- frontend/src/components/PageLoading.css | 34 - frontend/src/components/PageLoading.jsx | 14 - frontend/src/components/QRCodeModal.css | 134 - frontend/src/components/QRCodeModal.jsx | 89 +- frontend/src/components/ScrollToTop.css | 51 - frontend/src/components/ScrollToTop.jsx | 42 +- frontend/src/components/SimpleSearchInput.css | 64 - frontend/src/components/SimpleSearchInput.jsx | 97 +- frontend/src/components/StatusTag.jsx | 34 + frontend/src/components/StepIndicator.css | 81 - frontend/src/components/StepIndicator.jsx | 23 +- frontend/src/components/TagCloud.css | 395 --- frontend/src/components/TagCloud.jsx | 129 +- frontend/src/components/TagDisplay.css | 136 - frontend/src/components/TagDisplay.jsx | 80 +- frontend/src/components/TagEditor.css | 164 - frontend/src/components/TagEditor.jsx | 215 +- frontend/src/components/Toast.css | 105 - frontend/src/components/Toast.jsx | 38 - frontend/src/components/ToggleSwitch.css | 124 - frontend/src/components/ToggleSwitch.jsx | 48 +- .../components/VoiceprintCollectionModal.css | 393 --- .../components/VoiceprintCollectionModal.jsx | 307 +- frontend/src/config/api.js | 30 +- frontend/src/config/modelProviderCatalog.js | 131 + .../src/config/systemManagementModules.jsx | 117 + frontend/src/index.css | 122 +- frontend/src/main.jsx | 5 +- frontend/src/pages/AccountSettings.css | 294 +- frontend/src/pages/AccountSettings.jsx | 655 ++-- frontend/src/pages/AdminDashboard.css | 614 +--- frontend/src/pages/AdminDashboard.jsx | 1035 +++---- frontend/src/pages/AdminManagement.css | 149 - frontend/src/pages/AdminManagement.jsx | 97 +- frontend/src/pages/ClientDownloadPage.jsx | 46 +- frontend/src/pages/ClientManagement.css | 601 ---- frontend/src/pages/ClientManagement.jsx | 1115 +++---- frontend/src/pages/CreateMeeting.jsx | 406 +-- frontend/src/pages/Dashboard.css | 1027 ++----- frontend/src/pages/Dashboard.jsx | 790 ++--- frontend/src/pages/EditKnowledgeBase.jsx | 217 +- frontend/src/pages/EditMeeting.jsx | 446 +-- frontend/src/pages/HomePage.jsx | 345 +-- frontend/src/pages/KnowledgeBasePage.css | 1168 -------- frontend/src/pages/KnowledgeBasePage.jsx | 1044 ++----- frontend/src/pages/MeetingCenterPage.jsx | 396 +++ frontend/src/pages/MeetingDetails.css | 2653 ----------------- frontend/src/pages/MeetingDetails.jsx | 2649 +++++----------- frontend/src/pages/MeetingPreview.jsx | 751 +---- frontend/src/pages/PromptConfigPage.jsx | 261 ++ frontend/src/pages/PromptManagementPage.jsx | 408 ++- frontend/src/pages/admin/DictManagement.jsx | 289 +- .../src/pages/admin/ExternalAppManagement.css | 592 ---- .../src/pages/admin/ExternalAppManagement.jsx | 1151 +++---- .../src/pages/admin/HotWordManagement.css | 194 -- .../src/pages/admin/HotWordManagement.jsx | 769 +++-- frontend/src/pages/admin/ModelManagement.jsx | 692 +++++ .../src/pages/admin/ParameterManagement.jsx | 189 ++ .../src/pages/admin/PermissionManagement.jsx | 1267 ++++++-- frontend/src/pages/admin/PromptManagement.jsx | 766 ++--- .../pages/admin/SystemManagementOverview.jsx | 274 ++ .../src/pages/admin/TerminalManagement.css | 415 --- .../src/pages/admin/TerminalManagement.jsx | 739 +++-- frontend/src/pages/admin/UserManagement.css | 273 -- frontend/src/pages/admin/UserManagement.jsx | 519 ++-- frontend/src/services/menuService.js | 82 +- frontend/src/styles/console-theme.css | 827 +++++ frontend/src/utils/apiClient.js | 28 +- frontend/src/utils/configService.js | 22 +- frontend/src/utils/menuIcons.js | 240 ++ frontend/vite.config.js | 2 +- 158 files changed, 17327 insertions(+), 24287 deletions(-) create mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 backend/app/api/endpoints/admin_settings.py create mode 100644 backend/scripts/migrate_user_asset_layout.py create mode 100644 backend/scripts/run_sql_migration.py create mode 100644 backend/scripts/run_sql_migration_connector.py create mode 100644 backend/sql/migrations/add_extra_config_to_audio_model_config.sql create mode 100644 backend/sql/migrations/add_personal_meeting_menus.sql create mode 100644 backend/sql/migrations/create_parameter_and_model_management.sql create mode 100644 backend/sql/migrations/create_system_management_menu_group.sql create mode 100644 backend/sql/migrations/create_user_mcp_table.sql create mode 100644 backend/sql/migrations/expand_ai_model_configs_columns.sql create mode 100644 backend/sql/migrations/expand_menu_permission_tables.sql create mode 100644 backend/sql/migrations/fix_dashboard_desktop_visibility.sql create mode 100644 backend/sql/migrations/grant_dashboard_desktop_to_all_roles.sql create mode 100644 backend/sql/migrations/merge_personal_prompt_library_into_prompt_config.sql create mode 100644 backend/sql/migrations/normalize_dashboard_desktop_menu_rules.sql create mode 100644 backend/sql/migrations/optimize_menu_loading_performance.sql create mode 100644 backend/sql/migrations/optimize_user_management_query.sql create mode 100644 backend/sql/migrations/refactor_hot_words_to_group.sql create mode 100644 backend/sql/migrations/rename_history_meetings_to_meeting_center.sql create mode 100644 backend/sql/migrations/rename_model_tables_to_singular.sql create mode 100644 backend/sql/migrations/rename_user_prompt_config_table.sql create mode 100644 backend/sql/migrations/split_llm_audio_model_tables.sql create mode 100644 backend/sql/migrations/standardize_sys_table_prefix.sql create mode 100644 backend/sql/migrations/update_admin_menu_hierarchy.sql create mode 100644 backend/sql/migrations/upgrade_prompt_library_and_config.sql create mode 100644 frontend/src/components/AdminModuleShell.jsx create mode 100644 frontend/src/components/BrandLogo.jsx delete mode 100644 frontend/src/components/Breadcrumb.css delete mode 100644 frontend/src/components/Breadcrumb.jsx create mode 100644 frontend/src/components/CenterPager.jsx delete mode 100644 frontend/src/components/ClientDownloads.css delete mode 100644 frontend/src/components/ConfirmDialog.css delete mode 100644 frontend/src/components/ConfirmDialog.jsx delete mode 100644 frontend/src/components/ContentViewer.css delete mode 100644 frontend/src/components/DataTable.css delete mode 100644 frontend/src/components/DataTable.jsx delete mode 100644 frontend/src/components/DateTimePicker.css delete mode 100644 frontend/src/components/Dropdown.css delete mode 100644 frontend/src/components/Dropdown.jsx delete mode 100644 frontend/src/components/ExpandSearchBox.css delete mode 100644 frontend/src/components/FormModal.css delete mode 100644 frontend/src/components/FormModal.jsx delete mode 100644 frontend/src/components/Header.css delete mode 100644 frontend/src/components/ListTable.css delete mode 100644 frontend/src/components/ListTable.jsx create mode 100644 frontend/src/components/MainLayout.jsx delete mode 100644 frontend/src/components/MarkdownEditor.css delete mode 100644 frontend/src/components/MarkdownRenderer.css create mode 100644 frontend/src/components/MeetingFormDrawer.jsx delete mode 100644 frontend/src/components/MeetingTimeline.css delete mode 100644 frontend/src/components/PageLoading.css delete mode 100644 frontend/src/components/PageLoading.jsx delete mode 100644 frontend/src/components/QRCodeModal.css delete mode 100644 frontend/src/components/ScrollToTop.css delete mode 100644 frontend/src/components/SimpleSearchInput.css create mode 100644 frontend/src/components/StatusTag.jsx delete mode 100644 frontend/src/components/StepIndicator.css delete mode 100644 frontend/src/components/TagCloud.css delete mode 100644 frontend/src/components/TagDisplay.css delete mode 100644 frontend/src/components/TagEditor.css delete mode 100644 frontend/src/components/Toast.css delete mode 100644 frontend/src/components/Toast.jsx delete mode 100644 frontend/src/components/ToggleSwitch.css delete mode 100644 frontend/src/components/VoiceprintCollectionModal.css create mode 100644 frontend/src/config/modelProviderCatalog.js create mode 100644 frontend/src/config/systemManagementModules.jsx delete mode 100644 frontend/src/pages/AdminManagement.css delete mode 100644 frontend/src/pages/ClientManagement.css delete mode 100644 frontend/src/pages/KnowledgeBasePage.css create mode 100644 frontend/src/pages/MeetingCenterPage.jsx delete mode 100644 frontend/src/pages/MeetingDetails.css create mode 100644 frontend/src/pages/PromptConfigPage.jsx delete mode 100644 frontend/src/pages/admin/ExternalAppManagement.css delete mode 100644 frontend/src/pages/admin/HotWordManagement.css create mode 100644 frontend/src/pages/admin/ModelManagement.jsx create mode 100644 frontend/src/pages/admin/ParameterManagement.jsx create mode 100644 frontend/src/pages/admin/SystemManagementOverview.jsx delete mode 100644 frontend/src/pages/admin/TerminalManagement.css delete mode 100644 frontend/src/pages/admin/UserManagement.css create mode 100644 frontend/src/styles/console-theme.css create mode 100644 frontend/src/utils/menuIcons.js diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..de5cc84 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,27 @@ +# UI Modernization & Standardization Plan + +## Stage 1: Foundation (Global Theme & Layout) +**Goal**: Establish a consistent visual base and layout structure. +**Success Criteria**: +- Global `ConfigProvider` with a modern theme (v5 tokens). +- A reusable `MainLayout` component replacing duplicated header/sidebar logic. +- Unified navigation experience across Admin and User dashboards. +**Status**: Complete + +## Stage 2: Component Standardization +**Goal**: Replace custom, inconsistent components with Ant Design standards. +**Success Criteria**: +- `ListTable` and `DataTable` replaced by `antd.Table`. (Complete) +- `FormModal` and `ConfirmDialog` replaced by `antd.Modal`. (Complete) +- `Toast` and custom notifications replaced by `antd.message` and `antd.notification`. (Complete) +- Custom `Dropdown`, `Breadcrumb`, and `PageLoading` replaced by `antd` equivalents. (Complete) +**Status**: Complete + +## Stage 3: Visual Polish & UX +**Goal**: Enhance design details and interactive experience. +**Success Criteria**: +- Modernized dashboard cards with subtle shadows and transitions. (Complete) +- Standardized `Empty` states and `Skeleton` loaders. (Complete) +- Responsive design improvements for various screen sizes. (Complete) +- Clean up redundant CSS files and components. (In Progress) +**Status**: In Progress diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index 5fa8960..d579d95 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -1,11 +1,105 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from app.core.auth import get_current_admin_user, get_current_user from app.core.response import create_api_response from app.core.database import get_db_connection -from app.models.models import MenuInfo, MenuListResponse, RolePermissionInfo, UpdateRolePermissionsRequest, RoleInfo +from app.models.models import ( + MenuInfo, + MenuListResponse, + RolePermissionInfo, + UpdateRolePermissionsRequest, + RoleInfo, + CreateMenuRequest, + UpdateMenuRequest, + CreateRoleRequest, + UpdateRoleRequest, +) from typing import List +import time router = APIRouter() +_USER_MENU_CACHE_TTL_SECONDS = 120 +_USER_MENU_CACHE_VERSION = "menu-rules-v4" +_user_menu_cache_by_role = {} + + +def _get_cached_user_menus(role_id: int): + cached = _user_menu_cache_by_role.get(role_id) + if not cached: + return None + if cached.get("version") != _USER_MENU_CACHE_VERSION: + _user_menu_cache_by_role.pop(role_id, None) + return None + if time.time() > cached["expires_at"]: + _user_menu_cache_by_role.pop(role_id, None) + return None + return cached["menus"] + + +def _set_cached_user_menus(role_id: int, menus): + _user_menu_cache_by_role[role_id] = { + "version": _USER_MENU_CACHE_VERSION, + "menus": menus, + "expires_at": time.time() + _USER_MENU_CACHE_TTL_SECONDS, + } + + +def _invalidate_user_menu_cache(role_id: int | None = None): + if role_id is None: + _user_menu_cache_by_role.clear() + return + _user_menu_cache_by_role.pop(role_id, None) + + +def _build_menu_index(menus): + menu_by_id = {} + children_by_parent = {} + for menu in menus: + menu_id = menu["menu_id"] + menu_by_id[menu_id] = menu + parent_id = menu.get("parent_id") + if parent_id is not None: + children_by_parent.setdefault(parent_id, []).append(menu_id) + return menu_by_id, children_by_parent + + +def _get_descendants(menu_id, children_by_parent): + result = set() + stack = [menu_id] + while stack: + current = stack.pop() + for child_id in children_by_parent.get(current, []): + if child_id in result: + continue + result.add(child_id) + stack.append(child_id) + return result + + +def _normalize_permission_menu_ids(raw_menu_ids, all_menus): + """ + 对权限菜单ID做归一化: + 1. 选中父节点 => 自动包含全部子孙节点 + 2. 选中子节点 => 自动包含全部祖先节点 + """ + menu_by_id, children_by_parent = _build_menu_index(all_menus) + selected = {menu_id for menu_id in raw_menu_ids if menu_id in menu_by_id} + + expanded = set(selected) + + # 父 -> 子孙 + for menu_id in list(expanded): + expanded.update(_get_descendants(menu_id, children_by_parent)) + + # 子 -> 祖先 + for menu_id in list(expanded): + cursor = menu_by_id[menu_id].get("parent_id") + while cursor is not None and cursor in menu_by_id: + if cursor in expanded: + break + expanded.add(cursor) + cursor = menu_by_id[cursor].get("parent_id") + + return sorted(expanded) # ========== 菜单权限管理接口 ========== @@ -21,8 +115,12 @@ async def get_all_menus(current_user=Depends(get_current_admin_user)): query = """ SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description, created_at, updated_at - FROM menus - ORDER BY sort_order ASC, menu_id ASC + FROM sys_menus + ORDER BY + COALESCE(parent_id, menu_id) ASC, + CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END ASC, + sort_order ASC, + menu_id ASC """ cursor.execute(query) menus = cursor.fetchall() @@ -37,6 +135,171 @@ async def get_all_menus(current_user=Depends(get_current_admin_user)): except Exception as e: return create_api_response(code="500", message=f"获取菜单列表失败: {str(e)}") + +@router.post("/admin/menus") +async def create_menu(request: CreateMenuRequest, current_user=Depends(get_current_admin_user)): + """ + 创建菜单 + """ + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_code = %s", (request.menu_code,)) + if cursor.fetchone(): + return create_api_response(code="400", message="菜单编码已存在") + + if request.parent_id is not None: + cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,)) + if not cursor.fetchone(): + return create_api_response(code="400", message="父菜单不存在") + + cursor.execute( + """ + INSERT INTO sys_menus (menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + request.menu_code, + request.menu_name, + request.menu_icon, + request.menu_url, + request.menu_type, + request.parent_id, + request.sort_order, + 1 if request.is_active else 0, + request.description, + ), + ) + menu_id = cursor.lastrowid + connection.commit() + _invalidate_user_menu_cache() + + cursor.execute( + """ + SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, + parent_id, sort_order, is_active, description, created_at, updated_at + FROM sys_menus + WHERE menu_id = %s + """, + (menu_id,), + ) + created = cursor.fetchone() + return create_api_response(code="200", message="创建菜单成功", data=created) + except Exception as e: + return create_api_response(code="500", message=f"创建菜单失败: {str(e)}") + + +@router.put("/admin/menus/{menu_id}") +async def update_menu(menu_id: int, request: UpdateMenuRequest, current_user=Depends(get_current_admin_user)): + """ + 更新菜单 + """ + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + cursor.execute("SELECT * FROM sys_menus WHERE menu_id = %s", (menu_id,)) + current = cursor.fetchone() + if not current: + return create_api_response(code="404", message="菜单不存在") + + updates = {} + for field in [ + "menu_code", + "menu_name", + "menu_icon", + "menu_url", + "menu_type", + "sort_order", + "description", + ]: + value = getattr(request, field) + if value is not None: + updates[field] = value + + if request.is_active is not None: + updates["is_active"] = 1 if request.is_active else 0 + + fields_set = getattr(request, "model_fields_set", getattr(request, "__fields_set__", set())) + + # parent_id 允许设为 null,且不允许设为自己 + if request.parent_id == menu_id: + return create_api_response(code="400", message="父菜单不能为自身") + if request.parent_id is not None: + cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,)) + if not cursor.fetchone(): + return create_api_response(code="400", message="父菜单不存在") + + # 防止形成环:父菜单不能是当前菜单的子孙 + cursor.execute("SELECT menu_id, parent_id FROM sys_menus") + all_menus = cursor.fetchall() + _, children_by_parent = _build_menu_index(all_menus) + descendants = _get_descendants(menu_id, children_by_parent) + if request.parent_id in descendants: + return create_api_response(code="400", message="父菜单不能设置为当前菜单的子孙菜单") + + if request.parent_id is not None or (request.parent_id is None and "parent_id" in fields_set): + updates["parent_id"] = request.parent_id + + if "menu_code" in updates: + cursor.execute( + "SELECT menu_id FROM sys_menus WHERE menu_code = %s AND menu_id != %s", + (updates["menu_code"], menu_id), + ) + if cursor.fetchone(): + return create_api_response(code="400", message="菜单编码已存在") + + if not updates: + return create_api_response(code="200", message="没有变更内容", data=current) + + set_sql = ", ".join([f"{k} = %s" for k in updates.keys()]) + values = list(updates.values()) + [menu_id] + cursor.execute(f"UPDATE sys_menus SET {set_sql} WHERE menu_id = %s", tuple(values)) + connection.commit() + _invalidate_user_menu_cache() + + cursor.execute( + """ + SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, + parent_id, sort_order, is_active, description, created_at, updated_at + FROM sys_menus + WHERE menu_id = %s + """, + (menu_id,), + ) + updated = cursor.fetchone() + return create_api_response(code="200", message="更新菜单成功", data=updated) + except Exception as e: + return create_api_response(code="500", message=f"更新菜单失败: {str(e)}") + + +@router.delete("/admin/menus/{menu_id}") +async def delete_menu(menu_id: int, current_user=Depends(get_current_admin_user)): + """ + 删除菜单(有子菜单时不允许删除) + """ + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (menu_id,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="菜单不存在") + + cursor.execute("SELECT COUNT(*) AS cnt FROM sys_menus WHERE parent_id = %s", (menu_id,)) + child_count = cursor.fetchone()["cnt"] + if child_count > 0: + return create_api_response(code="400", message="请先删除子菜单") + + cursor.execute("DELETE FROM sys_role_menu_permissions WHERE menu_id = %s", (menu_id,)) + cursor.execute("DELETE FROM sys_menus WHERE menu_id = %s", (menu_id,)) + connection.commit() + _invalidate_user_menu_cache() + return create_api_response(code="200", message="删除菜单成功") + except Exception as e: + return create_api_response(code="500", message=f"删除菜单失败: {str(e)}") + @router.get("/admin/roles") async def get_all_roles(current_user=Depends(get_current_admin_user)): """ @@ -51,8 +314,8 @@ async def get_all_roles(current_user=Depends(get_current_admin_user)): query = """ SELECT r.role_id, r.role_name, r.created_at, COUNT(rmp.menu_id) as menu_count - FROM roles r - LEFT JOIN role_menu_permissions rmp ON r.role_id = rmp.role_id + FROM sys_roles r + LEFT JOIN sys_role_menu_permissions rmp ON r.role_id = rmp.role_id GROUP BY r.role_id ORDER BY r.role_id ASC """ @@ -67,6 +330,146 @@ async def get_all_roles(current_user=Depends(get_current_admin_user)): except Exception as e: return create_api_response(code="500", message=f"获取角色列表失败: {str(e)}") + +@router.post("/admin/roles") +async def create_role(request: CreateRoleRequest, current_user=Depends(get_current_admin_user)): + """ + 创建角色 + """ + try: + role_name = request.role_name.strip() + if not role_name: + return create_api_response(code="400", message="角色名称不能为空") + + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s", (role_name,)) + if cursor.fetchone(): + return create_api_response(code="400", message="角色名称已存在") + + cursor.execute("INSERT INTO sys_roles (role_name) VALUES (%s)", (role_name,)) + role_id = cursor.lastrowid + connection.commit() + + cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,)) + role = cursor.fetchone() + return create_api_response(code="200", message="创建角色成功", data=role) + except Exception as e: + return create_api_response(code="500", message=f"创建角色失败: {str(e)}") + + +@router.put("/admin/roles/{role_id}") +async def update_role(role_id: int, request: UpdateRoleRequest, current_user=Depends(get_current_admin_user)): + """ + 更新角色 + """ + try: + role_name = request.role_name.strip() + if not role_name: + return create_api_response(code="400", message="角色名称不能为空") + + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="角色不存在") + + cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s AND role_id != %s", (role_name, role_id)) + if cursor.fetchone(): + return create_api_response(code="400", message="角色名称已存在") + + cursor.execute("UPDATE sys_roles SET role_name = %s WHERE role_id = %s", (role_name, role_id)) + connection.commit() + + cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,)) + role = cursor.fetchone() + return create_api_response(code="200", message="更新角色成功", data=role) + except Exception as e: + return create_api_response(code="500", message=f"更新角色失败: {str(e)}") + + +@router.get("/admin/roles/{role_id}/users") +async def get_role_users( + role_id: int, + page: int = Query(1, ge=1), + size: int = Query(10, ge=1, le=100), + current_user=Depends(get_current_admin_user), +): + """ + 获取角色下用户列表 + """ + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,)) + role = cursor.fetchone() + if not role: + return create_api_response(code="404", message="角色不存在") + + cursor.execute( + """ + SELECT COUNT(*) AS total + FROM sys_users + WHERE role_id = %s + """, + (role_id,), + ) + total = cursor.fetchone()["total"] + offset = (page - 1) * size + + cursor.execute( + """ + SELECT user_id, username, caption, email, avatar_url, role_id, created_at + FROM sys_users + WHERE role_id = %s + ORDER BY user_id ASC + LIMIT %s OFFSET %s + """, + (role_id, size, offset), + ) + users = cursor.fetchall() + return create_api_response( + code="200", + message="获取角色用户成功", + data={ + "role_id": role_id, + "role_name": role["role_name"], + "users": users, + "total": total, + "page": page, + "size": size, + }, + ) + except Exception as e: + return create_api_response(code="500", message=f"获取角色用户失败: {str(e)}") + + +@router.get("/admin/roles/permissions/all") +async def get_all_role_permissions(current_user=Depends(get_current_admin_user)): + """ + 批量获取所有角色权限(用于减少N次请求) + """ + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute( + """ + SELECT rmp.role_id, rmp.menu_id + FROM sys_role_menu_permissions rmp + JOIN sys_menus m ON m.menu_id = rmp.menu_id + WHERE m.is_active = 1 + ORDER BY rmp.role_id ASC, rmp.menu_id ASC + """ + ) + rows = cursor.fetchall() + + result = {} + for row in rows: + result.setdefault(row["role_id"], []).append(row["menu_id"]) + return create_api_response(code="200", message="获取角色权限成功", data={"permissions": result}) + except Exception as e: + return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}") + @router.get("/admin/roles/{role_id}/permissions") async def get_role_permissions(role_id: int, current_user=Depends(get_current_admin_user)): """ @@ -78,16 +481,18 @@ async def get_role_permissions(role_id: int, current_user=Depends(get_current_ad cursor = connection.cursor(dictionary=True) # 检查角色是否存在 - cursor.execute("SELECT role_id, role_name FROM roles WHERE role_id = %s", (role_id,)) + cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,)) role = cursor.fetchone() if not role: return create_api_response(code="404", message="角色不存在") # 查询该角色的所有菜单权限 query = """ - SELECT menu_id - FROM role_menu_permissions - WHERE role_id = %s + SELECT rmp.menu_id + FROM sys_role_menu_permissions rmp + JOIN sys_menus m ON m.menu_id = rmp.menu_id + WHERE rmp.role_id = %s + AND m.is_active = 1 """ cursor.execute(query, (role_id,)) permissions = cursor.fetchall() @@ -121,38 +526,45 @@ async def update_role_permissions( cursor = connection.cursor(dictionary=True) # 检查角色是否存在 - cursor.execute("SELECT role_id FROM roles WHERE role_id = %s", (role_id,)) + cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,)) if not cursor.fetchone(): return create_api_response(code="404", message="角色不存在") + cursor.execute( + """ + SELECT menu_id, parent_id + FROM sys_menus + WHERE is_active = 1 + """ + ) + all_menus = cursor.fetchall() + menu_id_set = {menu["menu_id"] for menu in all_menus} + # 验证所有menu_id是否有效 - if request.menu_ids: - format_strings = ','.join(['%s'] * len(request.menu_ids)) - cursor.execute( - f"SELECT COUNT(*) as count FROM menus WHERE menu_id IN ({format_strings})", - tuple(request.menu_ids) - ) - valid_count = cursor.fetchone()['count'] - if valid_count != len(request.menu_ids): - return create_api_response(code="400", message="包含无效的菜单ID") + invalid_menu_ids = [menu_id for menu_id in request.menu_ids if menu_id not in menu_id_set] + if invalid_menu_ids: + return create_api_response(code="400", message="包含无效的菜单ID") + + normalized_menu_ids = _normalize_permission_menu_ids(request.menu_ids, all_menus) # 删除该角色的所有现有权限 - cursor.execute("DELETE FROM role_menu_permissions WHERE role_id = %s", (role_id,)) + cursor.execute("DELETE FROM sys_role_menu_permissions WHERE role_id = %s", (role_id,)) # 插入新的权限 - if request.menu_ids: - insert_values = [(role_id, menu_id) for menu_id in request.menu_ids] + if normalized_menu_ids: + insert_values = [(role_id, menu_id) for menu_id in normalized_menu_ids] cursor.executemany( - "INSERT INTO role_menu_permissions (role_id, menu_id) VALUES (%s, %s)", + "INSERT INTO sys_role_menu_permissions (role_id, menu_id) VALUES (%s, %s)", insert_values ) connection.commit() + _invalidate_user_menu_cache(role_id) return create_api_response( code="200", message="更新角色权限成功", - data={"role_id": role_id, "menu_count": len(request.menu_ids)} + data={"role_id": role_id, "menu_count": len(normalized_menu_ids)} ) except Exception as e: return create_api_response(code="500", message=f"更新角色权限失败: {str(e)}") @@ -164,21 +576,68 @@ async def get_user_menus(current_user=Depends(get_current_user)): 所有登录用户都可以访问 """ try: + role_id = current_user["role_id"] + cached_menus = _get_cached_user_menus(role_id) + if cached_menus is not None: + return create_api_response( + code="200", + message="获取用户菜单成功", + data={"menus": cached_menus} + ) + with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) # 根据用户的role_id查询可访问的菜单 query = """ - SELECT DISTINCT m.menu_id, m.menu_code, m.menu_name, m.menu_icon, - m.menu_url, m.menu_type, m.sort_order - FROM menus m - JOIN role_menu_permissions rmp ON m.menu_id = rmp.menu_id - WHERE rmp.role_id = %s AND m.is_active = 1 - ORDER BY m.sort_order ASC + SELECT m.menu_id, m.menu_code, m.menu_name, m.menu_icon, + m.menu_url, m.menu_type, m.parent_id, m.sort_order + FROM sys_menus m + JOIN sys_role_menu_permissions rmp ON m.menu_id = rmp.menu_id + WHERE rmp.role_id = %s + AND m.is_active = 1 + AND (m.is_visible = 1 OR m.is_visible IS NULL OR m.menu_code IN ('dashboard', 'desktop')) + ORDER BY + COALESCE(m.parent_id, m.menu_id) ASC, + CASE WHEN m.parent_id IS NULL THEN 0 ELSE 1 END ASC, + m.sort_order ASC, + m.menu_id ASC """ - cursor.execute(query, (current_user['role_id'],)) + cursor.execute(query, (role_id,)) menus = cursor.fetchall() + # 仅在缺失父菜单时补查,减少不必要的SQL + current_menu_ids = {menu["menu_id"] for menu in menus} + missing_parent_ids = { + menu["parent_id"] for menu in menus + if menu.get("parent_id") is not None and menu["parent_id"] not in current_menu_ids + } + + if missing_parent_ids: + format_strings = ",".join(["%s"] * len(missing_parent_ids)) + cursor.execute( + f""" + SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order + FROM sys_menus + WHERE is_active = 1 AND menu_id IN ({format_strings}) + """, + tuple(missing_parent_ids), + ) + parent_rows = cursor.fetchall() + menus.extend(parent_rows) + current_menu_ids.update(row["menu_id"] for row in parent_rows) + + menus = sorted( + {menu["menu_id"]: menu for menu in menus}.values(), + key=lambda m: ( + m["parent_id"] if m["parent_id"] is not None else m["menu_id"], + 0 if m["parent_id"] is None else 1, + m["sort_order"], + m["menu_id"], + ), + ) + _set_cached_user_menus(role_id, menus) + return create_api_response( code="200", message="获取用户菜单成功", @@ -186,4 +645,3 @@ async def get_user_menus(current_user=Depends(get_current_user)): ) except Exception as e: return create_api_response(code="500", message=f"获取用户菜单失败: {str(e)}") - diff --git a/backend/app/api/endpoints/admin_dashboard.py b/backend/app/api/endpoints/admin_dashboard.py index a0129f4..f0f62e2 100644 --- a/backend/app/api/endpoints/admin_dashboard.py +++ b/backend/app/api/endpoints/admin_dashboard.py @@ -47,6 +47,8 @@ def _get_online_user_count(redis_client) -> int: token_keys = redis_client.keys("token:*") user_ids = set() for key in token_keys: + if isinstance(key, bytes): + key = key.decode("utf-8", errors="ignore") parts = key.split(':') if len(parts) >= 2: user_ids.add(parts[1]) @@ -56,6 +58,18 @@ def _get_online_user_count(redis_client) -> int: return 0 +def _table_exists(cursor, table_name: str) -> bool: + cursor.execute( + """ + SELECT COUNT(*) AS cnt + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = %s + """, + (table_name,), + ) + return (cursor.fetchone() or {}).get("cnt", 0) > 0 + + def _calculate_audio_storage() -> Dict[str, float]: """计算音频文件存储统计""" audio_files_count = 0 @@ -90,42 +104,57 @@ async def get_dashboard_stats(current_user=Depends(get_current_admin_user)): # 1. 用户统计 today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + total_users = 0 + today_new_users = 0 - cursor.execute("SELECT COUNT(*) as total FROM users") - total_users = cursor.fetchone()['total'] + if _table_exists(cursor, "sys_users"): + cursor.execute("SELECT COUNT(*) as total FROM sys_users") + total_users = (cursor.fetchone() or {}).get("total", 0) - cursor.execute( - "SELECT COUNT(*) as count FROM users WHERE created_at >= %s", - (today_start,) - ) - today_new_users = cursor.fetchone()['count'] + cursor.execute( + "SELECT COUNT(*) as count FROM sys_users WHERE created_at >= %s", + (today_start,), + ) + today_new_users = (cursor.fetchone() or {}).get("count", 0) online_users = _get_online_user_count(redis_client) # 2. 会议统计 - cursor.execute("SELECT COUNT(*) as total FROM meetings") - total_meetings = cursor.fetchone()['total'] + total_meetings = 0 + today_new_meetings = 0 + if _table_exists(cursor, "meetings"): + cursor.execute("SELECT COUNT(*) as total FROM meetings") + total_meetings = (cursor.fetchone() or {}).get("total", 0) - cursor.execute( - "SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s", - (today_start,) - ) - today_new_meetings = cursor.fetchone()['count'] + cursor.execute( + "SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s", + (today_start,), + ) + today_new_meetings = (cursor.fetchone() or {}).get("count", 0) # 3. 任务统计 task_stats_query = _get_task_stats_query() # 转录任务 - cursor.execute(f"{task_stats_query} FROM transcript_tasks") - transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + if _table_exists(cursor, "transcript_tasks"): + cursor.execute(f"{task_stats_query} FROM transcript_tasks") + transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + else: + transcription_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} # 总结任务 - cursor.execute(f"{task_stats_query} FROM llm_tasks") - summary_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + if _table_exists(cursor, "llm_tasks"): + cursor.execute(f"{task_stats_query} FROM llm_tasks") + summary_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + else: + summary_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} # 知识库任务 - cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks") - kb_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + if _table_exists(cursor, "knowledge_base_tasks"): + cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks") + kb_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + else: + kb_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} # 4. 音频存储统计 storage_stats = _calculate_audio_storage() @@ -180,6 +209,8 @@ async def get_online_users(current_user=Depends(get_current_admin_user)): # 提取用户ID并去重 user_tokens = {} for key in token_keys: + if isinstance(key, bytes): + key = key.decode("utf-8", errors="ignore") parts = key.split(':') if len(parts) >= 3: user_id = int(parts[1]) @@ -195,7 +226,7 @@ async def get_online_users(current_user=Depends(get_current_admin_user)): online_users_list = [] for user_id, tokens in user_tokens.items(): cursor.execute( - "SELECT user_id, username, caption, email, role_id FROM users WHERE user_id = %s", + "SELECT user_id, username, caption, email, role_id FROM sys_users WHERE user_id = %s", (user_id,) ) user = cursor.fetchone() @@ -275,7 +306,7 @@ async def monitor_tasks( u.username as creator_name FROM transcript_tasks t LEFT JOIN meetings m ON t.meeting_id = m.meeting_id - LEFT JOIN users u ON m.user_id = u.user_id + LEFT JOIN sys_users u ON m.user_id = u.user_id WHERE 1=1 {status_condition} ORDER BY t.created_at DESC LIMIT %s @@ -292,14 +323,14 @@ async def monitor_tasks( t.meeting_id, m.title as meeting_title, t.status, - NULL as progress, + t.progress, t.error_message, t.created_at, t.completed_at, u.username as creator_name FROM llm_tasks t LEFT JOIN meetings m ON t.meeting_id = m.meeting_id - LEFT JOIN users u ON m.user_id = u.user_id + LEFT JOIN sys_users u ON m.user_id = u.user_id WHERE 1=1 {status_condition} ORDER BY t.created_at DESC LIMIT %s @@ -323,7 +354,7 @@ async def monitor_tasks( u.username as creator_name FROM knowledge_base_tasks t LEFT JOIN knowledge_bases k ON t.kb_id = k.kb_id - LEFT JOIN users u ON k.creator_id = u.user_id + LEFT JOIN sys_users u ON k.creator_id = u.user_id WHERE 1=1 {status_condition} ORDER BY t.created_at DESC LIMIT %s @@ -416,7 +447,7 @@ async def get_user_stats(current_user=Depends(get_current_admin_user)): WHERE user_id = u.user_id AND action_type = 'login') as last_login_time, COUNT(DISTINCT m.meeting_id) as meeting_count, COALESCE(SUM(af.duration), 0) as total_duration_seconds - FROM users u + FROM sys_users u INNER JOIN meetings m ON u.user_id = m.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id GROUP BY u.user_id, u.username, u.caption, u.created_at diff --git a/backend/app/api/endpoints/admin_settings.py b/backend/app/api/endpoints/admin_settings.py new file mode 100644 index 0000000..013c69a --- /dev/null +++ b/backend/app/api/endpoints/admin_settings.py @@ -0,0 +1,835 @@ +import json +from typing import Any + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel + +from app.core.auth import get_current_admin_user +from app.core.database import get_db_connection +from app.core.response import create_api_response +from app.services.async_transcription_service import AsyncTranscriptionService +from app.services.llm_service import LLMService + +router = APIRouter() +llm_service = LLMService() +transcription_service = AsyncTranscriptionService() + + +def _parse_json_object(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return dict(value) + if isinstance(value, str): + value = value.strip() + if not value: + return {} + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except json.JSONDecodeError: + return {} + return {} + + +def _normalize_string_list(value: Any) -> list[str] | None: + if value is None: + return None + if isinstance(value, list): + values = [str(item).strip() for item in value if str(item).strip()] + return values or None + if isinstance(value, str): + values = [item.strip() for item in value.split(",") if item.strip()] + return values or None + return None + + +def _normalize_int_list(value: Any) -> list[int] | None: + if value is None: + return None + if isinstance(value, list): + items = value + elif isinstance(value, str): + items = [item.strip() for item in value.split(",") if item.strip()] + else: + return None + + normalized = [] + for item in items: + try: + normalized.append(int(item)) + except (TypeError, ValueError): + continue + return normalized or None + + +def _clean_extra_config(config: dict[str, Any]) -> dict[str, Any]: + cleaned: dict[str, Any] = {} + for key, value in (config or {}).items(): + if value is None: + continue + if isinstance(value, str): + stripped = value.strip() + if stripped: + cleaned[key] = stripped + continue + if isinstance(value, list): + normalized_list = [] + for item in value: + if item is None: + continue + if isinstance(item, str): + stripped = item.strip() + if stripped: + normalized_list.append(stripped) + else: + normalized_list.append(item) + if normalized_list: + cleaned[key] = normalized_list + continue + cleaned[key] = value + return cleaned + + +def _merge_audio_extra_config( + request: "AudioModelUpsertRequest", + vocabulary_id: str | None = None, +) -> dict[str, Any]: + extra_config = _parse_json_object(request.extra_config) + + if request.audio_scene == "asr": + legacy_config = { + "model": request.asr_model_name, + "speaker_count": request.asr_speaker_count, + "language_hints": request.asr_language_hints, + "disfluency_removal_enabled": request.asr_disfluency_removal_enabled, + "diarization_enabled": request.asr_diarization_enabled, + } + else: + legacy_config = { + "model": request.model_name, + "template_text": request.vp_template_text, + "duration_seconds": request.vp_duration_seconds, + "sample_rate": request.vp_sample_rate, + "channels": request.vp_channels, + "max_size_bytes": request.vp_max_size_bytes, + } + + merged = {**legacy_config, **extra_config} + + language_hints = _normalize_string_list(merged.get("language_hints")) + if language_hints is not None: + merged["language_hints"] = language_hints + + channel_id = _normalize_int_list(merged.get("channel_id")) + if channel_id is not None: + merged["channel_id"] = channel_id + + resolved_vocabulary_id = vocabulary_id or merged.get("vocabulary_id") or request.asr_vocabulary_id + if request.audio_scene == "asr" and resolved_vocabulary_id: + merged["vocabulary_id"] = resolved_vocabulary_id + + return _clean_extra_config(merged) + + +def _extract_legacy_audio_columns(audio_scene: str, extra_config: dict[str, Any]) -> dict[str, Any]: + extra_config = _parse_json_object(extra_config) + columns = { + "asr_model_name": None, + "asr_vocabulary_id": None, + "asr_speaker_count": None, + "asr_language_hints": None, + "asr_disfluency_removal_enabled": None, + "asr_diarization_enabled": None, + "vp_template_text": None, + "vp_duration_seconds": None, + "vp_sample_rate": None, + "vp_channels": None, + "vp_max_size_bytes": None, + } + + if audio_scene == "asr": + language_hints = extra_config.get("language_hints") + if isinstance(language_hints, list): + language_hints = ",".join(str(item).strip() for item in language_hints if str(item).strip()) + columns.update( + { + "asr_model_name": extra_config.get("model"), + "asr_vocabulary_id": extra_config.get("vocabulary_id"), + "asr_speaker_count": extra_config.get("speaker_count"), + "asr_language_hints": language_hints, + "asr_disfluency_removal_enabled": 1 if extra_config.get("disfluency_removal_enabled") is True else 0 if extra_config.get("disfluency_removal_enabled") is False else None, + "asr_diarization_enabled": 1 if extra_config.get("diarization_enabled") is True else 0 if extra_config.get("diarization_enabled") is False else None, + } + ) + else: + columns.update( + { + "vp_template_text": extra_config.get("template_text"), + "vp_duration_seconds": extra_config.get("duration_seconds"), + "vp_sample_rate": extra_config.get("sample_rate"), + "vp_channels": extra_config.get("channels"), + "vp_max_size_bytes": extra_config.get("max_size_bytes"), + } + ) + + return columns + + +def _normalize_audio_row(row: dict[str, Any]) -> dict[str, Any]: + extra_config = _parse_json_object(row.get("extra_config")) + + if row.get("audio_scene") == "asr": + if extra_config.get("model") is None and row.get("asr_model_name") is not None: + extra_config["model"] = row["asr_model_name"] + if extra_config.get("vocabulary_id") is None and row.get("asr_vocabulary_id") is not None: + extra_config["vocabulary_id"] = row["asr_vocabulary_id"] + if extra_config.get("speaker_count") is None and row.get("asr_speaker_count") is not None: + extra_config["speaker_count"] = row["asr_speaker_count"] + if extra_config.get("language_hints") is None and row.get("asr_language_hints"): + extra_config["language_hints"] = _normalize_string_list(row["asr_language_hints"]) + if extra_config.get("disfluency_removal_enabled") is None and row.get("asr_disfluency_removal_enabled") is not None: + extra_config["disfluency_removal_enabled"] = bool(row["asr_disfluency_removal_enabled"]) + if extra_config.get("diarization_enabled") is None and row.get("asr_diarization_enabled") is not None: + extra_config["diarization_enabled"] = bool(row["asr_diarization_enabled"]) + else: + if extra_config.get("model") is None and row.get("model_name"): + extra_config["model"] = row["model_name"] + if extra_config.get("template_text") is None and row.get("vp_template_text") is not None: + extra_config["template_text"] = row["vp_template_text"] + if extra_config.get("duration_seconds") is None and row.get("vp_duration_seconds") is not None: + extra_config["duration_seconds"] = row["vp_duration_seconds"] + if extra_config.get("sample_rate") is None and row.get("vp_sample_rate") is not None: + extra_config["sample_rate"] = row["vp_sample_rate"] + if extra_config.get("channels") is None and row.get("vp_channels") is not None: + extra_config["channels"] = row["vp_channels"] + if extra_config.get("max_size_bytes") is None and row.get("vp_max_size_bytes") is not None: + extra_config["max_size_bytes"] = row["vp_max_size_bytes"] + + row["extra_config"] = extra_config + row["service_model_name"] = extra_config.get("model") + return row + + +class ParameterUpsertRequest(BaseModel): + param_key: str + param_name: str + param_value: str + value_type: str = "string" + category: str = "system" + description: str | None = None + is_active: bool = True + + +class LLMModelUpsertRequest(BaseModel): + model_code: str + model_name: str + provider: str | None = None + endpoint_url: str | None = None + api_key: str | None = None + llm_model_name: str + llm_timeout: int = 120 + llm_temperature: float = 0.7 + llm_top_p: float = 0.9 + llm_max_tokens: int = 2048 + llm_system_prompt: str | None = None + description: str | None = None + is_active: bool = True + is_default: bool = False + + +class AudioModelUpsertRequest(BaseModel): + model_code: str + model_name: str + audio_scene: str # asr / voiceprint + provider: str | None = None + endpoint_url: str | None = None + api_key: str | None = None + extra_config: dict[str, Any] | None = None + asr_model_name: str | None = None + asr_vocabulary_id: str | None = None + hot_word_group_id: int | None = None + asr_speaker_count: int | None = None + asr_language_hints: str | None = None + asr_disfluency_removal_enabled: bool | None = None + asr_diarization_enabled: bool | None = None + vp_template_text: str | None = None + vp_duration_seconds: int | None = None + vp_sample_rate: int | None = None + vp_channels: int | None = None + vp_max_size_bytes: int | None = None + description: str | None = None + is_active: bool = True + is_default: bool = False + + +class LLMModelTestRequest(LLMModelUpsertRequest): + test_prompt: str | None = None + + +class AudioModelTestRequest(AudioModelUpsertRequest): + test_file_url: str | None = None + + +@router.get("/admin/parameters") +async def list_parameters( + category: str | None = Query(None), + keyword: str | None = Query(None), + current_user=Depends(get_current_admin_user), +): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + query = """ + SELECT param_id, param_key, param_name, param_value, value_type, category, + description, is_active, created_at, updated_at + FROM sys_system_parameters + WHERE 1=1 + """ + params = [] + if category: + query += " AND category = %s" + params.append(category) + if keyword: + like_pattern = f"%{keyword}%" + query += " AND (param_key LIKE %s OR param_name LIKE %s)" + params.extend([like_pattern, like_pattern]) + + query += " ORDER BY category ASC, param_key ASC" + cursor.execute(query, tuple(params)) + rows = cursor.fetchall() + return create_api_response( + code="200", + message="获取参数列表成功", + data={"items": rows, "total": len(rows)}, + ) + except Exception as e: + return create_api_response(code="500", message=f"获取参数列表失败: {str(e)}") + + +@router.get("/admin/parameters/{param_key}") +async def get_parameter(param_key: str, current_user=Depends(get_current_admin_user)): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT param_id, param_key, param_name, param_value, value_type, category, + description, is_active, created_at, updated_at + FROM sys_system_parameters + WHERE param_key = %s + LIMIT 1 + """, + (param_key,), + ) + row = cursor.fetchone() + if not row: + return create_api_response(code="404", message="参数不存在") + return create_api_response(code="200", message="获取参数成功", data=row) + except Exception as e: + return create_api_response(code="500", message=f"获取参数失败: {str(e)}") + + +@router.post("/admin/parameters") +async def create_parameter(request: ParameterUpsertRequest, current_user=Depends(get_current_admin_user)): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (request.param_key,)) + if cursor.fetchone(): + return create_api_response(code="400", message="参数键已存在") + + cursor.execute( + """ + INSERT INTO sys_system_parameters + (param_key, param_name, param_value, value_type, category, description, is_active) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + request.param_key, + request.param_name, + request.param_value, + request.value_type, + request.category, + request.description, + 1 if request.is_active else 0, + ), + ) + conn.commit() + return create_api_response(code="200", message="创建参数成功") + except Exception as e: + return create_api_response(code="500", message=f"创建参数失败: {str(e)}") + + +@router.put("/admin/parameters/{param_key}") +async def update_parameter( + param_key: str, + request: ParameterUpsertRequest, + current_user=Depends(get_current_admin_user), +): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,)) + existed = cursor.fetchone() + if not existed: + return create_api_response(code="404", message="参数不存在") + + new_key = request.param_key or param_key + if new_key != param_key: + cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (new_key,)) + if cursor.fetchone(): + return create_api_response(code="400", message="新的参数键已存在") + + cursor.execute( + """ + UPDATE sys_system_parameters + SET param_key = %s, param_name = %s, param_value = %s, value_type = %s, + category = %s, description = %s, is_active = %s + WHERE param_key = %s + """, + ( + new_key, + request.param_name, + request.param_value, + request.value_type, + request.category, + request.description, + 1 if request.is_active else 0, + param_key, + ), + ) + conn.commit() + return create_api_response(code="200", message="更新参数成功") + except Exception as e: + return create_api_response(code="500", message=f"更新参数失败: {str(e)}") + + +@router.delete("/admin/parameters/{param_key}") +async def delete_parameter(param_key: str, current_user=Depends(get_current_admin_user)): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,)) + existed = cursor.fetchone() + if not existed: + return create_api_response(code="404", message="参数不存在") + + cursor.execute("DELETE FROM sys_system_parameters WHERE param_key = %s", (param_key,)) + conn.commit() + return create_api_response(code="200", message="删除参数成功") + except Exception as e: + return create_api_response(code="500", message=f"删除参数失败: {str(e)}") + + +@router.get("/admin/model-configs/llm") +async def list_llm_model_configs(current_user=Depends(get_current_admin_user)): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT config_id, model_code, model_name, provider, endpoint_url, api_key, + llm_model_name, llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, + llm_system_prompt, description, is_active, is_default, created_at, updated_at + FROM llm_model_config + ORDER BY model_code ASC + """ + ) + rows = cursor.fetchall() + return create_api_response( + code="200", + message="获取LLM模型配置成功", + data={"items": rows, "total": len(rows)}, + ) + except Exception as e: + return create_api_response(code="500", message=f"获取LLM模型配置失败: {str(e)}") + + +@router.post("/admin/model-configs/llm") +async def create_llm_model_config(request: LLMModelUpsertRequest, current_user=Depends(get_current_admin_user)): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (request.model_code,)) + if cursor.fetchone(): + return create_api_response(code="400", message="模型编码已存在") + + cursor.execute("SELECT COUNT(*) AS total FROM llm_model_config") + total_row = cursor.fetchone() or {"total": 0} + is_default = bool(request.is_default) or total_row["total"] == 0 + if is_default: + cursor.execute("UPDATE llm_model_config SET is_default = 0 WHERE is_default = 1") + + cursor.execute( + """ + INSERT INTO llm_model_config + (model_code, model_name, provider, endpoint_url, api_key, llm_model_name, + llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt, + description, is_active, is_default) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + request.model_code, + request.model_name, + request.provider, + request.endpoint_url, + request.api_key, + request.llm_model_name, + request.llm_timeout, + request.llm_temperature, + request.llm_top_p, + request.llm_max_tokens, + request.llm_system_prompt, + request.description, + 1 if request.is_active else 0, + 1 if is_default else 0, + ), + ) + conn.commit() + return create_api_response(code="200", message="创建LLM模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"创建LLM模型配置失败: {str(e)}") + + +@router.put("/admin/model-configs/llm/{model_code}") +async def update_llm_model_config( + model_code: str, + request: LLMModelUpsertRequest, + current_user=Depends(get_current_admin_user), +): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,)) + existed = cursor.fetchone() + if not existed: + return create_api_response(code="404", message="模型配置不存在") + + new_model_code = request.model_code or model_code + if new_model_code != model_code: + cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (new_model_code,)) + duplicate_row = cursor.fetchone() + if duplicate_row and duplicate_row["config_id"] != existed["config_id"]: + return create_api_response(code="400", message="新的模型编码已存在") + + if request.is_default: + cursor.execute("UPDATE llm_model_config SET is_default = 0 WHERE model_code <> %s AND is_default = 1", (model_code,)) + + cursor.execute( + """ + UPDATE llm_model_config + SET model_code = %s, model_name = %s, provider = %s, endpoint_url = %s, api_key = %s, + llm_model_name = %s, llm_timeout = %s, llm_temperature = %s, llm_top_p = %s, + llm_max_tokens = %s, llm_system_prompt = %s, description = %s, is_active = %s, is_default = %s + WHERE model_code = %s + """, + ( + new_model_code, + request.model_name, + request.provider, + request.endpoint_url, + request.api_key, + request.llm_model_name, + request.llm_timeout, + request.llm_temperature, + request.llm_top_p, + request.llm_max_tokens, + request.llm_system_prompt, + request.description, + 1 if request.is_active else 0, + 1 if request.is_default else 0, + model_code, + ), + ) + conn.commit() + return create_api_response(code="200", message="更新LLM模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"更新LLM模型配置失败: {str(e)}") + + +@router.get("/admin/model-configs/audio") +async def list_audio_model_configs( + scene: str = Query("all"), + current_user=Depends(get_current_admin_user), +): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + sql = """ + SELECT a.config_id, a.model_code, a.model_name, a.audio_scene, a.provider, a.endpoint_url, a.api_key, + a.asr_model_name, a.asr_vocabulary_id, a.hot_word_group_id, a.asr_speaker_count, a.asr_language_hints, + a.asr_disfluency_removal_enabled, a.asr_diarization_enabled, + a.vp_template_text, a.vp_duration_seconds, a.vp_sample_rate, a.vp_channels, a.vp_max_size_bytes, + a.extra_config, a.description, a.is_active, a.is_default, a.created_at, a.updated_at, + g.name AS hot_word_group_name, g.vocabulary_id AS hot_word_group_vocab_id + FROM audio_model_config a + LEFT JOIN hot_word_group g ON g.id = a.hot_word_group_id + """ + params = [] + if scene in ("asr", "voiceprint"): + sql += " WHERE a.audio_scene = %s" + params.append(scene) + sql += " ORDER BY a.audio_scene ASC, a.model_code ASC" + cursor.execute(sql, tuple(params)) + rows = [_normalize_audio_row(row) for row in cursor.fetchall()] + return create_api_response(code="200", message="获取音频模型配置成功", data={"items": rows, "total": len(rows)}) + except Exception as e: + return create_api_response(code="500", message=f"获取音频模型配置失败: {str(e)}") + + +@router.post("/admin/model-configs/audio") +async def create_audio_model_config(request: AudioModelUpsertRequest, current_user=Depends(get_current_admin_user)): + try: + if request.audio_scene not in ("asr", "voiceprint"): + return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint") + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (request.model_code,)) + if cursor.fetchone(): + return create_api_response(code="400", message="模型编码已存在") + + cursor.execute("SELECT COUNT(*) AS total FROM audio_model_config WHERE audio_scene = %s", (request.audio_scene,)) + total_row = cursor.fetchone() or {"total": 0} + is_default = bool(request.is_default) or total_row["total"] == 0 + if is_default: + cursor.execute("UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND is_default = 1", (request.audio_scene,)) + + # 如果指定了热词组,从组中获取 vocabulary_id + asr_vocabulary_id = request.asr_vocabulary_id + if request.hot_word_group_id: + cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,)) + group_row = cursor.fetchone() + if group_row and group_row.get("vocabulary_id"): + asr_vocabulary_id = group_row["vocabulary_id"] + extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id) + legacy_columns = _extract_legacy_audio_columns(request.audio_scene, extra_config) + + cursor.execute( + """ + INSERT INTO audio_model_config + (model_code, model_name, audio_scene, provider, endpoint_url, api_key, + asr_model_name, asr_vocabulary_id, hot_word_group_id, asr_speaker_count, asr_language_hints, + asr_disfluency_removal_enabled, asr_diarization_enabled, + vp_template_text, vp_duration_seconds, vp_sample_rate, vp_channels, vp_max_size_bytes, + extra_config, description, is_active, is_default) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + request.model_code, + request.model_name, + request.audio_scene, + request.provider, + request.endpoint_url, + request.api_key, + legacy_columns["asr_model_name"], + legacy_columns["asr_vocabulary_id"], + request.hot_word_group_id, + legacy_columns["asr_speaker_count"], + legacy_columns["asr_language_hints"], + legacy_columns["asr_disfluency_removal_enabled"], + legacy_columns["asr_diarization_enabled"], + legacy_columns["vp_template_text"], + legacy_columns["vp_duration_seconds"], + legacy_columns["vp_sample_rate"], + legacy_columns["vp_channels"], + legacy_columns["vp_max_size_bytes"], + json.dumps(extra_config, ensure_ascii=False), + request.description, + 1 if request.is_active else 0, + 1 if is_default else 0, + ), + ) + conn.commit() + return create_api_response(code="200", message="创建音频模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"创建音频模型配置失败: {str(e)}") + + +@router.put("/admin/model-configs/audio/{model_code}") +async def update_audio_model_config( + model_code: str, + request: AudioModelUpsertRequest, + current_user=Depends(get_current_admin_user), +): + try: + if request.audio_scene not in ("asr", "voiceprint"): + return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint") + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,)) + existed = cursor.fetchone() + if not existed: + return create_api_response(code="404", message="模型配置不存在") + + new_model_code = request.model_code or model_code + if new_model_code != model_code: + cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (new_model_code,)) + duplicate_row = cursor.fetchone() + if duplicate_row and duplicate_row["config_id"] != existed["config_id"]: + return create_api_response(code="400", message="新的模型编码已存在") + + if request.is_default: + cursor.execute( + "UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND model_code <> %s AND is_default = 1", + (request.audio_scene, model_code), + ) + + # 如果指定了热词组,从组中获取 vocabulary_id + asr_vocabulary_id = request.asr_vocabulary_id + if request.hot_word_group_id: + cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,)) + group_row = cursor.fetchone() + if group_row and group_row.get("vocabulary_id"): + asr_vocabulary_id = group_row["vocabulary_id"] + extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id) + legacy_columns = _extract_legacy_audio_columns(request.audio_scene, extra_config) + + cursor.execute( + """ + UPDATE audio_model_config + SET model_code = %s, model_name = %s, audio_scene = %s, provider = %s, endpoint_url = %s, api_key = %s, + asr_model_name = %s, asr_vocabulary_id = %s, hot_word_group_id = %s, asr_speaker_count = %s, asr_language_hints = %s, + asr_disfluency_removal_enabled = %s, asr_diarization_enabled = %s, + vp_template_text = %s, vp_duration_seconds = %s, vp_sample_rate = %s, vp_channels = %s, vp_max_size_bytes = %s, + extra_config = %s, description = %s, is_active = %s, is_default = %s + WHERE model_code = %s + """, + ( + new_model_code, + request.model_name, + request.audio_scene, + request.provider, + request.endpoint_url, + request.api_key, + legacy_columns["asr_model_name"], + legacy_columns["asr_vocabulary_id"], + request.hot_word_group_id, + legacy_columns["asr_speaker_count"], + legacy_columns["asr_language_hints"], + legacy_columns["asr_disfluency_removal_enabled"], + legacy_columns["asr_diarization_enabled"], + legacy_columns["vp_template_text"], + legacy_columns["vp_duration_seconds"], + legacy_columns["vp_sample_rate"], + legacy_columns["vp_channels"], + legacy_columns["vp_max_size_bytes"], + json.dumps(extra_config, ensure_ascii=False), + request.description, + 1 if request.is_active else 0, + 1 if request.is_default else 0, + model_code, + ), + ) + conn.commit() + return create_api_response(code="200", message="更新音频模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"更新音频模型配置失败: {str(e)}") + + +@router.delete("/admin/model-configs/llm/{model_code}") +async def delete_llm_model_config(model_code: str, current_user=Depends(get_current_admin_user)): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="模型配置不存在") + + cursor.execute("DELETE FROM llm_model_config WHERE model_code = %s", (model_code,)) + conn.commit() + return create_api_response(code="200", message="删除LLM模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"删除LLM模型配置失败: {str(e)}") + + +@router.delete("/admin/model-configs/audio/{model_code}") +async def delete_audio_model_config(model_code: str, current_user=Depends(get_current_admin_user)): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="模型配置不存在") + + cursor.execute("DELETE FROM audio_model_config WHERE model_code = %s", (model_code,)) + conn.commit() + return create_api_response(code="200", message="删除音频模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"删除音频模型配置失败: {str(e)}") + + +@router.post("/admin/model-configs/llm/test") +async def test_llm_model_config(request: LLMModelTestRequest, current_user=Depends(get_current_admin_user)): + try: + payload = request.model_dump() if hasattr(request, "model_dump") else request.dict() + result = llm_service.test_model(payload, prompt=request.test_prompt) + return create_api_response(code="200", message="LLM模型测试成功", data=result) + except Exception as e: + return create_api_response(code="500", message=f"LLM模型测试失败: {str(e)}") + + +@router.post("/admin/model-configs/audio/test") +async def test_audio_model_config(request: AudioModelTestRequest, current_user=Depends(get_current_admin_user)): + try: + if request.audio_scene != "asr": + return create_api_response(code="400", message="当前仅支持音频识别(ASR)测试") + + vocabulary_id = request.asr_vocabulary_id + if request.hot_word_group_id: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,)) + group_row = cursor.fetchone() + cursor.close() + if group_row and group_row.get("vocabulary_id"): + vocabulary_id = group_row["vocabulary_id"] + + extra_config = _merge_audio_extra_config(request, vocabulary_id=vocabulary_id) + runtime_config = { + "provider": request.provider, + "endpoint_url": request.endpoint_url, + "api_key": request.api_key, + "audio_scene": request.audio_scene, + "hot_word_group_id": request.hot_word_group_id, + **extra_config, + } + result = transcription_service.test_asr_model(runtime_config, test_file_url=request.test_file_url) + return create_api_response(code="200", message="音频模型测试任务已提交", data=result) + except Exception as e: + return create_api_response(code="500", message=f"音频模型测试失败: {str(e)}") + + +@router.get("/admin/system-config") +async def get_system_config_compat(current_user=Depends(get_current_admin_user)): + """兼容旧前端的系统配置接口,数据来源为 sys_system_parameters。""" + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT param_key, param_value + FROM sys_system_parameters + WHERE is_active = 1 + """ + ) + rows = cursor.fetchall() + data = {row["param_key"]: row["param_value"] for row in rows} + + # 兼容旧字段 + if "max_audio_size" in data: + try: + data["MAX_FILE_SIZE"] = int(data["max_audio_size"]) * 1024 * 1024 + except Exception: + data["MAX_FILE_SIZE"] = 100 * 1024 * 1024 + if "max_image_size" in data: + try: + data["MAX_IMAGE_SIZE"] = int(data["max_image_size"]) * 1024 * 1024 + except Exception: + data["MAX_IMAGE_SIZE"] = 10 * 1024 * 1024 + else: + data.setdefault("MAX_IMAGE_SIZE", 10 * 1024 * 1024) + + return create_api_response(code="200", message="获取系统配置成功", data=data) + except Exception as e: + return create_api_response(code="500", message=f"获取系统配置失败: {str(e)}") diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 10c812d..8a6a901 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -7,7 +7,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from app.core.auth import get_current_user from app.core.database import get_db_connection -from app.models.models import LoginRequest, LoginResponse +from app.models.models import LoginRequest, LoginResponse, UserInfo from app.services.jwt_service import jwt_service from app.core.response import create_api_response @@ -23,7 +23,21 @@ def login(request_body: LoginRequest, request: Request): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - query = "SELECT user_id, username, caption, avatar_url, email, password_hash, role_id FROM users WHERE username = %s" + query = """ + SELECT + u.user_id, + u.username, + u.caption, + u.avatar_url, + u.email, + u.password_hash, + u.role_id, + u.created_at, + COALESCE(r.role_name, '普通用户') AS role_name + FROM sys_users u + LEFT JOIN sys_roles r ON r.role_id = u.role_id + WHERE u.username = %s + """ cursor.execute(query, (request_body.username,)) user = cursor.fetchone() @@ -67,19 +81,23 @@ def login(request_body: LoginRequest, request: Request): print(f"Failed to log user login: {e}") login_response_data = LoginResponse( - user_id=user['user_id'], - username=user['username'], - caption=user['caption'], - avatar_url=user['avatar_url'], - email=user['email'], token=token, - role_id=user['role_id'] + user=UserInfo( + user_id=user['user_id'], + username=user['username'], + caption=user['caption'], + email=user.get('email'), + role_id=user['role_id'], + role_name=user.get('role_name') or '普通用户', + avatar_url=user.get('avatar_url'), + created_at=user['created_at'] + ) ) return create_api_response( code="200", message="登录成功", - data=login_response_data.dict() + data=login_response_data.model_dump() ) @router.post("/auth/logout") diff --git a/backend/app/api/endpoints/client_downloads.py b/backend/app/api/endpoints/client_downloads.py index 035300b..79a71a3 100644 --- a/backend/app/api/endpoints/client_downloads.py +++ b/backend/app/api/endpoints/client_downloads.py @@ -22,7 +22,8 @@ async def get_client_downloads( platform_code: Optional[str] = None, is_active: Optional[bool] = None, page: int = 1, - size: int = 50 + size: int = 50, + current_user: dict = Depends(get_current_admin_user) ): """ 获取客户端下载列表(管理后台接口) @@ -102,7 +103,7 @@ async def get_latest_clients(): query = """ SELECT cd.*, dd.label_cn, dd.label_en, dd.parent_code, dd.extension_attr FROM client_downloads cd - LEFT JOIN dict_data dd ON cd.platform_code = dd.dict_code + LEFT JOIN sys_dict_data dd ON cd.platform_code = dd.dict_code AND dd.dict_type = 'client_platform' WHERE cd.is_active = TRUE AND cd.is_latest = TRUE ORDER BY dd.parent_code, dd.sort_order, cd.platform_code diff --git a/backend/app/api/endpoints/dict_data.py b/backend/app/api/endpoints/dict_data.py index bc27185..23e54d5 100644 --- a/backend/app/api/endpoints/dict_data.py +++ b/backend/app/api/endpoints/dict_data.py @@ -60,7 +60,7 @@ async def get_dict_types(): query = """ SELECT DISTINCT dict_type - FROM dict_data + FROM sys_dict_data WHERE status = 1 ORDER BY dict_type """ @@ -99,7 +99,7 @@ async def get_dict_data_by_type(dict_type: str, parent_code: Optional[str] = Non SELECT id, dict_type, dict_code, parent_code, tree_path, label_cn, label_en, sort_order, extension_attr, is_default, status, create_time - FROM dict_data + FROM sys_dict_data WHERE dict_type = %s AND status = 1 """ params = [dict_type] @@ -187,7 +187,7 @@ async def get_dict_data_by_code(dict_type: str, dict_code: str): SELECT id, dict_type, dict_code, parent_code, tree_path, label_cn, label_en, sort_order, extension_attr, is_default, status, create_time, update_time - FROM dict_data + FROM sys_dict_data WHERE dict_type = %s AND dict_code = %s LIMIT 1 """ @@ -246,7 +246,7 @@ async def create_dict_data( # 检查是否已存在 cursor.execute( - "SELECT id FROM dict_data WHERE dict_type = %s AND dict_code = %s", + "SELECT id FROM sys_dict_data WHERE dict_type = %s AND dict_code = %s", (request.dict_type, request.dict_code) ) if cursor.fetchone(): @@ -258,7 +258,7 @@ async def create_dict_data( # 插入数据 query = """ - INSERT INTO dict_data ( + INSERT INTO sys_dict_data ( dict_type, dict_code, parent_code, label_cn, label_en, sort_order, extension_attr, is_default, status ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) @@ -319,7 +319,7 @@ async def update_dict_data( cursor = conn.cursor(dictionary=True) # 检查是否存在 - cursor.execute("SELECT * FROM dict_data WHERE id = %s", (id,)) + cursor.execute("SELECT * FROM sys_dict_data WHERE id = %s", (id,)) existing = cursor.fetchone() if not existing: cursor.close() @@ -369,7 +369,7 @@ async def update_dict_data( # 执行更新 update_query = f""" - UPDATE dict_data + UPDATE sys_dict_data SET {', '.join(update_fields)} WHERE id = %s """ @@ -404,7 +404,7 @@ async def delete_dict_data( cursor = conn.cursor(dictionary=True) # 检查是否存在 - cursor.execute("SELECT dict_code FROM dict_data WHERE id = %s", (id,)) + cursor.execute("SELECT dict_code FROM sys_dict_data WHERE id = %s", (id,)) existing = cursor.fetchone() if not existing: cursor.close() @@ -415,7 +415,7 @@ async def delete_dict_data( # 检查是否有子节点 cursor.execute( - "SELECT COUNT(*) as count FROM dict_data WHERE parent_code = %s", + "SELECT COUNT(*) as count FROM sys_dict_data WHERE parent_code = %s", (existing['dict_code'],) ) if cursor.fetchone()['count'] > 0: @@ -438,7 +438,7 @@ async def delete_dict_data( ) # 执行删除 - cursor.execute("DELETE FROM dict_data WHERE id = %s", (id,)) + cursor.execute("DELETE FROM sys_dict_data WHERE id = %s", (id,)) conn.commit() cursor.close() diff --git a/backend/app/api/endpoints/external_apps.py b/backend/app/api/endpoints/external_apps.py index 259307a..4bde361 100644 --- a/backend/app/api/endpoints/external_apps.py +++ b/backend/app/api/endpoints/external_apps.py @@ -76,7 +76,7 @@ async def get_external_apps( list_query = f""" SELECT ea.*, u.username as creator_username FROM external_apps ea - LEFT JOIN users u ON ea.created_by = u.user_id + LEFT JOIN sys_users u ON ea.created_by = u.user_id WHERE {where_clause} ORDER BY ea.sort_order ASC, ea.created_at DESC """ diff --git a/backend/app/api/endpoints/hot_words.py b/backend/app/api/endpoints/hot_words.py index 3f83cc7..28aef91 100644 --- a/backend/app/api/endpoints/hot_words.py +++ b/backend/app/api/endpoints/hot_words.py @@ -6,7 +6,6 @@ from app.core.config import QWEN_API_KEY from app.services.system_config_service import SystemConfigService from pydantic import BaseModel from typing import Optional, List -import json import dashscope from dashscope.audio.asr import VocabularyService from datetime import datetime @@ -14,48 +13,68 @@ from http import HTTPStatus router = APIRouter() -class HotWordItem(BaseModel): - id: int - text: str - weight: int - lang: str - status: int - create_time: datetime - update_time: datetime -class CreateHotWordRequest(BaseModel): +# ── Request Models ────────────────────────────────────────── + +class CreateGroupRequest(BaseModel): + name: str + description: Optional[str] = None + status: int = 1 + + +class UpdateGroupRequest(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + status: Optional[int] = None + + +class CreateItemRequest(BaseModel): text: str weight: int = 4 lang: str = "zh" status: int = 1 -class UpdateHotWordRequest(BaseModel): + +class UpdateItemRequest(BaseModel): text: Optional[str] = None weight: Optional[int] = None lang: Optional[str] = None status: Optional[int] = None -@router.get("/admin/hot-words", response_model=dict) -async def list_hot_words(current_user: dict = Depends(get_current_admin_user)): - """获取热词列表""" + +# ── Hot-Word Group CRUD ───────────────────────────────────── + +@router.get("/admin/hot-word-groups", response_model=dict) +async def list_groups(current_user: dict = Depends(get_current_admin_user)): + """列表(含每组热词数量统计)""" try: with get_db_connection() as conn: cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT * FROM hot_words ORDER BY update_time DESC") - items = cursor.fetchall() + cursor.execute(""" + SELECT g.*, + COUNT(i.id) AS item_count, + SUM(CASE WHEN i.status = 1 THEN 1 ELSE 0 END) AS enabled_item_count + FROM hot_word_group g + LEFT JOIN hot_word_item i ON i.group_id = g.id + GROUP BY g.id + ORDER BY g.update_time DESC + """) + groups = cursor.fetchall() cursor.close() - return create_api_response(code="200", message="获取成功", data=items) + return create_api_response(code="200", message="获取成功", data=groups) except Exception as e: return create_api_response(code="500", message=f"获取失败: {str(e)}") -@router.post("/admin/hot-words", response_model=dict) -async def create_hot_word(request: CreateHotWordRequest, current_user: dict = Depends(get_current_admin_user)): - """创建热词""" + +@router.post("/admin/hot-word-groups", response_model=dict) +async def create_group(request: CreateGroupRequest, current_user: dict = Depends(get_current_admin_user)): try: with get_db_connection() as conn: cursor = conn.cursor() - query = "INSERT INTO hot_words (text, weight, lang, status) VALUES (%s, %s, %s, %s)" - cursor.execute(query, (request.text, request.weight, request.lang, request.status)) + cursor.execute( + "INSERT INTO hot_word_group (name, description, status) VALUES (%s, %s, %s)", + (request.name, request.description, request.status), + ) new_id = cursor.lastrowid conn.commit() cursor.close() @@ -63,111 +82,209 @@ async def create_hot_word(request: CreateHotWordRequest, current_user: dict = De except Exception as e: return create_api_response(code="500", message=f"创建失败: {str(e)}") -@router.put("/admin/hot-words/{id}", response_model=dict) -async def update_hot_word(id: int, request: UpdateHotWordRequest, current_user: dict = Depends(get_current_admin_user)): - """更新热词""" + +@router.put("/admin/hot-word-groups/{id}", response_model=dict) +async def update_group(id: int, request: UpdateGroupRequest, current_user: dict = Depends(get_current_admin_user)): try: with get_db_connection() as conn: cursor = conn.cursor() - update_fields = [] - params = [] - if request.text is not None: - update_fields.append("text = %s") - params.append(request.text) - if request.weight is not None: - update_fields.append("weight = %s") - params.append(request.weight) - if request.lang is not None: - update_fields.append("lang = %s") - params.append(request.lang) + fields, params = [], [] + if request.name is not None: + fields.append("name = %s"); params.append(request.name) + if request.description is not None: + fields.append("description = %s"); params.append(request.description) if request.status is not None: - update_fields.append("status = %s") - params.append(request.status) - - if not update_fields: + fields.append("status = %s"); params.append(request.status) + if not fields: return create_api_response(code="400", message="无更新内容") - - query = f"UPDATE hot_words SET {', '.join(update_fields)} WHERE id = %s" params.append(id) - cursor.execute(query, tuple(params)) + cursor.execute(f"UPDATE hot_word_group SET {', '.join(fields)} WHERE id = %s", tuple(params)) conn.commit() cursor.close() return create_api_response(code="200", message="更新成功") except Exception as e: return create_api_response(code="500", message=f"更新失败: {str(e)}") -@router.delete("/admin/hot-words/{id}", response_model=dict) -async def delete_hot_word(id: int, current_user: dict = Depends(get_current_admin_user)): - """删除热词""" + +@router.delete("/admin/hot-word-groups/{id}", response_model=dict) +async def delete_group(id: int, current_user: dict = Depends(get_current_admin_user)): + """删除组(级联删除条目),同时清除关联的 audio_model_config""" try: with get_db_connection() as conn: cursor = conn.cursor() - cursor.execute("DELETE FROM hot_words WHERE id = %s", (id,)) + # 清除引用该组的音频模型配置 + cursor.execute( + """ + UPDATE audio_model_config + SET hot_word_group_id = NULL, + asr_vocabulary_id = NULL, + extra_config = JSON_REMOVE(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id') + WHERE hot_word_group_id = %s + """, + (id,), + ) + cursor.execute("DELETE FROM hot_word_item WHERE group_id = %s", (id,)) + cursor.execute("DELETE FROM hot_word_group WHERE id = %s", (id,)) conn.commit() cursor.close() return create_api_response(code="200", message="删除成功") except Exception as e: return create_api_response(code="500", message=f"删除失败: {str(e)}") -@router.post("/admin/hot-words/sync", response_model=dict) -async def sync_hot_words(current_user: dict = Depends(get_current_admin_user)): - """同步热词到阿里云 DashScope""" + +@router.post("/admin/hot-word-groups/{id}/sync", response_model=dict) +async def sync_group(id: int, current_user: dict = Depends(get_current_admin_user)): + """同步指定组到阿里云 DashScope""" try: dashscope.api_key = QWEN_API_KEY - # 1. 获取所有启用的热词 with get_db_connection() as conn: cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT text, weight, lang FROM hot_words WHERE status = 1") - hot_words = cursor.fetchall() - cursor.close() - # 2. 获取现有的 vocabulary_id - existing_vocab_id = SystemConfigService.get_asr_vocabulary_id() + # 获取组信息 + cursor.execute("SELECT * FROM hot_word_group WHERE id = %s", (id,)) + group = cursor.fetchone() + if not group: + return create_api_response(code="404", message="热词组不存在") - # 构建热词列表 - vocabulary_list = [{"text": hw['text'], "weight": hw['weight'], "lang": hw['lang']} for hw in hot_words] + # 获取该组下启用的热词 + cursor.execute( + "SELECT text, weight, lang FROM hot_word_item WHERE group_id = %s AND status = 1", + (id,), + ) + items = cursor.fetchall() + if not items: + return create_api_response(code="400", message="该组没有启用的热词可同步") - if not vocabulary_list: - return create_api_response(code="400", message="没有启用的热词可同步") + vocabulary_list = [{"text": it["text"], "weight": it["weight"], "lang": it["lang"]} for it in items] + + # ASR 模型名(同步时需要) + asr_model_name = SystemConfigService.get_config_attribute('audio_model', 'model', 'paraformer-v2') + existing_vocab_id = group.get("vocabulary_id") - # 3. 调用阿里云 API service = VocabularyService() vocab_id = existing_vocab_id try: if existing_vocab_id: - # 尝试更新现有的热词表 try: service.update_vocabulary( vocabulary_id=existing_vocab_id, - vocabulary=vocabulary_list + vocabulary=vocabulary_list, ) - # 更新成功,保持原有ID - except Exception as update_error: - # 如果更新失败(如资源不存在),尝试创建新的 - print(f"Update vocabulary failed: {update_error}, trying to create new one.") - existing_vocab_id = None # 重置,触发创建逻辑 + except Exception: + existing_vocab_id = None # 更新失败,重建 if not existing_vocab_id: - # 创建新的热词表 vocab_id = service.create_vocabulary( - prefix='imeeting', - target_model='paraformer-v2', - vocabulary=vocabulary_list + prefix="imeeting", + target_model=asr_model_name, + vocabulary=vocabulary_list, ) - except Exception as api_error: - return create_api_response(code="500", message=f"同步到阿里云失败: {str(api_error)}") + return create_api_response(code="500", message=f"同步到阿里云失败: {str(api_error)}") - # 4. 更新数据库中的 vocabulary_id - if vocab_id: - SystemConfigService.set_config( - SystemConfigService.ASR_VOCABULARY_ID, - vocab_id - ) + # 回写 vocabulary_id 到热词组 + cursor.execute( + "UPDATE hot_word_group SET vocabulary_id = %s, last_sync_time = NOW() WHERE id = %s", + (vocab_id, id), + ) - return create_api_response(code="200", message="同步成功", data={"vocabulary_id": vocab_id}) + # 更新关联该组的所有 audio_model_config.asr_vocabulary_id + cursor.execute( + """ + UPDATE audio_model_config + SET asr_vocabulary_id = %s, + extra_config = JSON_SET(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id', %s) + WHERE hot_word_group_id = %s + """, + (vocab_id, vocab_id, id), + ) + + conn.commit() + cursor.close() + return create_api_response( + code="200", + message="同步成功", + data={"vocabulary_id": vocab_id, "synced_count": len(vocabulary_list)}, + ) except Exception as e: return create_api_response(code="500", message=f"同步异常: {str(e)}") + + +# ── Hot-Word Item CRUD ────────────────────────────────────── + +@router.get("/admin/hot-word-groups/{group_id}/items", response_model=dict) +async def list_items(group_id: int, current_user: dict = Depends(get_current_admin_user)): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + "SELECT * FROM hot_word_item WHERE group_id = %s ORDER BY update_time DESC", + (group_id,), + ) + items = cursor.fetchall() + cursor.close() + return create_api_response(code="200", message="获取成功", data=items) + except Exception as e: + return create_api_response(code="500", message=f"获取失败: {str(e)}") + + +@router.post("/admin/hot-word-groups/{group_id}/items", response_model=dict) +async def create_item(group_id: int, request: CreateItemRequest, current_user: dict = Depends(get_current_admin_user)): + try: + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO hot_word_item (group_id, text, weight, lang, status) VALUES (%s, %s, %s, %s, %s)", + (group_id, request.text, request.weight, request.lang, request.status), + ) + new_id = cursor.lastrowid + conn.commit() + cursor.close() + return create_api_response(code="200", message="创建成功", data={"id": new_id}) + except Exception as e: + if "Duplicate entry" in str(e): + return create_api_response(code="400", message="该组内已存在相同热词") + return create_api_response(code="500", message=f"创建失败: {str(e)}") + + +@router.put("/admin/hot-word-items/{id}", response_model=dict) +async def update_item(id: int, request: UpdateItemRequest, current_user: dict = Depends(get_current_admin_user)): + try: + with get_db_connection() as conn: + cursor = conn.cursor() + fields, params = [], [] + if request.text is not None: + fields.append("text = %s"); params.append(request.text) + if request.weight is not None: + fields.append("weight = %s"); params.append(request.weight) + if request.lang is not None: + fields.append("lang = %s"); params.append(request.lang) + if request.status is not None: + fields.append("status = %s"); params.append(request.status) + if not fields: + return create_api_response(code="400", message="无更新内容") + params.append(id) + cursor.execute(f"UPDATE hot_word_item SET {', '.join(fields)} WHERE id = %s", tuple(params)) + conn.commit() + cursor.close() + return create_api_response(code="200", message="更新成功") + except Exception as e: + if "Duplicate entry" in str(e): + return create_api_response(code="400", message="该组内已存在相同热词") + return create_api_response(code="500", message=f"更新失败: {str(e)}") + + +@router.delete("/admin/hot-word-items/{id}", response_model=dict) +async def delete_item(id: int, current_user: dict = Depends(get_current_admin_user)): + try: + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM hot_word_item WHERE id = %s", (id,)) + conn.commit() + cursor.close() + return create_api_response(code="200", message="删除成功") + except Exception as e: + return create_api_response(code="500", message=f"删除失败: {str(e)}") diff --git a/backend/app/api/endpoints/knowledge_base.py b/backend/app/api/endpoints/knowledge_base.py index a198997..9a4d17a 100644 --- a/backend/app/api/endpoints/knowledge_base.py +++ b/backend/app/api/endpoints/knowledge_base.py @@ -41,7 +41,7 @@ def get_knowledge_bases( with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - base_query = "FROM knowledge_bases kb JOIN users u ON kb.creator_id = u.user_id" + base_query = "FROM knowledge_bases kb JOIN sys_users u ON kb.creator_id = u.user_id" where_clauses = [] params = [] @@ -156,7 +156,7 @@ def get_knowledge_base_detail( kb.is_shared, kb.source_meeting_ids, kb.user_prompt, kb.tags, kb.created_at, kb.updated_at, u.username as created_by_name FROM knowledge_bases kb - JOIN users u ON kb.creator_id = u.user_id + JOIN sys_users u ON kb.creator_id = u.user_id WHERE kb.kb_id = %s """ cursor.execute(query, (kb_id,)) diff --git a/backend/app/api/endpoints/meetings.py b/backend/app/api/endpoints/meetings.py index 0164018..a9daf84 100644 --- a/backend/app/api/endpoints/meetings.py +++ b/backend/app/api/endpoints/meetings.py @@ -29,6 +29,7 @@ transcription_service = AsyncTranscriptionService() class GenerateSummaryRequest(BaseModel): user_prompt: Optional[str] = "" prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版 + model_code: Optional[str] = None # LLM模型编码,如果不指定则使用默认模型 def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = None) -> List[Tag]: @@ -198,7 +199,7 @@ def get_meetings( SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, m.access_password, m.user_id as creator_id, u.caption as creator_username, MAX(af.file_path) as audio_file_path FROM meetings m - JOIN users u ON m.user_id = u.user_id + JOIN sys_users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id ''' @@ -238,16 +239,20 @@ def get_meetings( meeting_list = [] for meeting in meetings: - attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' + attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' cursor.execute(attendees_query, (meeting['meeting_id'],)) attendees_data = cursor.fetchall() attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] tags_list = _process_tags(cursor, meeting.get('tags')) + progress_info = _get_meeting_overall_status(meeting['meeting_id']) meeting_list.append(Meeting( meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], summary=meeting['summary'], created_at=meeting['created_at'], audio_file_path=meeting['audio_file_path'], attendees=attendees, creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags_list, - access_password=meeting.get('access_password') + access_password=meeting.get('access_password'), + overall_status=progress_info.get('overall_status'), + overall_progress=progress_info.get('overall_progress'), + current_stage=progress_info.get('current_stage'), )) return create_api_response(code="200", message="获取会议列表成功", data={ @@ -318,7 +323,7 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren af.file_path as audio_file_path, af.duration as audio_duration, p.name as prompt_name, m.access_password FROM meetings m - JOIN users u ON m.user_id = u.user_id + JOIN sys_users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id LEFT JOIN prompts p ON m.prompt_id = p.id WHERE m.meeting_id = %s @@ -327,7 +332,7 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren meeting = cursor.fetchone() if not meeting: return create_api_response(code="404", message="Meeting not found") - attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' + attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' cursor.execute(attendees_query, (meeting['meeting_id'],)) attendees_data = cursor.fetchall() attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] @@ -383,8 +388,14 @@ def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = D meeting_query = 'INSERT INTO meetings (user_id, title, meeting_time, summary, tags, created_at) VALUES (%s, %s, %s, %s, %s, %s)' cursor.execute(meeting_query, (meeting_request.user_id, meeting_request.title, meeting_request.meeting_time, None, meeting_request.tags, datetime.now().isoformat())) meeting_id = cursor.lastrowid - for attendee_id in meeting_request.attendee_ids: - cursor.execute('INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, attendee_id)) + # 根据 caption 查找用户ID并插入参会人 + if meeting_request.attendees: + captions = [c.strip() for c in meeting_request.attendees.split(',') if c.strip()] + if captions: + placeholders = ','.join(['%s'] * len(captions)) + cursor.execute(f'SELECT user_id FROM sys_users WHERE caption IN ({placeholders})', captions) + for row in cursor.fetchall(): + cursor.execute('INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, row['user_id'])) connection.commit() return create_api_response(code="200", message="Meeting created successfully", data={"meeting_id": meeting_id}) @@ -404,9 +415,18 @@ def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, curre update_query = 'UPDATE meetings SET title = %s, meeting_time = %s, summary = %s, tags = %s WHERE meeting_id = %s' cursor.execute(update_query, (meeting_request.title, meeting_request.meeting_time, meeting_request.summary, meeting_request.tags, meeting_id)) cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,)) - for attendee_id in meeting_request.attendee_ids: - cursor.execute('INSERT INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, attendee_id)) + # 根据 caption 查找用户ID并插入参会人 + if meeting_request.attendees: + captions = [c.strip() for c in meeting_request.attendees.split(',') if c.strip()] + if captions: + placeholders = ','.join(['%s'] * len(captions)) + cursor.execute(f'SELECT user_id FROM sys_users WHERE caption IN ({placeholders})', captions) + for row in cursor.fetchall(): + cursor.execute('INSERT INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, row['user_id'])) connection.commit() + # 同步导出总结MD文件 + if meeting_request.summary: + async_meeting_service._export_summary_md(meeting_id, meeting_request.summary) return create_api_response(code="200", message="Meeting updated successfully") @router.delete("/meetings/{meeting_id}") @@ -435,14 +455,14 @@ def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_curre SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, m.user_id as creator_id, u.caption as creator_username, af.file_path as audio_file_path, m.access_password - FROM meetings m JOIN users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id + FROM meetings m JOIN sys_users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id WHERE m.meeting_id = %s ''' cursor.execute(query, (meeting_id,)) meeting = cursor.fetchone() if not meeting: return create_api_response(code="404", message="Meeting not found") - attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' + attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' cursor.execute(attendees_query, (meeting['meeting_id'],)) attendees_data = cursor.fetchall() attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] @@ -861,8 +881,8 @@ def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequ cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) if not cursor.fetchone(): return create_api_response(code="404", message="Meeting not found") - # 传递 prompt_id 参数给服务层 - task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt, request.prompt_id) + # 传递 prompt_id 和 model_code 参数给服务层 + task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt, request.prompt_id, request.model_code) background_tasks.add_task(async_meeting_service._process_task, task_id) return create_api_response(code="200", message="Summary generation task has been accepted.", data={ "task_id": task_id, "status": "pending", "meeting_id": meeting_id @@ -885,6 +905,19 @@ def get_meeting_llm_tasks(meeting_id: int, current_user: dict = Depends(get_curr except Exception as e: return create_api_response(code="500", message=f"Failed to get LLM tasks: {str(e)}") +@router.get("/llm-models/active") +def list_active_llm_models(current_user: dict = Depends(get_current_user)): + """获取所有激活的LLM模型列表(供普通用户选择)""" + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute( + "SELECT model_code, model_name, provider, is_default FROM llm_model_config WHERE is_active = 1 ORDER BY is_default DESC, model_code ASC" + ) + models = cursor.fetchall() + return create_api_response(code="200", message="获取模型列表成功", data={"models": models}) + except Exception as e: + return create_api_response(code="500", message=f"获取模型列表失败: {str(e)}") @router.get("/meetings/{meeting_id}/navigation") def get_meeting_navigation( meeting_id: int, @@ -946,7 +979,7 @@ def get_meeting_navigation( query = ''' SELECT m.meeting_id FROM meetings m - JOIN users u ON m.user_id = u.user_id + JOIN sys_users u ON m.user_id = u.user_id ''' if has_attendees_join: @@ -1012,7 +1045,7 @@ def get_meeting_preview_data(meeting_id: int): m.user_id as creator_id, u.caption as creator_username, p.name as prompt_name, m.access_password FROM meetings m - JOIN users u ON m.user_id = u.user_id + JOIN sys_users u ON m.user_id = u.user_id LEFT JOIN prompts p ON m.prompt_id = p.id WHERE m.meeting_id = %s ''' @@ -1079,7 +1112,7 @@ def get_meeting_preview_data(meeting_id: int): # 获取参会人员信息 with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' + attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' cursor.execute(attendees_query, (meeting_id,)) attendees_data = cursor.fetchall() attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] @@ -1227,4 +1260,3 @@ def verify_meeting_password(meeting_id: int, request: VerifyPasswordRequest): code="500", message=f"验证密码失败: {str(e)}" ) - diff --git a/backend/app/api/endpoints/prompts.py b/backend/app/api/endpoints/prompts.py index d7bc985..cdb6e29 100644 --- a/backend/app/api/endpoints/prompts.py +++ b/backend/app/api/endpoints/prompts.py @@ -1,247 +1,383 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel -from typing import List, Optional +from typing import Optional, List from app.core.auth import get_current_user from app.core.database import get_db_connection from app.core.response import create_api_response +from app.models.models import PromptCreate, PromptUpdate router = APIRouter() -# Pydantic Models -class PromptIn(BaseModel): - name: str - task_type: str # 'MEETING_TASK' 或 'KNOWLEDGE_TASK' - content: str - desc: Optional[str] = None # 模版描述 - is_default: bool = False - is_active: bool = True -class PromptOut(PromptIn): - id: int - creator_id: int - created_at: str +class PromptConfigItem(BaseModel): + prompt_id: int + is_enabled: bool = True + sort_order: int = 0 + + +class PromptConfigUpdateRequest(BaseModel): + items: List[PromptConfigItem] + + +def _is_admin(user: dict) -> bool: + return int(user.get("role_id") or 0) == 1 + + +def _can_manage_prompt(current_user: dict, row: dict) -> bool: + if _is_admin(current_user): + return True + return int(row.get("creator_id") or 0) == int(current_user["user_id"]) and int(row.get("is_system") or 0) == 0 -class PromptListResponse(BaseModel): - prompts: List[PromptOut] - total: int @router.post("/prompts") -def create_prompt(prompt: PromptIn, current_user: dict = Depends(get_current_user)): - """Create a new prompt.""" +def create_prompt( + prompt: PromptCreate, + current_user: dict = Depends(get_current_user), +): + """Create a prompt template. Admin can create system prompts, others can only create personal prompts.""" with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) try: - # 如果设置为默认,需要先取消同类型其他提示词的默认状态 - if prompt.is_default: + is_admin = _is_admin(current_user) + requested_is_system = bool(getattr(prompt, "is_system", False)) + is_system = 1 if (is_admin and requested_is_system) else 0 + + owner_user_id = current_user["user_id"] + cursor.execute( + """ + SELECT COUNT(*) as cnt + FROM prompts + WHERE task_type = %s + AND is_system = %s + AND creator_id = %s + """, + (prompt.task_type, is_system, owner_user_id), + ) + count = (cursor.fetchone() or {}).get("cnt", 0) + is_default = 1 if count == 0 else (1 if prompt.is_default else 0) + + if is_default: cursor.execute( - "UPDATE prompts SET is_default = FALSE WHERE task_type = %s", - (prompt.task_type,) + """ + UPDATE prompts + SET is_default = 0 + WHERE task_type = %s + AND is_system = %s + AND creator_id = %s + """, + (prompt.task_type, is_system, owner_user_id), ) cursor.execute( - """INSERT INTO prompts (name, task_type, content, desc, is_default, is_active, creator_id) - VALUES (%s, %s, %s, %s, %s, %s, %s)""", - (prompt.name, prompt.task_type, prompt.content, prompt.desc, - prompt.is_default, prompt.is_active, current_user["user_id"]) + """ + INSERT INTO prompts (name, task_type, content, `desc`, is_default, is_active, creator_id, is_system) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + prompt.name, + prompt.task_type, + prompt.content, + prompt.desc, + is_default, + 1 if prompt.is_active else 0, + owner_user_id, + is_system, + ), ) + prompt_id = cursor.lastrowid connection.commit() - new_id = cursor.lastrowid - return create_api_response( - code="200", - message="提示词创建成功", - data={"id": new_id, **prompt.dict()} - ) + return create_api_response(code="200", message="提示词模版创建成功", data={"id": prompt_id}) except Exception as e: - if "Duplicate entry" in str(e): - return create_api_response(code="400", message="提示词名称已存在") - return create_api_response(code="500", message=f"创建提示词失败: {e}") + connection.rollback() + raise HTTPException(status_code=500, detail=str(e)) -@router.get("/prompts/active/{task_type}") -def get_active_prompts(task_type: str, current_user: dict = Depends(get_current_user)): - """Get all active prompts for a specific task type. - - Returns: - - All active prompts created by administrators (role_id = 1) - - All active prompts created by the current logged-in user - """ - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute( - """SELECT id, name, desc, content, is_default - FROM prompts - WHERE task_type = %s AND is_active = TRUE - AND (creator_id = 1 OR creator_id = %s) - ORDER BY is_default DESC, created_at DESC""", - (task_type, current_user["user_id"]) - ) - prompts = cursor.fetchall() - return create_api_response( - code="200", - message="获取启用模版列表成功", - data={"prompts": prompts} - ) @router.get("/prompts") def get_prompts( task_type: Optional[str] = None, page: int = 1, - size: int = 50, - current_user: dict = Depends(get_current_user) + size: int = 12, + keyword: Optional[str] = Query(None), + is_active: Optional[int] = Query(None), + scope: str = Query("mine"), # mine / system / all / accessible + current_user: dict = Depends(get_current_user), ): - """Get a paginated list of prompts filtered by current user and optionally by task_type.""" + """Get paginated prompt cards. Normal users can only view their own prompts.""" with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - # 构建 WHERE 条件 - where_conditions = ["creator_id = %s"] - params = [current_user["user_id"]] + is_admin = _is_admin(current_user) + where_conditions = [] + params = [] + + if scope == "all" and not is_admin: + scope = "accessible" + + if scope == "system": + where_conditions.append("p.is_system = 1") + elif scope == "all": + where_conditions.append("(p.is_system = 1 OR p.creator_id = %s)") + params.append(current_user["user_id"]) + elif scope == "accessible": + where_conditions.append("((p.is_system = 1 AND p.is_active = 1) OR (p.is_system = 0 AND p.creator_id = %s))") + params.append(current_user["user_id"]) + else: + where_conditions.append("p.is_system = 0 AND p.creator_id = %s") + params.append(current_user["user_id"]) if task_type: - where_conditions.append("task_type = %s") + where_conditions.append("p.task_type = %s") params.append(task_type) - where_clause = " AND ".join(where_conditions) + if keyword: + where_conditions.append("(p.name LIKE %s OR p.`desc` LIKE %s)") + like = f"%{keyword}%" + params.extend([like, like]) + if is_active in (0, 1): + where_conditions.append("p.is_active = %s") + params.append(is_active) - # 获取总数 - cursor.execute( - f"SELECT COUNT(*) as total FROM prompts WHERE {where_clause}", - tuple(params) - ) - total = cursor.fetchone()['total'] + where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" - # 获取分页数据 - offset = (page - 1) * size + cursor.execute(f"SELECT COUNT(*) as total FROM prompts p WHERE {where_clause}", tuple(params)) + total = (cursor.fetchone() or {}).get("total", 0) + + offset = max(page - 1, 0) * size cursor.execute( - f"""SELECT id, name, task_type, content, desc, is_default, is_active, creator_id, created_at - FROM prompts - WHERE {where_clause} - ORDER BY created_at DESC - LIMIT %s OFFSET %s""", - tuple(params + [size, offset]) + f""" + SELECT p.id, p.name, p.task_type, p.content, p.`desc`, p.is_default, p.is_active, + p.creator_id, p.is_system, p.created_at, + u.caption AS creator_name + FROM prompts p + LEFT JOIN sys_users u ON u.user_id = p.creator_id + WHERE {where_clause} + ORDER BY p.is_system DESC, p.task_type ASC, p.is_default DESC, p.created_at DESC + LIMIT %s OFFSET %s + """, + tuple(params + [size, offset]), ) - prompts = cursor.fetchall() + rows = cursor.fetchall() + return create_api_response( code="200", message="获取提示词列表成功", - data={"prompts": prompts, "total": total} + data={"prompts": rows, "total": total, "page": page, "size": size}, ) -@router.get("/prompts/{prompt_id}") -def get_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)): - """Get a single prompt by its ID.""" + +@router.get("/prompts/active/{task_type}") +def get_active_prompts(task_type: str, current_user: dict = Depends(get_current_user)): + """ + Active prompts for task selection. + Includes system prompts + personal prompts, and applies user's prompt config ordering. + """ with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) + cursor.execute( - """SELECT id, name, task_type, content, desc, is_default, is_active, creator_id, created_at - FROM prompts WHERE id = %s""", - (prompt_id,) + """ + SELECT p.id, p.name, p.`desc`, p.content, p.is_default, p.is_system, p.creator_id, + cfg.is_enabled, cfg.sort_order + FROM prompts p + LEFT JOIN prompt_config cfg + ON cfg.prompt_id = p.id + AND cfg.user_id = %s + AND cfg.task_type = %s + WHERE p.task_type = %s + AND p.is_active = 1 + AND (p.is_system = 1 OR p.creator_id = %s) + ORDER BY + CASE WHEN cfg.is_enabled = 1 THEN 0 ELSE 1 END, + cfg.sort_order ASC, + p.is_default DESC, + p.created_at DESC + """, + (current_user["user_id"], task_type, task_type, current_user["user_id"]), ) - prompt = cursor.fetchone() - if not prompt: - return create_api_response(code="404", message="提示词不存在") - return create_api_response(code="200", message="获取提示词成功", data=prompt) + prompts = cursor.fetchall() -@router.put("/prompts/{prompt_id}") -def update_prompt(prompt_id: int, prompt: PromptIn, current_user: dict = Depends(get_current_user)): - """Update an existing prompt.""" - print(f"[UPDATE PROMPT] prompt_id={prompt_id}, type={type(prompt_id)}") - print(f"[UPDATE PROMPT] user_id={current_user['user_id']}") - print(f"[UPDATE PROMPT] data: name={prompt.name}, task_type={prompt.task_type}, content_len={len(prompt.content)}, is_default={prompt.is_default}, is_active={prompt.is_active}") + enabled = [x for x in prompts if x.get("is_enabled") == 1] + if enabled: + result = enabled + else: + result = prompts + return create_api_response(code="200", message="获取启用模版列表成功", data={"prompts": result}) + + +@router.get("/prompts/config/{task_type}") +def get_prompt_config(task_type: str, current_user: dict = Depends(get_current_user)): + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + cursor.execute( + """ + SELECT id, name, task_type, content, `desc`, is_default, is_active, is_system, creator_id, created_at + FROM prompts + WHERE task_type = %s + AND is_active = 1 + AND (is_system = 1 OR creator_id = %s) + ORDER BY is_system DESC, is_default DESC, created_at DESC + """, + (task_type, current_user["user_id"]), + ) + available = cursor.fetchall() + + cursor.execute( + """ + SELECT prompt_id, is_enabled, sort_order + FROM prompt_config + WHERE user_id = %s AND task_type = %s + ORDER BY sort_order ASC, config_id ASC + """, + (current_user["user_id"], task_type), + ) + configs = cursor.fetchall() + + selected_prompt_ids = [item["prompt_id"] for item in configs if item.get("is_enabled") == 1] + return create_api_response( + code="200", + message="获取提示词配置成功", + data={ + "task_type": task_type, + "available_prompts": available, + "configs": configs, + "selected_prompt_ids": selected_prompt_ids, + }, + ) + + +@router.put("/prompts/config/{task_type}") +def update_prompt_config( + task_type: str, + request: PromptConfigUpdateRequest, + current_user: dict = Depends(get_current_user), +): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) try: - # 先检查记录是否存在 - cursor.execute("SELECT id, creator_id FROM prompts WHERE id = %s", (prompt_id,)) - existing = cursor.fetchone() - print(f"[UPDATE PROMPT] existing record: {existing}") - - if not existing: - print(f"[UPDATE PROMPT] Prompt {prompt_id} not found in database") - return create_api_response(code="404", message="提示词不存在") - - # 如果设置为默认,需要先取消同类型其他提示词的默认状态 - if prompt.is_default: - print(f"[UPDATE PROMPT] Setting as default, clearing other defaults for task_type={prompt.task_type}") + requested_ids = [int(item.prompt_id) for item in request.items if item.is_enabled] + if requested_ids: + placeholders = ",".join(["%s"] * len(requested_ids)) cursor.execute( - "UPDATE prompts SET is_default = FALSE WHERE task_type = %s AND id != %s", - (prompt.task_type, prompt_id) + f""" + SELECT id + FROM prompts + WHERE id IN ({placeholders}) + AND task_type = %s + AND is_active = 1 + AND (is_system = 1 OR creator_id = %s) + """, + tuple(requested_ids + [task_type, current_user["user_id"]]), ) - print(f"[UPDATE PROMPT] Cleared {cursor.rowcount} other default prompts") + valid_ids = {row["id"] for row in cursor.fetchall()} + invalid_ids = [pid for pid in requested_ids if pid not in valid_ids] + if invalid_ids: + raise HTTPException(status_code=400, detail=f"存在无效提示词ID: {invalid_ids}") - print(f"[UPDATE PROMPT] Executing UPDATE query") cursor.execute( - """UPDATE prompts - SET name = %s, task_type = %s, content = %s, desc = %s, is_default = %s, is_active = %s - WHERE id = %s""", - (prompt.name, prompt.task_type, prompt.content, prompt.desc, prompt.is_default, - prompt.is_active, prompt_id) + "DELETE FROM prompt_config WHERE user_id = %s AND task_type = %s", + (current_user["user_id"], task_type), ) - rows_affected = cursor.rowcount - print(f"[UPDATE PROMPT] UPDATE affected {rows_affected} rows (0 means no changes needed)") - # 注意:rowcount=0 不代表记录不存在,可能是所有字段值都相同 - # 我们已经在上面确认了记录存在,所以这里直接提交即可 + ordered = sorted( + [item for item in request.items if item.is_enabled], + key=lambda x: (x.sort_order, x.prompt_id), + ) + for idx, item in enumerate(ordered): + cursor.execute( + """ + INSERT INTO prompt_config (user_id, task_type, prompt_id, is_enabled, sort_order) + VALUES (%s, %s, %s, 1, %s) + """, + (current_user["user_id"], task_type, int(item.prompt_id), idx + 1), + ) + connection.commit() - print(f"[UPDATE PROMPT] Success! Committed changes") - return create_api_response(code="200", message="提示词更新成功") + return create_api_response(code="200", message="提示词配置保存成功") + except HTTPException: + connection.rollback() + raise except Exception as e: - print(f"[UPDATE PROMPT] Exception: {type(e).__name__}: {e}") - if "Duplicate entry" in str(e): - return create_api_response(code="400", message="提示词名称已存在") - return create_api_response(code="500", message=f"更新提示词失败: {e}") + connection.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/prompts/{prompt_id}") +def update_prompt(prompt_id: int, prompt: PromptUpdate, current_user: dict = Depends(get_current_user)): + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + try: + cursor.execute("SELECT id, creator_id, task_type, is_default, is_system FROM prompts WHERE id = %s", (prompt_id,)) + existing = cursor.fetchone() + if not existing: + raise HTTPException(status_code=404, detail="模版不存在") + if not _can_manage_prompt(current_user, existing): + raise HTTPException(status_code=403, detail="无权修改此模版") + + if prompt.is_default is False and existing["is_default"]: + raise HTTPException(status_code=400, detail="必须保留一个默认模版,请先设置其他模版为默认") + if prompt.is_system is not None and not _is_admin(current_user): + raise HTTPException(status_code=403, detail="普通用户不能修改系统提示词属性") + + if prompt.is_default: + task_type = prompt.task_type or existing["task_type"] + cursor.execute( + """ + UPDATE prompts + SET is_default = 0 + WHERE task_type = %s + AND is_system = %s + AND creator_id = %s + """, + (task_type, existing.get("is_system", 0), existing["creator_id"]), + ) + if prompt.is_active is False: + raise HTTPException(status_code=400, detail="默认模版必须处于启用状态") + + update_fields = [] + params = [] + prompt_data = prompt.dict(exclude_unset=True) + for field, value in prompt_data.items(): + if field == "desc": + update_fields.append("`desc` = %s") + else: + update_fields.append(f"{field} = %s") + params.append(value) + + if update_fields: + params.append(prompt_id) + cursor.execute(f"UPDATE prompts SET {', '.join(update_fields)} WHERE id = %s", tuple(params)) + + connection.commit() + return create_api_response(code="200", message="更新成功") + except HTTPException: + raise + except Exception as e: + connection.rollback() + raise HTTPException(status_code=500, detail=str(e)) + @router.delete("/prompts/{prompt_id}") def delete_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)): - """Delete a prompt. Only the creator can delete their own prompts.""" with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - # 首先检查提示词是否存在以及是否属于当前用户 - cursor.execute( - "SELECT creator_id FROM prompts WHERE id = %s", - (prompt_id,) - ) - prompt = cursor.fetchone() + try: + cursor.execute("SELECT id, creator_id, is_default, is_system FROM prompts WHERE id = %s", (prompt_id,)) + existing = cursor.fetchone() + if not existing: + raise HTTPException(status_code=404, detail="模版不存在") + if not _can_manage_prompt(current_user, existing): + raise HTTPException(status_code=403, detail="无权删除此模版") + if existing["is_default"]: + raise HTTPException(status_code=400, detail="默认模版不允许删除,请先设置其他模版为默认") - if not prompt: - return create_api_response(code="404", message="提示词不存在") - - if prompt['creator_id'] != current_user["user_id"]: - return create_api_response(code="403", message="无权删除其他用户的提示词") - - # 检查是否有会议引用了该提示词 - cursor.execute( - "SELECT COUNT(*) as count FROM meetings WHERE prompt_id = %s", - (prompt_id,) - ) - meeting_count = cursor.fetchone()['count'] - - # 检查是否有知识库引用了该提示词 - cursor.execute( - "SELECT COUNT(*) as count FROM knowledge_bases WHERE prompt_id = %s", - (prompt_id,) - ) - kb_count = cursor.fetchone()['count'] - - # 如果有引用,不允许删除 - if meeting_count > 0 or kb_count > 0: - references = [] - if meeting_count > 0: - references.append(f"{meeting_count}个会议") - if kb_count > 0: - references.append(f"{kb_count}个知识库") - - return create_api_response( - code="400", - message=f"无法删除:该提示词被{' 和 '.join(references)}引用", - data={ - "meeting_count": meeting_count, - "kb_count": kb_count - } - ) - - # 删除提示词 - cursor.execute("DELETE FROM prompts WHERE id = %s", (prompt_id,)) - connection.commit() - return create_api_response(code="200", message="提示词删除成功") + cursor.execute("DELETE FROM prompts WHERE id = %s", (prompt_id,)) + connection.commit() + return create_api_response(code="200", message="删除成功") + except HTTPException: + raise + except Exception as e: + connection.rollback() + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/api/endpoints/users.py b/backend/app/api/endpoints/users.py index dd4f7e1..d9f9507 100644 --- a/backend/app/api/endpoints/users.py +++ b/backend/app/api/endpoints/users.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, UploadFile, File from typing import Optional -from app.models.models import UserInfo, PasswordChangeRequest, UserListResponse, CreateUserRequest, UpdateUserRequest, RoleInfo +from app.models.models import UserInfo, PasswordChangeRequest, UserListResponse, CreateUserRequest, UpdateUserRequest, RoleInfo, UserMcpInfo from app.core.database import get_db_connection from app.core.auth import get_current_user from app.core.response import create_api_response @@ -13,6 +13,7 @@ import re import os import shutil import uuid +import secrets from pathlib import Path router = APIRouter() @@ -25,6 +26,59 @@ def validate_email(email: str) -> bool: def hash_password(password: str) -> str: return hashlib.sha256(password.encode()).hexdigest() + +def _generate_mcp_bot_id() -> str: + return f"nexbot_{secrets.token_hex(8)}" + + +def _generate_mcp_bot_secret() -> str: + random_part = secrets.token_urlsafe(24).replace('-', '').replace('_', '') + return f"nxbotsec_{random_part}" + + +def _get_user_mcp_record(cursor, user_id: int): + cursor.execute( + """ + SELECT id, user_id, bot_id, bot_secret, status, last_used_at, created_at, updated_at + FROM sys_user_mcp + WHERE user_id = %s + """, + (user_id,), + ) + return cursor.fetchone() + + +def _ensure_user_exists(cursor, user_id: int) -> bool: + cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,)) + return cursor.fetchone() is not None + + +def _serialize_user_mcp(record: dict) -> dict: + return UserMcpInfo(**record).dict() + + +def _ensure_user_mcp_record(connection, cursor, user_id: int): + record = _get_user_mcp_record(cursor, user_id) + if record: + return record + + bot_id = _generate_mcp_bot_id() + while True: + cursor.execute("SELECT id FROM sys_user_mcp WHERE bot_id = %s", (bot_id,)) + if not cursor.fetchone(): + break + bot_id = _generate_mcp_bot_id() + + cursor.execute( + """ + INSERT INTO sys_user_mcp (user_id, bot_id, bot_secret, status, last_used_at, created_at, updated_at) + VALUES (%s, %s, %s, 1, NULL, NOW(), NOW()) + """, + (user_id, bot_id, _generate_mcp_bot_secret()), + ) + connection.commit() + return _get_user_mcp_record(cursor, user_id) + @router.get("/roles") def get_all_roles(current_user: dict = Depends(get_current_user)): """获取所有角色列表""" @@ -33,7 +87,7 @@ def get_all_roles(current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT role_id, role_name FROM roles ORDER BY role_id") + cursor.execute("SELECT role_id, role_name FROM sys_roles ORDER BY role_id") roles = cursor.fetchall() return create_api_response(code="200", message="获取角色列表成功", data=[RoleInfo(**role).dict() for role in roles]) @@ -48,14 +102,14 @@ def create_user(request: CreateUserRequest, current_user: dict = Depends(get_cur with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT user_id FROM users WHERE username = %s", (request.username,)) + cursor.execute("SELECT user_id FROM sys_users WHERE username = %s", (request.username,)) if cursor.fetchone(): return create_api_response(code="400", message="用户名已存在") password = request.password if request.password else SystemConfigService.get_default_reset_password() hashed_password = hash_password(password) - query = "INSERT INTO users (username, password_hash, caption, email, avatar_url, role_id, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s)" + query = "INSERT INTO sys_users (username, password_hash, caption, email, avatar_url, role_id, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s)" created_at = datetime.datetime.utcnow() cursor.execute(query, (request.username, hashed_password, request.caption, request.email, request.avatar_url, request.role_id, created_at)) connection.commit() @@ -74,13 +128,13 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT user_id, username, caption, email, avatar_url, role_id FROM users WHERE user_id = %s", (user_id,)) + cursor.execute("SELECT user_id, username, caption, email, avatar_url, role_id FROM sys_users WHERE user_id = %s", (user_id,)) existing_user = cursor.fetchone() if not existing_user: return create_api_response(code="404", message="用户不存在") if request.username and request.username != existing_user['username']: - cursor.execute("SELECT user_id FROM users WHERE username = %s AND user_id != %s", (request.username, user_id)) + cursor.execute("SELECT user_id FROM sys_users WHERE username = %s AND user_id != %s", (request.username, user_id)) if cursor.fetchone(): return create_api_response(code="400", message="用户名已存在") @@ -97,14 +151,14 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D 'role_id': target_role_id } - query = "UPDATE users SET username = %s, caption = %s, email = %s, avatar_url = %s, role_id = %s WHERE user_id = %s" + query = "UPDATE sys_users SET username = %s, caption = %s, email = %s, avatar_url = %s, role_id = %s WHERE user_id = %s" cursor.execute(query, (update_data['username'], update_data['caption'], update_data['email'], update_data['avatar_url'], update_data['role_id'], user_id)) connection.commit() cursor.execute(''' SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name - FROM users u - LEFT JOIN roles r ON u.role_id = r.role_id + FROM sys_users u + LEFT JOIN sys_roles r ON u.role_id = r.role_id WHERE u.user_id = %s ''', (user_id,)) updated_user = cursor.fetchone() @@ -117,9 +171,7 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D avatar_url=updated_user['avatar_url'], created_at=updated_user['created_at'], role_id=updated_user['role_id'], - role_name=updated_user['role_name'], - meetings_created=0, - meetings_attended=0 + role_name=updated_user['role_name'] or '普通用户' ) return create_api_response(code="200", message="用户信息更新成功", data=user_info.dict()) @@ -131,11 +183,11 @@ def delete_user(user_id: int, current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,)) + cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,)) if not cursor.fetchone(): return create_api_response(code="404", message="用户不存在") - cursor.execute("DELETE FROM users WHERE user_id = %s", (user_id,)) + cursor.execute("DELETE FROM sys_users WHERE user_id = %s", (user_id,)) connection.commit() return create_api_response(code="200", message="用户删除成功") @@ -148,13 +200,13 @@ def reset_password(user_id: int, current_user: dict = Depends(get_current_user)) with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,)) + cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,)) if not cursor.fetchone(): return create_api_response(code="404", message="用户不存在") hashed_password = hash_password(SystemConfigService.get_default_reset_password()) - query = "UPDATE users SET password_hash = %s WHERE user_id = %s" + query = "UPDATE sys_users SET password_hash = %s WHERE user_id = %s" cursor.execute(query, (hashed_password, user_id)) connection.commit() @@ -185,7 +237,7 @@ def get_all_users( count_params.extend([search_pattern, search_pattern]) # 统计查询 - count_query = "SELECT COUNT(*) as total FROM users u" + count_query = "SELECT COUNT(*) as total FROM sys_users u" if where_conditions: count_query += " WHERE " + " AND ".join(where_conditions) @@ -197,12 +249,16 @@ def get_all_users( # 主查询 query = ''' SELECT - u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, - r.role_name, - (SELECT COUNT(*) FROM meetings WHERE user_id = u.user_id) as meetings_created, - (SELECT COUNT(*) FROM attendees WHERE user_id = u.user_id) as meetings_attended - FROM users u - LEFT JOIN roles r ON u.role_id = r.role_id + u.user_id, + u.username, + u.caption, + u.email, + u.avatar_url, + u.created_at, + u.role_id, + COALESCE(r.role_name, '普通用户') AS role_name + FROM sys_users u + LEFT JOIN sys_roles r ON u.role_id = r.role_id ''' query_params = [] @@ -231,9 +287,10 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)): cursor = connection.cursor(dictionary=True) user_query = ''' - SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name - FROM users u - LEFT JOIN roles r ON u.role_id = r.role_id + SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, + COALESCE(r.role_name, '普通用户') AS role_name + FROM sys_users u + LEFT JOIN sys_roles r ON u.role_id = r.role_id WHERE u.user_id = %s ''' cursor.execute(user_query, (user_id,)) @@ -242,14 +299,6 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)): if not user: return create_api_response(code="404", message="用户不存在") - created_query = "SELECT COUNT(*) as count FROM meetings WHERE user_id = %s" - cursor.execute(created_query, (user_id,)) - meetings_created = cursor.fetchone()['count'] - - attended_query = "SELECT COUNT(*) as count FROM attendees WHERE user_id = %s" - cursor.execute(attended_query, (user_id,)) - meetings_attended = cursor.fetchone()['count'] - user_info = UserInfo( user_id=user['user_id'], username=user['username'], @@ -258,9 +307,7 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)): avatar_url=user['avatar_url'], created_at=user['created_at'], role_id=user['role_id'], - role_name=user['role_name'], - meetings_created=meetings_created, - meetings_attended=meetings_attended + role_name=user['role_name'] ) return create_api_response(code="200", message="获取用户信息成功", data=user_info.dict()) @@ -272,7 +319,7 @@ def update_password(user_id: int, request: PasswordChangeRequest, current_user: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT password_hash FROM users WHERE user_id = %s", (user_id,)) + cursor.execute("SELECT password_hash FROM sys_users WHERE user_id = %s", (user_id,)) user = cursor.fetchone() if not user: @@ -283,7 +330,7 @@ def update_password(user_id: int, request: PasswordChangeRequest, current_user: return create_api_response(code="400", message="旧密码错误") new_password_hash = hash_password(request.new_password) - cursor.execute("UPDATE users SET password_hash = %s WHERE user_id = %s", (new_password_hash, user_id)) + cursor.execute("UPDATE sys_users SET password_hash = %s WHERE user_id = %s", (new_password_hash, user_id)) connection.commit() return create_api_response(code="200", message="密码修改成功") @@ -305,7 +352,7 @@ def upload_user_avatar( return create_api_response(code="400", message="不支持的文件类型") # Ensure upload directory exists: AVATAR_DIR / str(user_id) - user_avatar_dir = AVATAR_DIR / str(user_id) + user_avatar_dir = config_module.get_user_avatar_dir(user_id) if not user_avatar_dir.exists(): os.makedirs(user_avatar_dir) @@ -321,13 +368,57 @@ def upload_user_avatar( # AVATAR_DIR is uploads/user/avatar # file path is uploads/user/avatar/{user_id}/{filename} # URL should be /uploads/user/avatar/{user_id}/{filename} - avatar_url = f"/uploads/user/avatar/{user_id}/{unique_filename}" + avatar_url = f"/uploads/user/{user_id}/avatar/{unique_filename}" # Update database with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute("UPDATE users SET avatar_url = %s WHERE user_id = %s", (avatar_url, user_id)) + cursor.execute("UPDATE sys_users SET avatar_url = %s WHERE user_id = %s", (avatar_url, user_id)) connection.commit() - return create_api_response(code="200", message="头像上传成功", data={"avatar_url": avatar_url}) \ No newline at end of file + return create_api_response(code="200", message="头像上传成功", data={"avatar_url": avatar_url}) + + +@router.get("/users/{user_id}/mcp-config") +def get_user_mcp_config(user_id: int, current_user: dict = Depends(get_current_user)): + if current_user['role_id'] != 1 and current_user['user_id'] != user_id: + return create_api_response(code="403", message="没有权限查看该用户的MCP配置") + + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + if not _ensure_user_exists(cursor, user_id): + return create_api_response(code="404", message="用户不存在") + + record = _ensure_user_mcp_record(connection, cursor, user_id) + return create_api_response(code="200", message="获取MCP配置成功", data=_serialize_user_mcp(record)) + + +@router.post("/users/{user_id}/mcp-config/regenerate") +def regenerate_user_mcp_secret(user_id: int, current_user: dict = Depends(get_current_user)): + if current_user['role_id'] != 1 and current_user['user_id'] != user_id: + return create_api_response(code="403", message="没有权限更新该用户的MCP配置") + + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + if not _ensure_user_exists(cursor, user_id): + return create_api_response(code="404", message="用户不存在") + + record = _get_user_mcp_record(cursor, user_id) + if not record: + record = _ensure_user_mcp_record(connection, cursor, user_id) + else: + cursor.execute( + """ + UPDATE sys_user_mcp + SET bot_secret = %s, status = 1, updated_at = NOW() + WHERE user_id = %s + """, + (_generate_mcp_bot_secret(), user_id), + ) + connection.commit() + record = _get_user_mcp_record(cursor, user_id) + + return create_api_response(code="200", message="MCP Secret 已重新生成", data=_serialize_user_mcp(record)) diff --git a/backend/app/api/endpoints/voiceprint.py b/backend/app/api/endpoints/voiceprint.py index f6f17d0..297ae6b 100644 --- a/backend/app/api/endpoints/voiceprint.py +++ b/backend/app/api/endpoints/voiceprint.py @@ -89,7 +89,7 @@ async def upload_voiceprint( try: # 确保用户目录存在 - user_voiceprint_dir = config_module.VOICEPRINT_DIR / str(user_id) + user_voiceprint_dir = config_module.get_user_voiceprint_dir(user_id) user_voiceprint_dir.mkdir(parents=True, exist_ok=True) # 生成文件名:时间戳.wav diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index cdd84ca..4d117ac 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -24,7 +24,7 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute( - "SELECT user_id, username, caption, email, role_id FROM users WHERE user_id = %s", + "SELECT user_id, username, caption, email, role_id FROM sys_users WHERE user_id = %s", (user_id,) ) user = cursor.fetchone() @@ -67,7 +67,7 @@ def get_optional_current_user(request: Request) -> Optional[dict]: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute( - "SELECT user_id, username, caption, email FROM users WHERE user_id = %s", + "SELECT user_id, username, caption, email FROM sys_users WHERE user_id = %s", (user_id,) ) return cursor.fetchone() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 16cc52d..67c358d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -16,8 +16,22 @@ MARKDOWN_DIR = UPLOAD_DIR / "markdown" CLIENT_DIR = UPLOAD_DIR / "clients" EXTERNAL_APPS_DIR = UPLOAD_DIR / "external_apps" USER_DIR = UPLOAD_DIR / "user" -VOICEPRINT_DIR = USER_DIR / "voiceprint" -AVATAR_DIR = USER_DIR / "avatar" +LEGACY_VOICEPRINT_DIR = USER_DIR / "voiceprint" +LEGACY_AVATAR_DIR = USER_DIR / "avatar" +VOICEPRINT_DIR = USER_DIR +AVATAR_DIR = USER_DIR + + +def get_user_data_dir(user_id: int | str) -> Path: + return USER_DIR / str(user_id) + + +def get_user_voiceprint_dir(user_id: int | str) -> Path: + return get_user_data_dir(user_id) / "voiceprint" + + +def get_user_avatar_dir(user_id: int | str) -> Path: + return get_user_data_dir(user_id) / "avatar" # 文件上传配置 ALLOWED_EXTENSIONS = {".mp3", ".wav", ".m4a", ".mpeg", ".mp4"} @@ -35,8 +49,8 @@ MARKDOWN_DIR.mkdir(exist_ok=True) CLIENT_DIR.mkdir(exist_ok=True) EXTERNAL_APPS_DIR.mkdir(exist_ok=True) USER_DIR.mkdir(exist_ok=True) -VOICEPRINT_DIR.mkdir(exist_ok=True) -AVATAR_DIR.mkdir(exist_ok=True) +LEGACY_VOICEPRINT_DIR.mkdir(exist_ok=True) +LEGACY_AVATAR_DIR.mkdir(exist_ok=True) # 数据库配置 diff --git a/backend/app/main.py b/backend/app/main.py index c2d9333..c1f0ad5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,7 +15,25 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.openapi.docs import get_swagger_ui_html from app.core.middleware import TerminalCheckMiddleware -from app.api.endpoints import auth, users, meetings, tags, admin, admin_dashboard, tasks, prompts, knowledge_base, client_downloads, voiceprint, audio, dict_data, hot_words, external_apps, terminals +from app.api.endpoints import ( + auth, + users, + meetings, + tags, + admin, + admin_dashboard, + admin_settings, + tasks, + prompts, + knowledge_base, + client_downloads, + voiceprint, + audio, + dict_data, + hot_words, + external_apps, + terminals, +) from app.core.config import UPLOAD_DIR, API_CONFIG app = FastAPI( @@ -49,6 +67,7 @@ app.include_router(meetings.router, prefix="/api", tags=["Meetings"]) app.include_router(tags.router, prefix="/api", tags=["Tags"]) app.include_router(admin.router, prefix="/api", tags=["Admin"]) app.include_router(admin_dashboard.router, prefix="/api", tags=["AdminDashboard"]) +app.include_router(admin_settings.router, prefix="/api", tags=["AdminSettings"]) app.include_router(tasks.router, prefix="/api", tags=["Tasks"]) app.include_router(prompts.router, prefix="/api", tags=["Prompts"]) app.include_router(knowledge_base.router, prefix="/api", tags=["KnowledgeBase"]) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index cba8c56..e7587eb 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1,20 +1,12 @@ -from pydantic import BaseModel, EmailStr -from typing import Optional, Union, List +from pydantic import BaseModel, Field, EmailStr +from typing import List, Optional, Any, Dict, Union import datetime +# 认证相关模型 class LoginRequest(BaseModel): username: str password: str -class LoginResponse(BaseModel): - user_id: int - username: str - caption: str - email: EmailStr - avatar_url: Optional[str] = None - token: str - role_id: int - class RoleInfo(BaseModel): role_id: int role_name: str @@ -23,102 +15,105 @@ class UserInfo(BaseModel): user_id: int username: str caption: str - email: EmailStr - avatar_url: Optional[str] = None - created_at: datetime.datetime - meetings_created: int - meetings_attended: int + email: Optional[str] = None role_id: int role_name: str + avatar_url: Optional[str] = None + created_at: datetime.datetime + +class LoginResponse(BaseModel): + token: str + user: UserInfo class UserListResponse(BaseModel): - users: list[UserInfo] + users: List[UserInfo] total: int class CreateUserRequest(BaseModel): username: str password: Optional[str] = None caption: str - email: EmailStr - avatar_url: Optional[str] = None - role_id: int + email: Optional[str] = None + role_id: int = 2 class UpdateUserRequest(BaseModel): - username: Optional[str] = None caption: Optional[str] = None email: Optional[str] = None - avatar_url: Optional[str] = None role_id: Optional[int] = None + avatar_url: Optional[str] = None class UserLog(BaseModel): log_id: int user_id: int - action_type: str + username: str + action: str + details: Optional[str] = None ip_address: Optional[str] = None - user_agent: Optional[str] = None - metadata: Optional[dict] = None created_at: datetime.datetime +# 会议相关模型 class AttendeeInfo(BaseModel): - user_id: int + user_id: Optional[int] = None + username: Optional[str] = None caption: str class Tag(BaseModel): id: int name: str - color: str class TranscriptionTaskStatus(BaseModel): task_id: str - status: str # 'pending', 'processing', 'completed', 'failed' - progress: int # 0-100 - meeting_id: int - created_at: Optional[str] = None - updated_at: Optional[str] = None - completed_at: Optional[str] = None - error_message: Optional[str] = None + status: str + progress: int + message: Optional[str] = None class Meeting(BaseModel): meeting_id: int title: str - meeting_time: Optional[datetime.datetime] - summary: Optional[str] - created_at: datetime.datetime - attendees: Union[List[str], List[AttendeeInfo]] # Support both formats + meeting_time: datetime.datetime + description: Optional[str] = None creator_id: int creator_username: str + created_at: datetime.datetime + attendees: List[AttendeeInfo] + tags: List[Tag] audio_file_path: Optional[str] = None audio_duration: Optional[float] = None - prompt_name: Optional[str] = None + summary: Optional[str] = None transcription_status: Optional[TranscriptionTaskStatus] = None - tags: Optional[List[Tag]] = [] - access_password: Optional[str] = None + prompt_id: Optional[int] = None + prompt_name: Optional[str] = None + overall_status: Optional[str] = None + overall_progress: Optional[int] = None + current_stage: Optional[str] = None class TranscriptSegment(BaseModel): segment_id: int - meeting_id: int - speaker_id: Optional[int] = None # AI解析的原始结果 + speaker_id: int speaker_tag: str start_time_ms: int end_time_ms: int text_content: str class CreateMeetingRequest(BaseModel): - user_id: int title: str - meeting_time: Optional[datetime.datetime] - attendee_ids: list[int] - tags: Optional[str] = None + meeting_time: datetime.datetime + attendees: str # 逗号分隔的姓名 + description: Optional[str] = None + tags: Optional[str] = None # 逗号分隔 + prompt_id: Optional[int] = None class UpdateMeetingRequest(BaseModel): - title: str - meeting_time: Optional[datetime.datetime] - summary: Optional[str] - attendee_ids: list[int] + title: Optional[str] = None + meeting_time: Optional[datetime.datetime] = None + attendees: Optional[str] = None + description: Optional[str] = None tags: Optional[str] = None + summary: Optional[str] = None + prompt_id: Optional[int] = None class SpeakerTagUpdateRequest(BaseModel): - speaker_id: int # 使用原始speaker_id(整数) + speaker_id: int new_tag: str class BatchSpeakerTagUpdateRequest(BaseModel): @@ -126,7 +121,7 @@ class BatchSpeakerTagUpdateRequest(BaseModel): class TranscriptUpdateRequest(BaseModel): segment_id: int - text_content: str + new_text: str class BatchTranscriptUpdateRequest(BaseModel): updates: List[TranscriptUpdateRequest] @@ -135,45 +130,66 @@ class PasswordChangeRequest(BaseModel): old_password: str new_password: str +# 提示词模版模型 +class PromptBase(BaseModel): + name: str + task_type: str # MEETING_TASK, KNOWLEDGE_TASK + content: str + desc: Optional[str] = None + is_system: bool = False + is_default: bool = False + is_active: bool = True + +class PromptCreate(PromptBase): + pass + +class PromptUpdate(BaseModel): + name: Optional[str] = None + task_type: Optional[str] = None + content: Optional[str] = None + desc: Optional[str] = None + is_system: Optional[bool] = None + is_default: Optional[bool] = None + is_active: Optional[bool] = None + +class PromptInfo(PromptBase): + id: int + creator_id: Optional[int] = None + created_at: datetime.datetime + +# 知识库相关模型 class KnowledgeBase(BaseModel): kb_id: int title: str - content: Optional[str] = None + content: str creator_id: int - creator_caption: str # To show in the UI + created_by_name: str is_shared: bool - source_meeting_ids: Optional[str] = None - user_prompt: Optional[str] = None - tags: Union[Optional[str], Optional[List[Tag]]] = None # 支持字符串或Tag列表 created_at: datetime.datetime updated_at: datetime.datetime - source_meeting_count: Optional[int] = 0 - created_by_name: Optional[str] = None + source_meeting_count: int + source_meetings: Optional[List[Meeting]] = None + user_prompt: Optional[str] = None + tags: Optional[List[str]] = None + prompt_id: Optional[int] = None class KnowledgeBaseTask(BaseModel): task_id: str - user_id: int - kb_id: int - user_prompt: Optional[str] = None status: str progress: int - error_message: Optional[str] = None - created_at: datetime.datetime - updated_at: datetime.datetime - completed_at: Optional[datetime.datetime] = None + message: Optional[str] = None + result: Optional[str] = None class CreateKnowledgeBaseRequest(BaseModel): - title: Optional[str] = None # 改为可选,后台自动生成 - is_shared: bool user_prompt: Optional[str] = None - source_meeting_ids: Optional[str] = None - tags: Optional[str] = None - prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版 + source_meeting_ids: str # 逗号分隔 + is_shared: bool = False + prompt_id: Optional[int] = None class UpdateKnowledgeBaseRequest(BaseModel): - title: str + title: Optional[str] = None content: Optional[str] = None - tags: Optional[str] = None + is_shared: Optional[bool] = None class KnowledgeBaseListResponse(BaseModel): kbs: List[KnowledgeBase] @@ -182,73 +198,63 @@ class KnowledgeBaseListResponse(BaseModel): # 客户端下载相关模型 class ClientDownload(BaseModel): id: int - platform_type: Optional[str] = None # 兼容旧版:'mobile', 'desktop', 'terminal' - platform_name: Optional[str] = None # 兼容旧版:'ios', 'android', 'windows', 'mac_intel', 'mac_m', 'linux' - platform_code: str # 新版平台编码,关联 dict_data.dict_code + platform_code: str + platform_type: str # mobile, desktop, terminal + platform_name: str version: str version_code: int download_url: str file_size: Optional[int] = None release_notes: Optional[str] = None + min_system_version: Optional[str] = None is_active: bool is_latest: bool - min_system_version: Optional[str] = None created_at: datetime.datetime updated_at: datetime.datetime - created_by: Optional[int] = None class CreateClientDownloadRequest(BaseModel): - platform_type: Optional[str] = None # 兼容旧版 - platform_name: Optional[str] = None # 兼容旧版 - platform_code: str # 必填,关联 dict_data + platform_code: str + platform_type: Optional[str] = None + platform_name: Optional[str] = None version: str version_code: int download_url: str file_size: Optional[int] = None release_notes: Optional[str] = None + min_system_version: Optional[str] = None is_active: bool = True is_latest: bool = False - min_system_version: Optional[str] = None class UpdateClientDownloadRequest(BaseModel): + platform_code: Optional[str] = None platform_type: Optional[str] = None platform_name: Optional[str] = None - platform_code: Optional[str] = None version: Optional[str] = None version_code: Optional[int] = None download_url: Optional[str] = None file_size: Optional[int] = None release_notes: Optional[str] = None + min_system_version: Optional[str] = None is_active: Optional[bool] = None is_latest: Optional[bool] = None - min_system_version: Optional[str] = None class ClientDownloadListResponse(BaseModel): clients: List[ClientDownload] total: int -# 声纹采集相关模型 +# 声纹相关模型 class VoiceprintInfo(BaseModel): - vp_id: int user_id: int - file_path: str - file_size: Optional[int] = None - duration_seconds: Optional[float] = None - collected_at: datetime.datetime - updated_at: datetime.datetime + voiceprint_data: Any + created_at: datetime.datetime class VoiceprintStatus(BaseModel): has_voiceprint: bool - vp_id: Optional[int] = None - file_path: Optional[str] = None - duration_seconds: Optional[float] = None - collected_at: Optional[datetime.datetime] = None + updated_at: Optional[datetime.datetime] = None class VoiceprintTemplate(BaseModel): - template_text: str + content: str duration_seconds: int - sample_rate: int - channels: int # 菜单权限相关模型 class MenuInfo(BaseModel): @@ -277,13 +283,51 @@ class RolePermissionInfo(BaseModel): class UpdateRolePermissionsRequest(BaseModel): menu_ids: List[int] +class CreateRoleRequest(BaseModel): + role_name: str + +class UpdateRoleRequest(BaseModel): + role_name: str + +class CreateMenuRequest(BaseModel): + menu_code: str + menu_name: str + menu_icon: Optional[str] = None + menu_url: Optional[str] = None + menu_type: str = "link" + parent_id: Optional[int] = None + sort_order: int = 0 + is_active: bool = True + description: Optional[str] = None + +class UpdateMenuRequest(BaseModel): + menu_code: Optional[str] = None + menu_name: Optional[str] = None + menu_icon: Optional[str] = None + menu_url: Optional[str] = None + menu_type: Optional[str] = None + parent_id: Optional[int] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + description: Optional[str] = None + +class UserMcpInfo(BaseModel): + id: int + user_id: int + bot_id: str + bot_secret: str + status: int + last_used_at: Optional[datetime.datetime] = None + created_at: datetime.datetime + updated_at: datetime.datetime + # 专用终端设备模型 class Terminal(BaseModel): id: int imei: str terminal_name: Optional[str] = None terminal_type: str - terminal_type_name: Optional[str] = None # 终端类型名称(从字典获取) + terminal_type_name: Optional[str] = None description: Optional[str] = None status: int # 1: 启用, 0: 停用 is_activated: int # 1: 已激活, 0: 未激活 @@ -296,18 +340,23 @@ class Terminal(BaseModel): updated_at: datetime.datetime created_by: Optional[int] = None creator_username: Optional[str] = None + current_user_id: Optional[int] = None + current_username: Optional[str] = None + current_user_caption: Optional[str] = None class CreateTerminalRequest(BaseModel): imei: str terminal_name: Optional[str] = None terminal_type: str description: Optional[str] = None + firmware_version: Optional[str] = None + mac_address: Optional[str] = None status: int = 1 class UpdateTerminalRequest(BaseModel): terminal_name: Optional[str] = None terminal_type: Optional[str] = None description: Optional[str] = None - status: Optional[int] = None firmware_version: Optional[str] = None mac_address: Optional[str] = None + status: Optional[int] = None diff --git a/backend/app/services/async_meeting_service.py b/backend/app/services/async_meeting_service.py index 41dfbb2..98d7971 100644 --- a/backend/app/services/async_meeting_service.py +++ b/backend/app/services/async_meeting_service.py @@ -4,11 +4,13 @@ """ import uuid import time +import os from datetime import datetime from typing import Optional, Dict, Any, List +from pathlib import Path import redis -from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG +from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, AUDIO_DIR from app.core.database import get_db_connection from app.services.async_transcription_service import AsyncTranscriptionService from app.services.llm_service import LLMService @@ -23,7 +25,7 @@ class AsyncMeetingService: self.redis_client = redis.Redis(**REDIS_CONFIG) self.llm_service = LLMService() # 复用现有的同步LLM服务 - def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None) -> str: + def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None, model_code: Optional[str] = None) -> str: """ 创建异步总结任务,任务的执行将由外部(如API层的BackgroundTasks)触发。 @@ -31,6 +33,7 @@ class AsyncMeetingService: meeting_id: 会议ID user_prompt: 用户额外提示词 prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版 + model_code: 可选的LLM模型编码,如果不指定则使用默认模型 Returns: str: 任务ID @@ -49,6 +52,7 @@ class AsyncMeetingService: 'meeting_id': str(meeting_id), 'user_prompt': user_prompt, 'prompt_id': str(prompt_id) if prompt_id else '', + 'model_code': model_code or '', 'status': 'pending', 'progress': '0', 'created_at': current_time, @@ -79,6 +83,7 @@ class AsyncMeetingService: user_prompt = task_data.get('user_prompt', '') prompt_id_str = task_data.get('prompt_id', '') prompt_id = int(prompt_id_str) if prompt_id_str and prompt_id_str != '' else None + model_code = task_data.get('model_code', '') or None # 1. 更新状态为processing self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...") @@ -93,19 +98,26 @@ class AsyncMeetingService: self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...") full_prompt = self._build_prompt(transcript_text, user_prompt, prompt_id) - # 4. 调用LLM API + # 4. 调用LLM API(支持指定模型) self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...") - summary_content = self.llm_service._call_llm_api(full_prompt) + if model_code: + summary_content = self._call_llm_with_model(full_prompt, model_code) + else: + summary_content = self.llm_service._call_llm_api(full_prompt) if not summary_content: raise Exception("LLM API调用失败或返回空内容") # 5. 保存结果到主表 - self._update_task_status_in_redis(task_id, 'processing', 95, message="保存总结结果...") + self._update_task_status_in_redis(task_id, 'processing', 90, message="保存总结结果...") self._save_summary_to_db(meeting_id, summary_content, user_prompt, prompt_id) - # 6. 任务完成 - self._update_task_in_db(task_id, 'completed', 100, result=summary_content) - self._update_task_status_in_redis(task_id, 'completed', 100, result=summary_content) + # 6. 导出MD文件到音频同目录 + self._update_task_status_in_redis(task_id, 'processing', 95, message="导出Markdown文件...") + md_path = self._export_summary_md(meeting_id, summary_content) + + # 7. 任务完成,result保存MD文件路径 + self._update_task_in_db(task_id, 'completed', 100, result=md_path) + self._update_task_status_in_redis(task_id, 'completed', 100, result=md_path) print(f"Task {task_id} completed successfully") except Exception as e: @@ -210,6 +222,86 @@ class AsyncMeetingService: # --- 会议相关方法 --- + def _call_llm_with_model(self, prompt: str, model_code: str) -> Optional[str]: + """使用指定模型编码调用LLM API""" + import requests + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute( + "SELECT endpoint_url, api_key, llm_model_name, llm_timeout, llm_temperature, llm_top_p, llm_max_tokens FROM llm_model_config WHERE model_code = %s AND is_active = 1", + (model_code,) + ) + config = cursor.fetchone() + if not config: + print(f"模型 {model_code} 未找到或未激活,回退到默认模型") + return self.llm_service._call_llm_api(prompt) + + endpoint_url = (config['endpoint_url'] or '').rstrip('/') + if not endpoint_url.endswith('/chat/completions'): + endpoint_url = f"{endpoint_url}/chat/completions" + + headers = {"Content-Type": "application/json"} + if config['api_key']: + headers["Authorization"] = f"Bearer {config['api_key']}" + + payload = { + "model": config['llm_model_name'], + "messages": [{"role": "user", "content": prompt}], + "temperature": float(config.get('llm_temperature', 0.7)), + "top_p": float(config.get('llm_top_p', 0.9)), + "max_tokens": int(config.get('llm_max_tokens', 4096)), + "stream": False, + } + response = requests.post( + endpoint_url, + headers=headers, + json=payload, + timeout=int(config.get('llm_timeout', 120)), + ) + response.raise_for_status() + return self.llm_service._extract_response_text(response.json()) + except Exception as e: + print(f"使用模型 {model_code} 调用失败: {e}") + return None + + def _export_summary_md(self, meeting_id: int, summary_content: str) -> Optional[str]: + """将总结内容导出为MD文件,保存到音频同目录,返回文件路径""" + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT title FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting = cursor.fetchone() + cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,)) + audio = cursor.fetchone() + + title = meeting['title'] if meeting else f"meeting_{meeting_id}" + # 始终以 AUDIO_DIR 为基准,避免数据库中的绝对路径指向不可写目录 + if audio and audio.get('file_path'): + audio_path = Path(audio['file_path']) + # 提取 meeting_id 层级的子目录(如 "226" 或 "226/sub") + try: + rel = audio_path.relative_to(AUDIO_DIR) + md_dir = AUDIO_DIR / rel.parent + except ValueError: + # file_path 不在 AUDIO_DIR 下(如 Docker 绝对路径),取最后一级目录名 + md_dir = AUDIO_DIR / audio_path.parent.name + else: + md_dir = AUDIO_DIR / str(meeting_id) + + md_dir.mkdir(parents=True, exist_ok=True) + safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '.')).strip() + if not safe_title: + safe_title = f"meeting_{meeting_id}" + md_path = md_dir / f"{safe_title}_总结.md" + md_path.write_text(summary_content, encoding='utf-8') + md_path_str = str(md_path) + print(f"总结MD文件已保存: {md_path_str}") + return md_path_str + except Exception as e: + print(f"导出总结MD文件失败: {e}") + return None + def _get_meeting_transcript(self, meeting_id: int) -> str: """从数据库获取会议转录内容""" try: @@ -417,14 +509,14 @@ class AsyncMeetingService: try: with get_db_connection() as connection: cursor = connection.cursor() - params = [status, progress, error_message, task_id] if status == 'completed': - query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s, result = %s, completed_at = NOW() WHERE task_id = %s" - params.insert(2, result) + query = "UPDATE llm_tasks SET status = %s, progress = %s, result = %s, error_message = NULL, completed_at = NOW() WHERE task_id = %s" + params = (status, progress, result, task_id) else: query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s WHERE task_id = %s" + params = (status, progress, error_message, task_id) - cursor.execute(query, tuple(params)) + cursor.execute(query, params) connection.commit() except Exception as e: print(f"Error updating task in database: {e}") diff --git a/backend/app/services/async_transcription_service.py b/backend/app/services/async_transcription_service.py index 4d14e8c..ec57bb1 100644 --- a/backend/app/services/async_transcription_service.py +++ b/backend/app/services/async_transcription_service.py @@ -1,5 +1,6 @@ import uuid import json +import os import redis import requests from datetime import datetime @@ -21,6 +22,83 @@ class AsyncTranscriptionService: dashscope.api_key = QWEN_API_KEY self.redis_client = redis.Redis(**REDIS_CONFIG) self.base_url = APP_CONFIG['base_url'] + + @staticmethod + def _create_requests_session() -> requests.Session: + session = requests.Session() + session.trust_env = os.getenv("IMEETING_USE_SYSTEM_PROXY", "").lower() in {"1", "true", "yes", "on"} + return session + + @staticmethod + def _normalize_dashscope_base_address(endpoint_url: Optional[str]) -> Optional[str]: + if not endpoint_url: + return None + normalized = str(endpoint_url).strip().rstrip("/") + suffix = "/services/audio/asr/transcription" + if normalized.endswith(suffix): + normalized = normalized[: -len(suffix)] + return normalized or None + + @staticmethod + def _build_dashscope_call_params(audio_config: Dict[str, Any], file_url: str) -> Dict[str, Any]: + model_name = audio_config.get("model") or "paraformer-v2" + call_params: Dict[str, Any] = { + "model": model_name, + "file_urls": [file_url], + } + optional_keys = [ + "language_hints", + "disfluency_removal_enabled", + "diarization_enabled", + "speaker_count", + "vocabulary_id", + "timestamp_alignment_enabled", + "channel_id", + "special_word_filter", + "audio_event_detection_enabled", + "phrase_id", + ] + for key in optional_keys: + value = audio_config.get(key) + if value is None: + continue + if isinstance(value, str) and not value.strip(): + continue + if isinstance(value, list) and not value: + continue + call_params[key] = value + return call_params + + def test_asr_model(self, audio_config: Dict[str, Any], test_file_url: Optional[str] = None) -> Dict[str, Any]: + provider = str(audio_config.get("provider") or "dashscope").strip().lower() + if provider != "dashscope": + raise Exception(f"当前仅支持 DashScope 音频识别测试,暂不支持供应商: {provider}") + + dashscope.api_key = audio_config.get("api_key") or QWEN_API_KEY + target_file_url = ( + test_file_url + or "https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav" + ) + call_params = self._build_dashscope_call_params(audio_config, target_file_url) + base_address = self._normalize_dashscope_base_address(audio_config.get("endpoint_url")) + + session = self._create_requests_session() + try: + if base_address: + response = Transcription.async_call(base_address=base_address, session=session, **call_params) + else: + response = Transcription.async_call(session=session, **call_params) + finally: + session.close() + + if response.status_code != HTTPStatus.OK: + raise Exception(response.message or "音频模型测试失败") + + return { + "provider_task_id": response.output.task_id, + "test_file_url": target_file_url, + "used_params": call_params, + } def start_transcription(self, meeting_id: int, audio_file_path: str) -> str: """ @@ -59,24 +137,31 @@ class AsyncTranscriptionService: # 2. 构造完整的文件URL file_url = f"{self.base_url}{audio_file_path}" - # 获取热词表ID (asr_vocabulary_id) - vocabulary_id = SystemConfigService.get_asr_vocabulary_id() + # 获取音频模型配置 + audio_config = SystemConfigService.get_active_audio_model_config("asr") + provider = str(audio_config.get("provider") or "dashscope").strip().lower() + if provider != "dashscope": + raise Exception(f"当前仅支持 DashScope 音频识别,暂不支持供应商: {provider}") - print(f"Starting transcription for meeting_id: {meeting_id}, file_url: {file_url}, vocabulary_id: {vocabulary_id}") + dashscope.api_key = audio_config.get("api_key") or QWEN_API_KEY + call_params = self._build_dashscope_call_params(audio_config, file_url) + base_address = self._normalize_dashscope_base_address(audio_config.get("endpoint_url")) + + print( + f"Starting transcription for meeting_id: {meeting_id}, " + f"file_url: {file_url}, model: {call_params.get('model')}, " + f"vocabulary_id: {call_params.get('vocabulary_id')}" + ) # 3. 调用Paraformer异步API - call_params = { - 'model': 'paraformer-v2', - 'file_urls': [file_url], - 'language_hints': ['zh', 'en'], - 'disfluency_removal_enabled': True, - 'diarization_enabled': True, - 'speaker_count': 10 - } - if vocabulary_id: - call_params['vocabulary_id'] = vocabulary_id - - task_response = Transcription.async_call(**call_params) + session = self._create_requests_session() + try: + if base_address: + task_response = Transcription.async_call(base_address=base_address, session=session, **call_params) + else: + task_response = Transcription.async_call(session=session, **call_params) + finally: + session.close() if task_response.status_code != HTTPStatus.OK: print(f"Failed to start transcription: {task_response.status_code}, {task_response.message}") @@ -134,7 +219,11 @@ class AsyncTranscriptionService: # 2. 查询外部API获取状态 try: - paraformer_response = Transcription.fetch(task=paraformer_task_id) + session = self._create_requests_session() + try: + paraformer_response = Transcription.fetch(task=paraformer_task_id, session=session) + finally: + session.close() if paraformer_response.status_code != HTTPStatus.OK: raise Exception(f"Failed to fetch task status from provider: {paraformer_response.message}") @@ -411,7 +500,11 @@ class AsyncTranscriptionService: transcription_url = paraformer_output['results'][0]['transcription_url'] print(f"Fetching transcription from URL: {transcription_url}") - response = requests.get(transcription_url) + session = self._create_requests_session() + try: + response = session.get(transcription_url) + finally: + session.close() response.raise_for_status() transcription_data = response.json() diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index 399f6ec..3183ed4 100644 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -1,7 +1,9 @@ import json -import dashscope -from http import HTTPStatus -from typing import Optional, Dict, List, Generator, Any +import os +from typing import Optional, Dict, Generator, Any + +import requests + import app.core.config as config_module from app.core.database import get_db_connection from app.services.system_config_service import SystemConfigService @@ -10,23 +12,104 @@ from app.services.system_config_service import SystemConfigService class LLMService: """LLM服务 - 专注于大模型API调用和提示词管理""" - def __init__(self): - # 设置dashscope API key - dashscope.api_key = config_module.QWEN_API_KEY + @staticmethod + def _create_requests_session() -> requests.Session: + session = requests.Session() + session.trust_env = os.getenv("IMEETING_USE_SYSTEM_PROXY", "").lower() in {"1", "true", "yes", "on"} + return session + + @staticmethod + def build_call_params_from_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = config or {} + endpoint_url = config.get("endpoint_url") or SystemConfigService.get_llm_endpoint_url() + api_key = config.get("api_key") + if api_key is None: + api_key = SystemConfigService.get_llm_api_key(config_module.QWEN_API_KEY) + + return { + "endpoint_url": endpoint_url, + "api_key": api_key, + "model": config.get("llm_model_name") or config.get("model") or SystemConfigService.get_llm_model_name(), + "timeout": int(config.get("llm_timeout") or config.get("timeout") or SystemConfigService.get_llm_timeout()), + "temperature": float(config.get("llm_temperature") if config.get("llm_temperature") is not None else config.get("temperature", SystemConfigService.get_llm_temperature())), + "top_p": float(config.get("llm_top_p") if config.get("llm_top_p") is not None else config.get("top_p", SystemConfigService.get_llm_top_p())), + "max_tokens": int(config.get("llm_max_tokens") or config.get("max_tokens") or SystemConfigService.get_llm_max_tokens()), + "system_prompt": config.get("llm_system_prompt") or config.get("system_prompt") or SystemConfigService.get_llm_system_prompt(None), + } def _get_llm_call_params(self) -> Dict[str, Any]: """ - 获取 dashscope.Generation.call() 所需的参数字典 + 获取 OpenAI 兼容接口调用参数 Returns: - Dict: 包含 model、timeout、temperature、top_p 的参数字典 + Dict: 包含 endpoint_url、api_key、model、timeout、temperature、top_p、max_tokens 的参数字典 """ - return { - 'model': SystemConfigService.get_llm_model_name(), - 'timeout': SystemConfigService.get_llm_timeout(), - 'temperature': SystemConfigService.get_llm_temperature(), - 'top_p': SystemConfigService.get_llm_top_p(), + return self.build_call_params_from_config() + + @staticmethod + def _build_chat_url(endpoint_url: str) -> str: + base_url = (endpoint_url or "").rstrip("/") + if base_url.endswith("/chat/completions"): + return base_url + return f"{base_url}/chat/completions" + + @staticmethod + def _build_headers(api_key: Optional[str]) -> Dict[str, str]: + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return headers + + def _build_payload(self, prompt: str, stream: bool = False, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + params = params or self._get_llm_call_params() + messages = [] + system_prompt = params.get("system_prompt") + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + payload = { + "model": params["model"], + "messages": messages, + "temperature": params["temperature"], + "top_p": params["top_p"], + "max_tokens": params["max_tokens"], + "stream": stream, } + return payload + + @staticmethod + def _normalize_content(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + texts = [] + for item in content: + if isinstance(item, str): + texts.append(item) + elif isinstance(item, dict): + text = item.get("text") + if text: + texts.append(text) + return "".join(texts) + return "" + + def _extract_response_text(self, data: Dict[str, Any]) -> str: + choices = data.get("choices") or [] + if not choices: + return "" + + first_choice = choices[0] or {} + message = first_choice.get("message") or {} + content = message.get("content") + if content: + return self._normalize_content(content) + + delta = first_choice.get("delta") or {} + delta_content = delta.get("content") + if delta_content: + return self._normalize_content(delta_content) + + return "" def get_task_prompt(self, task_type: str, cursor=None, prompt_id: Optional[int] = None) -> str: """ @@ -79,7 +162,7 @@ class LLMService: def _get_default_prompt(self, task_name: str) -> str: """获取默认提示词""" - system_prompt = config_module.LLM_CONFIG.get("system_prompt", "请根据提供的内容进行总结和分析。") + system_prompt = SystemConfigService.get_llm_system_prompt("请根据提供的内容进行总结和分析。") default_prompts = { 'MEETING_TASK': system_prompt, 'KNOWLEDGE_TASK': "请根据提供的信息生成知识库文章。", @@ -87,50 +170,98 @@ class LLMService: return default_prompts.get(task_name, "请根据提供的内容进行总结和分析。") def _call_llm_api_stream(self, prompt: str) -> Generator[str, None, None]: - """流式调用阿里Qwen大模型API""" - try: - responses = dashscope.Generation.call( - **self._get_llm_call_params(), - prompt=prompt, - stream=True, - incremental_output=True - ) + """流式调用 OpenAI 兼容大模型API""" + params = self._get_llm_call_params() + if not params["api_key"]: + yield "error: 缺少API Key" + return - for response in responses: - if response.status_code == HTTPStatus.OK: - # 增量输出内容 - new_content = response.output.get('text', '') + try: + session = self._create_requests_session() + try: + response = session.post( + self._build_chat_url(params["endpoint_url"]), + headers=self._build_headers(params["api_key"]), + json=self._build_payload(prompt, stream=True), + timeout=params["timeout"], + stream=True, + ) + response.raise_for_status() + + for line in response.iter_lines(decode_unicode=True): + if not line or not line.startswith("data:"): + continue + + data_line = line[5:].strip() + if not data_line or data_line == "[DONE]": + continue + + try: + data = json.loads(data_line) + except json.JSONDecodeError: + continue + + new_content = self._extract_response_text(data) if new_content: yield new_content - else: - error_msg = f"Request failed with status code: {response.status_code}, Error: {response.message}" - print(error_msg) - yield f"error: {error_msg}" - break - + finally: + session.close() except Exception as e: error_msg = f"流式调用大模型API错误: {e}" print(error_msg) yield f"error: {error_msg}" def _call_llm_api(self, prompt: str) -> Optional[str]: - """调用阿里Qwen大模型API(非流式)""" + """调用 OpenAI 兼容大模型API(非流式)""" + params = self._get_llm_call_params() + return self.call_llm_api_with_config(params, prompt) + + def call_llm_api_with_config(self, params: Dict[str, Any], prompt: str) -> Optional[str]: + """使用指定配置调用 OpenAI 兼容大模型API(非流式)""" + if not params["api_key"]: + print("调用大模型API错误: 缺少API Key") + return None + try: - response = dashscope.Generation.call( - **self._get_llm_call_params(), - prompt=prompt - ) - - if response.status_code == HTTPStatus.OK: - return response.output.get('text', '') - else: - print(f"API调用失败: {response.status_code}, {response.message}") - return None - + session = self._create_requests_session() + try: + response = session.post( + self._build_chat_url(params["endpoint_url"]), + headers=self._build_headers(params["api_key"]), + json=self._build_payload(prompt, params=params), + timeout=params["timeout"], + ) + response.raise_for_status() + content = self._extract_response_text(response.json()) + finally: + session.close() + if content: + return content + print("API调用失败: 返回内容为空") + return None except Exception as e: print(f"调用大模型API错误: {e}") return None + def test_model(self, config: Dict[str, Any], prompt: Optional[str] = None) -> Dict[str, Any]: + params = self.build_call_params_from_config(config) + test_prompt = prompt or "请用一句中文回复:LLM测试成功。" + content = self.call_llm_api_with_config(params, test_prompt) + if not content: + raise Exception("模型无有效返回内容") + + return { + "model": params["model"], + "endpoint_url": params["endpoint_url"], + "response_preview": content[:500], + "used_params": { + "timeout": params["timeout"], + "temperature": params["temperature"], + "top_p": params["top_p"], + "max_tokens": params["max_tokens"], + }, + } + # 测试代码 if __name__ == '__main__': diff --git a/backend/app/services/system_config_service.py b/backend/app/services/system_config_service.py index ecc6e80..52a075f 100644 --- a/backend/app/services/system_config_service.py +++ b/backend/app/services/system_config_service.py @@ -4,9 +4,10 @@ from app.core.database import get_db_connection class SystemConfigService: - """系统配置服务 - 从 dict_data 表中读取和保存 system_config 类型的配置""" + """系统配置服务 - 优先从新配置表读取,兼容 dict_data(system_config) 回退""" DICT_TYPE = 'system_config' + DEFAULT_LLM_ENDPOINT_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1' # 配置键常量 ASR_VOCABULARY_ID = 'asr_vocabulary_id' @@ -27,6 +28,219 @@ class SystemConfigService: LLM_TEMPERATURE = 'llm_temperature' LLM_TOP_P = 'llm_top_p' + @staticmethod + def _parse_json_object(value: Any) -> Dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return dict(value) + if isinstance(value, str): + value = value.strip() + if not value: + return {} + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except json.JSONDecodeError: + return {} + return {} + + @staticmethod + def _normalize_string_list(value: Any) -> Optional[list[str]]: + if value is None: + return None + if isinstance(value, list): + items = [str(item).strip() for item in value if str(item).strip()] + return items or None + if isinstance(value, str): + items = [item.strip() for item in value.split(",") if item.strip()] + return items or None + return None + + @classmethod + def _build_audio_runtime_config(cls, audio_row: Dict[str, Any]) -> Dict[str, Any]: + cfg: Dict[str, Any] = {} + if not audio_row: + return cfg + + extra_config = cls._parse_json_object(audio_row.get("extra_config")) + + if audio_row.get("endpoint_url"): + cfg["endpoint_url"] = audio_row["endpoint_url"] + if audio_row.get("api_key"): + cfg["api_key"] = audio_row["api_key"] + if audio_row.get("provider"): + cfg["provider"] = audio_row["provider"] + if audio_row.get("model_code"): + cfg["model_code"] = audio_row["model_code"] + if audio_row.get("audio_scene"): + cfg["audio_scene"] = audio_row["audio_scene"] + if audio_row.get("hot_word_group_id") is not None: + cfg["hot_word_group_id"] = audio_row["hot_word_group_id"] + + if audio_row.get("audio_scene") == "asr": + if extra_config.get("model") is None and audio_row.get("asr_model_name") is not None: + extra_config["model"] = audio_row["asr_model_name"] + if extra_config.get("vocabulary_id") is None and audio_row.get("asr_vocabulary_id") is not None: + extra_config["vocabulary_id"] = audio_row["asr_vocabulary_id"] + if extra_config.get("speaker_count") is None and audio_row.get("asr_speaker_count") is not None: + extra_config["speaker_count"] = audio_row["asr_speaker_count"] + if extra_config.get("language_hints") is None and audio_row.get("asr_language_hints"): + extra_config["language_hints"] = audio_row["asr_language_hints"] + if extra_config.get("disfluency_removal_enabled") is None and audio_row.get("asr_disfluency_removal_enabled") is not None: + extra_config["disfluency_removal_enabled"] = bool(audio_row["asr_disfluency_removal_enabled"]) + if extra_config.get("diarization_enabled") is None and audio_row.get("asr_diarization_enabled") is not None: + extra_config["diarization_enabled"] = bool(audio_row["asr_diarization_enabled"]) + else: + if extra_config.get("model") is None and audio_row.get("model_name"): + extra_config["model"] = audio_row["model_name"] + if extra_config.get("template_text") is None and audio_row.get("vp_template_text") is not None: + extra_config["template_text"] = audio_row["vp_template_text"] + if extra_config.get("duration_seconds") is None and audio_row.get("vp_duration_seconds") is not None: + extra_config["duration_seconds"] = audio_row["vp_duration_seconds"] + if extra_config.get("sample_rate") is None and audio_row.get("vp_sample_rate") is not None: + extra_config["sample_rate"] = audio_row["vp_sample_rate"] + if extra_config.get("channels") is None and audio_row.get("vp_channels") is not None: + extra_config["channels"] = audio_row["vp_channels"] + if extra_config.get("max_size_bytes") is None and audio_row.get("vp_max_size_bytes") is not None: + extra_config["max_size_bytes"] = audio_row["vp_max_size_bytes"] + + language_hints = cls._normalize_string_list(extra_config.get("language_hints")) + if language_hints is not None: + extra_config["language_hints"] = language_hints + + cfg.update(extra_config) + return cfg + + @classmethod + def get_active_audio_model_config(cls, scene: str = "asr") -> Dict[str, Any]: + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key, hot_word_group_id, + asr_model_name, asr_vocabulary_id, asr_speaker_count, asr_language_hints, + asr_disfluency_removal_enabled, asr_diarization_enabled, + vp_template_text, vp_duration_seconds, vp_sample_rate, vp_channels, vp_max_size_bytes, + extra_config + FROM audio_model_config + WHERE audio_scene = %s AND is_active = 1 + ORDER BY is_default DESC, updated_at DESC, config_id ASC + LIMIT 1 + """, + (scene,), + ) + row = cursor.fetchone() + cursor.close() + return cls._build_audio_runtime_config(row) if row else {} + except Exception: + return {} + + @classmethod + def _get_parameter_value(cls, param_key: str): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT param_value + FROM sys_system_parameters + WHERE param_key = %s AND is_active = 1 + LIMIT 1 + """, + (param_key,), + ) + result = cursor.fetchone() + cursor.close() + return result["param_value"] if result else None + except Exception: + return None + + @classmethod + def _get_model_config_json(cls, model_code: str): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + # 1) llm 专表 + cursor.execute( + """ + SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout, + llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt + FROM llm_model_config + WHERE model_code = %s AND is_active = 1 + ORDER BY is_default DESC, config_id ASC + LIMIT 1 + """, + (model_code,), + ) + llm_row = cursor.fetchone() + if not llm_row and model_code == "llm_model": + cursor.execute( + """ + SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout, + llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt + FROM llm_model_config + WHERE is_active = 1 + ORDER BY is_default DESC, updated_at DESC, config_id ASC + LIMIT 1 + """ + ) + llm_row = cursor.fetchone() + if llm_row: + cursor.close() + cfg = {} + if llm_row.get("endpoint_url"): + cfg["endpoint_url"] = llm_row["endpoint_url"] + if llm_row.get("api_key"): + cfg["api_key"] = llm_row["api_key"] + if llm_row.get("llm_model_name") is not None: + cfg["model_name"] = llm_row["llm_model_name"] + if llm_row.get("llm_timeout") is not None: + cfg["time_out"] = llm_row["llm_timeout"] + if llm_row.get("llm_temperature") is not None: + cfg["temperature"] = float(llm_row["llm_temperature"]) + if llm_row.get("llm_top_p") is not None: + cfg["top_p"] = float(llm_row["llm_top_p"]) + if llm_row.get("llm_max_tokens") is not None: + cfg["max_tokens"] = llm_row["llm_max_tokens"] + if llm_row.get("llm_system_prompt") is not None: + cfg["system_prompt"] = llm_row["llm_system_prompt"] + return cfg + + # 2) audio 专表 + if model_code in ("audio_model", "voiceprint_model"): + target_scene = "voiceprint" if model_code == "voiceprint_model" else "asr" + cursor.close() + audio_cfg = cls.get_active_audio_model_config(target_scene) + return audio_cfg or None + + cursor.execute( + """ + SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key, hot_word_group_id, + asr_model_name, asr_vocabulary_id, asr_speaker_count, asr_language_hints, + asr_disfluency_removal_enabled, asr_diarization_enabled, + vp_template_text, vp_duration_seconds, vp_sample_rate, vp_channels, vp_max_size_bytes, + extra_config + FROM audio_model_config + WHERE model_code = %s AND is_active = 1 + ORDER BY is_default DESC, config_id ASC + LIMIT 1 + """, + (model_code,), + ) + audio_row = cursor.fetchone() + cursor.close() + if audio_row: + cfg = cls._build_audio_runtime_config(audio_row) + if cfg.get("max_size_bytes") is not None and cfg.get("voiceprint_max_size") is None: + cfg["voiceprint_max_size"] = cfg["max_size_bytes"] + return cfg + + return None + except Exception: + return None + @classmethod def get_config(cls, dict_code: str, default_value: Any = None) -> Any: """ @@ -39,12 +253,18 @@ class SystemConfigService: Returns: 配置项的值 """ + # 1) 新参数表 + value = cls._get_parameter_value(dict_code) + if value is not None: + return value + + # 2) 兼容旧 sys_dict_data try: with get_db_connection() as conn: cursor = conn.cursor(dictionary=True) query = """ SELECT extension_attr - FROM dict_data + FROM sys_dict_data WHERE dict_type = %s AND dict_code = %s AND status = 1 LIMIT 1 """ @@ -80,12 +300,18 @@ class SystemConfigService: Returns: 属性值 """ + # 1) 新模型配置表 + model_json = cls._get_model_config_json(dict_code) + if model_json is not None: + return model_json.get(attr_name, default_value) + + # 2) 兼容旧 sys_dict_data try: with get_db_connection() as conn: cursor = conn.cursor(dictionary=True) query = """ SELECT extension_attr - FROM dict_data + FROM sys_dict_data WHERE dict_type = %s AND dict_code = %s AND status = 1 LIMIT 1 """ @@ -119,13 +345,74 @@ class SystemConfigService: Returns: 是否设置成功 """ + # 1) 优先写入新参数表 + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + + cursor.execute( + """ + INSERT INTO sys_system_parameters + (param_key, param_name, param_value, value_type, category, description, is_active) + VALUES (%s, %s, %s, %s, %s, %s, 1) + ON DUPLICATE KEY UPDATE + param_name = VALUES(param_name), + param_value = VALUES(param_value), + value_type = VALUES(value_type), + category = VALUES(category), + description = VALUES(description), + is_active = 1 + """, + ( + dict_code, + label_cn or dict_code, + str(value) if value is not None else "", + "string", + "system", + "Migrated from legacy system_config", + ), + ) + if dict_code == cls.ASR_VOCABULARY_ID: + cursor.execute( + """ + INSERT INTO audio_model_config + (model_code, model_name, audio_scene, provider, asr_model_name, asr_vocabulary_id, asr_speaker_count, + asr_language_hints, asr_disfluency_removal_enabled, asr_diarization_enabled, description, is_active, is_default) + VALUES ( + 'audio_model', + '音频识别模型', + 'asr', + 'dashscope', + 'paraformer-v2', + %s, + 10, + 'zh,en', + 1, + 1, + '语音识别模型配置', + 1, + 1 + ) + ON DUPLICATE KEY UPDATE + asr_vocabulary_id = VALUES(asr_vocabulary_id), + is_active = 1 + """, + (str(value),), + ) + conn.commit() + cursor.close() + return True + except Exception as e: + print(f"Error setting config in sys_system_parameters {dict_code}: {e}") + + # 2) 回退写入旧 sys_dict_data try: with get_db_connection() as conn: cursor = conn.cursor(dictionary=True) # 检查配置是否存在 cursor.execute( - "SELECT id FROM dict_data WHERE dict_type = %s AND dict_code = %s", + "SELECT id FROM sys_dict_data WHERE dict_type = %s AND dict_code = %s", (cls.DICT_TYPE, dict_code) ) existing = cursor.fetchone() @@ -135,7 +422,7 @@ class SystemConfigService: if existing: # 更新现有配置 update_query = """ - UPDATE dict_data + UPDATE sys_dict_data SET extension_attr = %s, update_time = NOW() WHERE dict_type = %s AND dict_code = %s """ @@ -146,7 +433,7 @@ class SystemConfigService: label_cn = dict_code insert_query = """ - INSERT INTO dict_data ( + INSERT INTO sys_dict_data ( dict_type, dict_code, parent_code, label_cn, extension_attr, status, sort_order ) VALUES (%s, %s, 'ROOT', %s, %s, 1, 0) @@ -169,12 +456,32 @@ class SystemConfigService: Returns: 配置字典 {dict_code: value} """ + # 1) 新参数表 + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT param_key, param_value + FROM sys_system_parameters + WHERE is_active = 1 + ORDER BY category, param_key + """ + ) + rows = cursor.fetchall() + cursor.close() + if rows: + return {row["param_key"]: row["param_value"] for row in rows} + except Exception as e: + print(f"Error getting all configs from sys_system_parameters: {e}") + + # 2) 兼容旧 sys_dict_data try: with get_db_connection() as conn: cursor = conn.cursor(dictionary=True) query = """ SELECT dict_code, label_cn, extension_attr - FROM dict_data + FROM sys_dict_data WHERE dict_type = %s AND status = 1 ORDER BY sort_order """ @@ -219,19 +526,28 @@ class SystemConfigService: # 便捷方法:获取特定配置 @classmethod def get_asr_vocabulary_id(cls) -> Optional[str]: - """获取ASR热词字典ID""" + """获取ASR热词字典ID — 优先从 audio_model_config.hot_word_group_id → hot_word_group.vocabulary_id""" + audio_cfg = cls.get_active_audio_model_config("asr") + if audio_cfg.get("vocabulary_id"): + return audio_cfg["vocabulary_id"] + # 回退:直接读 audio_model_config.asr_vocabulary_id + audio_vocab = cls.get_config_attribute('audio_model', 'vocabulary_id') + if audio_vocab: + return audio_vocab return cls.get_config(cls.ASR_VOCABULARY_ID) # 声纹配置获取方法(直接使用通用方法) @classmethod def get_voiceprint_template(cls, default: str = "我正在进行声纹采集,这段语音将用于身份识别和验证。\n\n声纹技术能够准确识别每个人独特的声音特征。") -> str: """获取声纹采集模版""" - return cls.get_config_attribute('voiceprint', 'template_text', default) + return cls.get_config_attribute('voiceprint_model', 'template_text', default) @classmethod def get_voiceprint_max_size(cls, default: int = 5242880) -> int: """获取声纹文件大小限制 (bytes), 默认5MB""" - value = cls.get_config_attribute('voiceprint', 'voiceprint_max_size', default) + value = cls.get_config_attribute('voiceprint_model', 'max_size_bytes', None) + if value is None: + value = cls.get_config_attribute('voiceprint_model', 'voiceprint_max_size', default) try: return int(value) except (ValueError, TypeError): @@ -240,7 +556,7 @@ class SystemConfigService: @classmethod def get_voiceprint_duration(cls, default: int = 12) -> int: """获取声纹采集最短时长 (秒)""" - value = cls.get_config_attribute('voiceprint', 'duration_seconds', default) + value = cls.get_config_attribute('voiceprint_model', 'duration_seconds', default) try: return int(value) except (ValueError, TypeError): @@ -249,7 +565,7 @@ class SystemConfigService: @classmethod def get_voiceprint_sample_rate(cls, default: int = 16000) -> int: """获取声纹采样率""" - value = cls.get_config_attribute('voiceprint', 'sample_rate', default) + value = cls.get_config_attribute('voiceprint_model', 'sample_rate', default) try: return int(value) except (ValueError, TypeError): @@ -258,7 +574,7 @@ class SystemConfigService: @classmethod def get_voiceprint_channels(cls, default: int = 1) -> int: """获取声纹通道数""" - value = cls.get_config_attribute('voiceprint', 'channels', default) + value = cls.get_config_attribute('voiceprint_model', 'channels', default) try: return int(value) except (ValueError, TypeError): @@ -319,3 +635,33 @@ class SystemConfigService: return float(value) except (ValueError, TypeError): return default + + @classmethod + def get_llm_max_tokens(cls, default: int = 2048) -> int: + """获取LLM最大输出token""" + value = cls.get_config_attribute('llm_model', 'max_tokens', default) + try: + return int(value) + except (ValueError, TypeError): + return default + + @classmethod + def get_llm_system_prompt(cls, default: str = "请根据提供的内容进行总结和分析。") -> str: + """获取LLM系统提示词""" + value = cls.get_config_attribute('llm_model', 'system_prompt', default) + return value if isinstance(value, str) and value.strip() else default + + @classmethod + def get_llm_endpoint_url(cls, default: str = DEFAULT_LLM_ENDPOINT_URL) -> str: + """获取LLM服务Base API""" + value = cls.get_config_attribute('llm_model', 'endpoint_url', default) + return value if isinstance(value, str) and value.strip() else default + + @classmethod + def get_llm_api_key(cls, default: Optional[str] = None) -> Optional[str]: + """获取LLM服务API Key""" + value = cls.get_config_attribute('llm_model', 'api_key', default) + if value is None: + return default + value_str = str(value).strip() + return value_str or default diff --git a/backend/app/services/terminal_service.py b/backend/app/services/terminal_service.py index 9375310..6eaa7bf 100644 --- a/backend/app/services/terminal_service.py +++ b/backend/app/services/terminal_service.py @@ -49,9 +49,9 @@ class TerminalService: cu.caption as current_user_caption, dd.label_cn as terminal_type_name FROM terminals t - LEFT JOIN users u ON t.created_by = u.user_id - LEFT JOIN users cu ON t.current_user_id = cu.user_id - LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type' + LEFT JOIN sys_users u ON t.created_by = u.user_id + LEFT JOIN sys_users cu ON t.current_user_id = cu.user_id + LEFT JOIN sys_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type' WHERE {where_clause} ORDER BY t.created_at DESC LIMIT %s OFFSET %s @@ -75,8 +75,8 @@ class TerminalService: u.username as creator_username, dd.label_cn as terminal_type_name FROM terminals t - LEFT JOIN users u ON t.created_by = u.user_id - LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type' + LEFT JOIN sys_users u ON t.created_by = u.user_id + LEFT JOIN sys_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type' WHERE t.id = %s """ cursor.execute(query, (terminal_id,)) @@ -105,14 +105,17 @@ class TerminalService: query = """ INSERT INTO terminals ( - imei, terminal_name, terminal_type, description, status, created_by - ) VALUES (%s, %s, %s, %s, %s, %s) + imei, terminal_name, terminal_type, description, + firmware_version, mac_address, status, created_by + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """ cursor.execute(query, ( terminal_data.imei, terminal_data.terminal_name, terminal_data.terminal_type, terminal_data.description, + terminal_data.firmware_version, + terminal_data.mac_address, terminal_data.status, user_id )) diff --git a/backend/scripts/migrate_user_asset_layout.py b/backend/scripts/migrate_user_asset_layout.py new file mode 100644 index 0000000..6df2615 --- /dev/null +++ b/backend/scripts/migrate_user_asset_layout.py @@ -0,0 +1,97 @@ +from pathlib import Path +import shutil +import re + +from app.core.database import get_db_connection +import app.core.config as config_module + +OLD_AVATAR_PREFIX = "/uploads/user/avatar/" +NEW_AVATAR_PREFIX = "/uploads/user/" +OLD_VOICEPRINT_PREFIX = "uploads/user/voiceprint/" +NEW_VOICEPRINT_PREFIX = "uploads/user/" + + +def move_tree_contents(old_dir: Path, new_dir: Path): + if not old_dir.exists(): + return False + new_dir.mkdir(parents=True, exist_ok=True) + moved = False + for item in old_dir.iterdir(): + target = new_dir / item.name + if item.resolve() == target.resolve(): + continue + if target.exists(): + continue + shutil.move(str(item), str(target)) + moved = True + return moved + + +def migrate_avatar_files(): + legacy_root = config_module.LEGACY_AVATAR_DIR + if not legacy_root.exists(): + return + for user_dir in legacy_root.iterdir(): + if not user_dir.is_dir(): + continue + target_dir = config_module.get_user_avatar_dir(user_dir.name) + move_tree_contents(user_dir, target_dir) + + +def migrate_voiceprint_files(): + legacy_root = config_module.LEGACY_VOICEPRINT_DIR + if not legacy_root.exists(): + return + for user_dir in legacy_root.iterdir(): + if not user_dir.is_dir(): + continue + target_dir = config_module.get_user_voiceprint_dir(user_dir.name) + move_tree_contents(user_dir, target_dir) + + +def migrate_avatar_urls(cursor): + cursor.execute("SELECT user_id, avatar_url FROM sys_users WHERE avatar_url LIKE %s", (f"{OLD_AVATAR_PREFIX}%",)) + rows = cursor.fetchall() + for row in rows: + avatar_url = row["avatar_url"] + if not avatar_url: + continue + match = re.match(r"^/uploads/user/avatar/(\d+)/(.*)$", avatar_url) + if not match: + continue + user_id, filename = match.groups() + new_url = f"/uploads/user/{user_id}/avatar/{filename}" + cursor.execute("UPDATE sys_users SET avatar_url = %s WHERE user_id = %s", (new_url, row["user_id"])) + + +def migrate_voiceprint_paths(cursor): + try: + cursor.execute("SELECT vp_id, file_path FROM user_voiceprint WHERE file_path LIKE %s", (f"{OLD_VOICEPRINT_PREFIX}%",)) + except Exception: + return + rows = cursor.fetchall() + for row in rows: + file_path = row["file_path"] + if not file_path: + continue + match = re.match(r"^uploads/user/voiceprint/(\d+)/(.*)$", file_path) + if not match: + continue + user_id, filename = match.groups() + new_path = f"uploads/user/{user_id}/voiceprint/{filename}" + cursor.execute("UPDATE user_voiceprint SET file_path = %s WHERE vp_id = %s", (new_path, row["vp_id"])) + + +def main(): + migrate_avatar_files() + migrate_voiceprint_files() + + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + migrate_avatar_urls(cursor) + migrate_voiceprint_paths(cursor) + connection.commit() + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/run_sql_migration.py b/backend/scripts/run_sql_migration.py new file mode 100644 index 0000000..f0aa99c --- /dev/null +++ b/backend/scripts/run_sql_migration.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +import argparse +import re +import sys +from pathlib import Path + +import pymysql + + +def parse_env_file(env_path: Path): + data = {} + for raw in env_path.read_text(encoding='utf-8').splitlines(): + line = raw.strip() + if not line or line.startswith('#'): + continue + if '=' not in line: + continue + k, v = line.split('=', 1) + data[k.strip()] = v.strip() + return data + + +def split_sql_statements(sql_text: str): + statements = [] + buf = [] + in_single = False + in_double = False + in_line_comment = False + in_block_comment = False + + i = 0 + while i < len(sql_text): + ch = sql_text[i] + nxt = sql_text[i + 1] if i + 1 < len(sql_text) else '' + + if in_line_comment: + if ch == '\n': + in_line_comment = False + buf.append(ch) + i += 1 + continue + + if in_block_comment: + if ch == '*' and nxt == '/': + in_block_comment = False + i += 2 + else: + i += 1 + continue + + if not in_single and not in_double: + if ch == '-' and nxt == '-': + in_line_comment = True + i += 2 + continue + if ch == '#': + in_line_comment = True + i += 1 + continue + if ch == '/' and nxt == '*': + in_block_comment = True + i += 2 + continue + + if ch == "'" and not in_double: + in_single = not in_single + buf.append(ch) + i += 1 + continue + + if ch == '"' and not in_single: + in_double = not in_double + buf.append(ch) + i += 1 + continue + + if ch == ';' and not in_single and not in_double: + stmt = ''.join(buf).strip() + if stmt: + statements.append(stmt) + buf = [] + i += 1 + continue + + buf.append(ch) + i += 1 + + tail = ''.join(buf).strip() + if tail: + statements.append(tail) + return statements + + +def main(): + parser = argparse.ArgumentParser(description='Run SQL migration from file') + parser.add_argument('--env', default='backend/.env', help='Path to .env file') + parser.add_argument('--sql', required=True, help='Path to SQL file') + args = parser.parse_args() + + env = parse_env_file(Path(args.env)) + sql_path = Path(args.sql) + if not sql_path.exists(): + print(f'[ERROR] SQL file not found: {sql_path}') + return 1 + + sql_text = sql_path.read_text(encoding='utf-8') + statements = split_sql_statements(sql_text) + if not statements: + print('[ERROR] No SQL statements found') + return 1 + + conn = pymysql.connect( + host=env.get('DB_HOST', '127.0.0.1'), + port=int(env.get('DB_PORT', '3306')), + user=env.get('DB_USER', 'root'), + password=env.get('DB_PASSWORD', ''), + database=env.get('DB_NAME', ''), + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor, + autocommit=False, + ) + + # duplicate column/index tolerated for idempotency rerun + tolerated_errnos = {1060, 1061, 1831} + + try: + with conn.cursor() as cur: + print(f'[INFO] Running {len(statements)} statements from {sql_path}') + for idx, stmt in enumerate(statements, start=1): + normalized = re.sub(r'\s+', ' ', stmt).strip() + head = normalized[:120] + try: + cur.execute(stmt) + print(f'[OK] {idx:03d}: {head}') + except pymysql.MySQLError as e: + if e.args and e.args[0] in tolerated_errnos: + print(f'[SKIP] {idx:03d}: errno={e.args[0]} {e.args[1]} | {head}') + continue + conn.rollback() + print(f'[FAIL] {idx:03d}: errno={e.args[0] if e.args else "?"} {e}') + print(f'[STMT] {head}') + return 2 + + conn.commit() + print('[INFO] Migration committed successfully') + + checks = [ + "SELECT COUNT(*) AS cnt FROM menus", + "SELECT COUNT(*) AS cnt FROM role_menu_permissions", + "SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='menus' AND COLUMN_NAME IN ('menu_level','tree_path','is_visible')", + "SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='role_menu_permissions' AND COLUMN_NAME IN ('granted_by','granted_at')", + ] + for q in checks: + cur.execute(q) + row = cur.fetchone() + print(f'[CHECK] {q} => {row}') + + finally: + conn.close() + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/backend/scripts/run_sql_migration_connector.py b/backend/scripts/run_sql_migration_connector.py new file mode 100644 index 0000000..6453016 --- /dev/null +++ b/backend/scripts/run_sql_migration_connector.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +import argparse +import sys +from pathlib import Path + +import mysql.connector + + +def parse_env_file(env_path: Path): + data = {} + for raw in env_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def split_sql_statements(sql_text: str): + statements = [] + buf = [] + in_single = False + in_double = False + in_line_comment = False + in_block_comment = False + i = 0 + + while i < len(sql_text): + ch = sql_text[i] + nxt = sql_text[i + 1] if i + 1 < len(sql_text) else "" + + if in_line_comment: + if ch == "\n": + in_line_comment = False + buf.append(ch) + i += 1 + continue + + if in_block_comment: + if ch == "*" and nxt == "/": + in_block_comment = False + i += 2 + else: + i += 1 + continue + + if not in_single and not in_double: + if ch == "-" and nxt == "-": + in_line_comment = True + i += 2 + continue + if ch == "#": + in_line_comment = True + i += 1 + continue + if ch == "/" and nxt == "*": + in_block_comment = True + i += 2 + continue + + if ch == "'" and not in_double: + in_single = not in_single + buf.append(ch) + i += 1 + continue + + if ch == '"' and not in_single: + in_double = not in_double + buf.append(ch) + i += 1 + continue + + if ch == ";" and not in_single and not in_double: + stmt = "".join(buf).strip() + if stmt: + statements.append(stmt) + buf = [] + i += 1 + continue + + buf.append(ch) + i += 1 + + tail = "".join(buf).strip() + if tail: + statements.append(tail) + return statements + + +def main(): + parser = argparse.ArgumentParser(description="Run SQL migration using mysql-connector") + parser.add_argument("--env", default="backend/.env", help="Path to .env file") + parser.add_argument("--sql", required=True, help="Path to SQL file") + args = parser.parse_args() + + env = parse_env_file(Path(args.env)) + sql_path = Path(args.sql) + if not sql_path.exists(): + print(f"[ERROR] SQL file not found: {sql_path}") + return 1 + + sql_text = sql_path.read_text(encoding="utf-8") + statements = split_sql_statements(sql_text) + if not statements: + print("[ERROR] No SQL statements found") + return 1 + + conn = mysql.connector.connect( + host=env.get("DB_HOST", "127.0.0.1"), + port=int(env.get("DB_PORT", "3306")), + user=env.get("DB_USER", "root"), + password=env.get("DB_PASSWORD", ""), + database=env.get("DB_NAME", ""), + ) + + try: + cur = conn.cursor(dictionary=True) + print(f"[INFO] Running {len(statements)} statements from {sql_path}") + for idx, stmt in enumerate(statements, start=1): + head = " ".join(stmt.split())[:120] + cur.execute(stmt) + if cur.with_rows: + cur.fetchall() + print(f"[OK] {idx:03d}: {head}") + conn.commit() + print("[INFO] Migration committed successfully") + except Exception as e: + conn.rollback() + print(f"[FAIL] {e}") + return 2 + finally: + conn.close() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/sql/add_menu_permissions_system.sql b/backend/sql/add_menu_permissions_system.sql index 7fbad14..02ad15d 100644 --- a/backend/sql/add_menu_permissions_system.sql +++ b/backend/sql/add_menu_permissions_system.sql @@ -1,7 +1,7 @@ -- =================================================================== -- 菜单权限系统数据库迁移脚本 -- 创建日期: 2025-12-10 --- 说明: 添加 menus 表和 role_menu_permissions 表,实现基于角色的菜单权限管理 +-- 说明: 添加 menus 表和 role_menu_permissions 表,实现基于角色的多级菜单权限管理 -- =================================================================== -- ---------------------------- @@ -16,15 +16,21 @@ CREATE TABLE `menus` ( `menu_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '菜单URL/路由', `menu_type` enum('action','link','divider') COLLATE utf8mb4_unicode_ci DEFAULT 'action' COMMENT '菜单类型: action-操作/link-链接/divider-分隔符', `parent_id` int(11) DEFAULT NULL COMMENT '父菜单ID(用于层级菜单)', + `menu_level` tinyint(3) NOT NULL DEFAULT 1 COMMENT '菜单层级(根节点为1)', + `tree_path` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '树路径(如 /3/6)', `sort_order` int(11) DEFAULT 0 COMMENT '排序顺序', `is_active` tinyint(1) DEFAULT 1 COMMENT '是否启用: 1-启用, 0-禁用', + `is_visible` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否在侧边菜单显示', `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '菜单描述', `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`menu_id`), UNIQUE KEY `uk_menu_code` (`menu_code`), KEY `idx_parent_id` (`parent_id`), - KEY `idx_is_active` (`is_active`) + KEY `idx_menu_level` (`menu_level`), + KEY `idx_tree_path` (`tree_path`), + KEY `idx_is_active` (`is_active`), + KEY `idx_is_visible` (`is_visible`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统菜单表'; -- ---------------------------- @@ -35,28 +41,66 @@ CREATE TABLE `role_menu_permissions` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '权限ID', `role_id` int(11) NOT NULL COMMENT '角色ID', `menu_id` int(11) NOT NULL COMMENT '菜单ID', + `granted_by` int(11) DEFAULT NULL COMMENT '授权操作人ID', + `granted_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '授权时间', `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_role_menu` (`role_id`,`menu_id`), KEY `idx_role_id` (`role_id`), KEY `idx_menu_id` (`menu_id`), + KEY `idx_granted_by` (`granted_by`), + KEY `idx_granted_at` (`granted_at`), CONSTRAINT `fk_rmp_role_id` FOREIGN KEY (`role_id`) REFERENCES `roles` (`role_id`) ON DELETE CASCADE, CONSTRAINT `fk_rmp_menu_id` FOREIGN KEY (`menu_id`) REFERENCES `menus` (`menu_id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单权限映射表'; -- ---------------------------- --- 初始化菜单数据(基于现有系统的下拉菜单) +-- 初始化菜单数据 -- ---------------------------- BEGIN; --- 用户菜单项 -INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `sort_order`, `is_active`, `description`) +-- 一级菜单 +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`) VALUES -('change_password', '修改密码', 'KeyRound', NULL, 'action', 1, 1, '用户修改自己的密码'), -('prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', 2, 1, '管理AI提示词模版'), -('platform_admin', '平台管理', 'Shield', '/admin/management', 'link', 3, 1, '平台管理员后台'), -('logout', '退出登录', 'LogOut', NULL, 'action', 99, 1, '退出当前账号'); +('account_settings', '账户设置', 'UserCog', '/account-settings', 'link', NULL, 1, NULL, 1, 1, 1, '管理个人账户信息'), +('prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', NULL, 1, NULL, 2, 1, 1, '管理AI提示词模版'), +('platform_admin', '平台管理', 'Shield', '/admin/management/user-management', 'link', NULL, 1, NULL, 3, 1, 1, '平台管理员后台'), +('logout', '退出登录', 'LogOut', NULL, 'action', NULL, 1, NULL, 99, 1, 1, '退出当前账号'); + +-- 二级菜单(挂载到平台管理) +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`) +SELECT 'user_management', '用户管理', 'Users', '/admin/management/user-management', 'link', menu_id, 2, NULL, 1, 1, 1, '账号、角色、密码重置' +FROM `menus` WHERE `menu_code` = 'platform_admin'; + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`) +SELECT 'permission_management', '权限管理', 'KeyRound', '/admin/management/permission-management', 'link', menu_id, 2, NULL, 2, 1, 1, '菜单与角色授权矩阵' +FROM `menus` WHERE `menu_code` = 'platform_admin'; + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`) +SELECT 'dict_management', '字典管理', 'BookMarked', '/admin/management/dict-management', 'link', menu_id, 2, NULL, 3, 1, 1, '码表、平台类型、扩展属性' +FROM `menus` WHERE `menu_code` = 'platform_admin'; + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`) +SELECT 'hot_word_management', '热词管理', 'Text', '/admin/management/hot-word-management', 'link', menu_id, 2, NULL, 4, 1, 1, 'ASR 热词与同步' +FROM `menus` WHERE `menu_code` = 'platform_admin'; + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`) +SELECT 'client_management', '客户端管理', 'Smartphone', '/admin/management/client-management', 'link', menu_id, 2, NULL, 5, 1, 1, '版本、下载地址、发布状态' +FROM `menus` WHERE `menu_code` = 'platform_admin'; + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`) +SELECT 'external_app_management', '外部应用管理', 'AppWindow', '/admin/management/external-app-management', 'link', menu_id, 2, NULL, 6, 1, 1, '外部系统入口与图标配置' +FROM `menus` WHERE `menu_code` = 'platform_admin'; + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`) +SELECT 'terminal_management', '终端管理', 'Monitor', '/admin/management/terminal-management', 'link', menu_id, 2, NULL, 7, 1, 1, '专用设备、激活和绑定状态' +FROM `menus` WHERE `menu_code` = 'platform_admin'; + +-- 回填路径 +UPDATE `menus` SET `tree_path` = CONCAT('/', `menu_id`) WHERE `parent_id` IS NULL; +UPDATE `menus` c JOIN `menus` p ON c.`parent_id` = p.`menu_id` +SET c.`tree_path` = CONCAT(p.`tree_path`, '/', c.`menu_id`); COMMIT; @@ -70,30 +114,19 @@ BEGIN; INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`) SELECT 1, menu_id FROM `menus` WHERE is_active = 1; --- 普通用户(role_id=2)拥有除"平台管理"外的所有菜单权限 +-- 普通用户(role_id=2)排除平台管理与其二级子菜单 INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`) -SELECT 2, menu_id FROM `menus` WHERE menu_code != 'platform_admin' AND is_active = 1; +SELECT 2, menu_id FROM `menus` +WHERE is_active = 1 + AND menu_code NOT IN ( + 'platform_admin', + 'user_management', + 'permission_management', + 'dict_management', + 'hot_word_management', + 'client_management', + 'external_app_management', + 'terminal_management' + ); COMMIT; - --- ---------------------------- --- 查询验证 --- ---------------------------- --- 查看所有菜单 --- SELECT * FROM menus ORDER BY sort_order; - --- 查看平台管理员的菜单权限 --- SELECT r.role_name, m.menu_name, m.menu_code, m.menu_url --- FROM role_menu_permissions rmp --- JOIN roles r ON rmp.role_id = r.role_id --- JOIN menus m ON rmp.menu_id = m.menu_id --- WHERE r.role_id = 1 --- ORDER BY m.sort_order; - --- 查看普通用户的菜单权限 --- SELECT r.role_name, m.menu_name, m.menu_code, m.menu_url --- FROM role_menu_permissions rmp --- JOIN roles r ON rmp.role_id = r.role_id --- JOIN menus m ON rmp.menu_id = m.menu_id --- WHERE r.role_id = 2 --- ORDER BY m.sort_order; diff --git a/backend/sql/imeeting-init.sql b/backend/sql/imeeting-init.sql index 86801fe..44021f8 100644 --- a/backend/sql/imeeting-init.sql +++ b/backend/sql/imeeting-init.sql @@ -544,7 +544,7 @@ CREATE TABLE `menus` ( UNIQUE KEY `uk_menu_code` (`menu_code`), KEY `idx_parent_id` (`parent_id`), KEY `idx_is_active` (`is_active`) -) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统菜单表'; +) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统菜单表'; -- ---------------------------- -- Records of menus @@ -552,8 +552,15 @@ CREATE TABLE `menus` ( BEGIN; INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (1, 'account_settings', '账户设置', 'UserCog', '/account-settings', 'link', NULL, 1, 1, '管理个人账户信息', '2025-12-10 15:31:45', '2026-01-15 07:48:05'); INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (2, 'prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', NULL, 2, 1, '管理AI提示词模版', '2025-12-10 15:31:45', '2025-12-10 15:31:45'); -INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (3, 'platform_admin', '平台管理', 'Shield', '/admin/management', 'link', NULL, 3, 1, '平台管理员后台', '2025-12-10 15:31:45', '2025-12-10 15:31:45'); +INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (3, 'platform_admin', '平台管理', 'Shield', '/admin/management/user-management', 'link', NULL, 3, 1, '平台管理员后台', '2025-12-10 15:31:45', '2025-12-10 15:31:45'); INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (4, 'logout', '退出登录', 'LogOut', NULL, 'action', NULL, 99, 1, '退出当前账号', '2025-12-10 15:31:45', '2025-12-10 15:31:45'); +INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (5, 'user_management', '用户管理', 'Users', '/admin/management/user-management', 'link', 3, 1, 1, '账号、角色、密码重置', '2025-12-10 15:31:45', '2025-12-10 15:31:45'); +INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (6, 'permission_management', '权限管理', 'KeyRound', '/admin/management/permission-management', 'link', 3, 2, 1, '菜单与角色授权矩阵', '2025-12-10 15:31:45', '2025-12-10 15:31:45'); +INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (7, 'dict_management', '字典管理', 'BookMarked', '/admin/management/dict-management', 'link', 3, 3, 1, '码表、平台类型、扩展属性', '2025-12-10 15:31:45', '2025-12-10 15:31:45'); +INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (8, 'hot_word_management', '热词管理', 'Text', '/admin/management/hot-word-management', 'link', 3, 4, 1, 'ASR 热词与同步', '2025-12-10 15:31:45', '2025-12-10 15:31:45'); +INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (9, 'client_management', '客户端管理', 'Smartphone', '/admin/management/client-management', 'link', 3, 5, 1, '版本、下载地址、发布状态', '2025-12-10 15:31:45', '2025-12-10 15:31:45'); +INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (10, 'external_app_management', '外部应用管理', 'AppWindow', '/admin/management/external-app-management', 'link', 3, 6, 1, '外部系统入口与图标配置', '2025-12-10 15:31:45', '2025-12-10 15:31:45'); +INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (11, 'terminal_management', '终端管理', 'Monitor', '/admin/management/terminal-management', 'link', 3, 7, 1, '专用设备、激活和绑定状态', '2025-12-10 15:31:45', '2025-12-10 15:31:45'); COMMIT; -- ---------------------------- @@ -607,7 +614,7 @@ CREATE TABLE `role_menu_permissions` ( KEY `idx_menu_id` (`menu_id`), CONSTRAINT `fk_rmp_menu_id` FOREIGN KEY (`menu_id`) REFERENCES `menus` (`menu_id`) ON DELETE CASCADE, CONSTRAINT `fk_rmp_role_id` FOREIGN KEY (`role_id`) REFERENCES `roles` (`role_id`) ON DELETE CASCADE -) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单权限映射表'; +) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单权限映射表'; -- ---------------------------- -- Records of role_menu_permissions @@ -617,8 +624,16 @@ INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, ` INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (12, 1, 2, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (13, 1, 3, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (14, 1, 4, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); -INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (15, 2, 1, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); -INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (16, 2, 4, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); +INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (15, 1, 5, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); +INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (16, 1, 6, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); +INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (17, 1, 7, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); +INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (18, 1, 8, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); +INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (19, 1, 9, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); +INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (20, 1, 10, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); +INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (21, 1, 11, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); +INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (22, 2, 1, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); +INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (23, 2, 2, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); +INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (24, 2, 4, '2025-12-11 11:00:15', '2025-12-11 11:00:15'); COMMIT; -- ---------------------------- diff --git a/backend/sql/migrations/add_extra_config_to_audio_model_config.sql b/backend/sql/migrations/add_extra_config_to_audio_model_config.sql new file mode 100644 index 0000000..d4d8adc --- /dev/null +++ b/backend/sql/migrations/add_extra_config_to_audio_model_config.sql @@ -0,0 +1,24 @@ +ALTER TABLE `audio_model_config` + ADD COLUMN `extra_config` JSON DEFAULT NULL COMMENT '音频模型差异化配置(JSON)' AFTER `hot_word_group_id`; + +UPDATE `audio_model_config` +SET `extra_config` = CASE + WHEN `audio_scene` = 'asr' THEN JSON_OBJECT( + 'model', `asr_model_name`, + 'vocabulary_id', `asr_vocabulary_id`, + 'speaker_count', `asr_speaker_count`, + 'language_hints', `asr_language_hints`, + 'disfluency_removal_enabled', `asr_disfluency_removal_enabled`, + 'diarization_enabled', `asr_diarization_enabled` + ) + WHEN `audio_scene` = 'voiceprint' THEN JSON_OBJECT( + 'model', `model_name`, + 'template_text', `vp_template_text`, + 'duration_seconds', `vp_duration_seconds`, + 'sample_rate', `vp_sample_rate`, + 'channels', `vp_channels`, + 'max_size_bytes', `vp_max_size_bytes` + ) + ELSE JSON_OBJECT() +END +WHERE `extra_config` IS NULL; diff --git a/backend/sql/migrations/add_personal_meeting_menus.sql b/backend/sql/migrations/add_personal_meeting_menus.sql new file mode 100644 index 0000000..7ea03f8 --- /dev/null +++ b/backend/sql/migrations/add_personal_meeting_menus.sql @@ -0,0 +1,58 @@ +START TRANSACTION; + +SET @meeting_manage_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'meeting_manage' LIMIT 1); +SET @history_meetings_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'history_meetings' LIMIT 1); +SET @prompt_config_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'prompt_config' LIMIT 1); +SET @personal_prompt_library_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'personal_prompt_library' LIMIT 1); + +INSERT INTO sys_menus ( + menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description +) +SELECT + 'history_meetings', '历史会议', 'CalendarOutlined', '/meetings/history', 'link', @meeting_manage_id, 1, 1, '普通用户历史会议' +FROM DUAL +WHERE @meeting_manage_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menus WHERE menu_code = 'history_meetings'); + +SET @history_meetings_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'history_meetings' LIMIT 1); + +UPDATE sys_menus +SET + parent_id = @meeting_manage_id, + sort_order = 2, + menu_name = '提示词配置', + menu_icon = COALESCE(menu_icon, 'BookOutlined') +WHERE menu_code = 'prompt_config'; + +INSERT INTO sys_menus ( + menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description +) +SELECT + 'personal_prompt_library', '个人提示词仓库', 'ReadOutlined', '/personal-prompts', 'link', @meeting_manage_id, 3, 1, '普通用户个人提示词仓库' +FROM DUAL +WHERE @meeting_manage_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menus WHERE menu_code = 'personal_prompt_library'); + +SET @personal_prompt_library_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'personal_prompt_library' LIMIT 1); + +INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at) +SELECT 2, @meeting_manage_id, NOW() +FROM DUAL +WHERE @meeting_manage_id IS NOT NULL; + +INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at) +SELECT 2, @history_meetings_id, NOW() +FROM DUAL +WHERE @history_meetings_id IS NOT NULL; + +INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at) +SELECT 2, @prompt_config_id, NOW() +FROM DUAL +WHERE @prompt_config_id IS NOT NULL; + +INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at) +SELECT 2, @personal_prompt_library_id, NOW() +FROM DUAL +WHERE @personal_prompt_library_id IS NOT NULL; + +COMMIT; diff --git a/backend/sql/migrations/create_parameter_and_model_management.sql b/backend/sql/migrations/create_parameter_and_model_management.sql new file mode 100644 index 0000000..e222159 --- /dev/null +++ b/backend/sql/migrations/create_parameter_and_model_management.sql @@ -0,0 +1,173 @@ +-- Migration: create parameter/model management and migrate system_config +-- Created at: 2026-03-12 + +BEGIN; + +CREATE TABLE IF NOT EXISTS `sys_system_parameters` ( + `param_id` bigint(20) NOT NULL AUTO_INCREMENT, + `param_key` varchar(128) NOT NULL, + `param_name` varchar(255) NOT NULL, + `param_value` text, + `value_type` varchar(32) NOT NULL DEFAULT 'string', + `category` varchar(64) NOT NULL DEFAULT 'system', + `description` varchar(500) DEFAULT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT 1, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`param_id`), + UNIQUE KEY `uk_param_key` (`param_key`), + KEY `idx_param_category` (`category`), + KEY `idx_param_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `ai_model_configs` ( + `model_id` bigint(20) NOT NULL AUTO_INCREMENT, + `model_code` varchar(128) NOT NULL, + `model_name` varchar(255) NOT NULL, + `model_type` varchar(32) NOT NULL, + `provider` varchar(64) DEFAULT NULL, + `config_json` json DEFAULT NULL, + `description` varchar(500) DEFAULT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT 1, + `is_default` tinyint(1) NOT NULL DEFAULT 0, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`model_id`), + UNIQUE KEY `uk_model_code` (`model_code`), + KEY `idx_model_type` (`model_type`), + KEY `idx_model_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- migrate system_config parameters except model-like records +INSERT INTO `sys_system_parameters` (`param_key`, `param_name`, `param_value`, `value_type`, `category`, `description`, `is_active`) +SELECT + d.`dict_code`, + d.`label_cn`, + JSON_UNQUOTE(JSON_EXTRACT(d.`extension_attr`, '$.value')), + 'string', + 'system', + CONCAT('migrated from dict_data.system_config(', d.`dict_code`, ')'), + CASE WHEN d.`status` = 1 THEN 1 ELSE 0 END +FROM `sys_dict_data` d +WHERE d.`dict_type` = 'system_config' + AND d.`dict_code` NOT IN ('llm_model', 'voiceprint') + AND JSON_EXTRACT(d.`extension_attr`, '$.value') IS NOT NULL +ON DUPLICATE KEY UPDATE + `param_name` = VALUES(`param_name`), + `param_value` = VALUES(`param_value`), + `is_active` = VALUES(`is_active`); + +-- migrate llm model +INSERT INTO `ai_model_configs` (`model_code`, `model_name`, `model_type`, `provider`, `config_json`, `description`, `is_active`, `is_default`) +SELECT + 'llm_model', + 'LLM文本模型', + 'llm', + 'dashscope', + d.`extension_attr`, + 'migrated from dict_data.system_config.llm_model', + CASE WHEN d.`status` = 1 THEN 1 ELSE 0 END, + 1 +FROM `sys_dict_data` d +WHERE d.`dict_type` = 'system_config' + AND d.`dict_code` = 'llm_model' +LIMIT 1 +ON DUPLICATE KEY UPDATE + `config_json` = VALUES(`config_json`), + `is_active` = VALUES(`is_active`); + +-- migrate audio model (voiceprint) +INSERT INTO `ai_model_configs` (`model_code`, `model_name`, `model_type`, `provider`, `config_json`, `description`, `is_active`, `is_default`) +SELECT + 'voiceprint_model', + '声纹模型', + 'audio', + 'funasr', + d.`extension_attr`, + 'migrated from dict_data.system_config.voiceprint', + CASE WHEN d.`status` = 1 THEN 1 ELSE 0 END, + 1 +FROM `sys_dict_data` d +WHERE d.`dict_type` = 'system_config' + AND d.`dict_code` = 'voiceprint' +LIMIT 1 +ON DUPLICATE KEY UPDATE + `config_json` = VALUES(`config_json`), + `is_active` = VALUES(`is_active`); + +-- ensure audio ASR model exists (from current hard-coded settings) +INSERT INTO `ai_model_configs` (`model_code`, `model_name`, `model_type`, `provider`, `config_json`, `description`, `is_active`, `is_default`) +SELECT + 'audio_model', + '音频识别模型', + 'audio', + 'dashscope', + JSON_OBJECT( + 'model', 'paraformer-v2', + 'language_hints', JSON_ARRAY('zh', 'en'), + 'disfluency_removal_enabled', TRUE, + 'diarization_enabled', TRUE, + 'speaker_count', 10, + 'vocabulary_id', ( + SELECT JSON_UNQUOTE(JSON_EXTRACT(extension_attr, '$.value')) + FROM sys_dict_data + WHERE dict_type = 'system_config' AND dict_code = 'asr_vocabulary_id' + LIMIT 1 + ) + ), + '默认音频识别模型', + 1, + 1 +FROM dual +WHERE NOT EXISTS ( + SELECT 1 FROM ai_model_configs WHERE model_code = 'audio_model' +); + +-- add new platform submenus +INSERT IGNORE INTO `sys_menus` +(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `is_visible`, `description`) +SELECT + 'parameter_management', + '参数管理', + 'Setting', + '/admin/management/parameter-management', + 'link', + m.`menu_id`, + 8, + 1, + 1, + '系统参数管理' +FROM `sys_menus` m +WHERE m.`menu_code` = 'platform_admin'; + +INSERT IGNORE INTO `sys_menus` +(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `is_visible`, `description`) +SELECT + 'model_management', + '模型管理', + 'Appstore', + '/admin/management/model-management', + 'link', + m.`menu_id`, + 9, + 1, + 1, + '音频/LLM模型配置管理' +FROM `sys_menus` m +WHERE m.`menu_code` = 'platform_admin'; + +-- role 1 gets full access +INSERT IGNORE INTO `sys_role_menu_permissions` (`role_id`, `menu_id`, `granted_by`) +SELECT 1, m.menu_id, 1 +FROM sys_menus m +WHERE m.menu_code IN ('parameter_management', 'model_management'); + +-- backfill menu tree metadata for newly inserted rows +UPDATE `sys_menus` c +JOIN `sys_menus` p ON c.`parent_id` = p.`menu_id` +SET c.`menu_level` = p.`menu_level` + 1, + c.`tree_path` = CONCAT(p.`tree_path`, '/', c.`menu_id`) +WHERE c.`menu_code` IN ('parameter_management', 'model_management') + AND (c.`tree_path` IS NULL OR c.`menu_level` = 1); + +COMMIT; diff --git a/backend/sql/migrations/create_system_management_menu_group.sql b/backend/sql/migrations/create_system_management_menu_group.sql new file mode 100644 index 0000000..8882fd5 --- /dev/null +++ b/backend/sql/migrations/create_system_management_menu_group.sql @@ -0,0 +1,25 @@ +-- Migration: add system management root menu and regroup selected modules +-- Created at: 2026-03-12 + +BEGIN; + +-- ensure system_management root menu exists +INSERT INTO sys_menus +(menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, menu_level, tree_path, sort_order, is_active, is_visible, description) +SELECT + 'system_management', + '系统管理', + 'Setting', + '/admin/management/user-management', + 'link', + NULL, + 1, + NULL, + 4, + 1, + 1, + '系统基础配置管理(用户、权限、字段、参数)' +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM sys_menus WHERE menu_code = 'system_management'); + +COMMIT; diff --git a/backend/sql/migrations/create_user_mcp_table.sql b/backend/sql/migrations/create_user_mcp_table.sql new file mode 100644 index 0000000..04c5ca2 --- /dev/null +++ b/backend/sql/migrations/create_user_mcp_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS `sys_user_mcp` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `bot_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `bot_secret` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` tinyint(1) NOT NULL DEFAULT 1, + `last_used_at` datetime DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sys_user_mcp_user_id` (`user_id`), + UNIQUE KEY `uk_sys_user_mcp_bot_id` (`bot_id`), + KEY `idx_sys_user_mcp_status` (`status`), + CONSTRAINT `fk_sys_user_mcp_user` FOREIGN KEY (`user_id`) REFERENCES `sys_users` (`user_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户MCP接入凭证'; diff --git a/backend/sql/migrations/expand_ai_model_configs_columns.sql b/backend/sql/migrations/expand_ai_model_configs_columns.sql new file mode 100644 index 0000000..171dfad --- /dev/null +++ b/backend/sql/migrations/expand_ai_model_configs_columns.sql @@ -0,0 +1,52 @@ +-- Migration: expand ai_model_configs with structured columns +-- Created at: 2026-03-12 + +BEGIN; + +ALTER TABLE `ai_model_configs` + ADD COLUMN `endpoint_url` varchar(512) DEFAULT NULL COMMENT '模型服务 API 地址' AFTER `provider`, + ADD COLUMN `api_key` varchar(512) DEFAULT NULL COMMENT '模型服务 API Key' AFTER `endpoint_url`, + ADD COLUMN `llm_model_name` varchar(128) DEFAULT NULL COMMENT 'LLM 模型名称' AFTER `api_key`, + ADD COLUMN `llm_timeout` int(11) DEFAULT NULL COMMENT 'LLM 超时(秒)' AFTER `llm_model_name`, + ADD COLUMN `llm_temperature` decimal(5,2) DEFAULT NULL COMMENT 'LLM temperature' AFTER `llm_timeout`, + ADD COLUMN `llm_top_p` decimal(5,2) DEFAULT NULL COMMENT 'LLM top_p' AFTER `llm_temperature`, + ADD COLUMN `llm_max_tokens` int(11) DEFAULT NULL COMMENT 'LLM 最大token' AFTER `llm_top_p`, + ADD COLUMN `llm_system_prompt` text DEFAULT NULL COMMENT 'LLM 系统提示词' AFTER `llm_max_tokens`, + ADD COLUMN `asr_model_name` varchar(128) DEFAULT NULL COMMENT 'ASR 模型名称' AFTER `llm_system_prompt`, + ADD COLUMN `asr_vocabulary_id` varchar(255) DEFAULT NULL COMMENT 'ASR 热词词表ID' AFTER `asr_model_name`, + ADD COLUMN `asr_speaker_count` int(11) DEFAULT NULL COMMENT 'ASR 说话人数' AFTER `asr_vocabulary_id`, + ADD COLUMN `asr_language_hints` varchar(255) DEFAULT NULL COMMENT 'ASR 语言提示,逗号分隔' AFTER `asr_speaker_count`, + ADD COLUMN `asr_disfluency_removal_enabled` tinyint(1) DEFAULT NULL COMMENT 'ASR 去口头语开关' AFTER `asr_language_hints`, + ADD COLUMN `asr_diarization_enabled` tinyint(1) DEFAULT NULL COMMENT 'ASR 说话人分离开关' AFTER `asr_disfluency_removal_enabled`; + +-- backfill structured columns from existing config_json +UPDATE `ai_model_configs` +SET + endpoint_url = COALESCE(endpoint_url, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.endpoint_url'))), + api_key = COALESCE(api_key, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.api_key'))) +WHERE config_json IS NOT NULL; + +UPDATE `ai_model_configs` +SET + llm_model_name = COALESCE(llm_model_name, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.model_name'))), + llm_timeout = COALESCE(llm_timeout, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.time_out')) AS UNSIGNED)), + llm_temperature = COALESCE(llm_temperature, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.temperature')) AS DECIMAL(5,2))), + llm_top_p = COALESCE(llm_top_p, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.top_p')) AS DECIMAL(5,2))), + llm_max_tokens = COALESCE(llm_max_tokens, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.max_tokens')) AS UNSIGNED)), + llm_system_prompt = COALESCE(llm_system_prompt, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.system_prompt'))) +WHERE model_type = 'llm' AND config_json IS NOT NULL; + +UPDATE `ai_model_configs` +SET + asr_model_name = COALESCE(asr_model_name, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.model'))), + asr_vocabulary_id = COALESCE(asr_vocabulary_id, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.vocabulary_id'))), + asr_speaker_count = COALESCE(asr_speaker_count, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.speaker_count')) AS UNSIGNED)), + asr_language_hints = COALESCE( + asr_language_hints, + REPLACE(REPLACE(REPLACE(JSON_EXTRACT(config_json, '$.language_hints'), '\"', ''), '[', ''), ']', '') + ), + asr_disfluency_removal_enabled = COALESCE(asr_disfluency_removal_enabled, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.disfluency_removal_enabled')) AS UNSIGNED)), + asr_diarization_enabled = COALESCE(asr_diarization_enabled, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.diarization_enabled')) AS UNSIGNED)) +WHERE model_type = 'audio' AND config_json IS NOT NULL; + +COMMIT; diff --git a/backend/sql/migrations/expand_menu_permission_tables.sql b/backend/sql/migrations/expand_menu_permission_tables.sql new file mode 100644 index 0000000..7fe267c --- /dev/null +++ b/backend/sql/migrations/expand_menu_permission_tables.sql @@ -0,0 +1,87 @@ +-- Migration: expand sys_menus and sys_role_menu_permissions for menu-tree governance +-- Created at: 2026-03-03 + +BEGIN; + +-- 1) Extend sys_menus table +ALTER TABLE `sys_menus` + ADD COLUMN `menu_level` tinyint(3) NOT NULL DEFAULT 1 COMMENT '菜单层级(根节点为1)' AFTER `parent_id`, + ADD COLUMN `tree_path` varchar(255) DEFAULT NULL COMMENT '树路径(如 /3/6)' AFTER `menu_level`, + ADD COLUMN `is_visible` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否在侧边菜单显示' AFTER `is_active`; + +ALTER TABLE `sys_menus` + ADD KEY `idx_menu_level` (`menu_level`), + ADD KEY `idx_tree_path` (`tree_path`), + ADD KEY `idx_is_visible` (`is_visible`); + +-- 2) Extend sys_role_menu_permissions table +ALTER TABLE `sys_role_menu_permissions` + ADD COLUMN `granted_by` int(11) DEFAULT NULL COMMENT '授权操作人ID' AFTER `menu_id`, + ADD COLUMN `granted_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '授权时间' AFTER `granted_by`; + +ALTER TABLE `sys_role_menu_permissions` + ADD KEY `idx_granted_by` (`granted_by`), + ADD KEY `idx_granted_at` (`granted_at`); + +-- 3) Backfill tree metadata (supports current 1~2 level menus) +UPDATE `sys_menus` +SET `menu_level` = 1, + `tree_path` = CONCAT('/', `menu_id`) +WHERE `parent_id` IS NULL; + +UPDATE `sys_menus` c +JOIN `sys_menus` p ON c.`parent_id` = p.`menu_id` +SET c.`menu_level` = p.`menu_level` + 1, + c.`tree_path` = CONCAT(p.`tree_path`, '/', c.`menu_id`); + +-- 4) Add sample child menus under existing modules +INSERT INTO `sys_menus` +(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`) +SELECT + 'permission_menu_tree', + '菜单树维护', + 'AppstoreAdd', + '/admin/management/permission-management', + 'link', + m.`menu_id`, + 3, + NULL, + 20, + 1, + 0, + '权限管理中的菜单树维护入口(隐藏于侧栏)' +FROM `sys_menus` m +WHERE m.`menu_code` = 'permission_management' + AND NOT EXISTS (SELECT 1 FROM `sys_menus` WHERE `menu_code` = 'permission_menu_tree'); + +-- backfill tree_path for newly inserted rows +UPDATE `sys_menus` c +JOIN `sys_menus` p ON c.`parent_id` = p.`menu_id` +SET c.`menu_level` = p.`menu_level` + 1, + c.`tree_path` = CONCAT(p.`tree_path`, '/', c.`menu_id`) +WHERE c.`tree_path` IS NULL; + +-- 5) Align permissions: role 1 owns all active menus +INSERT IGNORE INTO `sys_role_menu_permissions` (`role_id`, `menu_id`, `granted_by`) +SELECT 1, m.`menu_id`, 1 +FROM `sys_menus` m +WHERE m.`is_active` = 1; + +-- role 2 excludes platform admin tree +DELETE rmp +FROM `sys_role_menu_permissions` rmp +JOIN `sys_menus` m ON m.`menu_id` = rmp.`menu_id` +WHERE rmp.`role_id` = 2 + AND m.`menu_code` IN ( + 'platform_admin', + 'user_management', + 'permission_management', + 'dict_management', + 'hot_word_management', + 'client_management', + 'external_app_management', + 'terminal_management', + 'permission_menu_tree' + ); + +COMMIT; diff --git a/backend/sql/migrations/fix_dashboard_desktop_visibility.sql b/backend/sql/migrations/fix_dashboard_desktop_visibility.sql new file mode 100644 index 0000000..0f7be46 --- /dev/null +++ b/backend/sql/migrations/fix_dashboard_desktop_visibility.sql @@ -0,0 +1,8 @@ +START TRANSACTION; + +UPDATE sys_menus +SET is_visible = 1, + is_active = 1 +WHERE menu_code IN ('dashboard', 'desktop'); + +COMMIT; diff --git a/backend/sql/migrations/grant_dashboard_desktop_to_all_roles.sql b/backend/sql/migrations/grant_dashboard_desktop_to_all_roles.sql new file mode 100644 index 0000000..a5217d8 --- /dev/null +++ b/backend/sql/migrations/grant_dashboard_desktop_to_all_roles.sql @@ -0,0 +1,24 @@ +-- 让所有角色都能看到 Dashboard 和 Desktop 菜单 +-- Dashboard sort_order=1, Desktop sort_order=2 +START TRANSACTION; + +SET @dashboard_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'dashboard' LIMIT 1); +SET @desktop_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'desktop' LIMIT 1); + +-- 确保 sort_order 有序 +UPDATE sys_menus SET sort_order = 1 WHERE menu_code = 'dashboard'; +UPDATE sys_menus SET sort_order = 2 WHERE menu_code = 'desktop'; + +-- 为 role_id=1 (admin) 补充 desktop 权限 +INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at) +SELECT 1, @desktop_id, NOW() +FROM DUAL +WHERE @desktop_id IS NOT NULL; + +-- 为 role_id=2 (普通用户) 补充 dashboard 权限 +INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at) +SELECT 2, @dashboard_id, NOW() +FROM DUAL +WHERE @dashboard_id IS NOT NULL; + +COMMIT; diff --git a/backend/sql/migrations/merge_personal_prompt_library_into_prompt_config.sql b/backend/sql/migrations/merge_personal_prompt_library_into_prompt_config.sql new file mode 100644 index 0000000..a062243 --- /dev/null +++ b/backend/sql/migrations/merge_personal_prompt_library_into_prompt_config.sql @@ -0,0 +1,12 @@ +START TRANSACTION; + +SET @personal_prompt_library_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'personal_prompt_library' LIMIT 1); + +DELETE FROM sys_role_menu_permissions +WHERE menu_id = @personal_prompt_library_id; + +UPDATE sys_menus +SET is_active = 0 +WHERE menu_id = @personal_prompt_library_id; + +COMMIT; diff --git a/backend/sql/migrations/normalize_dashboard_desktop_menu_rules.sql b/backend/sql/migrations/normalize_dashboard_desktop_menu_rules.sql new file mode 100644 index 0000000..e33fd76 --- /dev/null +++ b/backend/sql/migrations/normalize_dashboard_desktop_menu_rules.sql @@ -0,0 +1,88 @@ +START TRANSACTION; + +SET @dashboard_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'dashboard' LIMIT 1); +SET @desktop_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'desktop' LIMIT 1); +SET @account_settings_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'account_settings' LIMIT 1); +SET @logout_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'logout' LIMIT 1); + +UPDATE sys_menus +SET + menu_code = 'dashboard', + menu_name = 'Dashboard', + menu_icon = 'DashboardOutlined', + menu_url = '/dashboard', + menu_type = 'link', + parent_id = NULL, + sort_order = 1, + is_active = 1, + description = '管理员桌面' +WHERE @dashboard_id IS NULL + AND menu_id = @account_settings_id; + +INSERT INTO sys_menus ( + menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description +) +SELECT + 'dashboard', 'Dashboard', 'DashboardOutlined', '/dashboard', 'link', NULL, 1, 1, '管理员桌面' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menus WHERE menu_code = 'dashboard' +); + +SET @dashboard_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'dashboard' LIMIT 1); + +UPDATE sys_menus +SET + menu_code = 'desktop', + menu_name = 'Desktop', + menu_icon = 'DesktopOutlined', + menu_url = '/dashboard', + menu_type = 'link', + parent_id = NULL, + sort_order = 1, + is_active = 1, + description = '普通用户桌面' +WHERE @desktop_id IS NULL + AND menu_id = @logout_id; + +INSERT INTO sys_menus ( + menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description +) +SELECT + 'desktop', 'Desktop', 'DesktopOutlined', '/dashboard', 'link', NULL, 1, 1, '普通用户桌面' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menus WHERE menu_code = 'desktop' +); + +SET @desktop_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'desktop' LIMIT 1); + +UPDATE sys_menus +SET sort_order = 2 +WHERE menu_code = 'meeting_manage'; + +DELETE rmp +FROM sys_role_menu_permissions rmp +JOIN sys_menus m ON m.menu_id = rmp.menu_id +WHERE m.menu_code IN ('account_settings', 'logout'); + +DELETE FROM sys_menus +WHERE menu_code IN ('account_settings', 'logout'); + +DELETE FROM sys_role_menu_permissions +WHERE role_id = 1 AND menu_id = @desktop_id; + +DELETE FROM sys_role_menu_permissions +WHERE role_id = 2 AND menu_id = @dashboard_id; + +INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at) +SELECT 1, @dashboard_id, NOW() +FROM DUAL +WHERE @dashboard_id IS NOT NULL; + +INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at) +SELECT 2, @desktop_id, NOW() +FROM DUAL +WHERE @desktop_id IS NOT NULL; + +COMMIT; diff --git a/backend/sql/migrations/optimize_menu_loading_performance.sql b/backend/sql/migrations/optimize_menu_loading_performance.sql new file mode 100644 index 0000000..222fe68 --- /dev/null +++ b/backend/sql/migrations/optimize_menu_loading_performance.sql @@ -0,0 +1,24 @@ +-- Migration: optimize menu loading performance +-- Created at: 2026-03-12 + +BEGIN; + +-- 1) remove duplicate role-menu mapping rows to allow unique key +DELETE r1 +FROM role_menu_permissions r1 +JOIN role_menu_permissions r2 + ON r1.role_id = r2.role_id + AND r1.menu_id = r2.menu_id + AND r1.id > r2.id; + +-- 2) speed up role-menu lookup and prevent duplicate permission rows +ALTER TABLE `role_menu_permissions` + ADD UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`), + ADD KEY `idx_rmp_role` (`role_id`), + ADD KEY `idx_rmp_menu` (`menu_id`); + +-- 3) speed up visible active menu ordering by parent/sort +ALTER TABLE `menus` + ADD KEY `idx_menus_visible_tree` (`is_active`, `is_visible`, `parent_id`, `sort_order`, `menu_id`); + +COMMIT; diff --git a/backend/sql/migrations/optimize_user_management_query.sql b/backend/sql/migrations/optimize_user_management_query.sql new file mode 100644 index 0000000..3c18aa0 --- /dev/null +++ b/backend/sql/migrations/optimize_user_management_query.sql @@ -0,0 +1,12 @@ +-- Migration: optimize user management query performance +-- Created at: 2026-03-12 + +BEGIN; + +ALTER TABLE `meetings` + ADD KEY `idx_meetings_user_id` (`user_id`); + +ALTER TABLE `attendees` + ADD KEY `idx_attendees_user_id` (`user_id`); + +COMMIT; diff --git a/backend/sql/migrations/refactor_hot_words_to_group.sql b/backend/sql/migrations/refactor_hot_words_to_group.sql new file mode 100644 index 0000000..5ca8cab --- /dev/null +++ b/backend/sql/migrations/refactor_hot_words_to_group.sql @@ -0,0 +1,67 @@ +-- 热词管理:单表 → 主从表(热词组 + 热词条目) +-- 执行前请备份 hot_words 表 + +-- 1. 创建热词组主表 +CREATE TABLE IF NOT EXISTS `hot_word_group` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL COMMENT '热词组名称', + `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', + `vocabulary_id` VARCHAR(255) DEFAULT NULL COMMENT '阿里云 DashScope 词表ID', + `last_sync_time` DATETIME DEFAULT NULL COMMENT '最后同步时间', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1:启用 0:停用', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='热词组主表'; + +-- 2. 创建热词条目从表 +CREATE TABLE IF NOT EXISTS `hot_word_item` ( + `id` INT NOT NULL AUTO_INCREMENT, + `group_id` INT NOT NULL COMMENT '热词组ID', + `text` VARCHAR(255) NOT NULL COMMENT '热词内容', + `weight` INT NOT NULL DEFAULT 4 COMMENT '权重 1-10', + `lang` VARCHAR(20) NOT NULL DEFAULT 'zh' COMMENT 'zh/en', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1:启用 0:停用', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_group_id` (`group_id`), + UNIQUE KEY `idx_group_text` (`group_id`, `text`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='热词条目从表'; + +-- 3. audio_model_config 新增 hot_word_group_id 列 +ALTER TABLE `audio_model_config` + ADD COLUMN `hot_word_group_id` INT DEFAULT NULL COMMENT '关联热词组ID' + AFTER `asr_vocabulary_id`; + +-- 4. 数据迁移:将现有 hot_words 数据迁移到默认组 +INSERT INTO `hot_word_group` (`name`, `description`, `status`) +SELECT '默认热词组', '从旧 hot_words 表迁移的热词', 1 +FROM DUAL +WHERE EXISTS (SELECT 1 FROM `hot_words` LIMIT 1); + +-- 将旧表中已有的 vocabulary_id 回填到默认组(如果存在于 sys_system_parameters) +UPDATE `hot_word_group` g + JOIN ( + SELECT param_value FROM `sys_system_parameters` + WHERE param_key = 'asr_vocabulary_id' AND is_active = 1 + LIMIT 1 + ) p ON 1=1 +SET g.vocabulary_id = p.param_value, + g.last_sync_time = NOW() +WHERE g.name = '默认热词组'; + +-- 迁移热词条目 +INSERT INTO `hot_word_item` (`group_id`, `text`, `weight`, `lang`, `status`, `create_time`, `update_time`) +SELECT g.id, hw.text, hw.weight, hw.lang, hw.status, hw.create_time, hw.update_time +FROM `hot_words` hw +CROSS JOIN `hot_word_group` g +WHERE g.name = '默认热词组'; + +-- 5. 将已有 ASR 模型配置关联到默认组 +UPDATE `audio_model_config` a + JOIN `hot_word_group` g ON g.name = '默认热词组' +SET a.hot_word_group_id = g.id +WHERE a.audio_scene = 'asr' + AND a.asr_vocabulary_id IS NOT NULL + AND a.asr_vocabulary_id != ''; diff --git a/backend/sql/migrations/rename_history_meetings_to_meeting_center.sql b/backend/sql/migrations/rename_history_meetings_to_meeting_center.sql new file mode 100644 index 0000000..665ed99 --- /dev/null +++ b/backend/sql/migrations/rename_history_meetings_to_meeting_center.sql @@ -0,0 +1,12 @@ +START TRANSACTION; + +UPDATE sys_menus +SET + menu_code = 'meeting_center', + menu_name = '会议中心', + menu_icon = 'CalendarOutlined', + menu_url = '/meetings/center', + description = '普通用户会议中心' +WHERE menu_code = 'history_meetings'; + +COMMIT; diff --git a/backend/sql/migrations/rename_model_tables_to_singular.sql b/backend/sql/migrations/rename_model_tables_to_singular.sql new file mode 100644 index 0000000..5760172 --- /dev/null +++ b/backend/sql/migrations/rename_model_tables_to_singular.sql @@ -0,0 +1,157 @@ +-- Migration: rename model config tables to singular naming +-- Target names: +-- llm_model_config +-- audio_model_config + +SET @rename_llm_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'llm_model_configs' + ) + AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' + ), + 'RENAME TABLE llm_model_configs TO llm_model_config', + 'SELECT 1' + ) +); +PREPARE stmt FROM @rename_llm_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @rename_audio_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'audio_model_configs' + ) + AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'audio_model_config' + ), + 'RENAME TABLE audio_model_configs TO audio_model_config', + 'SELECT 1' + ) +); +PREPARE stmt FROM @rename_audio_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Remove possible redundant audio/voiceprint fields from llm table (idempotent) +SET @drop_audio_scene_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'audio_scene' + ), + 'ALTER TABLE llm_model_config DROP COLUMN audio_scene', + 'SELECT 1' + ) +); +PREPARE stmt FROM @drop_audio_scene_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @drop_asr_model_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'asr_model_name' + ), + 'ALTER TABLE llm_model_config DROP COLUMN asr_model_name', + 'SELECT 1' + ) +); +PREPARE stmt FROM @drop_asr_model_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @drop_asr_vocab_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'asr_vocabulary_id' + ), + 'ALTER TABLE llm_model_config DROP COLUMN asr_vocabulary_id', + 'SELECT 1' + ) +); +PREPARE stmt FROM @drop_asr_vocab_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @drop_vp_tpl_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'vp_template_text' + ), + 'ALTER TABLE llm_model_config DROP COLUMN vp_template_text', + 'SELECT 1' + ) +); +PREPARE stmt FROM @drop_vp_tpl_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @drop_vp_duration_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'vp_duration_seconds' + ), + 'ALTER TABLE llm_model_config DROP COLUMN vp_duration_seconds', + 'SELECT 1' + ) +); +PREPARE stmt FROM @drop_vp_duration_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @drop_vp_rate_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'vp_sample_rate' + ), + 'ALTER TABLE llm_model_config DROP COLUMN vp_sample_rate', + 'SELECT 1' + ) +); +PREPARE stmt FROM @drop_vp_rate_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @drop_vp_channels_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'vp_channels' + ), + 'ALTER TABLE llm_model_config DROP COLUMN vp_channels', + 'SELECT 1' + ) +); +PREPARE stmt FROM @drop_vp_channels_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @drop_vp_size_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'vp_max_size_bytes' + ), + 'ALTER TABLE llm_model_config DROP COLUMN vp_max_size_bytes', + 'SELECT 1' + ) +); +PREPARE stmt FROM @drop_vp_size_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Clean potential non-LLM rows in llm table +DELETE FROM llm_model_config +WHERE model_code IN ('audio_model', 'voiceprint_model'); diff --git a/backend/sql/migrations/rename_user_prompt_config_table.sql b/backend/sql/migrations/rename_user_prompt_config_table.sql new file mode 100644 index 0000000..26c274c --- /dev/null +++ b/backend/sql/migrations/rename_user_prompt_config_table.sql @@ -0,0 +1,31 @@ +-- Migration: rename user prompt config table and adjust prompt_config menu URL +-- Created at: 2026-03-13 + +BEGIN; + +SET @rename_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_prompt_config' + AND table_type = 'BASE TABLE' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'prompt_config' + AND table_type = 'BASE TABLE' + ), + 'RENAME TABLE sys_user_prompt_config TO prompt_config', + 'SELECT 1' + ) +); +PREPARE stmt FROM @rename_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE sys_menus +SET menu_url = '/prompt-config' +WHERE menu_code = 'prompt_config'; + +COMMIT; diff --git a/backend/sql/migrations/split_llm_audio_model_tables.sql b/backend/sql/migrations/split_llm_audio_model_tables.sql new file mode 100644 index 0000000..521ef3f --- /dev/null +++ b/backend/sql/migrations/split_llm_audio_model_tables.sql @@ -0,0 +1,170 @@ +-- Migration: split LLM and audio model configs into dedicated tables +-- Created at: 2026-03-12 + +BEGIN; + +CREATE TABLE IF NOT EXISTS `llm_model_config` ( + `config_id` bigint(20) NOT NULL AUTO_INCREMENT, + `model_code` varchar(128) NOT NULL, + `model_name` varchar(255) NOT NULL, + `provider` varchar(64) DEFAULT NULL, + `endpoint_url` varchar(512) DEFAULT NULL, + `api_key` varchar(512) DEFAULT NULL, + `llm_model_name` varchar(128) NOT NULL, + `llm_timeout` int(11) NOT NULL DEFAULT 120, + `llm_temperature` decimal(5,2) NOT NULL DEFAULT 0.70, + `llm_top_p` decimal(5,2) NOT NULL DEFAULT 0.90, + `llm_max_tokens` int(11) NOT NULL DEFAULT 2048, + `llm_system_prompt` text DEFAULT NULL, + `description` varchar(500) DEFAULT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT 1, + `is_default` tinyint(1) NOT NULL DEFAULT 0, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`config_id`), + UNIQUE KEY `uk_llm_model_code` (`model_code`), + KEY `idx_llm_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `audio_model_config` ( + `config_id` bigint(20) NOT NULL AUTO_INCREMENT, + `model_code` varchar(128) NOT NULL, + `model_name` varchar(255) NOT NULL, + `audio_scene` varchar(32) NOT NULL COMMENT 'asr / voiceprint', + `provider` varchar(64) DEFAULT NULL, + `endpoint_url` varchar(512) DEFAULT NULL, + `api_key` varchar(512) DEFAULT NULL, + `asr_model_name` varchar(128) DEFAULT NULL, + `asr_vocabulary_id` varchar(255) DEFAULT NULL, + `asr_speaker_count` int(11) DEFAULT NULL, + `asr_language_hints` varchar(255) DEFAULT NULL, + `asr_disfluency_removal_enabled` tinyint(1) DEFAULT NULL, + `asr_diarization_enabled` tinyint(1) DEFAULT NULL, + `vp_template_text` text DEFAULT NULL, + `vp_duration_seconds` int(11) DEFAULT NULL, + `vp_sample_rate` int(11) DEFAULT NULL, + `vp_channels` int(11) DEFAULT NULL, + `vp_max_size_bytes` bigint(20) DEFAULT NULL, + `description` varchar(500) DEFAULT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT 1, + `is_default` tinyint(1) NOT NULL DEFAULT 0, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`config_id`), + UNIQUE KEY `uk_audio_model_code` (`model_code`), + KEY `idx_audio_scene` (`audio_scene`), + KEY `idx_audio_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- migrate llm rows +INSERT INTO `llm_model_config` +(model_code, model_name, provider, endpoint_url, api_key, llm_model_name, llm_timeout, + llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt, description, is_active, is_default) +SELECT + model_code, + model_name, + provider, + endpoint_url, + api_key, + COALESCE(llm_model_name, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.model_name')), 'qwen-plus'), + COALESCE(llm_timeout, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.time_out')) AS UNSIGNED), 120), + COALESCE(llm_temperature, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.temperature')) AS DECIMAL(5,2)), 0.70), + COALESCE(llm_top_p, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.top_p')) AS DECIMAL(5,2)), 0.90), + COALESCE(llm_max_tokens, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.max_tokens')) AS UNSIGNED), 2048), + COALESCE(llm_system_prompt, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.system_prompt'))), + description, + is_active, + is_default +FROM ai_model_configs +WHERE model_type = 'llm' +ON DUPLICATE KEY UPDATE + model_name = VALUES(model_name), + provider = VALUES(provider), + endpoint_url = VALUES(endpoint_url), + api_key = VALUES(api_key), + llm_model_name = VALUES(llm_model_name), + llm_timeout = VALUES(llm_timeout), + llm_temperature = VALUES(llm_temperature), + llm_top_p = VALUES(llm_top_p), + llm_max_tokens = VALUES(llm_max_tokens), + llm_system_prompt = VALUES(llm_system_prompt), + description = VALUES(description), + is_active = VALUES(is_active), + is_default = VALUES(is_default); + +-- migrate audio recognition rows +INSERT INTO `audio_model_config` +(model_code, model_name, audio_scene, provider, endpoint_url, api_key, asr_model_name, asr_vocabulary_id, + asr_speaker_count, asr_language_hints, asr_disfluency_removal_enabled, asr_diarization_enabled, + description, is_active, is_default) +SELECT + model_code, + model_name, + 'asr', + provider, + endpoint_url, + api_key, + COALESCE(asr_model_name, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.model')), 'paraformer-v2'), + COALESCE(asr_vocabulary_id, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.vocabulary_id'))), + COALESCE(asr_speaker_count, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.speaker_count')) AS UNSIGNED), 10), + COALESCE(asr_language_hints, REPLACE(REPLACE(REPLACE(JSON_EXTRACT(config_json, '$.language_hints'), '"', ''), '[', ''), ']', ''), 'zh,en'), + COALESCE(asr_disfluency_removal_enabled, + CASE LOWER(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.disfluency_removal_enabled'))) WHEN 'true' THEN 1 WHEN '1' THEN 1 ELSE 0 END), + COALESCE(asr_diarization_enabled, + CASE LOWER(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.diarization_enabled'))) WHEN 'true' THEN 1 WHEN '1' THEN 1 ELSE 0 END), + description, + is_active, + is_default +FROM ai_model_configs +WHERE model_code = 'audio_model' +ON DUPLICATE KEY UPDATE + model_name = VALUES(model_name), + provider = VALUES(provider), + endpoint_url = VALUES(endpoint_url), + api_key = VALUES(api_key), + asr_model_name = VALUES(asr_model_name), + asr_vocabulary_id = VALUES(asr_vocabulary_id), + asr_speaker_count = VALUES(asr_speaker_count), + asr_language_hints = VALUES(asr_language_hints), + asr_disfluency_removal_enabled = VALUES(asr_disfluency_removal_enabled), + asr_diarization_enabled = VALUES(asr_diarization_enabled), + description = VALUES(description), + is_active = VALUES(is_active), + is_default = VALUES(is_default); + +-- migrate voiceprint rows +INSERT INTO `audio_model_config` +(model_code, model_name, audio_scene, provider, endpoint_url, api_key, vp_template_text, vp_duration_seconds, + vp_sample_rate, vp_channels, vp_max_size_bytes, description, is_active, is_default) +SELECT + model_code, + model_name, + 'voiceprint', + provider, + endpoint_url, + api_key, + JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.template_text')), + CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.duration_seconds')) AS UNSIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.sample_rate')) AS UNSIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.channels')) AS UNSIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.voiceprint_max_size')) AS UNSIGNED), + description, + is_active, + is_default +FROM ai_model_configs +WHERE model_code = 'voiceprint_model' +ON DUPLICATE KEY UPDATE + model_name = VALUES(model_name), + provider = VALUES(provider), + endpoint_url = VALUES(endpoint_url), + api_key = VALUES(api_key), + vp_template_text = VALUES(vp_template_text), + vp_duration_seconds = VALUES(vp_duration_seconds), + vp_sample_rate = VALUES(vp_sample_rate), + vp_channels = VALUES(vp_channels), + vp_max_size_bytes = VALUES(vp_max_size_bytes), + description = VALUES(description), + is_active = VALUES(is_active), + is_default = VALUES(is_default); + +COMMIT; diff --git a/backend/sql/migrations/standardize_sys_table_prefix.sql b/backend/sql/migrations/standardize_sys_table_prefix.sql new file mode 100644 index 0000000..3c57e61 --- /dev/null +++ b/backend/sql/migrations/standardize_sys_table_prefix.sql @@ -0,0 +1,121 @@ +-- Migration: standardize system-level table names with sys_ prefix +-- Strategy: +-- 1) Rename physical tables to sys_* +-- 2) Create compatibility views with legacy names + +SET @rename_users_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'users' AND table_type = 'BASE TABLE' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'sys_users' + ), + 'RENAME TABLE users TO sys_users', + 'SELECT 1' + ) +); +PREPARE stmt FROM @rename_users_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @rename_roles_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'roles' AND table_type = 'BASE TABLE' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'sys_roles' + ), + 'RENAME TABLE roles TO sys_roles', + 'SELECT 1' + ) +); +PREPARE stmt FROM @rename_roles_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @rename_menus_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'menus' AND table_type = 'BASE TABLE' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'sys_menus' + ), + 'RENAME TABLE menus TO sys_menus', + 'SELECT 1' + ) +); +PREPARE stmt FROM @rename_menus_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @rename_rmp_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'role_menu_permissions' AND table_type = 'BASE TABLE' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'sys_role_menu_permissions' + ), + 'RENAME TABLE role_menu_permissions TO sys_role_menu_permissions', + 'SELECT 1' + ) +); +PREPARE stmt FROM @rename_rmp_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @rename_dict_data_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'dict_data' AND table_type = 'BASE TABLE' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'sys_dict_data' + ), + 'RENAME TABLE dict_data TO sys_dict_data', + 'SELECT 1' + ) +); +PREPARE stmt FROM @rename_dict_data_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @rename_sys_param_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'system_parameters' AND table_type = 'BASE TABLE' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'sys_system_parameters' + ), + 'RENAME TABLE system_parameters TO sys_system_parameters', + 'SELECT 1' + ) +); +PREPARE stmt FROM @rename_sys_param_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Drop existing legacy-name views if present, then recreate compatibility views. +DROP VIEW IF EXISTS users; +DROP VIEW IF EXISTS roles; +DROP VIEW IF EXISTS menus; +DROP VIEW IF EXISTS role_menu_permissions; +DROP VIEW IF EXISTS dict_data; +DROP VIEW IF EXISTS system_parameters; + +CREATE VIEW users AS SELECT * FROM sys_users; +CREATE VIEW roles AS SELECT * FROM sys_roles; +CREATE VIEW menus AS SELECT * FROM sys_menus; +CREATE VIEW role_menu_permissions AS SELECT * FROM sys_role_menu_permissions; +CREATE VIEW dict_data AS SELECT * FROM sys_dict_data; +CREATE VIEW system_parameters AS SELECT * FROM sys_system_parameters; diff --git a/backend/sql/migrations/update_admin_menu_hierarchy.sql b/backend/sql/migrations/update_admin_menu_hierarchy.sql new file mode 100644 index 0000000..af4592d --- /dev/null +++ b/backend/sql/migrations/update_admin_menu_hierarchy.sql @@ -0,0 +1,176 @@ +-- Migration: convert platform admin menu to hierarchical navigation +-- Created at: 2026-03-03 + +BEGIN; + +-- 1) Ensure top-level menus exist and are aligned +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`) +VALUES ('account_settings', '账户设置', 'UserCog', '/account-settings', 'link', NULL, 1, 1, '管理个人账户信息') +ON DUPLICATE KEY UPDATE + `menu_name` = VALUES(`menu_name`), + `menu_icon` = VALUES(`menu_icon`), + `menu_url` = VALUES(`menu_url`), + `menu_type` = VALUES(`menu_type`), + `parent_id` = VALUES(`parent_id`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `description` = VALUES(`description`); + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`) +VALUES ('prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', NULL, 2, 1, '管理AI提示词模版') +ON DUPLICATE KEY UPDATE + `menu_name` = VALUES(`menu_name`), + `menu_icon` = VALUES(`menu_icon`), + `menu_url` = VALUES(`menu_url`), + `menu_type` = VALUES(`menu_type`), + `parent_id` = VALUES(`parent_id`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `description` = VALUES(`description`); + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`) +VALUES ('platform_admin', '平台管理', 'Shield', '/admin/management/user-management', 'link', NULL, 3, 1, '平台管理员后台') +ON DUPLICATE KEY UPDATE + `menu_name` = VALUES(`menu_name`), + `menu_icon` = VALUES(`menu_icon`), + `menu_url` = VALUES(`menu_url`), + `menu_type` = VALUES(`menu_type`), + `parent_id` = VALUES(`parent_id`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `description` = VALUES(`description`); + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`) +VALUES ('logout', '退出登录', 'LogOut', NULL, 'action', NULL, 99, 1, '退出当前账号') +ON DUPLICATE KEY UPDATE + `menu_name` = VALUES(`menu_name`), + `menu_icon` = VALUES(`menu_icon`), + `menu_url` = VALUES(`menu_url`), + `menu_type` = VALUES(`menu_type`), + `parent_id` = VALUES(`parent_id`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `description` = VALUES(`description`); + +-- 2) Ensure children under platform_admin +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`) +SELECT 'user_management', '用户管理', 'Users', '/admin/management/user-management', 'link', m.menu_id, 1, 1, '账号、角色、密码重置' +FROM `menus` m +WHERE m.`menu_code` = 'platform_admin' +ON DUPLICATE KEY UPDATE + `menu_name` = VALUES(`menu_name`), + `menu_icon` = VALUES(`menu_icon`), + `menu_url` = VALUES(`menu_url`), + `menu_type` = VALUES(`menu_type`), + `parent_id` = VALUES(`parent_id`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `description` = VALUES(`description`); + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`) +SELECT 'permission_management', '权限管理', 'KeyRound', '/admin/management/permission-management', 'link', m.menu_id, 2, 1, '菜单与角色授权矩阵' +FROM `menus` m +WHERE m.`menu_code` = 'platform_admin' +ON DUPLICATE KEY UPDATE + `menu_name` = VALUES(`menu_name`), + `menu_icon` = VALUES(`menu_icon`), + `menu_url` = VALUES(`menu_url`), + `menu_type` = VALUES(`menu_type`), + `parent_id` = VALUES(`parent_id`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `description` = VALUES(`description`); + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`) +SELECT 'dict_management', '字典管理', 'BookMarked', '/admin/management/dict-management', 'link', m.menu_id, 3, 1, '码表、平台类型、扩展属性' +FROM `menus` m +WHERE m.`menu_code` = 'platform_admin' +ON DUPLICATE KEY UPDATE + `menu_name` = VALUES(`menu_name`), + `menu_icon` = VALUES(`menu_icon`), + `menu_url` = VALUES(`menu_url`), + `menu_type` = VALUES(`menu_type`), + `parent_id` = VALUES(`parent_id`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `description` = VALUES(`description`); + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`) +SELECT 'hot_word_management', '热词管理', 'Text', '/admin/management/hot-word-management', 'link', m.menu_id, 4, 1, 'ASR 热词与同步' +FROM `menus` m +WHERE m.`menu_code` = 'platform_admin' +ON DUPLICATE KEY UPDATE + `menu_name` = VALUES(`menu_name`), + `menu_icon` = VALUES(`menu_icon`), + `menu_url` = VALUES(`menu_url`), + `menu_type` = VALUES(`menu_type`), + `parent_id` = VALUES(`parent_id`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `description` = VALUES(`description`); + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`) +SELECT 'client_management', '客户端管理', 'Smartphone', '/admin/management/client-management', 'link', m.menu_id, 5, 1, '版本、下载地址、发布状态' +FROM `menus` m +WHERE m.`menu_code` = 'platform_admin' +ON DUPLICATE KEY UPDATE + `menu_name` = VALUES(`menu_name`), + `menu_icon` = VALUES(`menu_icon`), + `menu_url` = VALUES(`menu_url`), + `menu_type` = VALUES(`menu_type`), + `parent_id` = VALUES(`parent_id`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `description` = VALUES(`description`); + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`) +SELECT 'external_app_management', '外部应用管理', 'AppWindow', '/admin/management/external-app-management', 'link', m.menu_id, 6, 1, '外部系统入口与图标配置' +FROM `menus` m +WHERE m.`menu_code` = 'platform_admin' +ON DUPLICATE KEY UPDATE + `menu_name` = VALUES(`menu_name`), + `menu_icon` = VALUES(`menu_icon`), + `menu_url` = VALUES(`menu_url`), + `menu_type` = VALUES(`menu_type`), + `parent_id` = VALUES(`parent_id`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `description` = VALUES(`description`); + +INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`) +SELECT 'terminal_management', '终端管理', 'Monitor', '/admin/management/terminal-management', 'link', m.menu_id, 7, 1, '专用设备、激活和绑定状态' +FROM `menus` m +WHERE m.`menu_code` = 'platform_admin' +ON DUPLICATE KEY UPDATE + `menu_name` = VALUES(`menu_name`), + `menu_icon` = VALUES(`menu_icon`), + `menu_url` = VALUES(`menu_url`), + `menu_type` = VALUES(`menu_type`), + `parent_id` = VALUES(`parent_id`), + `sort_order` = VALUES(`sort_order`), + `is_active` = VALUES(`is_active`), + `description` = VALUES(`description`); + +-- 3) Permission alignment +DELETE FROM `role_menu_permissions` +WHERE `role_id` = 2 + AND `menu_id` IN ( + SELECT `menu_id` FROM `menus` + WHERE `menu_code` IN ( + 'platform_admin', + 'user_management', + 'permission_management', + 'dict_management', + 'hot_word_management', + 'client_management', + 'external_app_management', + 'terminal_management' + ) + ); + +INSERT IGNORE INTO `role_menu_permissions` (`role_id`, `menu_id`) +SELECT 1, `menu_id` +FROM `menus` +WHERE `is_active` = 1; + +COMMIT; diff --git a/backend/sql/migrations/upgrade_prompt_library_and_config.sql b/backend/sql/migrations/upgrade_prompt_library_and_config.sql new file mode 100644 index 0000000..bf92d5f --- /dev/null +++ b/backend/sql/migrations/upgrade_prompt_library_and_config.sql @@ -0,0 +1,113 @@ +-- Migration: prompt library upgrade + user prompt config + menu regroup +-- Created at: 2026-03-13 + +BEGIN; + +-- 1) prompts table: support system prompt library +SET @add_is_system_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'prompts' AND column_name = 'is_system' + ), + 'SELECT 1', + 'ALTER TABLE prompts ADD COLUMN is_system TINYINT(1) NOT NULL DEFAULT 0 AFTER creator_id' + ) +); +PREPARE stmt FROM @add_is_system_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @add_prompts_idx_sql = ( + SELECT IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'prompts' + AND index_name = 'idx_prompts_task_scope_active' + ), + 'SELECT 1', + 'CREATE INDEX idx_prompts_task_scope_active ON prompts (task_type, is_system, creator_id, is_active, is_default)' + ) +); +PREPARE stmt FROM @add_prompts_idx_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Existing admin-created prompts become system prompts by default +UPDATE prompts +SET is_system = 1 +WHERE creator_id = 1; + +-- 2) user prompt config table +CREATE TABLE IF NOT EXISTS prompt_config ( + config_id BIGINT(20) NOT NULL AUTO_INCREMENT, + user_id INT(11) NOT NULL, + task_type ENUM('MEETING_TASK','KNOWLEDGE_TASK') NOT NULL, + prompt_id INT(11) NOT NULL, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT(11) NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (config_id), + UNIQUE KEY uk_user_task_prompt (user_id, task_type, prompt_id), + KEY idx_user_task_order (user_id, task_type, sort_order), + KEY idx_prompt_id (prompt_id), + CONSTRAINT fk_upc_user FOREIGN KEY (user_id) REFERENCES sys_users(user_id) ON DELETE CASCADE, + CONSTRAINT fk_upc_prompt FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 3) Menu regroup: +-- move prompt_management under platform_admin (2nd level) +UPDATE sys_menus child +JOIN sys_menus parent ON parent.menu_code = 'platform_admin' +SET child.parent_id = parent.menu_id, + child.sort_order = 8, + child.menu_level = 2, + child.tree_path = CONCAT(parent.tree_path, '/', child.menu_id) +WHERE child.menu_code = 'prompt_management'; + +-- add prompt_config entry +INSERT INTO sys_menus +( + menu_code, menu_name, menu_icon, menu_url, menu_type, + parent_id, menu_level, tree_path, sort_order, is_active, is_visible, description +) +SELECT + 'prompt_config', + '提示词配置', + 'Book', + '/prompt-config', + 'link', + p.menu_id, + 2, + NULL, + 9, + 1, + 1, + '用户可配置启用提示词与排序' +FROM sys_menus p +WHERE p.menu_code = 'platform_admin' + AND NOT EXISTS (SELECT 1 FROM sys_menus WHERE menu_code = 'prompt_config'); + +UPDATE sys_menus c +JOIN sys_menus p ON c.parent_id = p.menu_id +SET c.menu_level = p.menu_level + 1, + c.tree_path = CONCAT(p.tree_path, '/', c.menu_id) +WHERE c.menu_code = 'prompt_config'; + +-- permissions: +-- admin gets both +INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id) +SELECT 1, m.menu_id +FROM sys_menus m +WHERE m.menu_code IN ('platform_admin', 'prompt_management', 'prompt_config'); + +-- normal user gets platform_admin + prompt_management + prompt_config +INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id) +SELECT 2, m.menu_id +FROM sys_menus m +WHERE m.menu_code IN ('platform_admin', 'prompt_management', 'prompt_config'); + +COMMIT; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8760045..61bce18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.0.0", + "version": "1.1.0", "dependencies": { "@uiw/react-md-editor": "^4.0.8", "antd": "^5.27.3", diff --git a/frontend/package.json b/frontend/package.json index 2c3ba38..44967f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.0.0", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", @@ -20,7 +20,6 @@ "canvg": "^4.0.3", "html2canvas": "^1.4.1", "jspdf": "^3.0.2", - "lucide-react": "^0.294.0", "markmap-common": "^0.18.9", "markmap-lib": "^0.18.12", "markmap-view": "^0.18.12", diff --git a/frontend/src/App.css b/frontend/src/App.css index 8c41c5b..933af61 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -7,12 +7,10 @@ body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-family: 'MiSans', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background-color: #f8fafc; + background-color: transparent; color: #1e293b; line-height: 1.6; } @@ -22,6 +20,228 @@ body { width: 100%; } +.ant-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 12px; + font-weight: 600; + letter-spacing: 0.01em; + transition: transform 0.18s ease, box-shadow 0.22s ease, border-color 0.22s ease, background 0.22s ease, color 0.22s ease; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06); +} + +.ant-btn:hover { + transform: translateY(-1px); +} + +.ant-btn:active { + transform: translateY(0); +} + +.ant-btn .anticon { + font-size: 0.98em; +} + +.ant-btn.ant-btn-default, +.ant-btn.ant-btn-dashed { + background: rgba(255, 255, 255, 0.92); + border-color: rgba(148, 163, 184, 0.2); + color: #294261; +} + +.ant-btn.ant-btn-default:hover, +.ant-btn.ant-btn-dashed:hover { + background: #ffffff; + border-color: rgba(59, 130, 246, 0.28); + color: #1d4ed8; + box-shadow: 0 10px 24px rgba(59, 130, 246, 0.12); +} + +.ant-btn.ant-btn-primary { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 45%, #1e40af 100%); + border-color: transparent; + color: #fff; +} + +.ant-btn.ant-btn-primary:hover { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 48%, #1d4ed8 100%); + box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24); +} + +.ant-btn.ant-btn-primary.ant-btn-dangerous, +.ant-btn.ant-btn-dangerous:not(.ant-btn-link):not(.ant-btn-text) { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 48%, #b91c1c 100%); + border-color: transparent; + color: #fff; +} + +.ant-btn.ant-btn-primary.ant-btn-dangerous:hover, +.ant-btn.ant-btn-dangerous:not(.ant-btn-link):not(.ant-btn-text):hover { + box-shadow: 0 14px 28px rgba(220, 38, 38, 0.24); +} + +.ant-btn.ant-btn-link, +.ant-btn.ant-btn-text { + box-shadow: none; + transform: none; +} + +.ant-btn.ant-btn-link { + padding-inline: 6px; + color: #31568b; +} + +.ant-btn.ant-btn-link:hover { + color: #1d4ed8; + background: rgba(37, 99, 235, 0.08); +} + +.ant-btn.ant-btn-link.ant-btn-dangerous, +.ant-btn.ant-btn-text.ant-btn-dangerous { + color: #dc2626; +} + +.ant-btn.ant-btn-link.ant-btn-dangerous:hover, +.ant-btn.ant-btn-text.ant-btn-dangerous:hover { + background: rgba(220, 38, 38, 0.08); + color: #b91c1c; +} + +.ant-btn.btn-soft-blue, +.ant-btn.ant-btn-primary.btn-soft-blue { + background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%); + border-color: #bfdbfe; + color: #1d4ed8; + box-shadow: 0 10px 22px rgba(59, 130, 246, 0.12); +} + +.ant-btn.btn-soft-blue:hover, +.ant-btn.ant-btn-primary.btn-soft-blue:hover { + background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%); + border-color: #93c5fd; + color: #1d4ed8; + box-shadow: 0 14px 26px rgba(59, 130, 246, 0.18); +} + +.ant-btn.btn-soft-violet, +.ant-btn.ant-btn-primary.btn-soft-violet { + background: linear-gradient(180deg, #faf5ff 0%, #f3e8ff 100%); + border-color: #d8b4fe; + color: #7c3aed; + box-shadow: 0 10px 22px rgba(124, 58, 237, 0.12); +} + +.ant-btn.btn-soft-violet:hover, +.ant-btn.ant-btn-primary.btn-soft-violet:hover { + background: linear-gradient(180deg, #f3e8ff 0%, #e9d5ff 100%); + border-color: #c084fc; + color: #6d28d9; + box-shadow: 0 14px 26px rgba(124, 58, 237, 0.18); +} + +.ant-btn.btn-soft-green, +.ant-btn.ant-btn-primary.btn-soft-green { + background: linear-gradient(180deg, #f0fdf4 0%, #dcfce7 100%); + border-color: #86efac; + color: #15803d; + box-shadow: 0 10px 22px rgba(34, 197, 94, 0.12); +} + +.ant-btn.btn-soft-green:hover, +.ant-btn.ant-btn-primary.btn-soft-green:hover { + background: linear-gradient(180deg, #dcfce7 0%, #bbf7d0 100%); + border-color: #4ade80; + color: #166534; + box-shadow: 0 14px 26px rgba(34, 197, 94, 0.18); +} + +.ant-btn.btn-icon-soft-blue { + background: #eff6ff; + border-color: #bfdbfe; + color: #1d4ed8; + box-shadow: none; +} + +.ant-btn.btn-icon-soft-blue:hover { + background: #dbeafe; + border-color: #93c5fd; + color: #1d4ed8; +} + +.ant-btn.btn-icon-soft-red, +.ant-btn.ant-btn-dangerous.btn-icon-soft-red { + background: #fff1f2; + border-color: #fecdd3; + color: #dc2626; + box-shadow: none; +} + +.ant-btn.btn-icon-soft-red:hover, +.ant-btn.ant-btn-dangerous.btn-icon-soft-red:hover { + background: #ffe4e6; + border-color: #fda4af; + color: #b91c1c; +} + +.ant-btn.ant-btn-link.btn-text-view, +.ant-btn.ant-btn-text.btn-text-view { + color: #2563eb; +} + +.ant-btn.ant-btn-link.btn-text-view:hover, +.ant-btn.ant-btn-text.btn-text-view:hover { + background: rgba(37, 99, 235, 0.1); + color: #1d4ed8; +} + +.ant-btn.ant-btn-link.btn-text-edit, +.ant-btn.ant-btn-text.btn-text-edit { + color: #0f766e; +} + +.ant-btn.ant-btn-link.btn-text-edit:hover, +.ant-btn.ant-btn-text.btn-text-edit:hover { + background: rgba(13, 148, 136, 0.1); + color: #0f766e; +} + +.ant-btn.ant-btn-link.btn-text-accent, +.ant-btn.ant-btn-text.btn-text-accent { + color: #7c3aed; +} + +.ant-btn.ant-btn-link.btn-text-accent:hover, +.ant-btn.ant-btn-text.btn-text-accent:hover { + background: rgba(124, 58, 237, 0.1); + color: #6d28d9; +} + +.ant-btn.ant-btn-link.btn-text-delete, +.ant-btn.ant-btn-text.btn-text-delete, +.ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete, +.ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete { + color: #dc2626; +} + +.ant-btn.ant-btn-link.btn-text-delete:hover, +.ant-btn.ant-btn-text.btn-text-delete:hover, +.ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete:hover, +.ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete:hover { + background: rgba(220, 38, 38, 0.1); + color: #b91c1c; +} + +.ant-btn.ant-btn-icon-only.ant-btn-text, +.ant-btn.ant-btn-icon-only.ant-btn-link { + min-width: 36px; +} + +.ant-btn-icon-only { + min-width: 40px; +} + .app-loading { display: flex; flex-direction: column; @@ -147,4 +367,4 @@ body { .text-gray-500 { color: #64748b; } .text-gray-600 { color: #475569; } .text-gray-700 { color: #334155; } -.text-gray-900 { color: #0f172a; } \ No newline at end of file +.text-gray-900 { color: #0f172a; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 66b856d..8eb1243 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom'; +import { ConfigProvider, theme, App as AntdApp } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; import apiClient from './utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from './config/api'; import HomePage from './pages/HomePage'; @@ -7,15 +9,75 @@ import Dashboard from './pages/Dashboard'; import AdminDashboard from './pages/AdminDashboard'; import MeetingDetails from './pages/MeetingDetails'; import MeetingPreview from './pages/MeetingPreview'; -import CreateMeeting from './pages/CreateMeeting'; -import EditMeeting from './pages/EditMeeting'; import AdminManagement from './pages/AdminManagement'; import PromptManagementPage from './pages/PromptManagementPage'; +import PromptConfigPage from './pages/PromptConfigPage'; import KnowledgeBasePage from './pages/KnowledgeBasePage'; import EditKnowledgeBase from './pages/EditKnowledgeBase'; import ClientDownloadPage from './pages/ClientDownloadPage'; import AccountSettings from './pages/AccountSettings'; +import MeetingCenterPage from './pages/MeetingCenterPage'; +import MainLayout from './components/MainLayout'; +import menuService from './services/menuService'; import './App.css'; +import './styles/console-theme.css'; + +// Layout Wrapper to inject user and handleLogout +const AuthenticatedLayout = ({ user, handleLogout }) => { + // 如果还在加载中或用户不存在,不渲染,避免闪烁 + if (!user) return null; + + return ( + + + + ); +}; + +const DefaultMenuRedirect = ({ user }) => { + const [targetPath, setTargetPath] = useState(null); + + useEffect(() => { + let active = true; + + const resolveDefaultPath = async () => { + try { + const path = await menuService.getDefaultPath(); + if (active) { + setTargetPath(path || '/dashboard'); + } + } catch (error) { + console.error('Resolve default menu path failed:', error); + if (active) { + setTargetPath('/dashboard'); + } + } + }; + + if (user) { + resolveDefaultPath(); + } + + return () => { + active = false; + }; + }, [user]); + + if (!user) { + return ; + } + + if (!targetPath) { + return ( +
+
+

加载菜单中...

+
+ ); + } + + return ; +}; function App() { const [user, setUser] = useState(null); @@ -23,13 +85,19 @@ function App() { // Load user from localStorage on app start useEffect(() => { - const savedUser = localStorage.getItem('iMeetingUser'); - console.log('Saved user from localStorage:', savedUser); - if (savedUser) { + const savedAuth = localStorage.getItem('iMeetingUser'); + if (savedAuth && savedAuth !== "undefined" && savedAuth !== "null") { try { - const parsedUser = JSON.parse(savedUser); - console.log('Parsed user:', parsedUser); - setUser(parsedUser); + const authData = JSON.parse(savedAuth); + // 如果数据包含 user 字段,则提取 user 字段(适应新结构) + // 否则使用整个对象(兼容旧结构) + const userData = authData.user || authData; + + if (userData && typeof userData === 'object' && (userData.user_id || userData.id)) { + setUser(userData); + } else { + localStorage.removeItem('iMeetingUser'); + } } catch (error) { console.error('Error parsing saved user:', error); localStorage.removeItem('iMeetingUser'); @@ -38,22 +106,27 @@ function App() { setIsLoading(false); }, []); - const handleLogin = (userData) => { - setUser(userData); - localStorage.setItem('iMeetingUser', JSON.stringify(userData)); + const handleLogin = (authData) => { + if (authData) { + menuService.clearCache(); + // 提取用户信息用于 UI 展示 + const userData = authData.user || authData; + setUser(userData); + // 存入完整 auth 数据(包含 token)供拦截器使用 + localStorage.setItem('iMeetingUser', JSON.stringify(authData)); + } }; const handleLogout = async () => { try { - // 调用后端登出API撤销token await apiClient.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGOUT)); } catch (error) { console.error('Logout API error:', error); - // 即使API调用失败也继续登出流程 } finally { - // 清除本地状态和存储 setUser(null); localStorage.removeItem('iMeetingUser'); + menuService.clearCache(); + window.location.href = '/'; } }; @@ -67,49 +140,119 @@ function App() { } return ( - -
- - : - } /> - - : - ) : - } /> - : - } /> - : - } /> - : - } /> - : - } /> - : - } /> - : - } /> - : - } /> - : - } /> - } /> - } /> - -
-
+ + + +
+ + {/* Public Routes */} + : + } /> + + } /> + } /> + + {/* Authenticated Routes */} + : }> + + : + } /> + + : + } /> + } /> + } /> + } /> + } /> + + : + } /> + : + } /> + : + } /> + } /> + : + } /> + } /> + } /> + } /> + + + {/* Catch all */} + } /> + +
+
+
+
); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/components/AdminModuleShell.jsx b/frontend/src/components/AdminModuleShell.jsx new file mode 100644 index 0000000..5118835 --- /dev/null +++ b/frontend/src/components/AdminModuleShell.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Card, Space, Typography } from 'antd'; + +const { Text } = Typography; + +const AdminModuleShell = ({ + icon, + title, + subtitle, + rightActions, + stats, + toolbar, + children, +}) => { + return ( +
+ +
+
+ + {icon} +

{title}

+
+ {subtitle &&

{subtitle}

} +
+ {rightActions} +
+ + {stats?.length ? ( +
+ {stats.map((item) => ( +
+
+
+ {item.label} +
{item.value}
+
+ {item.icon ? ( +
+ {item.icon} +
+ ) : null} +
+ {item.desc ? ( +
{item.desc}
+ ) : null} +
+ ))} +
+ ) : null} + + {toolbar ?
{toolbar}
: null} +
+ + {children} +
+ ); +}; + +export default AdminModuleShell; diff --git a/frontend/src/components/BrandLogo.jsx b/frontend/src/components/BrandLogo.jsx new file mode 100644 index 0000000..1c02fd8 --- /dev/null +++ b/frontend/src/components/BrandLogo.jsx @@ -0,0 +1,44 @@ +import React from 'react'; + +const BrandLogo = ({ + title = 'iMeeting', + size = 32, + titleSize = 18, + gap = 10, + titleColor = '#1f2f4a', + weight = 700, +}) => ( + + iMeeting + + {title} + + +); + +export default BrandLogo; diff --git a/frontend/src/components/Breadcrumb.css b/frontend/src/components/Breadcrumb.css deleted file mode 100644 index cefd491..0000000 --- a/frontend/src/components/Breadcrumb.css +++ /dev/null @@ -1,79 +0,0 @@ -/* 面包屑导航容器 */ -.breadcrumb-container { - background: white; - border-bottom: 1px solid #e2e8f0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); -} - -.breadcrumb-content { - max-width: 1400px; - margin: 0 auto; - padding: 0.875rem 2rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -/* 面包屑项 */ -.breadcrumb-item { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.9rem; - font-weight: 500; - transition: all 0.2s ease; -} - -/* 首页链接 */ -.breadcrumb-home { - color: #667eea; - background: none; - border: none; - padding: 0.375rem 0.75rem; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; -} - -.breadcrumb-home:hover { - background: #eff6ff; - color: #5568d3; - transform: translateY(-1px); -} - -/* 分隔符 */ -.breadcrumb-separator { - color: #cbd5e1; - flex-shrink: 0; -} - -/* 当前页 */ -.breadcrumb-current { - color: #1e293b; - padding: 0.375rem 0.75rem; - background: #f8fafc; - border-radius: 6px; -} - -.breadcrumb-current svg { - color: #667eea; -} - -/* 响应式 */ -@media (max-width: 768px) { - .breadcrumb-content { - padding: 0.75rem 1rem; - } - - .breadcrumb-item { - font-size: 0.85rem; - } - - .breadcrumb-item span { - display: none; - } - - .breadcrumb-separator { - margin: 0 0.25rem; - } -} diff --git a/frontend/src/components/Breadcrumb.jsx b/frontend/src/components/Breadcrumb.jsx deleted file mode 100644 index c3078fa..0000000 --- a/frontend/src/components/Breadcrumb.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { Home, ChevronRight } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; -import './Breadcrumb.css'; - -/** - * 面包屑导航组件 - * @param {string} currentPage - 当前页面名称 - * @param {string} icon - 当前页面图标(可选,lucide-react组件) - */ -const Breadcrumb = ({ currentPage, icon: Icon }) => { - const navigate = useNavigate(); - - const handleHomeClick = () => { - navigate('/dashboard'); - }; - - return ( -
-
- - -
- {Icon && } - {currentPage} -
-
-
- ); -}; - -export default Breadcrumb; diff --git a/frontend/src/components/CenterPager.jsx b/frontend/src/components/CenterPager.jsx new file mode 100644 index 0000000..6d5d2f3 --- /dev/null +++ b/frontend/src/components/CenterPager.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Button, Space, Typography } from 'antd'; +import { LeftOutlined, RightOutlined } from '@ant-design/icons'; + +const { Text } = Typography; + +const CenterPager = ({ + current = 1, + total = 0, + pageSize = 10, + onChange, +}) => { + const totalPages = Math.max(1, Math.ceil((total || 0) / pageSize)); + + return ( +
+ {`共${total || 0}条`} + +
+ ); +}; + +export default CenterPager; diff --git a/frontend/src/components/ClientDownloads.css b/frontend/src/components/ClientDownloads.css deleted file mode 100644 index d536da0..0000000 --- a/frontend/src/components/ClientDownloads.css +++ /dev/null @@ -1,150 +0,0 @@ -/* 客户端下载组件样式 */ -.client-downloads-section { - background: white; - border-radius: 12px; - padding: 2rem; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - margin-bottom: 2rem; -} - -.section-header { - margin-bottom: 2rem; - text-align: center; -} - -.section-header h2 { - font-size: 1.75rem; - font-weight: 600; - color: #1e293b; - margin: 0 0 0.5rem 0; -} - -.section-header p { - color: #64748b; - margin: 0; -} - -.downloads-container { - display: flex; - flex-direction: column; - gap: 2rem; -} - -.platform-group { - background: #f8fafc; - border-radius: 10px; - padding: 1.5rem; -} - -.group-header { - display: flex; - align-items: center; - gap: 0.75rem; - margin-bottom: 1.25rem; - color: #667eea; -} - -.group-header h3 { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: #1e293b; -} - -.clients-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 1rem; -} - -.client-download-card { - display: flex; - align-items: center; - gap: 1rem; - padding: 1.25rem; - background: white; - border: 1px solid #e2e8f0; - border-radius: 10px; - text-decoration: none; - color: inherit; - transition: all 0.2s ease; - cursor: pointer; -} - -.client-download-card:hover { - border-color: #667eea; - box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15); - transform: translateY(-2px); -} - -.card-icon { - flex-shrink: 0; - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border-radius: 12px; -} - -.card-info { - flex: 1; - min-width: 0; -} - -.card-info h4 { - margin: 0 0 0.5rem 0; - font-size: 1rem; - font-weight: 600; - color: #1e293b; -} - -.version-info { - display: flex; - align-items: center; - gap: 0.75rem; - margin-bottom: 0.25rem; -} - -.version { - font-size: 0.875rem; - color: #667eea; - font-weight: 500; -} - -.file-size { - font-size: 0.75rem; - color: #94a3b8; -} - -.system-req { - margin: 0; - font-size: 0.75rem; - color: #64748b; -} - -.download-icon { - flex-shrink: 0; - color: #667eea; -} - -.loading-message, -.empty-message { - text-align: center; - padding: 3rem; - color: #94a3b8; - font-size: 1rem; -} - -/* 响应式设计 */ -@media (max-width: 768px) { - .clients-list { - grid-template-columns: 1fr; - } - - .client-download-card { - padding: 1rem; - } -} diff --git a/frontend/src/components/ClientDownloads.jsx b/frontend/src/components/ClientDownloads.jsx index bb4f8c1..27fdc02 100644 --- a/frontend/src/components/ClientDownloads.jsx +++ b/frontend/src/components/ClientDownloads.jsx @@ -1,202 +1,97 @@ import React, { useState, useEffect } from 'react'; -import { Download, Smartphone, Monitor, Apple, ChevronRight, Cpu } from 'lucide-react'; +import { Card, Button, Space, Typography, Tag, List, Badge, Empty, Skeleton } from 'antd'; +import { + CloudOutlined, + PhoneOutlined, + DesktopOutlined, + AppleOutlined, + RobotOutlined, + WindowsOutlined, + RightOutlined +} from '@ant-design/icons'; import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; -import './ClientDownloads.css'; + +const { Title, Text } = Typography; const ClientDownloads = () => { - const [clients, setClients] = useState({ - mobile: [], - desktop: [], - terminal: [] - }); + const [clients, setClients] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { - fetchLatestClients(); + fetchClients(); }, []); - const fetchLatestClients = async () => { - setLoading(true); + const fetchClients = async () => { try { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LATEST)); - console.log('Latest clients response:', response); - setClients(response.data || { mobile: [], desktop: [], terminal: [] }); + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.PUBLIC_LIST)); + setClients(response.data.clients || []); } catch (error) { - console.error('获取客户端下载失败:', error); + console.error('获取下载列表失败:', error); } finally { setLoading(false); } }; const getPlatformIcon = (platformCode) => { - const code = (platformCode || '').toUpperCase(); - - // 根据 platform_code 判断图标 - if (code.includes('IOS') || code.includes('MAC')) { - return ; - } else if (code.includes('ANDROID')) { - return ; - } else if (code.includes('TERM') || code.includes('MCU')) { - return ; - } else { - return ; - } + const code = platformCode.toLowerCase(); + if (code.includes('win')) return ; + if (code.includes('mac') || code.includes('ios')) return ; + if (code.includes('android')) return ; + return ; }; - const getPlatformLabel = (client) => { - // 优先使用 dict_data 的中文标签 - return client.label_cn || client.platform_code || '未知平台'; - }; - - const formatFileSize = (bytes) => { - if (!bytes) return ''; - const mb = bytes / (1024 * 1024); - return `${mb.toFixed(0)} MB`; - }; - - if (loading) { - return ( -
-
-

下载客户端

-
-
加载中...
-
- ); - } + if (loading) return ; return ( -
-
-

下载客户端

-

选择适合您设备的版本

-
- -
- {/* 移动端 */} - {clients.mobile && clients.mobile.length > 0 && ( -
-
- -

移动端

-
-
- {clients.mobile.map(client => ( - -
- {getPlatformIcon(client.platform_code)} -
-
-

{getPlatformLabel(client)}

-
- )} - - {/* 桌面端 */} - {clients.desktop && clients.desktop.length > 0 && ( - + +
+ 版本: + {client.version} +
- {/* 专用终端 */} - {clients.terminal && clients.terminal.length > 0 && ( - - )} -
- - {!clients.mobile?.length && !clients.desktop?.length && !clients.terminal?.length && ( -
暂无可用的客户端下载
+ 立即下载 + + + + )} + /> )}
); diff --git a/frontend/src/components/ConfirmDialog.css b/frontend/src/components/ConfirmDialog.css deleted file mode 100644 index 585da1b..0000000 --- a/frontend/src/components/ConfirmDialog.css +++ /dev/null @@ -1,188 +0,0 @@ -.confirm-dialog-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 2000; - backdrop-filter: blur(4px); - animation: fadeIn 0.2s ease-out; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.confirm-dialog-content { - background: white; - border-radius: 16px; - width: 90%; - max-width: 420px; - padding: 32px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); - animation: slideUp 0.3s ease-out; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -@keyframes slideUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.confirm-dialog-icon { - width: 80px; - height: 80px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 24px; -} - -.confirm-dialog-icon.warning { - background: #fff7e6; - color: #fa8c16; -} - -.confirm-dialog-icon.danger { - background: #fff2f0; - color: #ff4d4f; -} - -.confirm-dialog-icon.info { - background: #e6f7ff; - color: #1890ff; -} - -.confirm-dialog-body { - margin-bottom: 28px; -} - -.confirm-dialog-title { - margin: 0 0 12px 0; - font-size: 20px; - font-weight: 600; - color: #262626; -} - -.confirm-dialog-message { - margin: 0; - font-size: 15px; - color: #595959; - line-height: 1.6; -} - -.confirm-dialog-actions { - display: flex; - gap: 12px; - width: 100%; -} - -.confirm-dialog-btn { - flex: 1; - padding: 12px 24px; - border: none; - border-radius: 8px; - font-size: 15px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; -} - -.confirm-dialog-btn.cancel { - background: white; - color: #595959; - border: 1px solid #d9d9d9; -} - -.confirm-dialog-btn.cancel:hover { - color: #262626; - border-color: #40a9ff; - background: #fafafa; -} - -.confirm-dialog-btn.confirm.warning { - background: #fa8c16; - color: white; -} - -.confirm-dialog-btn.confirm.warning:hover { - background: #ff9c2e; - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(250, 140, 22, 0.4); -} - -.confirm-dialog-btn.confirm.danger { - background: #ff4d4f; - color: white; -} - -.confirm-dialog-btn.confirm.danger:hover { - background: #ff7875; - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(255, 77, 79, 0.4); -} - -.confirm-dialog-btn.confirm.info { - background: #1890ff; - color: white; -} - -.confirm-dialog-btn.confirm.info:hover { - background: #40a9ff; - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4); -} - -/* 响应式 */ -@media (max-width: 640px) { - .confirm-dialog-content { - width: 95%; - padding: 24px; - } - - .confirm-dialog-icon { - width: 64px; - height: 64px; - margin-bottom: 20px; - } - - .confirm-dialog-icon svg { - width: 36px; - height: 36px; - } - - .confirm-dialog-title { - font-size: 18px; - } - - .confirm-dialog-message { - font-size: 14px; - } - - .confirm-dialog-actions { - flex-direction: column; - } - - .confirm-dialog-btn { - width: 100%; - } -} diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx deleted file mode 100644 index 22f0f92..0000000 --- a/frontend/src/components/ConfirmDialog.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import { AlertTriangle } from 'lucide-react'; -import './ConfirmDialog.css'; - -const ConfirmDialog = ({ - isOpen, - onClose, - onConfirm, - title = '确认操作', - message, - confirmText = '确认', - cancelText = '取消', - type = 'warning' // 'warning', 'danger', 'info' -}) => { - if (!isOpen) return null; - - const handleConfirm = () => { - onConfirm(); - onClose(); - }; - - return ( -
-
e.stopPropagation()}> -
- -
- -
-

{title}

-

{message}

-
- -
- - -
-
-
- ); -}; - -export default ConfirmDialog; diff --git a/frontend/src/components/ContentViewer.css b/frontend/src/components/ContentViewer.css deleted file mode 100644 index 0fd3d04..0000000 --- a/frontend/src/components/ContentViewer.css +++ /dev/null @@ -1,165 +0,0 @@ -/* Content Viewer Component */ -.content-viewer { - background: white; - border-radius: 12px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - overflow: hidden; -} - -.content-viewer .ant-tabs-nav { - margin: 0; - padding: 0 1.5rem; - border-bottom: 1px solid #e2e8f0; -} - -.content-viewer .ant-tabs-tab { - font-size: 1rem; - color: #475569; - padding: 16px 4px; - margin: 0 16px; -} - -.content-viewer .ant-tabs-tab .ant-tabs-tab-btn { - display: flex; - align-items: center; - gap: 8px; - font-weight: 500; -} - -.content-viewer .ant-tabs-tab-active .ant-tabs-tab-btn { - color: #667eea; -} - -.content-viewer .ant-tabs-ink-bar { - background: #667eea; - height: 3px; -} - -.content-viewer .ant-tabs-content-holder { - padding: 2rem; -} - -/* Tab Header with Actions */ -.tab-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; -} - -.tab-header h2 { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: #1e293b; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.tab-actions { - display: flex; - gap: 0.5rem; -} - -/* Content Markdown Area */ -.content-markdown { - line-height: 1.8; - color: #475569; -} - -.content-markdown h1 { - color: #1e293b; - font-size: 1.75rem; - margin-top: 2rem; - margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 2px solid #e2e8f0; -} - -.content-markdown h2 { - color: #1e293b; - font-size: 1.5rem; - margin-top: 1.5rem; - margin-bottom: 0.75rem; -} - -.content-markdown h3 { - color: #1e293b; - font-size: 1.25rem; - margin-top: 1.25rem; - margin-bottom: 0.5rem; -} - -.content-markdown p { - margin-bottom: 1rem; -} - -.content-markdown ul, -.content-markdown ol { - margin-left: 1.5rem; - margin-bottom: 1rem; -} - -.content-markdown li { - margin-bottom: 0.5rem; -} - -.content-markdown code { - background: #f1f5f9; - padding: 0.2rem 0.4rem; - border-radius: 4px; - font-size: 0.9em; - font-family: 'Courier New', monospace; -} - -.content-markdown pre { - background: #1e293b; - color: #e2e8f0; - padding: 1rem; - border-radius: 8px; - overflow-x: auto; - margin-bottom: 1rem; -} - -.content-markdown pre code { - background: none; - padding: 0; - color: inherit; -} - -.content-markdown table { - width: 100%; - border-collapse: collapse; - margin-bottom: 1rem; -} - -.content-markdown th, -.content-markdown td { - border: 1px solid #e2e8f0; - padding: 0.5rem; - text-align: left; -} - -.content-markdown th { - background: #f8fafc; - font-weight: 600; -} - -.content-markdown blockquote { - border-left: 4px solid #667eea; - padding-left: 1rem; - margin-left: 0; - color: #64748b; - font-style: italic; -} - -/* Empty State */ -.empty-content { - display: flex; - align-items: center; - justify-content: center; - padding: 3rem; - color: #94a3b8; - font-size: 1rem; -} diff --git a/frontend/src/components/ContentViewer.jsx b/frontend/src/components/ContentViewer.jsx index d7fcbc8..d7e4723 100644 --- a/frontend/src/components/ContentViewer.jsx +++ b/frontend/src/components/ContentViewer.jsx @@ -1,82 +1,73 @@ -import React from 'react'; -import { Tabs } from 'antd'; -import { FileText, Brain } from 'lucide-react'; -import MindMap from './MindMap'; +import React, { useState } from 'react'; +import { Card, Tabs, Typography, Space, Button, Empty } from 'antd'; +import { + FileTextOutlined, + PartitionOutlined, + CopyOutlined, + PictureOutlined, + BulbOutlined +} from '@ant-design/icons'; import MarkdownRenderer from './MarkdownRenderer'; -import './ContentViewer.css'; +import MindMap from './MindMap'; -const { TabPane } = Tabs; +const { Title, Text } = Typography; -/** - * ContentViewer - 纯展示组件,用于显示Markdown内容和脑图 - * - * 设计原则: - * 1. 组件只负责纯展示,不处理数据获取 - * 2. 父组件负责数据准备和导出功能 - * 3. 通过props传入已准备好的content - * - * @param {Object} props - * @param {string} props.content - Markdown格式的内容(必须由父组件准备好) - * @param {string} props.title - 标题(用于脑图显示) - * @param {string} props.emptyMessage - 内容为空时的提示消息 - * @param {React.ReactNode} props.summaryActions - 总结tab的额外操作按钮(如导出) - * @param {React.ReactNode} props.mindmapActions - 脑图tab的额外操作按钮(如导出) - */ -const ContentViewer = ({ - content, - title, - emptyMessage = '暂无内容', - summaryActions, - mindmapActions +const ContentViewer = ({ + content, + title, + emptyMessage = "暂无内容", + summaryActions = null, + mindmapActions = null }) => { - return ( -
- - - 摘要 - - } - key="content" - > -
-

AI总结

- {summaryActions &&
{summaryActions}
} -
-
- -
-
+ const [activeTab, setActiveTab] = useState('summary'); - - 脑图 - - } - key="mindmap" - > -
-

思维导图

- {mindmapActions &&
{mindmapActions}
} + if (!content) { + return ( + + + + ); + } + + const items = [ + { + key: 'summary', + label: 智能摘要, + children: ( +
+
+ {summaryActions}
- {content ? ( - - ) : ( -
等待内容生成后查看脑图
- )} - - -
+ +
+ ) + }, + { + key: 'mindmap', + label: 思维导图, + children: ( +
+
+ {mindmapActions} +
+
+ +
+
+ ) + } + ]; + + return ( + + + ); }; diff --git a/frontend/src/components/DataTable.css b/frontend/src/components/DataTable.css deleted file mode 100644 index 0be2ba2..0000000 --- a/frontend/src/components/DataTable.css +++ /dev/null @@ -1,176 +0,0 @@ -.data-table-wrapper { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.table-container { - background: white; - border-radius: 12px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); - overflow-x: auto; /* Enable horizontal scroll */ - border: 1px solid #e2e8f0; - /* 隐藏横向滚动条在 sticky 列下方 */ - overflow-y: hidden; -} - -.data-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - min-width: 100%; -} - -.data-table tr { - background-color: white; -} - -/* Sticky Right Column - 操作列固定 */ -.data-table th.sticky-right, -.data-table td.sticky-right { - position: sticky; - right: 0; - z-index: 1; - background-color: white; - box-shadow: -2px 0 5px rgba(0, 0, 0, 0.05); - border-left: 1px solid #f1f5f9; - /* 根据按钮个数自动宽度(通过 col.width 控制) */ -} - -.data-table th.sticky-right { - background-color: #f8fafc; - z-index: 2; -} - -.data-table tr:hover td.sticky-right { - background-color: #f8fafc; -} - -.data-table th { - background: #f8fafc; - padding: 0.875rem 1rem; - text-align: left; - font-weight: 600; - color: #475569; - border-bottom: 1px solid #e2e8f0; - white-space: nowrap; - font-size: 0.875rem; - height: 48px; -} - -.data-table td { - padding: 0.875rem 1rem; - border-bottom: 1px solid #f1f5f9; - color: #1e293b; - vertical-align: middle; - font-size: 0.875rem; - height: 48px; -} - -.data-table tr:last-child td { - border-bottom: none; -} - -.data-table tr:hover { - background-color: #f8fafc; -} - -.data-table-empty { - text-align: center; - padding: 3rem !important; - color: #94a3b8; -} - -/* Pagination */ -.data-table-pagination { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0 0.5rem; -} - -.pagination-info { - color: #64748b; - font-size: 0.875rem; -} - -.pagination-controls { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.pagination-btn { - padding: 0.375rem 0.75rem; - border: 1px solid #e2e8f0; - border-radius: 6px; - background: white; - color: #475569; - font-size: 0.875rem; - cursor: pointer; - transition: all 0.2s; -} - -.pagination-btn:hover:not(:disabled) { - background: #f8fafc; - border-color: #cbd5e1; -} - -.pagination-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - background: #f1f5f9; -} - -.pagination-current { - font-weight: 600; - color: #1e293b; - min-width: 1.5rem; - text-align: center; - font-size: 0.875rem; -} - -/* Common Utility Classes for Cells */ -.cell-truncate { - max-width: 200px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: block; -} - -.cell-mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - color: #334155; -} - -.cell-actions { - display: flex; - gap: 0.5rem; - flex-wrap: nowrap; - align-items: center; - justify-content: flex-start; -} - -/* Loading State inside table */ -.table-loading { - padding: 3rem; - display: flex; - justify-content: center; - align-items: center; - color: #64748b; - gap: 0.5rem; -} - -.spinner { - width: 20px; - height: 20px; - border: 2px solid #e2e8f0; - border-top-color: #667eea; - border-radius: 50%; - animation: table-spin 1s linear infinite; -} - -@keyframes table-spin { - to { transform: rotate(360deg); } -} diff --git a/frontend/src/components/DataTable.jsx b/frontend/src/components/DataTable.jsx deleted file mode 100644 index a735020..0000000 --- a/frontend/src/components/DataTable.jsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from 'react'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; -import './DataTable.css'; - -const DataTable = ({ - columns = [], - data = [], - loading = false, - pagination = null, // { current, pageSize, total, onChange } - emptyMessage = "暂无数据", - rowKey = "id", - className = "" -}) => { - // Calculate total pages - const totalPages = pagination ? Math.ceil(pagination.total / pagination.pageSize) : 0; - - // Handle page change - const handlePrev = () => { - if (pagination && pagination.current > 1) { - pagination.onChange(pagination.current - 1); - } - }; - - const handleNext = () => { - if (pagination && pagination.current < totalPages) { - pagination.onChange(pagination.current + 1); - } - }; - - return ( -
-
- - - - {columns.map((col, index) => ( - - ))} - - - - {loading ? ( - - - - ) : data.length === 0 ? ( - - - - ) : ( - data.map((item, rowIndex) => ( - - {columns.map((col, colIndex) => ( - - ))} - - )) - )} - -
- {col.title} -
-
- 加载中... -
- {emptyMessage} -
- {col.render ? col.render(item, rowIndex) : item[col.dataIndex]} -
-
- - {pagination && !loading && pagination.total > 0 && ( -
-
- 共 {pagination.total} 条记录 -
-
- - {pagination.current} - -
-
- )} -
- ); -}; - -export default DataTable; diff --git a/frontend/src/components/DateTimePicker.css b/frontend/src/components/DateTimePicker.css deleted file mode 100644 index ded0a98..0000000 --- a/frontend/src/components/DateTimePicker.css +++ /dev/null @@ -1,258 +0,0 @@ -.datetime-picker { - position: relative; - width: 100%; -} - -.datetime-display { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - border: 1px solid #d1d5db; - border-radius: 8px; - background: white; - cursor: pointer; - transition: all 0.2s ease; - min-height: 48px; -} - -.datetime-display:hover { - border-color: #9ca3af; -} - -.datetime-display:focus-within, -.datetime-display:focus { - outline: none; - border-color: #667eea; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); -} - -.display-text { - flex: 1; - color: #374151; - font-size: 14px; -} - -.display-text.placeholder { - color: #9ca3af; -} - -.clear-btn { - background: none; - border: none; - color: #9ca3af; - font-size: 18px; - cursor: pointer; - padding: 4px; - border-radius: 4px; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; -} - -.clear-btn:hover { - background: #f3f4f6; - color: #6b7280; -} - -.datetime-picker-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.1); - z-index: 10; - pointer-events: auto; -} - -.datetime-picker-panel { - position: absolute; - top: 100%; - left: 0; - right: 0; - background: white; - border: 1px solid #e5e7eb; - border-radius: 8px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); - z-index: 20; - margin-top: 4px; - padding: 20px; - min-width: 320px; - max-height: 500px; - height: auto; - min-height: 400px; - overflow-y: auto; -} - -.picker-section { - margin-bottom: 24px; -} - -.picker-section:last-of-type { - margin-bottom: 16px; -} - -.picker-section h4 { - margin: 0 0 12px 0; - color: #374151; - font-size: 14px; - font-weight: 600; -} - -.quick-date-options, -.quick-time-options { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; - margin-bottom: 16px; -} - -.quick-time-options { - grid-template-columns: repeat(4, 1fr); -} - -.quick-option { - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 6px; - padding: 8px 12px; - font-size: 13px; - cursor: pointer; - transition: all 0.2s ease; - text-align: center; -} - -.quick-option:hover { - background: #f1f5f9; - border-color: #cbd5e1; -} - -.quick-option.selected { - background: #667eea; - border-color: #667eea; - color: white; -} - -.custom-date-input, -.custom-time-input { - display: flex; - align-items: center; - gap: 8px; -} - -.custom-time-input { - background: #f8fafc; - padding: 8px 12px; - border-radius: 6px; - border: 1px solid #e2e8f0; -} - -.date-input, -.time-input { - border: 1px solid #d1d5db; - border-radius: 6px; - padding: 8px 12px; - font-size: 14px; - background: white; - transition: all 0.2s ease; - flex: 1; -} - -.time-input { - border: none; - background: transparent; - flex: 1; -} - -.date-input:focus, -.time-input:focus { - outline: none; - border-color: #667eea; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); -} - -.time-input:focus { - box-shadow: none; -} - -.picker-actions { - display: flex; - justify-content: flex-end; - gap: 12px; - padding-top: 16px; - border-top: 1px solid #e5e7eb; -} - -.action-btn { - padding: 8px 16px; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - border: 1px solid transparent; -} - -.action-btn.cancel { - background: #f8fafc; - color: #6b7280; - border-color: #d1d5db; -} - -.action-btn.cancel:hover { - background: #f1f5f9; - border-color: #9ca3af; -} - -.action-btn.confirm { - background: #667eea; - color: white; -} - -.action-btn.confirm:hover { - background: #5a67d8; -} - -/* 响应式设计 */ -@media (max-width: 768px) { - .datetime-picker-panel { - min-width: 280px; - padding: 16px; - } - - .quick-date-options { - grid-template-columns: repeat(2, 1fr); - } - - .quick-time-options { - grid-template-columns: repeat(3, 1fr); - } - - .picker-actions { - flex-direction: column; - gap: 8px; - } - - .action-btn { - width: 100%; - text-align: center; - } -} - -/* 改进输入框在Safari中的显示 */ -.date-input::-webkit-calendar-picker-indicator, -.time-input::-webkit-calendar-picker-indicator { - background: transparent; - color: #6b7280; - cursor: pointer; -} - -.date-input::-webkit-calendar-picker-indicator:hover, -.time-input::-webkit-calendar-picker-indicator:hover { - background: #f3f4f6; - border-radius: 4px; -} \ No newline at end of file diff --git a/frontend/src/components/DateTimePicker.jsx b/frontend/src/components/DateTimePicker.jsx index 1fc22d6..3ea7c26 100644 --- a/frontend/src/components/DateTimePicker.jsx +++ b/frontend/src/components/DateTimePicker.jsx @@ -1,260 +1,18 @@ -import React, { useState, useEffect } from 'react'; -import { Calendar, Clock } from 'lucide-react'; -import './DateTimePicker.css'; - -const DateTimePicker = ({ value, onChange, placeholder = "选择会议时间" }) => { - const [date, setDate] = useState(''); - const [time, setTime] = useState(''); - const [showQuickSelect, setShowQuickSelect] = useState(false); - const [isInitialized, setIsInitialized] = useState(false); - - // 组件卸载时清理状态 - useEffect(() => { - return () => { - setShowQuickSelect(false); - }; - }, []); - - // 初始化时间值 - useEffect(() => { - if (value && !isInitialized) { - const dateObj = new Date(value); - if (!isNaN(dateObj.getTime())) { - // 转换为本地时间字符串 - const timeZoneOffset = dateObj.getTimezoneOffset() * 60000; - const localDate = new Date(dateObj.getTime() - timeZoneOffset); - const isoString = localDate.toISOString(); - - setDate(isoString.split('T')[0]); - setTime(isoString.split('T')[1].slice(0, 5)); - } - setIsInitialized(true); - } else if (!value && !isInitialized) { - setDate(''); - setTime(''); - setIsInitialized(true); - } - }, [value, isInitialized]); - - // 当日期或时间改变时,更新父组件的值 - useEffect(() => { - // 只在初始化完成后才触发onChange - if (!isInitialized) return; - - if (date && time) { - const dateTimeString = `${date}T${time}`; - onChange?.(dateTimeString); - } else if (!date && !time) { - onChange?.(''); - } - }, [date, time, isInitialized]); // 移除onChange依赖 - - // 快速选择时间的选项 - const timeOptions = [ - { label: '09:00', value: '09:00' }, - { label: '10:00', value: '10:00' }, - { label: '11:00', value: '11:00' }, - { label: '14:00', value: '14:00' }, - { label: '15:00', value: '15:00' }, - { label: '16:00', value: '16:00' }, - { label: '17:00', value: '17:00' }, - ]; - - // 快速选择日期的选项 - const getQuickDateOptions = () => { - const today = new Date(); - const options = []; - - // 今天 - options.push({ - label: '今天', - value: today.toISOString().split('T')[0] - }); - - // 明天 - const tomorrow = new Date(today); - tomorrow.setDate(today.getDate() + 1); - options.push({ - label: '明天', - value: tomorrow.toISOString().split('T')[0] - }); - - // 后天 - const dayAfterTomorrow = new Date(today); - dayAfterTomorrow.setDate(today.getDate() + 2); - options.push({ - label: '后天', - value: dayAfterTomorrow.toISOString().split('T')[0] - }); - - return options; - }; - - const quickDateOptions = getQuickDateOptions(); - - const formatDisplayText = () => { - if (!date && !time) return placeholder; - - if (date && time) { - const dateObj = new Date(`${date}T${time}`); - return dateObj.toLocaleString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - } - - if (date) { - const dateObj = new Date(date); - return dateObj.toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric' - }); - } - - return placeholder; - }; - - const clearDateTime = () => { - setDate(''); - setTime(''); - // 重置初始化状态,允许后续值的设定 - setIsInitialized(false); - onChange?.(''); - }; +import React from 'react'; +import { DatePicker } from 'antd'; +import dayjs from 'dayjs'; +const DateTimePicker = ({ value, onChange, placeholder = "选择日期时间" }) => { return ( -
-
{ - e.stopPropagation(); - setShowQuickSelect(!showQuickSelect); - }}> - - - {formatDisplayText()} - - {(date || time) && ( - - )} -
- - {showQuickSelect && ( -
-
-

选择日期

-
- {quickDateOptions.map((option) => ( - - ))} -
-
- { - e.preventDefault(); - e.stopPropagation(); - setDate(e.target.value); - }} - onClick={(e) => e.stopPropagation()} - className="date-input" - /> -
-
- -
-

选择时间

-
- {timeOptions.map((option) => ( - - ))} -
-
- - { - e.preventDefault(); - e.stopPropagation(); - setTime(e.target.value); - }} - onClick={(e) => e.stopPropagation()} - className="time-input" - /> -
-
- -
- - -
-
- )} - - {showQuickSelect && ( -
setShowQuickSelect(false)} - /> - )} -
+ onChange(date ? date.format('YYYY-MM-DD HH:mm:ss') : null)} + style={{ width: '100%' }} + size="large" + /> ); }; -export default DateTimePicker; \ No newline at end of file +export default DateTimePicker; diff --git a/frontend/src/components/Dropdown.css b/frontend/src/components/Dropdown.css deleted file mode 100644 index 55f9675..0000000 --- a/frontend/src/components/Dropdown.css +++ /dev/null @@ -1,101 +0,0 @@ -/* Dropdown Container */ -.dropdown-container { - position: relative; - display: inline-block; -} - -.dropdown-trigger-wrapper { - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; -} - -/* Dropdown Menu */ -.dropdown-menu-wrapper { - position: absolute; - top: calc(100% + 0.5rem); - background: white; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - border: 1px solid #e2e8f0; - padding: 0.5rem; - min-width: 140px; - z-index: 1000; - display: flex; - flex-direction: column; - gap: 2px; - animation: dropdown-fade-in 0.2s ease; -} - -@keyframes dropdown-fade-in { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Alignment */ -.dropdown-align-left { - left: 0; -} - -.dropdown-align-right { - right: 0; -} - -/* Menu Item */ -.dropdown-menu-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.65rem 1rem; - border: none; - background: none; - cursor: pointer; - border-radius: 6px; - font-size: 0.9375rem; - font-weight: 500; - color: #374151; - text-decoration: none; - transition: all 0.2s ease; - width: 100%; - text-align: left; - white-space: nowrap; -} - -.dropdown-menu-item:hover:not(:disabled) { - background: #f3f4f6; - color: #111827; -} - -.dropdown-menu-item:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Danger Item */ -.dropdown-menu-item.danger { - color: #dc2626; -} - -.dropdown-menu-item.danger:hover:not(:disabled) { - background: #fef2f2; - color: #b91c1c; -} - -/* Item Icon and Label */ -.dropdown-item-icon { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.dropdown-item-label { - flex: 1; -} diff --git a/frontend/src/components/Dropdown.jsx b/frontend/src/components/Dropdown.jsx deleted file mode 100644 index cc8b62b..0000000 --- a/frontend/src/components/Dropdown.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import './Dropdown.css'; - -/** - * Dropdown - 通用下拉菜单组件 - * - * @param {Object} props - * @param {React.ReactNode} props.trigger - 触发器元素(按钮) - * @param {Array} props.items - 菜单项数组 - * - label: string - 显示文本 - * - icon: React.ReactNode - 图标(可选) - * - onClick: function - 点击回调 - * - className: string - 自定义样式类(可选) - * - danger: boolean - 是否为危险操作(红色) - * @param {string} props.align - 对齐方式: 'left' | 'right',默认 'right' - * @param {string} props.className - 外层容器自定义类名 - */ -const Dropdown = ({ - trigger, - items = [], - align = 'right', - className = '' -}) => { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - // 点击外部关闭下拉菜单 - useEffect(() => { - const handleClickOutside = (event) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } - }, [isOpen]); - - const handleTriggerClick = (e) => { - e.preventDefault(); - e.stopPropagation(); - setIsOpen(!isOpen); - }; - - const handleItemClick = (item, e) => { - e.preventDefault(); - e.stopPropagation(); - if (item.onClick) { - item.onClick(e); - } - setIsOpen(false); - }; - - return ( -
-
- {trigger} -
- - {isOpen && ( -
- {items.map((item, index) => ( - - ))} -
- )} -
- ); -}; - -export default Dropdown; diff --git a/frontend/src/components/ExpandSearchBox.css b/frontend/src/components/ExpandSearchBox.css deleted file mode 100644 index 413a809..0000000 --- a/frontend/src/components/ExpandSearchBox.css +++ /dev/null @@ -1,104 +0,0 @@ -/* 可展开搜索框组件样式 */ - -/* 只读标题样式(当没有搜索功能时) */ -.expand-search-readonly { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.expand-search-readonly h3 { - margin: 0; - font-size: 1.1rem; - font-weight: 600; - color: #1e293b; -} - -.expand-search-readonly svg { - color: #667eea; -} - -/* 可展开的紧凑搜索框 */ -.expand-search-box { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.85rem; - border: 1px solid #e2e8f0; - border-radius: 8px; - background: #f8fafc; - cursor: pointer; - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - width: fit-content; - max-width: 150px; - height: 40px; -} - -.expand-search-box .search-icon { - color: #667eea; - flex-shrink: 0; - transition: color 0.3s ease; - display: flex; - align-items: center; -} - -.expand-search-box:hover:not(.expanded) { - border-color: #cbd5e1; - background: white; -} - -.expand-search-box:hover:not(.expanded) .search-icon { - color: #5a67d8; -} - -.expand-search-box .search-placeholder { - font-size: 0.9rem; - font-weight: 600; - color: #1e293b; - white-space: nowrap; - user-select: none; - line-height: 1.4; -} - -/* 展开状态 */ -.expand-search-box.expanded { - cursor: text; - max-width: 100%; - width: 100%; - border-color: #e2e8f0; - background: white; -} - -.expand-search-box.expanded .search-icon { - color: #667eea; -} - -/* Ant Design Input 样式定制 */ -.expand-search-box .search-input-antd { - flex: 1; - height: 32px; - border-radius: 6px; -} - -.expand-search-box .search-input-antd .ant-input { - font-size: 0.9rem; -} - -/* 移除默认的焦点阴影,使用自定义样式 */ -.expand-search-box .search-input-antd:focus, -.expand-search-box .search-input-antd:focus-within { - border-color: #667eea; - box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1); -} - -/* 响应式设计 */ -@media (max-width: 768px) { - .expand-search-box { - width: 100%; - max-width: 100%; - } - - .expand-search-box.expanded { - width: 100%; - } -} diff --git a/frontend/src/components/ExpandSearchBox.jsx b/frontend/src/components/ExpandSearchBox.jsx index 8e4e212..718ad4b 100644 --- a/frontend/src/components/ExpandSearchBox.jsx +++ b/frontend/src/components/ExpandSearchBox.jsx @@ -1,119 +1,16 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState } from 'react'; import { Input } from 'antd'; -import { Search } from 'lucide-react'; -import './ExpandSearchBox.css'; - -const ExpandSearchBox = ({ - searchQuery = '', - onSearchChange = null, - placeholder = '搜索会议名称或发起人...', - collapsedText = '会议搜索', - showIcon = true, - realTimeSearch = false, // 改为默认false,避免频繁刷新 - debounceDelay = 500 // 防抖延迟时间(毫秒) -}) => { - const [isExpanded, setIsExpanded] = useState(false); - const [inputValue, setInputValue] = useState(searchQuery); - const debounceTimerRef = useRef(null); - - // 同步外部 searchQuery 的变化 - useEffect(() => { - setInputValue(searchQuery); - }, [searchQuery]); - - const handleInputChange = (e) => { - const value = e.target.value; - setInputValue(value); - - // 如果启用实时搜索,使用防抖触发回调 - if (realTimeSearch && onSearchChange) { - // 清除之前的定时器 - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - - // 设置新的定时器 - debounceTimerRef.current = setTimeout(() => { - onSearchChange(value.trim()); - }, debounceDelay); - } - }; - - const handleSearch = () => { - // 立即清除防抖定时器 - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - - if (onSearchChange) { - onSearchChange(inputValue.trim()); - } - }; - - const handleKeyPress = (e) => { - if (e.key === 'Enter') { - handleSearch(); - } - }; - - const handleClear = () => { - // 清除防抖定时器 - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - - setInputValue(''); - if (onSearchChange) { - onSearchChange(''); - } - }; - - // 组件卸载时清除定时器 - useEffect(() => { - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - }; - }, []); - - // 如果没有提供搜索回调函数,显示只读标题 - if (!onSearchChange) { - return ( -
- {showIcon && } -

{collapsedText}

-
- ); - } +import { SearchOutlined } from '@ant-design/icons'; +const ExpandSearchBox = ({ onSearch, placeholder = "搜索会议..." }) => { return ( -
!isExpanded && setIsExpanded(true)} - > - {showIcon && } - {isExpanded ? ( - { - if (!inputValue) setIsExpanded(false); - }} - allowClear={{ - clearIcon: × - }} - onClear={handleClear} - autoFocus - className="search-input-antd" - /> - ) : ( - {collapsedText} - )} -
+ ); }; diff --git a/frontend/src/components/FormModal.css b/frontend/src/components/FormModal.css deleted file mode 100644 index dd14f01..0000000 --- a/frontend/src/components/FormModal.css +++ /dev/null @@ -1,196 +0,0 @@ -/* FormModal 通用模态框样式 */ - -/* 遮罩层 */ -.form-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - backdrop-filter: blur(2px); - animation: fadeIn 0.2s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -/* 模态框主体 */ -.form-modal-content { - background: white; - border-radius: 12px; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); - width: 90%; - max-height: 85vh; - display: flex; - flex-direction: column; - overflow: hidden; - animation: slideUp 0.3s ease; -} - -@keyframes slideUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* 尺寸变体 */ -.form-modal-small { - max-width: 500px; -} - -.form-modal-medium { - max-width: 700px; -} - -.form-modal-large { - max-width: 900px; -} - -/* 模态框头部 */ -.form-modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1.5rem 2rem; - border-bottom: 1px solid #e2e8f0; - flex-shrink: 0; -} - -.form-modal-header-left { - display: flex; - align-items: center; - gap: 1.5rem; - flex: 1; - min-width: 0; /* 允许flex项目收缩 */ -} - -.form-modal-header h2 { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: #1e293b; - white-space: nowrap; /* 防止标题换行 */ -} - -.form-modal-header-extra { - display: flex; - align-items: center; - gap: 0.75rem; - flex-shrink: 0; -} - -.form-modal-close-btn { - padding: 0.5rem; - border: none; - border-radius: 6px; - background: #f1f5f9; - color: #64748b; - cursor: pointer; - font-size: 1.5rem; - line-height: 1; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.form-modal-close-btn:hover { - background: #e2e8f0; - color: #1e293b; -} - -/* 模态框主体内容 - 可滚动区域 */ -.form-modal-body { - padding: 1.5rem 2rem; - overflow-y: auto; - flex: 1; - min-height: 0; /* 重要:允许flex项目正确滚动 */ -} - -/* 模态框底部操作区 */ -.form-modal-actions { - padding: 1rem 2rem; - border-top: 1px solid #e2e8f0; - display: flex; - gap: 1rem; - justify-content: flex-end; - flex-shrink: 0; -} - -/* 滚动条样式 */ -.form-modal-body::-webkit-scrollbar { - width: 6px; -} - -.form-modal-body::-webkit-scrollbar-track { - background: #f1f5f9; - border-radius: 3px; -} - -.form-modal-body::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 3px; -} - -.form-modal-body::-webkit-scrollbar-thumb:hover { - background: #94a3b8; -} - -/* 响应式设计 */ -@media (max-width: 768px) { - .form-modal-content { - width: 95%; - max-height: 90vh; - } - - .form-modal-header { - padding: 1rem 1.5rem; - } - - .form-modal-header h2 { - font-size: 1.125rem; - } - - .form-modal-body { - padding: 1rem 1.5rem; - } - - .form-modal-actions { - padding: 0.75rem 1.5rem; - } - - /* 小屏幕下标题和额外内容垂直排列 */ - .form-modal-header-left { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } -} - -/* 低分辨率优化 */ -@media (max-height: 700px) { - .form-modal-content { - max-height: 90vh; - } - - .form-modal-body { - padding: 1rem 2rem; - } -} diff --git a/frontend/src/components/FormModal.jsx b/frontend/src/components/FormModal.jsx deleted file mode 100644 index e95c3f4..0000000 --- a/frontend/src/components/FormModal.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { X } from 'lucide-react'; -import './FormModal.css'; - -/** - * 通用表单模态框组件 - * @param {boolean} isOpen - 是否显示模态框 - * @param {function} onClose - 关闭回调 - * @param {string} title - 标题 - * @param {React.ReactNode} children - 表单内容 - * @param {React.ReactNode} actions - 底部操作按钮 - * @param {string} size - 尺寸 'small' | 'medium' | 'large' - * @param {React.ReactNode} headerExtra - 标题栏额外内容(如步骤指示器) - * @param {string} className - 自定义类名 - */ -const FormModal = ({ - isOpen, - onClose, - title, - children, - actions, - size = 'medium', - headerExtra = null, - className = '' -}) => { - if (!isOpen) return null; - - const sizeClass = `form-modal-${size}`; - - return ( -
-
e.stopPropagation()} - > - {/* 模态框头部 */} -
-
-

{title}

- {headerExtra &&
{headerExtra}
} -
- -
- - {/* 模态框主体内容 */} -
- {children} -
- - {/* 模态框底部操作区 */} - {actions && ( -
- {actions} -
- )} -
-
- ); -}; - -export default FormModal; diff --git a/frontend/src/components/Header.css b/frontend/src/components/Header.css deleted file mode 100644 index 188c276..0000000 --- a/frontend/src/components/Header.css +++ /dev/null @@ -1,12 +0,0 @@ -.app-header { - background-color: #ffffff; - padding: 20px; - border-bottom: 1px solid #e0e0e0; - box-shadow: 0 2px 4px rgba(0,0,0,0.05); -} - -.app-header h1 { - margin: 0; - font-size: 1.8em; - color: #2c3e50; -} diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index dac39da..f5551c2 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -1,11 +1,39 @@ import React from 'react'; -import './Header.css'; +import { Layout, Space, Avatar, Dropdown, Typography, Button } from 'antd'; +import { UserOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons'; +import BrandLogo from './BrandLogo'; + +const { Header: AntdHeader } = Layout; +const { Text } = Typography; + +const Header = ({ user, onLogout }) => { + const items = [ + { key: 'settings', label: '个人设置', icon: }, + { type: 'divider' }, + { key: 'logout', label: '退出登录', icon: , danger: true, onClick: onLogout } + ]; -const Header = () => { return ( -
-

iMeeting (慧会议)

-
+ + + + + + + + } /> + {user?.caption} + + + ); }; diff --git a/frontend/src/components/ListTable.css b/frontend/src/components/ListTable.css deleted file mode 100644 index 0ecfecb..0000000 --- a/frontend/src/components/ListTable.css +++ /dev/null @@ -1,237 +0,0 @@ -/* ListTable 列表表格组件 */ - -.list-table-wrapper { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.list-table-container { - background: white; - border-radius: 12px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); - border: 1px solid #e2e8f0; - overflow: hidden; -} - -/* 内容区域:flex 布局,数据列可滚动 + 操作列固定 */ -.list-table-content { - display: flex; - width: 100%; - min-height: 0; - overflow: hidden; -} - -/* 可滚动的数据列区域 */ -.list-table-scroll { - flex: 1; - overflow-x: auto; - overflow-y: scroll; -} - -.list-table { - width: 100%; - border-collapse: collapse; - border-spacing: 0; -} - -.list-table thead tr { - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; -} - -.list-table thead th { - padding: 0.875rem 1rem; - text-align: left; - font-weight: 600; - color: #475569; - font-size: 0.875rem; - white-space: nowrap; - height: 48px; - vertical-align: middle; -} - -.list-table tbody tr { - border-bottom: 1px solid #f1f5f9; - background: white; -} - -.list-table tbody tr:last-child { - border-bottom: none; -} - -.list-table tbody tr:hover { - background: #f8fafc; -} - -.list-table tbody td { - padding: 0.875rem 1rem; - color: #1e293b; - font-size: 0.875rem; - height: 48px; - vertical-align: middle; -} - -/* 固定操作列 */ -.list-table-actions { - flex-shrink: 0; - border-left: 1px solid #e2e8f0; - background: white; - overflow-y: scroll; - overflow-x: hidden; -} - -.list-table-actions-table { - width: 100%; - border-collapse: collapse; - border-spacing: 0; -} - -.list-table-actions thead tr { - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; -} - -.list-table-actions thead th { - padding: 0.875rem 1rem; - text-align: left; - font-weight: 600; - color: #475569; - font-size: 0.875rem; - white-space: nowrap; - height: 48px; - vertical-align: middle; -} - -.list-table-actions tbody tr { - border-bottom: 1px solid #f1f5f9; - background: white; -} - -.list-table-actions tbody tr:last-child { - border-bottom: none; -} - -.list-table-actions tbody tr:hover { - background: #f8fafc; -} - -.list-table-actions tbody td { - padding: 0.875rem 1rem; - color: #1e293b; - font-size: 0.875rem; - height: 48px; - vertical-align: middle; -} - -/* 空状态 */ -.list-table-empty { - text-align: center; - padding: 3rem !important; - color: #94a3b8; - display: flex !important; - align-items: center; - justify-content: center; -} - -/* 加载状态 */ -.list-table-loading { - text-align: center; - padding: 3rem !important; - display: flex; - justify-content: center; - align-items: center; - gap: 0.75rem; - color: #64748b; - height: auto !important; -} - -.spinner { - width: 20px; - height: 20px; - border: 2px solid #e2e8f0; - border-top-color: #667eea; - border-radius: 50%; - animation: table-spin 1s linear infinite; -} - -@keyframes table-spin { - to { transform: rotate(360deg); } -} - -/* 分页 */ -.list-table-pagination { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 0.5rem; -} - -.pagination-info { - color: #64748b; - font-size: 0.875rem; - font-weight: 500; -} - -.pagination-controls { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.pagination-btn { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - border: 1px solid #e2e8f0; - border-radius: 6px; - background: white; - color: #475569; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; -} - -.pagination-btn:hover:not(:disabled) { - background: #f8fafc; - border-color: #cbd5e1; - color: #1e293b; -} - -.pagination-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - background: #f1f5f9; -} - -.pagination-current { - font-weight: 600; - color: #1e293b; - min-width: 1.5rem; - text-align: center; - font-size: 0.875rem; -} - -/* 兼容旧样式类 */ -.cell-truncate { - max-width: 200px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: block; -} - -.cell-mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - color: #334155; -} - -.action-buttons { - display: flex; - gap: 0.5rem; - flex-wrap: nowrap; - align-items: center; - justify-content: flex-start; -} diff --git a/frontend/src/components/ListTable.jsx b/frontend/src/components/ListTable.jsx deleted file mode 100644 index bcb4f25..0000000 --- a/frontend/src/components/ListTable.jsx +++ /dev/null @@ -1,169 +0,0 @@ -import React from 'react'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; -import './ListTable.css'; - -/** - * 统一的列表表格组件 - * 特点: - * 1. 操作列固定在右侧,宽度自适应 - * 2. 其他列可横向滚动 - * 3. 自动计算操作列宽度(基于按钮数量) - * 4. 统一的样式和交互 - * 5. 可配置的分页器 - */ -const ListTable = ({ - columns = [], - data = [], - loading = false, - pagination = null, // { current, pageSize, total, onChange } - emptyMessage = "暂无数据", - rowKey = "id", - className = "", - showPagination = true // 是否显示分页器,默认显示 -}) => { - const totalPages = pagination ? Math.ceil(pagination.total / pagination.pageSize) : 0; - - const handlePrev = () => { - if (pagination && pagination.current > 1) { - pagination.onChange(pagination.current - 1); - } - }; - - const handleNext = () => { - if (pagination && pagination.current < totalPages) { - pagination.onChange(pagination.current + 1); - } - }; - - // 分离操作列和数据列 - const actionColumn = columns.find(col => col.fixed === 'right'); - const dataColumns = columns.filter(col => col.fixed !== 'right'); - - // 根据按钮数量自动计算操作列宽度 - const calculateActionColumnWidth = () => { - if (!actionColumn) return '0'; - // 每个按钮 32px + 间距 8px,加上左右 padding 各 16px - // 3个按钮: 32 + 8 + 32 + 8 + 32 + 16 + 16 = 144px - // 2个按钮: 32 + 8 + 32 + 16 + 16 = 104px - return actionColumn.width || 'auto'; - }; - - return ( -
-
-
- {/* 数据列区域 */} -
- - - - {dataColumns.map((col, index) => ( - - ))} - - - - {loading ? ( - - - - ) : data.length === 0 ? ( - - - - ) : ( - data.map((item, rowIndex) => ( - - {dataColumns.map((col, colIndex) => ( - - ))} - - )) - )} - -
- {col.title} -
-
- 加载中... -
- {emptyMessage} -
- {col.render ? col.render(item, rowIndex) : item[col.dataIndex]} -
-
- - {/* 操作列区域(固定) */} - {actionColumn && ( -
- - - - - - - - {loading ? ( - - - - ) : data.length === 0 ? ( - - - - ) : ( - data.map((item, rowIndex) => ( - - - - )) - )} - -
{actionColumn.title}
- {actionColumn.render ? actionColumn.render(item, rowIndex) : item[actionColumn.dataIndex]} -
-
- )} -
-
- - {/* 分页 */} - {showPagination && pagination && !loading && pagination.total > 0 && ( -
-
- 共 {pagination.total} 条记录 -
-
- - {pagination.current} - -
-
- )} -
- ); -}; - -export default ListTable; diff --git a/frontend/src/components/MainLayout.jsx b/frontend/src/components/MainLayout.jsx new file mode 100644 index 0000000..f74084b --- /dev/null +++ b/frontend/src/components/MainLayout.jsx @@ -0,0 +1,417 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { Layout, Menu, Button, Avatar, Space, Drawer, Grid, Badge, Breadcrumb, Tooltip } from 'antd'; +import { + UserOutlined, + LogoutOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + RightOutlined, +} from '@ant-design/icons'; +import { useNavigate, useLocation } from 'react-router-dom'; +import menuService from '../services/menuService'; +import { renderMenuIcon } from '../utils/menuIcons'; + +const { Header, Content, Sider } = Layout; +const { useBreakpoint } = Grid; + +const MainLayout = ({ children, user, onLogout }) => { + const [collapsed, setCollapsed] = useState(false); + const [userMenus, setUserMenus] = useState([]); + const [drawerOpen, setDrawerOpen] = useState(false); + const [openKeys, setOpenKeys] = useState([]); + const [activeMenuKey, setActiveMenuKey] = useState(null); + const navigate = useNavigate(); + const location = useLocation(); + const screens = useBreakpoint(); + + const isMobile = !screens.lg; + + useEffect(() => { + const fetchMenus = async () => { + try { + const response = await menuService.getUserMenus(); + if (response.code === '200') { + setUserMenus(response.data.menus || []); + } + } catch (error) { + console.error('Error fetching menus:', error); + } + }; + fetchMenus(); + }, []); + + useEffect(() => { + if (isMobile) { + setCollapsed(false); + } + }, [isMobile]); + + const menuItems = useMemo(() => { + const sortedMenus = [...userMenus] + .sort((a, b) => { + if ((a.parent_id || 0) !== (b.parent_id || 0)) { + return (a.parent_id || 0) - (b.parent_id || 0); + } + if ((a.sort_order || 0) !== (b.sort_order || 0)) { + return (a.sort_order || 0) - (b.sort_order || 0); + } + return a.menu_id - b.menu_id; + }); + + const menuById = new Map(); + sortedMenus.forEach((menu) => { + menuById.set(menu.menu_id, { + ...menu, + key: `menu_${menu.menu_id}`, + icon: renderMenuIcon(menu.menu_icon, menu.menu_code), + children: [], + }); + }); + + const roots = []; + menuById.forEach((menu) => { + if (menu.parent_id && menuById.has(menu.parent_id)) { + menuById.get(menu.parent_id).children.push(menu); + } else { + roots.push(menu); + } + }); + + const toMenuItem = (menu) => { + const hasChildren = menu.children.length > 0; + return { + key: menu.key, + menu_code: menu.menu_code, + icon: menu.icon, + label: menu.menu_name, + path: hasChildren ? null : menu.menu_url, + children: hasChildren ? menu.children.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)).map(toMenuItem) : undefined, + onClick: () => { + if (menu.menu_type === 'link' && menu.menu_url && !hasChildren) { + setActiveMenuKey(`menu_${menu.menu_id}`); + navigate(menu.menu_url); + setDrawerOpen(false); + } + }, + }; + }; + + return roots + .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)) + .map(toMenuItem); + }, [navigate, onLogout, userMenus]); + + const flatMenuKeys = useMemo(() => { + const keys = []; + const walk = (items) => { + items.forEach((item) => { + keys.push(item.key); + if (item.children?.length) { + walk(item.children); + } + }); + }; + walk(menuItems); + return keys; + }, [menuItems]); + + const pathKeyEntries = useMemo(() => { + const entries = []; + const walk = (items) => { + items.forEach((item) => { + if (item.path && String(item.path).startsWith('/')) { + entries.push({ path: item.path, key: item.key, menuCode: item.menu_code, label: item.label }); + } + if (item.children?.length) { + walk(item.children); + } + }); + }; + walk(menuItems); + return entries; + }, [menuItems]); + + const keyParentMap = useMemo(() => { + const map = new Map(); + const walk = (items, parentKey = null) => { + items.forEach((item) => { + if (parentKey) { + map.set(item.key, parentKey); + } + if (item.children?.length) { + walk(item.children, item.key); + } + }); + }; + walk(menuItems); + return map; + }, [menuItems]); + + const menuItemByKey = useMemo(() => { + const map = new Map(); + const walk = (items) => { + items.forEach((item) => { + map.set(item.key, item); + if (item.children?.length) { + walk(item.children); + } + }); + }; + walk(menuItems); + return map; + }, [menuItems]); + + useEffect(() => { + if (activeMenuKey && !flatMenuKeys.includes(activeMenuKey)) { + setActiveMenuKey(null); + } + }, [activeMenuKey, flatMenuKeys]); + + const selectedMenuKey = useMemo(() => { + if (!pathKeyEntries.length) return []; + + const exactMatches = pathKeyEntries.filter((item) => item.path === location.pathname); + if (exactMatches.length) { + if (activeMenuKey && exactMatches.some((item) => item.key === activeMenuKey)) { + return [activeMenuKey]; + } + return [exactMatches[0].key]; + } + + const prefixMatches = [...pathKeyEntries] + .sort((a, b) => String(b.path).length - String(a.path).length) + .filter((item) => location.pathname.startsWith(String(item.path))); + + if (prefixMatches.length) { + if (activeMenuKey && prefixMatches.some((item) => item.key === activeMenuKey)) { + return [activeMenuKey]; + } + return [prefixMatches[0].key]; + } + + return []; + }, [activeMenuKey, location.pathname, pathKeyEntries]); + + useEffect(() => { + const currentKey = selectedMenuKey[0]; + if (!currentKey) return; + + const ancestors = []; + const visited = new Set(); + let cursor = keyParentMap.get(currentKey); + while (cursor && !visited.has(cursor)) { + visited.add(cursor); + ancestors.unshift(cursor); + cursor = keyParentMap.get(cursor); + } + + setOpenKeys((prev) => { + const next = Array.from(new Set([...prev, ...ancestors])); + if (next.length === prev.length && next.every((item, index) => item === prev[index])) { + return prev; + } + return next; + }); + }, [keyParentMap, selectedMenuKey]); + + const breadcrumbItems = useMemo(() => { + const create = (title, path) => ({ title, path }); + const path = location.pathname; + const activeMenuItem = selectedMenuKey[0] ? menuItemByKey.get(selectedMenuKey[0]) : null; + + if (path === '/dashboard') return [create(activeMenuItem?.label || '工作台')]; + if (path === '/prompt-management') return [create('平台管理', '/admin/management'), create('提示词仓库')]; + if (path === '/prompt-config') { + return user?.role_id === 1 + ? [create('平台管理', '/admin/management'), create('提示词配置')] + : [create('会议管理'), create('提示词配置')]; + } + if (path === '/personal-prompts') return [create('会议管理'), create('个人提示词仓库')]; + if (path === '/meetings/center' || path === '/meetings/history') return [create('会议管理'), create('会议中心')]; + if (path === '/knowledge-base') return [create('知识库')]; + if (path.startsWith('/knowledge-base/edit/')) return [create('知识库', '/knowledge-base'), create('编辑知识库')]; + if (path === '/account-settings') return [create('个人设置')]; + if (path.startsWith('/meetings/')) return [create('会议管理', '/meetings/center'), create('会议详情')]; + if (path === '/downloads') return [create('客户端下载')]; + + if (path === '/admin/management') { + return [create('平台管理')]; + } + + if (path.startsWith('/admin/management/')) { + const moduleKey = path.split('/')[3]; + const moduleNameMap = { + 'user-management': '用户管理', + 'permission-management': '权限管理', + 'dict-management': '字典管理', + 'hot-word-management': '热词管理', + 'client-management': '客户端管理', + 'external-app-management': '外部应用管理', + 'terminal-management': '终端管理', + 'parameter-management': '参数管理', + 'model-management': '模型管理', + }; + const systemModules = new Set(['user-management', 'permission-management', 'dict-management', 'parameter-management']); + const parentTitle = systemModules.has(moduleKey) ? '系统管理' : '平台管理'; + return [create(parentTitle, '/admin/management'), create(moduleNameMap[moduleKey] || parentTitle)]; + } + + return [create('当前页面')]; + }, [location.pathname, menuItemByKey, selectedMenuKey, user?.role_id]); + + const sidebar = ( + <> +
+ + iMeeting + + {!collapsed && ( +
+
iMeeting
+
智能会议控制台
+
+ )} + {!isMobile && ( +
+ +
+
navigate('/account-settings')} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + navigate('/account-settings'); + } + }} + > + } + className="main-layout-user-avatar" + /> + {!collapsed && ( +
+
{user.caption || user.username || '用户'}
+
+ {user.role_name ? String(user.role_name).toUpperCase() : user.role_id === 1 ? 'ADMIN' : 'USER'} +
+
+ )} +
+ +
+ + ); + + return ( + + {isMobile ? ( + setDrawerOpen(false)} + placement="left" + width={280} + styles={{ body: { padding: 0, background: '#f7fbff' } }} + closable={false} + > + {sidebar} + + ) : ( + + {sidebar} + + )} + + +
+ + {isMobile && ( +
+ {children} +
+
+ ); +}; + +export default MainLayout; diff --git a/frontend/src/components/MarkdownEditor.css b/frontend/src/components/MarkdownEditor.css deleted file mode 100644 index 8b452e0..0000000 --- a/frontend/src/components/MarkdownEditor.css +++ /dev/null @@ -1,280 +0,0 @@ -/* Markdown Editor Component */ -.markdown-editor-wrapper { - margin-top: 0.5rem; -} - -.editor-toolbar { - display: flex; - align-items: center; - gap: 0.25rem; - padding: 0.5rem; - background: #f8fafc; - border: 2px solid #e2e8f0; - border-radius: 8px 8px 0 0; - flex-wrap: wrap; -} - -.toolbar-btn { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 32px; - height: 32px; - padding: 0.25rem 0.5rem; - background: white; - border: 1px solid #d1d5db; - border-radius: 4px; - font-size: 0.875rem; - font-weight: 500; - color: #374155; - cursor: pointer; - transition: all 0.2s ease; - white-space: nowrap; -} - -.toolbar-btn:hover { - background: #f0f4ff; - border-color: #667eea; - color: #667eea; -} - -.toolbar-btn.active { - background: #667eea; - border-color: #667eea; - color: white; -} - -.toolbar-btn.active:hover { - background: #5a67d8; - border-color: #5a67d8; -} - -.toolbar-btn strong, -.toolbar-btn em { - font-style: normal; - font-weight: 600; -} - -.toolbar-divider { - width: 1px; - height: 24px; - background: #d1d5db; - margin: 0 0.25rem; -} - -/* 标题下拉菜单 */ -.toolbar-dropdown { - position: relative; -} - -.dropdown-menu { - position: absolute; - top: 100%; - left: 0; - margin-top: 0.25rem; - background: white; - border: 1px solid #d1d5db; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 100; - min-width: 160px; - overflow: visible; -} - -.dropdown-menu button { - display: block; - width: 100%; - padding: 0.75rem 1rem; - border: none; - background: white; - text-align: left; - cursor: pointer; - transition: background 0.2s ease; - font-family: inherit; - color: #374155; -} - -.dropdown-menu button:hover { - background: #f0f4ff; - color: #667eea; -} - -.dropdown-menu button h1, -.dropdown-menu button h2, -.dropdown-menu button h3, -.dropdown-menu button h4, -.dropdown-menu button h5, -.dropdown-menu button h6 { - font-weight: 600; - color: inherit; -} - -/* CodeMirror 样式覆盖 */ -.markdown-editor-wrapper .cm-editor { - border-top: none !important; - border-radius: 0 0 8px 8px !important; -} - -/* 预览区域 */ -.markdown-preview { - border: 2px solid #e2e8f0; - border-top: none; - border-radius: 0 0 8px 8px; - padding: 2rem; - background: white; - min-height: 400px; - font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; -} - -.markdown-preview h1, -.markdown-preview h2, -.markdown-preview h3, -.markdown-preview h4, -.markdown-preview h5, -.markdown-preview h6 { - margin: 1.5rem 0 0.75rem 0; - font-weight: 600; - color: #1e293b; - line-height: 1.3; -} - -.markdown-preview h1:first-child, -.markdown-preview h2:first-child, -.markdown-preview h3:first-child, -.markdown-preview h4:first-child, -.markdown-preview h5:first-child, -.markdown-preview h6:first-child { - margin-top: 0; -} - -.markdown-preview h1 { font-size: 1.875rem; } -.markdown-preview h2 { font-size: 1.5rem; } -.markdown-preview h3 { font-size: 1.25rem; } -.markdown-preview h4 { font-size: 1.125rem; } -.markdown-preview h5 { font-size: 1rem; } -.markdown-preview h6 { font-size: 0.875rem; } - -.markdown-preview p { - margin: 0.75rem 0; - line-height: 1.7; - color: #475569; -} - -.markdown-preview ul, -.markdown-preview ol { - margin: 1rem 0; - padding-left: 2rem; -} - -.markdown-preview li { - margin: 0.5rem 0; - line-height: 1.6; - color: #475569; -} - -.markdown-preview strong { - font-weight: 600; - color: #1e293b; -} - -.markdown-preview em { - font-style: italic; -} - -.markdown-preview code { - background: #f1f5f9; - padding: 0.2rem 0.4rem; - border-radius: 4px; - font-family: 'Monaco', 'Consolas', 'Courier New', monospace; - font-size: 0.875rem; - color: #dc2626; -} - -.markdown-preview pre { - background: #f8fafc; - padding: 1rem; - border-radius: 8px; - overflow-x: auto; - border: 1px solid #e2e8f0; - margin: 1rem 0; -} - -.markdown-preview pre code { - background: none; - padding: 0; - color: #334155; -} - -.markdown-preview blockquote { - border-left: 4px solid #667eea; - padding-left: 1rem; - margin: 1rem 0; - font-style: italic; - color: #64748b; - background: #f8fafc; - padding: 1rem; - border-radius: 0 8px 8px 0; -} - -.markdown-preview table { - width: 100%; - border-collapse: collapse; - margin: 1.5rem 0; - border: 1px solid #e2e8f0; -} - -.markdown-preview th, -.markdown-preview td { - border: 1px solid #e2e8f0; - padding: 0.75rem; - text-align: left; -} - -.markdown-preview th { - background: #f8fafc; - font-weight: 600; - color: #334155; -} - -.markdown-preview hr { - border: none; - height: 1px; - background: #e2e8f0; - margin: 2rem 0; -} - -.markdown-preview img { - max-width: 100%; - height: auto; - border-radius: 8px; - margin: 1rem 0; -} - -.markdown-preview a { - color: #667eea; - text-decoration: none; - border-bottom: 1px solid transparent; - transition: border-color 0.2s ease; -} - -.markdown-preview a:hover { - border-bottom-color: #667eea; -} - -/* 响应式 */ -@media (max-width: 768px) { - .editor-toolbar { - gap: 0.125rem; - padding: 0.375rem; - } - - .toolbar-btn { - min-width: 28px; - height: 28px; - font-size: 0.75rem; - } - - .markdown-preview { - padding: 1rem; - } -} diff --git a/frontend/src/components/MarkdownEditor.jsx b/frontend/src/components/MarkdownEditor.jsx index ff7b795..41a424e 100644 --- a/frontend/src/components/MarkdownEditor.jsx +++ b/frontend/src/components/MarkdownEditor.jsx @@ -2,8 +2,20 @@ import React, { useState, useRef, useMemo } from 'react'; import CodeMirror from '@uiw/react-codemirror'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { EditorView } from '@codemirror/view'; +import { + Space, Button, Tooltip, Card, + Divider, Dropdown, Typography +} from 'antd'; +import { + BoldOutlined, ItalicOutlined, FontSizeOutlined, + MessageOutlined, CodeOutlined, LinkOutlined, + TableOutlined, PictureOutlined, OrderedListOutlined, + UnorderedListOutlined, LineOutlined, EyeOutlined, + EditOutlined +} from '@ant-design/icons'; import MarkdownRenderer from './MarkdownRenderer'; -import './MarkdownEditor.css'; + +const { Text } = Typography; const MarkdownEditor = ({ value, @@ -16,45 +28,37 @@ const MarkdownEditor = ({ const editorRef = useRef(null); const imageInputRef = useRef(null); const [showPreview, setShowPreview] = useState(false); - const [showHeadingMenu, setShowHeadingMenu] = useState(false); - // CodeMirror extensions const editorExtensions = useMemo(() => [ markdown({ base: markdownLanguage }), EditorView.lineWrapping, EditorView.theme({ "&": { fontSize: "14px", - border: "2px solid #e2e8f0", + border: "1px solid #d9d9d9", borderRadius: "0 0 8px 8px", borderTop: "none", }, ".cm-content": { - fontFamily: "'Monaco', 'Menlo', 'Consolas', monospace", - padding: "1rem", + fontFamily: "var(--ant-font-family-code), monospace", + padding: "16px", minHeight: `${height}px`, }, - ".cm-scroller": { - fontFamily: "'Monaco', 'Menlo', 'Consolas', monospace", - }, "&.cm-focused": { outline: "none", - borderColor: "#667eea", - boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.1)", + borderColor: "#1677ff", + boxShadow: "0 0 0 2px rgba(22, 119, 255, 0.1)", } }) ], [height]); - // Markdown 插入函数 const insertMarkdown = (before, after = '', placeholder = '') => { if (!editorRef.current?.view) return; - const view = editorRef.current.view; const selection = view.state.selection.main; const selectedText = view.state.doc.sliceString(selection.from, selection.to); const text = selectedText || placeholder; const newText = `${before}${text}${after}`; - view.dispatch({ changes: { from: selection.from, to: selection.to, insert: newText }, selection: { anchor: selection.from + before.length, head: selection.from + before.length + text.length } @@ -62,139 +66,75 @@ const MarkdownEditor = ({ view.focus(); }; - // 工具栏操作 const toolbarActions = { - bold: () => insertMarkdown('**', '**', '粗体文字'), - italic: () => insertMarkdown('*', '*', '斜体文字'), - heading: (level) => { - setShowHeadingMenu(false); - insertMarkdown('#'.repeat(level) + ' ', '', '标题'); - }, - quote: () => insertMarkdown('> ', '', '引用内容'), + bold: () => insertMarkdown('**', '**', '粗体'), + italic: () => insertMarkdown('*', '*', '斜体'), + heading: (level) => insertMarkdown('#'.repeat(level) + ' ', '', '标题'), + quote: () => insertMarkdown('> ', '', '引用'), code: () => insertMarkdown('`', '`', '代码'), - codeBlock: () => insertMarkdown('```\n', '\n```', '代码块'), - link: () => insertMarkdown('[', '](url)', '链接文字'), + link: () => insertMarkdown('[', '](url)', '链接'), unorderedList: () => insertMarkdown('- ', '', '列表项'), orderedList: () => insertMarkdown('1. ', '', '列表项'), - table: () => { - const tableTemplate = '\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| 单元格 | 单元格 | 单元格 |\n| 单元格 | 单元格 | 单元格 |\n'; - insertMarkdown(tableTemplate, '', ''); - }, + table: () => insertMarkdown('\n| 列1 | 列2 |\n| --- | --- |\n| 单元格 | 单元格 |\n', '', ''), hr: () => insertMarkdown('\n---\n', '', ''), image: () => imageInputRef.current?.click(), }; - // 图片上传处理 - const handleImageSelect = async (event) => { - const file = event.target.files[0]; - if (file && onImageUpload) { - const imageUrl = await onImageUpload(file); - if (imageUrl) { - insertMarkdown(`![${file.name}](${imageUrl})`, '', ''); - } - } - // Reset file input - if (imageInputRef.current) { - imageInputRef.current.value = ''; - } + const headingMenu = { + items: [1, 2, 3, 4, 5, 6].map(level => ({ + key: level, + label: `标题 ${level}`, + onClick: () => toolbarActions.heading(level) + })) }; return ( -
-
- - +
+ + } size={4}> + + - {showHeadingMenu && ( -
- - - - - - -
- )} -
- - - - - - - - - {showImageUpload && ( - - )} - - - - - - - - - - {/* 预览按钮 */} - -
+ {showPreview ? "编辑" : "预览"} + + + {showPreview ? ( - + + + ) : ( )} - {showImageUpload && ( - - )} + { + const file = e.target.files[0]; + if (file && onImageUpload) { + onImageUpload(file).then(url => url && insertMarkdown(`![${file.name}](${url})`, '', '')); + } + e.target.value = ''; + }} style={{ display: 'none' }} />
); }; diff --git a/frontend/src/components/MarkdownRenderer.css b/frontend/src/components/MarkdownRenderer.css deleted file mode 100644 index 28943ad..0000000 --- a/frontend/src/components/MarkdownRenderer.css +++ /dev/null @@ -1,285 +0,0 @@ -/* Unified Markdown Renderer Styles */ - -.markdown-renderer { - font-size: 1rem; - line-height: 1.8; - color: #475569; - overflow-wrap: break-word; - word-wrap: break-word; - word-break: break-word; -} - -.markdown-empty { - text-align: center; - color: #94a3b8; - font-style: italic; - padding: 2rem; -} - -/* Headings */ -.markdown-renderer h1 { - color: #1e293b; - font-size: 1.5rem; - margin: 1.5rem 0 0.75rem; - font-weight: 600; - line-height: 1.3; -} - -.markdown-renderer h1:first-child { - margin-top: 0; -} - -.markdown-renderer h2 { - color: #374151; - font-size: 1.375rem; - margin: 1.25rem 0 0.625rem; - font-weight: 600; - line-height: 1.3; -} - -.markdown-renderer h2:first-child { - margin-top: 0; -} - -.markdown-renderer h3 { - color: #475569; - font-size: 1.25rem; - margin: 1.125rem 0 0.5rem; - font-weight: 600; - line-height: 1.3; -} - -.markdown-renderer h3:first-child { - margin-top: 0; -} - -.markdown-renderer h4 { - color: #475569; - font-size: 1.125rem; - margin: 1rem 0 0.5rem; - font-weight: 600; - line-height: 1.3; -} - -.markdown-renderer h5, -.markdown-renderer h6 { - color: #475569; - font-size: 1rem; - margin: 0.875rem 0 0.5rem; - font-weight: 600; - line-height: 1.3; -} - -/* Paragraphs */ -.markdown-renderer p { - margin: 0.75rem 0; - color: #475569; - line-height: 1.7; -} - -.markdown-renderer p:first-child { - margin-top: 0; -} - -.markdown-renderer p:last-child { - margin-bottom: 0; -} - -/* Lists */ -.markdown-renderer ul, -.markdown-renderer ol { - margin: 0.75rem 0; - padding-left: 1.5rem; -} - -.markdown-renderer li { - margin: 0.5rem 0; - line-height: 1.6; - color: #475569; -} - -.markdown-renderer li p { - margin: 0.25rem 0; -} - -/* Text formatting */ -.markdown-renderer strong { - color: #1e293b; - font-weight: 600; -} - -.markdown-renderer em { - font-style: italic; -} - -/* Inline code */ -.markdown-renderer code { - background: #f1f5f9; - padding: 0.2rem 0.5rem; - border-radius: 4px; - font-family: 'Monaco', 'Consolas', 'Courier New', monospace; - font-size: 0.875rem; - color: #e11d48; - border: 1px solid #e2e8f0; -} - -/* Code blocks */ -.markdown-renderer pre { - background: #1e293b; - padding: 1.25rem; - border-radius: 8px; - overflow-x: auto; - margin: 1.25rem 0; - border: 1px solid #334155; -} - -.markdown-renderer pre code { - background: transparent; - padding: 0; - color: #e2e8f0; - border: none; - font-size: 0.8125rem; - line-height: 1.6; -} - -/* Blockquotes */ -.markdown-renderer blockquote { - border-left: 4px solid #3b82f6; - background: #f8fafc; - margin: 1rem 0; - padding: 1rem 1.25rem; - font-style: italic; - border-radius: 0 8px 8px 0; -} - -.markdown-renderer blockquote p { - margin: 0.5rem 0; -} - -/* Tables */ -.markdown-renderer table { - border-collapse: collapse; - width: 100%; - margin: 1rem 0; - border: 1px solid #e2e8f0; - border-radius: 6px; - overflow: hidden; -} - -.markdown-renderer th, -.markdown-renderer td { - border: 1px solid #e2e8f0; - padding: 0.75rem; - text-align: left; -} - -.markdown-renderer th { - background: #f8fafc; - font-weight: 600; - color: #374151; -} - -.markdown-renderer tbody tr:nth-child(even) { - background: #fafbfc; -} - -.markdown-renderer tbody tr:hover { - background: #f1f5f9; - transition: background 0.2s ease; -} - -/* Links */ -.markdown-renderer a { - color: #3b82f6; - text-decoration: none; - font-weight: 500; - border-bottom: 1px solid transparent; - transition: all 0.2s ease; -} - -.markdown-renderer a:hover { - color: #2563eb; - border-bottom-color: #2563eb; -} - -/* Horizontal rules */ -.markdown-renderer hr { - border: none; - border-top: 2px solid #e5e7eb; - margin: 2rem 0; - background: linear-gradient(to right, transparent, #e5e7eb, transparent); -} - -/* Images */ -.markdown-renderer img { - max-width: 100%; - width: auto; - height: auto; - display: block; - margin: 1.25rem auto; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - object-fit: contain; -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - .markdown-renderer { - font-size: 0.875rem; - } - - .markdown-renderer h1 { - font-size: 1.25rem; - } - - .markdown-renderer h2 { - font-size: 1.125rem; - } - - .markdown-renderer h3 { - font-size: 1rem; - } - - .markdown-renderer h4, - .markdown-renderer h5, - .markdown-renderer h6 { - font-size: 0.9375rem; - } - - .markdown-renderer pre { - padding: 0.9375rem; - font-size: 0.75rem; - } - - .markdown-renderer table { - font-size: 0.8125rem; - } - - .markdown-renderer th, - .markdown-renderer td { - padding: 0.5rem; - } - - .markdown-renderer img { - margin: 0.9375rem auto; - border-radius: 6px; - } -} - -/* Print styles */ -@media print { - .markdown-renderer pre { - background: #f5f5f5; - border: 1px solid #ddd; - } - - .markdown-renderer pre code { - color: #000; - } - - .markdown-renderer a::after { - content: " (" attr(href) ")"; - font-size: 0.85em; - color: #666; - } -} diff --git a/frontend/src/components/MarkdownRenderer.jsx b/frontend/src/components/MarkdownRenderer.jsx index 764cc56..730540e 100644 --- a/frontend/src/components/MarkdownRenderer.jsx +++ b/frontend/src/components/MarkdownRenderer.jsx @@ -2,26 +2,52 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; -import rehypeSanitize from 'rehype-sanitize'; -import './MarkdownRenderer.css'; +import { Typography, Empty } from 'antd'; -/** - * 统一的Markdown渲染组件 - * - * @param {string} content - Markdown内容 - * @param {string} className - 自定义CSS类名(可选) - * @param {string} emptyMessage - 内容为空时显示的消息(可选) - */ -const MarkdownRenderer = ({ content, className = '', emptyMessage = '暂无内容' }) => { - if (!content || content.trim() === '') { - return
{emptyMessage}
; +const { Paragraph } = Typography; + +const MarkdownRenderer = ({ content, className = "", emptyMessage = "暂无内容" }) => { + if (!content) { + return ; } return ( -
+
, + h2: ({node, ...props}) => , + h3: ({node, ...props}) => , + h4: ({node, ...props}) => , + p: ({node, ...props}) => , + blockquote: ({node, ...props}) => ( +
+ ), + li: ({node, ...props}) =>
  • , + ul: ({node, ...props}) =>
      , + ol: ({node, ...props}) =>
        , + hr: ({node, ...props}) =>
        , + strong: ({node, ...props}) => , + table: ({node, ...props}) => , + th: ({node, ...props}) =>
        , + td: ({node, ...props}) => , + code: ({node, inline, className, ...props}) => { + if (inline) { + return ; + } + return ( +
        +                
        +              
        + ); + }, + }} > {content} diff --git a/frontend/src/components/MeetingFormDrawer.jsx b/frontend/src/components/MeetingFormDrawer.jsx new file mode 100644 index 0000000..632fd92 --- /dev/null +++ b/frontend/src/components/MeetingFormDrawer.jsx @@ -0,0 +1,150 @@ +import React, { useState, useEffect } from 'react'; +import { Drawer, Form, Input, Button, DatePicker, Select, Space, App } from 'antd'; +import { SaveOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import apiClient from '../utils/apiClient'; +import { buildApiUrl, API_ENDPOINTS } from '../config/api'; + +const { TextArea } = Input; + +const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user }) => { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [fetching, setFetching] = useState(false); + const [users, setUsers] = useState([]); + const [prompts, setPrompts] = useState([]); + + const isEdit = Boolean(meetingId); + + useEffect(() => { + if (!open) return; + fetchOptions(); + if (isEdit) { + fetchMeeting(); + } else { + form.resetFields(); + form.setFieldsValue({ meeting_time: dayjs() }); + } + }, [open, meetingId]); + + const fetchOptions = async () => { + try { + const [uRes, pRes] = await Promise.all([ + apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)), + apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))), + ]); + setUsers(uRes.data.users || []); + setPrompts(pRes.data.prompts || []); + } catch {} + }; + + const fetchMeeting = async () => { + setFetching(true); + try { + const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId))); + const meeting = res.data; + form.setFieldsValue({ + title: meeting.title, + meeting_time: dayjs(meeting.meeting_time), + attendees: meeting.attendees?.map((a) => (typeof a === 'string' ? a : a.caption)) || [], + prompt_id: meeting.prompt_id, + tags: meeting.tags?.map((t) => t.name) || [], + description: meeting.description, + }); + } catch { + message.error('加载会议数据失败'); + } finally { + setFetching(false); + } + }; + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + setLoading(true); + const payload = { + ...values, + meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'), + attendees: values.attendees?.join(',') || '', + tags: values.tags?.join(',') || '', + }; + + if (isEdit) { + await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meetingId)), payload); + message.success('会议更新成功'); + } else { + payload.creator_id = user.user_id; + const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload); + if (res.code === '200') { + message.success('会议创建成功'); + onSuccess?.(res.data.meeting_id); + onClose(); + return; + } + } + onSuccess?.(); + onClose(); + } catch (error) { + if (!error?.errorFields) { + message.error(error?.response?.data?.message || '操作失败'); + } + } finally { + setLoading(false); + } + }; + + return ( + + + + } + > +
        + + + + + + + + + + + + + + + + + +