feat: 优化定时任务管理与日志体验
- 增加工作流选项、时间范围筛选和日志详情展示 - 支持可配置自动刷新、轻量局部更新与响应式布局
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -19,5 +19,6 @@
|
|||||||
"workflow": "工作流",
|
"workflow": "工作流",
|
||||||
"beanMethod": "bean方法",
|
"beanMethod": "bean方法",
|
||||||
"javaMethod": "java方法",
|
"javaMethod": "java方法",
|
||||||
"example": "示例"
|
"example": "示例",
|
||||||
|
"triggerAccepted": "任务已提交执行"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "创建时间",
|
||||||
|
|||||||
@@ -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}`)
|
||||||
if (res.errorCode === 0) {
|
.then((res) => {
|
||||||
ElMessage.success(res.message);
|
if (res.errorCode === 0) {
|
||||||
reloadCurrentList();
|
ElMessage.success(res.message);
|
||||||
done();
|
reloadCurrentList();
|
||||||
}
|
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}`)
|
||||||
if (res.errorCode === 0) {
|
.then((res) => {
|
||||||
ElMessage.success(res.message);
|
if (res.errorCode === 0) {
|
||||||
reloadCurrentList();
|
ElMessage.success(res.message);
|
||||||
done();
|
reloadCurrentList();
|
||||||
}
|
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') }}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user