diff --git a/backend/app/api/endpoints/admin_dashboard.py b/backend/app/api/endpoints/admin_dashboard.py index f0f62e2..2317619 100644 --- a/backend/app/api/endpoints/admin_dashboard.py +++ b/backend/app/api/endpoints/admin_dashboard.py @@ -15,7 +15,7 @@ router = APIRouter() redis_client = redis.Redis(**REDIS_CONFIG) # 常量定义 -AUDIO_FILE_EXTENSIONS = ('.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg') +AUDIO_FILE_EXTENSIONS = ('.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.mpeg', '.mp4', '.webm') BYTES_TO_GB = 1024 ** 3 @@ -79,7 +79,8 @@ def _calculate_audio_storage() -> Dict[str, float]: if os.path.exists(AUDIO_DIR): for root, _, files in os.walk(AUDIO_DIR): for file in files: - if file.endswith(AUDIO_FILE_EXTENSIONS): + file_extension = os.path.splitext(file)[1].lower() + if file_extension in AUDIO_FILE_EXTENSIONS: audio_files_count += 1 file_path = os.path.join(root, file) try: @@ -90,6 +91,7 @@ def _calculate_audio_storage() -> Dict[str, float]: print(f"统计音频文件失败: {e}") return { + "audio_file_count": audio_files_count, "audio_files_count": audio_files_count, "audio_total_size_gb": round(audio_total_size / BYTES_TO_GB, 2) } diff --git a/backend/app/api/endpoints/admin_settings.py b/backend/app/api/endpoints/admin_settings.py index ac1548f..03a9ee4 100644 --- a/backend/app/api/endpoints/admin_settings.py +++ b/backend/app/api/endpoints/admin_settings.py @@ -357,6 +357,7 @@ async def create_parameter(request: ParameterUpsertRequest, current_user=Depends ), ) conn.commit() + SystemConfigService.invalidate_cache() return create_api_response(code="200", message="创建参数成功") except Exception as e: return create_api_response(code="500", message=f"创建参数失败: {str(e)}") @@ -401,6 +402,7 @@ async def update_parameter( ), ) conn.commit() + SystemConfigService.invalidate_cache() return create_api_response(code="200", message="更新参数成功") except Exception as e: return create_api_response(code="500", message=f"更新参数失败: {str(e)}") @@ -418,6 +420,7 @@ async def delete_parameter(param_key: str, current_user=Depends(get_current_admi cursor.execute("DELETE FROM sys_system_parameters WHERE param_key = %s", (param_key,)) conn.commit() + SystemConfigService.invalidate_cache() return create_api_response(code="200", message="删除参数成功") except Exception as e: return create_api_response(code="500", message=f"删除参数失败: {str(e)}") @@ -807,7 +810,7 @@ async def get_public_system_config(): return create_api_response( code="200", message="获取公开配置成功", - data=SystemConfigService.get_branding_config() + data=SystemConfigService.get_public_configs() ) except Exception as e: return create_api_response(code="500", message=f"获取公开配置失败: {str(e)}") diff --git a/backend/app/api/endpoints/meetings.py b/backend/app/api/endpoints/meetings.py index edf6ee4..7c80b3e 100644 --- a/backend/app/api/endpoints/meetings.py +++ b/backend/app/api/endpoints/meetings.py @@ -304,7 +304,7 @@ def get_meetings( ): # 使用配置的默认页面大小 if page_size is None: - page_size = SystemConfigService.get_timeline_pagesize(default=10) + page_size = SystemConfigService.get_page_size(default=10) with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) diff --git a/backend/app/services/system_config_service.py b/backend/app/services/system_config_service.py index a876120..a951596 100644 --- a/backend/app/services/system_config_service.py +++ b/backend/app/services/system_config_service.py @@ -1,4 +1,6 @@ import json +import time +from threading import RLock from typing import Optional, Dict, Any from app.core.database import get_db_connection @@ -7,18 +9,18 @@ class SystemConfigService: """系统配置服务 - 优先从新配置表读取,兼容 dict_data(system_config) 回退""" DICT_TYPE = 'system_config' + PUBLIC_CATEGORY = 'public' DEFAULT_LLM_ENDPOINT_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1' + CACHE_TTL_SECONDS = 60 # 配置键常量 ASR_VOCABULARY_ID = 'asr_vocabulary_id' - TIMELINE_PAGESIZE = 'timeline_pagesize' + PAGE_SIZE = 'page_size' DEFAULT_RESET_PASSWORD = 'default_reset_password' MAX_AUDIO_SIZE = 'max_audio_size' # 品牌配置 - BRANDING_APP_NAME = 'branding_app_name' - BRANDING_HOME_HEADLINE = 'branding_home_headline' - BRANDING_HOME_TAGLINE = 'branding_home_tagline' + APP_NAME = 'app_name' BRANDING_CONSOLE_SUBTITLE = 'branding_console_subtitle' BRANDING_PREVIEW_TITLE = 'branding_preview_title' BRANDING_LOGIN_WELCOME = 'branding_login_welcome' @@ -36,6 +38,38 @@ class SystemConfigService: LLM_TIMEOUT = 'llm_timeout' LLM_TEMPERATURE = 'llm_temperature' LLM_TOP_P = 'llm_top_p' + _cache_lock = RLock() + _config_cache: Dict[str, tuple[float, Any]] = {} + _category_cache: Dict[str, tuple[float, Dict[str, Any]]] = {} + _all_configs_cache: tuple[float, Dict[str, Any]] | None = None + + @classmethod + def _is_cache_valid(cls, cached_at: float) -> bool: + return (time.time() - cached_at) < cls.CACHE_TTL_SECONDS + + @classmethod + def _get_cached_config(cls, cache_key: str) -> Any: + with cls._cache_lock: + cached = cls._config_cache.get(cache_key) + if not cached: + return None + cached_at, value = cached + if not cls._is_cache_valid(cached_at): + cls._config_cache.pop(cache_key, None) + return None + return value + + @classmethod + def _set_cached_config(cls, cache_key: str, value: Any) -> None: + with cls._cache_lock: + cls._config_cache[cache_key] = (time.time(), value) + + @classmethod + def invalidate_cache(cls) -> None: + with cls._cache_lock: + cls._config_cache.clear() + cls._category_cache.clear() + cls._all_configs_cache = None @staticmethod def _parse_json_object(value: Any) -> Dict[str, Any]: @@ -262,9 +296,14 @@ class SystemConfigService: Returns: 配置项的值 """ + cached_value = cls._get_cached_config(dict_code) + if cached_value is not None: + return cached_value + # 1) 新参数表 value = cls._get_parameter_value(dict_code) if value is not None: + cls._set_cached_config(dict_code, value) return value # 2) 兼容旧 sys_dict_data @@ -284,10 +323,13 @@ class SystemConfigService: if result and result['extension_attr']: try: ext_attr = json.loads(result['extension_attr']) if isinstance(result['extension_attr'], str) else result['extension_attr'] - return ext_attr.get('value', default_value) + resolved_value = ext_attr.get('value', default_value) + cls._set_cached_config(dict_code, resolved_value) + return resolved_value except (json.JSONDecodeError, AttributeError): pass + cls._set_cached_config(dict_code, default_value) return default_value except Exception as e: @@ -410,6 +452,7 @@ class SystemConfigService: ) conn.commit() cursor.close() + cls.invalidate_cache() return True except Exception as e: print(f"Error setting config in sys_system_parameters {dict_code}: {e}") @@ -451,6 +494,7 @@ class SystemConfigService: conn.commit() cursor.close() + cls.invalidate_cache() return True except Exception as e: @@ -465,6 +509,12 @@ class SystemConfigService: Returns: 配置字典 {dict_code: value} """ + with cls._cache_lock: + if cls._all_configs_cache and cls._is_cache_valid(cls._all_configs_cache[0]): + return dict(cls._all_configs_cache[1]) + if cls._all_configs_cache and not cls._is_cache_valid(cls._all_configs_cache[0]): + cls._all_configs_cache = None + # 1) 新参数表 try: with get_db_connection() as conn: @@ -480,7 +530,10 @@ class SystemConfigService: rows = cursor.fetchall() cursor.close() if rows: - return {row["param_key"]: row["param_value"] for row in rows} + configs = {row["param_key"]: row["param_value"] for row in rows} + with cls._cache_lock: + cls._all_configs_cache = (time.time(), configs) + return dict(configs) except Exception as e: print(f"Error getting all configs from sys_system_parameters: {e}") @@ -509,12 +562,46 @@ class SystemConfigService: else: configs[row['dict_code']] = None - return configs + with cls._cache_lock: + cls._all_configs_cache = (time.time(), configs) + return dict(configs) except Exception as e: print(f"Error getting all configs: {e}") return {} + @classmethod + def get_configs_by_category(cls, category: str) -> Dict[str, Any]: + """按分类获取启用中的参数配置。""" + with cls._cache_lock: + cached = cls._category_cache.get(category) + if cached and cls._is_cache_valid(cached[0]): + return dict(cached[1]) + if cached and not cls._is_cache_valid(cached[0]): + cls._category_cache.pop(category, None) + + 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 AND category = %s + ORDER BY param_key + """, + (category,), + ) + rows = cursor.fetchall() + cursor.close() + configs = {row["param_key"]: row["param_value"] for row in rows} if rows else {} + with cls._cache_lock: + cls._category_cache[category] = (time.time(), configs) + return dict(configs) + except Exception as e: + print(f"Error getting configs by category {category}: {e}") + return {} + @classmethod def batch_set_configs(cls, configs: Dict[str, Any]) -> bool: """ @@ -590,9 +677,9 @@ class SystemConfigService: return default @classmethod - def get_timeline_pagesize(cls, default: int = 10) -> int: - """获取会议时间轴每页数量""" - value = cls.get_config(cls.TIMELINE_PAGESIZE, str(default)) + def get_page_size(cls, default: int = 10) -> int: + """获取系统通用分页数量。""" + value = cls.get_config(cls.PAGE_SIZE, str(default)) try: return int(value) except (ValueError, TypeError): @@ -613,22 +700,59 @@ class SystemConfigService: return default @classmethod - def get_branding_config(cls) -> Dict[str, str]: - max_audio_size_mb = cls.get_max_audio_size(100) - max_image_size_mb = cls.get_config("max_image_size", "10") + def get_public_configs(cls) -> Dict[str, Any]: + """获取提供给前端初始化使用的公开参数。""" + public_configs = cls.get_configs_by_category(cls.PUBLIC_CATEGORY) + + app_name = str(public_configs.get(cls.APP_NAME) or "智听云平台") + console_subtitle = str( + public_configs.get("console_subtitle") + or public_configs.get(cls.BRANDING_CONSOLE_SUBTITLE) + or "iMeeting控制台" + ) + preview_title = str( + public_configs.get("preview_title") + or public_configs.get(cls.BRANDING_PREVIEW_TITLE) + or "会议预览" + ) + login_welcome = str( + public_configs.get("login_welcome") + or public_configs.get(cls.BRANDING_LOGIN_WELCOME) + or "欢迎回来,请输入您的登录凭证。" + ) + footer_text = str( + public_configs.get("footer_text") + or public_configs.get(cls.BRANDING_FOOTER_TEXT) + or "©2026 智听云平台" + ) + + raw_page_size = public_configs.get(cls.PAGE_SIZE, "10") try: - max_image_size_mb = int(max_image_size_mb) + page_size = int(raw_page_size) + except (ValueError, TypeError): + page_size = 10 + + raw_max_audio_size = public_configs.get(cls.MAX_AUDIO_SIZE, "100") + try: + max_audio_size_mb = int(raw_max_audio_size) + except (ValueError, TypeError): + max_audio_size_mb = 100 + + raw_max_image_size = public_configs.get("max_image_size", "10") + try: + max_image_size_mb = int(raw_max_image_size) except (ValueError, TypeError): max_image_size_mb = 10 return { - "app_name": str(cls.get_config(cls.BRANDING_APP_NAME, "智听云平台") or "智听云平台"), - "home_headline": str(cls.get_config(cls.BRANDING_HOME_HEADLINE, "智听云平台") or "智听云平台"), - "home_tagline": str(cls.get_config(cls.BRANDING_HOME_TAGLINE, "让每一次谈话都产生价值") or "让每一次谈话都产生价值"), - "console_subtitle": str(cls.get_config(cls.BRANDING_CONSOLE_SUBTITLE, "iMeeting控制台") or "iMeeting控制台"), - "preview_title": str(cls.get_config(cls.BRANDING_PREVIEW_TITLE, "会议预览") or "会议预览"), - "login_welcome": str(cls.get_config(cls.BRANDING_LOGIN_WELCOME, "欢迎回来,请输入您的登录凭证。") or "欢迎回来,请输入您的登录凭证。"), - "footer_text": str(cls.get_config(cls.BRANDING_FOOTER_TEXT, "©2026 智听云平台") or "©2026 智听云平台"), + **public_configs, + "app_name": app_name, + "console_subtitle": console_subtitle, + "preview_title": preview_title, + "login_welcome": login_welcome, + "footer_text": footer_text, + "page_size": str(page_size), + "PAGE_SIZE": page_size, "max_audio_size": str(max_audio_size_mb), "MAX_FILE_SIZE": max_audio_size_mb * 1024 * 1024, "max_image_size": str(max_image_size_mb), diff --git a/backend/sql/imeeting-init.sql b/backend/sql/imeeting-init.sql index 44021f8..813b1d6 100644 --- a/backend/sql/imeeting-init.sql +++ b/backend/sql/imeeting-init.sql @@ -346,7 +346,7 @@ INSERT INTO `dict_data` (`id`, `dict_type`, `dict_code`, `parent_code`, `tree_pa INSERT INTO `dict_data` (`id`, `dict_type`, `dict_code`, `parent_code`, `tree_path`, `label_cn`, `label_en`, `sort_order`, `extension_attr`, `is_default`, `status`, `update_time`, `create_time`) VALUES (22, 'system_config', 'asr_vocabulary_id', 'ROOT', NULL, '热词字典ID', NULL, 0, '{\"value\": \"vocab-imeeting-734e93f5bd8a4f3bb665dd526d584516\"}', 0, 1, '2026-01-19 20:25:13', '2026-01-05 14:13:36'); INSERT INTO `dict_data` (`id`, `dict_type`, `dict_code`, `parent_code`, `tree_path`, `label_cn`, `label_en`, `sort_order`, `extension_attr`, `is_default`, `status`, `update_time`, `create_time`) VALUES (23, 'system_config', 'default_reset_password', 'ROOT', NULL, '默认重置密码', NULL, 2, '{\"value\": \"123456\"}', 0, 1, '2026-01-06 07:04:51', '2026-01-06 02:54:02'); INSERT INTO `dict_data` (`id`, `dict_type`, `dict_code`, `parent_code`, `tree_path`, `label_cn`, `label_en`, `sort_order`, `extension_attr`, `is_default`, `status`, `update_time`, `create_time`) VALUES (24, 'system_config', 'max_audio_size', 'ROOT', NULL, '上传音频文件大小', NULL, 3, '{\"value\": \"100\"}', 0, 1, '2026-01-06 07:55:57', '2026-01-06 02:56:06'); -INSERT INTO `dict_data` (`id`, `dict_type`, `dict_code`, `parent_code`, `tree_path`, `label_cn`, `label_en`, `sort_order`, `extension_attr`, `is_default`, `status`, `update_time`, `create_time`) VALUES (25, 'system_config', 'timeline_pagesize', 'ROOT', NULL, '会议时间轴每页数量', NULL, 1, '{\"value\": \"10\"}', 0, 1, '2026-01-06 07:04:51', '2026-01-06 02:57:35'); +INSERT INTO `dict_data` (`id`, `dict_type`, `dict_code`, `parent_code`, `tree_path`, `label_cn`, `label_en`, `sort_order`, `extension_attr`, `is_default`, `status`, `update_time`, `create_time`) VALUES (25, 'system_config', 'page_size', 'ROOT', NULL, '通用分页数量', NULL, 1, '{\"value\": \"10\"}', 0, 1, '2026-01-06 07:04:51', '2026-01-06 02:57:35'); INSERT INTO `dict_data` (`id`, `dict_type`, `dict_code`, `parent_code`, `tree_path`, `label_cn`, `label_en`, `sort_order`, `extension_attr`, `is_default`, `status`, `update_time`, `create_time`) VALUES (26, 'system_config', 'voiceprint', 'ROOT', NULL, '声纹采集配置', NULL, 0, '{\"channels\": 1, \"sample_rate\": 16000, \"template_text\": \"我正在进行声纹采集,这段语音将用于身份识别和验证。\\n\\n声纹技术能够准确识别每个人独特的声音特征。\", \"duration_seconds\": 12, \"voiceprint_max_size\": 5242880}', 0, 1, NULL, '2026-01-06 03:46:22'); INSERT INTO `dict_data` (`id`, `dict_type`, `dict_code`, `parent_code`, `tree_path`, `label_cn`, `label_en`, `sort_order`, `extension_attr`, `is_default`, `status`, `update_time`, `create_time`) VALUES (29, 'system_config', 'llm_model', 'ROOT', NULL, '文本大模型', 'llm_model', 6, '{\"top_p\": \"0.9\", \"time_out\": \"120\", \"model_name\": \"qwen-plus\", \"temperature\": \"0.7\", \"system_prompt\": \"You are a professional meeting transcript analysis assistant. Please generate a clear meeting summary based on the provided meeting transcript.\"}', 0, 1, NULL, '2026-01-06 10:23:34'); INSERT INTO `dict_data` (`id`, `dict_type`, `dict_code`, `parent_code`, `tree_path`, `label_cn`, `label_en`, `sort_order`, `extension_attr`, `is_default`, `status`, `update_time`, `create_time`) VALUES (30, 'external_apps', 'NATIVE', 'ROOT', NULL, '原生应用', 'Native App', 1, '{\"suffix\": \"apk\"}', 0, 1, NULL, '2026-01-14 16:54:25'); diff --git a/backend/sql/migrations/create_parameter_and_model_management.sql b/backend/sql/migrations/create_parameter_and_model_management.sql index 30d79d2..fabe236 100644 --- a/backend/sql/migrations/create_parameter_and_model_management.sql +++ b/backend/sql/migrations/create_parameter_and_model_management.sql @@ -41,11 +41,37 @@ CREATE TABLE IF NOT EXISTS `ai_model_configs` ( -- 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`, + CASE + WHEN d.`dict_code` = 'timeline_pagesize' THEN 'page_size' + WHEN d.`dict_code` = 'branding_app_name' THEN 'app_name' + WHEN d.`dict_code` = 'branding_console_subtitle' THEN 'console_subtitle' + WHEN d.`dict_code` = 'branding_preview_title' THEN 'preview_title' + WHEN d.`dict_code` = 'branding_login_welcome' THEN 'login_welcome' + WHEN d.`dict_code` = 'branding_footer_text' THEN 'footer_text' + ELSE d.`dict_code` + END, d.`label_cn`, JSON_UNQUOTE(JSON_EXTRACT(d.`extension_attr`, '$.value')), 'string', - 'system', + CASE + WHEN d.`dict_code` IN ( + 'timeline_pagesize', + 'page_size', + 'branding_app_name', + 'app_name', + 'branding_console_subtitle', + 'console_subtitle', + 'branding_preview_title', + 'preview_title', + 'branding_login_welcome', + 'login_welcome', + 'branding_footer_text', + 'footer_text', + 'max_audio_size', + 'max_image_size' + ) THEN 'public' + ELSE 'system' + END, 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 @@ -55,6 +81,7 @@ WHERE d.`dict_type` = 'system_config' ON DUPLICATE KEY UPDATE `param_name` = VALUES(`param_name`), `param_value` = VALUES(`param_value`), + `category` = VALUES(`category`), `is_active` = VALUES(`is_active`); -- migrate llm model diff --git a/backend/sql/migrations/upgrade_imeeting_qy_to_latest.sql b/backend/sql/migrations/upgrade_imeeting_qy_to_latest.sql index cf63546..5732b71 100644 --- a/backend/sql/migrations/upgrade_imeeting_qy_to_latest.sql +++ b/backend/sql/migrations/upgrade_imeeting_qy_to_latest.sql @@ -534,11 +534,37 @@ CREATE TABLE IF NOT EXISTS `sys_user_mcp` ( INSERT INTO `sys_system_parameters` (`param_key`, `param_name`, `param_value`, `value_type`, `category`, `description`, `is_active`) SELECT - d.`dict_code`, + CASE + WHEN d.`dict_code` = 'timeline_pagesize' THEN 'page_size' + WHEN d.`dict_code` = 'branding_app_name' THEN 'app_name' + WHEN d.`dict_code` = 'branding_console_subtitle' THEN 'console_subtitle' + WHEN d.`dict_code` = 'branding_preview_title' THEN 'preview_title' + WHEN d.`dict_code` = 'branding_login_welcome' THEN 'login_welcome' + WHEN d.`dict_code` = 'branding_footer_text' THEN 'footer_text' + ELSE d.`dict_code` + END, d.`label_cn`, JSON_UNQUOTE(JSON_EXTRACT(d.`extension_attr`, '$.value')), 'string', - 'system', + CASE + WHEN d.`dict_code` IN ( + 'timeline_pagesize', + 'page_size', + 'branding_app_name', + 'app_name', + 'branding_console_subtitle', + 'console_subtitle', + 'branding_preview_title', + 'preview_title', + 'branding_login_welcome', + 'login_welcome', + 'branding_footer_text', + 'footer_text', + 'max_audio_size', + 'max_image_size' + ) THEN 'public' + ELSE 'system' + END, 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 @@ -548,6 +574,7 @@ WHERE d.`dict_type` = 'system_config' ON DUPLICATE KEY UPDATE `param_name` = VALUES(`param_name`), `param_value` = VALUES(`param_value`), + `category` = VALUES(`category`), `description` = VALUES(`description`), `is_active` = VALUES(`is_active`); diff --git a/design/code-structure-standards.md b/design/code-structure-standards.md new file mode 100644 index 0000000..1b563fd --- /dev/null +++ b/design/code-structure-standards.md @@ -0,0 +1,499 @@ +# 通用代码结构设计规范(强制执行) + +本文档定义项目在长期演进中应遵守的结构边界、拆分原则与审计标准。 + +目标不是机械追求“小文件”“多目录”或“某种固定架构”,而是让任意语言、任意框架、任意部署形态下的代码都满足以下要求: + +- 入口清晰 +- 依赖方向稳定 +- 职责边界明确 +- 修改影响面可控 +- 新人可顺序读懂 + +本文档适用于: + +- 前端应用 +- 后端服务 +- CLI / 脚本 / Worker +- SDK / Library +- 单仓或多仓项目 + +本文档自落地起作为后续开发与重构的默认结构基线。 + +--- + +## 1. 核心原则 + +### 1.1 先划清职责,再决定目录 + +- 先区分“入口层 / 业务编排层 / 领域规则层 / 基础设施层 / 共享基础层”,再决定是否拆目录、拆文件、拆包。 +- 目录结构是职责设计的结果,不是先验答案。 +- 同一个团队可以采用不同目录形态,但不能模糊职责边界。 + +### 1.2 领域内聚优先于机械拆分 + +- 第一判断标准是“是否仍然属于同一业务主题”,不是“文件还能不能再拆小”。 +- 同一主题内的读取、写入、校验、少量派生逻辑,可以保留在同一模块中。 +- 如果拆分只会制造更多跳转、隐藏真实依赖、降低顺序可读性,就不应继续拆。 + +### 1.3 装配层必须薄 + +- 启动入口、路由入口、页面入口、命令入口都只负责装配。 +- 装配层可以做依赖注入、参数收集、状态接线、组件拼装、调用编排入口。 +- 装配层不应承载复杂业务规则、数据库细节、文件系统细节、网络细节或长流程状态机。 + +### 1.4 副作用必须收口 + +- 数据库访问、文件读写、网络调用、缓存、定时器、浏览器存储、进程环境依赖等,都属于副作用。 +- 副作用应集中在可识别的边界模块中,不应在页面、视图、路由、DTO、纯工具里四处散落。 +- 任何需要 mock、替换、复用或测试隔离的外部依赖,都应有明确归属。 + +### 1.5 依赖方向必须单向 + +- 默认依赖方向应从外向内:入口层 -> 业务编排层 -> 领域规则层 -> 基础设施实现。 +- 共享基础层可以被多个层使用,但不能反向依赖业务实现。 +- 低层不能反向引用高层具体实现来“图省事”。 + +### 1.6 文件大小不是目标,跨职责才是风险 + +- 行数只作为预警信号,不作为强制拆分指标。 +- 真正需要拆分的信号包括: + - 一个模块服务多个业务主题 + - 一个模块同时承担输入解析、业务决策、数据访问和展示 + - 一个改动常常需要在同一文件中切换多种关注点 + - 一个模块需要为不同调用方维持多套语义 + +### 1.7 重构优先低风险搬运 + +- 结构重构优先做“职责收口、边界清理、命名校正、依赖下沉/上提”。 +- 默认不要在同一轮改动里同时进行: + - 大规模结构调整 + - 新功能开发 + - 行为修复 +- 如果确需并行,必须以最小范围控制风险,并显式验证关键路径。 + +### 1.8 命名必须体现主题 + +- 文件、目录、模块、类型、服务名都应直接表达责任。 +- 禁止使用模糊命名掩盖职责,例如: + - `misc` + - `helpers2` + - `commonThing` + - `temp_service` + - `manager_new` + +--- + +## 2. 通用分层模型 + +以下是跨语言可复用的职责模型。项目不要求逐字使用这些目录名,但必须能映射到这些边界。 + +### 2.1 入口层 / 接口层 + +典型形态: + +- 前端的 `App`、路由入口、页面入口 +- 后端的 router / controller / handler +- CLI 的 command / main +- Worker 的 job handler / consumer entry + +职责: + +- 接收输入 +- 做基础参数解析与协议适配 +- 调用业务编排层 +- 返回结果或渲染输出 + +禁止: + +- 写复杂业务规则 +- 直接拼装 SQL / ORM 流程 +- 直接进行大段文件系统读写 +- 直接进行复杂网络编排 +- 在入口层内维护长生命周期状态机 + +### 2.2 业务编排层 / 用例层 + +典型形态: + +- service +- use case +- action +- controller hook +- page model +- workflow + +职责: + +- 表达一个明确业务流程 +- 协调多个依赖 +- 承载事务边界、步骤顺序、状态推进 +- 组织权限判断、前置校验、错误分支 + +要求: + +- 一个模块只负责一个业务域或一个稳定子流程 +- 可以依赖基础设施接口,但不应把具体协议细节暴露给上层 +- 可以包含少量私有 helper,但 helper 仅服务当前主题 + +### 2.3 领域规则层 + +典型形态: + +- domain service +- policy +- rule +- validator +- entity behavior +- pure business helpers + +职责: + +- 承载稳定的业务规则和领域语义 +- 保持尽量纯净、可测试、与外部协议解耦 +- 统一业务概念、状态转换、派生计算 + +要求: + +- 不直接访问数据库、网络、文件、浏览器环境 +- 不依赖 UI、HTTP、CLI、消息队列等入口协议 + +### 2.4 基础设施层 / 数据访问层 + +典型形态: + +- repository +- gateway +- API client +- storage adapter +- cache adapter +- filesystem adapter +- persistence implementation + +职责: + +- 封装外部系统细节 +- 处理数据库、缓存、HTTP、对象存储、消息队列、浏览器存储、操作系统能力 +- 提供可复用的边界接口 + +要求: + +- 只解决“怎么接外部系统”,不承担业务决策 +- 协议转换、序列化、连接管理、重试策略等应在此层收口 + +### 2.5 共享基础层 + +典型形态: + +- constants +- shared types +- date / string / number helpers +- 通用 UI 基础组件 +- 通用错误定义 + +要求: + +- 必须是真正跨域、稳定、低语义耦合的内容 +- 不允许把业务逻辑伪装成“common / shared / utils” +- 一旦某模块开始依赖特定业务名词,它就不再是共享基础层 + +--- + +## 3. 目录与模块组织规范 + +### 3.1 允许的组织方式 + +项目可以采用以下任一方式: + +- 按领域优先组织:`/` +- 按层优先组织:`entry/ application/ domain/ infra/` +- 混合组织:顶层按领域,领域内再分层 +- Monorepo 组织:`apps/ packages/ services/ workers/` + +允许多种组织方式并存,但必须满足: + +- 同一仓库内的同类代码遵循一致的判断逻辑 +- 每个模块都能被映射到明确职责层 +- 依赖方向清晰、稳定、可审计 + +### 3.2 推荐的判断方式 + +当你不确定某段代码应该放哪里时,按顺序判断: + +1. 它是在接收输入、渲染输出、还是拼装启动吗 +2. 它是在表达一个完整业务流程吗 +3. 它是在表达不依赖外部协议的业务规则吗 +4. 它是在接数据库、文件、网络、缓存、浏览器或系统能力吗 +5. 它真的是跨域共享能力吗 + +### 3.3 一个模块只能有一个主语义 + +- 一个模块可以有多个函数,但只能服务一个主职责。 +- 如果一个文件既是“页面”又是“API 聚合器”又是“缓存控制器”又是“视图组件”,就已经越界。 +- 如果一个 router 文件开始长时间停留在 SQL、文件读写、事务与状态轮询细节上,也已经越界。 + +### 3.4 兼容层与过渡层 + +- 允许存在短期兼容层、导出层、适配层。 +- 兼容层必须被明确标记为过渡用途。 +- 禁止长期把新逻辑继续堆回兼容层。 + +--- + +## 4. 前端通用规范 + +本节适用于 Web、桌面端、移动端和前端壳应用,不绑定 React / Vue / Svelte 等具体框架。 + +### 4.1 页面/路由入口必须薄 + +- 页面文件默认负责页面装配、布局组织、边界兜底。 +- 页面可持有少量与页面展示强绑定的状态。 +- 当页面开始同时承担以下两项及以上时,应拆出页面级编排模块: + - 多个接口请求 + - 轮询或定时器 + - 上传/下载流程 + - 多个 Drawer / Modal / Sheet 子流程 + - 复杂权限判断 + - 大量数据清洗与派生 + +### 4.2 视图组件默认无副作用 + +- 纯视图组件只接收整理好的 props。 +- 纯视图组件默认不直接请求接口、不直接碰浏览器存储、不直接起轮询。 +- 如果一个组件必须自带数据流程,它应被明确视为“功能组件”或“场景组件”,而不是伪装成通用组件。 + +### 4.3 页面级业务流程应集中编排 + +- 页面相关的请求、轮询、草稿保存、上传进度、权限行为、状态联动,应尽量集中在页面编排层。 +- 页面编排层可以是 hook、store、controller、presenter 或 view-model,不限定技术名词。 +- 不要求为了形式而一律抽 hook;只有在页面入口已经承担过多流程时才拆。 + +### 4.4 前端基础设施应收口 + +- API client +- 本地存储 +- Session / token 持久化 +- 浏览器标题、副作用事件、定时器策略 +- 配置读取 + +以上能力应收口在可识别模块中,不应被页面随机复制。 + +### 4.5 复用原则 + +- 提炼稳定复用模式,不提炼偶然重复。 +- 三处以上重复,优先评估抽取。 +- 如果抽取后的接口比原地代码更难理解,不应抽取。 +- 不允许制造“只有一个页面使用、但包装层很多”的伪复用。 + +--- + +## 5. 后端通用规范 + +本节适用于 HTTP 服务、RPC 服务、任务处理器和后台作业,不绑定 FastAPI / Spring / NestJS / Gin 等框架。 + +### 5.1 启动入口必须只做装配 + +- `main` +- app factory +- bootstrap +- container + +这些入口只负责: + +- 创建应用实例 +- 注册路由/处理器 +- 初始化中间件 +- 装配依赖 +- 生命周期绑定 + +不应承担: + +- 业务规则 +- SQL 或 ORM 编排 +- 文件系统细节 +- 长流程任务控制 + +### 5.2 Router / Controller / Handler 只做协议转换 + +允许: + +- 接收请求参数 +- 基础校验 +- 调用用例层 +- 将领域错误映射为接口错误 + +不允许: + +- 在 handler 内直接堆大量 SQL +- 一边处理权限,一边处理事务,一边操作文件,一边组装响应模型 +- 在 handler 内实现长流程状态机 + +### 5.3 Service / Use Case 以业务域组织 + +- 一个 service 文件只负责一个业务域或一个稳定子主题。 +- 同域内的查询、写入、校验、少量派生逻辑可以在一起。 +- 如果一个 service 同时承担多个主题,应优先拆主题而不是拆技术动作。 + +### 5.4 数据访问与外部适配要收口 + +- 数据库查询 +- ORM 组装 +- 缓存细节 +- 第三方 HTTP 调用 +- 文件上传下载 +- 对象存储 +- 队列/任务系统 + +这些细节应尽量沉到 repository / gateway / adapter / infra 中。 + +### 5.5 Schema / DTO / Contract 必须纯净 + +- DTO 只用于定义契约,不应携带数据库、文件系统、网络调用或业务副作用。 +- 契约字段演进必须可追踪。 +- 避免让数据库模型、接口模型、领域模型长期混成一种结构。 + +--- + +## 6. CLI / 脚本 / Worker 规范 + +- 命令入口只负责解析参数、准备依赖、调用用例。 +- 脚本如果会长期保留,必须从“一次性脚本”升级为可读的结构化模块。 +- Worker handler 只负责接收消息、提取 payload、调用业务流程、回写状态。 +- 重试、幂等、死信、超时策略等运行时策略应有单独归属,不应散落在业务逻辑内部。 + +--- + +## 7. 拆分与合并准则 + +### 7.1 何时应拆分 + +满足任一项即可考虑拆分: + +- 同一模块出现多个业务主题 +- 同一模块同时依赖多种外部系统 +- 同一模块同时承担输入解析、业务编排、持久化和展示 +- 多人修改时经常产生冲突 +- 阅读一个改动需要频繁跨越无关上下文 +- 相同规则被复制到多个入口 + +### 7.2 何时不应拆分 + +- 仍是单一主题 +- 代码虽长但顺序可读 +- 继续拆只会制造纯转发层 +- 继续拆会让调用链更深、定位更慢 +- 抽出后接口语义比原代码更含糊 + +### 7.3 合并也是一种优化 + +- 如果多个模块只是在相互转发、没有独立语义,应考虑合并。 +- 如果拆分之后需要同时打开 4 到 6 个文件才能理解一个简单流程,通常已经过度拆分。 + +--- + +## 8. 命名与依赖规则 + +### 8.1 命名规则 + +- 名称应表达业务主题或技术边界,不表达情绪和历史包袱。 +- 优先使用“对象 + 语义”命名,而不是“抽象 + 序号”命名。 +- 避免模糊后缀: + - `handler2` + - `newService` + - `commonUtils` + - `tempPage` + +### 8.2 依赖规则 + +- 上层可以依赖下层抽象,不应依赖下层杂乱细节。 +- 共享层不能反向依赖业务层。 +- 领域模块之间若需协作,应通过明确用例、接口或边界对象完成,而不是互相穿透内部实现。 + +--- + +## 9. 测试与验证要求 + +### 9.1 结构改动后的默认验证 + +- 前端结构改动后,至少执行构建或类型校验。 +- 后端结构改动后,至少执行语法校验、启动校验或最小测试集。 +- 如果改动涉及契约、权限、状态流转、持久化边界,应追加针对性验证。 + +### 9.2 测试优先级 + +- 先保关键业务路径 +- 再保跨层边界 +- 再保复杂状态流转 +- 最后补充纯工具覆盖 + +### 9.3 文档同步要求 + +以下情况必须同步设计文档或架构说明: + +- 新增一层明确职责边界 +- 新增一个稳定领域模块模板 +- 改变入口层、用例层、基础设施层的责任划分 +- 引入新的运行时或新的跨项目复用规范 + +--- + +## 10. 评审与审计清单 + +做结构评审时,默认检查以下问题: + +### 10.1 入口层 + +- 入口是否足够薄 +- 是否混入业务规则 +- 是否混入外部系统细节 + +### 10.2 业务编排层 + +- 是否以业务主题组织 +- 是否承担了过多无关流程 +- 是否存在纯转发服务 + +### 10.3 领域规则层 + +- 是否仍保持纯净 +- 是否被框架、HTTP、数据库协议污染 + +### 10.4 基础设施层 + +- 副作用是否收口 +- 是否把业务规则偷偷塞回 adapter / utils / core + +### 10.5 共享层 + +- 是否真的跨域复用 +- 是否把业务逻辑伪装成 common / shared / utils + +### 10.6 演进风险 + +- 新增功能是否沿着既有边界落位 +- 兼容层是否在持续变厚 +- 是否出现单文件多职责继续膨胀 + +--- + +## 11. 禁止事项 + +- 禁止为了图省事把新逻辑堆回入口层 +- 禁止为了“文件更短”制造无语义的纯包装层 +- 禁止在 `utils` / `common` / `shared` 中隐藏领域逻辑 +- 禁止让页面、路由、handler 直接承载大量持久化或文件系统细节 +- 禁止把 schema / DTO / config 当成业务逻辑容器 +- 禁止一次改动里同时重写结构、协议、UI 和业务行为,且没有明确验证策略 + +--- + +## 12. 执行基线 + +后续所有新增功能、重构与代码审计,均以本文档为默认判断依据: + +- 先判断职责边界是否正确 +- 再判断依赖方向是否健康 +- 再判断是否需要拆分或合并 +- 最后才考虑目录美观、文件长短和风格一致性 + +当“看起来更模块化”和“真实可读、可改、可验证”发生冲突时,优先后者。 diff --git a/frontend/index.html b/frontend/index.html index 596e08c..60ffe48 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - iMeeting - 智能会议助手 + 智听云平台
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8eb1243..ab2a1c9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ import AccountSettings from './pages/AccountSettings'; import MeetingCenterPage from './pages/MeetingCenterPage'; import MainLayout from './components/MainLayout'; import menuService from './services/menuService'; +import configService from './utils/configService'; import './App.css'; import './styles/console-theme.css'; @@ -106,6 +107,20 @@ function App() { setIsLoading(false); }, []); + useEffect(() => { + let active = true; + + configService.getBrandingConfig().then((branding) => { + if (active && branding?.app_name) { + document.title = branding.app_name; + } + }).catch(() => {}); + + return () => { + active = false; + }; + }, []); + const handleLogin = (authData) => { if (authData) { menuService.clearCache(); diff --git a/frontend/src/components/ExpandSearchBox.jsx b/frontend/src/components/ExpandSearchBox.jsx index 718ad4b..ab124f3 100644 --- a/frontend/src/components/ExpandSearchBox.jsx +++ b/frontend/src/components/ExpandSearchBox.jsx @@ -1,6 +1,5 @@ -import React, { useState } from 'react'; +import React from 'react'; import { Input } from 'antd'; -import { SearchOutlined } from '@ant-design/icons'; const ExpandSearchBox = ({ onSearch, placeholder = "搜索会议..." }) => { return ( diff --git a/frontend/src/components/MainLayout.jsx b/frontend/src/components/MainLayout.jsx index 07a1259..63dd609 100644 --- a/frontend/src/components/MainLayout.jsx +++ b/frontend/src/components/MainLayout.jsx @@ -105,7 +105,7 @@ const MainLayout = ({ children, user, onLogout }) => { return roots .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)) .map(toMenuItem); - }, [navigate, onLogout, userMenus]); + }, [navigate, userMenus]); const flatMenuKeys = useMemo(() => { const keys = []; diff --git a/frontend/src/components/MarkdownRenderer.jsx b/frontend/src/components/MarkdownRenderer.jsx index 730540e..4c710a1 100644 --- a/frontend/src/components/MarkdownRenderer.jsx +++ b/frontend/src/components/MarkdownRenderer.jsx @@ -17,27 +17,27 @@ const MarkdownRenderer = ({ content, className = "", emptyMessage = "暂无内 remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} components={{ - h1: ({node, ...props}) => , - h2: ({node, ...props}) => , - h3: ({node, ...props}) => , - h4: ({node, ...props}) => , - p: ({node, ...props}) => , - blockquote: ({node, ...props}) => ( + h1: (props) => , + h2: (props) => , + h3: (props) => , + h4: (props) => , + p: (props) => , + blockquote: (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}) => { + li: (props) =>
      1. , + ul: (props) =>
          , + ol: (props) =>
            , + hr: (props) =>
            , + strong: (props) => , + table: (props) => , + th: (props) =>
            -
            , + td: (props) => , + code: ({ inline, className, ...props }) => { if (inline) { return ; } diff --git a/frontend/src/components/MeetingFormDrawer.jsx b/frontend/src/components/MeetingFormDrawer.jsx index 95f709a..d3718be 100644 --- a/frontend/src/components/MeetingFormDrawer.jsx +++ b/frontend/src/components/MeetingFormDrawer.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Drawer, Form, Input, Button, DatePicker, Select, Space, App, Upload, Card, Progress, Typography } from 'antd'; import { SaveOutlined, UploadOutlined, DeleteOutlined, AudioOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; @@ -13,7 +13,6 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => { 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 [selectedAudioFile, setSelectedAudioFile] = useState(null); @@ -24,6 +23,44 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => { const isEdit = Boolean(meetingId); + const fetchOptions = useCallback(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 { + message.error('加载会议表单选项失败'); + } + }, [message]); + + const loadAudioUploadConfig = useCallback(async () => { + try { + const nextMaxAudioSize = await configService.getMaxAudioSize(); + setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024); + } catch { + setMaxAudioSize(100 * 1024 * 1024); + } + }, []); + + const fetchMeeting = useCallback(async () => { + 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), + attendee_ids: meeting.attendee_ids || meeting.attendees?.map((a) => a.user_id).filter(Boolean) || [], + prompt_id: meeting.prompt_id, + tags: meeting.tags?.map((t) => t.name) || [], + }); + } catch { + message.error('加载会议数据失败'); + } + }, [form, meetingId, message]); + useEffect(() => { if (!open) return; fetchOptions(); @@ -38,46 +75,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => { setAudioUploadProgress(0); setAudioUploadMessage(''); } - }, [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 loadAudioUploadConfig = async () => { - try { - const nextMaxAudioSize = await configService.getMaxAudioSize(); - setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024); - } catch (error) { - setMaxAudioSize(100 * 1024 * 1024); - } - }; - - 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), - attendee_ids: meeting.attendee_ids || meeting.attendees?.map((a) => a.user_id).filter(Boolean) || [], - prompt_id: meeting.prompt_id, - tags: meeting.tags?.map((t) => t.name) || [], - }); - } catch { - message.error('加载会议数据失败'); - } finally { - setFetching(false); - } - }; + }, [fetchMeeting, fetchOptions, form, isEdit, loadAudioUploadConfig, open]); const handleAudioBeforeUpload = (file) => { const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService)); diff --git a/frontend/src/components/MindMap.jsx b/frontend/src/components/MindMap.jsx index 3d8dc25..81f921f 100644 --- a/frontend/src/components/MindMap.jsx +++ b/frontend/src/components/MindMap.jsx @@ -12,7 +12,7 @@ const hasRenderableSize = (element) => { return Number.isFinite(rect.width) && Number.isFinite(rect.height) && rect.width > 0 && rect.height > 0; }; -const MindMap = ({ content, title }) => { +const MindMap = ({ content }) => { const svgRef = useRef(null); const markmapRef = useRef(null); const latestRootRef = useRef(null); diff --git a/frontend/src/components/ScrollToTop.jsx b/frontend/src/components/ScrollToTop.jsx index 5877805..7a8eff9 100644 --- a/frontend/src/components/ScrollToTop.jsx +++ b/frontend/src/components/ScrollToTop.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { FloatButton } from 'antd'; import { VerticalAlignTopOutlined } from '@ant-design/icons'; diff --git a/frontend/src/components/TagCloud.jsx b/frontend/src/components/TagCloud.jsx index 7e41b9e..bf5988d 100644 --- a/frontend/src/components/TagCloud.jsx +++ b/frontend/src/components/TagCloud.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Tag, Space, Typography, Skeleton, Empty } from 'antd'; import { TagsOutlined } from '@ant-design/icons'; import apiClient from '../utils/apiClient'; @@ -16,11 +16,7 @@ const TagCloud = ({ const [allTags, setAllTags] = useState([]); const [loading, setLoading] = useState(true); - useEffect(() => { - fetchAllTags(); - }, []); - - const fetchAllTags = async () => { + const fetchAllTags = useCallback(async () => { try { setLoading(true); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST)); @@ -31,7 +27,11 @@ const TagCloud = ({ } finally { setLoading(false); } - }; + }, [limitTags]); + + useEffect(() => { + fetchAllTags(); + }, [fetchAllTags]); const handleTagClick = (tag) => { if (onTagClick) { diff --git a/frontend/src/components/VoiceprintCollectionModal.jsx b/frontend/src/components/VoiceprintCollectionModal.jsx index 9b11f05..1aa6d89 100644 --- a/frontend/src/components/VoiceprintCollectionModal.jsx +++ b/frontend/src/components/VoiceprintCollectionModal.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useRef } from 'react'; import { Modal, Button, Typography, Space, Progress, App, Card, Alert } from 'antd'; import { AudioOutlined, @@ -50,7 +50,7 @@ const VoiceprintCollectionModal = ({ isOpen, onClose, onSuccess, templateConfig stopRecording(); } }, 100); - } catch (err) { + } catch { message.error('无法访问麦克风,请检查权限设置'); } }; @@ -71,7 +71,7 @@ const VoiceprintCollectionModal = ({ isOpen, onClose, onSuccess, templateConfig await onSuccess(formData); message.success('声纹采集成功'); onClose(); - } catch (err) { + } catch { message.error('上传失败,请重试'); } finally { setLoading(false); diff --git a/frontend/src/hooks/useSystemPageSize.js b/frontend/src/hooks/useSystemPageSize.js new file mode 100644 index 0000000..1dd6ff5 --- /dev/null +++ b/frontend/src/hooks/useSystemPageSize.js @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import configService from '../utils/configService'; + +const normalizePageSize = (value, fallback) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue) || numericValue <= 0) { + return fallback; + } + return Math.min(100, Math.max(5, Math.floor(numericValue))); +}; + +const useSystemPageSize = (fallback = 10, options = {}) => { + const { suspendUntilReady = false } = options; + const [pageSize, setPageSize] = useState(() => { + const cachedPageSize = configService.getCachedPageSize(); + if (cachedPageSize) { + return normalizePageSize(cachedPageSize, fallback); + } + return suspendUntilReady ? null : normalizePageSize(fallback, 10); + }); + const [isReady, setIsReady] = useState(() => pageSize !== null); + + useEffect(() => { + let active = true; + + configService.getPageSize().then((size) => { + if (!active) { + return; + } + setPageSize(normalizePageSize(size, fallback)); + setIsReady(true); + }).catch(() => { + if (!active) { + return; + } + setPageSize((prev) => prev ?? normalizePageSize(fallback, 10)); + setIsReady(true); + }); + + return () => { + active = false; + }; + }, [fallback]); + + if (suspendUntilReady) { + return { pageSize, isReady }; + } + + return pageSize ?? normalizePageSize(fallback, 10); +}; + +export default useSystemPageSize; diff --git a/frontend/src/pages/AccountSettings.jsx b/frontend/src/pages/AccountSettings.jsx index 3349ff2..12978ab 100644 --- a/frontend/src/pages/AccountSettings.jsx +++ b/frontend/src/pages/AccountSettings.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Alert, App, @@ -82,13 +82,7 @@ const AccountSettings = ({ user, onUpdateUser }) => { const [mcpConfig, setMcpConfig] = useState(null); const [mcpError, setMcpError] = useState(''); - useEffect(() => { - if (!user?.user_id) return; - fetchUserData(); - fetchMcpConfig(); - }, [user?.user_id]); - - const fetchUserData = async () => { + const fetchUserData = useCallback(async () => { try { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id))); profileForm.setFieldsValue({ @@ -101,9 +95,9 @@ const AccountSettings = ({ user, onUpdateUser }) => { } catch (error) { message.error(error?.response?.data?.message || '获取用户资料失败'); } - }; + }, [message, profileForm, user.user_id]); - const fetchMcpConfig = async () => { + const fetchMcpConfig = useCallback(async () => { if (!user?.user_id) return; setMcpLoading(true); @@ -117,7 +111,13 @@ const AccountSettings = ({ user, onUpdateUser }) => { } finally { setMcpLoading(false); } - }; + }, [user?.user_id]); + + useEffect(() => { + if (!user?.user_id) return; + fetchUserData(); + fetchMcpConfig(); + }, [fetchMcpConfig, fetchUserData, user?.user_id]); const handleAvatarBeforeUpload = (file) => { const isAllowedType = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type); @@ -193,7 +193,7 @@ const AccountSettings = ({ user, onUpdateUser }) => { try { await navigator.clipboard.writeText(value); message.success(`${label}已复制`); - } catch (error) { + } catch { message.error(`${label}复制失败`); } }; diff --git a/frontend/src/pages/AdminDashboard.css b/frontend/src/pages/AdminDashboard.css index 412590f..3709e14 100644 --- a/frontend/src/pages/AdminDashboard.css +++ b/frontend/src/pages/AdminDashboard.css @@ -35,6 +35,7 @@ .admin-overview-icon { width: 88px; height: 88px; + flex: 0 0 88px; border-radius: 24px; display: inline-flex; align-items: center; @@ -85,15 +86,26 @@ flex-direction: column; gap: 14px; width: 100%; + min-width: 0; } .admin-resource-row { display: grid; - grid-template-columns: 18px 1fr auto; + grid-template-columns: 112px 1fr auto; align-items: center; gap: 10px; } +.admin-resource-label { + display: inline-flex; + align-items: center; + gap: 6px; + color: #37475f; + font-size: 13px; + font-weight: 700; + white-space: nowrap; +} + .admin-resource-row .ant-progress { margin: 0; } diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index fb61cf9..53daa66 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Card, Table, @@ -27,7 +27,7 @@ import { SearchOutlined, DeploymentUnitOutlined, HddOutlined, - ApartmentOutlined, + DashboardOutlined, FileTextOutlined, ReloadOutlined, PauseCircleOutlined, @@ -38,6 +38,7 @@ import { import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import ActionButton from '../components/ActionButton'; +import useSystemPageSize from '../hooks/useSystemPageSize'; import './AdminDashboard.css'; const { Text } = Typography; @@ -75,6 +76,7 @@ const AdminDashboard = () => { const [taskType, setTaskType] = useState('all'); const [taskStatus, setTaskStatus] = useState('all'); + const pageSize = useSystemPageSize(10); const [autoRefresh, setAutoRefresh] = useState(true); const [countdown, setCountdown] = useState(AUTO_REFRESH_INTERVAL); @@ -90,31 +92,41 @@ const AdminDashboard = () => { }; }, []); - useEffect(() => { - fetchAllData(); + const fetchStats = useCallback(async () => { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS)); + if (response.code === '200') setStats(response.data); }, []); - useEffect(() => { - if (!autoRefresh || showMeetingModal) return; + const fetchOnlineUsers = useCallback(async () => { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS)); + if (response.code === '200') setOnlineUsers(response.data.users || []); + }, []); - const timer = setInterval(() => { - setCountdown((prev) => { - if (prev <= 1) { - fetchAllData({ silent: true }); - return AUTO_REFRESH_INTERVAL; - } - return prev - 1; - }); - }, 1000); + const fetchUsersList = useCallback(async () => { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS)); + if (response.code === '200') setUsersList(response.data.users || []); + }, []); - return () => clearInterval(timer); - }, [autoRefresh, showMeetingModal]); + const fetchTasks = useCallback(async () => { + try { + setTaskLoading(true); + const params = new URLSearchParams(); + if (taskType !== 'all') params.append('task_type', taskType); + if (taskStatus !== 'all') params.append('status', taskStatus); + params.append('limit', '20'); + const response = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.ADMIN.TASKS_MONITOR}?${params.toString()}`)); + if (response.code === '200') setTasks(response.data.tasks || []); + } finally { + setTaskLoading(false); + } + }, [taskStatus, taskType]); - useEffect(() => { - fetchTasks(); - }, [taskType, taskStatus]); + const fetchResources = useCallback(async () => { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES)); + if (response.code === '200') setResources(response.data); + }, []); - const fetchAllData = async ({ silent = false } = {}) => { + const fetchAllData = useCallback(async ({ silent = false } = {}) => { if (inFlightRef.current) return; inFlightRef.current = true; @@ -138,41 +150,31 @@ const AdminDashboard = () => { setLoading(false); } } - }; + }, [fetchOnlineUsers, fetchResources, fetchStats, fetchTasks, fetchUsersList, message]); - const fetchStats = async () => { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS)); - if (response.code === '200') setStats(response.data); - }; + useEffect(() => { + fetchAllData(); + }, [fetchAllData]); - const fetchOnlineUsers = async () => { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS)); - if (response.code === '200') setOnlineUsers(response.data.users || []); - }; + useEffect(() => { + if (!autoRefresh || showMeetingModal) return; - const fetchUsersList = async () => { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS)); - if (response.code === '200') setUsersList(response.data.users || []); - }; + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + fetchAllData({ silent: true }); + return AUTO_REFRESH_INTERVAL; + } + return prev - 1; + }); + }, 1000); - const fetchTasks = async () => { - try { - setTaskLoading(true); - const params = new URLSearchParams(); - if (taskType !== 'all') params.append('task_type', taskType); - if (taskStatus !== 'all') params.append('status', taskStatus); - params.append('limit', '20'); - const response = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.ADMIN.TASKS_MONITOR}?${params.toString()}`)); - if (response.code === '200') setTasks(response.data.tasks || []); - } finally { - setTaskLoading(false); - } - }; + return () => clearInterval(timer); + }, [autoRefresh, fetchAllData, showMeetingModal]); - const fetchResources = async () => { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES)); - if (response.code === '200') setResources(response.data); - }; + useEffect(() => { + fetchTasks(); + }, [fetchTasks]); const handleKickUser = (u) => { modal.confirm({ @@ -234,6 +236,33 @@ const AdminDashboard = () => { return Math.round((completed / all) * 100); }, [tasks]); + const resourceRows = useMemo(() => ([ + { + key: 'cpu', + label: 'CPU', + tooltip: resources?.cpu?.count ? `${resources.cpu.count} 核逻辑处理器` : 'CPU 使用率', + icon: , + percent: resources?.cpu?.percent || 0, + strokeColor: { from: '#60a5fa', to: '#2563eb' }, + }, + { + key: 'memory', + label: 'Memory', + tooltip: resources?.memory?.total_gb ? `已用 ${resources.memory.used_gb} / ${resources.memory.total_gb} GB` : '内存使用率', + icon: , + percent: resources?.memory?.percent || 0, + strokeColor: { from: '#a78bfa', to: '#7c3aed' }, + }, + { + key: 'disk', + label: 'Disk', + tooltip: resources?.disk?.total_gb ? `已用 ${resources.disk.used_gb} / ${resources.disk.total_gb} GB` : '磁盘使用率', + icon: , + percent: resources?.disk?.percent || 0, + strokeColor: { from: '#818cf8', to: '#6d28d9' }, + }, + ]), [resources]); + const userColumns = [ { title: 'ID', dataIndex: 'user_id', key: 'user_id', width: 80 }, { title: '用户名', dataIndex: 'username', key: 'username' }, @@ -377,7 +406,7 @@ const AdminDashboard = () => {
            {Number(stats?.storage?.audio_total_size_gb || 0).toFixed(2)} GB
            - 音频文件:{stats?.storage?.audio_file_count || 0} 个 + 音频文件:{stats?.storage?.audio_files_count ?? stats?.storage?.audio_file_count ?? 0} 个 音频目录占用汇总
            @@ -390,21 +419,18 @@ const AdminDashboard = () => {
            -
            - - - {formatResourcePercent(resources?.cpu?.percent)} -
            -
            - - - {formatResourcePercent(resources?.memory?.percent)} -
            -
            - - - {formatResourcePercent(resources?.disk?.percent)} -
            + {resourceRows.map((resource) => ( +
            + +
            + {resource.icon} + {resource.label} +
            +
            + + {formatResourcePercent(resource.percent)} +
            + ))}
            @@ -457,7 +483,7 @@ const AdminDashboard = () => { columns={taskColumns} dataSource={tasks} rowKey={(record) => `${record.task_type}-${record.task_id}`} - pagination={{ pageSize: 6 }} + pagination={{ pageSize }} loading={taskLoading} scroll={{ x: 760 }} /> @@ -472,7 +498,7 @@ const AdminDashboard = () => { columns={userColumns} dataSource={usersList} rowKey="user_id" - pagination={{ pageSize: 10 }} + pagination={{ pageSize }} scroll={{ x: 760 }} /> @@ -481,7 +507,13 @@ const AdminDashboard = () => {
            +
            在线用户会话可在此直接踢出,立即失效其 Token。 diff --git a/frontend/src/pages/ClientManagement.jsx b/frontend/src/pages/ClientManagement.jsx index 0216f24..0296e49 100644 --- a/frontend/src/pages/ClientManagement.jsx +++ b/frontend/src/pages/ClientManagement.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Table, Button, @@ -40,6 +40,7 @@ import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import AdminModuleShell from '../components/AdminModuleShell'; import ActionButton from '../components/ActionButton'; +import useSystemPageSize from '../hooks/useSystemPageSize'; const { Text } = Typography; const { TextArea } = Input; @@ -74,16 +75,12 @@ const ClientManagement = () => { const [activeTab, setActiveTab] = useState('all'); const [uploadingFile, setUploadingFile] = useState(false); const [updatingStatusId, setUpdatingStatusId] = useState(null); + const pageSize = useSystemPageSize(10); const [platforms, setPlatforms] = useState({ tree: [], items: [] }); const [platformsMap, setPlatformsMap] = useState({}); - useEffect(() => { - fetchPlatforms(); - fetchClients(); - }, []); - - const fetchPlatforms = async () => { + const fetchPlatforms = useCallback(async () => { try { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform'))); if (response.code === '200') { @@ -99,9 +96,9 @@ const ClientManagement = () => { } catch { message.error('获取平台列表失败'); } - }; + }, [message]); - const fetchClients = async () => { + const fetchClients = useCallback(async () => { setLoading(true); try { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST)); @@ -113,9 +110,17 @@ const ClientManagement = () => { } finally { setLoading(false); } - }; + }, [message]); - const getPlatformLabel = (platformCode) => platformsMap[platformCode]?.label_cn || platformCode || '-'; + const getPlatformLabel = useCallback( + (platformCode) => platformsMap[platformCode]?.label_cn || platformCode || '-', + [platformsMap], + ); + + useEffect(() => { + fetchPlatforms(); + fetchClients(); + }, [fetchClients, fetchPlatforms]); const openModal = (client = null) => { if (client) { @@ -295,7 +300,7 @@ const ClientManagement = () => { client.download_url, client.release_notes, ].some((field) => String(field || '').toLowerCase().includes(query)); - }), [activeTab, clients, searchQuery, statusFilter, platformsMap]); + }), [activeTab, clients, getPlatformLabel, searchQuery, statusFilter]); const publishedCount = clients.length; const activeCount = clients.filter((item) => isTruthy(item.is_active)).length; @@ -448,7 +453,7 @@ const ClientManagement = () => { rowKey="id" size="small" scroll={{ x: 1040 }} - pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条记录` }} + pagination={{ pageSize, showTotal: (total) => `共 ${total} 条记录` }} /> diff --git a/frontend/src/pages/CreateMeeting.jsx b/frontend/src/pages/CreateMeeting.jsx index 28ba9e6..77e6738 100644 --- a/frontend/src/pages/CreateMeeting.jsx +++ b/frontend/src/pages/CreateMeeting.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Card, Form, Input, Button, DatePicker, Select, Typography, App, Divider, Row, Col, Upload, Space, Progress @@ -29,34 +29,38 @@ const CreateMeeting = () => { const [audioUploadMessage, setAudioUploadMessage] = useState(''); const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024); + const fetchUsers = useCallback(async () => { + try { + const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)); + setUsers(res.data.users || []); + } catch { + setUsers([]); + } + }, []); + + const fetchPrompts = useCallback(async () => { + try { + const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))); + setPrompts(res.data.prompts || []); + } catch { + setPrompts([]); + } + }, []); + + const loadAudioUploadConfig = useCallback(async () => { + try { + const nextMaxAudioSize = await configService.getMaxAudioSize(); + setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024); + } catch { + setMaxAudioSize(100 * 1024 * 1024); + } + }, []); + useEffect(() => { fetchUsers(); fetchPrompts(); loadAudioUploadConfig(); - }, []); - - const fetchUsers = async () => { - try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)); - setUsers(res.data.users || []); - } catch (e) {} - }; - - const fetchPrompts = async () => { - try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))); - setPrompts(res.data.prompts || []); - } catch (e) {} - }; - - const loadAudioUploadConfig = async () => { - try { - const nextMaxAudioSize = await configService.getMaxAudioSize(); - setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024); - } catch (error) { - setMaxAudioSize(100 * 1024 * 1024); - } - }; + }, [fetchPrompts, fetchUsers, loadAudioUploadConfig]); const handleAudioBeforeUpload = (file) => { const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService)); diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index ca11a3c..93fd68b 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { App, Avatar, @@ -62,16 +62,6 @@ const Dashboard = ({ user }) => { const [voiceprintTemplate, setVoiceprintTemplate] = useState(null); const [voiceprintLoading, setVoiceprintLoading] = useState(true); - useEffect(() => { - fetchUserData(); - fetchMeetingsStats(); - fetchVoiceprintData(); - }, [user.user_id]); - - useEffect(() => { - fetchMeetings(1, false); - }, [selectedTags, filterType, searchQuery]); - useEffect(() => { if (selectedTags.length > 0) { setShowTagFilters(true); @@ -120,7 +110,7 @@ const Dashboard = ({ user }) => { }, {}) ), [meetings]); - const fetchVoiceprintData = async () => { + const fetchVoiceprintData = useCallback(async () => { try { setVoiceprintLoading(true); const statusResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.STATUS(user.user_id))); @@ -132,9 +122,9 @@ const Dashboard = ({ user }) => { } finally { setVoiceprintLoading(false); } - }; + }, [user.user_id]); - const fetchMeetings = async (page = 1, isLoadMore = false) => { + const fetchMeetings = useCallback(async (page = 1, isLoadMore = false) => { try { const filterKey = meetingCacheService.generateFilterKey(user.user_id, filterType, searchQuery, selectedTags); @@ -188,9 +178,9 @@ const Dashboard = ({ user }) => { setLoading(false); setLoadingMore(false); } - }; + }, [filterType, message, searchQuery, selectedTags, user.user_id]); - const fetchMeetingsStats = async () => { + const fetchMeetingsStats = useCallback(async () => { try { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.STATS), { params: { user_id: user.user_id }, @@ -199,16 +189,26 @@ const Dashboard = ({ user }) => { } catch (error) { console.error('Error fetching meetings stats:', error); } - }; + }, [user.user_id]); - const fetchUserData = async () => { + const fetchUserData = useCallback(async () => { try { const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id))); setUserInfo(userResponse.data); } catch { message.error('获取用户信息失败'); } - }; + }, [message, user.user_id]); + + useEffect(() => { + fetchUserData(); + fetchMeetingsStats(); + fetchVoiceprintData(); + }, [fetchMeetingsStats, fetchUserData, fetchVoiceprintData]); + + useEffect(() => { + fetchMeetings(1, false); + }, [fetchMeetings, selectedTags, filterType, searchQuery]); const handleLoadMore = () => { if (!loadingMore && pagination.has_more) { diff --git a/frontend/src/pages/EditKnowledgeBase.jsx b/frontend/src/pages/EditKnowledgeBase.jsx index b19580c..a41f95a 100644 --- a/frontend/src/pages/EditKnowledgeBase.jsx +++ b/frontend/src/pages/EditKnowledgeBase.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Card, Form, Input, Button, Space, Typography, App, Divider, Skeleton @@ -13,7 +13,7 @@ import MarkdownEditor from '../components/MarkdownEditor'; const { Title } = Typography; -const EditKnowledgeBase = ({ user }) => { +const EditKnowledgeBase = () => { const { kb_id } = useParams(); const navigate = useNavigate(); const { message } = App.useApp(); @@ -21,20 +21,20 @@ const EditKnowledgeBase = ({ user }) => { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - useEffect(() => { - fetchKbDetail(); - }, [kb_id]); - - const fetchKbDetail = async () => { + const fetchKbDetail = useCallback(async () => { try { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kb_id))); form.setFieldsValue(response.data); - } catch (error) { + } catch { message.error('加载知识库详情失败'); } finally { setLoading(false); } - }; + }, [form, kb_id, message]); + + useEffect(() => { + fetchKbDetail(); + }, [fetchKbDetail]); const onFinish = async (values) => { setSaving(true); @@ -42,7 +42,7 @@ const EditKnowledgeBase = ({ user }) => { await apiClient.put(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.UPDATE(kb_id)), values); message.success('更新成功'); navigate('/knowledge-base'); - } catch (error) { + } catch { message.error('更新失败'); } finally { setSaving(false); diff --git a/frontend/src/pages/EditMeeting.jsx b/frontend/src/pages/EditMeeting.jsx index f4c2523..0eed0b2 100644 --- a/frontend/src/pages/EditMeeting.jsx +++ b/frontend/src/pages/EditMeeting.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Card, Form, Input, Button, DatePicker, Select, Typography, App, Divider, Row, Col @@ -23,11 +23,7 @@ const EditMeeting = () => { const [users, setUsers] = useState([]); const [prompts, setPrompts] = useState([]); - useEffect(() => { - fetchData(); - }, [meeting_id]); - - const fetchData = async () => { + const fetchData = useCallback(async () => { try { const [uRes, pRes, mRes] = await Promise.all([ apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)), @@ -45,12 +41,16 @@ const EditMeeting = () => { attendee_ids: meeting.attendee_ids || meeting.attendees.map(a => a.user_id).filter(Boolean), tags: meeting.tags?.map(t => t.name) || [] }); - } catch (e) { + } catch { message.error('加载会议数据失败'); } finally { setFetching(false); } - }; + }, [form, meeting_id, message]); + + useEffect(() => { + fetchData(); + }, [fetchData]); const onFinish = async (values) => { setLoading(true); diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index 1fd1ff8..a88cd8d 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -21,6 +21,7 @@ const BRAND_HIGHLIGHTS = [ '时间轴回看与全文检索', '沉淀可追踪的会议资产', ]; +const HOME_TAGLINE = '让每一次谈话都产生价值'; const HomePage = ({ onLogin }) => { const [loading, setLoading] = useState(false); @@ -131,7 +132,7 @@ const HomePage = ({ onLogin }) => { color: '#1677ff', }} > - {branding.home_headline} + {branding.app_name} @@ -144,7 +145,7 @@ const HomePage = ({ onLogin }) => { maxWidth: 560, }} > - {branding.home_tagline} + {HOME_TAGLINE} { const { message, modal } = App.useApp(); const [kbs, setKbs] = useState([]); - const [loading, setLoading] = useState(true); const [selectedKb, setSelectedKb] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false); @@ -50,39 +47,29 @@ const KnowledgeBasePage = ({ user }) => { const [selectedPromptId, setSelectedPromptId] = useState(null); const [taskProgress, setTaskProgress] = useState(0); - useEffect(() => { - fetchAllKbs(); - fetchAvailableTags(); - }, []); - - useEffect(() => { - if (showCreateForm && createStep === 0) { - fetchMeetings(meetingsPagination.page); + const loadKbDetail = useCallback(async (id) => { + try { + const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(id))); + setSelectedKb(res.data); + } catch { + message.error('加载知识库详情失败'); } - }, [searchQuery, selectedTags, showCreateForm, createStep, meetingsPagination.page]); + }, [message]); - const fetchAllKbs = async () => { - setLoading(true); + const fetchAllKbs = useCallback(async () => { try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.LIST)); - const sorted = res.data.kbs.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + const sorted = (res.data.kbs || []).sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); setKbs(sorted); if (sorted.length > 0 && !selectedKb) { loadKbDetail(sorted[0].kb_id); } - } finally { - setLoading(false); + } catch { + message.error('加载知识库列表失败'); } - }; + }, [loadKbDetail, message, selectedKb]); - const loadKbDetail = async (id) => { - try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(id))); - setSelectedKb(res.data); - } catch (e) {} - }; - - const fetchMeetings = async (page = 1) => { + const fetchMeetings = useCallback(async (page = 1) => { setLoadingMeetings(true); try { const params = { @@ -94,26 +81,43 @@ const KnowledgeBasePage = ({ user }) => { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params }); setMeetings(res.data.meetings || []); setMeetingsPagination({ page: res.data.page, total: res.data.total }); + } catch { + message.error('获取会议列表失败'); } finally { setLoadingMeetings(false); } - }; + }, [message, searchQuery, selectedTags, user.user_id]); - const fetchAvailableTags = async () => { + const fetchAvailableTags = useCallback(async () => { try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST)); setAvailableTags(res.data?.slice(0, 10) || []); - } catch (e) {} - }; + } catch { + setAvailableTags([]); + } + }, []); - const fetchPrompts = async () => { + const fetchPrompts = useCallback(async () => { try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('KNOWLEDGE_TASK'))); setAvailablePrompts(res.data.prompts || []); const def = res.data.prompts?.find(p => p.is_default) || res.data.prompts?.[0]; if (def) setSelectedPromptId(def.id); - } catch (e) {} - }; + } catch { + setAvailablePrompts([]); + } + }, []); + + useEffect(() => { + fetchAllKbs(); + fetchAvailableTags(); + }, [fetchAllKbs, fetchAvailableTags]); + + useEffect(() => { + if (showCreateForm && createStep === 0) { + fetchMeetings(meetingsPagination.page); + } + }, [createStep, fetchMeetings, meetingsPagination.page, selectedTags, searchQuery, showCreateForm]); const handleStartCreate = () => { setShowCreateForm(true); @@ -122,7 +126,7 @@ const KnowledgeBasePage = ({ user }) => { fetchPrompts(); }; - const handleGenerate = async () => { + const handleGenerate = useCallback(async () => { setGenerating(true); setTaskProgress(10); try { @@ -149,10 +153,11 @@ const KnowledgeBasePage = ({ user }) => { message.error('生成失败'); } }, 3000); - } catch (e) { + } catch { setGenerating(false); + message.error('创建知识库任务失败'); } - }; + }, [fetchAllKbs, message, selectedMeetings, selectedPromptId, userPrompt]); const handleDelete = (kb) => { modal.confirm({ diff --git a/frontend/src/pages/MeetingCenterPage.jsx b/frontend/src/pages/MeetingCenterPage.jsx index 8d86865..edf8b55 100644 --- a/frontend/src/pages/MeetingCenterPage.jsx +++ b/frontend/src/pages/MeetingCenterPage.jsx @@ -31,6 +31,7 @@ import CenterPager from '../components/CenterPager'; import MeetingFormDrawer from '../components/MeetingFormDrawer'; import meetingCacheService from '../services/meetingCacheService'; import tools from '../utils/tools'; +import useSystemPageSize from '../hooks/useSystemPageSize'; import './MeetingCenterPage.css'; import '../components/MeetingInfoCard.css'; @@ -101,7 +102,7 @@ const MeetingCenterPage = ({ user }) => { const [searchValue, setSearchValue] = useState(''); const [filterType, setFilterType] = useState('all'); const [page, setPage] = useState(1); - const [pageSize] = useState(8); + const { pageSize, isReady: pageSizeReady } = useSystemPageSize(10, { suspendUntilReady: true }); const [total, setTotal] = useState(0); const [formDrawerOpen, setFormDrawerOpen] = useState(false); const [editingMeetingId, setEditingMeetingId] = useState(null); @@ -149,8 +150,15 @@ const MeetingCenterPage = ({ user }) => { }, [filterType, keyword, message, page, pageSize, user.user_id]); useEffect(() => { + meetingCacheService.clearAll(); + }, [pageSize]); + + useEffect(() => { + if (!pageSizeReady || !pageSize) { + return; + } loadMeetings(page, keyword, filterType); - }, [filterType, keyword, loadMeetings, page]); + }, [filterType, keyword, loadMeetings, page, pageSize, pageSizeReady]); useEffect(() => { if (location.state?.openCreate) { @@ -236,7 +244,11 @@ const MeetingCenterPage = ({ user }) => { variant="borderless" styles={{ body: { padding: '20px' } }} > - {meetings.length ? ( + {!pageSizeReady ? ( +
            + +
            + ) : meetings.length ? ( <> {meetings.map((meeting) => { @@ -351,7 +363,7 @@ const MeetingCenterPage = ({ user }) => { diff --git a/frontend/src/pages/MeetingDetails.jsx b/frontend/src/pages/MeetingDetails.jsx index 10519ec..41d787b 100644 --- a/frontend/src/pages/MeetingDetails.jsx +++ b/frontend/src/pages/MeetingDetails.jsx @@ -159,6 +159,8 @@ const MeetingDetails = ({ user }) => { /* ══════════════════ 数据获取 ══════════════════ */ + // The initial bootstrap should run only when the meeting id changes. + // Polling helpers are intentionally excluded here to avoid restarting intervals on every render. useEffect(() => { fetchMeetingDetails(); fetchTranscript(); @@ -168,17 +170,18 @@ const MeetingDetails = ({ user }) => { if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current); if (summaryBootstrapTimeoutRef.current) clearTimeout(summaryBootstrapTimeoutRef.current); }; - }, [meeting_id]); + }, [meeting_id]); // eslint-disable-line react-hooks/exhaustive-deps const loadAudioUploadConfig = async () => { try { const nextMaxAudioSize = await configService.getMaxAudioSize(); setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024); - } catch (error) { + } catch { setMaxAudioSize(100 * 1024 * 1024); } }; + // Summary resources are loaded lazily when the drawer opens; the existing prompt/model caches gate repeat fetches. useEffect(() => { if (!showSummaryDrawer) { return; @@ -189,7 +192,7 @@ const MeetingDetails = ({ user }) => { } fetchSummaryResources(); - }, [showSummaryDrawer]); + }, [showSummaryDrawer]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { transcriptRefs.current = []; diff --git a/frontend/src/pages/MeetingPreview.jsx b/frontend/src/pages/MeetingPreview.jsx index 3c72e80..ca673c1 100644 --- a/frontend/src/pages/MeetingPreview.jsx +++ b/frontend/src/pages/MeetingPreview.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; import { Layout, Space, Button, App, Empty, Input, Tabs } from 'antd'; import { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons'; @@ -36,22 +36,7 @@ const MeetingPreview = () => { configService.getBrandingConfig().then(setBranding).catch(() => {}); }, []); - useEffect(() => { - setMeeting(null); - setTranscript([]); - transcriptRefs.current = []; - setAudioUrl(''); - setActiveSegmentIndex(-1); - setPlaybackRate(1); - setError(null); - setPassword(''); - setPasswordError(''); - setPasswordRequired(false); - setIsAuthorized(false); - fetchPreview(); - }, [meeting_id]); - - const fetchTranscriptAndAudio = async () => { + const fetchTranscriptAndAudio = useCallback(async () => { const [transcriptRes, audioRes] = await Promise.allSettled([ apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))), apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id))), @@ -68,9 +53,9 @@ const MeetingPreview = () => { } else { setAudioUrl(''); } - }; + }, [meeting_id]); - const fetchPreview = async (pwd = '') => { + const fetchPreview = useCallback(async (pwd = '') => { setLoading(true); try { const endpoint = API_ENDPOINTS.MEETINGS.PREVIEW_DATA(meeting_id); @@ -100,7 +85,22 @@ const MeetingPreview = () => { } finally { setLoading(false); } - }; + }, [fetchTranscriptAndAudio, meeting_id]); + + useEffect(() => { + setMeeting(null); + setTranscript([]); + transcriptRefs.current = []; + setAudioUrl(''); + setActiveSegmentIndex(-1); + setPlaybackRate(1); + setError(null); + setPassword(''); + setPasswordError(''); + setPasswordRequired(false); + setIsAuthorized(false); + fetchPreview(); + }, [fetchPreview, meeting_id]); const handleCopyLink = async () => { await navigator.clipboard.writeText(window.location.href); diff --git a/frontend/src/pages/PromptConfigPage.jsx b/frontend/src/pages/PromptConfigPage.jsx index baf2302..128fba6 100644 --- a/frontend/src/pages/PromptConfigPage.jsx +++ b/frontend/src/pages/PromptConfigPage.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { App, Button, @@ -46,7 +46,7 @@ const PromptConfigPage = ({ user }) => { const [selectedPromptIds, setSelectedPromptIds] = useState([]); const [viewingPrompt, setViewingPrompt] = useState(null); - const loadConfig = async () => { + const loadConfig = useCallback(async () => { setLoading(true); try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.CONFIG(taskType))); @@ -57,11 +57,11 @@ const PromptConfigPage = ({ user }) => { } finally { setLoading(false); } - }; + }, [message, taskType]); useEffect(() => { loadConfig(); - }, [taskType]); + }, [loadConfig]); const selectedPromptCards = useMemo(() => { const map = new Map(availablePrompts.map((item) => [item.id, item])); diff --git a/frontend/src/pages/PromptManagementPage.jsx b/frontend/src/pages/PromptManagementPage.jsx index f8fc79c..cac1d18 100644 --- a/frontend/src/pages/PromptManagementPage.jsx +++ b/frontend/src/pages/PromptManagementPage.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { App, Button, @@ -32,6 +32,7 @@ import MarkdownEditor from '../components/MarkdownEditor'; import CenterPager from '../components/CenterPager'; import StatusTag from '../components/StatusTag'; import ActionButton from '../components/ActionButton'; +import useSystemPageSize from '../hooks/useSystemPageSize'; const { Title, Text } = Typography; @@ -49,7 +50,7 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => { const [statusFilter, setStatusFilter] = useState('all'); const [keyword, setKeyword] = useState(''); const [page, setPage] = useState(1); - const [size] = useState(12); + const size = useSystemPageSize(10); const [loading, setLoading] = useState(false); const [prompts, setPrompts] = useState([]); @@ -67,7 +68,7 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => { ? '仅维护你自己创建的提示词,供个人配置启用。' : '系统管理员可管理系统提示词,普通用户管理自己的提示词。'; - const loadPrompts = async () => { + const loadPrompts = useCallback(async () => { setLoading(true); try { const is_active = statusFilter === 'all' ? undefined : Number(statusFilter); @@ -88,11 +89,11 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => { } finally { setLoading(false); } - }; + }, [keyword, message, page, scope, size, statusFilter, taskType]); useEffect(() => { loadPrompts(); - }, [taskType, statusFilter, page]); + }, [loadPrompts]); const openCreateDrawer = () => { setEditingPrompt(null); diff --git a/frontend/src/pages/admin/DictManagement.jsx b/frontend/src/pages/admin/DictManagement.jsx index 8efb3ca..a5ee8ca 100644 --- a/frontend/src/pages/admin/DictManagement.jsx +++ b/frontend/src/pages/admin/DictManagement.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Tree, Button, Form, Input, InputNumber, Select, Switch, Space, Card, Empty, Popconfirm, App, Row, Col, Typography, Checkbox } from 'antd'; import { BookOutlined, @@ -17,7 +17,6 @@ const { Title, Text } = Typography; const DictManagement = () => { const { message } = App.useApp(); - const [loading, setLoading] = useState(false); const [dictTypes, setDictTypes] = useState([]); // 字典类型列表 const [selectedDictType, setSelectedDictType] = useState('client_platform'); // 当前选中的字典类型 const [dictData, setDictData] = useState([]); // 当前字典类型的数据 @@ -27,7 +26,7 @@ const DictManagement = () => { const [form] = Form.useForm(); // 获取所有字典类型 - const fetchDictTypes = async () => { + const fetchDictTypes = useCallback(async () => { try { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.TYPES)); if (response.code === '200') { @@ -37,14 +36,29 @@ const DictManagement = () => { setSelectedDictType(types[0] || ''); } } - } catch (error) { + } catch { message.error('获取字典类型失败'); } - }; + }, [message, selectedDictType]); // 获取指定类型的字典数据 - const fetchDictData = async (dictType) => { - setLoading(true); + // 将树形数据转换为 antd Tree 组件格式 + const buildAntdTreeData = useCallback((tree) => { + return tree.map(node => ({ + title: ( + + {node.parent_code === 'ROOT' ? : } + {node.label_cn} + ({node.dict_code}) + + ), + key: node.dict_code, + data: node, + children: node.children && node.children.length > 0 ? buildAntdTreeData(node.children) : [] + })); + }, []); + + const fetchDictData = useCallback(async (dictType) => { try { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE(dictType))); if (response.code === '200') { @@ -59,38 +73,20 @@ const DictManagement = () => { setIsEditing(false); form.resetFields(); } - } catch (error) { + } catch { message.error('获取字典数据失败'); - } finally { - setLoading(false); } - }; - - // 将树形数据转换为 antd Tree 组件格式 - const buildAntdTreeData = (tree) => { - return tree.map(node => ({ - title: ( - - {node.parent_code === 'ROOT' ? : } - {node.label_cn} - ({node.dict_code}) - - ), - key: node.dict_code, - data: node, - children: node.children && node.children.length > 0 ? buildAntdTreeData(node.children) : [] - })); - }; + }, [buildAntdTreeData, form, message]); useEffect(() => { fetchDictTypes(); - }, []); + }, [fetchDictTypes]); useEffect(() => { if (selectedDictType) { fetchDictData(selectedDictType); } - }, [selectedDictType]); + }, [fetchDictData, selectedDictType]); // 选中树节点 const handleSelectNode = (selectedKeys, info) => { @@ -137,7 +133,7 @@ const DictManagement = () => { if (values.extension_attr) { try { values.extension_attr = JSON.parse(values.extension_attr); - } catch (e) { + } catch { message.error('扩展属性 JSON 格式错误'); return; } @@ -167,9 +163,9 @@ const DictManagement = () => { fetchDictData(selectedDictType); } } - } catch (error) { - if (!error.errorFields) { - message.error(error.response?.data?.message || '操作失败'); + } catch (err) { + if (!err.errorFields) { + message.error(err.response?.data?.message || '操作失败'); } } }; @@ -184,7 +180,7 @@ const DictManagement = () => { setIsEditing(false); form.resetFields(); fetchDictData(selectedDictType); - } catch (error) { + } catch { message.error('删除失败'); } }; diff --git a/frontend/src/pages/admin/ExternalAppManagement.jsx b/frontend/src/pages/admin/ExternalAppManagement.jsx index d00bce7..e00c97b 100644 --- a/frontend/src/pages/admin/ExternalAppManagement.jsx +++ b/frontend/src/pages/admin/ExternalAppManagement.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Table, Button, @@ -39,6 +39,7 @@ import apiClient from '../../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../../config/api'; import AdminModuleShell from '../../components/AdminModuleShell'; import ActionButton from '../../components/ActionButton'; +import useSystemPageSize from '../../hooks/useSystemPageSize'; const { Text } = Typography; const { TextArea } = Input; @@ -74,6 +75,7 @@ const getAppEntryUrl = (app) => { const ExternalAppManagement = () => { const { message, modal } = App.useApp(); const [form] = Form.useForm(); + const pageSize = useSystemPageSize(10); const [apps, setApps] = useState([]); const [loading, setLoading] = useState(true); @@ -85,11 +87,7 @@ const ExternalAppManagement = () => { const [searchQuery, setSearchQuery] = useState(''); const [uploading, setUploading] = useState(false); - useEffect(() => { - fetchApps(); - }, []); - - const fetchApps = async () => { + const fetchApps = useCallback(async () => { setLoading(true); try { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.LIST)); @@ -101,7 +99,11 @@ const ExternalAppManagement = () => { } finally { setLoading(false); } - }; + }, [message]); + + useEffect(() => { + fetchApps(); + }, [fetchApps]); const handleOpenModal = (app = null) => { if (app) { @@ -411,7 +413,7 @@ const ExternalAppManagement = () => { loading={loading} rowKey="id" scroll={{ x: 980 }} - pagination={{ pageSize: 10, showTotal: (count) => `共 ${count} 条记录` }} + pagination={{ pageSize, showTotal: (count) => `共 ${count} 条记录` }} /> diff --git a/frontend/src/pages/admin/HotWordManagement.jsx b/frontend/src/pages/admin/HotWordManagement.jsx index a479f46..d8ccb3e 100644 --- a/frontend/src/pages/admin/HotWordManagement.jsx +++ b/frontend/src/pages/admin/HotWordManagement.jsx @@ -10,10 +10,10 @@ import { } from '@ant-design/icons'; import apiClient from '../../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../../config/api'; -import configService from '../../utils/configService'; import AdminModuleShell from '../../components/AdminModuleShell'; import ActionButton from '../../components/ActionButton'; import StatusTag from '../../components/StatusTag'; +import useSystemPageSize from '../../hooks/useSystemPageSize'; const { Text } = Typography; @@ -48,7 +48,7 @@ const HotWordManagement = () => { const [keyword, setKeyword] = useState(''); const [langFilter, setLangFilter] = useState('all'); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const pageSize = useSystemPageSize(10); // ── Fetch groups ── const fetchGroups = useCallback(async () => { @@ -82,7 +82,6 @@ const HotWordManagement = () => { }, [message]); useEffect(() => { - configService.getPageSize().then((size) => setPageSize(size)); fetchGroups(); }, [fetchGroups]); @@ -442,8 +441,7 @@ const HotWordManagement = () => { pagination={{ current: page, pageSize, total: filteredItems.length, - onChange: (p, s) => { setPage(p); setPageSize(s); }, - showSizeChanger: true, + onChange: (p) => { setPage(p); }, showTotal: (count) => `共 ${count} 条`, }} /> diff --git a/frontend/src/pages/admin/ModelManagement.jsx b/frontend/src/pages/admin/ModelManagement.jsx index ea46999..c8a42f1 100644 --- a/frontend/src/pages/admin/ModelManagement.jsx +++ b/frontend/src/pages/admin/ModelManagement.jsx @@ -32,6 +32,7 @@ import { import AdminModuleShell from '../../components/AdminModuleShell'; import ActionButton from '../../components/ActionButton'; import StatusTag from '../../components/StatusTag'; +import useSystemPageSize from '../../hooks/useSystemPageSize'; const AUDIO_SCENE_OPTIONS = [ { label: '全部', value: 'all' }, @@ -67,6 +68,7 @@ const ModelManagement = () => { const [testing, setTesting] = useState(false); const [audioSceneFilter, setAudioSceneFilter] = useState('all'); const [hotWordGroups, setHotWordGroups] = useState([]); + const pageSize = useSystemPageSize(10); const [form] = Form.useForm(); const watchedScene = Form.useWatch('audio_scene', form); const watchedProvider = Form.useWatch('provider', form); @@ -478,7 +480,7 @@ const ModelManagement = () => { columns={llmColumns} dataSource={llmItems} loading={loading} - pagination={{ pageSize: 10 }} + pagination={{ pageSize }} scroll={{ x: 1100 }} /> @@ -512,7 +514,7 @@ const ModelManagement = () => { columns={audioColumns} dataSource={filteredAudioItems} loading={loading} - pagination={{ pageSize: 10 }} + pagination={{ pageSize }} scroll={{ x: 1100 }} /> diff --git a/frontend/src/pages/admin/ParameterManagement.jsx b/frontend/src/pages/admin/ParameterManagement.jsx index 64f69e0..44b5e66 100644 --- a/frontend/src/pages/admin/ParameterManagement.jsx +++ b/frontend/src/pages/admin/ParameterManagement.jsx @@ -1,11 +1,36 @@ -import React, { useEffect, useState } from 'react'; -import { App, Button, Drawer, Form, Input, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip } from 'antd'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, App, Button, Drawer, Form, Input, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, Typography } from 'antd'; import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, SettingOutlined } from '@ant-design/icons'; import apiClient from '../../utils/apiClient'; import { API_ENDPOINTS, buildApiUrl } from '../../config/api'; import AdminModuleShell from '../../components/AdminModuleShell'; import ActionButton from '../../components/ActionButton'; import StatusTag from '../../components/StatusTag'; +import configService from '../../utils/configService'; +import useSystemPageSize from '../../hooks/useSystemPageSize'; + +const { Text } = Typography; + +const CATEGORY_OPTIONS = [ + { label: 'public', value: 'public' }, + { label: 'system', value: 'system' }, +]; + +const VALUE_TYPE_OPTIONS = [ + { label: 'string', value: 'string' }, + { label: 'number', value: 'number' }, + { label: 'json', value: 'json' }, +]; + +const PUBLIC_PARAM_KEYS = new Set([ + 'app_name', + 'page_size', + 'max_audio_size', + 'preview_title', + 'login_welcome', + 'footer_text', + 'console_subtitle', +]); const ParameterManagement = () => { const { message } = App.useApp(); @@ -15,8 +40,21 @@ const ParameterManagement = () => { const [editing, setEditing] = useState(null); const [submitting, setSubmitting] = useState(false); const [form] = Form.useForm(); + const pageSize = useSystemPageSize(10); + const currentParamKey = Form.useWatch('param_key', form); - const fetchItems = async () => { + const categorySuggestion = useMemo(() => { + const normalizedKey = String(currentParamKey || '').trim(); + if (!normalizedKey) { + return '前端需要读取并缓存的参数请选择 public,仅后端内部使用的参数请选择 system。'; + } + if (PUBLIC_PARAM_KEYS.has(normalizedKey)) { + return `检测到参数键 ${normalizedKey} 更适合作为 public 参数,前端会统一初始化并缓存本地。`; + } + return `参数键 ${normalizedKey} 暂未命中公开参数白名单,如仅后端使用,建议归类为 system。`; + }, [currentParamKey]); + + const fetchItems = useCallback(async () => { setLoading(true); try { const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETERS)); @@ -26,11 +64,11 @@ const ParameterManagement = () => { } finally { setLoading(false); } - }; + }, [message]); useEffect(() => { fetchItems(); - }, []); + }, [fetchItems]); const openCreate = () => { setEditing(null); @@ -66,6 +104,7 @@ const ParameterManagement = () => { await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETERS), values); message.success('参数创建成功'); } + configService.clearCache(); setDrawerOpen(false); fetchItems(); } catch (error) { @@ -80,7 +119,17 @@ const ParameterManagement = () => { { title: '参数名', dataIndex: 'param_name', key: 'param_name', width: 180 }, { title: '值', dataIndex: 'param_value', key: 'param_value' }, { title: '类型', dataIndex: 'value_type', key: 'value_type', width: 100, render: (v) => {v} }, - { title: '分类', dataIndex: 'category', key: 'category', width: 120 }, + { + title: '分类', + dataIndex: 'category', + key: 'category', + width: 120, + render: (value) => ( + + {value || 'system'} + + ), + }, { title: '状态', dataIndex: 'is_active', @@ -104,6 +153,7 @@ const ParameterManagement = () => { onConfirm={async () => { try { await apiClient.delete(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETER_DELETE(row.param_key))); + configService.clearCache(); message.success('参数删除成功'); fetchItems(); } catch (error) { @@ -141,7 +191,7 @@ const ParameterManagement = () => { columns={columns} dataSource={items} loading={loading} - pagination={{ pageSize: 10, showTotal: (count) => `共 ${count} 条` }} + pagination={{ pageSize, showTotal: (count) => `共 ${count} 条` }} scroll={{ x: 1100 }} /> @@ -160,20 +210,37 @@ const ParameterManagement = () => { )} >
            + + `public`:前端可读取,适合页面交互参数。 + `system`:仅后端内部使用,不对前端公开。 + + )} + /> - + - + - - - + +