feat: 增加QQBot插件定时任务页面

This commit is contained in:
sunlei 2026-06-17 09:48:59 +08:00
parent 4b7d732ec7
commit 35747e39eb
12 changed files with 870 additions and 0 deletions

View File

@ -45,6 +45,7 @@
"@vben/styles": "workspace:*",
"@vben/types": "workspace:*",
"@vben/utils": "workspace:*",
"@vue-js-cron/core": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"antdv-next": "catalog:",

View File

@ -46,6 +46,12 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'QqBotPermissionDelete',
'QqBotPermissionEdit',
'QqBotPlugin',
'QqBotPluginTask',
'QqBotPluginTaskDisable',
'QqBotPluginTaskEnable',
'QqBotPluginTaskRun',
'QqBotPluginTaskRunLog',
'QqBotPluginTaskUpdateCron',
'QqBotRule',
'QqBotRuleCreate',
'QqBotRuleDelete',

View 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 },
},
);
});
});

View 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 },
);
}

View File

@ -66,6 +66,15 @@ const routes: RouteRecordRaw[] = [
name: 'QqBotPlugin',
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'),
meta: {

View File

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

View File

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

View File

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

View 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>
);
},
});

View File

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

View File

@ -105,6 +105,9 @@ catalogs:
'@vitejs/plugin-vue-jsx':
specifier: ^5.1.4
version: 5.1.4
'@vue-js-cron/core':
specifier: ^6.3.0
version: 6.3.0
'@vue/shared':
specifier: ^3.5.27
version: 3.5.27
@ -581,6 +584,9 @@ importers:
'@vben/utils':
specifier: workspace:*
version: link:../../packages/utils
'@vue-js-cron/core':
specifier: 'catalog:'
version: 6.3.0
'@vueuse/core':
specifier: 'catalog:'
version: 14.2.0(vue@3.5.27(typescript@5.9.3))
@ -4149,6 +4155,9 @@ packages:
'@volar/typescript@2.4.28':
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':
resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==}
@ -6806,6 +6815,10 @@ packages:
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@ -12623,6 +12636,10 @@ snapshots:
path-browserify: 1.0.1
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@2.0.1': {}
@ -15712,6 +15729,8 @@ snapshots:
muggle-string@0.4.1: {}
mustache@4.2.0: {}
mz@2.7.0:
dependencies:
any-promise: 1.3.0

View File

@ -51,6 +51,7 @@ catalog:
'@types/qrcode': ^1.5.6
'@types/qs': ^6.14.0
'@types/sortablejs': ^1.15.9
'@vue-js-cron/core': ^6.3.0
'@typescript-eslint/eslint-plugin': ^8.54.0
'@typescript-eslint/parser': ^8.54.0
'@vee-validate/zod': ^4.15.1