main.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  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 { isServerBindingEnabled, isServerSetupMissing } from '@/core/settings/serverSetting';
  36. async function waitForBackendReady(maxRetries: number = 60, interval: number = 2000): Promise<boolean> {
  37. console.log('[bootstrap] waiting for backend to be ready...');
  38. for (let i = 0; i < maxRetries; i++) {
  39. try {
  40. const response = await healthApi();
  41. console.log(response);
  42. if (response?.code === 200) {
  43. console.log(`[bootstrap] backend ready after ${i + 1} attempts`);
  44. return true;
  45. }
  46. } catch (error) {
  47. // Backend not ready yet, continue waiting
  48. }
  49. if (i < maxRetries - 1) {
  50. await new Promise((resolve) => setTimeout(resolve, interval));
  51. }
  52. }
  53. console.warn(`[bootstrap] backend not ready after ${maxRetries} attempts, continuing anyway`);
  54. return false;
  55. }
  56. function getStartupRouteInfo() {
  57. // hash 模式:路径在 location.hash 中(如 #/data/index)
  58. const hash = window.location.hash?.replace(/^#/, '') || '';
  59. let fullPath: string;
  60. if (hash) {
  61. const [pathWithQuery = '/'] = hash.split('#');
  62. fullPath = pathWithQuery || '/';
  63. } else if (import.meta.env.VITE_ROUTE_WEB_HISTORY != 'true') {
  64. // hash 模式且没有 hash:不能退回 pathname —— 打包后是 file:///.../index.html,
  65. // 拿它当路由会落到 404,统一按根路由处理
  66. fullPath = '/';
  67. } else {
  68. // history 模式:无 hash,从 pathname 恢复,并剥离 VITE_PUBLIC_PATH 前缀
  69. let pathname = window.location.pathname || '/';
  70. const publicPath = import.meta.env.VITE_PUBLIC_PATH || '/';
  71. if (publicPath && publicPath !== '/') {
  72. const normalizedBase = publicPath.replace(/\/+$/, '');
  73. if (normalizedBase && pathname.startsWith(normalizedBase + '/')) {
  74. pathname = pathname.slice(normalizedBase.length);
  75. }
  76. }
  77. fullPath = pathname + (window.location.search || '');
  78. }
  79. const [path = '/'] = fullPath.split('?');
  80. return {
  81. path: path || '/',
  82. fullPath: fullPath || '/',
  83. };
  84. }
  85. function shouldRunStartupAuthCheck(path: string) {
  86. // 授权页与服务地址页都是自持页面:地址页是机器级设置,不参与启动授权流程
  87. return path !== PageEnum.BASE_AUTHORIZATION && path !== PageEnum.BASE_SERVER_SETUP;
  88. }
  89. function getAuthorizedTarget(fullPath: string, path: string) {
  90. // 首页、旧的测试页、入口页自身、服务地址页、以及已删除的 /login,统一落到案件页
  91. if (
  92. path === '/' ||
  93. path === '/test/testData/list' ||
  94. path === PageEnum.BASE_AUTHORIZATION ||
  95. path === PageEnum.BASE_SERVER_SETUP ||
  96. path === PageEnum.LEGACY_LOGIN
  97. ) {
  98. return PageEnum.BASE_CASE;
  99. }
  100. return fullPath || PageEnum.BASE_CASE;
  101. }
  102. function syncAuthorizationCache(source?: unknown) {
  103. const userStore = useUserStoreWithOut();
  104. const { authorizationLabel, overDateTime, authorizationUnit } = extractAuthorizationCache(source);
  105. userStore.setPageCache('authorizationLabel', authorizationLabel);
  106. userStore.setPageCache('licenseOverDateTime', overDateTime);
  107. userStore.setPageCache('authorizationUnit', authorizationUnit);
  108. return {
  109. authorizationLabel,
  110. overDateTime,
  111. authorizationUnit,
  112. };
  113. }
  114. async function resolveAuthorizationCache(source?: unknown) {
  115. const cache = syncAuthorizationCache(source);
  116. if (cache.overDateTime) {
  117. return cache;
  118. }
  119. try {
  120. const res = await authorizationInfoApi();
  121. return syncAuthorizationCache(res);
  122. } catch {
  123. return cache;
  124. }
  125. }
  126. async function redirectToExpiredAuthorization(overDateTime?: unknown) {
  127. await router.replace({
  128. path: PageEnum.BASE_AUTHORIZATION,
  129. query: {
  130. skipAuthCheck: '1',
  131. expired: '1',
  132. message: getAuthorizationExpiredMessage(overDateTime),
  133. },
  134. });
  135. }
  136. async function resolveStartupRoute() {
  137. const { path, fullPath } = getStartupRouteInfo();
  138. if (!shouldRunStartupAuthCheck(path)) {
  139. return;
  140. }
  141. try {
  142. const res = await checkAuthApi();
  143. if (res?.code === 200) {
  144. const cache = await resolveAuthorizationCache(res);
  145. if (isAuthorizationExpired(cache.overDateTime)) {
  146. await redirectToExpiredAuthorization(cache.overDateTime);
  147. return;
  148. }
  149. const targetPath = getAuthorizedTarget(fullPath, path);
  150. await router.replace(targetPath);
  151. return;
  152. }
  153. } catch (error: any) {
  154. const status = error?.response?.status || error?.status;
  155. if (status === 200 && error?.response?.data?.code === 200) {
  156. const cache = await resolveAuthorizationCache(error?.response?.data);
  157. if (isAuthorizationExpired(cache.overDateTime)) {
  158. await redirectToExpiredAuthorization(cache.overDateTime);
  159. return;
  160. }
  161. const targetPath = getAuthorizedTarget(fullPath, path);
  162. await router.replace(targetPath);
  163. return;
  164. }
  165. }
  166. syncAuthorizationCache();
  167. await router.replace({
  168. path: PageEnum.BASE_AUTHORIZATION,
  169. query: {
  170. skipAuthCheck: '1',
  171. },
  172. });
  173. }
  174. /**
  175. * 登录门禁:授权校验通过后,若本地没有 token 则进授权/登录入口页。
  176. *
  177. * <p>放在 license 校验<b>之后</b>:license 未通过时应停在 /authorization,
  178. * 不能反过来把用户推到别处。所以这里遇到入口页直接返回。
  179. * <p>入口页自身会根据 `checkAuthApi()` 结果决定显示授权卡片还是登录卡片。
  180. */
  181. async function ensureLoggedIn() {
  182. const current = router.currentRoute.value;
  183. // 自持页面不做登录门禁:授权页本身就是登录入口,地址页必须在未登录时也能操作
  184. if (current.path === PageEnum.BASE_AUTHORIZATION || current.path === PageEnum.BASE_SERVER_SETUP) {
  185. return;
  186. }
  187. if (!getToken()) {
  188. // /login 已删除,不作为回跳来源,否则登录后会落到 404
  189. const source =
  190. current.fullPath && current.fullPath !== '/' && current.path !== PageEnum.LEGACY_LOGIN ? current.fullPath : '';
  191. await router.replace({
  192. path: PageEnum.BASE_AUTHORIZATION,
  193. query: source ? { redirect: source } : undefined,
  194. });
  195. }
  196. }
  197. async function bootstrap() {
  198. // 窗口角色必须先解析:路由守卫要靠它区分主窗口(传统壳)与智能工作台窗口。
  199. // 浏览器里没有 IPC,initWindowRole 会回落到 'main',一切保持单窗口行为。
  200. await initWindowRole();
  201. setupAntdPopupBehavior();
  202. const app = createApp(App);
  203. // Configure store
  204. setupStore(app);
  205. // Initialize internal system configuration
  206. initAppConfigStore();
  207. // 跨窗口共享状态:案件/账号/凭据失效的广播处理(必须在路由初始化前注册)
  208. initSessionSync();
  209. // Register global components
  210. registerGlobComp(app);
  211. // Multilingual configuration
  212. // Asynchronous case: language files may be obtained from the server side
  213. await setupI18n(app);
  214. // Configure routing
  215. setupRouter(app);
  216. // router-guard
  217. setupRouterGuard(router);
  218. // 桌面端未绑定后端地址:先绑定,健康检查 / 授权 / 登录都要等地址确定后才有意义。
  219. // 必须在 waitForBackendReady 之前判断,否则会在没有地址的情况下空等。
  220. // 地址卡片已并入 /authorization,由页面根据 isServerSetupMissing() 自己切换形态。
  221. if (isServerSetupMissing()) {
  222. if (isSmartWindow()) {
  223. await focusMainWindow();
  224. void closeSmartWindow();
  225. return;
  226. }
  227. await router.replace(PageEnum.BASE_AUTHORIZATION);
  228. await router.isReady();
  229. setupGlobDirectives(app);
  230. setupErrorHandle(app);
  231. app.mount('#app');
  232. return;
  233. }
  234. // Wait for backend to be ready
  235. // 绑定地址是刚测试通过的,按「本地后端冷启动」等两分钟没有意义,收紧到约 1 分钟。
  236. // 直接打开授权页(含旧的服务地址页地址)时不做探测:页面对失败已有自己的展示。
  237. const bindingEnabled = isServerBindingEnabled();
  238. const startupPath = getStartupRouteInfo().path;
  239. const skipBackendProbe = startupPath === PageEnum.BASE_AUTHORIZATION || startupPath === PageEnum.BASE_SERVER_SETUP;
  240. const backendReady = skipBackendProbe
  241. ? true
  242. : await waitForBackendReady(bindingEnabled ? 6 : 60, bindingEnabled ? 1500 : 2000);
  243. // 地址已绑定但服务不可达:回到入口页并切到服务地址卡片,而不是带着坏地址继续跑授权校验
  244. if (!backendReady && bindingEnabled) {
  245. await router.replace({ path: PageEnum.BASE_AUTHORIZATION, query: { error: 'unreachable' } });
  246. await router.isReady();
  247. setupGlobDirectives(app);
  248. setupErrorHandle(app);
  249. app.mount('#app');
  250. return;
  251. }
  252. await resolveStartupRoute();
  253. // ★ 必须先等首次导航落定,再判登录态。
  254. // 否则此时 router.currentRoute 还是初始占位(path='/'),ensureLoggedIn 里的
  255. // replace 会按「来源不可用」走到 `query: undefined`,把 URL 上的
  256. // redirect / forceAuth / expired / message 全部冲掉 —— 入口页自己的 query 契约就失效了。
  257. await router.isReady();
  258. // 授权通过后再校验登录态:无 token 直接进授权/登录入口页
  259. await ensureLoggedIn();
  260. // 智能窗口只承载工作台:bootstrap 若被判定到授权页,说明凭据/授权已失效。
  261. // 关闭自己、把主窗口拉到前台,由主窗口做唯一的提示与跳转。
  262. if (isSmartWindow() && router.currentRoute.value.path === PageEnum.BASE_AUTHORIZATION) {
  263. await focusMainWindow();
  264. void closeSmartWindow();
  265. return;
  266. }
  267. // Register global directive
  268. setupGlobDirectives(app);
  269. // Configure global error handling
  270. setupErrorHandle(app);
  271. // https://next.router.js.org/api/#isready
  272. // await router.isReady();
  273. // Dynamic Form
  274. app.mount('#app');
  275. }
  276. // 仅开发模式显示
  277. if (isDevMode()) {
  278. setupAntdTabsListenerPatch();
  279. }
  280. if (!isDevMode()) {
  281. }
  282. bootstrap().then();