cleanProgress.vue 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232
  1. <template>
  2. <div :class="prefixCls" class="case-clean-progress">
  3. <div class="page-header">
  4. <div class="page-title-wrap">
  5. <div>
  6. <div class="page-title">数据清洗导入</div>
  7. <div class="page-subtitle">实时展示文件清洗、入库与治理的执行进度,便于快速确认任务运行状态。</div>
  8. </div>
  9. </div>
  10. </div>
  11. <div class="progress-layout">
  12. <div class="progress-card">
  13. <template v-if="!isComplete">
  14. <div class="progress-card__header">
  15. <div>
  16. <div class="panel-title">任务执行中</div>
  17. <div class="panel-desc">{{ progressStatusText }}</div>
  18. </div>
  19. <div class="progress-text">{{ progress }}%</div>
  20. </div>
  21. <div class="progress-ring-section">
  22. <div class="progress-ring" :class="{ complete: isComplete }">
  23. <svg viewBox="0 0 120 120">
  24. <defs>
  25. <linearGradient id="progressGradient" x1="0" y1="0" x2="1" y2="1">
  26. <stop offset="0%" stop-color="#60a5fa" />
  27. <stop offset="100%" stop-color="#2563eb" />
  28. </linearGradient>
  29. <linearGradient id="progressComplete" x1="0" y1="0" x2="1" y2="1">
  30. <stop offset="0%" stop-color="#34d399" />
  31. <stop offset="100%" stop-color="#16a34a" />
  32. </linearGradient>
  33. </defs>
  34. <circle cx="60" cy="60" r="50" class="ring-bg" />
  35. <circle
  36. cx="60"
  37. cy="60"
  38. r="50"
  39. class="ring-value"
  40. :style="{ strokeDasharray: `${progressDash} 314`, strokeDashoffset: `${314 - progressDash}` }"
  41. />
  42. </svg>
  43. <div class="progress-value">
  44. <strong>{{ progress }}%</strong>
  45. <span>{{ currentStepLabel }}</span>
  46. </div>
  47. </div>
  48. <div class="progress-summary" :class="{ 'is-govern': activeStep >= 2 }">
  49. <div class="summary-title">当前任务概况</div>
  50. <div class="summary-desc">{{ summaryDesc }}</div>
  51. <div class="summary-file">{{ summaryMetaLabel }}:{{ summaryMetaText }}</div>
  52. </div>
  53. </div>
  54. <div class="step-track">
  55. <div class="step" :class="{ active: activeStep === 0, done: activeStep > 0 }">
  56. <div class="step-icon">
  57. <span class="step-dot"></span>
  58. </div>
  59. <div class="step-title">读取数据</div>
  60. </div>
  61. <div class="step-line"></div>
  62. <div class="step" :class="{ active: activeStep === 1, done: activeStep > 1 }">
  63. <div class="step-icon">
  64. <span class="step-dot"></span>
  65. </div>
  66. <div class="step-title">清洗入库</div>
  67. </div>
  68. <div class="step-line"></div>
  69. <div class="step" :class="{ active: activeStep === 2, done: activeStep > 2 }">
  70. <div class="step-icon">
  71. <span class="step-dot"></span>
  72. </div>
  73. <div class="step-title">数据治理</div>
  74. </div>
  75. </div>
  76. <div class="status-grid">
  77. <div class="status-card">
  78. <div class="status-label">当前步骤</div>
  79. <div class="status-value">{{ currentStepLabel }}</div>
  80. </div>
  81. <div class="status-card">
  82. <div class="status-label">进度状态</div>
  83. <div class="status-value">{{ progressStatusText }}</div>
  84. </div>
  85. <div class="status-card">
  86. <div class="status-label">推送连接</div>
  87. <div class="status-value">{{ sseConnected ? '已连接' : '未连接' }}</div>
  88. </div>
  89. </div>
  90. </template>
  91. <template v-else>
  92. <div class="progress-complete">
  93. <div class="complete-icon">✓</div>
  94. <div class="complete-title">{{ completeTitle }}</div>
  95. <div class="complete-desc">{{ completeDesc }}</div>
  96. <div class="complete-stats">
  97. <div class="stat-row">
  98. <span class="stat-label">导入成功</span>
  99. <span class="stat-value">{{ displaySuccessCount }} 条</span>
  100. </div>
  101. <div class="stat-row">
  102. <span class="stat-label">导入失败</span>
  103. <span class="stat-value">{{ displayFailCount }} 条</span>
  104. </div>
  105. </div>
  106. <div class="complete-actions">
  107. <Button v-if="showErrorLogAction" class="complete-log" @click="handleViewLog">查看错误日志</Button>
  108. <Button type="primary" class="complete-result" @click="handleViewResult">查看导入结果</Button>
  109. </div>
  110. </div>
  111. </template>
  112. </div>
  113. </div>
  114. <CleanLogModal v-model:open="logModalOpen" :file-infos="currentCleanFiles" :batch-id="currentCleanBatchId" />
  115. </div>
  116. </template>
  117. <script lang="ts" setup name="ViewsDataCleanProgress">
  118. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
  119. import { Button } from 'ant-design-vue';
  120. import { useGo } from '@jeesite/core/hooks/web/usePage';
  121. import { PageEnum } from '@jeesite/core/enums/pageEnum';
  122. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  123. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  124. import { useEmitter, useUserStore } from '@jeesite/core/store/modules/case';
  125. import { markAnalysisDataChanged } from '@jeesite/core/logics/analysisRefreshPrompt';
  126. import {
  127. calcTask,
  128. doClean,
  129. getGovernConfList,
  130. getGovernTaskSseUrl,
  131. testSse,
  132. } from '@jeesite/case/api/govern/governApi';
  133. import CleanLogModal from './components/CleanLogModal.vue';
  134. import {
  135. clearImportProgress,
  136. getActiveImportProgress,
  137. getImportProgressEventSource,
  138. resetImportProgressRuntimeState,
  139. saveImportProgress,
  140. setImportProgressEventSource,
  141. useImportProgressRuntimeState,
  142. } from './importProgressSession';
  143. import type { FileInfo } from '@jeesite/types/api';
  144. const { prefixCls } = useDesign('case-clean-progress');
  145. const go = useGo();
  146. const emitter = useEmitter();
  147. const userStore = useUserStore();
  148. const { createMessage, createWarningModal } = useMessage();
  149. const CASE_DATA_REFRESH_EVENT = 'case-data:refresh';
  150. const CASE_IMPORT_FILE_REFRESH_EVENT = 'case-import-file:refresh';
  151. const FILE_INFO_STORAGE_KEY = 'case_clean_file_infos';
  152. const IMPORT_FILE_INFO_STORAGE_KEY = 'case_clean_import_file_infos';
  153. const RECLEAN_CONTEXT_STORAGE_KEY = 'case_clean_reclean_context';
  154. const GOVERN_START_TASK_PROCESS = 1;
  155. const {
  156. progress,
  157. activeStep,
  158. currentFileName,
  159. currentCleanFiles,
  160. currentCleanBatchId,
  161. sseConnected,
  162. sseError,
  163. cleanTaskStarted,
  164. calcTaskTriggered,
  165. cleanTaskFinished,
  166. governTaskStarted,
  167. governTaskFinished,
  168. shouldRunGovernAfterClean,
  169. governCompleteNotified,
  170. successCount,
  171. failCount,
  172. cleanStatsSnapshot,
  173. } = useImportProgressRuntimeState();
  174. const sseMessages = ref<string[]>([]);
  175. const logModalOpen = ref(false);
  176. let eventSource = getImportProgressEventSource();
  177. const closeSse = () => {
  178. const activeEventSource = eventSource || getImportProgressEventSource();
  179. activeEventSource?.close();
  180. if (getImportProgressEventSource() === activeEventSource) {
  181. setImportProgressEventSource(null);
  182. }
  183. eventSource = null;
  184. sseConnected.value = false;
  185. };
  186. const progressDash = computed(() => Math.round((progress.value / 100) * 314));
  187. const isComplete = computed(
  188. () => progress.value >= 100 && (!shouldRunGovernAfterClean.value || governTaskFinished.value),
  189. );
  190. const currentStepLabel = computed(() => {
  191. if (activeStep.value >= 2) return '数据治理';
  192. if (activeStep.value === 1) return '清洗入库';
  193. return '读取数据';
  194. });
  195. const progressStatusText = computed(() => {
  196. if (isComplete.value) return governTaskFinished.value ? '数据治理完成' : '清洗入库流程已完成';
  197. return `当前正在执行${currentStepLabel.value}`;
  198. });
  199. const summaryDesc = computed(() => {
  200. if (activeStep.value >= 2) {
  201. return '清洗入库已完成,系统正在执行关系去重、数据识别、关系合并与图谱治理计算。';
  202. }
  203. if (activeStep.value === 1) {
  204. return '系统正在将清洗后的文件写入案件数据,并同步统计导入成功与失败数量。';
  205. }
  206. return '系统正在读取上传文件并准备清洗任务,完成后将进入清洗入库流程。';
  207. });
  208. const summaryMetaLabel = computed(() => (activeStep.value >= 2 ? '治理对象' : '当前处理文件'));
  209. const summaryMetaText = computed(
  210. () => currentFileName.value || (activeStep.value >= 2 ? '本次导入文件' : '暂无文件'),
  211. );
  212. const completeTitle = computed(() => (governTaskFinished.value ? '数据治理完成' : '清洗导入完成'));
  213. const showErrorLogAction = computed(() => Number(displayFailCount.value ?? 0) > 0);
  214. const completeDesc = computed(() =>
  215. governTaskFinished.value
  216. ? '数据治理已完成,治理结果已写入案件数据,您可以查看导入结果。'
  217. : showErrorLogAction.value
  218. ? '任务已完成,您可以查看错误日志或查看导入结果。'
  219. : '任务已完成,您可以直接查看导入结果。',
  220. );
  221. const displaySuccessCount = computed(() => cleanStatsSnapshot.value.success ?? successCount.value);
  222. const displayFailCount = computed(() => cleanStatsSnapshot.value.fail ?? failCount.value);
  223. const getCurrentCaseId = () => userStore.getCaseInfo?.id;
  224. const persistImportProgress = () => {
  225. if (isComplete.value) {
  226. clearImportProgress(getCurrentCaseId());
  227. return;
  228. }
  229. if (!cleanTaskStarted.value) return;
  230. saveImportProgress({
  231. active: true,
  232. caseId: getCurrentCaseId(),
  233. progress: progress.value,
  234. activeStep: activeStep.value,
  235. currentFileName: currentFileName.value,
  236. currentCleanFiles: currentCleanFiles.value,
  237. currentCleanBatchId: currentCleanBatchId.value,
  238. successCount: successCount.value,
  239. failCount: failCount.value,
  240. cleanStatsSnapshot: cleanStatsSnapshot.value,
  241. cleanTaskStarted: cleanTaskStarted.value,
  242. cleanTaskFinished: cleanTaskFinished.value,
  243. calcTaskTriggered: calcTaskTriggered.value,
  244. governTaskStarted: governTaskStarted.value,
  245. governTaskFinished: governTaskFinished.value,
  246. shouldRunGovernAfterClean: shouldRunGovernAfterClean.value,
  247. updatedAt: Date.now(),
  248. });
  249. };
  250. const restoreImportProgress = () => {
  251. const snapshot = getActiveImportProgress(getCurrentCaseId());
  252. if (!snapshot) return false;
  253. progress.value = snapshot.progress;
  254. activeStep.value = snapshot.activeStep;
  255. currentFileName.value = snapshot.currentFileName;
  256. currentCleanFiles.value = snapshot.currentCleanFiles || [];
  257. currentCleanBatchId.value = snapshot.currentCleanBatchId;
  258. successCount.value = snapshot.successCount;
  259. failCount.value = snapshot.failCount;
  260. cleanStatsSnapshot.value = snapshot.cleanStatsSnapshot || {};
  261. cleanTaskStarted.value = snapshot.cleanTaskStarted;
  262. cleanTaskFinished.value = snapshot.cleanTaskFinished;
  263. calcTaskTriggered.value = snapshot.calcTaskTriggered;
  264. governTaskStarted.value = snapshot.governTaskStarted;
  265. governTaskFinished.value = snapshot.governTaskFinished;
  266. shouldRunGovernAfterClean.value = snapshot.shouldRunGovernAfterClean;
  267. return true;
  268. };
  269. watch(
  270. [
  271. progress,
  272. activeStep,
  273. currentFileName,
  274. currentCleanFiles,
  275. currentCleanBatchId,
  276. successCount,
  277. failCount,
  278. cleanStatsSnapshot,
  279. cleanTaskStarted,
  280. cleanTaskFinished,
  281. calcTaskTriggered,
  282. governTaskStarted,
  283. governTaskFinished,
  284. shouldRunGovernAfterClean,
  285. ],
  286. persistImportProgress,
  287. { deep: true },
  288. );
  289. const normalizeText = (value: unknown) => String(value ?? '').trim();
  290. const normalizeBatchId = (value: unknown) => normalizeText(value);
  291. const findBatchId = (items?: FileInfo[] | null): string => {
  292. if (!Array.isArray(items) || !items.length) return '';
  293. for (const item of items) {
  294. const batchId = normalizeBatchId(item?.batchId);
  295. if (batchId) return batchId;
  296. const childBatchId = findBatchId(Array.isArray(item?.children) ? item.children : []);
  297. if (childBatchId) return childBatchId;
  298. }
  299. return '';
  300. };
  301. const refreshCurrentCleanBatchId = () => {
  302. const preFileRaw = sessionStorage.getItem(FILE_INFO_STORAGE_KEY);
  303. if (preFileRaw) {
  304. try {
  305. const preFileInfos = JSON.parse(preFileRaw) as FileInfo[];
  306. const preFileBatchId = findBatchId(preFileInfos);
  307. if (preFileBatchId) {
  308. currentCleanBatchId.value = preFileBatchId;
  309. return currentCleanBatchId.value;
  310. }
  311. } catch (error) {
  312. // Ignore invalid preFile cache and fallback to current task files.
  313. }
  314. }
  315. currentCleanBatchId.value = findBatchId(currentCleanFiles.value);
  316. return currentCleanBatchId.value;
  317. };
  318. const handleViewLog = () => {
  319. if (!showErrorLogAction.value) return;
  320. refreshCurrentCleanBatchId();
  321. logModalOpen.value = true;
  322. };
  323. const navigateToImportResult = async () => {
  324. sessionStorage.removeItem(IMPORT_FILE_INFO_STORAGE_KEY);
  325. sessionStorage.removeItem(RECLEAN_CONTEXT_STORAGE_KEY);
  326. markAnalysisDataChanged();
  327. await go(PageEnum.BASE_HOME, true);
  328. await nextTick();
  329. emitter.emit(CASE_DATA_REFRESH_EVENT);
  330. };
  331. const handleViewResult = async () => {
  332. if (!shouldRunGovernAfterClean.value) {
  333. createWarningModal({
  334. title: '提示',
  335. content: '数据治理关闭,数据未更新,请手动治理。',
  336. async onOk() {
  337. await navigateToImportResult();
  338. },
  339. });
  340. return;
  341. }
  342. await navigateToImportResult();
  343. };
  344. type SseDTO = {
  345. cmd: number;
  346. code: number;
  347. process: number;
  348. message: string;
  349. seeData?: Recordable;
  350. data?: Recordable;
  351. result?: Recordable;
  352. successCount?: number;
  353. failCount?: number;
  354. successNum?: number;
  355. failNum?: number;
  356. };
  357. const readNumber = (source: Recordable | null | undefined, keys: string[]) => {
  358. if (!source) return undefined;
  359. for (const key of keys) {
  360. const value = source[key];
  361. if (value !== undefined && value !== null && value !== '') {
  362. const numberValue = Number(value);
  363. if (Number.isFinite(numberValue)) return numberValue;
  364. }
  365. }
  366. return undefined;
  367. };
  368. const normalizeCleanResult = (result: Recordable | null | undefined) => {
  369. const nested =
  370. result?.data && typeof result.data === 'object'
  371. ? (result.data as Recordable)
  372. : result?.seeData && typeof result.seeData === 'object'
  373. ? (result.seeData as Recordable)
  374. : result?.result && typeof result.result === 'object'
  375. ? (result.result as Recordable)
  376. : result;
  377. const data = nested || {};
  378. const completedCount = readNumber(data, ['completedCount', 'successCount', 'successNum', 'success']);
  379. const failCount = readNumber(data, ['failCount', 'failNum', 'failedCount', 'fail']);
  380. return {
  381. success: completedCount,
  382. fail: failCount,
  383. process: readNumber(data, ['process', 'progress', 'taskProcess']),
  384. };
  385. };
  386. const normalizeSseProcess = (value: unknown) => {
  387. const numberValue = Number(value);
  388. if (!Number.isFinite(numberValue)) return progress.value;
  389. return Math.min(100, Math.max(0, Math.round(numberValue)));
  390. };
  391. const extractCurrentFileName = (payload: SseDTO) => {
  392. return payload.message;
  393. };
  394. const applyCleanResult = (result: Recordable | null | undefined, applyProcess = true) => {
  395. const normalized = normalizeCleanResult(result);
  396. if (normalized.success !== undefined) {
  397. successCount.value = normalized.success;
  398. cleanStatsSnapshot.value = { ...cleanStatsSnapshot.value, success: normalized.success };
  399. }
  400. if (normalized.fail !== undefined) {
  401. failCount.value = normalized.fail;
  402. cleanStatsSnapshot.value = { ...cleanStatsSnapshot.value, fail: normalized.fail };
  403. }
  404. if (applyProcess && normalized.process !== undefined) {
  405. progress.value = Math.min(100, Math.max(0, normalized.process));
  406. }
  407. };
  408. // const isCleanFinishedResult = (result: Recordable | null | undefined) => {
  409. // const normalized = normalizeCleanResult(result);
  410. // if (normalized.process !== undefined && normalized.process >= 100) return true;
  411. // const cmd = Number(result?.cmd ?? result?.data?.cmd ?? result?.seeData?.cmd ?? result?.result?.cmd);
  412. // if (cmd === 102) return false;
  413. // const data = (result?.data || result?.seeData || result?.result || result || {}) as Recordable;
  414. // const message = String(data?.message || data?.msg || result?.message || result?.msg || '').trim();
  415. // return (
  416. // message.includes('清洗导入完成') ||
  417. // message.includes('清洗完成') ||
  418. // message.includes('清洗结束') ||
  419. // message.includes('入库完成') ||
  420. // message.includes('全部清洗完成') ||
  421. // message.includes('全部入库完成')
  422. // );
  423. // };
  424. const startGovernAfterClean = async () => {
  425. // if (!shouldRunGovernAfterClean.value) {
  426. // await triggerCalcTask();
  427. // return;
  428. // }
  429. activeStep.value = 2;
  430. await triggerCalcTask();
  431. };
  432. const safeEmitImportRefresh = () => {
  433. try {
  434. emitter.emit(CASE_IMPORT_FILE_REFRESH_EVENT);
  435. } catch (error) {
  436. console.error('[clean-progress] import refresh emit failed', error);
  437. }
  438. };
  439. // const handleCleanFinished = async () => {
  440. // if (calcTaskTriggered.value || governTaskStarted.value || governTaskFinished.value) return;
  441. // if (!cleanTaskFinished.value) {
  442. // cleanTaskFinished.value = true;
  443. // }
  444. // await startGovernAfterClean();
  445. // safeEmitImportRefresh();
  446. // };
  447. const normalizeGovernConfFlag = (value?: unknown) =>
  448. String(value ?? '')
  449. .trim()
  450. .toUpperCase();
  451. const getRequestErrorMessage = (error: any, fallback = '操作失败') =>
  452. error?.response?.data?.message || error?.message || fallback;
  453. const loadGovernSwitch = async () => {
  454. try {
  455. const list = await getGovernConfList();
  456. const hasGovernConf = (Array.isArray(list) ? list : []).find(
  457. (item) =>
  458. String(item?.confType ?? '')
  459. .trim()
  460. .toLowerCase() === 'hasgovern',
  461. );
  462. // `hasGovern` 的实际语义以接口返回为准:N 表示导入不立即治理,Y 表示立即治理。
  463. shouldRunGovernAfterClean.value = normalizeGovernConfFlag(hasGovernConf?.confStr) === 'Y';
  464. } catch (error) {
  465. shouldRunGovernAfterClean.value = true;
  466. createMessage.warning('治理配置读取失败,将按默认流程继续执行数据治理');
  467. }
  468. };
  469. const isGovernCompleteText = (text: string) =>
  470. ['数据治理完成', '治理完成', '治理结束', '治理任务完成'].some((keyword) => text.includes(keyword));
  471. const isGovernCompletePayload = (payload: SseDTO, raw = '') => {
  472. const value = Number(payload.process ?? 0);
  473. const message = `${payload.message || ''}${raw}`;
  474. if (isGovernCompleteText(message)) return true;
  475. if (payload.cmd === 101) return false;
  476. return (governTaskStarted.value || calcTaskTriggered.value || activeStep.value >= 2) && value >= 100;
  477. };
  478. const markGovernComplete = () => {
  479. if (governTaskFinished.value) return;
  480. activeStep.value = 2;
  481. governTaskFinished.value = true;
  482. closeSse();
  483. clearImportProgress(getCurrentCaseId());
  484. safeEmitImportRefresh();
  485. if (!governCompleteNotified.value) {
  486. governCompleteNotified.value = true;
  487. createMessage.success('数据治理完成');
  488. }
  489. };
  490. const triggerCalcTask = async () => {
  491. if (!shouldRunGovernAfterClean.value) {
  492. activeStep.value = 1;
  493. progress.value = 100;
  494. closeSse();
  495. persistImportProgress();
  496. return;
  497. }
  498. if (calcTaskTriggered.value) return;
  499. calcTaskTriggered.value = true;
  500. persistImportProgress();
  501. try {
  502. if (!eventSource) {
  503. connectSse();
  504. }
  505. await calcTask({
  506. // 治理启动接口固定传起始进度值,不能复用清洗完成时返回的 100。
  507. taskProcess: progress.value,
  508. });
  509. governTaskStarted.value = true;
  510. persistImportProgress();
  511. if (!eventSource || !sseConnected.value) {
  512. connectSse();
  513. }
  514. createMessage.success('治理任务已启动');
  515. } catch (error: any) {
  516. calcTaskTriggered.value = false;
  517. governTaskStarted.value = false;
  518. persistImportProgress();
  519. createMessage.error(getRequestErrorMessage(error, '治理任务启动失败'));
  520. }
  521. };
  522. const updateProgressFromMessage = (rawMessage: string) => {
  523. const normalized = rawMessage.trim();
  524. if (!normalized) return;
  525. let payload: SseDTO | null = null;
  526. try {
  527. payload = JSON.parse(normalized) as SseDTO;
  528. } catch (error) {
  529. payload = null;
  530. }
  531. if (payload) {
  532. const value = normalizeSseProcess(payload.process);
  533. if (payload.cmd === 101) {
  534. const shouldApplyCleanProgress = !calcTaskTriggered.value && activeStep.value < 2;
  535. const fileName = extractCurrentFileName(payload);
  536. if (fileName) {
  537. currentFileName.value = fileName;
  538. }
  539. applyCleanResult(payload as unknown as Recordable, shouldApplyCleanProgress);
  540. if (shouldApplyCleanProgress) {
  541. activeStep.value = 1;
  542. progress.value = value;
  543. }
  544. // if (isCleanFinishedResult(payload as unknown as Recordable)) {
  545. // void handleCleanFinished();
  546. // }
  547. if (value >= 100) {
  548. cleanTaskFinished.value = true;
  549. }
  550. } else if (payload.cmd === 102) {
  551. activeStep.value = 2;
  552. governTaskStarted.value = true;
  553. progress.value = value;
  554. // shouldRunGovernAfterClean.value = true;
  555. startGovernAfterClean();
  556. if (value >= 100) {
  557. markGovernComplete();
  558. }
  559. } else {
  560. // activeStep.value = 0;
  561. // progress.value = value;
  562. }
  563. if (payload.message) {
  564. sseMessages.value.unshift(payload.message);
  565. sseMessages.value = sseMessages.value.slice(0, 20);
  566. }
  567. persistImportProgress();
  568. return;
  569. }
  570. // if (isGovernCompleteText(normalized)) {
  571. // sseMessages.value.unshift(normalized);
  572. // sseMessages.value = sseMessages.value.slice(0, 20);
  573. // markGovernComplete();
  574. // return;
  575. // }
  576. // const percentMatch = normalized.match(/(\d{1,3})%/);
  577. // if (percentMatch) {
  578. // const value = Math.min(100, Math.max(0, Number(percentMatch[1])));
  579. // progress.value = value;
  580. // if (value >= 90) {
  581. // activeStep.value = 2;
  582. // } else if (value >= 50) {
  583. // activeStep.value = 1;
  584. // } else {
  585. // activeStep.value = 0;
  586. // }
  587. // return;
  588. // }
  589. // if (normalized.includes('读取')) {
  590. // activeStep.value = 0;
  591. // } else if (normalized.includes('清洗')) {
  592. // activeStep.value = 1;
  593. // } else if (normalized.includes('治理')) {
  594. // activeStep.value = 2;
  595. // }
  596. };
  597. const connectSse = () => {
  598. const activeEventSource = getImportProgressEventSource();
  599. if (activeEventSource) {
  600. eventSource = activeEventSource;
  601. return;
  602. }
  603. sseConnected.value = false;
  604. sseError.value = null;
  605. eventSource = new EventSource(getGovernTaskSseUrl());
  606. setImportProgressEventSource(eventSource);
  607. eventSource.onopen = () => {
  608. sseConnected.value = true;
  609. sseError.value = null;
  610. };
  611. eventSource.onmessage = (event) => {
  612. updateProgressFromMessage(event.data);
  613. };
  614. eventSource.onerror = () => {
  615. sseConnected.value = false;
  616. if (!governTaskFinished.value) {
  617. sseError.value = 'SSE 连接中断';
  618. }
  619. eventSource?.close();
  620. if (getImportProgressEventSource() === eventSource) {
  621. setImportProgressEventSource(null);
  622. }
  623. eventSource = null;
  624. };
  625. };
  626. const loadCleanFiles = () => {
  627. const storageKeys = [IMPORT_FILE_INFO_STORAGE_KEY, FILE_INFO_STORAGE_KEY];
  628. for (const key of storageKeys) {
  629. const raw = sessionStorage.getItem(key);
  630. if (!raw) continue;
  631. try {
  632. const parsed = JSON.parse(raw) as FileInfo[];
  633. if (Array.isArray(parsed) && parsed.length) {
  634. return parsed;
  635. }
  636. } catch (error) {
  637. // Ignore invalid session payload and continue reading fallback storage.
  638. }
  639. }
  640. return [];
  641. };
  642. const loadReCleanContext = (): { reClean?: 0 | 1; fileId?: string } | null => {
  643. const raw = sessionStorage.getItem(RECLEAN_CONTEXT_STORAGE_KEY);
  644. if (!raw) return null;
  645. try {
  646. const parsed = JSON.parse(raw) as { reClean?: number; fileId?: string | number };
  647. const reClean = Number(parsed?.reClean) === 1 ? 1 : 0;
  648. const fileId = normalizeText(parsed?.fileId);
  649. if (reClean !== 1 || !fileId) return null;
  650. return { reClean: 1, fileId };
  651. } catch {
  652. return null;
  653. }
  654. };
  655. const pickFirstFileName = (files: FileInfo[]) => {
  656. const first = files[0];
  657. if (!first) return '暂无文件';
  658. return first.fileName || first.sheetName || first.filePath || '暂无文件';
  659. };
  660. const startClean = async () => {
  661. if (cleanTaskStarted.value) return;
  662. const fileInfos = loadCleanFiles();
  663. if (!fileInfos.length) {
  664. createMessage.warning('未找到可清洗文件,请先上传并匹配文件');
  665. return;
  666. }
  667. currentCleanFiles.value = fileInfos;
  668. currentCleanBatchId.value = findBatchId(fileInfos);
  669. currentFileName.value = pickFirstFileName(fileInfos);
  670. cleanTaskStarted.value = true;
  671. persistImportProgress();
  672. try {
  673. const reCleanContext = loadReCleanContext();
  674. await doClean(
  675. reCleanContext
  676. ? {
  677. fileInfos,
  678. reClean: 1,
  679. fileId: reCleanContext.fileId,
  680. }
  681. : { fileInfos },
  682. );
  683. sessionStorage.removeItem(RECLEAN_CONTEXT_STORAGE_KEY);
  684. // applyCleanResult(result);
  685. // if (isCleanFinishedResult(result)) {
  686. // await handleCleanFinished();
  687. // }
  688. createMessage.success('清洗任务已启动');
  689. } catch (error) {
  690. cleanTaskStarted.value = false;
  691. clearImportProgress(getCurrentCaseId());
  692. createMessage.error('清洗任务启动失败');
  693. }
  694. };
  695. const initializeProgress = async () => {
  696. if (restoreImportProgress()) {
  697. eventSource = getImportProgressEventSource();
  698. if (!eventSource) {
  699. connectSse();
  700. await testSse();
  701. }
  702. return;
  703. }
  704. resetImportProgressRuntimeState();
  705. await loadGovernSwitch();
  706. connectSse();
  707. await testSse();
  708. await startClean();
  709. };
  710. onMounted(() => {
  711. void initializeProgress();
  712. });
  713. onBeforeUnmount(() => {
  714. if (isComplete.value) {
  715. closeSse();
  716. } else {
  717. persistImportProgress();
  718. }
  719. });
  720. </script>
  721. <style lang="less">
  722. @prefix-cls: ~'jeesite-case-clean-progress';
  723. .@{prefix-cls}.case-clean-progress {
  724. min-height: 100%;
  725. height: 100%;
  726. padding: 12px 16px 24px;
  727. background: #fff;
  728. display: flex;
  729. flex-direction: column;
  730. gap: 12px;
  731. box-sizing: border-box;
  732. overflow: auto;
  733. .page-header {
  734. display: flex;
  735. align-items: center;
  736. justify-content: space-between;
  737. gap: 16px;
  738. }
  739. .page-title-wrap {
  740. display: flex;
  741. align-items: flex-start;
  742. min-width: 0;
  743. }
  744. .page-title {
  745. font-size: 18px;
  746. line-height: 1.4;
  747. font-weight: 600;
  748. color: #111827;
  749. }
  750. .page-subtitle {
  751. margin-top: 4px;
  752. font-size: 13px;
  753. line-height: 1.6;
  754. color: #6b7280;
  755. }
  756. .progress-layout {
  757. flex: 1 1 auto;
  758. min-height: 0;
  759. display: flex;
  760. }
  761. .progress-card {
  762. border: 1px solid #e5e7eb;
  763. border-radius: 14px;
  764. background: #fff;
  765. box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
  766. min-height: 0;
  767. width: 100%;
  768. }
  769. .progress-card {
  770. display: flex;
  771. flex-direction: column;
  772. gap: 20px;
  773. padding: 20px;
  774. }
  775. .progress-card__header {
  776. display: flex;
  777. align-items: flex-start;
  778. justify-content: space-between;
  779. gap: 16px;
  780. }
  781. .panel-title {
  782. font-size: 16px;
  783. font-weight: 600;
  784. color: #111827;
  785. }
  786. .panel-desc {
  787. margin-top: 4px;
  788. font-size: 12px;
  789. line-height: 1.6;
  790. color: #6b7280;
  791. }
  792. .progress-text {
  793. font-size: 28px;
  794. line-height: 1;
  795. font-weight: 600;
  796. color: #2563eb;
  797. }
  798. .progress-ring-section {
  799. display: grid;
  800. grid-template-columns: 220px minmax(0, 1fr);
  801. gap: 24px;
  802. align-items: center;
  803. }
  804. .progress-ring {
  805. position: relative;
  806. width: 180px;
  807. height: 180px;
  808. margin: 0 auto;
  809. display: flex;
  810. align-items: center;
  811. justify-content: center;
  812. }
  813. .progress-ring svg {
  814. width: 180px;
  815. height: 180px;
  816. transform: rotate(-90deg);
  817. }
  818. .ring-bg {
  819. fill: none;
  820. stroke: #e5e7eb;
  821. stroke-width: 12;
  822. }
  823. .ring-value {
  824. fill: none;
  825. stroke: url(#progressGradient);
  826. stroke-width: 12;
  827. stroke-linecap: round;
  828. transition:
  829. stroke-dasharray 0.4s ease,
  830. stroke-dashoffset 0.4s ease;
  831. filter: drop-shadow(0 10px 18px rgba(37, 99, 235, 0.16));
  832. }
  833. .progress-ring.complete .ring-value {
  834. stroke: url(#progressComplete);
  835. filter: drop-shadow(0 10px 18px rgba(22, 163, 74, 0.18));
  836. }
  837. .progress-value {
  838. position: absolute;
  839. inset: 28px;
  840. border-radius: 50%;
  841. background: #f8fafc;
  842. border: 1px solid #eef2f7;
  843. display: flex;
  844. flex-direction: column;
  845. align-items: center;
  846. justify-content: center;
  847. gap: 4px;
  848. color: #111827;
  849. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
  850. strong {
  851. font-size: 28px;
  852. line-height: 1;
  853. }
  854. span {
  855. font-size: 12px;
  856. color: #6b7280;
  857. }
  858. }
  859. .progress-summary {
  860. display: flex;
  861. flex-direction: column;
  862. gap: 8px;
  863. padding: 18px;
  864. border-radius: 12px;
  865. background: #fafafa;
  866. border: 1px solid #eef2f7;
  867. }
  868. .progress-summary.is-govern {
  869. background: #f8fbff;
  870. border-color: #dbeafe;
  871. }
  872. .summary-title {
  873. font-size: 15px;
  874. font-weight: 600;
  875. color: #111827;
  876. }
  877. .summary-desc,
  878. .summary-file {
  879. font-size: 13px;
  880. line-height: 1.7;
  881. color: #6b7280;
  882. }
  883. .step-track {
  884. display: flex;
  885. align-items: center;
  886. gap: 12px;
  887. padding: 16px 18px;
  888. border-radius: 12px;
  889. background: #fafafa;
  890. border: 1px solid #eef2f7;
  891. }
  892. .step {
  893. display: flex;
  894. flex-direction: column;
  895. align-items: center;
  896. gap: 8px;
  897. color: #9ca3af;
  898. min-width: 88px;
  899. }
  900. .step-icon {
  901. position: relative;
  902. width: 44px;
  903. height: 44px;
  904. border-radius: 50%;
  905. border: 1px solid #d1d5db;
  906. background: #fff;
  907. display: flex;
  908. align-items: center;
  909. justify-content: center;
  910. }
  911. .step-dot {
  912. width: 10px;
  913. height: 10px;
  914. border-radius: 50%;
  915. background: #cbd5e1;
  916. }
  917. .step-title {
  918. font-size: 12px;
  919. font-weight: 500;
  920. }
  921. .step.active {
  922. color: #2563eb;
  923. }
  924. .step.active .step-icon {
  925. border-color: #60a5fa;
  926. box-shadow: 0 8px 20px rgba(37, 99, 235, 0.14);
  927. animation: step-active-float 1.8s ease-in-out infinite;
  928. }
  929. .step.active .step-icon::before {
  930. position: absolute;
  931. inset: -6px;
  932. border: 1px solid rgba(37, 99, 235, 0.26);
  933. border-radius: 50%;
  934. content: '';
  935. animation: step-active-pulse 1.6s ease-out infinite;
  936. }
  937. .step.active .step-dot {
  938. background: #2563eb;
  939. box-shadow: 0 0 0 5px rgba(37, 99, 235, 0.12);
  940. animation: step-dot-pulse 1.05s ease-in-out infinite;
  941. }
  942. .step.active .step-title {
  943. font-weight: 700;
  944. }
  945. .step.done {
  946. color: #15803d;
  947. }
  948. .step.done .step-icon {
  949. border-color: #34d399;
  950. }
  951. .step.done .step-dot {
  952. background: #16a34a;
  953. }
  954. .step-line {
  955. flex: 1 1 auto;
  956. height: 1px;
  957. background: #dbe3ef;
  958. }
  959. @keyframes step-active-float {
  960. 0%,
  961. 100% {
  962. transform: translateY(0);
  963. }
  964. 50% {
  965. transform: translateY(-3px);
  966. }
  967. }
  968. @keyframes step-active-pulse {
  969. 0% {
  970. opacity: 0.72;
  971. transform: scale(0.88);
  972. }
  973. 100% {
  974. opacity: 0;
  975. transform: scale(1.22);
  976. }
  977. }
  978. @keyframes step-dot-pulse {
  979. 0%,
  980. 100% {
  981. transform: scale(1);
  982. }
  983. 50% {
  984. transform: scale(1.18);
  985. }
  986. }
  987. .status-grid {
  988. display: grid;
  989. grid-template-columns: repeat(3, minmax(0, 1fr));
  990. gap: 12px;
  991. }
  992. .status-card {
  993. padding: 14px 16px;
  994. border-radius: 12px;
  995. background: #fafafa;
  996. border: 1px solid #eef2f7;
  997. }
  998. .status-label {
  999. font-size: 12px;
  1000. color: #6b7280;
  1001. }
  1002. .status-value {
  1003. margin-top: 6px;
  1004. font-size: 14px;
  1005. font-weight: 600;
  1006. line-height: 1.6;
  1007. color: #111827;
  1008. word-break: break-all;
  1009. }
  1010. .progress-complete {
  1011. min-height: 100%;
  1012. display: flex;
  1013. flex-direction: column;
  1014. align-items: center;
  1015. justify-content: center;
  1016. gap: 16px;
  1017. text-align: center;
  1018. padding: 28px 20px;
  1019. }
  1020. .complete-icon {
  1021. width: 72px;
  1022. height: 72px;
  1023. border-radius: 50%;
  1024. display: flex;
  1025. align-items: center;
  1026. justify-content: center;
  1027. background: #ecfdf3;
  1028. border: 1px solid #bbf7d0;
  1029. color: #16a34a;
  1030. font-size: 34px;
  1031. font-weight: 700;
  1032. }
  1033. .complete-title {
  1034. font-size: 22px;
  1035. font-weight: 600;
  1036. color: #111827;
  1037. }
  1038. .complete-desc {
  1039. max-width: 360px;
  1040. font-size: 13px;
  1041. line-height: 1.7;
  1042. color: #6b7280;
  1043. }
  1044. .complete-stats {
  1045. display: grid;
  1046. grid-template-columns: repeat(2, minmax(0, 1fr));
  1047. gap: 12px;
  1048. width: 100%;
  1049. max-width: 360px;
  1050. }
  1051. .stat-row {
  1052. padding: 14px 16px;
  1053. border-radius: 12px;
  1054. background: #fafafa;
  1055. border: 1px solid #eef2f7;
  1056. display: flex;
  1057. flex-direction: column;
  1058. gap: 6px;
  1059. text-align: left;
  1060. }
  1061. .stat-label {
  1062. font-size: 12px;
  1063. color: #6b7280;
  1064. }
  1065. .stat-value {
  1066. font-size: 18px;
  1067. font-weight: 600;
  1068. color: #111827;
  1069. }
  1070. .complete-actions {
  1071. display: flex;
  1072. align-items: center;
  1073. gap: 12px;
  1074. margin-top: 8px;
  1075. }
  1076. .complete-log,
  1077. .complete-result {
  1078. min-width: 110px;
  1079. height: 36px;
  1080. border-radius: 8px;
  1081. }
  1082. @media (max-width: 768px) {
  1083. .page-header {
  1084. flex-direction: column;
  1085. align-items: flex-start;
  1086. }
  1087. .progress-ring-section,
  1088. .status-grid,
  1089. .complete-stats {
  1090. grid-template-columns: 1fr;
  1091. }
  1092. .step-track {
  1093. flex-direction: column;
  1094. }
  1095. .step-line {
  1096. width: 1px;
  1097. min-height: 24px;
  1098. }
  1099. .complete-actions {
  1100. width: 100%;
  1101. flex-direction: column;
  1102. }
  1103. .complete-log,
  1104. .complete-result {
  1105. width: 100%;
  1106. }
  1107. }
  1108. }
  1109. </style>