浩彬
Committed by GitHub

Shutdown only clean 增加安全关闭系统的功能 (#441)

* feat: add shutdown endpoint and minimal UI

* feat(ui): 添加系统关闭确认弹窗和安全刷新功能

- 新增系统关闭确认弹窗,显示运行中的服务和端口状态
- 添加安全刷新按钮,优化状态更新流程
- 重构关机逻辑,增加超时处理和错误恢复机制
- 改进状态栏布局和操作按钮样式

* feat(ui): update shutdown messages in index.html
@@ -343,4 +343,6 @@ OperationGuidance/ @@ -343,4 +343,6 @@ OperationGuidance/
343 insight_engine_streamlit_reports/ 343 insight_engine_streamlit_reports/
344 media_engine_streamlit_reports/ 344 media_engine_streamlit_reports/
345 query_engine_streamlit_reports/ 345 query_engine_streamlit_reports/
346 -final_reports/  
  346 +final_reports/
  347 +forward_prs.sh
  348 +变更报告/屏幕录制 2025-12-03 094707.mp4
@@ -226,7 +226,8 @@ def write_config_values(updates): @@ -226,7 +226,8 @@ def write_config_values(updates):
226 system_state_lock = threading.Lock() 226 system_state_lock = threading.Lock()
227 system_state = { 227 system_state = {
228 'started': False, 228 'started': False,
229 - 'starting': False 229 + 'starting': False,
  230 + 'shutdown_in_progress': False
230 } 231 }
231 232
232 233
@@ -255,6 +256,14 @@ def _prepare_system_start(): @@ -255,6 +256,14 @@ def _prepare_system_start():
255 system_state['starting'] = True 256 system_state['starting'] = True
256 return True, None 257 return True, None
257 258
  259 +def _mark_shutdown_requested():
  260 + """标记关机已请求;若已有关机流程则返回 False。"""
  261 + with system_state_lock:
  262 + if system_state.get('shutdown_in_progress'):
  263 + return False
  264 + system_state['shutdown_in_progress'] = True
  265 + return True
  266 +
258 267
259 def initialize_system_components(): 268 def initialize_system_components():
260 """启动所有依赖组件(Streamlit 子应用、ForumEngine、ReportEngine)。""" 269 """启动所有依赖组件(Streamlit 子应用、ForumEngine、ReportEngine)。"""
@@ -500,6 +509,21 @@ STREAMLIT_SCRIPTS = { @@ -500,6 +509,21 @@ STREAMLIT_SCRIPTS = {
500 'query': 'SingleEngineApp/query_engine_streamlit_app.py' 509 'query': 'SingleEngineApp/query_engine_streamlit_app.py'
501 } 510 }
502 511
  512 +def _log_shutdown_step(message: str):
  513 + """统一记录关机步骤,便于排查。"""
  514 + logger.info(f"[Shutdown] {message}")
  515 +
  516 +
  517 +def _describe_running_children():
  518 + """列出当前存活的子进程。"""
  519 + running = []
  520 + for name, info in processes.items():
  521 + proc = info.get('process')
  522 + if proc is not None and proc.poll() is None:
  523 + port_desc = f", port={info.get('port')}" if info.get('port') else ""
  524 + running.append(f"{name}(pid={proc.pid}{port_desc})")
  525 + return running
  526 +
503 # 输出队列 527 # 输出队列
504 output_queues = { 528 output_queues = {
505 'insight': Queue(), 529 'insight': Queue(),
@@ -683,18 +707,28 @@ def start_streamlit_app(app_name, script_path, port): @@ -683,18 +707,28 @@ def start_streamlit_app(app_name, script_path, port):
683 def stop_streamlit_app(app_name): 707 def stop_streamlit_app(app_name):
684 """停止Streamlit应用""" 708 """停止Streamlit应用"""
685 try: 709 try:
686 - if processes[app_name]['process'] is None: 710 + process = processes[app_name]['process']
  711 + if process is None:
  712 + _log_shutdown_step(f"{app_name} 未运行,跳过停止")
687 return False, "应用未运行" 713 return False, "应用未运行"
688 714
689 - process = processes[app_name]['process'] 715 + try:
  716 + pid = process.pid
  717 + except Exception:
  718 + pid = 'unknown'
  719 +
  720 + _log_shutdown_step(f"正在停止 {app_name} (pid={pid})")
690 process.terminate() 721 process.terminate()
691 722
692 # 等待进程结束 723 # 等待进程结束
693 try: 724 try:
694 process.wait(timeout=5) 725 process.wait(timeout=5)
  726 + _log_shutdown_step(f"{app_name} 退出完成,returncode={process.returncode}")
695 except subprocess.TimeoutExpired: 727 except subprocess.TimeoutExpired:
  728 + _log_shutdown_step(f"{app_name} 终止超时,尝试强制结束 (pid={pid})")
696 process.kill() 729 process.kill()
697 process.wait() 730 process.wait()
  731 + _log_shutdown_step(f"{app_name} 已强制结束,returncode={process.returncode}")
698 732
699 processes[app_name]['process'] = None 733 processes[app_name]['process'] = None
700 processes[app_name]['status'] = 'stopped' 734 processes[app_name]['status'] = 'stopped'
@@ -702,6 +736,7 @@ def stop_streamlit_app(app_name): @@ -702,6 +736,7 @@ def stop_streamlit_app(app_name):
702 return True, f"{app_name} 应用已停止" 736 return True, f"{app_name} 应用已停止"
703 737
704 except Exception as e: 738 except Exception as e:
  739 + _log_shutdown_step(f"{app_name} 停止失败: {e}")
705 return False, f"停止失败: {str(e)}" 740 return False, f"停止失败: {str(e)}"
706 741
707 HEALTHCHECK_PATH = "/_stcore/health" 742 HEALTHCHECK_PATH = "/_stcore/health"
@@ -766,6 +801,7 @@ def wait_for_app_startup(app_name, max_wait_time=90): @@ -766,6 +801,7 @@ def wait_for_app_startup(app_name, max_wait_time=90):
766 801
767 def cleanup_processes(): 802 def cleanup_processes():
768 """清理所有进程""" 803 """清理所有进程"""
  804 + _log_shutdown_step("开始串行清理子进程")
769 for app_name in STREAMLIT_SCRIPTS: 805 for app_name in STREAMLIT_SCRIPTS:
770 stop_streamlit_app(app_name) 806 stop_streamlit_app(app_name)
771 807
@@ -774,8 +810,101 @@ def cleanup_processes(): @@ -774,8 +810,101 @@ def cleanup_processes():
774 stop_forum_engine() 810 stop_forum_engine()
775 except Exception: # pragma: no cover 811 except Exception: # pragma: no cover
776 logger.exception("停止ForumEngine失败") 812 logger.exception("停止ForumEngine失败")
  813 + _log_shutdown_step("子进程清理完成")
777 _set_system_state(started=False, starting=False) 814 _set_system_state(started=False, starting=False)
778 815
  816 +def cleanup_processes_concurrent(timeout: float = 6.0):
  817 + """并发清理所有子进程,超时后强制杀掉残留进程。"""
  818 + _log_shutdown_step(f"开始并发清理子进程(超时 {timeout}s)")
  819 + _log_shutdown_step("仅终止当前控制台启动并记录的子进程,不做端口扫描")
  820 + running_before = _describe_running_children()
  821 + if running_before:
  822 + _log_shutdown_step("当前存活子进程: " + ", ".join(running_before))
  823 + else:
  824 + _log_shutdown_step("未检测到存活子进程,仍将发送关闭指令")
  825 +
  826 + threads = []
  827 +
  828 + # 并发关闭 Streamlit 子进程
  829 + for app_name in STREAMLIT_SCRIPTS:
  830 + t = threading.Thread(target=stop_streamlit_app, args=(app_name,), daemon=True)
  831 + threads.append(t)
  832 + t.start()
  833 +
  834 + # 并发关闭 ForumEngine
  835 + forum_thread = threading.Thread(target=stop_forum_engine, daemon=True)
  836 + threads.append(forum_thread)
  837 + forum_thread.start()
  838 +
  839 + # 等待所有线程完成,最多 timeout 秒
  840 + end_time = time.time() + timeout
  841 + for t in threads:
  842 + remaining = end_time - time.time()
  843 + if remaining <= 0:
  844 + break
  845 + t.join(timeout=remaining)
  846 +
  847 + # 二次检查:强制杀掉仍存活的子进程
  848 + for app_name in STREAMLIT_SCRIPTS:
  849 + proc = processes[app_name]['process']
  850 + if proc is not None and proc.poll() is None:
  851 + try:
  852 + _log_shutdown_step(f"{app_name} 进程仍存活,触发二次终止 (pid={proc.pid})")
  853 + proc.terminate()
  854 + proc.wait(timeout=1)
  855 + except Exception:
  856 + try:
  857 + _log_shutdown_step(f"{app_name} 二次终止失败,尝试kill (pid={proc.pid})")
  858 + proc.kill()
  859 + proc.wait(timeout=1)
  860 + except Exception:
  861 + logger.warning(f"{app_name} 进程强制退出失败,继续关机")
  862 + finally:
  863 + processes[app_name]['process'] = None
  864 + processes[app_name]['status'] = 'stopped'
  865 +
  866 + processes['forum']['status'] = 'stopped'
  867 + _log_shutdown_step("并发清理结束,标记系统未启动")
  868 + _set_system_state(started=False, starting=False)
  869 +
  870 +def _schedule_server_shutdown(delay_seconds: float = 0.1):
  871 + """在清理完成后尽快退出,避免阻塞当前请求。"""
  872 + def _shutdown():
  873 + time.sleep(delay_seconds)
  874 + try:
  875 + socketio.stop()
  876 + except Exception as exc: # pragma: no cover
  877 + logger.warning(f"SocketIO 停止时异常,继续退出: {exc}")
  878 + _log_shutdown_step("SocketIO 停止指令已发送,即将退出主进程")
  879 + os._exit(0)
  880 +
  881 + threading.Thread(target=_shutdown, daemon=True).start()
  882 +
  883 +def _start_async_shutdown(cleanup_timeout: float = 3.0):
  884 + """异步触发清理并强制退出,避免HTTP请求阻塞。"""
  885 + _log_shutdown_step(f"收到关机指令,启动异步清理(超时 {cleanup_timeout}s)")
  886 +
  887 + def _force_exit():
  888 + _log_shutdown_step("关机超时,触发强制退出")
  889 + os._exit(0)
  890 +
  891 + # 硬超时保护,即便清理线程异常也能退出
  892 + hard_timeout = cleanup_timeout + 2.0
  893 + force_timer = threading.Timer(hard_timeout, _force_exit)
  894 + force_timer.daemon = True
  895 + force_timer.start()
  896 +
  897 + def _cleanup_and_exit():
  898 + try:
  899 + cleanup_processes_concurrent(timeout=cleanup_timeout)
  900 + except Exception as exc: # pragma: no cover
  901 + logger.exception(f"关机清理异常: {exc}")
  902 + finally:
  903 + _log_shutdown_step("清理线程结束,调度主进程退出")
  904 + _schedule_server_shutdown(0.05)
  905 +
  906 + threading.Thread(target=_cleanup_and_exit, daemon=True).start()
  907 +
779 # 注册清理函数 908 # 注册清理函数
780 atexit.register(cleanup_processes) 909 atexit.register(cleanup_processes)
781 910
@@ -1124,6 +1253,48 @@ def start_system(): @@ -1124,6 +1253,48 @@ def start_system():
1124 finally: 1253 finally:
1125 _set_system_state(starting=False) 1254 _set_system_state(starting=False)
1126 1255
  1256 +@app.route('/api/system/shutdown', methods=['POST'])
  1257 +def shutdown_system():
  1258 + """优雅停止所有组件并关闭当前服务进程。"""
  1259 + state = _get_system_state()
  1260 + if state['starting']:
  1261 + return jsonify({'success': False, 'message': '系统正在启动/重启,请稍候'}), 400
  1262 +
  1263 + target_ports = [
  1264 + f"{name}:{info['port']}"
  1265 + for name, info in processes.items()
  1266 + if info.get('port')
  1267 + ]
  1268 +
  1269 + # 已有关机请求执行中时,返回当前存活的子进程,便于前端判断进度
  1270 + if not _mark_shutdown_requested():
  1271 + running = _describe_running_children()
  1272 + detail = '关机指令已下发,请稍等...'
  1273 + if running:
  1274 + detail = f"关机指令已下发,等待进程退出: {', '.join(running)}"
  1275 + if target_ports:
  1276 + detail = f"{detail}(端口: {', '.join(target_ports)})"
  1277 + return jsonify({'success': True, 'message': detail, 'ports': target_ports})
  1278 +
  1279 + running = _describe_running_children()
  1280 + if running:
  1281 + _log_shutdown_step("开始关闭系统,正在等待子进程退出: " + ", ".join(running))
  1282 + else:
  1283 + _log_shutdown_step("开始关闭系统,未检测到存活子进程")
  1284 +
  1285 + try:
  1286 + _set_system_state(started=False, starting=False)
  1287 + _start_async_shutdown(cleanup_timeout=6.0)
  1288 + message = '关闭系统指令已下发,正在停止进程'
  1289 + if running:
  1290 + message = f"{message}: {', '.join(running)}"
  1291 + if target_ports:
  1292 + message = f"{message}(端口: {', '.join(target_ports)})"
  1293 + return jsonify({'success': True, 'message': message, 'ports': target_ports})
  1294 + except Exception as exc: # pragma: no cover - 兜底捕获
  1295 + logger.exception("系统关闭过程中出现异常")
  1296 + return jsonify({'success': False, 'message': f'系统关闭异常: {exc}'}), 500
  1297 +
1127 @socketio.on('connect') 1298 @socketio.on('connect')
1128 def handle_connect(): 1299 def handle_connect():
1129 """客户端连接""" 1300 """客户端连接"""
@@ -34,6 +34,60 @@ @@ -34,6 +34,60 @@
34 flex-direction: column; 34 flex-direction: column;
35 border: 2px solid #000000; 35 border: 2px solid #000000;
36 overflow: hidden; /* 防止整体滚动 */ 36 overflow: hidden; /* 防止整体滚动 */
  37 + position: relative;
  38 + }
  39 +
  40 + .page-actions {
  41 + position: absolute;
  42 + top: 16px;
  43 + right: 18px;
  44 + display: flex;
  45 + gap: 10px;
  46 + z-index: 20;
  47 + }
  48 +
  49 + .page-action-button {
  50 + border: 2px solid #000000;
  51 + background-color: #ffffff;
  52 + color: #000000;
  53 + padding: 10px 14px;
  54 + font-weight: bold;
  55 + cursor: pointer;
  56 + box-shadow: 4px 4px 0 #000000;
  57 + transition: transform 0.12s ease, box-shadow 0.12s ease, background-color 0.2s ease, color 0.2s ease;
  58 + letter-spacing: 0.5px;
  59 + }
  60 +
  61 + .page-action-button:hover {
  62 + transform: translate(-2px, -2px);
  63 + box-shadow: 6px 6px 0 #000000;
  64 + background-color: #f5f5f5;
  65 + }
  66 +
  67 + .page-action-button:disabled {
  68 + opacity: 0.6;
  69 + cursor: not-allowed;
  70 + transform: none;
  71 + box-shadow: 2px 2px 0 #777777;
  72 + }
  73 +
  74 + .page-action-button.refresh {
  75 + background-color: #ffffff;
  76 + color: #000000;
  77 + }
  78 +
  79 + .page-action-button.shutdown {
  80 + background-color: #000000;
  81 + color: #ffffff;
  82 + border-color: #000000;
  83 + box-shadow: none;
  84 + }
  85 +
  86 + .page-action-button.shutdown:hover {
  87 + background-color: #1a1a1a;
  88 + color: #ffffff;
  89 + transform: none;
  90 + box-shadow: none;
37 } 91 }
38 92
39 /* 搜索框区域 */ 93 /* 搜索框区域 */
@@ -491,6 +545,23 @@ @@ -491,6 +545,23 @@
491 align-items: center; 545 align-items: center;
492 } 546 }
493 547
  548 + .status-bar-left {
  549 + display: flex;
  550 + align-items: center;
  551 + gap: 12px;
  552 + }
  553 +
  554 + .status-shutdown-button {
  555 + padding: 8px 16px;
  556 + box-shadow: none;
  557 + }
  558 +
  559 + .status-shutdown-button:hover,
  560 + .status-shutdown-button:disabled {
  561 + box-shadow: none;
  562 + transform: none;
  563 + }
  564 +
494 .config-modal-overlay { 565 .config-modal-overlay {
495 position: fixed; 566 position: fixed;
496 inset: 0; 567 inset: 0;
@@ -916,6 +987,143 @@ @@ -916,6 +987,143 @@
916 background-color: #fff0f0; 987 background-color: #fff0f0;
917 } 988 }
918 989
  990 + /* 系统关机确认弹窗 */
  991 + .confirm-overlay {
  992 + position: fixed;
  993 + inset: 0;
  994 + background-color: rgba(0, 0, 0, 0.35);
  995 + display: none;
  996 + align-items: center;
  997 + justify-content: center;
  998 + z-index: 1200;
  999 + padding: 20px;
  1000 + }
  1001 +
  1002 + .confirm-overlay.visible {
  1003 + display: flex;
  1004 + }
  1005 +
  1006 + .confirm-dialog {
  1007 + background-color: #ffffff;
  1008 + border: 2px solid #000000;
  1009 + width: 420px;
  1010 + max-width: 90vw;
  1011 + display: flex;
  1012 + flex-direction: column;
  1013 + }
  1014 +
  1015 + .confirm-dialog.danger {
  1016 + border-color: #b42318;
  1017 + }
  1018 +
  1019 + .confirm-header {
  1020 + display: flex;
  1021 + align-items: center;
  1022 + justify-content: space-between;
  1023 + padding: 14px 16px;
  1024 + border-bottom: 2px solid #000000;
  1025 + }
  1026 +
  1027 + .confirm-dialog.danger .confirm-header {
  1028 + border-color: #b42318;
  1029 + }
  1030 +
  1031 + .confirm-title {
  1032 + font-size: 16px;
  1033 + font-weight: bold;
  1034 + }
  1035 +
  1036 + .confirm-close {
  1037 + width: 30px;
  1038 + height: 30px;
  1039 + border: 2px solid #000000;
  1040 + background-color: #ffffff;
  1041 + cursor: pointer;
  1042 + font-size: 18px;
  1043 + line-height: 1;
  1044 + display: flex;
  1045 + align-items: center;
  1046 + justify-content: center;
  1047 + }
  1048 +
  1049 + .confirm-close:hover {
  1050 + background-color: #000000;
  1051 + color: #ffffff;
  1052 + }
  1053 +
  1054 + .confirm-body {
  1055 + padding: 16px;
  1056 + border-bottom: 2px solid #000000;
  1057 + }
  1058 +
  1059 + .confirm-dialog.danger .confirm-body {
  1060 + border-color: #b42318;
  1061 + }
  1062 +
  1063 + .confirm-text {
  1064 + display: flex;
  1065 + flex-direction: column;
  1066 + gap: 8px;
  1067 + }
  1068 +
  1069 + .confirm-list {
  1070 + display: flex;
  1071 + flex-direction: column;
  1072 + gap: 6px;
  1073 + }
  1074 +
  1075 + .confirm-pill {
  1076 + padding: 4px 10px;
  1077 + border: 2px solid #000000;
  1078 + background-color: #f5f5f0;
  1079 + font-size: 12px;
  1080 + font-weight: bold;
  1081 + }
  1082 +
  1083 + .confirm-dialog .confirm-button.danger {
  1084 + background-color: #ffecec;
  1085 + color: #b42318;
  1086 + }
  1087 +
  1088 + .confirm-dialog .confirm-button.danger:hover {
  1089 + background-color: #ffc9c9;
  1090 + color: #8c1111;
  1091 + }
  1092 +
  1093 + .shutdown-pill {
  1094 + text-align: center;
  1095 + display: inline-flex;
  1096 + justify-content: center;
  1097 + align-items: center;
  1098 + }
  1099 +
  1100 + .confirm-subtext {
  1101 + font-size: 12px;
  1102 + color: #555555;
  1103 + line-height: 1.5;
  1104 + }
  1105 +
  1106 + .confirm-actions {
  1107 + display: flex;
  1108 + justify-content: flex-end;
  1109 + gap: 10px;
  1110 + padding: 12px 16px;
  1111 + }
  1112 +
  1113 + .confirm-button {
  1114 + padding: 9px 18px;
  1115 + border: 2px solid #000000;
  1116 + background-color: #ffffff;
  1117 + cursor: pointer;
  1118 + font-size: 13px;
  1119 + font-weight: bold;
  1120 + transition: all 0.3s ease;
  1121 + }
  1122 +
  1123 + .confirm-button:hover {
  1124 + background-color: #f0f0f0;
  1125 + }
  1126 +
919 /* Forum Engine 专用样式 */ 1127 /* Forum Engine 专用样式 */
920 .forum-container { 1128 .forum-container {
921 display: none; 1129 display: none;
@@ -1331,8 +1539,31 @@ @@ -1331,8 +1539,31 @@
1331 1539
1332 <!-- 状态栏:实时展示WebSocket连接状态与系统时钟 --> 1540 <!-- 状态栏:实时展示WebSocket连接状态与系统时钟 -->
1333 <div class="status-bar"> 1541 <div class="status-bar">
1334 - <span id="connectionStatus">连接中...</span>  
1335 - <span id="systemTime"></span> 1542 + <div class="status-bar-left">
  1543 + <span id="connectionStatus">连接中...</span>
  1544 + <span id="systemTime"></span>
  1545 + </div>
  1546 + <button class="page-action-button shutdown status-shutdown-button" id="shutdownButton" aria-label="关闭系统">关闭系统</button>
  1547 + </div>
  1548 + </div>
  1549 +
  1550 + <div class="confirm-overlay" id="shutdownConfirmModal" aria-hidden="true">
  1551 + <div class="confirm-dialog danger">
  1552 + <div class="confirm-header">
  1553 + <div class="confirm-title" id="shutdownStrongText">确定要关闭系统吗?</div>
  1554 + <button class="confirm-close" id="closeShutdownConfirm" aria-label="关闭系统确认">×</button>
  1555 + </div>
  1556 + <div class="confirm-body">
  1557 + <div class="confirm-text">
  1558 + <div class="confirm-subtext" id="shutdownSubText">将关闭系统并停止由本控制台启动的子进程,涉及端口/进程:</div>
  1559 + <div class="confirm-list" id="shutdownRunningList"></div>
  1560 + <div class="confirm-list" id="shutdownPortList"></div>
  1561 + </div>
  1562 + </div>
  1563 + <div class="confirm-actions">
  1564 + <button class="confirm-button" id="cancelShutdownButton">取消</button>
  1565 + <button class="confirm-button danger" id="confirmShutdownButton">立即关闭系统</button>
  1566 + </div>
1336 </div> 1567 </div>
1337 </div> 1568 </div>
1338 1569
@@ -1753,10 +1984,19 @@ @@ -1753,10 +1984,19 @@
1753 } 1984 }
1754 } 1985 }
1755 1986
  1987 + let pageRefreshInProgress = false;
  1988 + let shutdownInProgress = false;
  1989 +
1756 const CONFIG_ENDPOINT = '/api/config'; 1990 const CONFIG_ENDPOINT = '/api/config';
1757 const SYSTEM_STATUS_ENDPOINT = '/api/system/status'; 1991 const SYSTEM_STATUS_ENDPOINT = '/api/system/status';
1758 const SYSTEM_START_ENDPOINT = '/api/system/start'; 1992 const SYSTEM_START_ENDPOINT = '/api/system/start';
  1993 + const SYSTEM_SHUTDOWN_ENDPOINT = '/api/system/shutdown';
1759 const START_BUTTON_DEFAULT_TEXT = '保存并启动系统'; 1994 const START_BUTTON_DEFAULT_TEXT = '保存并启动系统';
  1995 + const APP_PORTS = {
  1996 + insight: 8501,
  1997 + media: 8502,
  1998 + query: 8503
  1999 + };
1760 2000
1761 const configFieldGroups = [ 2001 const configFieldGroups = [
1762 { 2002 {
@@ -2002,6 +2242,34 @@ @@ -2002,6 +2242,34 @@
2002 startSystemButton.addEventListener('click', () => startSystem()); 2242 startSystemButton.addEventListener('click', () => startSystem());
2003 } 2243 }
2004 2244
  2245 + const refreshPageButton = document.getElementById('pageRefreshButton');
  2246 + if (refreshPageButton) {
  2247 + refreshPageButton.addEventListener('click', () => handleSafeRefresh());
  2248 + }
  2249 +
  2250 + const shutdownButton = document.getElementById('shutdownButton');
  2251 + if (shutdownButton) {
  2252 + shutdownButton.addEventListener('click', () => handleShutdownRequest());
  2253 + }
  2254 +
  2255 + const cancelShutdownButton = document.getElementById('cancelShutdownButton');
  2256 + if (cancelShutdownButton) {
  2257 + cancelShutdownButton.addEventListener('click', () => hideShutdownConfirm());
  2258 + }
  2259 +
  2260 + const closeShutdownButton = document.getElementById('closeShutdownConfirm');
  2261 + if (closeShutdownButton) {
  2262 + closeShutdownButton.addEventListener('click', () => hideShutdownConfirm());
  2263 + }
  2264 +
  2265 + const confirmShutdownButton = document.getElementById('confirmShutdownButton');
  2266 + if (confirmShutdownButton) {
  2267 + confirmShutdownButton.addEventListener('click', () => {
  2268 + hideShutdownConfirm();
  2269 + shutdownSystem({ skipAgentWarning: true });
  2270 + });
  2271 + }
  2272 +
2005 const configModal = document.getElementById('configModal'); 2273 const configModal = document.getElementById('configModal');
2006 if (configModal) { 2274 if (configModal) {
2007 configModal.addEventListener('click', (event) => { 2275 configModal.addEventListener('click', (event) => {
@@ -2020,8 +2288,14 @@ @@ -2020,8 +2288,14 @@
2020 } 2288 }
2021 2289
2022 document.addEventListener('keydown', function(event) { 2290 document.addEventListener('keydown', function(event) {
2023 - if (event.key === 'Escape' && isConfigModalVisible()) {  
2024 - closeConfigModal(); 2291 + if (event.key === 'Escape') {
  2292 + if (isConfigModalVisible()) {
  2293 + closeConfigModal();
  2294 + }
  2295 + const shutdownModal = document.getElementById('shutdownConfirmModal');
  2296 + if (shutdownModal && shutdownModal.classList.contains('visible')) {
  2297 + hideShutdownConfirm();
  2298 + }
2025 } 2299 }
2026 }); 2300 });
2027 } 2301 }
@@ -2494,6 +2768,154 @@ @@ -2494,6 +2768,154 @@
2494 } 2768 }
2495 } 2769 }
2496 2770
  2771 + function getRunningAgents() {
  2772 + return Object.keys(appStatus).filter(app => appStatus[app] === 'running');
  2773 + }
  2774 +
  2775 + function hideShutdownConfirm() {
  2776 + const modal = document.getElementById('shutdownConfirmModal');
  2777 + if (modal) {
  2778 + modal.classList.remove('visible');
  2779 + modal.setAttribute('aria-hidden', 'true');
  2780 + }
  2781 + }
  2782 +
  2783 + function showShutdownConfirm(runningAgents = []) {
  2784 + const modal = document.getElementById('shutdownConfirmModal');
  2785 + const list = document.getElementById('shutdownRunningList');
  2786 + const portList = document.getElementById('shutdownPortList');
  2787 + const strongText = document.getElementById('shutdownStrongText');
  2788 +
  2789 + if (strongText) {
  2790 + strongText.textContent = runningAgents.length > 0
  2791 + ? '部分 Agent 正在运行,确定要关闭吗?'
  2792 + : '确定要关闭系统吗?';
  2793 + }
  2794 +
  2795 + if (list) {
  2796 + list.innerHTML = '';
  2797 + list.style.display = 'none';
  2798 + }
  2799 +
  2800 + if (portList) {
  2801 + const targets = Object.entries(APP_PORTS).map(([key, port]) => {
  2802 + const status = appStatus[key] || 'unknown';
  2803 + const label = `${appNames[key] || key}${port ? `:${port}` : ''}`;
  2804 + const suffix = status === 'running' ? '运行中' : '未运行';
  2805 + return `<span class="confirm-pill shutdown-pill">${label} · ${suffix}</span>`;
  2806 + });
  2807 + portList.innerHTML = targets.length > 0
  2808 + ? targets.join('')
  2809 + : '<span class="confirm-pill">暂无需要关闭的端口</span>';
  2810 + }
  2811 +
  2812 + if (modal) {
  2813 + modal.classList.add('visible');
  2814 + modal.setAttribute('aria-hidden', 'false');
  2815 + }
  2816 + }
  2817 +
  2818 + async function handleSafeRefresh() {
  2819 + if (pageRefreshInProgress) {
  2820 + return;
  2821 + }
  2822 +
  2823 + pageRefreshInProgress = true;
  2824 + const refreshButton = document.getElementById('pageRefreshButton');
  2825 + const originalText = refreshButton ? refreshButton.textContent : '';
  2826 + if (refreshButton) {
  2827 + refreshButton.disabled = true;
  2828 + refreshButton.textContent = '刷新中...';
  2829 + }
  2830 +
  2831 + try {
  2832 + await fetchSystemStatus();
  2833 + await checkStatus();
  2834 + refreshConsoleOutput();
  2835 + showMessage('已刷新最新状态与日志', 'success');
  2836 + } catch (error) {
  2837 + console.error('刷新页面数据失败', error);
  2838 + showMessage(`刷新失败: ${error.message}`, 'error');
  2839 + } finally {
  2840 + pageRefreshInProgress = false;
  2841 + if (refreshButton) {
  2842 + refreshButton.disabled = false;
  2843 + refreshButton.textContent = originalText || '安全刷新';
  2844 + }
  2845 + }
  2846 + }
  2847 +
  2848 + async function handleShutdownRequest() {
  2849 + if (shutdownInProgress) {
  2850 + return;
  2851 + }
  2852 +
  2853 + if (systemStarting) {
  2854 + showMessage('系统正在启动/重启,请稍后再关闭', 'error');
  2855 + return;
  2856 + }
  2857 +
  2858 + const runningAgents = getRunningAgents();
  2859 + if (runningAgents.length > 0) {
  2860 + showShutdownConfirm(runningAgents);
  2861 + return;
  2862 + }
  2863 +
  2864 + shutdownSystem({ skipAgentWarning: true });
  2865 + }
  2866 +
  2867 + async function shutdownSystem(options = {}) {
  2868 + const { skipAgentWarning = false } = options;
  2869 +
  2870 + if (shutdownInProgress) {
  2871 + return;
  2872 + }
  2873 +
  2874 + if (!skipAgentWarning) {
  2875 + const runningAgents = getRunningAgents();
  2876 + if (runningAgents.length > 0) {
  2877 + showShutdownConfirm(runningAgents);
  2878 + return;
  2879 + }
  2880 + }
  2881 +
  2882 + shutdownInProgress = true;
  2883 + const button = document.getElementById('shutdownButton');
  2884 + const originalText = button ? button.textContent : '';
  2885 + if (button) {
  2886 + button.disabled = true;
  2887 + button.textContent = '关闭中...';
  2888 + }
  2889 +
  2890 + try {
  2891 + const controller = new AbortController();
  2892 + const timeoutId = setTimeout(() => controller.abort(), 4000);
  2893 + const response = await fetch(SYSTEM_SHUTDOWN_ENDPOINT, { method: 'POST', signal: controller.signal });
  2894 + clearTimeout(timeoutId);
  2895 +
  2896 + const message = '系统正在停止,请稍候...';
  2897 + if (!response.ok) {
  2898 + throw new Error(`服务返回 ${response.status}`);
  2899 + }
  2900 +
  2901 + setConfigStatus(message, 'success');
  2902 + showMessage(message, 'success');
  2903 + } catch (error) {
  2904 + const text = error.name === 'AbortError'
  2905 + ? '停止指令已发送,请稍候退出'
  2906 + : `停止失败: ${error.message}`;
  2907 + showMessage(text, error.name === 'AbortError' ? 'success' : 'error');
  2908 +
  2909 + if (error.name !== 'AbortError') {
  2910 + shutdownInProgress = false;
  2911 + if (button) {
  2912 + button.disabled = false;
  2913 + button.textContent = originalText || '关闭系统';
  2914 + }
  2915 + }
  2916 + }
  2917 + }
  2918 +
2497 async function startSystem() { 2919 async function startSystem() {
2498 if (systemStarting) { 2920 if (systemStarting) {
2499 setConfigStatus('系统正在启动,请稍候...', ''); 2921 setConfigStatus('系统正在启动,请稍候...', '');