diff --git a/app.py b/app.py index 83fcf9a..ff7006e 100644 --- a/app.py +++ b/app.py @@ -16,6 +16,8 @@ import signal import atexit import requests import logging +import importlib +import re from pathlib import Path # 导入ReportEngine @@ -45,6 +47,217 @@ os.environ['PYTHONUTF8'] = '1' LOG_DIR = Path('logs') LOG_DIR.mkdir(exist_ok=True) +CONFIG_MODULE_NAME = 'config' +CONFIG_FILE_PATH = Path(__file__).resolve().parent / 'config.py' +CONFIG_KEYS = [ + 'DB_HOST', + 'DB_PORT', + 'DB_USER', + 'DB_PASSWORD', + 'DB_NAME', + 'DB_CHARSET', + 'INSIGHT_ENGINE_API_KEY', + 'INSIGHT_ENGINE_BASE_URL', + 'INSIGHT_ENGINE_MODEL_NAME', + 'MEDIA_ENGINE_API_KEY', + 'MEDIA_ENGINE_BASE_URL', + 'MEDIA_ENGINE_MODEL_NAME', + 'QUERY_ENGINE_API_KEY', + 'QUERY_ENGINE_BASE_URL', + 'QUERY_ENGINE_MODEL_NAME', + 'REPORT_ENGINE_API_KEY', + 'REPORT_ENGINE_BASE_URL', + 'REPORT_ENGINE_MODEL_NAME', + 'FORUM_HOST_API_KEY', + 'FORUM_HOST_BASE_URL', + 'FORUM_HOST_MODEL_NAME', + 'KEYWORD_OPTIMIZER_API_KEY', + 'KEYWORD_OPTIMIZER_BASE_URL', + 'KEYWORD_OPTIMIZER_MODEL_NAME', + 'TAVILY_API_KEY', + 'BOCHA_WEB_SEARCH_API_KEY' +] + + +def _load_config_module(): + """Load or reload the config module to ensure latest values are available.""" + importlib.invalidate_caches() + module = sys.modules.get(CONFIG_MODULE_NAME) + try: + if module is None: + module = importlib.import_module(CONFIG_MODULE_NAME) + else: + module = importlib.reload(module) + except ModuleNotFoundError: + return None + return module + + +def read_config_values(): + """Return the current configuration values that are exposed to the frontend.""" + module = _load_config_module() + if not module: + return {} + + values = {} + for key in CONFIG_KEYS: + value = getattr(module, key, '') + # Convert to string for uniform handling on the frontend. + if value is None: + values[key] = '' + else: + values[key] = str(value) + return values + + +def _serialize_config_value(value): + """Serialize Python values back to a config.py assignment-friendly string.""" + if isinstance(value, bool): + return 'True' if value else 'False' + if isinstance(value, (int, float)): + return str(value) + if value is None: + return 'None' + + value_str = str(value) + escaped = value_str.replace('\\', '\\\\').replace('"', '\\"') + return f'"{escaped}"' + + +def write_config_values(updates): + """Persist configuration updates into config.py.""" + if not CONFIG_FILE_PATH.exists(): + raise FileNotFoundError("配置文件 config.py 不存在") + + content = CONFIG_FILE_PATH.read_text(encoding='utf-8') + + for key, raw_value in updates.items(): + formatted_value = _serialize_config_value(raw_value) + pattern = re.compile( + rf'^(\s*{key}\s*=\s*)(["\'].*?["\']|None|True|False|[0-9\.-]+)(.*)$', + re.MULTILINE + ) + + def replace(match): + prefix, _, suffix = match.groups() + return f"{prefix}{formatted_value}{suffix}" + + new_content, count = pattern.subn(replace, content, count=1) + + if count == 0: + # Append the new key if it was not present. + if not new_content.endswith('\n'): + new_content += '\n' + new_content += f'{key} = {formatted_value}\n' + + content = new_content + + CONFIG_FILE_PATH.write_text(content, encoding='utf-8') + # Reload the module so the rest of the app observes the new values when possible. + _load_config_module() + + +system_state_lock = threading.Lock() +system_state = { + 'started': False, + 'starting': False +} + + +def _set_system_state(*, started=None, starting=None): + """Safely update the cached system state flags.""" + with system_state_lock: + if started is not None: + system_state['started'] = started + if starting is not None: + system_state['starting'] = starting + + +def _get_system_state(): + """Return a shallow copy of the system state flags.""" + with system_state_lock: + return system_state.copy() + + +def _prepare_system_start(): + """Mark the system as starting if it is not already running or starting.""" + with system_state_lock: + if system_state['started']: + return False, '系统已启动' + if system_state['starting']: + return False, '系统正在启动' + system_state['starting'] = True + return True, None + + +def initialize_system_components(): + """启动所有依赖组件(Streamlit 子应用、ForumEngine、ReportEngine)。""" + logs = [] + errors = [] + + try: + stop_forum_engine() + logs.append("已停止 ForumEngine 监控器以避免文件冲突") + except Exception as exc: # pragma: no cover - 安全捕获 + message = f"停止 ForumEngine 时发生异常: {exc}" + logs.append(message) + logging.exception(message) + + processes['forum']['status'] = 'stopped' + + for app_name, script_path in STREAMLIT_SCRIPTS.items(): + logs.append(f"检查文件: {script_path}") + if os.path.exists(script_path): + success, message = start_streamlit_app(app_name, script_path, processes[app_name]['port']) + logs.append(f"{app_name}: {message}") + if success: + startup_success, startup_message = wait_for_app_startup(app_name, 30) + logs.append(f"{app_name} 启动检查: {startup_message}") + if not startup_success: + errors.append(f"{app_name} 启动失败: {startup_message}") + else: + errors.append(f"{app_name} 启动失败: {message}") + else: + msg = f"文件不存在: {script_path}" + logs.append(f"错误: {msg}") + errors.append(f"{app_name}: {msg}") + + forum_started = False + try: + start_forum_engine() + processes['forum']['status'] = 'running' + logs.append("ForumEngine 启动完成") + forum_started = True + except Exception as exc: # pragma: no cover - 保底捕获 + error_msg = f"ForumEngine 启动失败: {exc}" + logs.append(error_msg) + errors.append(error_msg) + + if REPORT_ENGINE_AVAILABLE: + try: + if initialize_report_engine(): + logs.append("ReportEngine 初始化成功") + else: + msg = "ReportEngine 初始化失败" + logs.append(msg) + errors.append(msg) + except Exception as exc: # pragma: no cover + msg = f"ReportEngine 初始化异常: {exc}" + logs.append(msg) + errors.append(msg) + + if errors: + cleanup_processes() + processes['forum']['status'] = 'stopped' + if forum_started: + try: + stop_forum_engine() + except Exception: # pragma: no cover + logging.exception("停止ForumEngine失败") + return False, logs, errors + + return True, logs, [] + # 初始化ForumEngine的forum.log文件 def init_forum_log(): """初始化forum.log文件""" @@ -195,7 +408,13 @@ processes = { 'insight': {'process': None, 'port': 8501, 'status': 'stopped', 'output': [], 'log_file': None}, 'media': {'process': None, 'port': 8502, 'status': 'stopped', 'output': [], 'log_file': None}, 'query': {'process': None, 'port': 8503, 'status': 'stopped', 'output': [], 'log_file': None}, - 'forum': {'process': None, 'port': None, 'status': 'running', 'output': [], 'log_file': None} # Forum始终运行 + 'forum': {'process': None, 'port': None, 'status': 'stopped', 'output': [], 'log_file': None} # 启动后标记为 running +} + +STREAMLIT_SCRIPTS = { + 'insight': 'SingleEngineApp/insight_engine_streamlit_app.py', + 'media': 'SingleEngineApp/media_engine_streamlit_app.py', + 'query': 'SingleEngineApp/query_engine_streamlit_app.py' } # 输出队列 @@ -449,8 +668,15 @@ def wait_for_app_startup(app_name, max_wait_time=30): def cleanup_processes(): """清理所有进程""" - for app_name in processes: + for app_name in STREAMLIT_SCRIPTS: stop_streamlit_app(app_name) + + processes['forum']['status'] = 'stopped' + try: + stop_forum_engine() + except Exception: # pragma: no cover + logging.exception("停止ForumEngine失败") + _set_system_state(started=False, starting=False) # 注册清理函数 atexit.register(cleanup_processes) @@ -478,20 +704,26 @@ def start_app(app_name): """启动指定应用""" if app_name not in processes: return jsonify({'success': False, 'message': '未知应用'}) - - script_paths = { - 'insight': 'SingleEngineApp/insight_engine_streamlit_app.py', - 'media': 'SingleEngineApp/media_engine_streamlit_app.py', - 'query': 'SingleEngineApp/query_engine_streamlit_app.py' - } - + + if app_name == 'forum': + try: + start_forum_engine() + processes['forum']['status'] = 'running' + return jsonify({'success': True, 'message': 'ForumEngine已启动'}) + except Exception as exc: # pragma: no cover + logging.exception("手动启动ForumEngine失败") + return jsonify({'success': False, 'message': f'ForumEngine启动失败: {exc}'}) + + script_path = STREAMLIT_SCRIPTS.get(app_name) + if not script_path: + return jsonify({'success': False, 'message': '该应用不支持启动操作'}) + success, message = start_streamlit_app( - app_name, - script_paths[app_name], + app_name, + script_path, processes[app_name]['port'] ) - - + if success: # 等待应用启动 startup_success, startup_message = wait_for_app_startup(app_name, 15) @@ -505,7 +737,16 @@ def stop_app(app_name): """停止指定应用""" if app_name not in processes: return jsonify({'success': False, 'message': '未知应用'}) - + + if app_name == 'forum': + try: + stop_forum_engine() + processes['forum']['status'] = 'stopped' + return jsonify({'success': True, 'message': 'ForumEngine已停止'}) + except Exception as exc: # pragma: no cover + logging.exception("手动停止ForumEngine失败") + return jsonify({'success': False, 'message': f'ForumEngine停止失败: {exc}'}) + success, message = stop_streamlit_app(app_name) return jsonify({'success': success, 'message': message}) @@ -660,6 +901,80 @@ def search(): 'results': results }) + +@app.route('/api/config', methods=['GET']) +def get_config(): + """Expose selected configuration values to the frontend.""" + try: + config_values = read_config_values() + return jsonify({'success': True, 'config': config_values}) + except Exception as exc: + logging.exception("读取配置失败") + return jsonify({'success': False, 'message': f'读取配置失败: {exc}'}), 500 + + +@app.route('/api/config', methods=['POST']) +def update_config(): + """Update configuration values and persist them to config.py.""" + payload = request.get_json(silent=True) or {} + if not isinstance(payload, dict) or not payload: + return jsonify({'success': False, 'message': '请求体不能为空'}), 400 + + updates = {} + for key, value in payload.items(): + if key in CONFIG_KEYS: + updates[key] = value if value is not None else '' + + if not updates: + return jsonify({'success': False, 'message': '没有可更新的配置项'}), 400 + + try: + write_config_values(updates) + updated_config = read_config_values() + return jsonify({'success': True, 'config': updated_config}) + except Exception as exc: + logging.exception("更新配置失败") + return jsonify({'success': False, 'message': f'更新配置失败: {exc}'}), 500 + + +@app.route('/api/system/status') +def get_system_status(): + """返回系统启动状态。""" + state = _get_system_state() + return jsonify({ + 'success': True, + 'started': state['started'], + 'starting': state['starting'] + }) + + +@app.route('/api/system/start', methods=['POST']) +def start_system(): + """在接收到请求后启动完整系统。""" + allowed, message = _prepare_system_start() + if not allowed: + return jsonify({'success': False, 'message': message}), 400 + + try: + success, logs, errors = initialize_system_components() + if success: + _set_system_state(started=True) + return jsonify({'success': True, 'message': '系统启动成功', 'logs': logs}) + + _set_system_state(started=False) + return jsonify({ + 'success': False, + 'message': '系统启动失败', + 'logs': logs, + 'errors': errors + }), 500 + except Exception as exc: # pragma: no cover - 保底捕获 + logging.exception("系统启动过程中出现异常") + _set_system_state(started=False) + return jsonify({'success': False, 'message': f'系统启动异常: {exc}'}), 500 + finally: + _set_system_state(starting=False) + @socketio.on('connect') def handle_connect(): """客户端连接""" @@ -678,51 +993,12 @@ def handle_status_request(): }) if __name__ == '__main__': - # 启动时自动启动所有Streamlit应用 - print("正在启动Streamlit应用...") - - # 先停止ForumEngine监控器,避免文件占用冲突 - print("停止ForumEngine监控器以避免文件冲突...") - stop_forum_engine() - - script_paths = { - 'insight': 'SingleEngineApp/insight_engine_streamlit_app.py', - 'media': 'SingleEngineApp/media_engine_streamlit_app.py', - 'query': 'SingleEngineApp/query_engine_streamlit_app.py' - } - - for app_name, script_path in script_paths.items(): - print(f"检查文件: {script_path}") - if os.path.exists(script_path): - print(f"启动 {app_name}...") - success, message = start_streamlit_app(app_name, script_path, processes[app_name]['port']) - print(f"{app_name}: {message}") - - if success: - print(f"等待 {app_name} 启动完成...") - startup_success, startup_message = wait_for_app_startup(app_name, 30) - print(f"{app_name} 启动检查: {startup_message}") - else: - print(f"错误: {script_path} 不存在") - - start_forum_engine() - - # 初始化ReportEngine - if REPORT_ENGINE_AVAILABLE: - print("初始化ReportEngine...") - if initialize_report_engine(): - print("ReportEngine初始化成功") - print("ReportEngine文件基准已建立,开始监控文件变化") - else: - print("ReportEngine初始化失败") - + print("等待配置确认,系统将在前端指令后启动组件...") print("启动Flask服务器...") - + try: - # 启动Flask应用 socketio.run(app, host='0.0.0.0', port=5000, debug=False) except KeyboardInterrupt: print("\n正在关闭应用...") cleanup_processes() - diff --git a/templates/index.html b/templates/index.html index 0eda843..592b8bd 100644 --- a/templates/index.html +++ b/templates/index.html @@ -44,10 +44,63 @@ letter-spacing: 1px; } + .search-row { + display: flex; + align-items: stretch; + gap: 12px; + max-width: 950px; + margin: 0 auto 10px; + } + + .config-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0 24px; + border: 2px solid #000000; + background-color: #ffffff; + color: #000000; + cursor: pointer; + font-size: 14px; + font-weight: bold; + transition: all 0.3s ease; + min-width: 120px; + } + + .config-button:hover { + background-color: #000000; + color: #ffffff; + } + + .config-password-wrapper { + display: flex; + align-items: center; + gap: 8px; + } + + .config-password-wrapper .config-field-input { + flex: 1; + } + + .config-password-toggle { + padding: 8px 14px; + border: 2px solid #000000; + background-color: #ffffff; + cursor: pointer; + font-size: 12px; + font-weight: bold; + transition: all 0.3s ease; + } + + .config-password-toggle:hover, + .config-password-toggle.revealed { + background-color: #000000; + color: #ffffff; + } + .search-box { display: flex; - max-width: 800px; - margin: 0 auto; + flex: 1; border: 2px solid #000000; } @@ -111,9 +164,10 @@ .upload-status { font-size: 12px; - margin-top: 10px; + margin: 10px auto 0; text-align: center; color: #666666; + max-width: 950px; } .upload-status.success { @@ -268,6 +322,207 @@ align-items: center; } + .config-modal-overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.35); + display: none; + align-items: center; + justify-content: center; + z-index: 999; + padding: 20px; + } + + .config-modal-overlay.visible { + display: flex; + } + + .config-modal { + background-color: #ffffff; + border: 2px solid #000000; + width: 720px; + max-width: 90vw; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 6px 6px 0 #000000; + } + + .config-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 2px solid #000000; + background-color: #ffffff; + } + + .config-modal-title { + font-size: 18px; + font-weight: bold; + } + + .config-modal-actions { + display: flex; + gap: 10px; + align-items: center; + } + + .config-close-button { + width: 32px; + height: 32px; + border: 2px solid #000000; + background-color: #ffffff; + font-size: 18px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + + .config-close-button:hover { + background-color: #000000; + color: #ffffff; + } + + .config-close-button:disabled { + opacity: 0.4; + cursor: not-allowed; + background-color: #f0f0f0; + color: #666666; + } + + .config-secondary-button { + padding: 8px 18px; + border: 2px solid #000000; + background-color: #ffffff; + color: #000000; + cursor: pointer; + font-size: 13px; + font-weight: bold; + transition: all 0.3s ease; + } + + .config-secondary-button:hover { + background-color: #f0f0f0; + } + + .config-modal-body { + padding: 20px; + overflow-y: auto; + } + + .config-group { + border: 2px solid #000000; + padding: 16px; + margin-bottom: 16px; + background-color: #ffffff; + } + + .config-group-title { + font-size: 15px; + font-weight: bold; + margin-bottom: 10px; + } + + .config-group-subtitle { + font-size: 12px; + color: #555555; + margin-bottom: 12px; + } + + .config-field { + display: flex; + flex-direction: column; + margin-bottom: 12px; + } + + .config-field-label { + font-size: 12px; + font-weight: bold; + margin-bottom: 6px; + } + + .config-field-input { + padding: 10px 12px; + border: 2px solid #000000; + font-size: 14px; + background-color: #ffffff; + } + + .config-field-input:focus { + outline: none; + border-color: #333333; + } + + .config-modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-top: 2px solid #000000; + background-color: #ffffff; + } + + .config-modal-footer-actions { + display: flex; + gap: 10px; + } + + .config-status-message { + font-size: 12px; + color: #555555; + } + + .config-status-message.error { + color: #8b4513; + } + + .config-status-message.success { + color: #4a6741; + } + + .config-save-button { + padding: 10px 24px; + border: none; + background-color: #000000; + color: #ffffff; + cursor: pointer; + font-size: 14px; + font-weight: bold; + transition: all 0.3s ease; + } + + .config-save-button:hover { + background-color: #333333; + } + + .config-save-button:disabled { + background-color: #666666; + cursor: not-allowed; + } + + .config-start-button { + padding: 10px 24px; + border: none; + background-color: #000000; + color: #ffffff; + cursor: pointer; + font-size: 14px; + font-weight: bold; + transition: all 0.3s ease; + } + + .config-start-button:hover { + background-color: #333333; + } + + .config-start-button:disabled { + background-color: #666666; + cursor: not-allowed; + } + .loading { display: inline-block; width: 12px; @@ -752,13 +1007,16 @@
微舆 - 致力于打造简洁通用的舆情分析平台
- @@ -829,6 +1087,28 @@
+
+
+
+
LLM 配置 - 与Config文件双向同步
+
+ + +
+
+
+ +
+ +
+
+
@@ -840,10 +1120,98 @@ insight: 'stopped', media: 'stopped', query: 'stopped', - forum: 'running', // Forum Engine 默认运行 + forum: 'stopped', // 前端启动后再标记为 running report: 'stopped' // Report Engine }; let customTemplate = ''; // 存储用户上传的自定义模板内容 + let configValues = {}; + let configDirty = false; + let configAutoRefreshTimer = null; + let systemStarted = false; + let systemStarting = false; + let configModalLocked = false; + + const CONFIG_ENDPOINT = '/api/config'; + const SYSTEM_STATUS_ENDPOINT = '/api/system/status'; + const SYSTEM_START_ENDPOINT = '/api/system/start'; + const START_BUTTON_DEFAULT_TEXT = '保存并启动系统'; + + const configFieldGroups = [ + { + title: '数据库连接', + subtitle: '用于连接业务数据库的基本配置', + fields: [ + { key: 'DB_HOST', label: '主机地址' }, + { key: 'DB_PORT', label: '端口' }, + { key: 'DB_USER', label: '用户名' }, + { key: 'DB_PASSWORD', label: '密码', type: 'password' }, + { key: 'DB_NAME', label: '数据库名称' }, + { key: 'DB_CHARSET', label: '字符集' } + ] + }, + { + title: 'Insight Agent', + subtitle: '负责洞察分析的模型配置', + fields: [ + { key: 'INSIGHT_ENGINE_API_KEY', label: 'API Key' }, + { key: 'INSIGHT_ENGINE_BASE_URL', label: 'Base URL' }, + { key: 'INSIGHT_ENGINE_MODEL_NAME', label: '模型名称' } + ] + }, + { + title: 'Media Agent', + subtitle: '媒体内容理解与生成模型', + fields: [ + { key: 'MEDIA_ENGINE_API_KEY', label: 'API Key' }, + { key: 'MEDIA_ENGINE_BASE_URL', label: 'Base URL' }, + { key: 'MEDIA_ENGINE_MODEL_NAME', label: '模型名称' } + ] + }, + { + title: 'Query Agent', + subtitle: '负责搜索与信息汇总的模型配置', + fields: [ + { key: 'QUERY_ENGINE_API_KEY', label: 'API Key' }, + { key: 'QUERY_ENGINE_BASE_URL', label: 'Base URL' }, + { key: 'QUERY_ENGINE_MODEL_NAME', label: '模型名称' } + ] + }, + { + title: 'Report Agent', + subtitle: '报告生成使用的模型配置', + fields: [ + { key: 'REPORT_ENGINE_API_KEY', label: 'API Key' }, + { key: 'REPORT_ENGINE_BASE_URL', label: 'Base URL' }, + { key: 'REPORT_ENGINE_MODEL_NAME', label: '模型名称' } + ] + }, + { + title: 'Forum Host', + subtitle: '多智能体协同使用的模型配置', + fields: [ + { key: 'FORUM_HOST_API_KEY', label: 'API Key' }, + { key: 'FORUM_HOST_BASE_URL', label: 'Base URL' }, + { key: 'FORUM_HOST_MODEL_NAME', label: '模型名称' } + ] + }, + { + title: 'Keyword Optimizer', + subtitle: 'SQL / 关键词优化模型配置', + fields: [ + { key: 'KEYWORD_OPTIMIZER_API_KEY', label: 'API Key' }, + { key: 'KEYWORD_OPTIMIZER_BASE_URL', label: 'Base URL' }, + { key: 'KEYWORD_OPTIMIZER_MODEL_NAME', label: '模型名称' } + ] + }, + { + title: '外部检索工具', + subtitle: '联动搜索引擎、网站抓取等在线服务', + fields: [ + { key: 'TAVILY_API_KEY', label: 'Tavily API Key' }, + { key: 'BOCHA_WEB_SEARCH_API_KEY', label: 'Bocha API Key' } + ] + } + ]; // 应用名称映射 const appNames = { @@ -867,6 +1235,7 @@ document.addEventListener('DOMContentLoaded', function() { initializeSocket(); initializeEventListeners(); + ensureSystemReadyOnLoad(); updateTime(); setInterval(updateTime, 1000); checkStatus(); @@ -952,6 +1321,445 @@ switchToApp(app); }); }); + + // LLM 配置弹窗 + const openConfigButton = document.getElementById('openConfigButton'); + if (openConfigButton) { + openConfigButton.addEventListener('click', () => openConfigModal({ lock: !systemStarted })); + } + + const closeConfigButton = document.getElementById('closeConfigModal'); + if (closeConfigButton) { + closeConfigButton.addEventListener('click', () => closeConfigModal()); + } + + const refreshConfigButton = document.getElementById('refreshConfigButton'); + if (refreshConfigButton) { + refreshConfigButton.addEventListener('click', () => refreshConfigFromServer(true)); + } + + const saveConfigButton = document.getElementById('saveConfigButton'); + if (saveConfigButton) { + saveConfigButton.addEventListener('click', () => saveConfigUpdates()); + } + + const startSystemButton = document.getElementById('startSystemButton'); + if (startSystemButton) { + startSystemButton.addEventListener('click', () => startSystem()); + } + + const configModal = document.getElementById('configModal'); + if (configModal) { + configModal.addEventListener('click', (event) => { + if (event.target === configModal) { + closeConfigModal(); + } + }); + } + + const configFormContainer = document.getElementById('configFormContainer'); + if (configFormContainer) { + configFormContainer.addEventListener('input', () => { + configDirty = true; + setConfigStatus('已修改,尚未保存'); + }); + } + + document.addEventListener('keydown', function(event) { + if (event.key === 'Escape' && isConfigModalVisible()) { + closeConfigModal(); + } + }); + } + + function isConfigModalVisible() { + const modal = document.getElementById('configModal'); + return modal ? modal.classList.contains('visible') : false; + } + + function openConfigModal(options = {}) { + const { lock = false, message = '' } = options; + const modal = document.getElementById('configModal'); + if (!modal) { + return; + } + + configModalLocked = lock; + modal.classList.add('visible'); + configDirty = false; + + const initialMessage = message || '正在读取配置...'; + setConfigStatus(initialMessage, ''); + + const messageAfterLoad = message || ''; + + refreshConfigFromServer(true, messageAfterLoad); + + if (configAutoRefreshTimer) { + clearInterval(configAutoRefreshTimer); + } + configAutoRefreshTimer = setInterval(() => { + if (!configDirty) { + refreshConfigFromServer(false, messageAfterLoad); + } + }, 10000); + + updateStartButtonState(); + updateConfigCloseButton(); + } + + function closeConfigModal(force = false) { + if (!force && configModalLocked && !systemStarted) { + setConfigStatus('请先完成配置并启动系统', 'error'); + showMessage('请先完成配置并启动系统', 'error'); + return; + } + + const modal = document.getElementById('configModal'); + if (modal) { + modal.classList.remove('visible'); + } + if (configAutoRefreshTimer) { + clearInterval(configAutoRefreshTimer); + configAutoRefreshTimer = null; + } + configDirty = false; + configModalLocked = false; + setConfigStatus('', ''); + updateStartButtonState(); + updateConfigCloseButton(); + } + + function refreshConfigFromServer(showFeedback = false, messageOverride = '') { + if (showFeedback && configDirty) { + const proceed = window.confirm('当前修改尚未保存,确定要刷新并放弃更改吗?'); + if (!proceed) { + return; + } + } + fetch(CONFIG_ENDPOINT) + .then(response => response.json()) + .then(data => { + if (!data.success) { + throw new Error(data.message || '读取配置失败'); + } + configValues = data.config || {}; + renderConfigForm(configValues); + configDirty = false; + if (messageOverride) { + setConfigStatus(messageOverride); + } else if (showFeedback) { + setConfigStatus('已加载最新配置'); + } else { + setConfigStatus('已同步最新配置'); + } + }) + .catch(error => { + console.error(error); + setConfigStatus(`读取配置失败: ${error.message}`, 'error'); + }); + } + + function escapeHtml(str) { + return str.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function renderConfigForm(values) { + const container = document.getElementById('configFormContainer'); + if (!container) { + return; + } + + const sections = configFieldGroups.map(group => { + const fieldsHtml = group.fields.map(field => { + const value = values[field.key] !== undefined ? values[field.key] : ''; + const safeValue = escapeHtml(String(value || '')); + const inputType = field.type === 'password' ? 'password' : (field.type || 'text'); + const inputElement = ` + + `; + + const control = field.type === 'password' + ? ` +
+ ${inputElement} + +
+ ` + : inputElement; + + return ` + + `; + }).join(''); + + const subtitle = group.subtitle ? `
${group.subtitle}
` : ''; + + return ` +
+
${group.title}
+ ${subtitle} + ${fieldsHtml} +
+ `; + }).join(''); + + container.innerHTML = sections; + attachConfigPasswordToggles(); + } + + function attachConfigPasswordToggles() { + const toggles = document.querySelectorAll('.config-password-toggle'); + toggles.forEach(toggle => { + const key = toggle.dataset.target; + const input = document.querySelector(`.config-field-input[data-config-key="${key}"]`); + if (!input) { + return; + } + toggle.addEventListener('click', () => { + const reveal = input.getAttribute('type') === 'password'; + input.setAttribute('type', reveal ? 'text' : 'password'); + toggle.textContent = reveal ? '隐藏' : '显示'; + toggle.classList.toggle('revealed', reveal); + }); + }); + } + + function collectConfigUpdates() { + const inputs = document.querySelectorAll('#configFormContainer [data-config-key]'); + const updates = {}; + inputs.forEach(input => { + const key = input.dataset.configKey; + if (!key) { + return; + } + const fieldType = input.dataset.fieldType || 'text'; + let value = input.value; + if (fieldType !== 'password' && typeof value === 'string') { + value = value.trim(); + } + + if (value !== '' && /PORT$/i.test(key)) { + const numeric = Number(value); + if (!Number.isNaN(numeric)) { + updates[key] = numeric; + return; + } + } + + updates[key] = value; + }); + return updates; + } + + function setConfigStatus(message, type = '') { + const status = document.getElementById('configStatusMessage'); + if (!status) { + return; + } + status.textContent = message || ''; + status.classList.remove('error', 'success'); + if (type) { + status.classList.add(type); + } + } + + async function saveConfigUpdates(options = {}) { + const { silent = false } = options; + const saveButton = document.getElementById('saveConfigButton'); + + if (!silent && saveButton) { + saveButton.disabled = true; + saveButton.textContent = '保存中...'; + } + if (!silent) { + setConfigStatus('正在保存配置...', ''); + } + + const updates = collectConfigUpdates(); + + try { + const response = await fetch(CONFIG_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }); + const data = await response.json(); + if (!data.success) { + throw new Error(data.message || '保存失败'); + } + configValues = data.config || {}; + renderConfigForm(configValues); + configDirty = false; + if (silent) { + setConfigStatus('配置已保存', 'success'); + } else { + setConfigStatus('配置已保存', 'success'); + showMessage('配置已保存', 'success'); + } + return true; + } catch (error) { + console.error(error); + setConfigStatus(`保存失败: ${error.message}`, 'error'); + if (!silent) { + showMessage(`保存失败: ${error.message}`, 'error'); + } + return false; + } finally { + if (!silent && saveButton) { + saveButton.disabled = false; + saveButton.textContent = '保存'; + } + } + } + + function updateStartButtonState() { + const startButton = document.getElementById('startSystemButton'); + if (!startButton) { + return; + } + + if (systemStarting) { + startButton.disabled = true; + startButton.textContent = '启动中...'; + } else if (systemStarted) { + startButton.disabled = true; + startButton.textContent = '系统已启动'; + } else { + startButton.disabled = false; + startButton.textContent = START_BUTTON_DEFAULT_TEXT; + } + } + + function updateConfigCloseButton() { + const closeButton = document.getElementById('closeConfigModal'); + if (!closeButton) { + return; + } + if (configModalLocked && !systemStarted) { + closeButton.setAttribute('disabled', 'disabled'); + } else { + closeButton.removeAttribute('disabled'); + } + } + + function applySystemState(state) { + if (!state) { + return; + } + if (Object.prototype.hasOwnProperty.call(state, 'started')) { + systemStarted = !!state.started; + } + if (Object.prototype.hasOwnProperty.call(state, 'starting')) { + systemStarting = !!state.starting; + } + updateStartButtonState(); + updateConfigCloseButton(); + } + + async function fetchSystemStatus() { + try { + const response = await fetch(SYSTEM_STATUS_ENDPOINT); + const data = await response.json(); + if (data && data.success) { + applySystemState(data); + } + return data; + } catch (error) { + console.error('获取系统状态失败', error); + return null; + } + } + + async function ensureSystemReadyOnLoad() { + const status = await fetchSystemStatus(); + if (!status || !status.success) { + openConfigModal({ + lock: true, + message: '无法获取系统状态,请检查配置后重试。' + }); + return; + } + + if (!status.started) { + openConfigModal({ + lock: true, + message: '请先确认配置,然后点击“保存并启动系统”' + }); + } else { + applySystemState(status); + configModalLocked = false; + } + } + + async function startSystem() { + if (systemStarting) { + setConfigStatus('系统正在启动,请稍候...', ''); + return; + } + + systemStarting = true; + updateStartButtonState(); + + try { + if (configDirty) { + setConfigStatus('检测到未保存的修改,正在保存配置...', ''); + const saved = await saveConfigUpdates({ silent: true }); + if (!saved) { + systemStarting = false; + updateStartButtonState(); + return; + } + } + + setConfigStatus('正在启动系统...', ''); + const response = await fetch(SYSTEM_START_ENDPOINT, { method: 'POST' }); + const data = await response.json(); + if (!response.ok || !data.success) { + const message = data && data.message ? data.message : '系统启动失败'; + throw new Error(message); + } + + showMessage('系统启动成功', 'success'); + setConfigStatus('系统启动成功', 'success'); + applySystemState({ started: true, starting: false }); + configModalLocked = false; + + setTimeout(() => { + closeConfigModal(); + }, 800); + + setTimeout(() => { + checkStatus(); + }, 1000); + + setTimeout(() => { + window.location.reload(); + }, 1200); + } catch (error) { + setConfigStatus(`系统启动失败: ${error.message}`, 'error'); + showMessage(`系统启动失败: ${error.message}`, 'error'); + applySystemState({ started: false, starting: false }); + } finally { + systemStarting = false; + updateStartButtonState(); + await fetchSystemStatus(); + } } // 执行搜索