feat: 优化定时任务管理与日志体验

- 增加工作流选项、时间范围筛选和日志详情展示

- 支持可配置自动刷新、轻量局部更新与响应式布局
This commit is contained in:
2026-08-31 14:57:20 +08:00
parent 8c174e5c02
commit 155af9989c
9 changed files with 1533 additions and 133 deletions

View File

@@ -122,6 +122,109 @@ describe('page data recovery', () => {
expect(get).toHaveBeenCalledTimes(2); 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 () => { it('restores a mounted list to a new route state with one target request', async () => {
const get = vi const get = vi
.fn() .fn()

View File

@@ -10,6 +10,7 @@ import { getEmptyStateImageUrl } from '#/utils/assets';
interface PageDataProps { interface PageDataProps {
pageUrl: string; pageUrl: string;
refreshUrl?: string;
pageSize?: number; pageSize?: number;
pageSizes?: number[]; pageSizes?: number[];
extraQueryParams?: Record<string, any>; extraQueryParams?: Record<string, any>;
@@ -29,23 +30,38 @@ interface PageDataRestoreState extends PageDataState {
} }
interface PageDataReloadOptions { interface PageDataReloadOptions {
lightweight?: boolean;
silent?: boolean; silent?: boolean;
} }
interface PageDataRequest { interface PageDataRequest {
lightweight: boolean;
silent: boolean; silent: boolean;
version: number; version: number;
} }
interface PageDataLoadEvent {
lightweight: boolean;
pageNumber: number;
recordCount: number;
}
interface PageDataLoadErrorEvent extends PageDataLoadEvent {
error: unknown;
}
const props = withDefaults(defineProps<PageDataProps>(), { const props = withDefaults(defineProps<PageDataProps>(), {
pageSize: 10, pageSize: 10,
pageSizes: () => [10, 20, 50, 100], pageSizes: () => [10, 20, 50, 100],
refreshUrl: undefined,
extraQueryParams: () => ({}), extraQueryParams: () => ({}),
initialPageNumber: 1, initialPageNumber: 1,
initialQueryParams: () => ({}), initialQueryParams: () => ({}),
requestClient: () => api, requestClient: () => api,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'loadError', event: PageDataLoadErrorEvent): void;
(e: 'loadSuccess', event: PageDataLoadEvent): void;
(e: 'stateChange', state: PageDataState): void; (e: 'stateChange', state: PageDataState): void;
}>(); }>();
@@ -67,10 +83,10 @@ const pageInfo = reactive({
}); });
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整 // 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
const doGet = async (params: Record<string, any>) => { const doGet = async (url: string, params: Record<string, any>) => {
// 这里替换为你的实际 API 调用 // 这里替换为你的实际 API 调用
// 例如return await api.get(props.pageUrl, { params }) // 例如return await api.get(props.pageUrl, { params })
const response = await props.requestClient.get(`${props.pageUrl}`, { const response = await props.requestClient.get(url, {
params, params,
}); });
const data = await response.data; const data = await response.data;
@@ -78,14 +94,28 @@ const doGet = async (params: Record<string, any>) => {
}; };
const loadPageListOnce = async (request: PageDataRequest) => { const loadPageListOnce = async (request: PageDataRequest) => {
const lightweight = Boolean(
request.lightweight && props.refreshUrl && pageInfo.pageNumber === 1,
);
try { try {
const res = await doGet({ const res = await doGet(lightweight ? props.refreshUrl! : props.pageUrl, {
pageNumber: pageInfo.pageNumber, pageNumber: pageInfo.pageNumber,
pageSize: pageInfo.pageSize, pageSize: pageInfo.pageSize,
...props.extraQueryParams, ...props.extraQueryParams,
...queryParams.value, ...queryParams.value,
}); });
if (request.version === pageRequestVersion) { 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 rawTotal = Number(res.data?.totalRow || 0);
const total = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : 0; const total = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : 0;
const lastPage = Math.max(1, Math.ceil(total / pageInfo.pageSize)); const lastPage = Math.max(1, Math.ceil(total / pageInfo.pageSize));
@@ -96,6 +126,11 @@ const loadPageListOnce = async (request: PageDataRequest) => {
return; return;
} }
pageList.value = res.data?.records || []; pageList.value = res.data?.records || [];
emit('loadSuccess', {
lightweight: false,
pageNumber: pageInfo.pageNumber,
recordCount: pageList.value.length,
});
} }
} catch (error) { } catch (error) {
if (request.version === pageRequestVersion) { if (request.version === pageRequestVersion) {
@@ -104,12 +139,24 @@ const loadPageListOnce = async (request: PageDataRequest) => {
pageList.value = []; pageList.value = [];
pageInfo.total = 0; 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 = { const request: PageDataRequest = {
lightweight,
silent: silent && pageList.value.length > 0, silent: silent && pageList.value.length > 0,
version: ++pageRequestVersion, version: ++pageRequestVersion,
}; };
@@ -120,6 +167,7 @@ const requestPageList = (silent: boolean) => {
if (activePageRequest) { if (activePageRequest) {
pendingPageRequest = pendingPageRequest pendingPageRequest = pendingPageRequest
? { ? {
lightweight: pendingPageRequest.lightweight && request.lightweight,
silent: pendingPageRequest.silent && request.silent, silent: pendingPageRequest.silent && request.silent,
version: request.version, version: request.version,
} }
@@ -143,10 +191,10 @@ const requestPageList = (silent: boolean) => {
}; };
// 获取页面数据 // 获取页面数据
const getPageList = () => requestPageList(false); const getPageList = () => requestPageList(false, false);
const reload = (options: PageDataReloadOptions = {}) => const reload = (options: PageDataReloadOptions = {}) =>
requestPageList(Boolean(options.silent)); requestPageList(Boolean(options.silent), Boolean(options.lightweight));
// 分页事件处理 // 分页事件处理
const handleSizeChange = (newSize: number) => { const handleSizeChange = (newSize: number) => {

View File

@@ -19,5 +19,6 @@
"workflow": "Workflow", "workflow": "Workflow",
"beanMethod": "BeanMethod", "beanMethod": "BeanMethod",
"javaMethod": "JavaMethod", "javaMethod": "JavaMethod",
"example": "example" "example": "example",
"triggerAccepted": "The job has been submitted"
} }

View File

@@ -7,6 +7,37 @@
"jobResult": "JobResult", "jobResult": "JobResult",
"errorInfo": "ErrorInfo", "errorInfo": "ErrorInfo",
"status": "Status", "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", "startTime": "StartTime",
"endTime": "EndTime", "endTime": "EndTime",
"created": "Created", "created": "Created",

View File

@@ -19,5 +19,6 @@
"workflow": "工作流", "workflow": "工作流",
"beanMethod": "bean方法", "beanMethod": "bean方法",
"javaMethod": "java方法", "javaMethod": "java方法",
"example": "示例" "example": "示例",
"triggerAccepted": "任务已提交执行"
} }

View File

@@ -7,6 +7,37 @@
"jobResult": "执行结果", "jobResult": "执行结果",
"errorInfo": "错误信息", "errorInfo": "错误信息",
"status": "执行状态", "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": "开始时间", "startTime": "开始时间",
"endTime": "结束时间", "endTime": "结束时间",
"created": "创建时间", "created": "创建时间",

View File

@@ -9,6 +9,7 @@ import {
MoreFilled, MoreFilled,
Plus, Plus,
Tickets, Tickets,
VideoPlay,
} from '@element-plus/icons-vue'; } from '@element-plus/icons-vue';
import { import {
ElButton, ElButton,
@@ -66,6 +67,7 @@ watchListRouteState(route, sysJobListRouteSchema, (state) => {
}); });
}); });
const saveDialog = ref(); const saveDialog = ref();
const triggeringJobId = ref<string | undefined>();
const dictStore = useDictStore(); const dictStore = useDictStore();
const headerButtons = [ const headerButtons = [
{ {
@@ -131,19 +133,24 @@ function start(row: any) {
beforeClose: (action, instance, done) => { beforeClose: (action, instance, done) => {
if (action === 'confirm') { if (action === 'confirm') {
instance.confirmButtonLoading = true; instance.confirmButtonLoading = true;
api.get(`/api/v1/sysJob/start?id=${row.id}`).then((res) => { api
instance.confirmButtonLoading = false; .get(`/api/v1/sysJob/start?id=${row.id}`)
.then((res) => {
if (res.errorCode === 0) { if (res.errorCode === 0) {
ElMessage.success(res.message); ElMessage.success(res.message);
reloadCurrentList(); reloadCurrentList();
done(); done();
} }
})
.catch(() => {})
.finally(() => {
instance.confirmButtonLoading = false;
}); });
} else { } else {
done(); done();
} }
}, },
}); }).catch(() => {});
} }
function stop(row: any) { function stop(row: any) {
ElMessageBox.confirm($t('message.stopAlert'), $t('message.noticeTitle'), { ElMessageBox.confirm($t('message.stopAlert'), $t('message.noticeTitle'), {
@@ -153,19 +160,36 @@ function stop(row: any) {
beforeClose: (action, instance, done) => { beforeClose: (action, instance, done) => {
if (action === 'confirm') { if (action === 'confirm') {
instance.confirmButtonLoading = true; instance.confirmButtonLoading = true;
api.get(`/api/v1/sysJob/stop?id=${row.id}`).then((res) => { api
instance.confirmButtonLoading = false; .get(`/api/v1/sysJob/stop?id=${row.id}`)
.then((res) => {
if (res.errorCode === 0) { if (res.errorCode === 0) {
ElMessage.success(res.message); ElMessage.success(res.message);
reloadCurrentList(); reloadCurrentList();
done(); done();
} }
})
.catch(() => {})
.finally(() => {
instance.confirmButtonLoading = false;
}); });
} else { } else {
done(); 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) { function toLogPage(row: any) {
router.push({ router.push({
@@ -313,6 +337,18 @@ function handlePageStateChange(state: {
v-if="row.status === 1" v-if="row.status === 1"
v-access:code="'/api/v1/sysJob/save'" v-access:code="'/api/v1/sysJob/save'"
> >
<ElDropdownItem
:disabled="Boolean(triggeringJobId)"
@click="trigger(row)"
>
<ElButton
:icon="VideoPlay"
:loading="triggeringJobId === String(row.id)"
link
>
{{ $t('button.run') }}
</ElButton>
</ElDropdownItem>
<ElDropdownItem @click="stop(row)"> <ElDropdownItem @click="stop(row)">
<ElButton :icon="CircleCloseFilled" link> <ElButton :icon="CircleCloseFilled" link>
{{ $t('button.stop') }} {{ $t('button.stop') }}

View File

@@ -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();
});
});

File diff suppressed because it is too large Load Diff