From 155af9989c41723298a249aadddde51feffd6dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Mon, 31 Aug 2026 14:57:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=AE=A1=E7=90=86=E4=B8=8E=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加工作流选项、时间范围筛选和日志详情展示 - 支持可配置自动刷新、轻量局部更新与响应式布局 --- .../app/src/components/page/PageData.test.ts | 103 ++ .../app/src/components/page/PageData.vue | 60 +- .../app/src/locales/langs/en-US/sysJob.json | 3 +- .../src/locales/langs/en-US/sysJobLog.json | 31 + .../app/src/locales/langs/zh-CN/sysJob.json | 3 +- .../src/locales/langs/zh-CN/sysJobLog.json | 31 + .../src/views/system/sysJob/SysJobList.vue | 72 +- .../views/system/sysJob/SysJobLogList.test.ts | 197 +++ .../src/views/system/sysJob/SysJobLogList.vue | 1166 +++++++++++++++-- 9 files changed, 1533 insertions(+), 133 deletions(-) create mode 100644 easyflow-ui-admin/app/src/views/system/sysJob/SysJobLogList.test.ts diff --git a/easyflow-ui-admin/app/src/components/page/PageData.test.ts b/easyflow-ui-admin/app/src/components/page/PageData.test.ts index 59670f8b..cbeeda0a 100644 --- a/easyflow-ui-admin/app/src/components/page/PageData.test.ts +++ b/easyflow-ui-admin/app/src/components/page/PageData.test.ts @@ -122,6 +122,109 @@ describe('page data recovery', () => { expect(get).toHaveBeenCalledTimes(2); }); + it('does not let a lightweight refresh supersede an active page query', async () => { + let resolveInitialRequest: (value: { + data: { records: { id: string }[]; totalRow: number }; + }) => void = () => {}; + const initialRequest = new Promise<{ + data: { records: { id: string }[]; totalRow: number }; + }>((resolve) => { + resolveInitialRequest = resolve; + }); + const get = vi.fn().mockReturnValueOnce(initialRequest); + const wrapper = mount(PageData, { + global: { + directives: { loading: {} }, + }, + props: { + pageUrl: '/page', + refreshUrl: '/refresh', + requestClient: { get }, + }, + }); + + const refreshRequest = (wrapper.vm as any).reload({ + lightweight: true, + silent: true, + }); + expect(get).toHaveBeenCalledTimes(1); + + resolveInitialRequest({ + data: { records: [{ id: 'counted-result' }], totalRow: 21 }, + }); + await refreshRequest; + await flushPromises(); + + expect(get).toHaveBeenCalledTimes(1); + expect(wrapper.emitted('loadSuccess')?.at(-1)?.[0]).toMatchObject({ + lightweight: false, + recordCount: 1, + }); + expect(wrapper.find('.el-pagination').exists()).toBe(true); + }); + + it('uses the lightweight refresh endpoint without replacing the total', async () => { + const get = vi + .fn() + .mockResolvedValueOnce({ + data: { records: [{ id: 'initial' }], totalRow: 35 }, + }) + .mockResolvedValueOnce({ data: [{ id: 'latest' }] }); + const wrapper = mount(PageData, { + global: { + directives: { loading: {} }, + }, + props: { + pageUrl: '/page', + refreshUrl: '/refresh', + requestClient: { get }, + }, + }); + await flushPromises(); + + await (wrapper.vm as any).reload({ lightweight: true, silent: true }); + await flushPromises(); + + expect(get).toHaveBeenLastCalledWith('/refresh', { + params: { pageNumber: 1, pageSize: 10 }, + }); + expect(wrapper.find('.el-pagination').exists()).toBe(true); + expect(wrapper.emitted('loadSuccess')?.at(-1)?.[0]).toMatchObject({ + lightweight: true, + pageNumber: 1, + recordCount: 1, + }); + }); + + it('falls back to the counted page endpoint outside the first page', async () => { + const get = vi + .fn() + .mockResolvedValue({ data: { records: [], totalRow: 30 } }); + const wrapper = mount(PageData, { + global: { + directives: { loading: {} }, + }, + props: { + initialPageNumber: 2, + pageUrl: '/page', + refreshUrl: '/refresh', + requestClient: { get }, + }, + }); + await flushPromises(); + + await (wrapper.vm as any).reload({ lightweight: true, silent: true }); + await flushPromises(); + + expect(get).toHaveBeenLastCalledWith('/page', { + params: { pageNumber: 2, pageSize: 10 }, + }); + expect(wrapper.emitted('loadSuccess')?.at(-1)?.[0]).toMatchObject({ + lightweight: false, + pageNumber: 2, + }); + }); + it('restores a mounted list to a new route state with one target request', async () => { const get = vi .fn() diff --git a/easyflow-ui-admin/app/src/components/page/PageData.vue b/easyflow-ui-admin/app/src/components/page/PageData.vue index 74588d0f..c8da9ae1 100644 --- a/easyflow-ui-admin/app/src/components/page/PageData.vue +++ b/easyflow-ui-admin/app/src/components/page/PageData.vue @@ -10,6 +10,7 @@ import { getEmptyStateImageUrl } from '#/utils/assets'; interface PageDataProps { pageUrl: string; + refreshUrl?: string; pageSize?: number; pageSizes?: number[]; extraQueryParams?: Record; @@ -29,23 +30,38 @@ interface PageDataRestoreState extends PageDataState { } interface PageDataReloadOptions { + lightweight?: boolean; silent?: boolean; } interface PageDataRequest { + lightweight: boolean; silent: boolean; version: number; } +interface PageDataLoadEvent { + lightweight: boolean; + pageNumber: number; + recordCount: number; +} + +interface PageDataLoadErrorEvent extends PageDataLoadEvent { + error: unknown; +} + const props = withDefaults(defineProps(), { pageSize: 10, pageSizes: () => [10, 20, 50, 100], + refreshUrl: undefined, extraQueryParams: () => ({}), initialPageNumber: 1, initialQueryParams: () => ({}), requestClient: () => api, }); const emit = defineEmits<{ + (e: 'loadError', event: PageDataLoadErrorEvent): void; + (e: 'loadSuccess', event: PageDataLoadEvent): void; (e: 'stateChange', state: PageDataState): void; }>(); @@ -67,10 +83,10 @@ const pageInfo = reactive({ }); // 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整 -const doGet = async (params: Record) => { +const doGet = async (url: string, params: Record) => { // 这里替换为你的实际 API 调用 // 例如:return await api.get(props.pageUrl, { params }) - const response = await props.requestClient.get(`${props.pageUrl}`, { + const response = await props.requestClient.get(url, { params, }); const data = await response.data; @@ -78,14 +94,28 @@ const doGet = async (params: Record) => { }; const loadPageListOnce = async (request: PageDataRequest) => { + const lightweight = Boolean( + request.lightweight && props.refreshUrl && pageInfo.pageNumber === 1, + ); try { - const res = await doGet({ + const res = await doGet(lightweight ? props.refreshUrl! : props.pageUrl, { pageNumber: pageInfo.pageNumber, pageSize: pageInfo.pageSize, ...props.extraQueryParams, ...queryParams.value, }); if (request.version === pageRequestVersion) { + if (lightweight) { + pageList.value = Array.isArray(res.data) + ? res.data + : res.data?.records || []; + emit('loadSuccess', { + lightweight: true, + pageNumber: pageInfo.pageNumber, + recordCount: pageList.value.length, + }); + return; + } const rawTotal = Number(res.data?.totalRow || 0); const total = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : 0; const lastPage = Math.max(1, Math.ceil(total / pageInfo.pageSize)); @@ -96,6 +126,11 @@ const loadPageListOnce = async (request: PageDataRequest) => { return; } pageList.value = res.data?.records || []; + emit('loadSuccess', { + lightweight: false, + pageNumber: pageInfo.pageNumber, + recordCount: pageList.value.length, + }); } } catch (error) { if (request.version === pageRequestVersion) { @@ -104,12 +139,24 @@ const loadPageListOnce = async (request: PageDataRequest) => { pageList.value = []; pageInfo.total = 0; } + emit('loadError', { + error, + lightweight, + pageNumber: pageInfo.pageNumber, + recordCount: pageList.value.length, + }); } } }; -const requestPageList = (silent: boolean) => { +const requestPageList = (silent: boolean, lightweight = false) => { + // 自动刷新只补充最新行;已有请求能够提供同等或更完整的数据时直接复用, + // 避免它使正在进行的分页查询失效或额外排队。 + if (activePageRequest && lightweight) { + return activePageRequest; + } const request: PageDataRequest = { + lightweight, silent: silent && pageList.value.length > 0, version: ++pageRequestVersion, }; @@ -120,6 +167,7 @@ const requestPageList = (silent: boolean) => { if (activePageRequest) { pendingPageRequest = pendingPageRequest ? { + lightweight: pendingPageRequest.lightweight && request.lightweight, silent: pendingPageRequest.silent && request.silent, version: request.version, } @@ -143,10 +191,10 @@ const requestPageList = (silent: boolean) => { }; // 获取页面数据 -const getPageList = () => requestPageList(false); +const getPageList = () => requestPageList(false, false); const reload = (options: PageDataReloadOptions = {}) => - requestPageList(Boolean(options.silent)); + requestPageList(Boolean(options.silent), Boolean(options.lightweight)); // 分页事件处理 const handleSizeChange = (newSize: number) => { diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/sysJob.json b/easyflow-ui-admin/app/src/locales/langs/en-US/sysJob.json index 620acbae..3253a691 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/sysJob.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/sysJob.json @@ -19,5 +19,6 @@ "workflow": "Workflow", "beanMethod": "BeanMethod", "javaMethod": "JavaMethod", - "example": "example" + "example": "example", + "triggerAccepted": "The job has been submitted" } diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/sysJobLog.json b/easyflow-ui-admin/app/src/locales/langs/en-US/sysJobLog.json index 521ac9b8..85ea45da 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/sysJobLog.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/sysJobLog.json @@ -7,6 +7,37 @@ "jobResult": "JobResult", "errorInfo": "ErrorInfo", "status": "Status", + "jobInfo": "Job information", + "triggerSource": "Trigger source", + "timeField": "Fire time type", + "scheduledFireTime": "Scheduled fire time", + "actualFireTime": "Actual fire time", + "attemptCount": "Claim attempts", + "manual": "Manual", + "scheduled": "Scheduled", + "rangeStart": "Start time", + "rangeEnd": "End time", + "autoRefresh": "Auto refresh", + "refreshInterval": "Refresh interval", + "seconds": "s", + "manualRefresh": "Refresh now", + "refreshing": "Refreshing", + "updated": "updated", + "notRefreshed": "Waiting for refresh", + "historyPagePaused": "Paused on history page", + "refreshFailedPaused": "Refresh failed and paused", + "unknownStatus": "Unknown status", + "unknownSource": "Unknown source", + "executionWindow": "Execution window", + "executionSummary": "Execution summary", + "duration": "Duration", + "noExecutionOutput": "No execution output", + "detailTitle": "Execution details", + "executionNode": "Execution node", + "jobOptions": "Job option snapshot", + "copy": "Copy", + "copied": "Copied", + "copyFailed": "Copy failed; select the content manually", "startTime": "StartTime", "endTime": "EndTime", "created": "Created", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysJob.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysJob.json index 70de1fa5..2656d7ab 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysJob.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysJob.json @@ -19,5 +19,6 @@ "workflow": "工作流", "beanMethod": "bean方法", "javaMethod": "java方法", - "example": "示例" + "example": "示例", + "triggerAccepted": "任务已提交执行" } diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysJobLog.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysJobLog.json index 32dabc13..427cffa5 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysJobLog.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysJobLog.json @@ -7,6 +7,37 @@ "jobResult": "执行结果", "errorInfo": "错误信息", "status": "执行状态", + "jobInfo": "任务信息", + "triggerSource": "触发来源", + "timeField": "触发时间类型", + "scheduledFireTime": "计划触发时间", + "actualFireTime": "实际触发时间", + "attemptCount": "认领次数", + "manual": "手动", + "scheduled": "计划", + "rangeStart": "开始时间", + "rangeEnd": "结束时间", + "autoRefresh": "自动刷新", + "refreshInterval": "刷新间隔", + "seconds": "秒", + "manualRefresh": "立即刷新", + "refreshing": "正在刷新", + "updated": "已更新", + "notRefreshed": "等待刷新", + "historyPagePaused": "历史页已暂停", + "refreshFailedPaused": "刷新失败,已暂停", + "unknownStatus": "未知状态", + "unknownSource": "未知来源", + "executionWindow": "执行窗口", + "executionSummary": "执行摘要", + "duration": "耗时", + "noExecutionOutput": "暂无执行输出", + "detailTitle": "执行详情", + "executionNode": "执行节点", + "jobOptions": "任务配置快照", + "copy": "复制", + "copied": "已复制", + "copyFailed": "复制失败,请手动选择内容", "startTime": "开始时间", "endTime": "结束时间", "created": "创建时间", diff --git a/easyflow-ui-admin/app/src/views/system/sysJob/SysJobList.vue b/easyflow-ui-admin/app/src/views/system/sysJob/SysJobList.vue index 248d07b0..80004a7b 100644 --- a/easyflow-ui-admin/app/src/views/system/sysJob/SysJobList.vue +++ b/easyflow-ui-admin/app/src/views/system/sysJob/SysJobList.vue @@ -9,6 +9,7 @@ import { MoreFilled, Plus, Tickets, + VideoPlay, } from '@element-plus/icons-vue'; import { ElButton, @@ -66,6 +67,7 @@ watchListRouteState(route, sysJobListRouteSchema, (state) => { }); }); const saveDialog = ref(); +const triggeringJobId = ref(); const dictStore = useDictStore(); const headerButtons = [ { @@ -131,19 +133,24 @@ function start(row: any) { beforeClose: (action, instance, done) => { if (action === 'confirm') { instance.confirmButtonLoading = true; - api.get(`/api/v1/sysJob/start?id=${row.id}`).then((res) => { - instance.confirmButtonLoading = false; - if (res.errorCode === 0) { - ElMessage.success(res.message); - reloadCurrentList(); - done(); - } - }); + api + .get(`/api/v1/sysJob/start?id=${row.id}`) + .then((res) => { + if (res.errorCode === 0) { + ElMessage.success(res.message); + reloadCurrentList(); + done(); + } + }) + .catch(() => {}) + .finally(() => { + instance.confirmButtonLoading = false; + }); } else { done(); } }, - }); + }).catch(() => {}); } function stop(row: any) { ElMessageBox.confirm($t('message.stopAlert'), $t('message.noticeTitle'), { @@ -153,19 +160,36 @@ function stop(row: any) { beforeClose: (action, instance, done) => { if (action === 'confirm') { instance.confirmButtonLoading = true; - api.get(`/api/v1/sysJob/stop?id=${row.id}`).then((res) => { - instance.confirmButtonLoading = false; - if (res.errorCode === 0) { - ElMessage.success(res.message); - reloadCurrentList(); - done(); - } - }); + api + .get(`/api/v1/sysJob/stop?id=${row.id}`) + .then((res) => { + if (res.errorCode === 0) { + ElMessage.success(res.message); + reloadCurrentList(); + done(); + } + }) + .catch(() => {}) + .finally(() => { + instance.confirmButtonLoading = false; + }); } else { done(); } }, - }); + }).catch(() => {}); +} +async function trigger(row: any) { + if (triggeringJobId.value) return; + triggeringJobId.value = String(row.id); + try { + const res = await api.get(`/api/v1/sysJob/trigger?id=${row.id}`); + if (res.errorCode === 0) { + ElMessage.success($t('sysJob.triggerAccepted')); + } + } finally { + triggeringJobId.value = undefined; + } } function toLogPage(row: any) { router.push({ @@ -313,6 +337,18 @@ function handlePageStateChange(state: { v-if="row.status === 1" v-access:code="'/api/v1/sysJob/save'" > + + + {{ $t('button.run') }} + + {{ $t('button.stop') }} diff --git a/easyflow-ui-admin/app/src/views/system/sysJob/SysJobLogList.test.ts b/easyflow-ui-admin/app/src/views/system/sysJob/SysJobLogList.test.ts new file mode 100644 index 00000000..05f54dfb --- /dev/null +++ b/easyflow-ui-admin/app/src/views/system/sysJob/SysJobLogList.test.ts @@ -0,0 +1,197 @@ +/* eslint-disable vue/one-component-per-file -- Inline stubs isolate timer behavior. */ +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h, onMounted } from 'vue'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import SysJobLogList from './SysJobLogList.vue'; + +const pageDataMocks = vi.hoisted(() => ({ + emitLoadError: () => {}, + reload: vi.fn(() => Promise.resolve()), + setQuery: vi.fn(), +})); + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: { jobId: 'job-1' } }), + useRouter: () => ({}), +})); +vi.mock('#/locales', () => ({ $t: (key: string) => key })); +vi.mock('#/router/list-return-context', () => ({ + navigateBackToList: vi.fn(), +})); +vi.mock('#/store', () => ({ + useDictStore: () => ({ + fetchDictionary: vi.fn(), + getDictLabel: vi.fn(), + }), +})); +vi.mock('#/components/dict/DictSelect.vue', () => ({ + default: defineComponent({ + name: 'DictSelect', + setup: () => () => h('div'), + }), +})); +vi.mock('#/components/page/ListPageShell.vue', () => ({ + default: defineComponent({ + name: 'ListPageShell', + setup(_, { slots }) { + return () => + h('div', [slots.filters?.(), slots.actions?.(), slots.default?.()]); + }, + }), +})); +vi.mock('#/components/page/PageData.vue', () => ({ + default: defineComponent({ + name: 'PageData', + emits: ['loadError', 'loadSuccess', 'stateChange'], + setup(_, { emit, expose }) { + expose({ + reload: pageDataMocks.reload, + setQuery: pageDataMocks.setQuery, + }); + pageDataMocks.emitLoadError = () => + emit('loadError', { + error: new Error('refresh failed'), + lightweight: true, + pageNumber: 1, + recordCount: 1, + }); + onMounted(() => { + emit('stateChange', { pageNumber: 1, pageSize: 10 }); + emit('loadSuccess', { + lightweight: false, + pageNumber: 1, + recordCount: 1, + }); + }); + return () => h('div'); + }, + }), +})); + +const ElButtonStub = defineComponent({ + name: 'ElButton', + setup(_, { attrs, slots }) { + return () => h('button', attrs, slots.default?.()); + }, +}); +const ElSwitchStub = defineComponent({ + name: 'ElSwitch', + emits: ['change', 'update:modelValue'], + setup(_, { emit }) { + return () => + h( + 'button', + { + 'data-testid': 'auto-refresh-switch', + onClick: () => { + emit('update:modelValue', true); + emit('change', true); + }, + }, + 'auto refresh', + ); + }, +}); + +function mountPage() { + return mount(SysJobLogList, { + global: { + stubs: { + ElButton: ElButtonStub, + ElDatePicker: true, + ElDrawer: true, + ElOption: true, + ElSelect: true, + ElSwitch: ElSwitchStub, + ElTable: true, + ElTableColumn: true, + ElTag: true, + ElTooltip: true, + }, + }, + }); +} + +describe('sys job log auto refresh', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + localStorage.setItem('easyflow.sysJobLog.autoRefreshIntervalSeconds', '5'); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + }); + + afterEach(() => { + localStorage.clear(); + vi.useRealTimers(); + }); + + it('refreshes immediately and schedules the next lightweight request', async () => { + const wrapper = mountPage(); + await flushPromises(); + + await wrapper.get('[data-testid="auto-refresh-switch"]').trigger('click'); + await flushPromises(); + expect(pageDataMocks.reload).toHaveBeenCalledWith({ + lightweight: true, + silent: true, + }); + + await vi.advanceTimersByTimeAsync(5000); + await flushPromises(); + expect(pageDataMocks.reload).toHaveBeenCalledTimes(2); + wrapper.unmount(); + }); + + it('stops polling after a lightweight refresh failure', async () => { + const wrapper = mountPage(); + await flushPromises(); + await wrapper.get('[data-testid="auto-refresh-switch"]').trigger('click'); + await flushPromises(); + expect(pageDataMocks.reload).toHaveBeenCalledTimes(1); + + pageDataMocks.emitLoadError(); + await flushPromises(); + await vi.advanceTimersByTimeAsync(10_000); + expect(pageDataMocks.reload).toHaveBeenCalledTimes(1); + expect(wrapper.text()).toContain('sysJobLog.refreshFailedPaused'); + wrapper.unmount(); + }); + + it('maps the single time range to the selected fire time field', async () => { + const wrapper = mountPage(); + await flushPromises(); + const viewModel = wrapper.vm as any; + viewModel.filters.status = 1; + viewModel.filters.timeField = 'actual'; + viewModel.filters.timeRange = [ + '2026-08-31 10:00:00', + '2026-08-31 11:00:00', + ]; + await flushPromises(); + + const queryButton = wrapper + .findAll('button') + .find((button) => button.text() === 'button.query'); + await queryButton?.trigger('click'); + + expect(pageDataMocks.setQuery).toHaveBeenLastCalledWith({ + actualEnd: '2026-08-31 11:00:00', + actualStart: '2026-08-31 10:00:00', + status: 1, + }); + + viewModel.filters.timeField = 'scheduled'; + await queryButton?.trigger('click'); + expect(pageDataMocks.setQuery).toHaveBeenLastCalledWith({ + scheduledEnd: '2026-08-31 11:00:00', + scheduledStart: '2026-08-31 10:00:00', + status: 1, + }); + wrapper.unmount(); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/system/sysJob/SysJobLogList.vue b/easyflow-ui-admin/app/src/views/system/sysJob/SysJobLogList.vue index a3de3739..eca954c0 100644 --- a/easyflow-ui-admin/app/src/views/system/sysJob/SysJobLogList.vue +++ b/easyflow-ui-admin/app/src/views/system/sysJob/SysJobLogList.vue @@ -1,49 +1,217 @@