| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689 |
- <template>
- <Modal
- :open="open"
- :footer="null"
- :maskClosable="false"
- :keyboard="false"
- :centered="true"
- width="560px"
- wrapClassName="case-govern-progress-modal"
- @cancel="handleCancel"
- >
- <template #title>
- <div class="govern-modal-title">
- <span class="govern-modal-title__icon">
- <Icon icon="i-ant-design:rocket-outlined" />
- </span>
- <span>数据治理</span>
- </div>
- </template>
- <div class="govern-progress-panel">
- <div v-if="noticeText" :class="['govern-progress-notice', { 'is-error': !!errorText }]">
- {{ noticeText }}
- </div>
- <div class="govern-progress-table">
- <div class="govern-progress-table__head">
- <div class="govern-progress-table__cell cell-content">治理内容</div>
- <div class="govern-progress-table__cell cell-status">治理状态</div>
- <div class="govern-progress-table__cell cell-duration">治理耗时</div>
- </div>
- <div class="govern-progress-table__body">
- <div v-if="displayRows.length" class="govern-progress-table__list">
- <div v-for="row in displayRows" :key="row.key" class="govern-progress-table__row">
- <div class="govern-progress-table__cell cell-content" :title="row.content">
- {{ row.content }}
- </div>
- <div class="govern-progress-table__cell cell-status">
- <span :class="['status-text', `is-${row.status}`]">{{ row.statusText }}</span>
- </div>
- <div class="govern-progress-table__cell cell-duration">{{ row.durationText }}</div>
- </div>
- </div>
- <div v-else class="govern-progress-table__empty">
- {{ errorText ? '未获取到治理进度,请稍后重试。' : '正在等待治理步骤推送...' }}
- </div>
- </div>
- </div>
- <div class="govern-progress-footer">
- <span>{{ footerLabel }}</span>
- <span v-if="footerTimeText">{{ footerTimeText }}</span>
- </div>
- </div>
- </Modal>
- </template>
- <script lang="ts" setup name="ViewsDataGovernProgressModal">
- import { computed, onBeforeUnmount, ref, watch } from 'vue';
- import { Modal } from 'ant-design-vue';
- import { Icon } from '@jeesite/core/components/Icon';
- import { useMessage } from '@jeesite/core/hooks/web/useMessage';
- import { calcTask, getGovernTaskSseUrl, testSse } from '@jeesite/case/api/govern/governApi';
- type GovernRowStatus = 'pending' | 'running' | 'success' | 'fail';
- interface GovernProgressRow {
- key: string;
- content: string;
- status: GovernRowStatus;
- startedAt?: number;
- endedAt?: number;
- durationMs?: number;
- rawMessage?: string;
- }
- interface StructuredGovernRow {
- key: string;
- content: string;
- status: GovernRowStatus;
- durationMs?: number;
- }
- interface GovernSsePayload {
- cmd?: number;
- code?: number;
- process?: number;
- message?: string;
- status?: string;
- state?: string;
- seeData?: Recordable;
- data?: Recordable;
- list?: Recordable[];
- rows?: Recordable[];
- }
- const props = defineProps<{
- open: boolean;
- }>();
- const emit = defineEmits<{
- (e: 'update:open', value: boolean): void;
- (e: 'finished'): void;
- }>();
- const { createMessage } = useMessage();
- const rows = ref<GovernProgressRow[]>([]);
- const starting = ref(false);
- const sseConnected = ref(false);
- const errorText = ref('');
- const finishedAt = ref<number | null>(null);
- const currentNow = ref(Date.now());
- const finishedEmitted = ref(false);
- let eventSource: EventSource | null = null;
- let tickerTimer: ReturnType<typeof setInterval> | null = null;
- const noticeText = computed(() => {
- if (errorText.value) return errorText.value;
- if (starting.value && !rows.value.length) return '正在建立治理连接并启动任务,请稍候...';
- if (!sseConnected.value && !finishedAt.value) return '正在接收治理进度...';
- return '';
- });
- const displayRows = computed(() =>
- rows.value.map((row) => ({
- ...row,
- statusText: getStatusText(row.status),
- durationText: getRowDurationText(row),
- })),
- );
- const footerLabel = computed(() => {
- if (finishedAt.value) return '治理完成';
- if (errorText.value) return '治理中断';
- return '治理进行中';
- });
- const footerTimeText = computed(() => {
- if (!finishedAt.value) return '';
- return formatDateTime(finishedAt.value);
- });
- const getErrorMessage = (error: any, fallback = '治理任务启动失败') =>
- error?.response?.data?.message || error?.message || fallback;
- const getStatusText = (status: GovernRowStatus) => {
- if (status === 'success') return '完成';
- if (status === 'fail') return '失败';
- if (status === 'running') return '进行中';
- return '等待中';
- };
- const formatDateTime = (value: number) => {
- const date = new Date(value);
- const year = date.getFullYear();
- const month = date.getMonth() + 1;
- const day = date.getDate();
- const hours = String(date.getHours()).padStart(2, '0');
- const minutes = String(date.getMinutes()).padStart(2, '0');
- const seconds = String(date.getSeconds()).padStart(2, '0');
- return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
- };
- const formatDuration = (value?: number) => {
- const duration = Math.max(0, Math.round(Number(value ?? 0)));
- if (duration >= 1000) {
- const seconds = Math.floor(duration / 1000);
- const milliseconds = duration % 1000;
- return milliseconds ? `${seconds}秒${milliseconds}毫秒` : `${seconds}秒`;
- }
- return `${duration}毫秒`;
- };
- const parseDurationToMs = (value: unknown) => {
- if (value === undefined || value === null || value === '') return undefined;
- if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.round(value));
- const text = String(value).trim();
- if (!text) return undefined;
- if (/^\d+$/.test(text)) return Math.max(0, Math.round(Number(text)));
- const secondMatch = text.match(/(\d+(?:\.\d+)?)\s*秒/);
- const millisecondMatch = text.match(/(\d+(?:\.\d+)?)\s*毫秒/);
- if (secondMatch || millisecondMatch) {
- const seconds = secondMatch ? Number(secondMatch[1]) : 0;
- const milliseconds = millisecondMatch ? Number(millisecondMatch[1]) : 0;
- return Math.max(0, Math.round(seconds * 1000 + milliseconds));
- }
- return undefined;
- };
- const getRowDuration = (row: GovernProgressRow) => {
- if (row.durationMs !== undefined) return row.durationMs;
- if (row.endedAt !== undefined && row.startedAt !== undefined) return row.endedAt - row.startedAt;
- if (row.status === 'running' && row.startedAt !== undefined) return currentNow.value - row.startedAt;
- return undefined;
- };
- const getRowDurationText = (row: GovernProgressRow) => {
- const duration = getRowDuration(row);
- if (duration === undefined) {
- return row.status === 'pending' ? '-' : '0毫秒';
- }
- return formatDuration(duration);
- };
- const normalizeStatus = (value: unknown): GovernRowStatus => {
- const text = String(value ?? '')
- .trim()
- .toLowerCase();
- if (!text) return 'pending';
- if (
- text.includes('success') ||
- text.includes('finish') ||
- text.includes('done') ||
- text.includes('complete') ||
- text.includes('完成') ||
- text.includes('成功')
- ) {
- return 'success';
- }
- if (text.includes('fail') || text.includes('error') || text.includes('失败') || text.includes('异常')) {
- return 'fail';
- }
- if (text.includes('run') || text.includes('process') || text.includes('进行') || text.includes('执行')) {
- return 'running';
- }
- return 'pending';
- };
- const extractTextFromObject = (source?: Recordable | null) => {
- if (!source || typeof source !== 'object') return '';
- const keys = ['message', 'msg', 'taskName', 'content', 'title', 'name', 'stepName'];
- for (const key of keys) {
- const value = String(source[key] ?? '').trim();
- if (value) return value;
- }
- return '';
- };
- const parseSsePayload = (raw: string) => {
- try {
- return JSON.parse(raw) as GovernSsePayload;
- } catch (error) {
- return null;
- }
- };
- const extractStructuredRows = (payload?: GovernSsePayload | null): StructuredGovernRow[] => {
- if (!payload) return [];
- const candidateLists = [
- payload.seeData?.list,
- payload.seeData?.rows,
- payload.data?.list,
- payload.data?.rows,
- payload.list,
- payload.rows,
- ];
- const rawList = candidateLists.find((item) => Array.isArray(item));
- if (!Array.isArray(rawList) || !rawList.length) return [];
- return rawList
- .map((item, index) => {
- const content = extractTextFromObject(item) || extractTextFromObject(item?.seeData) || '';
- if (!content) return null;
- const durationMs =
- parseDurationToMs(item?.durationMs) ??
- parseDurationToMs(item?.costMs) ??
- parseDurationToMs(item?.elapsedMs) ??
- parseDurationToMs(item?.useTime) ??
- parseDurationToMs(item?.costTime) ??
- parseDurationToMs(item?.duration) ??
- parseDurationToMs(item?.time);
- return {
- key: String(item?.id ?? item?.key ?? `${index}-${content}`),
- content,
- status: normalizeStatus(item?.status ?? item?.state ?? item?.result ?? item?.stepStatus),
- durationMs,
- } satisfies StructuredGovernRow;
- })
- .filter((item): item is StructuredGovernRow => !!item);
- };
- const isPayloadFailure = (payload?: GovernSsePayload | null, raw = '') => {
- if (payload?.code !== undefined && Number(payload.code) !== 200) return true;
- const text = `${payload?.message || ''}${raw}`.trim();
- return !!text && (text.includes('失败') || text.includes('异常'));
- };
- const isPayloadComplete = (payload?: GovernSsePayload | null, raw = '') => {
- const process = Number(payload?.process ?? -1);
- if (Number.isFinite(process) && process >= 100) return true;
- const text = `${payload?.message || ''}${raw}`.trim();
- return !!text && (text.includes('治理完成') || text.includes('执行完成') || text.includes('已完成'));
- };
- const finalizeRunningRow = (status: Extract<GovernRowStatus, 'success' | 'fail'>, finishedTime: number) => {
- for (let index = rows.value.length - 1; index >= 0; index -= 1) {
- const row = rows.value[index];
- if (row.status === 'running') {
- row.status = status;
- row.endedAt = finishedTime;
- break;
- }
- }
- };
- const upsertSequentialRow = (content: string, finishedTime: number) => {
- const normalizedContent = String(content || '').trim();
- if (!normalizedContent) return;
- const currentRow = rows.value[rows.value.length - 1];
- if (currentRow && currentRow.content === normalizedContent) {
- if (currentRow.status === 'pending') {
- currentRow.status = 'running';
- currentRow.startedAt = finishedTime;
- }
- return;
- }
- finalizeRunningRow('success', finishedTime);
- rows.value.push({
- key: `${finishedTime}-${rows.value.length}`,
- content: normalizedContent,
- status: 'running',
- startedAt: finishedTime,
- rawMessage: normalizedContent,
- });
- };
- const applyStructuredRows = (nextRows: StructuredGovernRow[], finishedTime: number) => {
- rows.value = nextRows.map((row, index) => ({
- key: row.key || `${index}-${row.content}`,
- content: row.content,
- status: row.status,
- durationMs: row.durationMs,
- startedAt: finishedTime,
- endedAt: row.durationMs !== undefined ? finishedTime : undefined,
- }));
- if (rows.value.length && rows.value.every((row) => row.status === 'success' || row.status === 'fail')) {
- markFinished(finishedTime);
- }
- };
- const markFinished = (finishedTime: number) => {
- if (!finishedAt.value) {
- finishedAt.value = finishedTime;
- }
- starting.value = false;
- if (!finishedEmitted.value) {
- finishedEmitted.value = true;
- emit('finished');
- }
- };
- const handleSseMessage = (raw: string) => {
- const now = Date.now();
- const payload = parseSsePayload(raw);
- const structuredRows = extractStructuredRows(payload);
- if(payload?.cmd===999) return;
- if (structuredRows.length) {
- applyStructuredRows(structuredRows, now);
- } else {
- const content =
- extractTextFromObject(payload?.seeData) || extractTextFromObject(payload?.data) || payload?.message || raw;
- upsertSequentialRow(content, now);
- }
- if (isPayloadFailure(payload, raw)) {
- finalizeRunningRow('fail', now);
- errorText.value = '治理任务执行失败';
- starting.value = false;
- return;
- }
- if (isPayloadComplete(payload, raw)) {
- finalizeRunningRow('success', now);
- markFinished(now);
- }
- };
- const openSse = () => {
- eventSource?.close();
- sseConnected.value = false;
- errorText.value = '';
- eventSource = new EventSource(getGovernTaskSseUrl());
- eventSource.onopen = () => {
- sseConnected.value = true;
- errorText.value = '';
- };
- eventSource.onmessage = (event) => {
- handleSseMessage(event.data);
- };
- eventSource.onerror = () => {
- sseConnected.value = false;
- if (!finishedAt.value && !errorText.value) {
- errorText.value = 'SSE 连接中断,请稍后重试。';
- }
- eventSource?.close();
- eventSource = null;
- };
- };
- const startTask = async () => {
- try {
- await calcTask({ taskProcess: 1 });
- } catch (error: any) {
- starting.value = false;
- errorText.value = getErrorMessage(error);
- createMessage.error(errorText.value);
- }
- };
- const startTicker = () => {
- stopTicker();
- tickerTimer = setInterval(() => {
- currentNow.value = Date.now();
- }, 200);
- };
- const stopTicker = () => {
- if (tickerTimer) {
- clearInterval(tickerTimer);
- tickerTimer = null;
- }
- };
- const cleanup = () => {
- eventSource?.close();
- eventSource = null;
- stopTicker();
- starting.value = false;
- sseConnected.value = false;
- };
- const reset = () => {
- rows.value = [];
- starting.value = true;
- sseConnected.value = false;
- errorText.value = '';
- finishedAt.value = null;
- currentNow.value = Date.now();
- finishedEmitted.value = false;
- };
- const startGovernFlow = async () => {
- cleanup();
- reset();
- startTicker();
- openSse();
- await testSse();
- if (!props.open) return;
- await startTask();
- if (!eventSource || !sseConnected.value) {
- openSse();
- }
- };
- const handleCancel = () => {
- emit('update:open', false);
- };
- watch(
- () => props.open,
- (open) => {
- if (open) {
- void startGovernFlow();
- } else {
- cleanup();
- }
- },
- );
- onBeforeUnmount(() => {
- cleanup();
- });
- </script>
- <style lang="less">
- .case-govern-progress-modal {
- .ant-modal {
- width: min(560px, calc(100vw - 32px)) !important;
- top: 32px;
- }
- .ant-modal-content {
- height: 640px;
- border-radius: 8px;
- overflow: hidden;
- padding: 0;
- display: flex;
- flex-direction: column;
- }
- .ant-modal-header {
- padding: 12px 16px;
- border-bottom: 1px solid #e5e7eb;
- background: @modal-header-bg-color;
- flex: 0 0 auto;
- }
- .ant-modal-body {
- flex: 1 1 auto;
- min-height: 0;
- padding: 16px;
- display: flex;
- flex-direction: column;
- }
- .ant-modal-close {
- top: 16px;
- right: 16px;
- }
- }
- .govern-modal-title {
- display: inline-flex;
- align-items: center;
- gap: 10px;
- color: #111827;
- font-size: 16px;
- font-weight: 600;
- line-height: 24px;
- }
- .govern-modal-title__icon {
- width: 22px;
- height: 22px;
- border-radius: 6px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: linear-gradient(135deg, #fff1f2 0%, #ffe4e6 100%);
- color: #e11d48;
- font-size: 13px;
- }
- .govern-progress-panel {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- flex-direction: column;
- gap: 16px;
- }
- .govern-progress-notice {
- padding: 10px 12px;
- border-radius: 10px;
- background: #f8fafc;
- color: #64748b;
- font-size: 12px;
- line-height: 1.6;
- &.is-error {
- background: #fef2f2;
- color: #dc2626;
- }
- }
- .govern-progress-table {
- flex: 1 1 auto;
- min-height: 0;
- border: 1px solid #e5e7eb;
- border-radius: 10px;
- overflow: hidden;
- background: #fff;
- display: flex;
- flex-direction: column;
- }
- .govern-progress-table__head,
- .govern-progress-table__row {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 96px 112px;
- align-items: center;
- }
- .govern-progress-table__head {
- flex: 0 0 auto;
- min-height: 48px;
- background: #f8fafc;
- color: #475569;
- font-size: 13px;
- font-weight: 600;
- border-bottom: 1px solid #e5e7eb;
- }
- .govern-progress-table__cell {
- min-width: 0;
- padding: 0 14px;
- border-right: 1px solid #eef2f7;
- &:last-child {
- border-right: 0;
- }
- }
- .govern-progress-table__body {
- flex: 1 1 auto;
- min-height: 0;
- overflow: auto;
- }
- .govern-progress-table__list {
- display: flex;
- flex-direction: column;
- }
- .govern-progress-table__row {
- min-height: 44px;
- color: #334155;
- font-size: 13px;
- border-bottom: 1px solid #f1f5f9;
- &:nth-child(even) {
- background: #fafcff;
- }
- &:last-child {
- border-bottom: 0;
- }
- }
- .govern-progress-table__row .cell-content {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .govern-progress-table__row .cell-status,
- .govern-progress-table__row .cell-duration {
- text-align: center;
- white-space: nowrap;
- }
- .status-text {
- color: #334155;
- &.is-running {
- color: #2563eb;
- }
- &.is-success {
- color: #15803d;
- }
- &.is-fail {
- color: #dc2626;
- }
- &.is-pending {
- color: #94a3b8;
- }
- }
- .govern-progress-table__empty {
- min-height: 160px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #94a3b8;
- font-size: 13px;
- line-height: 1.6;
- }
- .govern-progress-footer {
- flex: 0 0 auto;
- min-height: 22px;
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 8px;
- color: #6b7280;
- font-size: 13px;
- line-height: 1.6;
- }
- @media (max-width: 640px) {
- .govern-progress-table__head,
- .govern-progress-table__row {
- grid-template-columns: minmax(0, 1fr) 84px 92px;
- }
- .govern-progress-table__cell {
- padding: 0 10px;
- }
- }
- </style>
|