codex/dev
mula.liu 2026-04-08 17:29:06 +08:00
parent aa99ee1f6a
commit ad16567e82
47 changed files with 1472 additions and 510 deletions

View File

@ -15,7 +15,7 @@ router = APIRouter()
redis_client = redis.Redis(**REDIS_CONFIG) 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 BYTES_TO_GB = 1024 ** 3
@ -79,7 +79,8 @@ def _calculate_audio_storage() -> Dict[str, float]:
if os.path.exists(AUDIO_DIR): if os.path.exists(AUDIO_DIR):
for root, _, files in os.walk(AUDIO_DIR): for root, _, files in os.walk(AUDIO_DIR):
for file in files: 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 audio_files_count += 1
file_path = os.path.join(root, file) file_path = os.path.join(root, file)
try: try:
@ -90,6 +91,7 @@ def _calculate_audio_storage() -> Dict[str, float]:
print(f"统计音频文件失败: {e}") print(f"统计音频文件失败: {e}")
return { return {
"audio_file_count": audio_files_count,
"audio_files_count": audio_files_count, "audio_files_count": audio_files_count,
"audio_total_size_gb": round(audio_total_size / BYTES_TO_GB, 2) "audio_total_size_gb": round(audio_total_size / BYTES_TO_GB, 2)
} }

View File

@ -357,6 +357,7 @@ async def create_parameter(request: ParameterUpsertRequest, current_user=Depends
), ),
) )
conn.commit() conn.commit()
SystemConfigService.invalidate_cache()
return create_api_response(code="200", message="创建参数成功") return create_api_response(code="200", message="创建参数成功")
except Exception as e: except Exception as e:
return create_api_response(code="500", message=f"创建参数失败: {str(e)}") return create_api_response(code="500", message=f"创建参数失败: {str(e)}")
@ -401,6 +402,7 @@ async def update_parameter(
), ),
) )
conn.commit() conn.commit()
SystemConfigService.invalidate_cache()
return create_api_response(code="200", message="更新参数成功") return create_api_response(code="200", message="更新参数成功")
except Exception as e: except Exception as e:
return create_api_response(code="500", message=f"更新参数失败: {str(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,)) cursor.execute("DELETE FROM sys_system_parameters WHERE param_key = %s", (param_key,))
conn.commit() conn.commit()
SystemConfigService.invalidate_cache()
return create_api_response(code="200", message="删除参数成功") return create_api_response(code="200", message="删除参数成功")
except Exception as e: except Exception as e:
return create_api_response(code="500", message=f"删除参数失败: {str(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( return create_api_response(
code="200", code="200",
message="获取公开配置成功", message="获取公开配置成功",
data=SystemConfigService.get_branding_config() data=SystemConfigService.get_public_configs()
) )
except Exception as e: except Exception as e:
return create_api_response(code="500", message=f"获取公开配置失败: {str(e)}") return create_api_response(code="500", message=f"获取公开配置失败: {str(e)}")

View File

@ -304,7 +304,7 @@ def get_meetings(
): ):
# 使用配置的默认页面大小 # 使用配置的默认页面大小
if page_size is None: 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: with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)

View File

@ -1,4 +1,6 @@
import json import json
import time
from threading import RLock
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from app.core.database import get_db_connection from app.core.database import get_db_connection
@ -7,18 +9,18 @@ class SystemConfigService:
"""系统配置服务 - 优先从新配置表读取,兼容 dict_data(system_config) 回退""" """系统配置服务 - 优先从新配置表读取,兼容 dict_data(system_config) 回退"""
DICT_TYPE = 'system_config' DICT_TYPE = 'system_config'
PUBLIC_CATEGORY = 'public'
DEFAULT_LLM_ENDPOINT_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1' DEFAULT_LLM_ENDPOINT_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
CACHE_TTL_SECONDS = 60
# 配置键常量 # 配置键常量
ASR_VOCABULARY_ID = 'asr_vocabulary_id' ASR_VOCABULARY_ID = 'asr_vocabulary_id'
TIMELINE_PAGESIZE = 'timeline_pagesize' PAGE_SIZE = 'page_size'
DEFAULT_RESET_PASSWORD = 'default_reset_password' DEFAULT_RESET_PASSWORD = 'default_reset_password'
MAX_AUDIO_SIZE = 'max_audio_size' MAX_AUDIO_SIZE = 'max_audio_size'
# 品牌配置 # 品牌配置
BRANDING_APP_NAME = 'branding_app_name' APP_NAME = 'app_name'
BRANDING_HOME_HEADLINE = 'branding_home_headline'
BRANDING_HOME_TAGLINE = 'branding_home_tagline'
BRANDING_CONSOLE_SUBTITLE = 'branding_console_subtitle' BRANDING_CONSOLE_SUBTITLE = 'branding_console_subtitle'
BRANDING_PREVIEW_TITLE = 'branding_preview_title' BRANDING_PREVIEW_TITLE = 'branding_preview_title'
BRANDING_LOGIN_WELCOME = 'branding_login_welcome' BRANDING_LOGIN_WELCOME = 'branding_login_welcome'
@ -36,6 +38,38 @@ class SystemConfigService:
LLM_TIMEOUT = 'llm_timeout' LLM_TIMEOUT = 'llm_timeout'
LLM_TEMPERATURE = 'llm_temperature' LLM_TEMPERATURE = 'llm_temperature'
LLM_TOP_P = 'llm_top_p' 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 @staticmethod
def _parse_json_object(value: Any) -> Dict[str, Any]: def _parse_json_object(value: Any) -> Dict[str, Any]:
@ -262,9 +296,14 @@ class SystemConfigService:
Returns: Returns:
配置项的值 配置项的值
""" """
cached_value = cls._get_cached_config(dict_code)
if cached_value is not None:
return cached_value
# 1) 新参数表 # 1) 新参数表
value = cls._get_parameter_value(dict_code) value = cls._get_parameter_value(dict_code)
if value is not None: if value is not None:
cls._set_cached_config(dict_code, value)
return value return value
# 2) 兼容旧 sys_dict_data # 2) 兼容旧 sys_dict_data
@ -284,10 +323,13 @@ class SystemConfigService:
if result and result['extension_attr']: if result and result['extension_attr']:
try: try:
ext_attr = json.loads(result['extension_attr']) if isinstance(result['extension_attr'], str) else result['extension_attr'] 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): except (json.JSONDecodeError, AttributeError):
pass pass
cls._set_cached_config(dict_code, default_value)
return default_value return default_value
except Exception as e: except Exception as e:
@ -410,6 +452,7 @@ class SystemConfigService:
) )
conn.commit() conn.commit()
cursor.close() cursor.close()
cls.invalidate_cache()
return True return True
except Exception as e: except Exception as e:
print(f"Error setting config in sys_system_parameters {dict_code}: {e}") print(f"Error setting config in sys_system_parameters {dict_code}: {e}")
@ -451,6 +494,7 @@ class SystemConfigService:
conn.commit() conn.commit()
cursor.close() cursor.close()
cls.invalidate_cache()
return True return True
except Exception as e: except Exception as e:
@ -465,6 +509,12 @@ class SystemConfigService:
Returns: Returns:
配置字典 {dict_code: value} 配置字典 {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) 新参数表 # 1) 新参数表
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
@ -480,7 +530,10 @@ class SystemConfigService:
rows = cursor.fetchall() rows = cursor.fetchall()
cursor.close() cursor.close()
if rows: 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: except Exception as e:
print(f"Error getting all configs from sys_system_parameters: {e}") print(f"Error getting all configs from sys_system_parameters: {e}")
@ -509,12 +562,46 @@ class SystemConfigService:
else: else:
configs[row['dict_code']] = None 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: except Exception as e:
print(f"Error getting all configs: {e}") print(f"Error getting all configs: {e}")
return {} 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 @classmethod
def batch_set_configs(cls, configs: Dict[str, Any]) -> bool: def batch_set_configs(cls, configs: Dict[str, Any]) -> bool:
""" """
@ -590,9 +677,9 @@ class SystemConfigService:
return default return default
@classmethod @classmethod
def get_timeline_pagesize(cls, default: int = 10) -> int: def get_page_size(cls, default: int = 10) -> int:
"""获取会议时间轴每页数量""" """获取系统通用分页数量。"""
value = cls.get_config(cls.TIMELINE_PAGESIZE, str(default)) value = cls.get_config(cls.PAGE_SIZE, str(default))
try: try:
return int(value) return int(value)
except (ValueError, TypeError): except (ValueError, TypeError):
@ -613,22 +700,59 @@ class SystemConfigService:
return default return default
@classmethod @classmethod
def get_branding_config(cls) -> Dict[str, str]: def get_public_configs(cls) -> Dict[str, Any]:
max_audio_size_mb = cls.get_max_audio_size(100) """获取提供给前端初始化使用的公开参数。"""
max_image_size_mb = cls.get_config("max_image_size", "10") 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: 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): except (ValueError, TypeError):
max_image_size_mb = 10 max_image_size_mb = 10
return { return {
"app_name": str(cls.get_config(cls.BRANDING_APP_NAME, "智听云平台") or "智听云平台"), **public_configs,
"home_headline": str(cls.get_config(cls.BRANDING_HOME_HEADLINE, "智听云平台") or "智听云平台"), "app_name": app_name,
"home_tagline": str(cls.get_config(cls.BRANDING_HOME_TAGLINE, "让每一次谈话都产生价值") or "让每一次谈话都产生价值"), "console_subtitle": console_subtitle,
"console_subtitle": str(cls.get_config(cls.BRANDING_CONSOLE_SUBTITLE, "iMeeting控制台") or "iMeeting控制台"), "preview_title": preview_title,
"preview_title": str(cls.get_config(cls.BRANDING_PREVIEW_TITLE, "会议预览") or "会议预览"), "login_welcome": login_welcome,
"login_welcome": str(cls.get_config(cls.BRANDING_LOGIN_WELCOME, "欢迎回来,请输入您的登录凭证。") or "欢迎回来,请输入您的登录凭证。"), "footer_text": footer_text,
"footer_text": str(cls.get_config(cls.BRANDING_FOOTER_TEXT, "©2026 智听云平台") or "©2026 智听云平台"), "page_size": str(page_size),
"PAGE_SIZE": page_size,
"max_audio_size": str(max_audio_size_mb), "max_audio_size": str(max_audio_size_mb),
"MAX_FILE_SIZE": max_audio_size_mb * 1024 * 1024, "MAX_FILE_SIZE": max_audio_size_mb * 1024 * 1024,
"max_image_size": str(max_image_size_mb), "max_image_size": str(max_image_size_mb),

View File

@ -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 (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 (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 (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 (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 (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'); 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');

View File

@ -41,11 +41,37 @@ CREATE TABLE IF NOT EXISTS `ai_model_configs` (
-- migrate system_config parameters except model-like records -- 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`) INSERT INTO `sys_system_parameters` (`param_key`, `param_name`, `param_value`, `value_type`, `category`, `description`, `is_active`)
SELECT 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`, d.`label_cn`,
JSON_UNQUOTE(JSON_EXTRACT(d.`extension_attr`, '$.value')), JSON_UNQUOTE(JSON_EXTRACT(d.`extension_attr`, '$.value')),
'string', '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`, ')'), CONCAT('migrated from dict_data.system_config(', d.`dict_code`, ')'),
CASE WHEN d.`status` = 1 THEN 1 ELSE 0 END CASE WHEN d.`status` = 1 THEN 1 ELSE 0 END
FROM `sys_dict_data` d FROM `sys_dict_data` d
@ -55,6 +81,7 @@ WHERE d.`dict_type` = 'system_config'
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`param_name` = VALUES(`param_name`), `param_name` = VALUES(`param_name`),
`param_value` = VALUES(`param_value`), `param_value` = VALUES(`param_value`),
`category` = VALUES(`category`),
`is_active` = VALUES(`is_active`); `is_active` = VALUES(`is_active`);
-- migrate llm model -- migrate llm model

View File

@ -534,11 +534,37 @@ CREATE TABLE IF NOT EXISTS `sys_user_mcp` (
INSERT INTO `sys_system_parameters` INSERT INTO `sys_system_parameters`
(`param_key`, `param_name`, `param_value`, `value_type`, `category`, `description`, `is_active`) (`param_key`, `param_name`, `param_value`, `value_type`, `category`, `description`, `is_active`)
SELECT 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`, d.`label_cn`,
JSON_UNQUOTE(JSON_EXTRACT(d.`extension_attr`, '$.value')), JSON_UNQUOTE(JSON_EXTRACT(d.`extension_attr`, '$.value')),
'string', '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`, ')'), CONCAT('migrated from dict_data.system_config(', d.`dict_code`, ')'),
CASE WHEN d.`status` = 1 THEN 1 ELSE 0 END CASE WHEN d.`status` = 1 THEN 1 ELSE 0 END
FROM `sys_dict_data` d FROM `sys_dict_data` d
@ -548,6 +574,7 @@ WHERE d.`dict_type` = 'system_config'
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`param_name` = VALUES(`param_name`), `param_name` = VALUES(`param_name`),
`param_value` = VALUES(`param_value`), `param_value` = VALUES(`param_value`),
`category` = VALUES(`category`),
`description` = VALUES(`description`), `description` = VALUES(`description`),
`is_active` = VALUES(`is_active`); `is_active` = VALUES(`is_active`);

View File

@ -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 允许的组织方式
项目可以采用以下任一方式:
- 按领域优先组织:`<domain>/<entry|application|infra|shared>`
- 按层优先组织:`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. 执行基线
后续所有新增功能、重构与代码审计,均以本文档为默认判断依据:
- 先判断职责边界是否正确
- 再判断依赖方向是否健康
- 再判断是否需要拆分或合并
- 最后才考虑目录美观、文件长短和风格一致性
当“看起来更模块化”和“真实可读、可改、可验证”发生冲突时,优先后者。

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>iMeeting - 智能会议助手</title> <title>智听云平台</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -19,6 +19,7 @@ import AccountSettings from './pages/AccountSettings';
import MeetingCenterPage from './pages/MeetingCenterPage'; import MeetingCenterPage from './pages/MeetingCenterPage';
import MainLayout from './components/MainLayout'; import MainLayout from './components/MainLayout';
import menuService from './services/menuService'; import menuService from './services/menuService';
import configService from './utils/configService';
import './App.css'; import './App.css';
import './styles/console-theme.css'; import './styles/console-theme.css';
@ -106,6 +107,20 @@ function App() {
setIsLoading(false); 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) => { const handleLogin = (authData) => {
if (authData) { if (authData) {
menuService.clearCache(); menuService.clearCache();

View File

@ -1,6 +1,5 @@
import React, { useState } from 'react'; import React from 'react';
import { Input } from 'antd'; import { Input } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
const ExpandSearchBox = ({ onSearch, placeholder = "搜索会议..." }) => { const ExpandSearchBox = ({ onSearch, placeholder = "搜索会议..." }) => {
return ( return (

View File

@ -105,7 +105,7 @@ const MainLayout = ({ children, user, onLogout }) => {
return roots return roots
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)) .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
.map(toMenuItem); .map(toMenuItem);
}, [navigate, onLogout, userMenus]); }, [navigate, userMenus]);
const flatMenuKeys = useMemo(() => { const flatMenuKeys = useMemo(() => {
const keys = []; const keys = [];

View File

@ -17,27 +17,27 @@ const MarkdownRenderer = ({ content, className = "", emptyMessage = "暂无内
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]} rehypePlugins={[rehypeRaw]}
components={{ components={{
h1: ({node, ...props}) => <Typography.Title level={1} style={{ marginTop: 24 }} {...props} />, h1: (props) => <Typography.Title level={1} style={{ marginTop: 24 }} {...props} />,
h2: ({node, ...props}) => <Typography.Title level={2} style={{ marginTop: 20 }} {...props} />, h2: (props) => <Typography.Title level={2} style={{ marginTop: 20 }} {...props} />,
h3: ({node, ...props}) => <Typography.Title level={3} style={{ marginTop: 16 }} {...props} />, h3: (props) => <Typography.Title level={3} style={{ marginTop: 16 }} {...props} />,
h4: ({node, ...props}) => <Typography.Title level={4} style={{ marginTop: 12 }} {...props} />, h4: (props) => <Typography.Title level={4} style={{ marginTop: 12 }} {...props} />,
p: ({node, ...props}) => <Paragraph style={{ marginBottom: 16 }} {...props} />, p: (props) => <Paragraph style={{ marginBottom: 16 }} {...props} />,
blockquote: ({node, ...props}) => ( blockquote: (props) => (
<blockquote style={{ <blockquote style={{
margin: '12px 0', padding: '8px 16px', margin: '12px 0', padding: '8px 16px',
borderLeft: '4px solid #1677ff', background: '#f0f5ff', borderLeft: '4px solid #1677ff', background: '#f0f5ff',
borderRadius: '0 8px 8px 0', color: '#444', borderRadius: '0 8px 8px 0', color: '#444',
}} {...props} /> }} {...props} />
), ),
li: ({node, ...props}) => <li style={{ marginBottom: 8 }} {...props} />, li: (props) => <li style={{ marginBottom: 8 }} {...props} />,
ul: ({node, ...props}) => <ul style={{ paddingLeft: 24, marginBottom: 16 }} {...props} />, ul: (props) => <ul style={{ paddingLeft: 24, marginBottom: 16 }} {...props} />,
ol: ({node, ...props}) => <ol style={{ paddingLeft: 24, marginBottom: 16 }} {...props} />, ol: (props) => <ol style={{ paddingLeft: 24, marginBottom: 16 }} {...props} />,
hr: ({node, ...props}) => <hr style={{ border: 'none', borderTop: '1px solid #e5e7eb', margin: '20px 0' }} {...props} />, hr: (props) => <hr style={{ border: 'none', borderTop: '1px solid #e5e7eb', margin: '20px 0' }} {...props} />,
strong: ({node, ...props}) => <strong style={{ fontWeight: 600 }} {...props} />, strong: (props) => <strong style={{ fontWeight: 600 }} {...props} />,
table: ({node, ...props}) => <table style={{ borderCollapse: 'collapse', width: '100%', marginBottom: 16 }} {...props} />, table: (props) => <table style={{ borderCollapse: 'collapse', width: '100%', marginBottom: 16 }} {...props} />,
th: ({node, ...props}) => <th style={{ border: '1px solid #d9d9d9', padding: '8px 12px', background: '#f5f5f5', fontWeight: 600 }} {...props} />, th: (props) => <th style={{ border: '1px solid #d9d9d9', padding: '8px 12px', background: '#f5f5f5', fontWeight: 600 }} {...props} />,
td: ({node, ...props}) => <td style={{ border: '1px solid #d9d9d9', padding: '8px 12px' }} {...props} />, td: (props) => <td style={{ border: '1px solid #d9d9d9', padding: '8px 12px' }} {...props} />,
code: ({node, inline, className, ...props}) => { code: ({ inline, className, ...props }) => {
if (inline) { if (inline) {
return <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4, fontSize: '0.9em', color: '#d63384' }} {...props} />; return <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4, fontSize: '0.9em', color: '#d63384' }} {...props} />;
} }

View File

@ -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 { Drawer, Form, Input, Button, DatePicker, Select, Space, App, Upload, Card, Progress, Typography } from 'antd';
import { SaveOutlined, UploadOutlined, DeleteOutlined, AudioOutlined } from '@ant-design/icons'; import { SaveOutlined, UploadOutlined, DeleteOutlined, AudioOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -13,7 +13,6 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
const { message } = App.useApp(); const { message } = App.useApp();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [prompts, setPrompts] = useState([]); const [prompts, setPrompts] = useState([]);
const [selectedAudioFile, setSelectedAudioFile] = useState(null); const [selectedAudioFile, setSelectedAudioFile] = useState(null);
@ -24,6 +23,44 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
const isEdit = Boolean(meetingId); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
fetchOptions(); fetchOptions();
@ -38,46 +75,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
setAudioUploadProgress(0); setAudioUploadProgress(0);
setAudioUploadMessage(''); setAudioUploadMessage('');
} }
}, [open, meetingId]); }, [fetchMeeting, fetchOptions, form, isEdit, loadAudioUploadConfig, open]);
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);
}
};
const handleAudioBeforeUpload = (file) => { const handleAudioBeforeUpload = (file) => {
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService)); const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));

View File

@ -12,7 +12,7 @@ const hasRenderableSize = (element) => {
return Number.isFinite(rect.width) && Number.isFinite(rect.height) && rect.width > 0 && rect.height > 0; 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 svgRef = useRef(null);
const markmapRef = useRef(null); const markmapRef = useRef(null);
const latestRootRef = useRef(null); const latestRootRef = useRef(null);

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React from 'react';
import { FloatButton } from 'antd'; import { FloatButton } from 'antd';
import { VerticalAlignTopOutlined } from '@ant-design/icons'; import { VerticalAlignTopOutlined } from '@ant-design/icons';

View File

@ -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 { Tag, Space, Typography, Skeleton, Empty } from 'antd';
import { TagsOutlined } from '@ant-design/icons'; import { TagsOutlined } from '@ant-design/icons';
import apiClient from '../utils/apiClient'; import apiClient from '../utils/apiClient';
@ -16,11 +16,7 @@ const TagCloud = ({
const [allTags, setAllTags] = useState([]); const [allTags, setAllTags] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { const fetchAllTags = useCallback(async () => {
fetchAllTags();
}, []);
const fetchAllTags = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST)); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST));
@ -31,7 +27,11 @@ const TagCloud = ({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [limitTags]);
useEffect(() => {
fetchAllTags();
}, [fetchAllTags]);
const handleTagClick = (tag) => { const handleTagClick = (tag) => {
if (onTagClick) { if (onTagClick) {

View File

@ -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 { Modal, Button, Typography, Space, Progress, App, Card, Alert } from 'antd';
import { import {
AudioOutlined, AudioOutlined,
@ -50,7 +50,7 @@ const VoiceprintCollectionModal = ({ isOpen, onClose, onSuccess, templateConfig
stopRecording(); stopRecording();
} }
}, 100); }, 100);
} catch (err) { } catch {
message.error('无法访问麦克风,请检查权限设置'); message.error('无法访问麦克风,请检查权限设置');
} }
}; };
@ -71,7 +71,7 @@ const VoiceprintCollectionModal = ({ isOpen, onClose, onSuccess, templateConfig
await onSuccess(formData); await onSuccess(formData);
message.success('声纹采集成功'); message.success('声纹采集成功');
onClose(); onClose();
} catch (err) { } catch {
message.error('上传失败,请重试'); message.error('上传失败,请重试');
} finally { } finally {
setLoading(false); setLoading(false);

View File

@ -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;

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
Alert, Alert,
App, App,
@ -82,13 +82,7 @@ const AccountSettings = ({ user, onUpdateUser }) => {
const [mcpConfig, setMcpConfig] = useState(null); const [mcpConfig, setMcpConfig] = useState(null);
const [mcpError, setMcpError] = useState(''); const [mcpError, setMcpError] = useState('');
useEffect(() => { const fetchUserData = useCallback(async () => {
if (!user?.user_id) return;
fetchUserData();
fetchMcpConfig();
}, [user?.user_id]);
const fetchUserData = async () => {
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id))); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
profileForm.setFieldsValue({ profileForm.setFieldsValue({
@ -101,9 +95,9 @@ const AccountSettings = ({ user, onUpdateUser }) => {
} catch (error) { } catch (error) {
message.error(error?.response?.data?.message || '获取用户资料失败'); message.error(error?.response?.data?.message || '获取用户资料失败');
} }
}; }, [message, profileForm, user.user_id]);
const fetchMcpConfig = async () => { const fetchMcpConfig = useCallback(async () => {
if (!user?.user_id) return; if (!user?.user_id) return;
setMcpLoading(true); setMcpLoading(true);
@ -117,7 +111,13 @@ const AccountSettings = ({ user, onUpdateUser }) => {
} finally { } finally {
setMcpLoading(false); setMcpLoading(false);
} }
}; }, [user?.user_id]);
useEffect(() => {
if (!user?.user_id) return;
fetchUserData();
fetchMcpConfig();
}, [fetchMcpConfig, fetchUserData, user?.user_id]);
const handleAvatarBeforeUpload = (file) => { const handleAvatarBeforeUpload = (file) => {
const isAllowedType = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type); const isAllowedType = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type);
@ -193,7 +193,7 @@ const AccountSettings = ({ user, onUpdateUser }) => {
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
message.success(`${label}已复制`); message.success(`${label}已复制`);
} catch (error) { } catch {
message.error(`${label}复制失败`); message.error(`${label}复制失败`);
} }
}; };

View File

@ -35,6 +35,7 @@
.admin-overview-icon { .admin-overview-icon {
width: 88px; width: 88px;
height: 88px; height: 88px;
flex: 0 0 88px;
border-radius: 24px; border-radius: 24px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -85,15 +86,26 @@
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
width: 100%; width: 100%;
min-width: 0;
} }
.admin-resource-row { .admin-resource-row {
display: grid; display: grid;
grid-template-columns: 18px 1fr auto; grid-template-columns: 112px 1fr auto;
align-items: center; align-items: center;
gap: 10px; 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 { .admin-resource-row .ant-progress {
margin: 0; margin: 0;
} }

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Card, Card,
Table, Table,
@ -27,7 +27,7 @@ import {
SearchOutlined, SearchOutlined,
DeploymentUnitOutlined, DeploymentUnitOutlined,
HddOutlined, HddOutlined,
ApartmentOutlined, DashboardOutlined,
FileTextOutlined, FileTextOutlined,
ReloadOutlined, ReloadOutlined,
PauseCircleOutlined, PauseCircleOutlined,
@ -38,6 +38,7 @@ import {
import apiClient from '../utils/apiClient'; import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import ActionButton from '../components/ActionButton'; import ActionButton from '../components/ActionButton';
import useSystemPageSize from '../hooks/useSystemPageSize';
import './AdminDashboard.css'; import './AdminDashboard.css';
const { Text } = Typography; const { Text } = Typography;
@ -75,6 +76,7 @@ const AdminDashboard = () => {
const [taskType, setTaskType] = useState('all'); const [taskType, setTaskType] = useState('all');
const [taskStatus, setTaskStatus] = useState('all'); const [taskStatus, setTaskStatus] = useState('all');
const pageSize = useSystemPageSize(10);
const [autoRefresh, setAutoRefresh] = useState(true); const [autoRefresh, setAutoRefresh] = useState(true);
const [countdown, setCountdown] = useState(AUTO_REFRESH_INTERVAL); const [countdown, setCountdown] = useState(AUTO_REFRESH_INTERVAL);
@ -90,31 +92,41 @@ const AdminDashboard = () => {
}; };
}, []); }, []);
useEffect(() => { const fetchStats = useCallback(async () => {
fetchAllData(); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS));
if (response.code === '200') setStats(response.data);
}, []); }, []);
useEffect(() => { const fetchOnlineUsers = useCallback(async () => {
if (!autoRefresh || showMeetingModal) return; const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS));
if (response.code === '200') setOnlineUsers(response.data.users || []);
}, []);
const timer = setInterval(() => { const fetchUsersList = useCallback(async () => {
setCountdown((prev) => { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS));
if (prev <= 1) { if (response.code === '200') setUsersList(response.data.users || []);
fetchAllData({ silent: true }); }, []);
return AUTO_REFRESH_INTERVAL;
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);
} }
return prev - 1; }, [taskStatus, taskType]);
});
}, 1000);
return () => clearInterval(timer); const fetchResources = useCallback(async () => {
}, [autoRefresh, showMeetingModal]); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES));
if (response.code === '200') setResources(response.data);
}, []);
useEffect(() => { const fetchAllData = useCallback(async ({ silent = false } = {}) => {
fetchTasks();
}, [taskType, taskStatus]);
const fetchAllData = async ({ silent = false } = {}) => {
if (inFlightRef.current) return; if (inFlightRef.current) return;
inFlightRef.current = true; inFlightRef.current = true;
@ -138,41 +150,31 @@ const AdminDashboard = () => {
setLoading(false); setLoading(false);
} }
} }
}; }, [fetchOnlineUsers, fetchResources, fetchStats, fetchTasks, fetchUsersList, message]);
const fetchStats = async () => { useEffect(() => {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS)); fetchAllData();
if (response.code === '200') setStats(response.data); }, [fetchAllData]);
};
const fetchOnlineUsers = async () => { useEffect(() => {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS)); if (!autoRefresh || showMeetingModal) return;
if (response.code === '200') setOnlineUsers(response.data.users || []);
};
const fetchUsersList = async () => { const timer = setInterval(() => {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS)); setCountdown((prev) => {
if (response.code === '200') setUsersList(response.data.users || []); if (prev <= 1) {
}; fetchAllData({ silent: true });
return AUTO_REFRESH_INTERVAL;
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 prev - 1;
});
}, 1000);
const fetchResources = async () => { return () => clearInterval(timer);
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES)); }, [autoRefresh, fetchAllData, showMeetingModal]);
if (response.code === '200') setResources(response.data);
}; useEffect(() => {
fetchTasks();
}, [fetchTasks]);
const handleKickUser = (u) => { const handleKickUser = (u) => {
modal.confirm({ modal.confirm({
@ -234,6 +236,33 @@ const AdminDashboard = () => {
return Math.round((completed / all) * 100); return Math.round((completed / all) * 100);
}, [tasks]); }, [tasks]);
const resourceRows = useMemo(() => ([
{
key: 'cpu',
label: 'CPU',
tooltip: resources?.cpu?.count ? `${resources.cpu.count} 核逻辑处理器` : 'CPU 使用率',
icon: <DashboardOutlined style={{ color: '#334155' }} />,
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: <DatabaseOutlined style={{ color: '#334155' }} />,
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: <HddOutlined style={{ color: '#334155' }} />,
percent: resources?.disk?.percent || 0,
strokeColor: { from: '#818cf8', to: '#6d28d9' },
},
]), [resources]);
const userColumns = [ const userColumns = [
{ title: 'ID', dataIndex: 'user_id', key: 'user_id', width: 80 }, { title: 'ID', dataIndex: 'user_id', key: 'user_id', width: 80 },
{ title: '用户名', dataIndex: 'username', key: 'username' }, { title: '用户名', dataIndex: 'username', key: 'username' },
@ -377,7 +406,7 @@ const AdminDashboard = () => {
<div> <div>
<div className="admin-overview-value compact">{Number(stats?.storage?.audio_total_size_gb || 0).toFixed(2)} GB</div> <div className="admin-overview-value compact">{Number(stats?.storage?.audio_total_size_gb || 0).toFixed(2)} GB</div>
<div className="admin-overview-meta"> <div className="admin-overview-meta">
<span>音频文件{stats?.storage?.audio_file_count || 0} </span> <span>音频文件{stats?.storage?.audio_files_count ?? stats?.storage?.audio_file_count ?? 0} </span>
<span>音频目录占用汇总</span> <span>音频目录占用汇总</span>
</div> </div>
</div> </div>
@ -390,21 +419,18 @@ const AdminDashboard = () => {
<div className="admin-overview-main"> <div className="admin-overview-main">
<span className="admin-overview-icon resources"><DeploymentUnitOutlined /></span> <span className="admin-overview-icon resources"><DeploymentUnitOutlined /></span>
<div className="admin-resource-list"> <div className="admin-resource-list">
<div className="admin-resource-row"> {resourceRows.map((resource) => (
<ApartmentOutlined style={{ color: '#334155' }} /> <div key={resource.key} className="admin-resource-row">
<Progress percent={resources?.cpu?.percent || 0} size="small" showInfo={false} strokeColor={{ from: '#60a5fa', to: '#2563eb' }} /> <Tooltip title={resource.tooltip}>
<span className="admin-resource-value">{formatResourcePercent(resources?.cpu?.percent)}</span> <div className="admin-resource-label">
{resource.icon}
<span>{resource.label}</span>
</div> </div>
<div className="admin-resource-row"> </Tooltip>
<DatabaseOutlined style={{ color: '#334155' }} /> <Progress percent={resource.percent} size="small" showInfo={false} strokeColor={resource.strokeColor} />
<Progress percent={resources?.memory?.percent || 0} size="small" showInfo={false} strokeColor={{ from: '#a78bfa', to: '#7c3aed' }} /> <span className="admin-resource-value">{formatResourcePercent(resource.percent)}</span>
<span className="admin-resource-value">{formatResourcePercent(resources?.memory?.percent)}</span>
</div>
<div className="admin-resource-row">
<HddOutlined style={{ color: '#334155' }} />
<Progress percent={resources?.disk?.percent || 0} size="small" showInfo={false} strokeColor={{ from: '#818cf8', to: '#6d28d9' }} />
<span className="admin-resource-value">{formatResourcePercent(resources?.disk?.percent)}</span>
</div> </div>
))}
</div> </div>
</div> </div>
</Card> </Card>
@ -457,7 +483,7 @@ const AdminDashboard = () => {
columns={taskColumns} columns={taskColumns}
dataSource={tasks} dataSource={tasks}
rowKey={(record) => `${record.task_type}-${record.task_id}`} rowKey={(record) => `${record.task_type}-${record.task_id}`}
pagination={{ pageSize: 6 }} pagination={{ pageSize }}
loading={taskLoading} loading={taskLoading}
scroll={{ x: 760 }} scroll={{ x: 760 }}
/> />
@ -472,7 +498,7 @@ const AdminDashboard = () => {
columns={userColumns} columns={userColumns}
dataSource={usersList} dataSource={usersList}
rowKey="user_id" rowKey="user_id"
pagination={{ pageSize: 10 }} pagination={{ pageSize }}
scroll={{ x: 760 }} scroll={{ x: 760 }}
/> />
</div> </div>
@ -481,7 +507,13 @@ const AdminDashboard = () => {
<Col xs={24} lg={8}> <Col xs={24} lg={8}>
<Card className="console-surface" title={`在线用户 (${onlineUsers.length})`}> <Card className="console-surface" title={`在线用户 (${onlineUsers.length})`}>
<div className="console-table"> <div className="console-table">
<Table columns={onlineUserColumns} dataSource={onlineUsers} rowKey="user_id" pagination={false} /> <Table
columns={onlineUserColumns}
dataSource={onlineUsers}
rowKey="user_id"
pagination={{ pageSize }}
scroll={{ x: 420 }}
/>
</div> </div>
<Divider style={{ margin: '16px 0 12px' }} /> <Divider style={{ margin: '16px 0 12px' }} />
<Text type="secondary">在线用户会话可在此直接踢出立即失效其 Token</Text> <Text type="secondary">在线用户会话可在此直接踢出立即失效其 Token</Text>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Table, Table,
Button, Button,
@ -40,6 +40,7 @@ import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import AdminModuleShell from '../components/AdminModuleShell'; import AdminModuleShell from '../components/AdminModuleShell';
import ActionButton from '../components/ActionButton'; import ActionButton from '../components/ActionButton';
import useSystemPageSize from '../hooks/useSystemPageSize';
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@ -74,16 +75,12 @@ const ClientManagement = () => {
const [activeTab, setActiveTab] = useState('all'); const [activeTab, setActiveTab] = useState('all');
const [uploadingFile, setUploadingFile] = useState(false); const [uploadingFile, setUploadingFile] = useState(false);
const [updatingStatusId, setUpdatingStatusId] = useState(null); const [updatingStatusId, setUpdatingStatusId] = useState(null);
const pageSize = useSystemPageSize(10);
const [platforms, setPlatforms] = useState({ tree: [], items: [] }); const [platforms, setPlatforms] = useState({ tree: [], items: [] });
const [platformsMap, setPlatformsMap] = useState({}); const [platformsMap, setPlatformsMap] = useState({});
useEffect(() => { const fetchPlatforms = useCallback(async () => {
fetchPlatforms();
fetchClients();
}, []);
const fetchPlatforms = async () => {
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform'))); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')));
if (response.code === '200') { if (response.code === '200') {
@ -99,9 +96,9 @@ const ClientManagement = () => {
} catch { } catch {
message.error('获取平台列表失败'); message.error('获取平台列表失败');
} }
}; }, [message]);
const fetchClients = async () => { const fetchClients = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST)); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST));
@ -113,9 +110,17 @@ const ClientManagement = () => {
} finally { } finally {
setLoading(false); 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) => { const openModal = (client = null) => {
if (client) { if (client) {
@ -295,7 +300,7 @@ const ClientManagement = () => {
client.download_url, client.download_url,
client.release_notes, client.release_notes,
].some((field) => String(field || '').toLowerCase().includes(query)); ].some((field) => String(field || '').toLowerCase().includes(query));
}), [activeTab, clients, searchQuery, statusFilter, platformsMap]); }), [activeTab, clients, getPlatformLabel, searchQuery, statusFilter]);
const publishedCount = clients.length; const publishedCount = clients.length;
const activeCount = clients.filter((item) => isTruthy(item.is_active)).length; const activeCount = clients.filter((item) => isTruthy(item.is_active)).length;
@ -448,7 +453,7 @@ const ClientManagement = () => {
rowKey="id" rowKey="id"
size="small" size="small"
scroll={{ x: 1040 }} scroll={{ x: 1040 }}
pagination={{ pageSize: 10, showTotal: (total) => `${total} 条记录` }} pagination={{ pageSize, showTotal: (total) => `${total} 条记录` }}
/> />
</div> </div>
</AdminModuleShell> </AdminModuleShell>

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
Card, Form, Input, Button, DatePicker, Select, Card, Form, Input, Button, DatePicker, Select,
Typography, App, Divider, Row, Col, Upload, Space, Progress Typography, App, Divider, Row, Col, Upload, Space, Progress
@ -29,34 +29,38 @@ const CreateMeeting = () => {
const [audioUploadMessage, setAudioUploadMessage] = useState(''); const [audioUploadMessage, setAudioUploadMessage] = useState('');
const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024); 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(() => { useEffect(() => {
fetchUsers(); fetchUsers();
fetchPrompts(); fetchPrompts();
loadAudioUploadConfig(); loadAudioUploadConfig();
}, []); }, [fetchPrompts, fetchUsers, 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);
}
};
const handleAudioBeforeUpload = (file) => { const handleAudioBeforeUpload = (file) => {
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService)); const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
App, App,
Avatar, Avatar,
@ -62,16 +62,6 @@ const Dashboard = ({ user }) => {
const [voiceprintTemplate, setVoiceprintTemplate] = useState(null); const [voiceprintTemplate, setVoiceprintTemplate] = useState(null);
const [voiceprintLoading, setVoiceprintLoading] = useState(true); const [voiceprintLoading, setVoiceprintLoading] = useState(true);
useEffect(() => {
fetchUserData();
fetchMeetingsStats();
fetchVoiceprintData();
}, [user.user_id]);
useEffect(() => {
fetchMeetings(1, false);
}, [selectedTags, filterType, searchQuery]);
useEffect(() => { useEffect(() => {
if (selectedTags.length > 0) { if (selectedTags.length > 0) {
setShowTagFilters(true); setShowTagFilters(true);
@ -120,7 +110,7 @@ const Dashboard = ({ user }) => {
}, {}) }, {})
), [meetings]); ), [meetings]);
const fetchVoiceprintData = async () => { const fetchVoiceprintData = useCallback(async () => {
try { try {
setVoiceprintLoading(true); setVoiceprintLoading(true);
const statusResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.STATUS(user.user_id))); const statusResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.STATUS(user.user_id)));
@ -132,9 +122,9 @@ const Dashboard = ({ user }) => {
} finally { } finally {
setVoiceprintLoading(false); setVoiceprintLoading(false);
} }
}; }, [user.user_id]);
const fetchMeetings = async (page = 1, isLoadMore = false) => { const fetchMeetings = useCallback(async (page = 1, isLoadMore = false) => {
try { try {
const filterKey = meetingCacheService.generateFilterKey(user.user_id, filterType, searchQuery, selectedTags); const filterKey = meetingCacheService.generateFilterKey(user.user_id, filterType, searchQuery, selectedTags);
@ -188,9 +178,9 @@ const Dashboard = ({ user }) => {
setLoading(false); setLoading(false);
setLoadingMore(false); setLoadingMore(false);
} }
}; }, [filterType, message, searchQuery, selectedTags, user.user_id]);
const fetchMeetingsStats = async () => { const fetchMeetingsStats = useCallback(async () => {
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.STATS), { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.STATS), {
params: { user_id: user.user_id }, params: { user_id: user.user_id },
@ -199,16 +189,26 @@ const Dashboard = ({ user }) => {
} catch (error) { } catch (error) {
console.error('Error fetching meetings stats:', error); console.error('Error fetching meetings stats:', error);
} }
}; }, [user.user_id]);
const fetchUserData = async () => { const fetchUserData = useCallback(async () => {
try { try {
const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id))); const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
setUserInfo(userResponse.data); setUserInfo(userResponse.data);
} catch { } catch {
message.error('获取用户信息失败'); message.error('获取用户信息失败');
} }
}; }, [message, user.user_id]);
useEffect(() => {
fetchUserData();
fetchMeetingsStats();
fetchVoiceprintData();
}, [fetchMeetingsStats, fetchUserData, fetchVoiceprintData]);
useEffect(() => {
fetchMeetings(1, false);
}, [fetchMeetings, selectedTags, filterType, searchQuery]);
const handleLoadMore = () => { const handleLoadMore = () => {
if (!loadingMore && pagination.has_more) { if (!loadingMore && pagination.has_more) {

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
Card, Form, Input, Button, Space, Typography, Card, Form, Input, Button, Space, Typography,
App, Divider, Skeleton App, Divider, Skeleton
@ -13,7 +13,7 @@ import MarkdownEditor from '../components/MarkdownEditor';
const { Title } = Typography; const { Title } = Typography;
const EditKnowledgeBase = ({ user }) => { const EditKnowledgeBase = () => {
const { kb_id } = useParams(); const { kb_id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { message } = App.useApp(); const { message } = App.useApp();
@ -21,20 +21,20 @@ const EditKnowledgeBase = ({ user }) => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { const fetchKbDetail = useCallback(async () => {
fetchKbDetail();
}, [kb_id]);
const fetchKbDetail = async () => {
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kb_id))); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kb_id)));
form.setFieldsValue(response.data); form.setFieldsValue(response.data);
} catch (error) { } catch {
message.error('加载知识库详情失败'); message.error('加载知识库详情失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [form, kb_id, message]);
useEffect(() => {
fetchKbDetail();
}, [fetchKbDetail]);
const onFinish = async (values) => { const onFinish = async (values) => {
setSaving(true); setSaving(true);
@ -42,7 +42,7 @@ const EditKnowledgeBase = ({ user }) => {
await apiClient.put(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.UPDATE(kb_id)), values); await apiClient.put(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.UPDATE(kb_id)), values);
message.success('更新成功'); message.success('更新成功');
navigate('/knowledge-base'); navigate('/knowledge-base');
} catch (error) { } catch {
message.error('更新失败'); message.error('更新失败');
} finally { } finally {
setSaving(false); setSaving(false);

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
Card, Form, Input, Button, DatePicker, Select, Card, Form, Input, Button, DatePicker, Select,
Typography, App, Divider, Row, Col Typography, App, Divider, Row, Col
@ -23,11 +23,7 @@ const EditMeeting = () => {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [prompts, setPrompts] = useState([]); const [prompts, setPrompts] = useState([]);
useEffect(() => { const fetchData = useCallback(async () => {
fetchData();
}, [meeting_id]);
const fetchData = async () => {
try { try {
const [uRes, pRes, mRes] = await Promise.all([ const [uRes, pRes, mRes] = await Promise.all([
apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)), 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), attendee_ids: meeting.attendee_ids || meeting.attendees.map(a => a.user_id).filter(Boolean),
tags: meeting.tags?.map(t => t.name) || [] tags: meeting.tags?.map(t => t.name) || []
}); });
} catch (e) { } catch {
message.error('加载会议数据失败'); message.error('加载会议数据失败');
} finally { } finally {
setFetching(false); setFetching(false);
} }
}; }, [form, meeting_id, message]);
useEffect(() => {
fetchData();
}, [fetchData]);
const onFinish = async (values) => { const onFinish = async (values) => {
setLoading(true); setLoading(true);

View File

@ -21,6 +21,7 @@ const BRAND_HIGHLIGHTS = [
'时间轴回看与全文检索', '时间轴回看与全文检索',
'沉淀可追踪的会议资产', '沉淀可追踪的会议资产',
]; ];
const HOME_TAGLINE = '让每一次谈话都产生价值';
const HomePage = ({ onLogin }) => { const HomePage = ({ onLogin }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -131,7 +132,7 @@ const HomePage = ({ onLogin }) => {
color: '#1677ff', color: '#1677ff',
}} }}
> >
{branding.home_headline} {branding.app_name}
</Title> </Title>
</div> </div>
@ -144,7 +145,7 @@ const HomePage = ({ onLogin }) => {
maxWidth: 560, maxWidth: 560,
}} }}
> >
{branding.home_tagline} {HOME_TAGLINE}
</Paragraph> </Paragraph>
<Paragraph <Paragraph

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
Layout, Card, Row, Col, Button, Space, Typography, Tag, Layout, Card, Row, Col, Button, Space, Typography, Tag,
Tooltip, Modal, Progress, Spin, App, Descriptions, Tooltip, Modal, Progress, Spin, App, Descriptions,
@ -12,14 +12,12 @@ import {
ClockCircleOutlined, UserOutlined, CalendarOutlined, ClockCircleOutlined, UserOutlined, CalendarOutlined,
CheckCircleOutlined, InfoCircleOutlined, ThunderboltOutlined CheckCircleOutlined, InfoCircleOutlined, ThunderboltOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import apiClient from '../utils/apiClient'; import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import ContentViewer from '../components/ContentViewer'; import ContentViewer from '../components/ContentViewer';
import ActionButton from '../components/ActionButton'; import ActionButton from '../components/ActionButton';
import exportService from '../services/exportService';
import tools from '../utils/tools'; import tools from '../utils/tools';
import meetingCacheService from '../services/meetingCacheService';
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
const { Sider, Content } = Layout; const { Sider, Content } = Layout;
@ -30,7 +28,6 @@ const KnowledgeBasePage = ({ user }) => {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedKb, setSelectedKb] = useState(null); const [selectedKb, setSelectedKb] = useState(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
@ -50,39 +47,29 @@ const KnowledgeBasePage = ({ user }) => {
const [selectedPromptId, setSelectedPromptId] = useState(null); const [selectedPromptId, setSelectedPromptId] = useState(null);
const [taskProgress, setTaskProgress] = useState(0); const [taskProgress, setTaskProgress] = useState(0);
useEffect(() => { const loadKbDetail = useCallback(async (id) => {
fetchAllKbs(); try {
fetchAvailableTags(); const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(id)));
}, []); setSelectedKb(res.data);
} catch {
useEffect(() => { message.error('加载知识库详情失败');
if (showCreateForm && createStep === 0) {
fetchMeetings(meetingsPagination.page);
} }
}, [searchQuery, selectedTags, showCreateForm, createStep, meetingsPagination.page]); }, [message]);
const fetchAllKbs = async () => { const fetchAllKbs = useCallback(async () => {
setLoading(true);
try { try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.LIST)); 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); setKbs(sorted);
if (sorted.length > 0 && !selectedKb) { if (sorted.length > 0 && !selectedKb) {
loadKbDetail(sorted[0].kb_id); loadKbDetail(sorted[0].kb_id);
} }
} finally { } catch {
setLoading(false); message.error('加载知识库列表失败');
} }
}; }, [loadKbDetail, message, selectedKb]);
const loadKbDetail = async (id) => { const fetchMeetings = useCallback(async (page = 1) => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(id)));
setSelectedKb(res.data);
} catch (e) {}
};
const fetchMeetings = async (page = 1) => {
setLoadingMeetings(true); setLoadingMeetings(true);
try { try {
const params = { const params = {
@ -94,26 +81,43 @@ const KnowledgeBasePage = ({ user }) => {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params }); const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params });
setMeetings(res.data.meetings || []); setMeetings(res.data.meetings || []);
setMeetingsPagination({ page: res.data.page, total: res.data.total }); setMeetingsPagination({ page: res.data.page, total: res.data.total });
} catch {
message.error('获取会议列表失败');
} finally { } finally {
setLoadingMeetings(false); setLoadingMeetings(false);
} }
}; }, [message, searchQuery, selectedTags, user.user_id]);
const fetchAvailableTags = async () => { const fetchAvailableTags = useCallback(async () => {
try { try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST)); const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST));
setAvailableTags(res.data?.slice(0, 10) || []); setAvailableTags(res.data?.slice(0, 10) || []);
} catch (e) {} } catch {
}; setAvailableTags([]);
}
}, []);
const fetchPrompts = async () => { const fetchPrompts = useCallback(async () => {
try { try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('KNOWLEDGE_TASK'))); const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('KNOWLEDGE_TASK')));
setAvailablePrompts(res.data.prompts || []); setAvailablePrompts(res.data.prompts || []);
const def = res.data.prompts?.find(p => p.is_default) || res.data.prompts?.[0]; const def = res.data.prompts?.find(p => p.is_default) || res.data.prompts?.[0];
if (def) setSelectedPromptId(def.id); 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 = () => { const handleStartCreate = () => {
setShowCreateForm(true); setShowCreateForm(true);
@ -122,7 +126,7 @@ const KnowledgeBasePage = ({ user }) => {
fetchPrompts(); fetchPrompts();
}; };
const handleGenerate = async () => { const handleGenerate = useCallback(async () => {
setGenerating(true); setGenerating(true);
setTaskProgress(10); setTaskProgress(10);
try { try {
@ -149,10 +153,11 @@ const KnowledgeBasePage = ({ user }) => {
message.error('生成失败'); message.error('生成失败');
} }
}, 3000); }, 3000);
} catch (e) { } catch {
setGenerating(false); setGenerating(false);
message.error('创建知识库任务失败');
} }
}; }, [fetchAllKbs, message, selectedMeetings, selectedPromptId, userPrompt]);
const handleDelete = (kb) => { const handleDelete = (kb) => {
modal.confirm({ modal.confirm({

View File

@ -31,6 +31,7 @@ import CenterPager from '../components/CenterPager';
import MeetingFormDrawer from '../components/MeetingFormDrawer'; import MeetingFormDrawer from '../components/MeetingFormDrawer';
import meetingCacheService from '../services/meetingCacheService'; import meetingCacheService from '../services/meetingCacheService';
import tools from '../utils/tools'; import tools from '../utils/tools';
import useSystemPageSize from '../hooks/useSystemPageSize';
import './MeetingCenterPage.css'; import './MeetingCenterPage.css';
import '../components/MeetingInfoCard.css'; import '../components/MeetingInfoCard.css';
@ -101,7 +102,7 @@ const MeetingCenterPage = ({ user }) => {
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [filterType, setFilterType] = useState('all'); const [filterType, setFilterType] = useState('all');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize] = useState(8); const { pageSize, isReady: pageSizeReady } = useSystemPageSize(10, { suspendUntilReady: true });
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [formDrawerOpen, setFormDrawerOpen] = useState(false); const [formDrawerOpen, setFormDrawerOpen] = useState(false);
const [editingMeetingId, setEditingMeetingId] = useState(null); const [editingMeetingId, setEditingMeetingId] = useState(null);
@ -149,8 +150,15 @@ const MeetingCenterPage = ({ user }) => {
}, [filterType, keyword, message, page, pageSize, user.user_id]); }, [filterType, keyword, message, page, pageSize, user.user_id]);
useEffect(() => { useEffect(() => {
meetingCacheService.clearAll();
}, [pageSize]);
useEffect(() => {
if (!pageSizeReady || !pageSize) {
return;
}
loadMeetings(page, keyword, filterType); loadMeetings(page, keyword, filterType);
}, [filterType, keyword, loadMeetings, page]); }, [filterType, keyword, loadMeetings, page, pageSize, pageSizeReady]);
useEffect(() => { useEffect(() => {
if (location.state?.openCreate) { if (location.state?.openCreate) {
@ -236,7 +244,11 @@ const MeetingCenterPage = ({ user }) => {
variant="borderless" variant="borderless"
styles={{ body: { padding: '20px' } }} styles={{ body: { padding: '20px' } }}
> >
{meetings.length ? ( {!pageSizeReady ? (
<div className="meeting-center-empty">
<Empty description="加载分页配置中..." />
</div>
) : meetings.length ? (
<> <>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{meetings.map((meeting) => { {meetings.map((meeting) => {
@ -351,7 +363,7 @@ const MeetingCenterPage = ({ user }) => {
<CenterPager <CenterPager
current={page} current={page}
total={total} total={total}
pageSize={pageSize} pageSize={pageSize || 10}
onChange={setPage} onChange={setPage}
/> />
</> </>

View File

@ -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(() => { useEffect(() => {
fetchMeetingDetails(); fetchMeetingDetails();
fetchTranscript(); fetchTranscript();
@ -168,17 +170,18 @@ const MeetingDetails = ({ user }) => {
if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current); if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current);
if (summaryBootstrapTimeoutRef.current) clearTimeout(summaryBootstrapTimeoutRef.current); if (summaryBootstrapTimeoutRef.current) clearTimeout(summaryBootstrapTimeoutRef.current);
}; };
}, [meeting_id]); }, [meeting_id]); // eslint-disable-line react-hooks/exhaustive-deps
const loadAudioUploadConfig = async () => { const loadAudioUploadConfig = async () => {
try { try {
const nextMaxAudioSize = await configService.getMaxAudioSize(); const nextMaxAudioSize = await configService.getMaxAudioSize();
setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024); setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024);
} catch (error) { } catch {
setMaxAudioSize(100 * 1024 * 1024); setMaxAudioSize(100 * 1024 * 1024);
} }
}; };
// Summary resources are loaded lazily when the drawer opens; the existing prompt/model caches gate repeat fetches.
useEffect(() => { useEffect(() => {
if (!showSummaryDrawer) { if (!showSummaryDrawer) {
return; return;
@ -189,7 +192,7 @@ const MeetingDetails = ({ user }) => {
} }
fetchSummaryResources(); fetchSummaryResources();
}, [showSummaryDrawer]); }, [showSummaryDrawer]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
transcriptRefs.current = []; transcriptRefs.current = [];

View File

@ -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 { useParams, Link } from 'react-router-dom';
import { Layout, Space, Button, App, Empty, Input, Tabs } from 'antd'; import { Layout, Space, Button, App, Empty, Input, Tabs } from 'antd';
import { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons'; import { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons';
@ -36,22 +36,7 @@ const MeetingPreview = () => {
configService.getBrandingConfig().then(setBranding).catch(() => {}); configService.getBrandingConfig().then(setBranding).catch(() => {});
}, []); }, []);
useEffect(() => { const fetchTranscriptAndAudio = useCallback(async () => {
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 [transcriptRes, audioRes] = await Promise.allSettled([ const [transcriptRes, audioRes] = await Promise.allSettled([
apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))), apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))),
apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id))), apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id))),
@ -68,9 +53,9 @@ const MeetingPreview = () => {
} else { } else {
setAudioUrl(''); setAudioUrl('');
} }
}; }, [meeting_id]);
const fetchPreview = async (pwd = '') => { const fetchPreview = useCallback(async (pwd = '') => {
setLoading(true); setLoading(true);
try { try {
const endpoint = API_ENDPOINTS.MEETINGS.PREVIEW_DATA(meeting_id); const endpoint = API_ENDPOINTS.MEETINGS.PREVIEW_DATA(meeting_id);
@ -100,7 +85,22 @@ const MeetingPreview = () => {
} finally { } finally {
setLoading(false); 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 () => { const handleCopyLink = async () => {
await navigator.clipboard.writeText(window.location.href); await navigator.clipboard.writeText(window.location.href);

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
App, App,
Button, Button,
@ -46,7 +46,7 @@ const PromptConfigPage = ({ user }) => {
const [selectedPromptIds, setSelectedPromptIds] = useState([]); const [selectedPromptIds, setSelectedPromptIds] = useState([]);
const [viewingPrompt, setViewingPrompt] = useState(null); const [viewingPrompt, setViewingPrompt] = useState(null);
const loadConfig = async () => { const loadConfig = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.CONFIG(taskType))); const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.CONFIG(taskType)));
@ -57,11 +57,11 @@ const PromptConfigPage = ({ user }) => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [message, taskType]);
useEffect(() => { useEffect(() => {
loadConfig(); loadConfig();
}, [taskType]); }, [loadConfig]);
const selectedPromptCards = useMemo(() => { const selectedPromptCards = useMemo(() => {
const map = new Map(availablePrompts.map((item) => [item.id, item])); const map = new Map(availablePrompts.map((item) => [item.id, item]));

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
App, App,
Button, Button,
@ -32,6 +32,7 @@ import MarkdownEditor from '../components/MarkdownEditor';
import CenterPager from '../components/CenterPager'; import CenterPager from '../components/CenterPager';
import StatusTag from '../components/StatusTag'; import StatusTag from '../components/StatusTag';
import ActionButton from '../components/ActionButton'; import ActionButton from '../components/ActionButton';
import useSystemPageSize from '../hooks/useSystemPageSize';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -49,7 +50,7 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => {
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [size] = useState(12); const size = useSystemPageSize(10);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [prompts, setPrompts] = useState([]); const [prompts, setPrompts] = useState([]);
@ -67,7 +68,7 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => {
? '仅维护你自己创建的提示词,供个人配置启用。' ? '仅维护你自己创建的提示词,供个人配置启用。'
: '系统管理员可管理系统提示词,普通用户管理自己的提示词。'; : '系统管理员可管理系统提示词,普通用户管理自己的提示词。';
const loadPrompts = async () => { const loadPrompts = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const is_active = statusFilter === 'all' ? undefined : Number(statusFilter); const is_active = statusFilter === 'all' ? undefined : Number(statusFilter);
@ -88,11 +89,11 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [keyword, message, page, scope, size, statusFilter, taskType]);
useEffect(() => { useEffect(() => {
loadPrompts(); loadPrompts();
}, [taskType, statusFilter, page]); }, [loadPrompts]);
const openCreateDrawer = () => { const openCreateDrawer = () => {
setEditingPrompt(null); setEditingPrompt(null);

View File

@ -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 { Tree, Button, Form, Input, InputNumber, Select, Switch, Space, Card, Empty, Popconfirm, App, Row, Col, Typography, Checkbox } from 'antd';
import { import {
BookOutlined, BookOutlined,
@ -17,7 +17,6 @@ const { Title, Text } = Typography;
const DictManagement = () => { const DictManagement = () => {
const { message } = App.useApp(); const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const [dictTypes, setDictTypes] = useState([]); // const [dictTypes, setDictTypes] = useState([]); //
const [selectedDictType, setSelectedDictType] = useState('client_platform'); // const [selectedDictType, setSelectedDictType] = useState('client_platform'); //
const [dictData, setDictData] = useState([]); // const [dictData, setDictData] = useState([]); //
@ -27,7 +26,7 @@ const DictManagement = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
// //
const fetchDictTypes = async () => { const fetchDictTypes = useCallback(async () => {
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.TYPES)); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.TYPES));
if (response.code === '200') { if (response.code === '200') {
@ -37,14 +36,29 @@ const DictManagement = () => {
setSelectedDictType(types[0] || ''); setSelectedDictType(types[0] || '');
} }
} }
} catch (error) { } catch {
message.error('获取字典类型失败'); message.error('获取字典类型失败');
} }
}; }, [message, selectedDictType]);
// //
const fetchDictData = async (dictType) => { // antd Tree
setLoading(true); const buildAntdTreeData = useCallback((tree) => {
return tree.map(node => ({
title: (
<Space>
{node.parent_code === 'ROOT' ? <FolderOpenOutlined /> : <FileOutlined />}
<span>{node.label_cn}</span>
<Text type="secondary" size="small">({node.dict_code})</Text>
</Space>
),
key: node.dict_code,
data: node,
children: node.children && node.children.length > 0 ? buildAntdTreeData(node.children) : []
}));
}, []);
const fetchDictData = useCallback(async (dictType) => {
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE(dictType))); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE(dictType)));
if (response.code === '200') { if (response.code === '200') {
@ -59,38 +73,20 @@ const DictManagement = () => {
setIsEditing(false); setIsEditing(false);
form.resetFields(); form.resetFields();
} }
} catch (error) { } catch {
message.error('获取字典数据失败'); message.error('获取字典数据失败');
} finally {
setLoading(false);
} }
}; }, [buildAntdTreeData, form, message]);
// antd Tree
const buildAntdTreeData = (tree) => {
return tree.map(node => ({
title: (
<Space>
{node.parent_code === 'ROOT' ? <FolderOpenOutlined /> : <FileOutlined />}
<span>{node.label_cn}</span>
<Text type="secondary" size="small">({node.dict_code})</Text>
</Space>
),
key: node.dict_code,
data: node,
children: node.children && node.children.length > 0 ? buildAntdTreeData(node.children) : []
}));
};
useEffect(() => { useEffect(() => {
fetchDictTypes(); fetchDictTypes();
}, []); }, [fetchDictTypes]);
useEffect(() => { useEffect(() => {
if (selectedDictType) { if (selectedDictType) {
fetchDictData(selectedDictType); fetchDictData(selectedDictType);
} }
}, [selectedDictType]); }, [fetchDictData, selectedDictType]);
// //
const handleSelectNode = (selectedKeys, info) => { const handleSelectNode = (selectedKeys, info) => {
@ -137,7 +133,7 @@ const DictManagement = () => {
if (values.extension_attr) { if (values.extension_attr) {
try { try {
values.extension_attr = JSON.parse(values.extension_attr); values.extension_attr = JSON.parse(values.extension_attr);
} catch (e) { } catch {
message.error('扩展属性 JSON 格式错误'); message.error('扩展属性 JSON 格式错误');
return; return;
} }
@ -167,9 +163,9 @@ const DictManagement = () => {
fetchDictData(selectedDictType); fetchDictData(selectedDictType);
} }
} }
} catch (error) { } catch (err) {
if (!error.errorFields) { if (!err.errorFields) {
message.error(error.response?.data?.message || '操作失败'); message.error(err.response?.data?.message || '操作失败');
} }
} }
}; };
@ -184,7 +180,7 @@ const DictManagement = () => {
setIsEditing(false); setIsEditing(false);
form.resetFields(); form.resetFields();
fetchDictData(selectedDictType); fetchDictData(selectedDictType);
} catch (error) { } catch {
message.error('删除失败'); message.error('删除失败');
} }
}; };

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Table, Table,
Button, Button,
@ -39,6 +39,7 @@ import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell'; import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton'; import ActionButton from '../../components/ActionButton';
import useSystemPageSize from '../../hooks/useSystemPageSize';
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@ -74,6 +75,7 @@ const getAppEntryUrl = (app) => {
const ExternalAppManagement = () => { const ExternalAppManagement = () => {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const [form] = Form.useForm(); const [form] = Form.useForm();
const pageSize = useSystemPageSize(10);
const [apps, setApps] = useState([]); const [apps, setApps] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -85,11 +87,7 @@ const ExternalAppManagement = () => {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
useEffect(() => { const fetchApps = useCallback(async () => {
fetchApps();
}, []);
const fetchApps = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.LIST)); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.LIST));
@ -101,7 +99,11 @@ const ExternalAppManagement = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [message]);
useEffect(() => {
fetchApps();
}, [fetchApps]);
const handleOpenModal = (app = null) => { const handleOpenModal = (app = null) => {
if (app) { if (app) {
@ -411,7 +413,7 @@ const ExternalAppManagement = () => {
loading={loading} loading={loading}
rowKey="id" rowKey="id"
scroll={{ x: 980 }} scroll={{ x: 980 }}
pagination={{ pageSize: 10, showTotal: (count) => `${count} 条记录` }} pagination={{ pageSize, showTotal: (count) => `${count} 条记录` }}
/> />
</div> </div>
</AdminModuleShell> </AdminModuleShell>

View File

@ -10,10 +10,10 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import apiClient from '../../utils/apiClient'; import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import configService from '../../utils/configService';
import AdminModuleShell from '../../components/AdminModuleShell'; import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton'; import ActionButton from '../../components/ActionButton';
import StatusTag from '../../components/StatusTag'; import StatusTag from '../../components/StatusTag';
import useSystemPageSize from '../../hooks/useSystemPageSize';
const { Text } = Typography; const { Text } = Typography;
@ -48,7 +48,7 @@ const HotWordManagement = () => {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [langFilter, setLangFilter] = useState('all'); const [langFilter, setLangFilter] = useState('all');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const pageSize = useSystemPageSize(10);
// Fetch groups // Fetch groups
const fetchGroups = useCallback(async () => { const fetchGroups = useCallback(async () => {
@ -82,7 +82,6 @@ const HotWordManagement = () => {
}, [message]); }, [message]);
useEffect(() => { useEffect(() => {
configService.getPageSize().then((size) => setPageSize(size));
fetchGroups(); fetchGroups();
}, [fetchGroups]); }, [fetchGroups]);
@ -442,8 +441,7 @@ const HotWordManagement = () => {
pagination={{ pagination={{
current: page, pageSize, current: page, pageSize,
total: filteredItems.length, total: filteredItems.length,
onChange: (p, s) => { setPage(p); setPageSize(s); }, onChange: (p) => { setPage(p); },
showSizeChanger: true,
showTotal: (count) => `${count}`, showTotal: (count) => `${count}`,
}} }}
/> />

View File

@ -32,6 +32,7 @@ import {
import AdminModuleShell from '../../components/AdminModuleShell'; import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton'; import ActionButton from '../../components/ActionButton';
import StatusTag from '../../components/StatusTag'; import StatusTag from '../../components/StatusTag';
import useSystemPageSize from '../../hooks/useSystemPageSize';
const AUDIO_SCENE_OPTIONS = [ const AUDIO_SCENE_OPTIONS = [
{ label: '全部', value: 'all' }, { label: '全部', value: 'all' },
@ -67,6 +68,7 @@ const ModelManagement = () => {
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [audioSceneFilter, setAudioSceneFilter] = useState('all'); const [audioSceneFilter, setAudioSceneFilter] = useState('all');
const [hotWordGroups, setHotWordGroups] = useState([]); const [hotWordGroups, setHotWordGroups] = useState([]);
const pageSize = useSystemPageSize(10);
const [form] = Form.useForm(); const [form] = Form.useForm();
const watchedScene = Form.useWatch('audio_scene', form); const watchedScene = Form.useWatch('audio_scene', form);
const watchedProvider = Form.useWatch('provider', form); const watchedProvider = Form.useWatch('provider', form);
@ -478,7 +480,7 @@ const ModelManagement = () => {
columns={llmColumns} columns={llmColumns}
dataSource={llmItems} dataSource={llmItems}
loading={loading} loading={loading}
pagination={{ pageSize: 10 }} pagination={{ pageSize }}
scroll={{ x: 1100 }} scroll={{ x: 1100 }}
/> />
</div> </div>
@ -512,7 +514,7 @@ const ModelManagement = () => {
columns={audioColumns} columns={audioColumns}
dataSource={filteredAudioItems} dataSource={filteredAudioItems}
loading={loading} loading={loading}
pagination={{ pageSize: 10 }} pagination={{ pageSize }}
scroll={{ x: 1100 }} scroll={{ x: 1100 }}
/> />
</div> </div>

View File

@ -1,11 +1,36 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { App, Button, Drawer, Form, Input, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip } from 'antd'; 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 { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, SettingOutlined } from '@ant-design/icons';
import apiClient from '../../utils/apiClient'; import apiClient from '../../utils/apiClient';
import { API_ENDPOINTS, buildApiUrl } from '../../config/api'; import { API_ENDPOINTS, buildApiUrl } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell'; import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton'; import ActionButton from '../../components/ActionButton';
import StatusTag from '../../components/StatusTag'; 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 ParameterManagement = () => {
const { message } = App.useApp(); const { message } = App.useApp();
@ -15,8 +40,21 @@ const ParameterManagement = () => {
const [editing, setEditing] = useState(null); const [editing, setEditing] = useState(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm(); 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); setLoading(true);
try { try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETERS)); const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETERS));
@ -26,11 +64,11 @@ const ParameterManagement = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [message]);
useEffect(() => { useEffect(() => {
fetchItems(); fetchItems();
}, []); }, [fetchItems]);
const openCreate = () => { const openCreate = () => {
setEditing(null); setEditing(null);
@ -66,6 +104,7 @@ const ParameterManagement = () => {
await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETERS), values); await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETERS), values);
message.success('参数创建成功'); message.success('参数创建成功');
} }
configService.clearCache();
setDrawerOpen(false); setDrawerOpen(false);
fetchItems(); fetchItems();
} catch (error) { } catch (error) {
@ -80,7 +119,17 @@ const ParameterManagement = () => {
{ title: '参数名', dataIndex: 'param_name', key: 'param_name', width: 180 }, { title: '参数名', dataIndex: 'param_name', key: 'param_name', width: 180 },
{ title: '值', dataIndex: 'param_value', key: 'param_value' }, { title: '值', dataIndex: 'param_value', key: 'param_value' },
{ title: '类型', dataIndex: 'value_type', key: 'value_type', width: 100, render: (v) => <Tag>{v}</Tag> }, { title: '类型', dataIndex: 'value_type', key: 'value_type', width: 100, render: (v) => <Tag>{v}</Tag> },
{ title: '分类', dataIndex: 'category', key: 'category', width: 120 }, {
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120,
render: (value) => (
<Tag color={value === 'public' ? 'blue' : 'default'}>
{value || 'system'}
</Tag>
),
},
{ {
title: '状态', title: '状态',
dataIndex: 'is_active', dataIndex: 'is_active',
@ -104,6 +153,7 @@ const ParameterManagement = () => {
onConfirm={async () => { onConfirm={async () => {
try { try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETER_DELETE(row.param_key))); await apiClient.delete(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETER_DELETE(row.param_key)));
configService.clearCache();
message.success('参数删除成功'); message.success('参数删除成功');
fetchItems(); fetchItems();
} catch (error) { } catch (error) {
@ -141,7 +191,7 @@ const ParameterManagement = () => {
columns={columns} columns={columns}
dataSource={items} dataSource={items}
loading={loading} loading={loading}
pagination={{ pageSize: 10, showTotal: (count) => `${count}` }} pagination={{ pageSize, showTotal: (count) => `${count}` }}
scroll={{ x: 1100 }} scroll={{ x: 1100 }}
/> />
</div> </div>
@ -160,20 +210,37 @@ const ParameterManagement = () => {
)} )}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="参数分类说明"
description={(
<Space direction="vertical" size={4}>
<Text>`public`前端可读取适合页面交互参数</Text>
<Text>`system`仅后端内部使用不对前端公开</Text>
</Space>
)}
/>
<Form.Item name="param_key" label="参数键" rules={[{ required: true, message: '请输入参数键' }]}> <Form.Item name="param_key" label="参数键" rules={[{ required: true, message: '请输入参数键' }]}>
<Input placeholder="timeline_pagesize" disabled={Boolean(editing)} /> <Input placeholder="page_size" disabled={Boolean(editing)} />
</Form.Item> </Form.Item>
<Form.Item name="param_name" label="参数名" rules={[{ required: true, message: '请输入参数名' }]}> <Form.Item name="param_name" label="参数名" rules={[{ required: true, message: '请输入参数名' }]}>
<Input placeholder="会议时间轴分页大小" /> <Input placeholder="通用分页数量" />
</Form.Item> </Form.Item>
<Form.Item name="param_value" label="参数值" rules={[{ required: true, message: '请输入参数值' }]}> <Form.Item name="param_value" label="参数值" rules={[{ required: true, message: '请输入参数值' }]}>
<Input.TextArea rows={3} /> <Input.TextArea rows={3} />
</Form.Item> </Form.Item>
<Form.Item name="value_type" label="值类型" rules={[{ required: true, message: '请选择值类型' }]}> <Form.Item name="value_type" label="值类型" rules={[{ required: true, message: '请选择值类型' }]}>
<Select options={[{ label: 'string', value: 'string' }, { label: 'number', value: 'number' }, { label: 'json', value: 'json' }]} /> <Select options={VALUE_TYPE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="category" label="分类"> <Form.Item
<Input placeholder="system" /> name="category"
label="分类"
rules={[{ required: true, message: '请选择分类' }]}
extra={categorySuggestion}
>
<Select options={CATEGORY_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="description" label="描述"> <Form.Item name="description" label="描述">
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Table, Table,
Button, Button,
@ -42,6 +42,7 @@ import ActionButton from '../../components/ActionButton';
import apiClient from '../../utils/apiClient'; import apiClient from '../../utils/apiClient';
import { buildApiUrl } from '../../config/api'; import { buildApiUrl } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell'; import AdminModuleShell from '../../components/AdminModuleShell';
import useSystemPageSize from '../../hooks/useSystemPageSize';
import { MENU_ICON_OPTIONS, renderMenuIcon } from '../../utils/menuIcons'; import { MENU_ICON_OPTIONS, renderMenuIcon } from '../../utils/menuIcons';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@ -67,6 +68,7 @@ const PermissionManagement = () => {
const [roleUsersTotal, setRoleUsersTotal] = useState(0); const [roleUsersTotal, setRoleUsersTotal] = useState(0);
const [roleUsersPage, setRoleUsersPage] = useState(1); const [roleUsersPage, setRoleUsersPage] = useState(1);
const [roleUsersPageSize, setRoleUsersPageSize] = useState(10); const [roleUsersPageSize, setRoleUsersPageSize] = useState(10);
const systemPageSize = useSystemPageSize(10);
const [roleUsersLoading, setRoleUsersLoading] = useState(false); const [roleUsersLoading, setRoleUsersLoading] = useState(false);
const [roleDetailTab, setRoleDetailTab] = useState('role-permissions'); const [roleDetailTab, setRoleDetailTab] = useState('role-permissions');
const [menuKeyword, setMenuKeyword] = useState(''); const [menuKeyword, setMenuKeyword] = useState('');
@ -93,6 +95,11 @@ const PermissionManagement = () => {
const [menuForm] = Form.useForm(); const [menuForm] = Form.useForm();
const selectedMenuIconValue = Form.useWatch('menu_icon', menuForm); const selectedMenuIconValue = Form.useWatch('menu_icon', menuForm);
useEffect(() => {
setRoleUsersPageSize(systemPageSize);
setRoleUsersPage(1);
}, [systemPageSize]);
const orderedMenus = useMemo(() => { const orderedMenus = useMemo(() => {
if (!menus.length) return []; if (!menus.length) return [];
@ -176,8 +183,14 @@ const PermissionManagement = () => {
[roles, selectedRoleId], [roles, selectedRoleId],
); );
const selectedRoleMenuIds = permissionsByRole[selectedRoleId] || []; const selectedRoleMenuIds = useMemo(
const baselineSelectedRoleMenuIds = baselinePermissionsByRole[selectedRoleId] || []; () => permissionsByRole[selectedRoleId] || [],
[permissionsByRole, selectedRoleId],
);
const baselineSelectedRoleMenuIds = useMemo(
() => baselinePermissionsByRole[selectedRoleId] || [],
[baselinePermissionsByRole, selectedRoleId],
);
const activeMenus = useMemo(() => menus.filter((menu) => Boolean(menu.is_active)), [menus]); const activeMenus = useMemo(() => menus.filter((menu) => Boolean(menu.is_active)), [menus]);
const activeMenuIdSet = useMemo(() => new Set(activeMenus.map((menu) => menu.menu_id)), [activeMenus]); const activeMenuIdSet = useMemo(() => new Set(activeMenus.map((menu) => menu.menu_id)), [activeMenus]);
const permissionMenuIds = useMemo(() => activeMenus.map((menu) => String(menu.menu_id)), [activeMenus]); const permissionMenuIds = useMemo(() => activeMenus.map((menu) => String(menu.menu_id)), [activeMenus]);
@ -197,7 +210,7 @@ const PermissionManagement = () => {
setExpandedManageKeys(allMenuIds); setExpandedManageKeys(allMenuIds);
}, [allMenuIds]); }, [allMenuIds]);
const fetchBaseData = async () => { const fetchBaseData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const [rolesRes, menusRes, rolePermsRes] = await Promise.all([ const [rolesRes, menusRes, rolePermsRes] = await Promise.all([
@ -214,25 +227,27 @@ const PermissionManagement = () => {
setMenus(menusList); setMenus(menusList);
setPermissionsByRole(permsMap); setPermissionsByRole(permsMap);
setBaselinePermissionsByRole(permsMap); setBaselinePermissionsByRole(permsMap);
setSelectedRoleId((prevSelectedRoleId) => {
if (!selectedRoleId && rolesList.length > 0) { if (!prevSelectedRoleId && rolesList.length > 0) {
setSelectedRoleId(rolesList[0].role_id); return rolesList[0].role_id;
} else if (selectedRoleId && !rolesList.find((r) => r.role_id === selectedRoleId)) {
setSelectedRoleId(rolesList[0]?.role_id || null);
} }
if (prevSelectedRoleId && !rolesList.find((r) => r.role_id === prevSelectedRoleId)) {
return rolesList[0]?.role_id || null;
}
return prevSelectedRoleId;
});
} catch { } catch {
message.error('获取权限数据失败'); message.error('获取权限数据失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [message]);
useEffect(() => { useEffect(() => {
fetchBaseData(); fetchBaseData();
}, []); }, [fetchBaseData]);
useEffect(() => { const fetchRoleUsers = useCallback(async () => {
const fetchRoleUsers = async () => {
if (!selectedRoleId) { if (!selectedRoleId) {
setRoleUsers([]); setRoleUsers([]);
setRoleUsersTotal(0); setRoleUsersTotal(0);
@ -252,9 +267,11 @@ const PermissionManagement = () => {
} finally { } finally {
setRoleUsersLoading(false); setRoleUsersLoading(false);
} }
}; }, [message, roleUsersPage, roleUsersPageSize, selectedRoleId]);
useEffect(() => {
fetchRoleUsers(); fetchRoleUsers();
}, [selectedRoleId, roleUsersPage, roleUsersPageSize]); }, [fetchRoleUsers]);
const saveSelectedRolePermissions = async () => { const saveSelectedRolePermissions = async () => {
if (!selectedRoleId) return; if (!selectedRoleId) return;
@ -357,7 +374,7 @@ const PermissionManagement = () => {
}); });
}; };
const openViewMenuPanel = (menu) => { const openViewMenuPanel = useCallback((menu) => {
setEditingMenu(menu); setEditingMenu(menu);
setMenuPanelMode('view'); setMenuPanelMode('view');
setMenuIconPickerOpen(false); setMenuIconPickerOpen(false);
@ -373,7 +390,7 @@ const PermissionManagement = () => {
is_active: Boolean(menu.is_active), is_active: Boolean(menu.is_active),
description: menu.description, description: menu.description,
}); });
}; }, [menuForm]);
const submitMenu = async () => { const submitMenu = async () => {
const values = await menuForm.validateFields(); const values = await menuForm.validateFields();
@ -530,7 +547,7 @@ const PermissionManagement = () => {
if (menuPanelMode === 'view') { if (menuPanelMode === 'view') {
openViewMenuPanel(selectedManageMenu); openViewMenuPanel(selectedManageMenu);
} }
}, [selectedManageMenu, menuPanelMode]); }, [menuPanelMode, openViewMenuPanel, selectedManageMenu]);
const handleMenuTreeDrop = async (info) => { const handleMenuTreeDrop = async (info) => {
const dragId = Number(info.dragNode?.key); const dragId = Number(info.dragNode?.key);

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
Layout, List, Button, Space, Typography, Card, Layout, List, Button, Space, Typography, Card,
Input, Switch, App, Modal, Form, Badge, Tooltip, Divider, Checkbox, Empty, Select, Tag Input, Switch, App, Modal, Form, Badge, Tooltip, Divider, Checkbox, Empty, Select, Tag
@ -41,18 +41,11 @@ const PromptManagement = () => {
const [prompts, setPrompts] = useState([]); const [prompts, setPrompts] = useState([]);
const [selectedPrompt, setSelectedPrompt] = useState(null); const [selectedPrompt, setSelectedPrompt] = useState(null);
const [editingPrompt, setEditingPrompt] = useState(null); const [editingPrompt, setEditingPrompt] = useState(null);
const [editingTitle, setEditingTitle] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
useEffect(() => { const fetchPrompts = useCallback(async () => {
fetchPrompts();
}, []);
const fetchPrompts = async () => {
setLoading(true);
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.LIST)); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.LIST));
const list = response.data.prompts || []; const list = response.data.prompts || [];
@ -61,15 +54,18 @@ const PromptManagement = () => {
setSelectedPrompt(list[0]); setSelectedPrompt(list[0]);
setEditingPrompt({ ...list[0] }); setEditingPrompt({ ...list[0] });
} }
} finally { } catch {
setLoading(false); message.error('加载模板失败');
} }
}; }, [message, selectedPrompt]);
useEffect(() => {
fetchPrompts();
}, [fetchPrompts]);
const handlePromptSelect = (prompt) => { const handlePromptSelect = (prompt) => {
setSelectedPrompt(prompt); setSelectedPrompt(prompt);
setEditingPrompt({ ...prompt }); setEditingPrompt({ ...prompt });
setEditingTitle(false);
}; };
const handleEditChange = (field, value) => { const handleEditChange = (field, value) => {
@ -88,7 +84,7 @@ const PromptManagement = () => {
await apiClient.put(buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id)), payload); await apiClient.put(buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id)), payload);
message.success('保存成功'); message.success('保存成功');
fetchPrompts(); fetchPrompts();
} catch (e) { } catch {
message.error('保存失败'); message.error('保存失败');
} finally { } finally {
setIsSaving(false); setIsSaving(false);

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
App, App,
Alert, Alert,
@ -75,7 +75,7 @@ const SystemManagementOverview = () => {
const [apps, setApps] = useState([]); const [apps, setApps] = useState([]);
const [terminals, setTerminals] = useState([]); const [terminals, setTerminals] = useState([]);
const fetchOverviewData = async () => { const fetchOverviewData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const [clientRes, appRes, terminalRes] = await Promise.all([ const [clientRes, appRes, terminalRes] = await Promise.all([
@ -92,11 +92,11 @@ const SystemManagementOverview = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [message]);
useEffect(() => { useEffect(() => {
fetchOverviewData(); fetchOverviewData();
}, []); }, [fetchOverviewData]);
const governanceItems = useMemo(() => { const governanceItems = useMemo(() => {
const inactiveLatestCount = clients.filter((item) => { const inactiveLatestCount = clients.filter((item) => {

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Table, Table,
Button, Button,
@ -34,6 +34,7 @@ import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell'; import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton'; import ActionButton from '../../components/ActionButton';
import useSystemPageSize from '../../hooks/useSystemPageSize';
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@ -43,6 +44,7 @@ const ONLINE_MINUTES = 10;
const TerminalManagement = () => { const TerminalManagement = () => {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const [form] = Form.useForm(); const [form] = Form.useForm();
const pageSize = useSystemPageSize(10);
const [terminals, setTerminals] = useState([]); const [terminals, setTerminals] = useState([]);
const [terminalTypes, setTerminalTypes] = useState([]); const [terminalTypes, setTerminalTypes] = useState([]);
@ -57,12 +59,7 @@ const TerminalManagement = () => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [selectedTerminal, setSelectedTerminal] = useState(null); const [selectedTerminal, setSelectedTerminal] = useState(null);
useEffect(() => { const fetchTerminalTypes = useCallback(async () => {
fetchTerminalTypes();
fetchTerminals();
}, []);
const fetchTerminalTypes = async () => {
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')), { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')), {
params: { parent_code: 'TERMINAL' }, params: { parent_code: 'TERMINAL' },
@ -73,9 +70,9 @@ const TerminalManagement = () => {
} catch (error) { } catch (error) {
console.error('Failed to fetch terminal types:', error); console.error('Failed to fetch terminal types:', error);
} }
}; }, []);
const fetchTerminals = async () => { const fetchTerminals = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TERMINALS.LIST), { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TERMINALS.LIST), {
@ -89,7 +86,12 @@ const TerminalManagement = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [message]);
useEffect(() => {
fetchTerminalTypes();
fetchTerminals();
}, [fetchTerminalTypes, fetchTerminals]);
const handleOpenModal = (terminal = null) => { const handleOpenModal = (terminal = null) => {
if (terminal) { if (terminal) {
@ -391,7 +393,7 @@ const TerminalManagement = () => {
loading={loading} loading={loading}
rowKey="id" rowKey="id"
scroll={{ x: 1220 }} scroll={{ x: 1220 }}
pagination={{ pageSize: 10, showTotal: (count) => `${count} 条记录` }} pagination={{ pageSize, showTotal: (count) => `${count} 条记录` }}
/> />
</div> </div>
</AdminModuleShell> </AdminModuleShell>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Table, Button, Input, Space, Drawer, Form, Select, App, Tooltip, Tag } from 'antd'; import { Table, Button, Input, Space, Drawer, Form, Select, App, Tooltip, Tag } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
@ -14,9 +14,9 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import apiClient from '../../utils/apiClient'; import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import configService from '../../utils/configService';
import AdminModuleShell from '../../components/AdminModuleShell'; import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton'; import ActionButton from '../../components/ActionButton';
import useSystemPageSize from '../../hooks/useSystemPageSize';
const UserManagement = () => { const UserManagement = () => {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
@ -25,7 +25,7 @@ const UserManagement = () => {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const pageSize = useSystemPageSize(10);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showUserDrawer, setShowUserDrawer] = useState(false); const [showUserDrawer, setShowUserDrawer] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@ -35,10 +35,6 @@ const UserManagement = () => {
const [debouncedSearchText, setDebouncedSearchText] = useState(''); const [debouncedSearchText, setDebouncedSearchText] = useState('');
useEffect(() => { useEffect(() => {
configService.getPageSize().then((size) => {
const safeSize = Number.isFinite(size) ? Math.min(100, Math.max(5, size)) : 10;
setPageSize(safeSize);
});
fetchRoles(); fetchRoles();
}, []); }, []);
@ -51,11 +47,7 @@ const UserManagement = () => {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchText]); }, [searchText]);
useEffect(() => { const fetchUsers = useCallback(async () => {
fetchUsers();
}, [page, pageSize, debouncedSearchText]);
const fetchUsers = async () => {
setLoading(true); setLoading(true);
try { try {
let url = `${API_ENDPOINTS.USERS.LIST}?page=${page}&size=${pageSize}`; let url = `${API_ENDPOINTS.USERS.LIST}?page=${page}&size=${pageSize}`;
@ -70,7 +62,11 @@ const UserManagement = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [debouncedSearchText, message, page, pageSize]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const fetchRoles = async () => { const fetchRoles = async () => {
try { try {
@ -239,11 +235,9 @@ const UserManagement = () => {
current: page, current: page,
pageSize, pageSize,
total, total,
onChange: (nextPage, size) => { onChange: (nextPage) => {
setPage(nextPage); setPage(nextPage);
setPageSize(size);
}, },
showSizeChanger: true,
showTotal: (count) => `${count} 条记录`, showTotal: (count) => `${count} 条记录`,
}} }}
rowKey="user_id" rowKey="user_id"

View File

@ -1,10 +1,11 @@
import apiClient from './apiClient'; import apiClient from './apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import { buildApiUrl, API_ENDPOINTS } from '../config/api';
const PUBLIC_CONFIG_CACHE_KEY = 'imeeting_public_config_cache';
const PUBLIC_CONFIG_CACHE_TTL = 5 * 60 * 1000;
export const DEFAULT_BRANDING_CONFIG = { export const DEFAULT_BRANDING_CONFIG = {
app_name: '智听云平台', app_name: '智听云平台',
home_headline: '智听云平台',
home_tagline: '让每一次谈话都产生价值',
console_subtitle: 'iMeeting控制台', console_subtitle: 'iMeeting控制台',
preview_title: '会议预览', preview_title: '会议预览',
login_welcome: '欢迎回来,请输入您的登录凭证。', login_welcome: '欢迎回来,请输入您的登录凭证。',
@ -13,13 +14,73 @@ export const DEFAULT_BRANDING_CONFIG = {
class ConfigService { class ConfigService {
constructor() { constructor() {
this.configs = null; this.configs = this.readCachedPublicConfig();
this.loadPromise = null; this.loadPromise = null;
this._pageSize = null; this._pageSize = this.extractPageSize(this.configs);
this.brandingConfig = null; this.brandingConfig = this.configs ? this.buildBrandingConfig(this.configs) : null;
this.brandingPromise = null; this.brandingPromise = null;
} }
readCachedPublicConfig() {
try {
const raw = window.localStorage.getItem(PUBLIC_CONFIG_CACHE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return null;
}
if (parsed.data && parsed.cached_at) {
if (Date.now() - Number(parsed.cached_at) > PUBLIC_CONFIG_CACHE_TTL) {
window.localStorage.removeItem(PUBLIC_CONFIG_CACHE_KEY);
return null;
}
return parsed.data;
}
return parsed;
} catch {
return null;
}
}
writeCachedPublicConfig(configs) {
try {
window.localStorage.setItem(PUBLIC_CONFIG_CACHE_KEY, JSON.stringify({
data: configs || {},
cached_at: Date.now(),
}));
} catch {
// Ignore storage failures and continue with in-memory cache.
}
}
extractPageSize(configs) {
const raw = configs?.PAGE_SIZE ?? configs?.page_size;
const val = parseInt(raw, 10);
if (isNaN(val) || val <= 0) {
return null;
}
return Math.min(100, Math.max(5, val));
}
buildBrandingConfig(configs) {
return {
...DEFAULT_BRANDING_CONFIG,
...(configs || {}),
};
}
applyPublicConfig(configs) {
this.configs = configs || null;
this._pageSize = this.extractPageSize(this.configs);
this.brandingConfig = this.buildBrandingConfig(this.configs);
if (this.configs) {
this.writeCachedPublicConfig(this.configs);
}
return this.configs;
}
async getConfigs() { async getConfigs() {
if (this.configs) { if (this.configs) {
return this.configs; return this.configs;
@ -30,21 +91,24 @@ class ConfigService {
} }
this.loadPromise = this.loadConfigsFromServer(); this.loadPromise = this.loadConfigsFromServer();
this.configs = await this.loadPromise; const configs = await this.loadPromise;
return this.configs; this.loadPromise = null;
return configs;
} }
async loadConfigsFromServer() { async loadConfigsFromServer() {
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PUBLIC.SYSTEM_CONFIG)); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PUBLIC.SYSTEM_CONFIG));
return response.data; return this.applyPublicConfig(response.data || {});
} catch (error) { } catch (error) {
console.warn('Failed to load system configs, using defaults:', error); console.warn('Failed to load system configs, using defaults:', error);
// 返回默认配置 // 返回默认配置
return { return this.applyPublicConfig({
MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB
MAX_IMAGE_SIZE: 10 * 1024 * 1024 // 10MB MAX_IMAGE_SIZE: 10 * 1024 * 1024, // 10MB
}; PAGE_SIZE: 10,
page_size: '10',
});
} }
} }
@ -65,21 +129,22 @@ class ConfigService {
async getPageSize() { async getPageSize() {
if (this._pageSize) return this._pageSize; if (this._pageSize) return this._pageSize;
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETER_DETAIL('timeline_pagesize'))); const configs = await this.getConfigs();
const raw = response.data?.param_value; const cachedPageSize = this.extractPageSize(configs);
const val = parseInt(raw); if (cachedPageSize) {
if (!isNaN(val) && val > 0) { this._pageSize = cachedPageSize;
// 防御异常配置,避免一次性拉取过多数据导致页面卡顿 return cachedPageSize;
const safeVal = Math.min(100, Math.max(5, val));
this._pageSize = safeVal;
return safeVal;
} }
} catch (e) { } catch (error) {
console.warn('Failed to load page_size config:', e.message); console.warn('Failed to load page_size config:', error.message);
} }
return 10; return 10;
} }
getCachedPageSize() {
return this._pageSize;
}
async getBrandingConfig() { async getBrandingConfig() {
if (this.brandingConfig) { if (this.brandingConfig) {
return this.brandingConfig; return this.brandingConfig;
@ -90,17 +155,15 @@ class ConfigService {
} }
this.brandingPromise = this.loadBrandingConfigFromServer(); this.brandingPromise = this.loadBrandingConfigFromServer();
this.brandingConfig = await this.brandingPromise; const branding = await this.brandingPromise;
return this.brandingConfig; this.brandingPromise = null;
return branding;
} }
async loadBrandingConfigFromServer() { async loadBrandingConfigFromServer() {
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PUBLIC.SYSTEM_CONFIG)); const configs = await this.getConfigs();
return { return this.buildBrandingConfig(configs);
...DEFAULT_BRANDING_CONFIG,
...(response.data || {}),
};
} catch (error) { } catch (error) {
console.warn('Failed to load branding configs, using defaults:', error); console.warn('Failed to load branding configs, using defaults:', error);
return { ...DEFAULT_BRANDING_CONFIG }; return { ...DEFAULT_BRANDING_CONFIG };
@ -123,6 +186,11 @@ class ConfigService {
this._pageSize = null; this._pageSize = null;
this.brandingConfig = null; this.brandingConfig = null;
this.brandingPromise = null; this.brandingPromise = null;
try {
window.localStorage.removeItem(PUBLIC_CONFIG_CACHE_KEY);
} catch {
// Ignore storage cleanup failures.
}
} }
} }

View File

@ -95,7 +95,6 @@ const ICON_COMPONENTS = {
FileTextOutlined, FileTextOutlined,
ClusterOutlined, ClusterOutlined,
BranchesOutlined, BranchesOutlined,
ApartmentOutlined,
NotificationOutlined, NotificationOutlined,
CommentOutlined, CommentOutlined,
CustomerServiceOutlined, CustomerServiceOutlined,

View File

@ -316,7 +316,7 @@ export function deepClone(obj) {
if (obj instanceof Object) { if (obj instanceof Object) {
const clonedObj = {}; const clonedObj = {};
for (const key in obj) { for (const key in obj) {
if (obj.hasOwnProperty(key)) { if (Object.prototype.hasOwnProperty.call(obj, key)) {
clonedObj[key] = deepClone(obj[key]); clonedObj[key] = deepClone(obj[key]);
} }
} }