feat: 增加QQBot插件定时任务页面
This commit is contained in:
parent
4b7d732ec7
commit
35747e39eb
@ -45,6 +45,7 @@
|
|||||||
"@vben/styles": "workspace:*",
|
"@vben/styles": "workspace:*",
|
||||||
"@vben/types": "workspace:*",
|
"@vben/types": "workspace:*",
|
||||||
"@vben/utils": "workspace:*",
|
"@vben/utils": "workspace:*",
|
||||||
|
"@vue-js-cron/core": "catalog:",
|
||||||
"@vueuse/core": "catalog:",
|
"@vueuse/core": "catalog:",
|
||||||
"@vueuse/integrations": "catalog:",
|
"@vueuse/integrations": "catalog:",
|
||||||
"antdv-next": "catalog:",
|
"antdv-next": "catalog:",
|
||||||
|
|||||||
@ -46,6 +46,12 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
|
|||||||
'QqBotPermissionDelete',
|
'QqBotPermissionDelete',
|
||||||
'QqBotPermissionEdit',
|
'QqBotPermissionEdit',
|
||||||
'QqBotPlugin',
|
'QqBotPlugin',
|
||||||
|
'QqBotPluginTask',
|
||||||
|
'QqBotPluginTaskDisable',
|
||||||
|
'QqBotPluginTaskEnable',
|
||||||
|
'QqBotPluginTaskRun',
|
||||||
|
'QqBotPluginTaskRunLog',
|
||||||
|
'QqBotPluginTaskUpdateCron',
|
||||||
'QqBotRule',
|
'QqBotRule',
|
||||||
'QqBotRuleCreate',
|
'QqBotRuleCreate',
|
||||||
'QqBotRuleDelete',
|
'QqBotRuleDelete',
|
||||||
|
|||||||
78
apps/web-antdv-next/src/api/qqbot/plugin-task.spec.ts
Normal file
78
apps/web-antdv-next/src/api/qqbot/plugin-task.spec.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
import {
|
||||||
|
disableQqbotPluginTask,
|
||||||
|
enableQqbotPluginTask,
|
||||||
|
getQqbotPluginTaskPage,
|
||||||
|
getQqbotPluginTaskRunPage,
|
||||||
|
runQqbotPluginTaskOnce,
|
||||||
|
updateQqbotPluginTaskCron,
|
||||||
|
} from './plugin-task';
|
||||||
|
|
||||||
|
vi.mock('#/api/request', () => ({
|
||||||
|
requestClient: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('qqbot plugin task API wrappers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses plugin-platform task endpoints', async () => {
|
||||||
|
vi.mocked(requestClient.get).mockResolvedValue({ list: [], total: 0 });
|
||||||
|
vi.mocked(requestClient.post).mockResolvedValue({});
|
||||||
|
|
||||||
|
await getQqbotPluginTaskPage({
|
||||||
|
enabled: true,
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
taskKey: 'bangdream.bestdori.sync-main-data',
|
||||||
|
});
|
||||||
|
await enableQqbotPluginTask('task-1');
|
||||||
|
await disableQqbotPluginTask('task-1');
|
||||||
|
await updateQqbotPluginTaskCron('task-1', '0 */6 * * *');
|
||||||
|
await runQqbotPluginTaskOnce('task-1', { force: true });
|
||||||
|
await getQqbotPluginTaskRunPage('task-1', { pageNo: 1, pageSize: 20 });
|
||||||
|
|
||||||
|
expect(requestClient.get).toHaveBeenCalledWith(
|
||||||
|
'/qqbot/plugin-platform/tasks/page',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
enabled: true,
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
taskKey: 'bangdream.bestdori.sync-main-data',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(requestClient.post).toHaveBeenCalledWith(
|
||||||
|
'/qqbot/plugin-platform/tasks/task-1/enable',
|
||||||
|
);
|
||||||
|
expect(requestClient.post).toHaveBeenCalledWith(
|
||||||
|
'/qqbot/plugin-platform/tasks/task-1/disable',
|
||||||
|
);
|
||||||
|
expect(requestClient.post).toHaveBeenCalledWith(
|
||||||
|
'/qqbot/plugin-platform/tasks/task-1/cron',
|
||||||
|
{
|
||||||
|
cronExpression: '0 */6 * * *',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(requestClient.post).toHaveBeenCalledWith(
|
||||||
|
'/qqbot/plugin-platform/tasks/task-1/run',
|
||||||
|
{
|
||||||
|
input: { force: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(requestClient.get).toHaveBeenCalledWith(
|
||||||
|
'/qqbot/plugin-platform/tasks/task-1/runs',
|
||||||
|
{
|
||||||
|
params: { pageNo: 1, pageSize: 20 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
apps/web-antdv-next/src/api/qqbot/plugin-task.ts
Normal file
114
apps/web-antdv-next/src/api/qqbot/plugin-task.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import type { Recordable } from '@vben/types';
|
||||||
|
|
||||||
|
import type { QqbotApi } from './index';
|
||||||
|
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
export namespace QqbotPluginTaskApi {
|
||||||
|
export type RuntimeStatus =
|
||||||
|
| 'disabled'
|
||||||
|
| 'failed'
|
||||||
|
| 'idle'
|
||||||
|
| 'running'
|
||||||
|
| 'scheduled';
|
||||||
|
export type RunStatus = 'failed' | 'running' | 'skipped' | 'success';
|
||||||
|
export type TriggerType = 'bootstrap' | 'manual' | 'schedule';
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
cronExpression: string;
|
||||||
|
defaultCron: string;
|
||||||
|
description?: null | string;
|
||||||
|
enabled: boolean;
|
||||||
|
id: string;
|
||||||
|
installationId: string;
|
||||||
|
lastDurationMs?: null | number;
|
||||||
|
lastError?: null | string;
|
||||||
|
lastRunAt?: null | string;
|
||||||
|
lastStatus?: null | RunStatus;
|
||||||
|
nextRunAt?: null | string;
|
||||||
|
pluginId: string;
|
||||||
|
pluginKey?: string;
|
||||||
|
pluginName?: string;
|
||||||
|
runtimeStatus: RuntimeStatus;
|
||||||
|
taskKey: string;
|
||||||
|
taskName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskRun {
|
||||||
|
createTime?: string;
|
||||||
|
durationMs?: null | number;
|
||||||
|
errorMessage?: null | string;
|
||||||
|
finishedAt?: null | string;
|
||||||
|
id: string;
|
||||||
|
jobId?: null | string;
|
||||||
|
safeSummary?: null | Recordable<any>;
|
||||||
|
startedAt?: null | string;
|
||||||
|
status: RunStatus;
|
||||||
|
taskId: string;
|
||||||
|
taskKey: string;
|
||||||
|
triggerType: TriggerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskQuery extends Recordable<any> {
|
||||||
|
enabled?: boolean;
|
||||||
|
pageNo?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
pluginId?: string;
|
||||||
|
pluginKey?: string;
|
||||||
|
status?: RuntimeStatus;
|
||||||
|
taskKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskRunQuery extends Recordable<any> {
|
||||||
|
pageNo?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
status?: RunStatus;
|
||||||
|
triggerType?: TriggerType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQqbotPluginTaskPage(params: QqbotPluginTaskApi.TaskQuery) {
|
||||||
|
return requestClient.get<QqbotApi.PageResult<QqbotPluginTaskApi.Task>>(
|
||||||
|
'/qqbot/plugin-platform/tasks/page',
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enableQqbotPluginTask(id: string) {
|
||||||
|
return requestClient.post<QqbotPluginTaskApi.Task>(
|
||||||
|
`/qqbot/plugin-platform/tasks/${id}/enable`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disableQqbotPluginTask(id: string) {
|
||||||
|
return requestClient.post<QqbotPluginTaskApi.Task>(
|
||||||
|
`/qqbot/plugin-platform/tasks/${id}/disable`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateQqbotPluginTaskCron(id: string, cronExpression: string) {
|
||||||
|
return requestClient.post<QqbotPluginTaskApi.Task>(
|
||||||
|
`/qqbot/plugin-platform/tasks/${id}/cron`,
|
||||||
|
{ cronExpression },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runQqbotPluginTaskOnce(
|
||||||
|
id: string,
|
||||||
|
input: Recordable<any> = {},
|
||||||
|
) {
|
||||||
|
return requestClient.post<{ jobId: string; taskId: string }>(
|
||||||
|
`/qqbot/plugin-platform/tasks/${id}/run`,
|
||||||
|
{ input },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQqbotPluginTaskRunPage(
|
||||||
|
id: string,
|
||||||
|
params: QqbotPluginTaskApi.TaskRunQuery,
|
||||||
|
) {
|
||||||
|
return requestClient.get<QqbotApi.PageResult<QqbotPluginTaskApi.TaskRun>>(
|
||||||
|
`/qqbot/plugin-platform/tasks/${id}/runs`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -66,6 +66,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'QqBotPlugin',
|
name: 'QqBotPlugin',
|
||||||
path: '/qqbot/plugin',
|
path: '/qqbot/plugin',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: () => import('#/views/qqbot/plugin-task/list'),
|
||||||
|
meta: {
|
||||||
|
icon: 'lucide:calendar-clock',
|
||||||
|
title: '插件定时任务',
|
||||||
|
},
|
||||||
|
name: 'QqBotPluginTask',
|
||||||
|
path: '/qqbot/plugin-task',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
component: () => import('#/views/qqbot/conversation/list'),
|
component: () => import('#/views/qqbot/conversation/list'),
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
import { defineComponent, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useCron } from '@vue-js-cron/core';
|
||||||
|
import { Alert, Input, RadioButton, RadioGroup, Space } from 'antdv-next';
|
||||||
|
|
||||||
|
const AAlert = Alert as any;
|
||||||
|
const AInput = Input as any;
|
||||||
|
const ARadioButton = RadioButton as any;
|
||||||
|
const ARadioGroup = RadioGroup as any;
|
||||||
|
const ASpace = Space as any;
|
||||||
|
|
||||||
|
const fieldPattern = /^[\d*/,-]+$/;
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'CronEditorAntdvNext',
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
default: '0 */6 * * *',
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['update:value', 'validChange'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const cronCore = useCron({
|
||||||
|
format: 'crontab',
|
||||||
|
initialValue: props.value,
|
||||||
|
locale: 'cn',
|
||||||
|
});
|
||||||
|
const expression = ref(props.value);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
function validate(value: string) {
|
||||||
|
const nextValue = `${value || ''}`.trim().replaceAll(/\s+/g, ' ');
|
||||||
|
const fields = nextValue.split(' ').filter(Boolean);
|
||||||
|
cronCore.cron.value = nextValue;
|
||||||
|
if (fields.length !== 5) {
|
||||||
|
error.value = '请输入 5 段 cron 表达式';
|
||||||
|
emit('validChange', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fields.every((field) => fieldPattern.test(field))) {
|
||||||
|
error.value = 'cron 只能包含数字、星号、斜杠、逗号和横线';
|
||||||
|
emit('validChange', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fields[0] === '*') {
|
||||||
|
error.value = '不允许每分钟执行';
|
||||||
|
emit('validChange', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.value = cronCore.error.value || '';
|
||||||
|
emit('validChange', !error.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateValue(value: string) {
|
||||||
|
expression.value = value;
|
||||||
|
emit('update:value', value);
|
||||||
|
validate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.value,
|
||||||
|
(value) => {
|
||||||
|
expression.value = value;
|
||||||
|
validate(value);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<ASpace direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<ARadioGroup
|
||||||
|
buttonStyle="solid"
|
||||||
|
onChange={(event: any) => updateValue(event.target.value)}
|
||||||
|
value={expression.value}
|
||||||
|
>
|
||||||
|
<ARadioButton value="0 */6 * * *">每 6 小时</ARadioButton>
|
||||||
|
<ARadioButton value="0 3 * * *">每天 03:00</ARadioButton>
|
||||||
|
<ARadioButton value="0 3 * * 1">每周一 03:00</ARadioButton>
|
||||||
|
</ARadioGroup>
|
||||||
|
<AInput
|
||||||
|
onChange={(event: any) => updateValue(event.target.value)}
|
||||||
|
value={expression.value}
|
||||||
|
/>
|
||||||
|
{error.value ? (
|
||||||
|
<AAlert message={error.value} showIcon type="error" />
|
||||||
|
) : null}
|
||||||
|
</ASpace>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
|
import type { QqbotPluginTaskApi } from '#/api/qqbot/plugin-task';
|
||||||
|
|
||||||
|
import { defineComponent, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { message, Modal } from 'antdv-next';
|
||||||
|
|
||||||
|
import { updateQqbotPluginTaskCron } from '#/api/qqbot/plugin-task';
|
||||||
|
|
||||||
|
import CronEditorAntdvNext from './CronEditorAntdvNext';
|
||||||
|
|
||||||
|
const AModal = Modal as any;
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'QqBotPluginTaskCronModal',
|
||||||
|
props: {
|
||||||
|
open: {
|
||||||
|
default: false,
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
default: undefined,
|
||||||
|
type: Object as PropType<QqbotPluginTaskApi.Task | undefined>,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['close', 'saved'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const cronExpression = ref('0 */6 * * *');
|
||||||
|
const valid = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.open, props.task?.id],
|
||||||
|
() => {
|
||||||
|
cronExpression.value =
|
||||||
|
props.task?.cronExpression ||
|
||||||
|
props.task?.defaultCron ||
|
||||||
|
'0 */6 * * *';
|
||||||
|
valid.value = true;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!props.task || !valid.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await updateQqbotPluginTaskCron(props.task.id, cronExpression.value);
|
||||||
|
message.success('Cron 已更新');
|
||||||
|
emit('saved');
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<AModal
|
||||||
|
confirmLoading={saving.value}
|
||||||
|
okButtonProps={{ disabled: !valid.value }}
|
||||||
|
onCancel={() => emit('close')}
|
||||||
|
onOk={() => void save()}
|
||||||
|
open={props.open}
|
||||||
|
title="修改 Cron"
|
||||||
|
width={620}
|
||||||
|
>
|
||||||
|
<CronEditorAntdvNext
|
||||||
|
onUpdate:value={(value: string) => {
|
||||||
|
cronExpression.value = value;
|
||||||
|
}}
|
||||||
|
onValidChange={(value: boolean) => {
|
||||||
|
valid.value = value;
|
||||||
|
}}
|
||||||
|
value={cronExpression.value}
|
||||||
|
/>
|
||||||
|
</AModal>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
|
import type { QqbotPluginTaskApi } from '#/api/qqbot/plugin-task';
|
||||||
|
|
||||||
|
import { defineComponent, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Drawer, Tag } from 'antdv-next';
|
||||||
|
|
||||||
|
import { getQqbotPluginTaskRunPage } from '#/api/qqbot/plugin-task';
|
||||||
|
|
||||||
|
const ADrawer = Drawer as any;
|
||||||
|
|
||||||
|
const runStatusColor: Record<QqbotPluginTaskApi.RunStatus, string> = {
|
||||||
|
failed: 'error',
|
||||||
|
running: 'processing',
|
||||||
|
skipped: 'default',
|
||||||
|
success: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'QqBotPluginTaskRunDrawer',
|
||||||
|
props: {
|
||||||
|
open: {
|
||||||
|
default: false,
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
default: undefined,
|
||||||
|
type: Object as PropType<QqbotPluginTaskApi.Task | undefined>,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['close'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const loading = ref(false);
|
||||||
|
const runs = ref<QqbotPluginTaskApi.TaskRun[]>([]);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.open, props.task?.id],
|
||||||
|
() => {
|
||||||
|
if (props.open && props.task?.id) void loadRuns();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadRuns() {
|
||||||
|
if (!props.task?.id) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const page = await getQqbotPluginTaskRunPage(props.task.id, {
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
runs.value = page.list || [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderRun = (item: QqbotPluginTaskApi.TaskRun) => (
|
||||||
|
<div class="border-b border-solid border-gray-100 py-3" key={item.id}>
|
||||||
|
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<Tag color={runStatusColor[item.status]}>{item.status}</Tag>
|
||||||
|
<Tag>{item.triggerType}</Tag>
|
||||||
|
<span>{item.startedAt || item.createTime || '-'}</span>
|
||||||
|
<span>
|
||||||
|
{item.durationMs === null || item.durationMs === undefined
|
||||||
|
? '-'
|
||||||
|
: `${item.durationMs} ms`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.safeSummary ? (
|
||||||
|
<pre class="whitespace-pre-wrap rounded bg-gray-50 p-2 text-xs">
|
||||||
|
{JSON.stringify(item.safeSummary, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
{item.errorMessage ? (
|
||||||
|
<div class="mt-2 text-sm text-red-500">{item.errorMessage}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<ADrawer
|
||||||
|
loading={loading.value}
|
||||||
|
onClose={() => emit('close')}
|
||||||
|
open={props.open}
|
||||||
|
size="large"
|
||||||
|
title={props.task?.taskName || '运行记录'}
|
||||||
|
>
|
||||||
|
{runs.value.length > 0 ? (
|
||||||
|
<div>{runs.value.map((run) => renderRun(run))}</div>
|
||||||
|
) : (
|
||||||
|
<span>暂无运行记录</span>
|
||||||
|
)}
|
||||||
|
</ADrawer>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
248
apps/web-antdv-next/src/views/qqbot/plugin-task/list.tsx
Normal file
248
apps/web-antdv-next/src/views/qqbot/plugin-task/list.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import type { TableColumnType } from 'antdv-next';
|
||||||
|
|
||||||
|
import type { QqbotPluginTaskApi } from '#/api/qqbot/plugin-task';
|
||||||
|
import type { KtTableApi, KtTableRowAction } from '#/components/ktTable';
|
||||||
|
|
||||||
|
import { defineComponent, ref } from 'vue';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message, Tag } from 'antdv-next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
disableQqbotPluginTask,
|
||||||
|
enableQqbotPluginTask,
|
||||||
|
getQqbotPluginTaskPage,
|
||||||
|
runQqbotPluginTaskOnce,
|
||||||
|
} from '#/api/qqbot/plugin-task';
|
||||||
|
import { KtTable, useKtTable } from '#/components/ktTable';
|
||||||
|
|
||||||
|
import TaskCronModal from './components/TaskCronModal';
|
||||||
|
import TaskRunDrawer from './components/TaskRunDrawer';
|
||||||
|
|
||||||
|
const AKtTable = KtTable as any;
|
||||||
|
|
||||||
|
const runtimeStatusColor: Record<QqbotPluginTaskApi.RuntimeStatus, string> = {
|
||||||
|
disabled: 'default',
|
||||||
|
failed: 'error',
|
||||||
|
idle: 'default',
|
||||||
|
running: 'processing',
|
||||||
|
scheduled: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
const runStatusColor: Record<QqbotPluginTaskApi.RunStatus, string> = {
|
||||||
|
failed: 'error',
|
||||||
|
running: 'processing',
|
||||||
|
skipped: 'default',
|
||||||
|
success: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'QqBotPluginTaskList',
|
||||||
|
setup() {
|
||||||
|
const cronModalOpen = ref(false);
|
||||||
|
const cronTask = ref<QqbotPluginTaskApi.Task>();
|
||||||
|
const runDrawerOpen = ref(false);
|
||||||
|
const runTask = ref<QqbotPluginTaskApi.Task>();
|
||||||
|
|
||||||
|
const columns: Array<TableColumnType<QqbotPluginTaskApi.Task>> = [
|
||||||
|
{ dataIndex: 'pluginName', key: 'pluginName', title: '插件', width: 160 },
|
||||||
|
{ dataIndex: 'taskKey', key: 'taskKey', title: '任务 Key', width: 260 },
|
||||||
|
{ dataIndex: 'taskName', key: 'taskName', title: '任务名称', width: 180 },
|
||||||
|
{
|
||||||
|
dataIndex: 'cronExpression',
|
||||||
|
key: 'cronExpression',
|
||||||
|
title: 'Cron',
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 },
|
||||||
|
{
|
||||||
|
dataIndex: 'runtimeStatus',
|
||||||
|
key: 'runtimeStatus',
|
||||||
|
title: '运行状态',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataIndex: 'lastStatus',
|
||||||
|
key: 'lastStatus',
|
||||||
|
title: '最近结果',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataIndex: 'nextRunAt',
|
||||||
|
key: 'nextRunAt',
|
||||||
|
title: '下次运行',
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const api: KtTableApi<QqbotPluginTaskApi.Task> = {
|
||||||
|
list: async (params) => await getQqbotPluginTaskPage(params),
|
||||||
|
};
|
||||||
|
const rowActions: Array<KtTableRowAction<QqbotPluginTaskApi.Task>> = [
|
||||||
|
{
|
||||||
|
key: 'run',
|
||||||
|
label: '运行一次',
|
||||||
|
onClick: async (row, context) => {
|
||||||
|
await runQqbotPluginTaskOnce(row.id, {});
|
||||||
|
message.success('任务已提交');
|
||||||
|
await context.reload();
|
||||||
|
},
|
||||||
|
permissionCodes: ['QqBot:PluginTask:Run'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cron',
|
||||||
|
label: '修改 Cron',
|
||||||
|
onClick: (row) => {
|
||||||
|
cronTask.value = row;
|
||||||
|
cronModalOpen.value = true;
|
||||||
|
},
|
||||||
|
permissionCodes: ['QqBot:PluginTask:UpdateCron'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'runs',
|
||||||
|
label: '运行记录',
|
||||||
|
onClick: (row) => {
|
||||||
|
runTask.value = row;
|
||||||
|
runDrawerOpen.value = true;
|
||||||
|
},
|
||||||
|
permissionCodes: ['QqBot:PluginTask:RunLog'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'toggle',
|
||||||
|
label: '启停',
|
||||||
|
onClick: async (row, context) => {
|
||||||
|
if (row.enabled) {
|
||||||
|
await disableQqbotPluginTask(row.id);
|
||||||
|
message.success('任务已停用');
|
||||||
|
} else {
|
||||||
|
await enableQqbotPluginTask(row.id);
|
||||||
|
message.success('任务已启用');
|
||||||
|
}
|
||||||
|
await context.reload();
|
||||||
|
},
|
||||||
|
permissionCodes: [
|
||||||
|
'QqBot:PluginTask:Enable',
|
||||||
|
'QqBot:PluginTask:Disable',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const [registerTable, tableApi] = useKtTable<QqbotPluginTaskApi.Task>({
|
||||||
|
api,
|
||||||
|
columns,
|
||||||
|
formOptions: {
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: true,
|
||||||
|
placeholder: '插件 Key',
|
||||||
|
},
|
||||||
|
fieldName: 'pluginKey',
|
||||||
|
label: '插件',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: true,
|
||||||
|
placeholder: '任务 Key',
|
||||||
|
},
|
||||||
|
fieldName: 'taskKey',
|
||||||
|
label: '任务',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: true,
|
||||||
|
options: [
|
||||||
|
{ label: '已调度', value: 'scheduled' },
|
||||||
|
{ label: '运行中', value: 'running' },
|
||||||
|
{ label: '失败', value: 'failed' },
|
||||||
|
{ label: '停用', value: 'disabled' },
|
||||||
|
{ label: '空闲', value: 'idle' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '状态',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: true,
|
||||||
|
options: [
|
||||||
|
{ label: '启用', value: true },
|
||||||
|
{ label: '停用', value: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fieldName: 'enabled',
|
||||||
|
label: '启用',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rowActions,
|
||||||
|
tableTitle: '插件定时任务',
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeCronModal() {
|
||||||
|
cronModalOpen.value = false;
|
||||||
|
cronTask.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCronSaved() {
|
||||||
|
closeCronModal();
|
||||||
|
await tableApi.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<Page autoContentHeight>
|
||||||
|
<AKtTable
|
||||||
|
onRegister={registerTable}
|
||||||
|
v-slots={{
|
||||||
|
bodyCell: ({ column, record }: any) => {
|
||||||
|
const row = record as QqbotPluginTaskApi.Task;
|
||||||
|
if (column.key === 'pluginName') {
|
||||||
|
return row.pluginName || row.pluginKey || row.pluginId || '-';
|
||||||
|
}
|
||||||
|
if (column.key === 'enabled') {
|
||||||
|
return (
|
||||||
|
<Tag color={row.enabled ? 'success' : 'default'}>
|
||||||
|
{row.enabled ? '启用' : '停用'}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (column.key === 'runtimeStatus') {
|
||||||
|
return (
|
||||||
|
<Tag color={runtimeStatusColor[row.runtimeStatus]}>
|
||||||
|
{row.runtimeStatus}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (column.key === 'lastStatus') {
|
||||||
|
return row.lastStatus ? (
|
||||||
|
<Tag color={runStatusColor[row.lastStatus]}>
|
||||||
|
{row.lastStatus}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TaskCronModal
|
||||||
|
onClose={closeCronModal}
|
||||||
|
onSaved={() => void handleCronSaved()}
|
||||||
|
open={cronModalOpen.value}
|
||||||
|
task={cronTask.value}
|
||||||
|
/>
|
||||||
|
<TaskRunDrawer
|
||||||
|
onClose={() => {
|
||||||
|
runDrawerOpen.value = false;
|
||||||
|
}}
|
||||||
|
open={runDrawerOpen.value}
|
||||||
|
task={runTask.value}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
/* @vitest-environment happy-dom */
|
||||||
|
|
||||||
|
/* eslint-disable vue/one-component-per-file */
|
||||||
|
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { defineComponent, h } from 'vue';
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { isSupportedAdminMenuName } from '#/api/core/menu';
|
||||||
|
import routes from '#/router/routes/modules/qqbot';
|
||||||
|
|
||||||
|
import QqBotPluginTaskList from './list';
|
||||||
|
|
||||||
|
vi.mock('#/api/request', () => ({
|
||||||
|
requestClient: {
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@vben/common-ui', () => ({
|
||||||
|
Page: defineComponent({
|
||||||
|
name: 'MockPage',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('main', slots.default?.());
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('antdv-next', () => ({
|
||||||
|
Alert: defineComponent({
|
||||||
|
name: 'MockAlert',
|
||||||
|
setup() {
|
||||||
|
return () => h('div');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Drawer: defineComponent({
|
||||||
|
name: 'MockDrawer',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('aside', slots.default?.());
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Input: defineComponent({
|
||||||
|
name: 'MockInput',
|
||||||
|
setup() {
|
||||||
|
return () => h('input');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Modal: defineComponent({
|
||||||
|
name: 'MockModal',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('div', slots.default?.());
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
RadioButton: defineComponent({
|
||||||
|
name: 'MockRadioButton',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('button', slots.default?.());
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
RadioGroup: defineComponent({
|
||||||
|
name: 'MockRadioGroup',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('div', slots.default?.());
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Space: defineComponent({
|
||||||
|
name: 'MockSpace',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('div', slots.default?.());
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tag: defineComponent({
|
||||||
|
name: 'MockTag',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('span', slots.default?.());
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
message: {
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('#/components/ktTable', () => ({
|
||||||
|
KtTable: defineComponent({
|
||||||
|
name: 'KtTable',
|
||||||
|
setup() {
|
||||||
|
return () => h('section', { 'data-testid': 'plugin-task-table' });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useKtTable: vi.fn(() => [vi.fn(), { reload: vi.fn() }]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('#/api/qqbot/plugin-task', () => ({
|
||||||
|
disableQqbotPluginTask: vi.fn(),
|
||||||
|
enableQqbotPluginTask: vi.fn(),
|
||||||
|
getQqbotPluginTaskPage: vi.fn(async () => ({ list: [], total: 0 })),
|
||||||
|
getQqbotPluginTaskRunPage: vi.fn(async () => ({ list: [], total: 0 })),
|
||||||
|
runQqbotPluginTaskOnce: vi.fn(),
|
||||||
|
updateQqbotPluginTaskCron: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('qqbot plugin task page', () => {
|
||||||
|
it('renders a single route root and task table shell', () => {
|
||||||
|
const wrapper = mount(QqBotPluginTaskList);
|
||||||
|
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
expect(wrapper.element.nodeType).toBe(Node.ELEMENT_NODE);
|
||||||
|
expect(wrapper.find('[data-testid="plugin-task-table"]').exists()).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers a supported QQBot plugin task route', () => {
|
||||||
|
const qqbotRoute = routes.find((route) => route.name === 'QqBot');
|
||||||
|
expect(qqbotRoute?.children).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'QqBotPluginTask',
|
||||||
|
path: '/qqbot/plugin-task',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(isSupportedAdminMenuName('QqBotPluginTask')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -105,6 +105,9 @@ catalogs:
|
|||||||
'@vitejs/plugin-vue-jsx':
|
'@vitejs/plugin-vue-jsx':
|
||||||
specifier: ^5.1.4
|
specifier: ^5.1.4
|
||||||
version: 5.1.4
|
version: 5.1.4
|
||||||
|
'@vue-js-cron/core':
|
||||||
|
specifier: ^6.3.0
|
||||||
|
version: 6.3.0
|
||||||
'@vue/shared':
|
'@vue/shared':
|
||||||
specifier: ^3.5.27
|
specifier: ^3.5.27
|
||||||
version: 3.5.27
|
version: 3.5.27
|
||||||
@ -581,6 +584,9 @@ importers:
|
|||||||
'@vben/utils':
|
'@vben/utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/utils
|
version: link:../../packages/utils
|
||||||
|
'@vue-js-cron/core':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 6.3.0
|
||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 14.2.0(vue@3.5.27(typescript@5.9.3))
|
version: 14.2.0(vue@3.5.27(typescript@5.9.3))
|
||||||
@ -4149,6 +4155,9 @@ packages:
|
|||||||
'@volar/typescript@2.4.28':
|
'@volar/typescript@2.4.28':
|
||||||
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
|
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
|
||||||
|
|
||||||
|
'@vue-js-cron/core@6.3.0':
|
||||||
|
resolution: {integrity: sha512-XhEJ77vcvbgg1TQP3eluHHrLM1ouM6S6SJEJMTV2vP0clKtKwiM0s79HcWIYzDvQWLGLtl3hFJlWdxqhp3LNqA==}
|
||||||
|
|
||||||
'@vue/babel-helper-vue-transform-on@1.5.0':
|
'@vue/babel-helper-vue-transform-on@1.5.0':
|
||||||
resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==}
|
resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==}
|
||||||
|
|
||||||
@ -6806,6 +6815,10 @@ packages:
|
|||||||
muggle-string@0.4.1:
|
muggle-string@0.4.1:
|
||||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||||
|
|
||||||
|
mustache@4.2.0:
|
||||||
|
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
mz@2.7.0:
|
mz@2.7.0:
|
||||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||||
|
|
||||||
@ -12623,6 +12636,10 @@ snapshots:
|
|||||||
path-browserify: 1.0.1
|
path-browserify: 1.0.1
|
||||||
vscode-uri: 3.1.0
|
vscode-uri: 3.1.0
|
||||||
|
|
||||||
|
'@vue-js-cron/core@6.3.0':
|
||||||
|
dependencies:
|
||||||
|
mustache: 4.2.0
|
||||||
|
|
||||||
'@vue/babel-helper-vue-transform-on@1.5.0': {}
|
'@vue/babel-helper-vue-transform-on@1.5.0': {}
|
||||||
|
|
||||||
'@vue/babel-helper-vue-transform-on@2.0.1': {}
|
'@vue/babel-helper-vue-transform-on@2.0.1': {}
|
||||||
@ -15712,6 +15729,8 @@ snapshots:
|
|||||||
|
|
||||||
muggle-string@0.4.1: {}
|
muggle-string@0.4.1: {}
|
||||||
|
|
||||||
|
mustache@4.2.0: {}
|
||||||
|
|
||||||
mz@2.7.0:
|
mz@2.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
|
|||||||
@ -51,6 +51,7 @@ catalog:
|
|||||||
'@types/qrcode': ^1.5.6
|
'@types/qrcode': ^1.5.6
|
||||||
'@types/qs': ^6.14.0
|
'@types/qs': ^6.14.0
|
||||||
'@types/sortablejs': ^1.15.9
|
'@types/sortablejs': ^1.15.9
|
||||||
|
'@vue-js-cron/core': ^6.3.0
|
||||||
'@typescript-eslint/eslint-plugin': ^8.54.0
|
'@typescript-eslint/eslint-plugin': ^8.54.0
|
||||||
'@typescript-eslint/parser': ^8.54.0
|
'@typescript-eslint/parser': ^8.54.0
|
||||||
'@vee-validate/zod': ^4.15.1
|
'@vee-validate/zod': ^4.15.1
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user