Просмотр исходного кода

feat(bootstrap): 启动探测改为10次重试并支持手动重连

- 启动健康检查统一 10 次 × 3 秒,loading 页显示重试次数与红色重连提示
- 10 次失败后停在 loading 页,提供重连与修改服务地址入口,不再无限转圈
- 重连成功后自动刷新恢复;移除原大转圈,改为文字前的小旋转图标
- 修改服务地址进入授权页的服务地址卡片,保留桌面端自救通道
cc 3 дней назад
Родитель
Сommit
ba9e871454

+ 5 - 3
ai-electron/frontend/index.html

@@ -15,10 +15,12 @@
       <div class="app-loading">
         <div class="app-loading-wrap">
           <img src="/resource/img/logo.png" class="app-loading-logo" alt="Logo" />
-          <div class="app-loading-dots">
-            <span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
-          </div>
           <div class="app-loading-title"><%= VITE_GLOB_APP_TITLE %></div>
+          <div class="app-loading-tip" role="status" aria-live="polite"></div>
+          <div class="app-loading-actions">
+            <button class="app-loading-reconnect" type="button">重连</button>
+            <button class="app-loading-server-setup" type="button">修改服务地址</button>
+          </div>
         </div>
       </div>
     </div>

+ 96 - 62
ai-electron/frontend/public/resource/css/index.css

@@ -42,82 +42,116 @@ body,
   align-items: center;
 }
 
-.app-loading .app-loading-logo {
-  display: block;
-  width: 90px;
-  margin: 0 auto;
-  margin-bottom: 20px;
+/* 启动探测进度:红字 + 文字前的小旋转图标表示正在重连;不再使用页面中央的大转圈 */
+.app-loading .app-loading-tip {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-height: 20px;
+  margin-top: 18px;
+  font-size: 13px;
+  line-height: 20px;
+  color: #6b7280;
+  text-align: center;
 }
 
-.dot {
-  position: relative;
-  display: inline-block;
-  width: 48px;
-  height: 48px;
-  margin-top: 30px;
-  font-size: 32px;
-  transform: rotate(45deg);
-  box-sizing: border-box;
-  animation: antRotate 1.2s infinite linear;
+.app-loading .app-loading-tip.is-retrying,
+.app-loading .app-loading-tip.is-failed {
+  color: #cf1322;
 }
 
-.dot i {
-  position: absolute;
-  display: block;
-  width: 20px;
-  height: 20px;
-  background-color: #0065cc;
-  border-radius: 100%;
-  opacity: 0.3;
-  transform: scale(0.75);
-  animation: antSpinMove 1s infinite linear alternate;
-  transform-origin: 50% 50%;
+.app-loading .app-loading-tip.is-retrying::before {
+  content: '';
+  flex: 0 0 auto;
+  width: 12px;
+  height: 12px;
+  border: 2px solid rgb(207 19 34 / 25%);
+  border-top-color: #cf1322;
+  border-radius: 50%;
+  animation: app-loading-tip-spin 0.8s linear infinite;
 }
 
-.dot i:nth-child(1) {
-  top: 0;
-  left: 0;
+.app-loading .app-loading-tip.is-failed::before {
+  display: none;
 }
 
-.dot i:nth-child(2) {
-  top: 0;
-  right: 0;
-  -webkit-animation-delay: 0.4s;
-  animation-delay: 0.4s;
+@keyframes app-loading-tip-spin {
+  to {
+    transform: rotate(360deg);
+  }
 }
 
-.dot i:nth-child(3) {
-  right: 0;
-  bottom: 0;
-  -webkit-animation-delay: 0.8s;
-  animation-delay: 0.8s;
+html[data-theme='dark'] .app-loading .app-loading-tip {
+  color: rgba(255, 255, 255, 0.65);
 }
 
-.dot i:nth-child(4) {
-  bottom: 0;
-  left: 0;
-  -webkit-animation-delay: 1.2s;
-  animation-delay: 1.2s;
+html[data-theme='dark'] .app-loading .app-loading-tip.is-retrying,
+html[data-theme='dark'] .app-loading .app-loading-tip.is-failed {
+  color: #ff7875;
 }
-@keyframes antRotate {
-  to {
-    -webkit-transform: rotate(405deg);
-    transform: rotate(405deg);
-  }
+
+/* 10 次重试失败后的操作区:重连按钮 + 桌面端的修改服务地址入口 */
+.app-loading .app-loading-actions {
+  display: none;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  margin-top: 18px;
 }
-@-webkit-keyframes antRotate {
-  to {
-    -webkit-transform: rotate(405deg);
-    transform: rotate(405deg);
-  }
+
+.app-loading .app-loading-actions.is-visible {
+  display: flex;
 }
-@keyframes antSpinMove {
-  to {
-    opacity: 1;
-  }
+
+.app-loading .app-loading-reconnect {
+  min-width: 88px;
+  height: 34px;
+  padding: 0 20px;
+  font-size: 14px;
+  color: #fff;
+  background: #cf1322;
+  border: 0;
+  border-radius: 17px;
+  cursor: pointer;
+  transition: background-color 0.2s ease;
 }
-@-webkit-keyframes antSpinMove {
-  to {
-    opacity: 1;
-  }
+
+.app-loading .app-loading-reconnect:hover {
+  background: #a8071a;
+}
+
+.app-loading .app-loading-reconnect:disabled {
+  cursor: default;
+  opacity: 0.6;
+}
+
+.app-loading .app-loading-server-setup {
+  display: inline-flex;
+  align-items: center;
+  height: 34px;
+  padding: 0 8px;
+  font-size: 13px;
+  color: #6b7280;
+  background: transparent;
+  border: 0;
+  cursor: pointer;
+}
+
+.app-loading .app-loading-server-setup:hover {
+  color: #cf1322;
+}
+
+html[data-theme='dark'] .app-loading .app-loading-server-setup {
+  color: rgba(255, 255, 255, 0.65);
+}
+
+html[data-theme='dark'] .app-loading .app-loading-server-setup:hover {
+  color: #ff7875;
+}
+
+.app-loading .app-loading-logo {
+  display: block;
+  width: 90px;
+  margin: 0 auto;
+  margin-bottom: 20px;
 }

+ 2 - 2
ai-electron/frontend/src/core/api/sys/login.ts

@@ -144,9 +144,9 @@ export { getMapLocalApi, setMapLocalApi } from './mapLocal';
 export const menuRouteApi = () => defHttp.get<Menu[]>({ url: adminPath + '/menuRoute' });
 
 export const logoutApi = () => defHttp.get({ url: adminPath + '/logout' });
-export const healthApi = () =>
+export const healthApi = (timeout: number = 8 * 1000) =>
   defHttp.get<Result>(
     // 短超时:这是启动就绪探测,默认 3 分钟会让错误地址卡住整个启动流程
-    { url: adminPath + '/sys/health', timeout: 8 * 1000 },
+    { url: adminPath + '/sys/health', timeout },
     { isTransformResponse: false, skipSuccessMessage: true, skipErrorMessage: true },
   );

+ 102 - 22
ai-electron/frontend/src/main.ts

@@ -33,31 +33,106 @@ import { setupAntdTabsListenerPatch } from './setupAntdTabsListenerPatch';
 import { setupAntdPopupBehavior } from './setupAntdPopupBehavior';
 import { closeSmartWindow, focusMainWindow, initWindowRole, isSmartWindow } from '@/core/logics/appWindow';
 import { initSessionSync } from '@/core/logics/sessionSync';
-import { isServerBindingEnabled, isServerSetupMissing } from '@/core/settings/serverSetting';
+import { isServerSetupMissing } from '@/core/settings/serverSetting';
 
-async function waitForBackendReady(maxRetries: number = 60, interval: number = 2000): Promise<boolean> {
+const STARTUP_PROBE_RETRIES = 10;
+const STARTUP_PROBE_INTERVAL = 3000;
+
+/**
+ * 更新启动 loading 页上的提示。
+ *
+ * <p>此时 Vue 还没 mount,只能直接操作 index.html 里的静态 DOM。
+ * 重连中显示红色文案 + 文字前的小旋转图标;成功或最终失败时移除旋转图标。
+ */
+function updateStartupLoadingTip(text: string, state: 'retrying' | 'success' | 'failed' = 'retrying') {
+  const tip = document.querySelector<HTMLElement>('.app-loading-tip');
+  if (!tip) {
+    return;
+  }
+  tip.textContent = text;
+  tip.classList.toggle('is-retrying', state === 'retrying');
+  tip.classList.toggle('is-failed', state === 'failed');
+}
+
+/**
+ * 等待后端就绪:10 次 × 3 秒(约 30 秒),并在 loading 页实时展示重试次数。
+ *
+ * <p>按固定节拍重试而不是「探测超时 + 再等一个间隔」,单次探测上限也是 3 秒,
+ * 保证整体耗时不随失败次数膨胀;10 次仍未连上就停转,由调用方展示重连/修改服务地址入口。
+ */
+async function waitForBackendReady(
+  maxRetries: number = STARTUP_PROBE_RETRIES,
+  interval: number = STARTUP_PROBE_INTERVAL,
+): Promise<boolean> {
   console.log('[bootstrap] waiting for backend to be ready...');
-  for (let i = 0; i < maxRetries; i++) {
+  const startedAt = Date.now();
+  const deadline = startedAt + maxRetries * interval;
+  for (let attempt = 1; attempt <= maxRetries; attempt++) {
+    const remaining = deadline - Date.now();
+    if (remaining <= 0) {
+      break;
+    }
+    updateStartupLoadingTip(`正在连接后端服务…(第 ${attempt}/${maxRetries} 次)`);
     try {
-      const response = await healthApi();
-      console.log(response);
+      const response = await healthApi(Math.min(interval, remaining));
       if (response?.code === 200) {
-        console.log(`[bootstrap] backend ready after ${i + 1} attempts`);
+        console.log(`[bootstrap] backend ready after ${attempt} attempts`);
+        updateStartupLoadingTip('后端服务已连接,正在加载…', 'success');
         return true;
       }
     } catch (error) {
       // Backend not ready yet, continue waiting
     }
 
-    if (i < maxRetries - 1) {
-      await new Promise((resolve) => setTimeout(resolve, interval));
+    const nextAt = startedAt + attempt * interval;
+    const wait = Math.min(nextAt - Date.now(), deadline - Date.now());
+    if (attempt < maxRetries && wait > 0) {
+      await new Promise((resolve) => setTimeout(resolve, wait));
     }
   }
 
-  console.warn(`[bootstrap] backend not ready after ${maxRetries} attempts, continuing anyway`);
+  console.warn(`[bootstrap] backend not ready after ${maxRetries} attempts, waiting for manual reconnect`);
+  updateStartupLoadingTip(`后端服务连接失败(已重试 ${maxRetries} 次),可重连或修改服务地址`, 'failed');
   return false;
 }
 
+/** 显示 / 隐藏重试失败后的「重连」「修改服务地址」操作区 */
+function setReconnectActionsVisible(visible: boolean) {
+  document.querySelector<HTMLElement>('.app-loading-actions')?.classList.toggle('is-visible', visible);
+}
+
+/**
+ * 10 次探测失败后停在 loading 页,等待用户重连或修改服务地址。
+ *
+ * <p>重连会重新执行一轮 10 次探测;连上后整页 reload,走正常启动流程。
+ * 修改服务地址则进入授权页的地址卡片(带 error=unreachable),避免绑错地址后无路可走。
+ */
+function showReconnectState(onServerSetup: () => void) {
+  const reconnect = document.querySelector<HTMLButtonElement>('.app-loading-reconnect');
+  const serverSetup = document.querySelector<HTMLButtonElement>('.app-loading-server-setup');
+  setReconnectActionsVisible(true);
+
+  if (reconnect) {
+    reconnect.disabled = false;
+    reconnect.onclick = async () => {
+      reconnect.disabled = true;
+      setReconnectActionsVisible(false);
+      const ready = await waitForBackendReady();
+      if (ready) {
+        updateStartupLoadingTip('后端服务已连接,正在重新加载…', 'success');
+        window.location.reload();
+        return;
+      }
+      reconnect.disabled = false;
+      setReconnectActionsVisible(true);
+    };
+  }
+
+  if (serverSetup) {
+    serverSetup.onclick = () => onServerSetup();
+  }
+}
+
 function getStartupRouteInfo() {
   // hash 模式:路径在 location.hash 中(如 #/data/index)
   const hash = window.location.hash?.replace(/^#/, '') || '';
@@ -258,22 +333,27 @@ async function bootstrap() {
   }
 
   // Wait for backend to be ready
-  // 绑定地址是刚测试通过的,按「本地后端冷启动」等两分钟没有意义,收紧到约 1 分钟。
+  // 统一 10 次 × 3 秒(约 30 秒),loading 页展示重试次数;
   // 直接打开授权页(含旧的服务地址页地址)时不做探测:页面对失败已有自己的展示。
-  const bindingEnabled = isServerBindingEnabled();
   const startupPath = getStartupRouteInfo().path;
   const skipBackendProbe = startupPath === PageEnum.BASE_AUTHORIZATION || startupPath === PageEnum.BASE_SERVER_SETUP;
-  const backendReady = skipBackendProbe
-    ? true
-    : await waitForBackendReady(bindingEnabled ? 6 : 60, bindingEnabled ? 1500 : 2000);
-
-  // 地址已绑定但服务不可达:回到入口页并切到服务地址卡片,而不是带着坏地址继续跑授权校验
-  if (!backendReady && bindingEnabled) {
-    await router.replace({ path: PageEnum.BASE_AUTHORIZATION, query: { error: 'unreachable' } });
-    await router.isReady();
-    setupGlobDirectives(app);
-    setupErrorHandle(app);
-    app.mount('#app');
+  const backendReady = skipBackendProbe ? true : await waitForBackendReady();
+
+  // 后端不可达:停在 loading 页提供「重连 / 修改服务地址」,不先带着坏地址进入授权校验
+  if (!backendReady) {
+    showReconnectState(async () => {
+      // 智能窗口只承载工作台:地址/授权问题交回主窗口处理
+      if (isSmartWindow()) {
+        await focusMainWindow();
+        void closeSmartWindow();
+        return;
+      }
+      await router.replace({ path: PageEnum.BASE_AUTHORIZATION, query: { error: 'unreachable' } });
+      await router.isReady();
+      setupGlobDirectives(app);
+      setupErrorHandle(app);
+      app.mount('#app');
+    });
     return;
   }