GovernProgressModal.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  1. <template>
  2. <Modal
  3. :open="open"
  4. :footer="null"
  5. :maskClosable="false"
  6. :keyboard="false"
  7. :centered="true"
  8. width="560px"
  9. wrapClassName="case-govern-progress-modal"
  10. @cancel="handleCancel"
  11. >
  12. <template #title>
  13. <div class="govern-modal-title">
  14. <span class="govern-modal-title__icon">
  15. <Icon icon="i-ant-design:rocket-outlined" />
  16. </span>
  17. <span>数据治理</span>
  18. </div>
  19. </template>
  20. <div class="govern-progress-panel">
  21. <div v-if="noticeText" :class="['govern-progress-notice', { 'is-error': !!errorText }]">
  22. {{ noticeText }}
  23. </div>
  24. <div class="govern-progress-table">
  25. <div class="govern-progress-table__head">
  26. <div class="govern-progress-table__cell cell-content">治理内容</div>
  27. <div class="govern-progress-table__cell cell-status">治理状态</div>
  28. <div class="govern-progress-table__cell cell-duration">治理耗时</div>
  29. </div>
  30. <div class="govern-progress-table__body">
  31. <div v-if="displayRows.length" class="govern-progress-table__list">
  32. <div v-for="row in displayRows" :key="row.key" class="govern-progress-table__row">
  33. <div class="govern-progress-table__cell cell-content" :title="row.content">
  34. {{ row.content }}
  35. </div>
  36. <div class="govern-progress-table__cell cell-status">
  37. <span :class="['status-text', `is-${row.status}`]">{{ row.statusText }}</span>
  38. </div>
  39. <div class="govern-progress-table__cell cell-duration">{{ row.durationText }}</div>
  40. </div>
  41. </div>
  42. <div v-else class="govern-progress-table__empty">
  43. {{ errorText ? '未获取到治理进度,请稍后重试。' : '正在等待治理步骤推送...' }}
  44. </div>
  45. </div>
  46. </div>
  47. <div class="govern-progress-footer">
  48. <span>{{ footerLabel }}</span>
  49. <span v-if="footerTimeText">{{ footerTimeText }}</span>
  50. </div>
  51. </div>
  52. </Modal>
  53. </template>
  54. <script lang="ts" setup name="ViewsDataGovernProgressModal">
  55. import { computed, onBeforeUnmount, ref, watch } from 'vue';
  56. import { Modal } from 'ant-design-vue';
  57. import { Icon } from '@jeesite/core/components/Icon';
  58. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  59. import { calcTask, getGovernTaskSseUrl, testSse } from '@jeesite/case/api/govern/governApi';
  60. type GovernRowStatus = 'pending' | 'running' | 'success' | 'fail';
  61. interface GovernProgressRow {
  62. key: string;
  63. content: string;
  64. status: GovernRowStatus;
  65. startedAt?: number;
  66. endedAt?: number;
  67. durationMs?: number;
  68. rawMessage?: string;
  69. }
  70. interface StructuredGovernRow {
  71. key: string;
  72. content: string;
  73. status: GovernRowStatus;
  74. durationMs?: number;
  75. }
  76. interface GovernSsePayload {
  77. cmd?: number;
  78. code?: number;
  79. process?: number;
  80. message?: string;
  81. status?: string;
  82. state?: string;
  83. seeData?: Recordable;
  84. data?: Recordable;
  85. list?: Recordable[];
  86. rows?: Recordable[];
  87. }
  88. const props = defineProps<{
  89. open: boolean;
  90. }>();
  91. const emit = defineEmits<{
  92. (e: 'update:open', value: boolean): void;
  93. (e: 'finished'): void;
  94. }>();
  95. const { createMessage } = useMessage();
  96. const rows = ref<GovernProgressRow[]>([]);
  97. const starting = ref(false);
  98. const sseConnected = ref(false);
  99. const errorText = ref('');
  100. const finishedAt = ref<number | null>(null);
  101. const currentNow = ref(Date.now());
  102. const finishedEmitted = ref(false);
  103. let eventSource: EventSource | null = null;
  104. let tickerTimer: ReturnType<typeof setInterval> | null = null;
  105. const noticeText = computed(() => {
  106. if (errorText.value) return errorText.value;
  107. if (starting.value && !rows.value.length) return '正在建立治理连接并启动任务,请稍候...';
  108. if (!sseConnected.value && !finishedAt.value) return '正在接收治理进度...';
  109. return '';
  110. });
  111. const displayRows = computed(() =>
  112. rows.value.map((row) => ({
  113. ...row,
  114. statusText: getStatusText(row.status),
  115. durationText: getRowDurationText(row),
  116. })),
  117. );
  118. const footerLabel = computed(() => {
  119. if (finishedAt.value) return '治理完成';
  120. if (errorText.value) return '治理中断';
  121. return '治理进行中';
  122. });
  123. const footerTimeText = computed(() => {
  124. if (!finishedAt.value) return '';
  125. return formatDateTime(finishedAt.value);
  126. });
  127. const getErrorMessage = (error: any, fallback = '治理任务启动失败') =>
  128. error?.response?.data?.message || error?.message || fallback;
  129. const getStatusText = (status: GovernRowStatus) => {
  130. if (status === 'success') return '完成';
  131. if (status === 'fail') return '失败';
  132. if (status === 'running') return '进行中';
  133. return '等待中';
  134. };
  135. const formatDateTime = (value: number) => {
  136. const date = new Date(value);
  137. const year = date.getFullYear();
  138. const month = date.getMonth() + 1;
  139. const day = date.getDate();
  140. const hours = String(date.getHours()).padStart(2, '0');
  141. const minutes = String(date.getMinutes()).padStart(2, '0');
  142. const seconds = String(date.getSeconds()).padStart(2, '0');
  143. return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
  144. };
  145. const formatDuration = (value?: number) => {
  146. const duration = Math.max(0, Math.round(Number(value ?? 0)));
  147. if (duration >= 1000) {
  148. const seconds = Math.floor(duration / 1000);
  149. const milliseconds = duration % 1000;
  150. return milliseconds ? `${seconds}秒${milliseconds}毫秒` : `${seconds}秒`;
  151. }
  152. return `${duration}毫秒`;
  153. };
  154. const parseDurationToMs = (value: unknown) => {
  155. if (value === undefined || value === null || value === '') return undefined;
  156. if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.round(value));
  157. const text = String(value).trim();
  158. if (!text) return undefined;
  159. if (/^\d+$/.test(text)) return Math.max(0, Math.round(Number(text)));
  160. const secondMatch = text.match(/(\d+(?:\.\d+)?)\s*秒/);
  161. const millisecondMatch = text.match(/(\d+(?:\.\d+)?)\s*毫秒/);
  162. if (secondMatch || millisecondMatch) {
  163. const seconds = secondMatch ? Number(secondMatch[1]) : 0;
  164. const milliseconds = millisecondMatch ? Number(millisecondMatch[1]) : 0;
  165. return Math.max(0, Math.round(seconds * 1000 + milliseconds));
  166. }
  167. return undefined;
  168. };
  169. const getRowDuration = (row: GovernProgressRow) => {
  170. if (row.durationMs !== undefined) return row.durationMs;
  171. if (row.endedAt !== undefined && row.startedAt !== undefined) return row.endedAt - row.startedAt;
  172. if (row.status === 'running' && row.startedAt !== undefined) return currentNow.value - row.startedAt;
  173. return undefined;
  174. };
  175. const getRowDurationText = (row: GovernProgressRow) => {
  176. const duration = getRowDuration(row);
  177. if (duration === undefined) {
  178. return row.status === 'pending' ? '-' : '0毫秒';
  179. }
  180. return formatDuration(duration);
  181. };
  182. const normalizeStatus = (value: unknown): GovernRowStatus => {
  183. const text = String(value ?? '')
  184. .trim()
  185. .toLowerCase();
  186. if (!text) return 'pending';
  187. if (
  188. text.includes('success') ||
  189. text.includes('finish') ||
  190. text.includes('done') ||
  191. text.includes('complete') ||
  192. text.includes('完成') ||
  193. text.includes('成功')
  194. ) {
  195. return 'success';
  196. }
  197. if (text.includes('fail') || text.includes('error') || text.includes('失败') || text.includes('异常')) {
  198. return 'fail';
  199. }
  200. if (text.includes('run') || text.includes('process') || text.includes('进行') || text.includes('执行')) {
  201. return 'running';
  202. }
  203. return 'pending';
  204. };
  205. const extractTextFromObject = (source?: Recordable | null) => {
  206. if (!source || typeof source !== 'object') return '';
  207. const keys = ['message', 'msg', 'taskName', 'content', 'title', 'name', 'stepName'];
  208. for (const key of keys) {
  209. const value = String(source[key] ?? '').trim();
  210. if (value) return value;
  211. }
  212. return '';
  213. };
  214. const parseSsePayload = (raw: string) => {
  215. try {
  216. return JSON.parse(raw) as GovernSsePayload;
  217. } catch (error) {
  218. return null;
  219. }
  220. };
  221. const extractStructuredRows = (payload?: GovernSsePayload | null): StructuredGovernRow[] => {
  222. if (!payload) return [];
  223. const candidateLists = [
  224. payload.seeData?.list,
  225. payload.seeData?.rows,
  226. payload.data?.list,
  227. payload.data?.rows,
  228. payload.list,
  229. payload.rows,
  230. ];
  231. const rawList = candidateLists.find((item) => Array.isArray(item));
  232. if (!Array.isArray(rawList) || !rawList.length) return [];
  233. return rawList
  234. .map((item, index) => {
  235. const content = extractTextFromObject(item) || extractTextFromObject(item?.seeData) || '';
  236. if (!content) return null;
  237. const durationMs =
  238. parseDurationToMs(item?.durationMs) ??
  239. parseDurationToMs(item?.costMs) ??
  240. parseDurationToMs(item?.elapsedMs) ??
  241. parseDurationToMs(item?.useTime) ??
  242. parseDurationToMs(item?.costTime) ??
  243. parseDurationToMs(item?.duration) ??
  244. parseDurationToMs(item?.time);
  245. return {
  246. key: String(item?.id ?? item?.key ?? `${index}-${content}`),
  247. content,
  248. status: normalizeStatus(item?.status ?? item?.state ?? item?.result ?? item?.stepStatus),
  249. durationMs,
  250. } satisfies StructuredGovernRow;
  251. })
  252. .filter((item): item is StructuredGovernRow => !!item);
  253. };
  254. const isPayloadFailure = (payload?: GovernSsePayload | null, raw = '') => {
  255. if (payload?.code !== undefined && Number(payload.code) !== 200) return true;
  256. const text = `${payload?.message || ''}${raw}`.trim();
  257. return !!text && (text.includes('失败') || text.includes('异常'));
  258. };
  259. const isPayloadComplete = (payload?: GovernSsePayload | null, raw = '') => {
  260. const process = Number(payload?.process ?? -1);
  261. if (Number.isFinite(process) && process >= 100) return true;
  262. const text = `${payload?.message || ''}${raw}`.trim();
  263. return !!text && (text.includes('治理完成') || text.includes('执行完成') || text.includes('已完成'));
  264. };
  265. const finalizeRunningRow = (status: Extract<GovernRowStatus, 'success' | 'fail'>, finishedTime: number) => {
  266. for (let index = rows.value.length - 1; index >= 0; index -= 1) {
  267. const row = rows.value[index];
  268. if (row.status === 'running') {
  269. row.status = status;
  270. row.endedAt = finishedTime;
  271. break;
  272. }
  273. }
  274. };
  275. const upsertSequentialRow = (content: string, finishedTime: number) => {
  276. const normalizedContent = String(content || '').trim();
  277. if (!normalizedContent) return;
  278. const currentRow = rows.value[rows.value.length - 1];
  279. if (currentRow && currentRow.content === normalizedContent) {
  280. if (currentRow.status === 'pending') {
  281. currentRow.status = 'running';
  282. currentRow.startedAt = finishedTime;
  283. }
  284. return;
  285. }
  286. finalizeRunningRow('success', finishedTime);
  287. rows.value.push({
  288. key: `${finishedTime}-${rows.value.length}`,
  289. content: normalizedContent,
  290. status: 'running',
  291. startedAt: finishedTime,
  292. rawMessage: normalizedContent,
  293. });
  294. };
  295. const applyStructuredRows = (nextRows: StructuredGovernRow[], finishedTime: number) => {
  296. rows.value = nextRows.map((row, index) => ({
  297. key: row.key || `${index}-${row.content}`,
  298. content: row.content,
  299. status: row.status,
  300. durationMs: row.durationMs,
  301. startedAt: finishedTime,
  302. endedAt: row.durationMs !== undefined ? finishedTime : undefined,
  303. }));
  304. if (rows.value.length && rows.value.every((row) => row.status === 'success' || row.status === 'fail')) {
  305. markFinished(finishedTime);
  306. }
  307. };
  308. const markFinished = (finishedTime: number) => {
  309. if (!finishedAt.value) {
  310. finishedAt.value = finishedTime;
  311. }
  312. starting.value = false;
  313. if (!finishedEmitted.value) {
  314. finishedEmitted.value = true;
  315. emit('finished');
  316. }
  317. };
  318. const handleSseMessage = (raw: string) => {
  319. const now = Date.now();
  320. const payload = parseSsePayload(raw);
  321. const structuredRows = extractStructuredRows(payload);
  322. if(payload?.cmd===999) return;
  323. if (structuredRows.length) {
  324. applyStructuredRows(structuredRows, now);
  325. } else {
  326. const content =
  327. extractTextFromObject(payload?.seeData) || extractTextFromObject(payload?.data) || payload?.message || raw;
  328. upsertSequentialRow(content, now);
  329. }
  330. if (isPayloadFailure(payload, raw)) {
  331. finalizeRunningRow('fail', now);
  332. errorText.value = '治理任务执行失败';
  333. starting.value = false;
  334. return;
  335. }
  336. if (isPayloadComplete(payload, raw)) {
  337. finalizeRunningRow('success', now);
  338. markFinished(now);
  339. }
  340. };
  341. const openSse = () => {
  342. eventSource?.close();
  343. sseConnected.value = false;
  344. errorText.value = '';
  345. eventSource = new EventSource(getGovernTaskSseUrl());
  346. eventSource.onopen = () => {
  347. sseConnected.value = true;
  348. errorText.value = '';
  349. };
  350. eventSource.onmessage = (event) => {
  351. handleSseMessage(event.data);
  352. };
  353. eventSource.onerror = () => {
  354. sseConnected.value = false;
  355. if (!finishedAt.value && !errorText.value) {
  356. errorText.value = 'SSE 连接中断,请稍后重试。';
  357. }
  358. eventSource?.close();
  359. eventSource = null;
  360. };
  361. };
  362. const startTask = async () => {
  363. try {
  364. await calcTask({ taskProcess: 1 });
  365. } catch (error: any) {
  366. starting.value = false;
  367. errorText.value = getErrorMessage(error);
  368. createMessage.error(errorText.value);
  369. }
  370. };
  371. const startTicker = () => {
  372. stopTicker();
  373. tickerTimer = setInterval(() => {
  374. currentNow.value = Date.now();
  375. }, 200);
  376. };
  377. const stopTicker = () => {
  378. if (tickerTimer) {
  379. clearInterval(tickerTimer);
  380. tickerTimer = null;
  381. }
  382. };
  383. const cleanup = () => {
  384. eventSource?.close();
  385. eventSource = null;
  386. stopTicker();
  387. starting.value = false;
  388. sseConnected.value = false;
  389. };
  390. const reset = () => {
  391. rows.value = [];
  392. starting.value = true;
  393. sseConnected.value = false;
  394. errorText.value = '';
  395. finishedAt.value = null;
  396. currentNow.value = Date.now();
  397. finishedEmitted.value = false;
  398. };
  399. const startGovernFlow = async () => {
  400. cleanup();
  401. reset();
  402. startTicker();
  403. openSse();
  404. await testSse();
  405. if (!props.open) return;
  406. await startTask();
  407. if (!eventSource || !sseConnected.value) {
  408. openSse();
  409. }
  410. };
  411. const handleCancel = () => {
  412. emit('update:open', false);
  413. };
  414. watch(
  415. () => props.open,
  416. (open) => {
  417. if (open) {
  418. void startGovernFlow();
  419. } else {
  420. cleanup();
  421. }
  422. },
  423. );
  424. onBeforeUnmount(() => {
  425. cleanup();
  426. });
  427. </script>
  428. <style lang="less">
  429. .case-govern-progress-modal {
  430. .ant-modal {
  431. width: min(560px, calc(100vw - 32px)) !important;
  432. top: 32px;
  433. }
  434. .ant-modal-content {
  435. height: 640px;
  436. border-radius: 8px;
  437. overflow: hidden;
  438. padding: 0;
  439. display: flex;
  440. flex-direction: column;
  441. }
  442. .ant-modal-header {
  443. padding: 12px 16px;
  444. border-bottom: 1px solid #e5e7eb;
  445. background: @modal-header-bg-color;
  446. flex: 0 0 auto;
  447. }
  448. .ant-modal-body {
  449. flex: 1 1 auto;
  450. min-height: 0;
  451. padding: 16px;
  452. display: flex;
  453. flex-direction: column;
  454. }
  455. .ant-modal-close {
  456. top: 16px;
  457. right: 16px;
  458. }
  459. }
  460. .govern-modal-title {
  461. display: inline-flex;
  462. align-items: center;
  463. gap: 10px;
  464. color: #111827;
  465. font-size: 16px;
  466. font-weight: 600;
  467. line-height: 24px;
  468. }
  469. .govern-modal-title__icon {
  470. width: 22px;
  471. height: 22px;
  472. border-radius: 6px;
  473. display: inline-flex;
  474. align-items: center;
  475. justify-content: center;
  476. background: linear-gradient(135deg, #fff1f2 0%, #ffe4e6 100%);
  477. color: #e11d48;
  478. font-size: 13px;
  479. }
  480. .govern-progress-panel {
  481. flex: 1 1 auto;
  482. min-height: 0;
  483. display: flex;
  484. flex-direction: column;
  485. gap: 16px;
  486. }
  487. .govern-progress-notice {
  488. padding: 10px 12px;
  489. border-radius: 10px;
  490. background: #f8fafc;
  491. color: #64748b;
  492. font-size: 12px;
  493. line-height: 1.6;
  494. &.is-error {
  495. background: #fef2f2;
  496. color: #dc2626;
  497. }
  498. }
  499. .govern-progress-table {
  500. flex: 1 1 auto;
  501. min-height: 0;
  502. border: 1px solid #e5e7eb;
  503. border-radius: 10px;
  504. overflow: hidden;
  505. background: #fff;
  506. display: flex;
  507. flex-direction: column;
  508. }
  509. .govern-progress-table__head,
  510. .govern-progress-table__row {
  511. display: grid;
  512. grid-template-columns: minmax(0, 1fr) 96px 112px;
  513. align-items: center;
  514. }
  515. .govern-progress-table__head {
  516. flex: 0 0 auto;
  517. min-height: 48px;
  518. background: #f8fafc;
  519. color: #475569;
  520. font-size: 13px;
  521. font-weight: 600;
  522. border-bottom: 1px solid #e5e7eb;
  523. }
  524. .govern-progress-table__cell {
  525. min-width: 0;
  526. padding: 0 14px;
  527. border-right: 1px solid #eef2f7;
  528. &:last-child {
  529. border-right: 0;
  530. }
  531. }
  532. .govern-progress-table__body {
  533. flex: 1 1 auto;
  534. min-height: 0;
  535. overflow: auto;
  536. }
  537. .govern-progress-table__list {
  538. display: flex;
  539. flex-direction: column;
  540. }
  541. .govern-progress-table__row {
  542. min-height: 44px;
  543. color: #334155;
  544. font-size: 13px;
  545. border-bottom: 1px solid #f1f5f9;
  546. &:nth-child(even) {
  547. background: #fafcff;
  548. }
  549. &:last-child {
  550. border-bottom: 0;
  551. }
  552. }
  553. .govern-progress-table__row .cell-content {
  554. overflow: hidden;
  555. text-overflow: ellipsis;
  556. white-space: nowrap;
  557. }
  558. .govern-progress-table__row .cell-status,
  559. .govern-progress-table__row .cell-duration {
  560. text-align: center;
  561. white-space: nowrap;
  562. }
  563. .status-text {
  564. color: #334155;
  565. &.is-running {
  566. color: #2563eb;
  567. }
  568. &.is-success {
  569. color: #15803d;
  570. }
  571. &.is-fail {
  572. color: #dc2626;
  573. }
  574. &.is-pending {
  575. color: #94a3b8;
  576. }
  577. }
  578. .govern-progress-table__empty {
  579. min-height: 160px;
  580. display: flex;
  581. align-items: center;
  582. justify-content: center;
  583. color: #94a3b8;
  584. font-size: 13px;
  585. line-height: 1.6;
  586. }
  587. .govern-progress-footer {
  588. flex: 0 0 auto;
  589. min-height: 22px;
  590. display: flex;
  591. justify-content: center;
  592. align-items: center;
  593. gap: 8px;
  594. color: #6b7280;
  595. font-size: 13px;
  596. line-height: 1.6;
  597. }
  598. @media (max-width: 640px) {
  599. .govern-progress-table__head,
  600. .govern-progress-table__row {
  601. grid-template-columns: minmax(0, 1fr) 84px 92px;
  602. }
  603. .govern-progress-table__cell {
  604. padding: 0 10px;
  605. }
  606. }
  607. </style>