Kaynağa Gözat

feat: 添加任务调度

DESKTOP-USV654P\pc 6 ay önce
ebeveyn
işleme
e3d6e1bc65
24 değiştirilmiş dosya ile 1823 ekleme ve 11 silme
  1. 6 1
      apps/baicai-cms/src/components/article/list-page/list-page.vue
  2. 2 1
      apps/baicai-cms/src/locales/langs/en-US/page.json
  3. 2 1
      apps/baicai-cms/src/locales/langs/zh-CN/page.json
  4. 92 0
      apps/baicai-cms/src/views/default/about/index.vue
  5. 22 1
      apps/baicai-cms/src/views/default/hardware/list/index.vue
  6. 2 2
      apps/baicai-cms/src/views/default/software/detail/index.vue
  7. 12 0
      apps/baicai-cms/src/views/default/software/list/index.vue
  8. 5 4
      apps/web-baicai/src/api/model/index.ts
  9. 1 0
      apps/web-baicai/src/api/system/index.ts
  10. 3 0
      apps/web-baicai/src/api/system/job/index.ts
  11. 38 0
      apps/web-baicai/src/api/system/job/record.ts
  12. 54 0
      apps/web-baicai/src/api/system/job/term.ts
  13. 48 0
      apps/web-baicai/src/api/system/job/trigger.ts
  14. 1 0
      apps/web-baicai/src/components/bc-crontab/index.ts
  15. 74 0
      apps/web-baicai/src/components/bc-crontab/src/bc-crontab.vue
  16. 5 1
      apps/web-baicai/src/components/form/components/input-code.vue
  17. 85 0
      apps/web-baicai/src/views/system/job/record/data.config.ts
  18. 113 0
      apps/web-baicai/src/views/system/job/record/index.vue
  19. 103 0
      apps/web-baicai/src/views/system/job/term/components/edit.vue
  20. 347 0
      apps/web-baicai/src/views/system/job/term/data.config.ts
  21. 166 0
      apps/web-baicai/src/views/system/job/term/index.vue
  22. 97 0
      apps/web-baicai/src/views/system/job/trigger/components/edit.vue
  23. 360 0
      apps/web-baicai/src/views/system/job/trigger/data.config.ts
  24. 185 0
      apps/web-baicai/src/views/system/job/trigger/index.vue

+ 6 - 1
apps/baicai-cms/src/components/article/list-page/list-page.vue

@@ -3,7 +3,7 @@ import type { PropType } from 'vue';
 
 import { reactive, watch } from 'vue';
 
-import { Loading } from '@vben/common-ui';
+import { Fallback, Loading } from '@vben/common-ui';
 
 import { Pagination } from 'ant-design-vue';
 
@@ -107,6 +107,11 @@ defineExpose({ loadData: fetch });
         :item="item"
       />
     </div>
+    <Fallback
+      status="coming-soon"
+      v-if="state.listData.length === 0"
+      :title="$t('page.common.empty')"
+    />
     <div class="mt-8 flex justify-center" v-if="state.totalPages > 1">
       <Pagination
         v-model:current="searchInfo.pageIndex"

+ 2 - 1
apps/baicai-cms/src/locales/langs/en-US/page.json

@@ -19,6 +19,7 @@
     "edit": "Edit",
     "copy": "Copy",
     "image": "Image",
-    "share": "Share"
+    "share": "Share",
+    "empty": "Empty"
   }
 }

+ 2 - 1
apps/baicai-cms/src/locales/langs/zh-CN/page.json

@@ -19,6 +19,7 @@
     "edit": "编辑",
     "copy": "复制",
     "image": "图片",
-    "share": "分享"
+    "share": "分享",
+    "empty": "没有内容"
   }
 }

+ 92 - 0
apps/baicai-cms/src/views/default/about/index.vue

@@ -0,0 +1,92 @@
+<script lang="ts" setup>
+import { onMounted, reactive } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { Fallback, Loading } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+import { Segmented } from 'ant-design-vue';
+
+import { ArticleApi } from '#/api';
+import { DetailBanner } from '#/components/article';
+import { BcWrap } from '#/components/bc-wrap';
+import { useWebStore } from '#/store';
+
+const route = useRoute();
+const webSite = useWebStore();
+
+const state = reactive({
+  channelId: Number(route.query.channelId ?? 0),
+  typeId: Number(route.query.typeId ?? 0),
+  articleId: Number(route.query.articleId ?? 0),
+  segmentedData: [] as any[],
+  loading: false,
+  dataInfo: {} as ArticleApi.ListItem,
+});
+
+const fetch = async () => {
+  try {
+    state.loading = true;
+    state.dataInfo = await ArticleApi.getDetail({
+      id: state.articleId,
+      siteId: webSite.config?.id ?? 0,
+    });
+  } finally {
+    state.loading = false;
+  }
+};
+
+const fetchSegmented = async () => {
+  const data = await ArticleApi.getList({
+    siteId: webSite.config?.id ?? 0,
+    channelId: state.channelId,
+    typeId: state.typeId,
+    top: 0,
+  });
+
+  state.segmentedData = data.map((item) => ({
+    label: item.title,
+    value: item.id,
+  }));
+};
+
+const handelChange = async (value: number | string) => {
+  state.articleId = Number(value);
+  await fetch();
+};
+
+onMounted(async () => {
+  await fetchSegmented();
+  await fetch();
+});
+</script>
+<template>
+  <BcWrap :full="true">
+    <DetailBanner channel-code="other" article-code="software-banner" />
+    <BcWrap :full="false" class="my-12 w-full">
+      <div class="flex w-full flex-col">
+        <Segmented
+          v-model:value="state.articleId"
+          :options="state.segmentedData"
+          block
+          size="large"
+          @change="handelChange"
+        />
+
+        <Loading
+          class="mt-8 w-full"
+          :spinning="state.loading"
+          :text="$t('page.common.loading')"
+        >
+          <div
+            v-if="state.dataInfo.content"
+            v-dompurify-html="state.dataInfo.content"
+          ></div>
+          <div v-else>
+            <Fallback status="coming-soon" :title="$t('page.common.empty')" />
+          </div>
+        </Loading>
+      </div>
+    </BcWrap>
+  </BcWrap>
+</template>

+ 22 - 1
apps/baicai-cms/src/views/default/hardware/list/index.vue

@@ -6,7 +6,9 @@ import { useRoute } from 'vue-router';
 
 import { ArticleApi, SiteApi } from '#/api';
 import { ArticleListPage, DetailBanner } from '#/components/article';
+import { BcLink } from '#/components/bc-link';
 import { BcWrap } from '#/components/bc-wrap';
+import { Icon } from '#/components/icon';
 
 import LeftMenu from '../components/menu.vue';
 
@@ -18,18 +20,21 @@ const state = reactive<{
   dataList: ArticleApi.ListItem[];
   loading: boolean;
   typeId: number;
+  typeName: string;
 }>({
   channelId: Number(route.query.channelId ?? 0),
   channelInfo: {} as SiteApi.SiteChannelItem,
   dataList: [],
   loading: false,
   typeId: 0,
+  typeName: '',
 });
 
 const listPageRef = ref<any | null>(null);
 
 const handleClick = (item: BasicTreeOptionResult) => {
   state.typeId = Number(item.value);
+  state.typeName = item.label;
 };
 
 onMounted(async () => {
@@ -53,11 +58,27 @@ onMounted(async () => {
           @click="handleClick"
         />
         <div class="ml-8 flex-1">
+          <div class="border-primary-200 mb-8 border-b py-3">
+            <div class="flex min-h-[2rem] items-center justify-end gap-2">
+              <Icon icon="icon-park-solid:local" class="text-2xl" />
+              <div>
+                <BcLink to="/index">首页</BcLink>
+              </div>
+              <div>></div>
+              <div>
+                <a href="#"> {{ state.channelInfo.title }} </a>
+              </div>
+              <div>></div>
+              <div>
+                <a href="#"> {{ state.typeName }} </a>
+              </div>
+            </div>
+          </div>
           <ArticleListPage
             ref="listPageRef"
             :channel-id="state.channelId"
             :type-id="state.typeId"
-            href="/hardware/detail"
+            :href="`/${state.channelInfo.code}/detail`"
           />
         </div>
       </div>

+ 2 - 2
apps/baicai-cms/src/views/default/software/detail/index.vue

@@ -4,12 +4,12 @@ import { onMounted, reactive } from 'vue';
 import { useRoute } from 'vue-router';
 
 import { Fallback, Loading } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 import { ArticleApi } from '#/api';
 import { DetailBanner } from '#/components/article';
 import { BcWrap } from '#/components/bc-wrap';
 import { useWebStore } from '#/store';
-// import { replaceHtml } from '#/utils';  v-html="replaceHtml(state.dataInfo.content)"
 
 const route = useRoute();
 const webSite = useWebStore();
@@ -52,7 +52,7 @@ onMounted(async () => {
             v-dompurify-html="state.dataInfo.content"
           ></div>
           <div v-else>
-            <Fallback status="coming-soon" />
+            <Fallback status="coming-soon" :title="$t('page.common.empty')" />
           </div>
         </Loading>
       </div>

+ 12 - 0
apps/baicai-cms/src/views/default/software/list/index.vue

@@ -4,7 +4,9 @@ import { useRoute } from 'vue-router';
 
 import { ArticleApi, SiteApi } from '#/api';
 import { ArticleListPage, DetailBanner } from '#/components/article';
+import { BcLink } from '#/components/bc-link';
 import { BcWrap } from '#/components/bc-wrap';
+import { Icon } from '#/components/icon';
 
 import LeftMenu from '../components/menu.vue';
 
@@ -47,6 +49,16 @@ onMounted(async () => {
           href="/software/detail"
         />
         <div class="ml-8 flex-1">
+          <div class="border-primary-200 mb-8 border-b py-3">
+            <div class="flex min-h-[2rem] items-center justify-end gap-2">
+              <Icon icon="icon-park-solid:local" class="text-2xl" />
+              <div>
+                <BcLink to="/index">首页</BcLink>
+              </div>
+              <div>></div>
+              <div><a href="#"> RFID软件系统 </a></div>
+            </div>
+          </div>
           <ArticleListPage
             ref="listPageRef"
             :channel-id="state.channelId"

+ 5 - 4
apps/web-baicai/src/api/model/index.ts

@@ -22,6 +22,7 @@ export interface BasicOptionResult {
   label: string;
   value: boolean | number | string;
   disabled?: boolean;
+  color?: string;
 }
 
 export interface BasicTreeOptionResult extends BasicOptionResult {
@@ -46,8 +47,8 @@ export interface QueryParams {
 }
 
 export const statusOptions: BasicOptionResult[] = [
-  { label: '启用', value: 1 },
-  { label: '停用', value: 2 },
+  { label: '启用', value: 1, color: 'success' },
+  { label: '停用', value: 2, color: 'error' },
 ];
 
 export const formatterStatus = ({
@@ -61,8 +62,8 @@ export const formatterStatus = ({
 };
 
 export const boolOptions: BasicOptionResult[] = [
-  { label: '是', value: true },
-  { label: '否', value: false },
+  { label: '是', value: true, color: 'success' },
+  { label: '否', value: false, color: 'error' },
 ];
 
 export const dbTypeOptions: BasicOptionResult[] = [

+ 1 - 0
apps/web-baicai/src/api/system/index.ts

@@ -5,6 +5,7 @@ export * from './department';
 export * from './dictionary';
 export * from './enum';
 export * from './file';
+export * from './job';
 export * from './log';
 export * from './menu';
 export * from './post';

+ 3 - 0
apps/web-baicai/src/api/system/job/index.ts

@@ -0,0 +1,3 @@
+export * from './record';
+export * from './term';
+export * from './trigger';

+ 38 - 0
apps/web-baicai/src/api/system/job/record.ts

@@ -0,0 +1,38 @@
+import type { BasicFetchResult, BasicPageParams } from '#/api/model';
+
+import { requestClient } from '#/api/request';
+
+export namespace JobRecordApi {
+  export interface PageParams extends BasicPageParams {
+    jobId?: string;
+  }
+
+  export interface BasicRecordItem {
+    jobId: string;
+  }
+
+  export interface RecordItem extends BasicRecordItem {
+    id: number;
+  }
+
+  export type PageResult = BasicFetchResult<RecordItem>;
+
+  export const getPage = (params: PageParams) =>
+    requestClient.get<PageResult>('/job/record/page', { params });
+
+  export const getDetail = (id: number) =>
+    requestClient.get<RecordItem>('/job/record/entity', {
+      params: { id },
+    });
+
+  export const deleteDetail = (id: number) =>
+    requestClient.delete('/job/record', { data: { id } });
+
+  export const deleteByJob = (jobId: string) =>
+    requestClient.delete('/job/record/job', { data: { code: jobId } });
+
+  export const deleteByTrigger = (triggerId: string) =>
+    requestClient.delete('/job/record/trigger', {
+      data: { code: triggerId },
+    });
+}

+ 54 - 0
apps/web-baicai/src/api/system/job/term.ts

@@ -0,0 +1,54 @@
+import type { BasicFetchResult, BasicPageParams } from '#/api/model';
+
+import { requestClient } from '#/api/request';
+
+export namespace JobTermApi {
+  export interface PageParams extends BasicPageParams {
+    jobId?: string;
+    description?: string;
+  }
+
+  export interface BasicRecordItem {
+    jobId: string;
+    groupName: string;
+    jobType: string;
+    jobCreateType: number;
+    properties: string;
+    [key: string]: any;
+  }
+
+  export interface RecordItem extends BasicRecordItem {
+    id: number;
+  }
+
+  export type PageResult = BasicFetchResult<RecordItem>;
+
+  export const getPage = (params: PageParams) =>
+    requestClient.get<PageResult>('/job/term/page', { params });
+
+  export const getDetail = (id: number) =>
+    requestClient.get<RecordItem>('/job/term/entity', {
+      params: { id },
+    });
+
+  export const addDetail = (data: BasicRecordItem) =>
+    requestClient.post('/job/term', data);
+
+  export const editDetail = (data: RecordItem) =>
+    requestClient.put('/job/term', data);
+
+  export const deleteDetail = (id: number) =>
+    requestClient.delete('/job/term', { data: { id } });
+
+  export const start = (jobid: string) =>
+    requestClient.post('/job/term/start', { code: jobid });
+
+  export const cancel = (jobid: string) =>
+    requestClient.post('/job/term/cancel', { code: jobid });
+
+  export const pause = (jobid: string) =>
+    requestClient.post('/job/term/pause', { code: jobid });
+
+  export const run = (jobid: string) =>
+    requestClient.post('/job/term/run', { code: jobid });
+}

+ 48 - 0
apps/web-baicai/src/api/system/job/trigger.ts

@@ -0,0 +1,48 @@
+import { requestClient } from '#/api/request';
+
+export namespace JobTriggerApi {
+  export interface PageParams {
+    jobId?: string;
+    triggerId?: string;
+  }
+
+  export interface BasicRecordItem {
+    jobId: string;
+    triggerId: string;
+    args: string;
+    [key: string]: any;
+  }
+
+  export interface RecordItem extends BasicRecordItem {
+    id: number;
+  }
+
+  export const getList = (params: PageParams) =>
+    requestClient.get<RecordItem[]>('/job/trigger/list', { params });
+
+  export const getDetail = (id: number) =>
+    requestClient.get<RecordItem>('/job/trigger/entity', {
+      params: { id },
+    });
+
+  export const addDetail = (data: BasicRecordItem) =>
+    requestClient.post('/job/trigger', data);
+
+  export const editDetail = (data: RecordItem) =>
+    requestClient.put('/job/trigger', data);
+
+  export const deleteDetail = (id: number) =>
+    requestClient.delete('/job/trigger', { data: { id } });
+
+  export const start = (data: { jobid: string; triggerId: string }) =>
+    requestClient.post('/job/trigger/start', data);
+
+  export const cancel = (data: { jobid: string; triggerId: string }) =>
+    requestClient.post('/job/trigger/cancel', data);
+
+  export const pause = (data: { jobid: string; triggerId: string }) =>
+    requestClient.post('/job/trigger/pause', data);
+
+  export const run = (data: { jobid: string; triggerId: string }) =>
+    requestClient.post('/job/trigger/run', data);
+}

+ 1 - 0
apps/web-baicai/src/components/bc-crontab/index.ts

@@ -0,0 +1 @@
+export { default as BcCrontab } from './src/bc-crontab.vue';

+ 74 - 0
apps/web-baicai/src/components/bc-crontab/src/bc-crontab.vue

@@ -0,0 +1,74 @@
+<script lang="ts" setup>
+import type { PropType } from 'vue';
+
+import { computed } from 'vue';
+
+import { Button, Dropdown, Input, Menu } from 'ant-design-vue';
+
+defineProps({
+  placeholder: {
+    type: String as PropType<string>,
+    default: '',
+  },
+});
+
+const emit = defineEmits(['blur', 'change']);
+
+const modelValue = defineModel<string>({
+  default: () => undefined,
+});
+
+function onChange() {
+  emit('change', modelValue.value);
+}
+
+const toolbarList = computed(() => {
+  const defaultToolbarList = [
+    { value: '@secondly', label: '每秒 .0000000' },
+    { value: '@minutely', label: '每分钟 00' },
+    { value: '@hourly', label: '每小时 00:00' },
+    { value: '@daily', label: '每天 00:00:00' },
+    { value: '@monthly', label: '每月 1 号 00:00:00' },
+    { value: '@weekly', label: '每周日 00:00:00' },
+    { value: '@yearly', label: '每年 1 月 1 号 00:00:00' },
+    { value: '@workday', label: '每周一至周五 00:00:00' },
+  ];
+
+  return defaultToolbarList;
+});
+
+const handleMenuClick = (e: any) => {
+  const { key } = e;
+  modelValue.value = `"${key}",0`;
+  onChange();
+};
+</script>
+<template>
+  <Input.Group class="flex items-center">
+    <Input
+      allow-clear
+      v-model:value="modelValue"
+      class="me-[-1px] border-e"
+      :placeholder="placeholder"
+    />
+    <Dropdown @click.prevent>
+      <Button
+        class="me-[-1px] rounded-ee-none rounded-es-none rounded-se-none rounded-ss-none border-e"
+      >
+        Macro
+      </Button>
+      <template #overlay>
+        <Menu @click="handleMenuClick">
+          <template v-for="item in toolbarList" :key="item.value">
+            <Menu.Item v-bind="{ key: item.value }">
+              {{ item.label }}
+            </Menu.Item>
+          </template>
+        </Menu>
+      </template>
+    </Dropdown>
+    <Button class="me-[-1px] rounded-es-none rounded-ss-none border-e">
+      Cron表达式
+    </Button>
+  </Input.Group>
+</template>

+ 5 - 1
apps/web-baicai/src/components/form/components/input-code.vue

@@ -27,6 +27,10 @@ const props = defineProps({
     type: String as PropType<string>,
     default: '请输入',
   },
+  popTitle: {
+    type: String as PropType<string>,
+    default: '验证规则',
+  },
 });
 const emit = defineEmits(['update:value']);
 const modelValue = useVModel(props, 'value', emit, {
@@ -64,7 +68,7 @@ const handleInput = () => {
     .setData({
       baseData: {
         scriptCode: modelValue.value,
-        name: '验证规则',
+        name: props.popTitle,
         language,
       },
     })

+ 85 - 0
apps/web-baicai/src/views/system/job/record/data.config.ts

@@ -0,0 +1,85 @@
+import type {
+  OnActionClickFn,
+  VbenFormSchema,
+  VxeTableGridOptions,
+} from '#/adapter';
+
+import { JobRecordApi } from '#/api';
+
+import { triggerStatusOptions } from '../trigger/data.config';
+
+export const useSearchSchema = (): VbenFormSchema[] => {
+  return [
+    {
+      component: 'RangePicker',
+      fieldName: '__date',
+      label: '运行日期',
+      componentProps: {
+        format: 'YYYY-MM-DD',
+        placeholder: ['开始时间', '结束时间'],
+      },
+      formItemClass: 'col-span-2',
+    },
+  ];
+};
+
+export function useColumns(
+  onActionClick?: OnActionClickFn<JobRecordApi.RecordItem>,
+): VxeTableGridOptions<JobRecordApi.RecordItem>['columns'] {
+  return [
+    { title: '序号', type: 'seq', width: 50 },
+    {
+      align: 'left',
+      field: 'jobId',
+      title: '作业编号',
+      width: 120,
+    },
+    { align: 'left', field: 'triggerId', title: '触发器编号', width: 120 },
+    { align: 'right', field: 'numberOfRuns', title: '运行次数', width: 80 },
+    { align: 'left', field: 'lastRunTime', title: '最近运行时间', width: 150 },
+    {
+      align: 'center',
+      field: 'nextRunTime',
+      title: '下一次运行时间',
+      width: 150,
+    },
+    {
+      align: 'center',
+      field: 'status',
+      title: '触发器状态',
+      width: 100,
+      cellRender: {
+        name: 'CellTag',
+        options: triggerStatusOptions,
+      },
+    },
+    {
+      align: 'center',
+      field: 'result',
+      title: '执行结果',
+    },
+    {
+      align: 'right',
+      cellRender: {
+        attrs: {
+          nameField: 'jobId',
+          nameTitle: '作业编号',
+          onClick: onActionClick,
+        },
+        name: 'CellAction',
+        options: [
+          {
+            code: 'delete',
+            auth: ['jog-record:delete'],
+          },
+        ],
+      },
+      field: 'operation',
+      fixed: 'right',
+      headerAlign: 'center',
+      showOverflow: false,
+      title: '操作',
+      width: 100,
+    },
+  ];
+}

+ 113 - 0
apps/web-baicai/src/views/system/job/record/index.vue

@@ -0,0 +1,113 @@
+<script lang="ts" setup>
+import type { OnActionClickParams, VxeTableGridOptions } from '#/adapter';
+
+import { computed, nextTick, reactive, ref, unref } from 'vue';
+
+import { useVbenDrawer } from '@vben/common-ui';
+
+import { Button, message } from 'ant-design-vue';
+
+import { useTableGridOptions, useVbenVxeGrid } from '#/adapter';
+import { JobRecordApi } from '#/api';
+
+import { useColumns, useSearchSchema } from './data.config';
+
+const modelRef = ref<Record<string, any>>({});
+const searchInfo = reactive<Record<string, any>>({});
+
+const handelSuccess = () => {
+  reload();
+};
+const handleDelete = async (id: number) => {
+  await JobRecordApi.getDetail(id);
+  message.success('数据删除成功');
+  handelSuccess();
+};
+
+const handleClear = async () => {
+  await JobRecordApi.deleteByJob(searchInfo.jobId);
+  message.success('数据清空成功');
+  handelSuccess();
+};
+
+const handleActionClick = async ({
+  code,
+  row,
+}: OnActionClickParams<JobRecordApi.RecordItem>) => {
+  switch (code) {
+    case 'delete': {
+      await handleDelete(row.id);
+      break;
+    }
+  }
+};
+
+const [Grid, { reload }] = useVbenVxeGrid(
+  useTableGridOptions({
+    formOptions: {
+      fieldMappingTime: [['__date', ['startTime', 'endTime']]],
+      schema: useSearchSchema(),
+    },
+    gridOptions: {
+      columns: useColumns(handleActionClick),
+      proxyConfig: {
+        autoLoad: false,
+        ajax: {
+          query: async ({ page }, formValues) => {
+            return await JobRecordApi.getPage({
+              pageIndex: page.currentPage,
+              pageSize: page.pageSize,
+              ...formValues,
+              ...searchInfo,
+            });
+          },
+        },
+      },
+    } as VxeTableGridOptions,
+  }),
+);
+
+const [Drawer, { setState, getData }] = useVbenDrawer({
+  closeOnClickModal: false,
+  footer: false,
+  onOpenChange: async (isOpen: boolean) => {
+    if (isOpen) {
+      setState({ loading: true });
+      const data = getData<Record<string, any>>();
+
+      modelRef.value = { ...data.baseData };
+      searchInfo.jobId = data.baseData.jobId;
+      searchInfo.triggerId = data.baseData?.triggerId;
+      nextTick(() => {
+        reload();
+      });
+
+      setState({ loading: false });
+    }
+  },
+});
+
+const getTitle = computed(() => `执行记录[${unref(modelRef).jobId}]`);
+</script>
+
+<template>
+  <Drawer class="w-[1000px]" :title="getTitle">
+    <Grid>
+      <template #table-title>
+        <span class="border-l-primary border-l-8 border-solid pl-2">
+          执行记录列表
+        </span>
+      </template>
+      <template #toolbar-tools>
+        <Button
+          class="mr-2"
+          type="primary"
+          v-access:code="'jog-record:clear'"
+          @click="() => handleClear()"
+        >
+          清空
+        </Button>
+      </template>
+    </Grid>
+  </Drawer>
+</template>

+ 103 - 0
apps/web-baicai/src/views/system/job/term/components/edit.vue

@@ -0,0 +1,103 @@
+<script lang="ts" setup>
+import { computed, ref, unref } from 'vue';
+
+import { alert, useVbenModal } from '@vben/common-ui';
+
+import { useFormOptions, useVbenForm } from '#/adapter';
+import { JobTermApi } from '#/api';
+
+import { jobScriptCode, useSchema } from '../data.config';
+
+defineOptions({
+  name: 'RoleEdit',
+});
+const emit = defineEmits(['success']);
+const modelRef = ref<Record<string, any>>({});
+const isUpdate = ref(true);
+
+const [Form, { validate, setValues, getValues, updateSchema }] = useVbenForm(
+  useFormOptions({
+    wrapperClass: 'grid-cols-2',
+    schema: useSchema(),
+  }),
+);
+
+const [Modal, { close, setState, getData, lock, unlock }] = useVbenModal({
+  fullscreenButton: false,
+  draggable: true,
+  closeOnClickModal: false,
+  onCancel() {
+    close();
+  },
+  onConfirm: async () => {
+    try {
+      const { valid } = await validate();
+      if (!valid) return;
+      const values = await getValues();
+      if (values.jobCreateType === 2) {
+        values.scriptCode = '';
+        const httpJob = Object.assign({}, values.httpJob, {
+          ClientName: 'HttpJob',
+          EnsureSuccessStatusCode: true,
+          Timeout: 100_000,
+        });
+        delete values.httpJob;
+        values.properties = JSON.stringify({
+          HttpJob: JSON.stringify(httpJob),
+        });
+      }
+      lock();
+      const postParams = unref(modelRef);
+      Object.assign(postParams, values);
+      await (unref(isUpdate)
+        ? JobTermApi.editDetail(postParams as JobTermApi.RecordItem)
+        : JobTermApi.addDetail(postParams as JobTermApi.BasicRecordItem));
+      alert('操作成功');
+
+      emit('success');
+      close();
+    } finally {
+      unlock();
+    }
+  },
+  onOpenChange: async (isOpen: boolean) => {
+    if (isOpen) {
+      setState({ loading: true });
+      const data = getData<Record<string, any>>();
+      isUpdate.value = !!data.isUpdate;
+      modelRef.value = { ...data.baseData };
+
+      if (unref(isUpdate)) {
+        const entity = await JobTermApi.getDetail(data.baseData.id);
+
+        if (entity.jobCreateType === 2) {
+          entity.httpJob = JSON.parse(JSON.parse(entity.properties).HttpJob);
+        }
+
+        modelRef.value = { ...entity };
+        setValues(entity);
+        updateSchema([
+          { fieldName: 'jobId', componentProps: { disabled: true } },
+          { fieldName: 'jobCreateType', componentProps: { disabled: true } },
+        ]);
+      } else {
+        setValues({ scriptCode: jobScriptCode });
+        updateSchema([
+          { fieldName: 'jobId', componentProps: { disabled: false } },
+          { fieldName: 'jobCreateType', componentProps: { disabled: false } },
+        ]);
+      }
+      setState({ loading: false });
+    }
+  },
+});
+
+const getTitle = computed(() =>
+  unref(isUpdate) ? '编辑任务调试' : '新增任务调试',
+);
+</script>
+<template>
+  <Modal class="w-[1000px]" :title="getTitle">
+    <Form />
+  </Modal>
+</template>

+ 347 - 0
apps/web-baicai/src/views/system/job/term/data.config.ts

@@ -0,0 +1,347 @@
+import type {
+  OnActionClickFn,
+  VbenFormSchema,
+  VxeTableGridOptions,
+} from '#/adapter';
+
+import { JobTermApi } from '#/api';
+import { boolOptions } from '#/api/model';
+
+export const concurrentOptions = [
+  { color: '#3300FF', label: '并行', value: true },
+  { color: '#9900FF', label: '串行', value: false },
+];
+
+export const jobCreateTypeOptions = [
+  { color: '#3399CC', label: '内置', value: 0, disabled: true },
+  { color: '#FFCC99', label: '脚本', value: 1 },
+  { color: '#99CCCC', label: 'HTTP请求', value: 2 },
+];
+
+export const useSearchSchema = (): VbenFormSchema[] => {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'jobId',
+      label: '作业编号',
+    },
+    {
+      component: 'Input',
+      fieldName: 'description',
+      label: '描述信息',
+    },
+  ];
+};
+
+export function useColumns(
+  onActionClick?: OnActionClickFn<JobTermApi.RecordItem>,
+): VxeTableGridOptions<JobTermApi.RecordItem>['columns'] {
+  return [
+    { title: '序号', type: 'seq', width: 50 },
+    {
+      align: 'left',
+      field: 'jobId',
+      title: '作业编号',
+      width: 120,
+    },
+    { align: 'left', field: 'groupName', title: '组名称', width: 90 },
+    { align: 'left', field: 'jobType', title: '类名', width: 200 },
+    { align: 'left', field: 'description', title: '描述信息' },
+    {
+      align: 'center',
+      field: 'concurrent',
+      title: '执行方式',
+      width: 90,
+      cellRender: {
+        name: 'CellTag',
+        options: concurrentOptions,
+      },
+    },
+    {
+      align: 'center',
+      field: 'jobCreateType',
+      title: '作业类型',
+      width: 100,
+      cellRender: {
+        name: 'CellTag',
+        options: jobCreateTypeOptions,
+      },
+    },
+    {
+      align: 'center',
+      field: 'includeAnnotation',
+      title: '扫描特性',
+      width: 80,
+      cellRender: {
+        name: 'CellTag',
+        options: boolOptions,
+      },
+    },
+    {
+      field: 'updatedTime',
+      title: '更新时间',
+      align: 'left',
+      width: 150,
+    },
+    {
+      field: 'properties',
+      title: '额外数据',
+      align: 'left',
+      width: 150,
+    },
+    {
+      align: 'right',
+      cellRender: {
+        attrs: {
+          nameField: 'jobId',
+          nameTitle: '作业',
+          onClick: onActionClick,
+        },
+        name: 'CellAction',
+        options: [
+          {
+            code: 'execute',
+            label: '执行',
+            auth: ['jog-term:execute'],
+          },
+          {
+            code: 'edit',
+            auth: ['jog-term:edit'],
+          },
+          {
+            code: 'trigger',
+            label: '触发器',
+            auth: ['jog-trigger:view'],
+          },
+          {
+            code: 'start',
+            label: '启动作业',
+            auth: ['jog-term:start'],
+          },
+          {
+            code: 'pause',
+            label: '暂停作业',
+            auth: ['jog-term:pause'],
+          },
+          {
+            code: 'cancel',
+            label: '取消作业',
+            auth: ['jog-term:cancel'],
+          },
+          {
+            code: 'record',
+            label: '执行记录',
+            auth: ['jog-record:view'],
+          },
+          {
+            code: 'delete',
+            auth: ['jog-term:delete'],
+          },
+        ],
+      },
+      field: 'operation',
+      fixed: 'right',
+      headerAlign: 'center',
+      showOverflow: false,
+      title: '操作',
+      width: 100,
+    },
+  ];
+}
+
+export const useSchema = (): VbenFormSchema[] => {
+  return [
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'jobId',
+      label: '作业编号',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      defaultValue: 'default',
+      fieldName: 'groupName',
+      label: '组名称',
+      rules: 'required',
+    },
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        options: jobCreateTypeOptions,
+        optionType: 'button',
+        buttonStyle: 'solid',
+        autoSelect: 'first',
+      },
+      defaultValue: 2,
+      fieldName: 'jobCreateType',
+      label: '创建类型',
+      rules: 'required',
+    },
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        options: concurrentOptions,
+        optionType: 'button',
+        buttonStyle: 'solid',
+      },
+      defaultValue: true,
+      fieldName: 'concurrent',
+      label: '执行方式',
+      rules: 'required',
+    },
+    {
+      component: 'Textarea',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'properties',
+      label: '额外数据',
+      formItemClass: 'col-span-2',
+      dependencies: {
+        triggerFields: ['jobCreateType'],
+        show: (values) => values.jobCreateType !== 2,
+      },
+    },
+    {
+      component: 'InputCode',
+      componentProps: {
+        placeholder: '请输入',
+        language: 'csharp',
+        popTitle: '脚本代码',
+      },
+      fieldName: 'scriptCode',
+      label: '脚本代码',
+      formItemClass: 'col-span-2',
+      dependencies: {
+        triggerFields: ['jobCreateType'],
+        show: (values) => values.jobCreateType === 1,
+      },
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'httpJob.RequestUri',
+      label: '请求地址',
+      formItemClass: 'col-span-2',
+      dependencies: {
+        triggerFields: ['jobCreateType'],
+        show: (values) => values.jobCreateType === 2,
+      },
+    },
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        optionType: 'button',
+        buttonStyle: 'solid',
+        autoSelect: 'first',
+        options: [
+          { label: 'GET', value: '{"Method":"GET"}' },
+          { label: 'POST', value: '{"Method":"POST"}' },
+          { label: 'PUT', value: '{"Method":"PUT"}' },
+          { label: 'DELETE', value: '{"Method":"DELETE"}' },
+        ],
+      },
+      defaultValue: '{"Method":"GET"}',
+      fieldName: 'httpJob.HttpMethod',
+      label: '求证方式',
+      dependencies: {
+        triggerFields: ['jobCreateType'],
+        show: (values) => values.jobCreateType === 2,
+      },
+    },
+    {
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '请输入',
+        addonAfter: 'ms',
+      },
+      defaultValue: 1000,
+      fieldName: 'httpJob.Timeout',
+      label: '超时时间',
+      help: '设为 -1 表示无限等待,留空默认值是 100,000 毫秒(100 秒)',
+      dependencies: {
+        triggerFields: ['jobCreateType'],
+        show: (values) => values.jobCreateType === 2,
+      },
+    },
+    {
+      component: 'Textarea',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'httpJob.Body',
+      label: '请求报文体',
+      formItemClass: 'col-span-2',
+      dependencies: {
+        triggerFields: ['jobCreateType'],
+        show: (values) => values.jobCreateType === 2,
+      },
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'description',
+      label: '描述信息',
+      formItemClass: 'col-span-2',
+    },
+  ];
+};
+
+export const jobScriptCode = `#region using
+
+using Furion;
+using Furion.Logging;
+using Furion.RemoteRequest.Extensions;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using System;
+using System.Data;
+using System.Linq.Dynamic.Core;
+using System.Linq.Expressions;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Yitter.IdGenerator;
+
+#endregion
+
+namespace Baicai.Plugins.Job;
+
+/// <summary>
+/// 动态作业任务
+/// </summary>
+[JobDetail("你的作业编号")]
+public class DynamicJob : IJob
+{
+    private readonly IServiceProvider _serviceProvider;
+
+    public DynamicJob(IServiceProvider serviceProvider)
+    {
+        _serviceProvider = serviceProvider;
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var serviceScope = _serviceProvider.CreateScope();
+        
+        // 获取用户仓储
+        // var rep = serviceScope.ServiceProvider.GetService<SqlSugarRepository<SysUser>>();
+
+        // 请求网址
+        // var result = await "http://www.baidu.com".GetAsStringAsync();
+        // Console.WriteLine(result);
+
+        // 日志
+        // Log.Information("日志消息");
+    }
+}`;

+ 166 - 0
apps/web-baicai/src/views/system/job/term/index.vue

@@ -0,0 +1,166 @@
+<script lang="ts" setup>
+import type { OnActionClickParams, VxeTableGridOptions } from '#/adapter';
+
+import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
+
+import { Button, message } from 'ant-design-vue';
+
+import { useTableGridOptions, useVbenVxeGrid } from '#/adapter';
+import { JobTermApi } from '#/api';
+
+import FormRecord from '../record/index.vue';
+import FormTrigger from '../trigger/index.vue';
+import FormEdit from './components/edit.vue';
+import { useColumns, useSearchSchema } from './data.config';
+
+const [FormEditModal, formEditApi] = useVbenModal({
+  connectedComponent: FormEdit,
+});
+
+const [FormRecordModal, formRecordApi] = useVbenDrawer({
+  connectedComponent: FormRecord,
+});
+
+const [FormTriggerModal, formTriggerApi] = useVbenDrawer({
+  connectedComponent: FormTrigger,
+});
+
+const handelSuccess = () => {
+  reload();
+};
+const handleDelete = async (id: number) => {
+  await JobTermApi.deleteDetail(id);
+  message.success('数据删除成功');
+  handelSuccess();
+};
+
+const handleStart = async (record: any) => {
+  await JobTermApi.start(record.jobId);
+  message.success('启动成功');
+};
+
+const handlePause = async (record: any) => {
+  await JobTermApi.pause(record.jobId);
+  message.success('暂停成功');
+};
+
+const handleCancel = async (record: any) => {
+  await JobTermApi.cancel(record.jobId);
+  message.success('取消成功');
+};
+const handleRun = async (record: any) => {
+  await JobTermApi.run(record.jobId);
+  message.success('执行成功');
+};
+const handleEdit = (record: any, isUpdate: boolean) => {
+  formEditApi
+    .setData({
+      isUpdate,
+      baseData: { id: record.id },
+    })
+    .open();
+};
+
+const handleRecord = (record: any) => {
+  formRecordApi
+    .setData({
+      baseData: { ...record },
+    })
+    .open();
+};
+
+const handleTrigger = (record: any) => {
+  formTriggerApi
+    .setData({
+      baseData: { ...record },
+    })
+    .open();
+};
+
+const handleActionClick = async ({
+  code,
+  row,
+}: OnActionClickParams<JobTermApi.RecordItem>) => {
+  switch (code) {
+    case 'cancel': {
+      handleCancel(row);
+      break;
+    }
+    case 'delete': {
+      await handleDelete(row.id);
+      break;
+    }
+    case 'edit': {
+      handleEdit(row, true);
+      break;
+    }
+    case 'execute': {
+      handleRun(row);
+      break;
+    }
+    case 'pause': {
+      handlePause(row);
+      break;
+    }
+    case 'record': {
+      handleRecord(row);
+      break;
+    }
+    case 'start': {
+      handleStart(row);
+      break;
+    }
+    case 'trigger': {
+      handleTrigger(row);
+      break;
+    }
+  }
+};
+
+const [Grid, { reload }] = useVbenVxeGrid(
+  useTableGridOptions({
+    formOptions: {
+      schema: useSearchSchema(),
+    },
+    gridOptions: {
+      columns: useColumns(handleActionClick),
+      proxyConfig: {
+        ajax: {
+          query: async ({ page }, formValues) => {
+            return await JobTermApi.getPage({
+              pageIndex: page.currentPage,
+              pageSize: page.pageSize,
+              ...formValues,
+            });
+          },
+        },
+      },
+    } as VxeTableGridOptions,
+  }),
+);
+</script>
+
+<template>
+  <Page auto-content-height>
+    <FormEditModal @success="handelSuccess" />
+    <FormRecordModal />
+    <FormTriggerModal />
+    <Grid>
+      <template #table-title>
+        <span class="border-l-primary border-l-8 border-solid pl-2">
+          任务调试列表
+        </span>
+      </template>
+      <template #toolbar-tools>
+        <Button
+          class="mr-2"
+          type="primary"
+          v-access:code="'jog-term:add'"
+          @click="() => handleEdit({}, false)"
+        >
+          新增任务调试
+        </Button>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 97 - 0
apps/web-baicai/src/views/system/job/trigger/components/edit.vue

@@ -0,0 +1,97 @@
+<script lang="ts" setup>
+import { computed, ref, unref } from 'vue';
+
+import { alert, useVbenModal } from '@vben/common-ui';
+
+import { useFormOptions, useVbenForm } from '#/adapter';
+import { JobTriggerApi } from '#/api';
+
+import { triggerTypeOptions, useSchema } from '../data.config';
+
+defineOptions({
+  name: 'RoleEdit',
+});
+const emit = defineEmits(['success']);
+const modelRef = ref<Record<string, any>>({});
+const isUpdate = ref(true);
+
+const [Form, { validate, setValues, getValues }] = useVbenForm(
+  useFormOptions({
+    wrapperClass: 'grid-cols-2',
+    schema: useSchema(),
+    commonConfig: {
+      labelWidth: 120,
+    },
+  }),
+);
+
+const [Modal, { close, setState, getData, lock, unlock }] = useVbenModal({
+  fullscreenButton: false,
+  draggable: true,
+  closeOnClickModal: false,
+  onCancel() {
+    close();
+  },
+  onConfirm: async () => {
+    try {
+      const { valid } = await validate();
+      if (!valid) return;
+      const values = await getValues();
+      if (values.triggerType === triggerTypeOptions[0]?.value) {
+        values.args = String(values.periodValue);
+        delete values.periodValue;
+      } else {
+        values.args = values.cronValue;
+        delete values.cronValue;
+      }
+      lock();
+      const postParams = unref(modelRef);
+      Object.assign(postParams, values);
+      await (unref(isUpdate)
+        ? JobTriggerApi.editDetail(postParams as JobTriggerApi.RecordItem)
+        : JobTriggerApi.addDetail(postParams as JobTriggerApi.BasicRecordItem));
+      alert('操作成功');
+
+      emit('success');
+      close();
+    } finally {
+      unlock();
+    }
+  },
+  onOpenChange: async (isOpen: boolean) => {
+    if (isOpen) {
+      setState({ loading: true });
+      const data = getData<Record<string, any>>();
+      isUpdate.value = !!data.isUpdate;
+      modelRef.value = { ...data.baseData };
+
+      if (unref(isUpdate)) {
+        const entity = await JobTriggerApi.getDetail(data.baseData.id);
+        modelRef.value = { ...entity };
+
+        if (entity.triggerType === triggerTypeOptions[0]?.value) {
+          entity.periodValue = Number(
+            entity.args.replaceAll('[', '').replaceAll(']', ''),
+          );
+        } else {
+          entity.cronValue = entity.args
+            .replaceAll('[', '')
+            .replaceAll(']', '');
+        }
+
+        setValues(entity);
+      }
+      setState({ loading: false });
+    }
+  },
+});
+
+const getTitle = computed(() =>
+  unref(isUpdate) ? '编辑触发器' : '新增触发器',
+);
+</script>
+<template>
+  <Modal class="w-[1000px]" :title="getTitle">
+    <Form />
+  </Modal>
+</template>

+ 360 - 0
apps/web-baicai/src/views/system/job/trigger/data.config.ts

@@ -0,0 +1,360 @@
+import type {
+  OnActionClickFn,
+  VbenFormSchema,
+  VxeTableGridOptions,
+} from '#/adapter';
+
+import { h } from 'vue';
+
+import { JobTriggerApi } from '#/api';
+import { boolOptions } from '#/api/model';
+import { BcCrontab } from '#/components/bc-crontab';
+
+export const triggerStatusOptions = [
+  { color: '#3399CC', label: '积压', value: 0 },
+  { color: '#FFCC99', label: '就绪', value: 1 },
+  { color: '#99CCCC', label: '正在运行', value: 2 },
+  { color: '#3399CC', label: '暂停', value: 3 },
+  { color: '#FFCC99', label: '阻塞', value: 4 },
+  { color: '#99CCCC', label: '由失败进入就绪', value: 5 },
+  { color: '#3399CC', label: '归档', value: 6 },
+  { color: '#FFCC99', label: '崩溃', value: 7 },
+  { color: '#99CCCC', label: '超限', value: 8 },
+  { color: '#3399CC', label: '无触发时间', value: 9 },
+  { color: '#FFCC99', label: '未启动', value: 10 },
+  { color: '#99CCCC', label: '未知作业触发器', value: 11 },
+  { color: '#99CCCC', label: '未知作业处理程序', value: 12 },
+];
+
+export const triggerTypeOptions = [
+  { color: '#3399CC', label: '间隔', value: 'Furion.Schedule.PeriodTrigger' },
+  {
+    color: '#FFCC99',
+    label: 'Cron表达式',
+    value: 'Furion.Schedule.CronTrigger',
+  },
+];
+
+export function useColumns(
+  onActionClick?: OnActionClickFn<JobTriggerApi.RecordItem>,
+): VxeTableGridOptions<JobTriggerApi.RecordItem>['columns'] {
+  return [
+    { title: '序号', type: 'seq', width: 50 },
+    {
+      align: 'left',
+      field: 'triggerId',
+      title: '触发器编号',
+      width: 120,
+    },
+    {
+      align: 'center',
+      field: 'triggerType',
+      title: '类型',
+      width: 90,
+      cellRender: {
+        name: 'CellTag',
+        options: triggerTypeOptions,
+      },
+    },
+    { align: 'left', field: 'args', title: '参数', width: 200 },
+    { align: 'left', field: 'description', title: '描述', minWidth: 200 },
+    {
+      align: 'center',
+      field: 'status',
+      title: '状态',
+      width: 90,
+      cellRender: {
+        name: 'CellTag',
+        options: triggerStatusOptions,
+      },
+    },
+    {
+      align: 'left',
+      field: 'startTime',
+      title: '起始时间',
+      width: 150,
+    },
+    {
+      align: 'left',
+      field: 'endTime',
+      title: '结束时间',
+      width: 150,
+    },
+    {
+      field: 'group1',
+      title: '运行时间',
+      headerAlign: 'center',
+      children: [
+        { field: 'lastRunTime', title: '最近', width: 150, align: 'left' },
+        { field: 'nextRunTime', title: '下一次', width: 150, align: 'left' },
+      ],
+    },
+    {
+      field: 'group2',
+      title: '触发',
+      headerAlign: 'center',
+      children: [
+        { field: 'numberOfRuns', title: '次数', width: 80, align: 'right' },
+        { field: 'maxNumberOfRuns', title: '最大', width: 80, align: 'right' },
+      ],
+    },
+    {
+      field: 'group3',
+      title: '出错',
+      headerAlign: 'center',
+      children: [
+        { field: 'numberOfErrors', title: '次数', width: 80, align: 'right' },
+        {
+          field: 'maxNumberOfErrors',
+          title: '最大',
+          width: 80,
+          align: 'right',
+        },
+      ],
+    },
+    {
+      field: 'group4',
+      title: '重试',
+      headerAlign: 'center',
+      children: [
+        { field: 'numRetries', title: '次数', width: 80, align: 'right' },
+        { field: 'retryTimeout', title: '间隔ms', width: 80, align: 'right' },
+      ],
+    },
+    {
+      field: 'startNow',
+      title: '立即启动',
+      align: 'center',
+      width: 80,
+      cellRender: {
+        name: 'CellTag',
+        options: boolOptions,
+      },
+    },
+    {
+      field: 'runOnStart',
+      title: '启动时执行一次',
+      align: 'center',
+      width: 120,
+      cellRender: {
+        name: 'CellTag',
+        options: boolOptions,
+      },
+    },
+    {
+      field: 'resetOnlyOnce',
+      title: '重置触发次数',
+      align: 'center',
+      width: 100,
+      cellRender: {
+        name: 'CellTag',
+        options: boolOptions,
+      },
+    },
+    {
+      align: 'right',
+      cellRender: {
+        attrs: {
+          nameField: 'triggerId',
+          nameTitle: '触发器',
+          onClick: onActionClick,
+        },
+        name: 'CellAction',
+        options: [
+          {
+            code: 'execute',
+            label: '执行',
+            auth: ['jog-trigger:execute'],
+          },
+          {
+            code: 'edit',
+            auth: ['jog-trigger:edit'],
+          },
+          {
+            code: 'start',
+            label: '启动触发器',
+            auth: ['jog-trigger:start'],
+          },
+          {
+            code: 'pause',
+            label: '暂停触发器',
+            auth: ['jog-trigger:pause'],
+          },
+          {
+            code: 'cancel',
+            label: '取消触发器',
+            auth: ['jog-trigger:cancel'],
+          },
+          {
+            code: 'record',
+            label: '执行记录',
+            auth: ['jog-record:view'],
+          },
+          {
+            code: 'delete',
+            auth: ['jog-trigger:delete'],
+          },
+        ],
+      },
+      field: 'operation',
+      fixed: 'right',
+      headerAlign: 'center',
+      showOverflow: false,
+      title: '操作',
+      width: 100,
+    },
+  ];
+}
+
+export const useSchema = (): VbenFormSchema[] => {
+  return [
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'triggerId',
+      label: '触发器编号',
+      rules: 'required',
+    },
+    {
+      component: 'Select',
+      componentProps: {
+        placeholder: '请选择',
+        options: triggerTypeOptions,
+      },
+      fieldName: 'triggerType',
+      label: '触发器类型',
+      rules: 'required',
+      defaultValue: triggerTypeOptions[0]?.value,
+    },
+    {
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '请输入',
+        addonAfter: 'ms',
+      },
+      fieldName: 'periodValue',
+      label: '间隔时间',
+      rules: 'required',
+      dependencies: {
+        show: (values) => {
+          return values.triggerType === triggerTypeOptions[0]?.value;
+        },
+        triggerFields: ['triggerType'],
+      },
+    },
+    {
+      component: h(BcCrontab, { placeholder: '请输入' }),
+      fieldName: 'cronValue',
+      label: 'Cron表达式',
+      rules: 'required',
+      dependencies: {
+        show: (values) => {
+          return values.triggerType === triggerTypeOptions[1]?.value;
+        },
+        triggerFields: ['triggerType'],
+      },
+      formItemClass: 'col-span-2',
+    },
+    {
+      component: 'DatePicker',
+      componentProps: {
+        placeholder: '请输入',
+        showTime: true,
+        valueFormat: 'YYYY-MM-DD HH:mm:ss',
+      },
+      fieldName: 'startTime',
+      label: '起始时间',
+    },
+    {
+      component: 'DatePicker',
+      componentProps: {
+        placeholder: '请输入',
+        showTime: true,
+        valueFormat: 'YYYY-MM-DD HH:mm:ss',
+      },
+      fieldName: 'endTime',
+      label: '结束时间',
+    },
+    {
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'maxNumberOfRuns',
+      label: '最大触发次数',
+    },
+    {
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'maxNumberOfErrors',
+      label: '最大出错次数',
+    },
+    {
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'numRetries',
+      label: '重试次数',
+    },
+    {
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '请输入',
+        addonAfter: 'ms',
+      },
+      fieldName: 'retryTimeout',
+      label: '重试间隔',
+    },
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        optionType: 'button',
+        buttonStyle: 'solid',
+        autoSelect: 'first',
+        options: boolOptions,
+      },
+      fieldName: 'startNow',
+      label: '立即启动',
+      defaultValue: true,
+    },
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        optionType: 'button',
+        buttonStyle: 'solid',
+        autoSelect: 'first',
+        options: boolOptions,
+      },
+      fieldName: 'runOnStart',
+      label: '启动时执行一次',
+      defaultValue: true,
+    },
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        optionType: 'button',
+        buttonStyle: 'solid',
+        autoSelect: 'first',
+        options: boolOptions,
+      },
+      fieldName: 'resetOnlyOnce',
+      label: '重置触发次数',
+      help: '是否在启动时重置最大触发次数等于一次的作业,解决因持久化数据已完成一次触发但启动时不再执行的问题',
+      defaultValue: true,
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'description',
+      label: '描述信息',
+      formItemClass: 'col-span-2',
+    },
+  ];
+};

+ 185 - 0
apps/web-baicai/src/views/system/job/trigger/index.vue

@@ -0,0 +1,185 @@
+<script lang="ts" setup>
+import type { OnActionClickParams, VxeTableGridOptions } from '#/adapter';
+
+import { computed, nextTick, reactive, ref, unref } from 'vue';
+
+import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
+
+import { Button, message } from 'ant-design-vue';
+
+import { useTableGridOptions, useVbenVxeGrid } from '#/adapter';
+import { JobTriggerApi } from '#/api';
+
+import FormRecord from '../record/index.vue';
+import FormEdit from './components/edit.vue';
+import { useColumns } from './data.config';
+
+const modelRef = ref<Record<string, any>>({});
+const searchInfo = reactive<Record<string, any>>({});
+
+const [FormEditModal, formEditApi] = useVbenModal({
+  connectedComponent: FormEdit,
+});
+
+const [FormRecordModal, formRecordApi] = useVbenDrawer({
+  connectedComponent: FormRecord,
+});
+
+const handelSuccess = () => {
+  reload();
+};
+const handleDelete = async (id: number) => {
+  await JobTriggerApi.deleteDetail(id);
+  message.success('数据删除成功');
+  handelSuccess();
+};
+
+const handleStart = async (record: JobTriggerApi.RecordItem) => {
+  await JobTriggerApi.start({
+    jobid: record.jobId,
+    triggerId: record.triggerId,
+  });
+  message.success('启动成功');
+};
+
+const handlePause = async (record: JobTriggerApi.RecordItem) => {
+  await JobTriggerApi.pause({
+    jobid: record.jobId,
+    triggerId: record.triggerId,
+  });
+  message.success('暂停成功');
+};
+
+const handleCancel = async (record: JobTriggerApi.RecordItem) => {
+  await JobTriggerApi.cancel({
+    jobid: record.jobId,
+    triggerId: record.triggerId,
+  });
+  message.success('取消成功');
+};
+const handleRun = async (record: JobTriggerApi.RecordItem) => {
+  await JobTriggerApi.run({
+    jobid: record.jobId,
+    triggerId: record.triggerId,
+  });
+  message.success('执行成功');
+};
+const handleEdit = (record: any, isUpdate: boolean) => {
+  formEditApi
+    .setData({
+      isUpdate,
+      baseData: { id: record.id, ...searchInfo },
+    })
+    .open();
+};
+
+const handleRecord = (record: any) => {
+  formRecordApi
+    .setData({
+      baseData: { ...record },
+    })
+    .open();
+};
+
+const handleActionClick = async ({
+  code,
+  row,
+}: OnActionClickParams<JobTriggerApi.RecordItem>) => {
+  switch (code) {
+    case 'cancel': {
+      handleCancel(row);
+      break;
+    }
+    case 'delete': {
+      await handleDelete(row.id);
+      break;
+    }
+    case 'edit': {
+      handleEdit(row, true);
+      break;
+    }
+    case 'execute': {
+      handleRun(row);
+      break;
+    }
+    case 'pause': {
+      handlePause(row);
+      break;
+    }
+    case 'record': {
+      handleRecord(row);
+      break;
+    }
+    case 'start': {
+      handleStart(row);
+      break;
+    }
+  }
+};
+
+const [Grid, { reload }] = useVbenVxeGrid(
+  useTableGridOptions({
+    gridOptions: {
+      columns: useColumns(handleActionClick),
+      pagerConfig: {
+        enabled: false,
+      },
+      proxyConfig: {
+        autoLoad: false,
+        ajax: {
+          query: async () => {
+            return await JobTriggerApi.getList({
+              ...searchInfo,
+            });
+          },
+        },
+      },
+    } as VxeTableGridOptions,
+  }),
+);
+
+const [Drawer, { setState, getData }] = useVbenDrawer({
+  closeOnClickModal: false,
+  footer: false,
+  onOpenChange: async (isOpen: boolean) => {
+    if (isOpen) {
+      setState({ loading: true });
+      const data = getData<Record<string, any>>();
+
+      modelRef.value = { ...data.baseData };
+      searchInfo.jobId = data.baseData.jobId;
+      nextTick(() => {
+        reload();
+      });
+
+      setState({ loading: false });
+    }
+  },
+});
+
+const getTitle = computed(() => `触发器[${unref(modelRef).jobId}]`);
+</script>
+
+<template>
+  <Drawer class="w-[80%]" :title="getTitle">
+    <FormEditModal @success="handelSuccess" />
+    <FormRecordModal />
+    <Grid>
+      <template #table-title>
+        <span class="border-l-primary border-l-8 border-solid pl-2">
+          触发器列表
+        </span>
+      </template>
+      <template #toolbar-tools>
+        <Button
+          class="mr-2"
+          type="primary"
+          v-access:code="'jog-trigger:add'"
+          @click="() => handleEdit({}, false)"
+        >
+          新增触发器
+        </Button>
+      </template>
+    </Grid>
+  </Drawer>
+</template>