main.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. /**
  2. * Copyright (c) 2013-Now https://jeesite.com All rights reserved.
  3. * No deletion without permission, or be held responsible to law.
  4. * @author ThinkGem
  5. */
  6. import 'virtual:uno.css';
  7. import 'ant-design-vue/dist/reset.css';
  8. import '@/core/design/index.less';
  9. // AI 数据分析模块全局样式(设计 token / markdown 正文 / 消息布局基元)
  10. import '@/ai/index';
  11. import App from './App.vue';
  12. import { createApp } from 'vue';
  13. import { isDevMode } from '@/core/utils/env';
  14. import { registerGlobComp } from '@/core/components/registerGlobComp';
  15. import { initAppConfigStore } from '@/core/logics/initAppConfig';
  16. import { setupErrorHandle } from '@/core/logics/error-handle';
  17. import { setupGlobDirectives } from '@/core/directives';
  18. import { setupI18n } from '@/core/locales/setupI18n';
  19. import { setupRouter, router } from '@/core/router';
  20. import { setupRouterGuard } from '@/core/router/guard';
  21. import { setupStore } from '@/core/store';
  22. import { authorizationInfoApi, checkAuthApi, healthApi } from '@/core/api/sys/login';
  23. import { PageEnum } from '@/core/enums/pageEnum';
  24. import { useUserStoreWithOut } from '@/core/store/modules/case';
  25. import { getToken } from '@/core/utils/auth';
  26. import {
  27. extractAuthorizationCache,
  28. getAuthorizationExpiredMessage,
  29. isAuthorizationExpired,
  30. } from '@/core/utils/license';
  31. import { setupAntdTabsListenerPatch } from './setupAntdTabsListenerPatch';
  32. import { setupAntdPopupBehavior } from './setupAntdPopupBehavior';
  33. import { closeSmartWindow, focusMainWindow, initWindowRole, isSmartWindow } from '@/core/logics/appWindow';
  34. import { initSessionSync } from '@/core/logics/sessionSync';
  35. import { isServerSetupMissing } from '@/core/settings/serverSetting';
  36. const STARTUP_PROBE_RETRIES = 10;
  37. const STARTUP_PROBE_INTERVAL = 3000;
  38. /**
  39. * 更新启动 loading 页上的提示。
  40. *
  41. * <p>此时 Vue 还没 mount,只能直接操作 index.html 里的静态 DOM。
  42. * 重连中显示红色文案 + 文字前的小旋转图标;成功或最终失败时移除旋转图标。
  43. */
  44. function updateStartupLoadingTip(text: string, state: 'retrying' | 'success' | 'failed' = 'retrying') {
  45. const tip = document.querySelector<HTMLElement>('.app-loading-tip');
  46. if (!tip) {
  47. return;
  48. }
  49. tip.textContent = text;
  50. tip.classList.toggle('is-retrying', state === 'retrying');
  51. tip.classList.toggle('is-failed', state === 'failed');
  52. }
  53. /**
  54. * 等待后端就绪:10 次 × 3 秒(约 30 秒),并在 loading 页实时展示重试次数。
  55. *
  56. * <p>按固定节拍重试而不是「探测超时 + 再等一个间隔」,单次探测上限也是 3 秒,
  57. * 保证整体耗时不随失败次数膨胀;10 次仍未连上就停转,由调用方展示重连/修改服务地址入口。
  58. */
  59. async function waitForBackendReady(
  60. maxRetries: number = STARTUP_PROBE_RETRIES,
  61. interval: number = STARTUP_PROBE_INTERVAL,
  62. ): Promise<boolean> {
  63. console.log('[bootstrap] waiting for backend to be ready...');
  64. const startedAt = Date.now();
  65. const deadline = startedAt + maxRetries * interval;
  66. for (let attempt = 1; attempt <= maxRetries; attempt++) {
  67. const remaining = deadline - Date.now();
  68. if (remaining <= 0) {
  69. break;
  70. }
  71. updateStartupLoadingTip(`正在连接后端服务…(第 ${attempt}/${maxRetries} 次)`);
  72. try {
  73. const response = await healthApi(Math.min(interval, remaining));
  74. if (response?.code === 200) {
  75. console.log(`[bootstrap] backend ready after ${attempt} attempts`);
  76. updateStartupLoadingTip('后端服务已连接,正在加载…', 'success');
  77. return true;
  78. }
  79. } catch (error) {
  80. // Backend not ready yet, continue waiting
  81. }
  82. const nextAt = startedAt + attempt * interval;
  83. const wait = Math.min(nextAt - Date.now(), deadline - Date.now());
  84. if (attempt < maxRetries && wait > 0) {
  85. await new Promise((resolve) => setTimeout(resolve, wait));
  86. }
  87. }
  88. console.warn(`[bootstrap] backend not ready after ${maxRetries} attempts, waiting for manual reconnect`);
  89. updateStartupLoadingTip(`后端服务连接失败(已重试 ${maxRetries} 次),可重连或修改服务地址`, 'failed');
  90. return false;
  91. }
  92. /** 显示 / 隐藏重试失败后的「重连」「修改服务地址」操作区 */
  93. function setReconnectActionsVisible(visible: boolean) {
  94. document.querySelector<HTMLElement>('.app-loading-actions')?.classList.toggle('is-visible', visible);
  95. }
  96. /**
  97. * 10 次探测失败后停在 loading 页,等待用户重连或修改服务地址。
  98. *
  99. * <p>重连会重新执行一轮 10 次探测;连上后整页 reload,走正常启动流程。
  100. * 修改服务地址则进入授权页的地址卡片(带 error=unreachable),避免绑错地址后无路可走。
  101. */
  102. function showReconnectState(onServerSetup: () => void) {
  103. const reconnect = document.querySelector<HTMLButtonElement>('.app-loading-reconnect');
  104. const serverSetup = document.querySelector<HTMLButtonElement>('.app-loading-server-setup');
  105. setReconnectActionsVisible(true);
  106. if (reconnect) {
  107. reconnect.disabled = false;
  108. reconnect.onclick = async () => {
  109. reconnect.disabled = true;
  110. setReconnectActionsVisible(false);
  111. const ready = await waitForBackendReady();
  112. if (ready) {
  113. updateStartupLoadingTip('后端服务已连接,正在重新加载…', 'success');
  114. window.location.reload();
  115. return;
  116. }
  117. reconnect.disabled = false;
  118. setReconnectActionsVisible(true);
  119. };
  120. }
  121. if (serverSetup) {
  122. serverSetup.onclick = () => onServerSetup();
  123. }
  124. }
  125. function getStartupRouteInfo() {
  126. // hash 模式:路径在 location.hash 中(如 #/data/index)
  127. const hash = window.location.hash?.replace(/^#/, '') || '';
  128. let fullPath: string;
  129. if (hash) {
  130. const [pathWithQuery = '/'] = hash.split('#');
  131. fullPath = pathWithQuery || '/';
  132. } else if (import.meta.env.VITE_ROUTE_WEB_HISTORY != 'true') {
  133. // hash 模式且没有 hash:不能退回 pathname —— 打包后是 file:///.../index.html,
  134. // 拿它当路由会落到 404,统一按根路由处理
  135. fullPath = '/';
  136. } else {
  137. // history 模式:无 hash,从 pathname 恢复,并剥离 VITE_PUBLIC_PATH 前缀
  138. let pathname = window.location.pathname || '/';
  139. const publicPath = import.meta.env.VITE_PUBLIC_PATH || '/';
  140. if (publicPath && publicPath !== '/') {
  141. const normalizedBase = publicPath.replace(/\/+$/, '');
  142. if (normalizedBase && pathname.startsWith(normalizedBase + '/')) {
  143. pathname = pathname.slice(normalizedBase.length);
  144. }
  145. }
  146. fullPath = pathname + (window.location.search || '');
  147. }
  148. const [path = '/'] = fullPath.split('?');
  149. return {
  150. path: path || '/',
  151. fullPath: fullPath || '/',
  152. };
  153. }
  154. function shouldRunStartupAuthCheck(path: string) {
  155. // 授权页与服务地址页都是自持页面:地址页是机器级设置,不参与启动授权流程
  156. return path !== PageEnum.BASE_AUTHORIZATION && path !== PageEnum.BASE_SERVER_SETUP;
  157. }
  158. function getAuthorizedTarget(fullPath: string, path: string) {
  159. // 首页、旧的测试页、入口页自身、服务地址页、以及已删除的 /login,统一落到案件页
  160. if (
  161. path === '/' ||
  162. path === '/test/testData/list' ||
  163. path === PageEnum.BASE_AUTHORIZATION ||
  164. path === PageEnum.BASE_SERVER_SETUP ||
  165. path === PageEnum.LEGACY_LOGIN
  166. ) {
  167. return PageEnum.BASE_CASE;
  168. }
  169. return fullPath || PageEnum.BASE_CASE;
  170. }
  171. function syncAuthorizationCache(source?: unknown) {
  172. const userStore = useUserStoreWithOut();
  173. const { authorizationLabel, overDateTime, authorizationUnit } = extractAuthorizationCache(source);
  174. userStore.setPageCache('authorizationLabel', authorizationLabel);
  175. userStore.setPageCache('licenseOverDateTime', overDateTime);
  176. userStore.setPageCache('authorizationUnit', authorizationUnit);
  177. return {
  178. authorizationLabel,
  179. overDateTime,
  180. authorizationUnit,
  181. };
  182. }
  183. async function resolveAuthorizationCache(source?: unknown) {
  184. const cache = syncAuthorizationCache(source);
  185. if (cache.overDateTime) {
  186. return cache;
  187. }
  188. try {
  189. const res = await authorizationInfoApi();
  190. return syncAuthorizationCache(res);
  191. } catch {
  192. return cache;
  193. }
  194. }
  195. async function redirectToExpiredAuthorization(overDateTime?: unknown) {
  196. await router.replace({
  197. path: PageEnum.BASE_AUTHORIZATION,
  198. query: {
  199. skipAuthCheck: '1',
  200. expired: '1',
  201. message: getAuthorizationExpiredMessage(overDateTime),
  202. },
  203. });
  204. }
  205. async function resolveStartupRoute() {
  206. const { path, fullPath } = getStartupRouteInfo();
  207. if (!shouldRunStartupAuthCheck(path)) {
  208. return;
  209. }
  210. try {
  211. const res = await checkAuthApi();
  212. if (res?.code === 200) {
  213. const cache = await resolveAuthorizationCache(res);
  214. if (isAuthorizationExpired(cache.overDateTime)) {
  215. await redirectToExpiredAuthorization(cache.overDateTime);
  216. return;
  217. }
  218. const targetPath = getAuthorizedTarget(fullPath, path);
  219. await router.replace(targetPath);
  220. return;
  221. }
  222. } catch (error: any) {
  223. const status = error?.response?.status || error?.status;
  224. if (status === 200 && error?.response?.data?.code === 200) {
  225. const cache = await resolveAuthorizationCache(error?.response?.data);
  226. if (isAuthorizationExpired(cache.overDateTime)) {
  227. await redirectToExpiredAuthorization(cache.overDateTime);
  228. return;
  229. }
  230. const targetPath = getAuthorizedTarget(fullPath, path);
  231. await router.replace(targetPath);
  232. return;
  233. }
  234. }
  235. syncAuthorizationCache();
  236. await router.replace({
  237. path: PageEnum.BASE_AUTHORIZATION,
  238. query: {
  239. skipAuthCheck: '1',
  240. },
  241. });
  242. }
  243. /**
  244. * 登录门禁:授权校验通过后,若本地没有 token 则进授权/登录入口页。
  245. *
  246. * <p>放在 license 校验<b>之后</b>:license 未通过时应停在 /authorization,
  247. * 不能反过来把用户推到别处。所以这里遇到入口页直接返回。
  248. * <p>入口页自身会根据 `checkAuthApi()` 结果决定显示授权卡片还是登录卡片。
  249. */
  250. async function ensureLoggedIn() {
  251. const current = router.currentRoute.value;
  252. // 自持页面不做登录门禁:授权页本身就是登录入口,地址页必须在未登录时也能操作
  253. if (current.path === PageEnum.BASE_AUTHORIZATION || current.path === PageEnum.BASE_SERVER_SETUP) {
  254. return;
  255. }
  256. if (!getToken()) {
  257. // /login 已删除,不作为回跳来源,否则登录后会落到 404
  258. const source =
  259. current.fullPath && current.fullPath !== '/' && current.path !== PageEnum.LEGACY_LOGIN ? current.fullPath : '';
  260. await router.replace({
  261. path: PageEnum.BASE_AUTHORIZATION,
  262. query: source ? { redirect: source } : undefined,
  263. });
  264. }
  265. }
  266. async function bootstrap() {
  267. // 窗口角色必须先解析:路由守卫要靠它区分主窗口(传统壳)与智能工作台窗口。
  268. // 浏览器里没有 IPC,initWindowRole 会回落到 'main',一切保持单窗口行为。
  269. await initWindowRole();
  270. setupAntdPopupBehavior();
  271. const app = createApp(App);
  272. // Configure store
  273. setupStore(app);
  274. // Initialize internal system configuration
  275. initAppConfigStore();
  276. // 跨窗口共享状态:案件/账号/凭据失效的广播处理(必须在路由初始化前注册)
  277. initSessionSync();
  278. // Register global components
  279. registerGlobComp(app);
  280. // Multilingual configuration
  281. // Asynchronous case: language files may be obtained from the server side
  282. await setupI18n(app);
  283. // Configure routing
  284. setupRouter(app);
  285. // router-guard
  286. setupRouterGuard(router);
  287. // 桌面端未绑定后端地址:先绑定,健康检查 / 授权 / 登录都要等地址确定后才有意义。
  288. // 必须在 waitForBackendReady 之前判断,否则会在没有地址的情况下空等。
  289. // 地址卡片已并入 /authorization,由页面根据 isServerSetupMissing() 自己切换形态。
  290. if (isServerSetupMissing()) {
  291. if (isSmartWindow()) {
  292. await focusMainWindow();
  293. void closeSmartWindow();
  294. return;
  295. }
  296. await router.replace(PageEnum.BASE_AUTHORIZATION);
  297. await router.isReady();
  298. setupGlobDirectives(app);
  299. setupErrorHandle(app);
  300. app.mount('#app');
  301. return;
  302. }
  303. // Wait for backend to be ready
  304. // 统一 10 次 × 3 秒(约 30 秒),loading 页展示重试次数;
  305. // 直接打开授权页(含旧的服务地址页地址)时不做探测:页面对失败已有自己的展示。
  306. const startupPath = getStartupRouteInfo().path;
  307. const skipBackendProbe = startupPath === PageEnum.BASE_AUTHORIZATION || startupPath === PageEnum.BASE_SERVER_SETUP;
  308. const backendReady = skipBackendProbe ? true : await waitForBackendReady();
  309. // 后端不可达:停在 loading 页提供「重连 / 修改服务地址」,不先带着坏地址进入授权校验
  310. if (!backendReady) {
  311. showReconnectState(async () => {
  312. // 智能窗口只承载工作台:地址/授权问题交回主窗口处理
  313. if (isSmartWindow()) {
  314. await focusMainWindow();
  315. void closeSmartWindow();
  316. return;
  317. }
  318. await router.replace({ path: PageEnum.BASE_AUTHORIZATION, query: { error: 'unreachable' } });
  319. await router.isReady();
  320. setupGlobDirectives(app);
  321. setupErrorHandle(app);
  322. app.mount('#app');
  323. });
  324. return;
  325. }
  326. await resolveStartupRoute();
  327. // ★ 必须先等首次导航落定,再判登录态。
  328. // 否则此时 router.currentRoute 还是初始占位(path='/'),ensureLoggedIn 里的
  329. // replace 会按「来源不可用」走到 `query: undefined`,把 URL 上的
  330. // redirect / forceAuth / expired / message 全部冲掉 —— 入口页自己的 query 契约就失效了。
  331. await router.isReady();
  332. // 授权通过后再校验登录态:无 token 直接进授权/登录入口页
  333. await ensureLoggedIn();
  334. // 智能窗口只承载工作台:bootstrap 若被判定到授权页,说明凭据/授权已失效。
  335. // 关闭自己、把主窗口拉到前台,由主窗口做唯一的提示与跳转。
  336. if (isSmartWindow() && router.currentRoute.value.path === PageEnum.BASE_AUTHORIZATION) {
  337. await focusMainWindow();
  338. void closeSmartWindow();
  339. return;
  340. }
  341. // Register global directive
  342. setupGlobDirectives(app);
  343. // Configure global error handling
  344. setupErrorHandle(app);
  345. // https://next.router.js.org/api/#isready
  346. // await router.isReady();
  347. // Dynamic Form
  348. app.mount('#app');
  349. }
  350. // 仅开发模式显示
  351. if (isDevMode()) {
  352. setupAntdTabsListenerPatch();
  353. }
  354. if (!isDevMode()) {
  355. }
  356. bootstrap().then();