Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ui/src/views/VIEW_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,9 @@ View 负责路由、资源与模式、图加载、保存/发布/调试编排

### 触发器

- 智能体、工具的资源触发器 Action 与列表弹窗分别放在各自的 `action-dropdown/trigger-action/`,
创建/编辑表单继续共用 `trigger/trigger-form/TriggerFormDrawer.vue`。

- 触发器执行记录的执行时间列使用服务端排序,独立 `orderBy` 经 `toOrderBy` 添加 `ett.` 前缀,
通过查询参数 `order` 传入。切换排序回第一页;搜索、分页和详情跨页保留排序,重新打开抽屉恢复默认排序。
- 触发器列表的下次执行时间、创建时间使用 `order_by` 表头排序,切换时清空选择并回到第一页;搜索、筛选和分页保留排序。
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export { default as ExportApplicationAction } from './ExportApplicationAction.vu
export { default as PortalApplicationAction } from './PortalApplicationAction.vue'
export { default as MoveApplicationAction } from './MoveApplicationAction.vue'
export { default as SettingApplicationAction } from './SettingApplicationAction.vue'
export { default as TriggerApplicationAction } from './TriggerApplicationAction.vue'
export { default as TriggerApplicationAction } from './trigger-action/TriggerApplicationAction.vue'
export { default as McpConfigApplicationAction } from './McpConfigApplicationAction.vue'
export { default as AuthorizeApplicationAction } from './AuthorizeApplicationAction.vue'
export { default as RelatedResourcesApplicationAction } from './RelatedResourcesApplicationAction.vue'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed, nextTick, ref, useTemplateRef } from 'vue'
import type ResourceTriggerApi from '@/api/admin/workspace/trigger/resource-trigger'
import type ApplicationApi from '@/api/admin/workspace/application/application'
import type SystemResourceApplicationApi from '@/api/admin/system/resource-management/application/application'
import type { ApplicationDetail, ResourceTrigger, ResourceTriggerResource, TriggerSetting } from '@/api/types'
import { RESOURCE_TYPE, TRIGGER_TYPE, SCHEDULE_TYPE } from '@/api/enums'
import { MsgSuccess } from '@/utils/message'
import TriggerFormDrawer from '@/views/trigger/trigger-form/TriggerFormDrawer.vue'

const props = defineProps<{
api: typeof ResourceTriggerApi
applicationApi?: typeof ApplicationApi | typeof SystemResourceApplicationApi
application: ApplicationDetail
}>()
const emit = defineEmits<{ closed: [] }>()
const resource = computed<ResourceTriggerResource>(() => ({
source_type: RESOURCE_TYPE.APPLICATION,
source_id: props.application.id,
// Action 在打开弹窗前已校验所属工作空间。
workspace_id: props.application.workspace_id!,
}))

/* 当前资源的触发器列表 */
const visible = ref(false)
const loading = ref(false)
const triggersData = ref<ResourceTrigger[]>([])
function loadTriggers() {
loading.value = true
return props.api
.getResourceTriggerList(resource.value)
.then((result) => {
triggersData.value = result
})
.catch(() => {})
.finally(() => {
loading.value = false
})
}
function open() {
visible.value = true
return loadTriggers()
}

function getScheduleLabel(setting: TriggerSetting) {
const times = setting.time?.join('、') || '-'
switch (setting.schedule_type) {
case SCHEDULE_TYPE.DAILY:
return `每日/${times}`
case SCHEDULE_TYPE.WEEKLY: {
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
return `每周${setting.days?.map((day) => weekdays[Number(day) % 7]).join('、') || '-'}/${times}`
}
case SCHEDULE_TYPE.MONTHLY:
return `每月${setting.days?.join('、') || '-'}日/${times}`
case SCHEDULE_TYPE.INTERVAL:
return `每隔${setting.interval_value ?? '-'}${setting.interval_unit === 'hours' ? '小时' : '分钟'}`
case SCHEDULE_TYPE.CRON:
return setting.cron_expression || '-'
default:
return '-'
}
}

/* 创建、编辑复用同一表单抽屉,关闭后释放 */
const formMounted = ref(false)
const triggerFormRef = useTemplateRef<InstanceType<typeof TriggerFormDrawer>>('triggerFormRef')
function handleOpenForm(triggerId?: string) {
if (loading.value) return
formMounted.value = true
return nextTick(() => triggerFormRef.value?.open(triggerId))
}
function handleFormClosed() {
formMounted.value = false
}
function handleRemoveTrigger(trigger: ResourceTrigger) {
if (loading.value) return
loading.value = true
return props.api
.deleteResourceTrigger(resource.value, trigger.id)
.then(() => {
MsgSuccess('移除成功')
return loadTriggers()
})
.finally(() => {
loading.value = false
})
}
defineExpose({ open })
</script>

<template>
<MkDialog class="resource-trigger-dialog" v-model="visible" @closed="emit('closed')">
<template #header>
<div class="flex-between">
<h4>触发器</h4>
<div class="flex-align-center">
<!-- 添加触发器 -->
<el-button text :disabled="loading" @click="handleOpenForm()" class="text-N900!">
<MkIcon name="icon_add_outlined" />
<span>添加</span>
</el-button>
<el-divider direction="vertical" class="ml-3! mr-4!" />
</div>
</div>
</template>
<div v-loading="loading" class="min-h-30">
<div class="space-y-2">
<template v-for="trigger in triggersData" :key="trigger.id">
<el-card shadow="never" class="small">
<div class="flex-between gap-3">
<div class="flex-align-center min-w-0 flex-1 gap-2">
<TriggerIcon :type="trigger.trigger_type" :size="20" class="shrink-0" />
<span class="truncate" :title="trigger.name">{{ trigger.name }}</span>
</div>
<span v-if="trigger.trigger_type === TRIGGER_TYPE.SCHEDULED" class="text-N600" :title="getScheduleLabel(trigger.trigger_setting)">
{{ getScheduleLabel(trigger.trigger_setting) }}
</span>
<div class="flex-align-center shrink-0">
<!-- 编辑触发器 -->

<el-button text :disabled="loading" @click="handleOpenForm(trigger.id)"><MkIcon name="icon_edit_outlined" /></el-button>

<!-- 移除当前资源的触发器 -->

<el-button text :disabled="loading" @click="handleRemoveTrigger(trigger)"><MkIcon name="icon_close_outlined" /></el-button>
</div>
</div>
</el-card>
</template>
</div>
<MkEmpty v-if="!triggersData.length" />
</div>
</MkDialog>
<TriggerFormDrawer
v-if="formMounted"
ref="triggerFormRef"
:resource="resource"
:resource-api="api"
:application-api="applicationApi"
@refresh="loadTriggers"
@closed="handleFormClosed"
/>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { nextTick, ref, useTemplateRef } from 'vue'
import type ResourceTriggerApi from '@/api/admin/workspace/trigger/resource-trigger'
import type ApplicationApi from '@/api/admin/workspace/application/application'
import type SystemResourceApplicationApi from '@/api/admin/system/resource-management/application/application'
import type { ApplicationDetail } from '@/api/types'
import { MsgError } from '@/utils/message'
import ApplicationTriggerDialog from './ApplicationTriggerDialog.vue'

const props = defineProps<{
api: typeof ResourceTriggerApi
applicationApi?: typeof ApplicationApi | typeof SystemResourceApplicationApi
application: ApplicationDetail
label: string
}>()

/* 点击后挂载资源触发器列表。 */
const dialogMounted = ref(false)
const resourceTriggerRef = useTemplateRef<InstanceType<typeof ApplicationTriggerDialog>>('resourceTriggerRef')
function handleOpenTriggers() {
if (!props.application.workspace_id) {
MsgError('智能体缺少所属工作空间信息')
return
}
dialogMounted.value = true
return nextTick(() => resourceTriggerRef.value?.open())
}
function handleDialogClosed() {
dialogMounted.value = false
}
</script>

<template>
<!-- 查看智能体触发器 -->
<MkDropdownItem @click="handleOpenTriggers" :disabled="!application.is_publish">
<template #icon><MkIcon name="icon-laser" /></template>
<span>{{ label }}</span>
</MkDropdownItem>
<ApplicationTriggerDialog
v-if="dialogMounted"
ref="resourceTriggerRef"
:api="api"
:application-api="applicationApi"
:application="application"
@closed="handleDialogClosed"
/>
</template>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Correctness / potential bugs

  1. Falsy check on workspace_id

    if (!props.application.workspace_id) { ... }

    If workspace_id can legitimately be 0, this wrongly blocks. Prefer an explicit null check: if (props.application.workspace_id == null).

  2. is_publish truthiness for disabled

    :disabled="!application.is_publish"

    Fine if it's a real boolean. If the API can return 0/1 or "0"/"1", this is buggy ("0" is truthy). Consider normalizing (Boolean(application.is_publish)) or typing it strictly. Also the property name is grammatically odd — is_published reads better.

  3. Inconsistent return from handleOpenTriggers
    One path returns undefined, the other returns the promise from nextTick. This trips consistent-return and is a bit misleading:

    function handleOpenTriggers(): void {
      ...
      dialogMounted.value = true
      void nextTick(() => resourceTriggerRef.value?.open())
      // or: await nextTick(); resourceTriggerRef.value?.open()
    }
  4. Silently swallowing a failed open
    resourceTriggerRef.value?.open() does nothing if the ref isn't resolved yet. If nextTick ever isn't enough (async child setup, async component, teleport delay), the click appears to do nothing. Safer alternatives:

    • Have the child accept an open-on-mount / v-model:visible prop instead of imperative open() + nextTick, or
    • Await and guard: await nextTick(); if (!resourceTriggerRef.value) { MsgError(...) }.
  5. State not reset on unexpected unmount
    dialogMounted is only reset via @closed. If the child is destroyed by an ancestor (e.g., the dropdown popper is torn down while the dialog is open and the dialog is not teleported), closed never fires and the flag stays true. Confirm ApplicationTriggerDialog renders into a teleport/body. If not, this component's placement as a sibling of a dropdown item is fragile — the whole dialog could unmount with the menu.

  6. Double-click race
    Two rapid clicks before nextTick resolves will call open() twice. Usually harmless, but guard if open() isn't idempotent.

Type-safety issues

  1. typeof SomeApi as a prop type is likely wrong

    api: typeof ResourceTriggerApi

    If ResourceTriggerApi is a class, typeof X is the constructor type, not an instance. You probably want InstanceType<typeof ResourceTriggerApi> (the same applies to applicationApi). If it's a plain object/namespace export, ignore this.

  2. InstanceType<typeof ApplicationTriggerDialog> only exposes defineExposed members
    With <script setup>, InstanceType does not include internal methods. Make sure the child actually does defineExpose({ open }), otherwise resourceTriggerRef.value?.open() is a type error or a runtime no-op. A cleaner approach is to declare an explicit export interface ApplicationTriggerDialogExpose { open(): void } and use useTemplateRef<ApplicationTriggerDialogExpose>('resourceTriggerRef').

  3. useTemplateRef requires Vue ≥ 3.5. Not a problem if you're already on it, but worth confirming for the project's declared peer version.

UX / a11y

  1. Silent disabled state. When the agent isn't published the menu item is disabled with no explanation. Consider a tooltip ("Publish the agent first") or a disabled reason, otherwise users will think it's broken.
  2. Hardcoded Chinese error string — route it through your i18n layer if one exists.

Performance / optimization

  1. Lazy-load the dialog. It's statically imported, so it's in the same chunk even though it's only needed on click:
    const ApplicationTriggerDialog = defineAsyncComponent(() => import('./ApplicationTriggerDialog.vue'))
    (You'd then import the expose type separately for useTemplateRef.) This is the biggest win here.
  2. Consider v-model-driven visibility instead of the v-if + nextTick + imperative open() dance. Keeping the component mounted but its content lazy (<ApplicationTriggerDialog v-model:open="open" /> where the inner dialog uses v-if on its body) removes the timing dependency entirely and is easier to reason about.
  3. Lazy mounting here is otherwise good — you correctly avoid instantiating the dialog until it's needed.

Style nits

  1. dialogMounted → isDialogMounted / showTriggerDialog matches common Vue naming.
  2. The comment /* 点击后挂载资源触发器列表。 */ documents what, not why; the lazy-mount rationale would be more useful.
  3. props is only used inside setup, not the template — you could destructure with const { application } = toRefs(props) if you prefer, but the current form is fine.

Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,13 @@ onMounted(() => {
<!-- 资源授权 -->
<AuthorizeApplicationAction v-if="perm.application.system.auth()" label="资源授权" :application="row" />
<!-- 智能体触发器 -->
<TriggerApplicationAction v-if="perm.application.system.triggerRead()" label="触发器" :api="SystemResourceTriggerApi" :application="row" />
<TriggerApplicationAction
v-if="perm.application.system.triggerRead()"
label="触发器"
:api="SystemResourceTriggerApi"
:application-api="SystemResourceApplicationApi"
:application="row"
/>
<!-- MCP 服务配置 -->
<McpConfigApplicationAction v-if="perm.application.system.mcpConfig()" label="MCP 服务配置" :application="row" />
<!-- 查看关联资源 -->

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the shown diff only, the template syntax itself looks valid. The main points to verify are around the new application-api prop and its availability/type in the child component.

Potential issues

  1. Child component must declare applicationApi

    • In Vue templates, :application-api="..." maps to the applicationApi prop.
    • If TriggerApplicationAction does not declare applicationApi in its props, Vue will treat it as a fallthrough attribute. Depending on the component root, this may:
      • render an unwanted DOM attribute such as application-api="[object Object]", or
      • produce a warning / unexpected behavior.
    • Ensure the child has something like:
      const props = defineProps<{
        api: ...
        applicationApi: ...
        application: ...
      }>()
      or make it optional if not always passed.
  2. Ensure SystemResourceApplicationApi is imported/exposed

    • Since this is inside <template>, SystemResourceApplicationApi must be available in the component scope.
    • If using <script setup>, confirm it is imported:
      import { SystemResourceApplicationApi } from '...'
    • If using Options API, confirm it is returned from setup, declared in data, or exposed as a computed/property.
  3. Permission scope may need checking

    • The action is still guarded only by perm.application.system.triggerRead().
    • If TriggerApplicationAction now uses SystemResourceApplicationApi to fetch additional application data, verify that triggerRead() is sufficient, or whether an application-read permission is also required.
  4. Prop naming is slightly ambiguous

    • Passing both :api and :application-api is understandable, but the names are generic.
    • Consider clearer names such as:
      :trigger-api="SystemResourceTriggerApi"
      :application-api="SystemResourceApplicationApi"
      and in the child:
      triggerApi: ...
      applicationApi: ...
    • This improves readability and reduces confusion about which API is used for what.
  5. Compatibility with existing usages

    • If applicationApi is required in the child, every existing usage of TriggerApplicationAction must pass it.
    • If some usages do not, make the prop optional and handle the missing value:
      applicationApi?: ApplicationApiType

Optimization suggestions

  • If the child component can reliably import SystemResourceApplicationApi itself, passing it as a prop may be unnecessary and adds coupling. However, passing it as a prop is useful for testability and dependency injection.
  • If many action components receive similar API objects, consider centralizing API injection or using a shared composable/provider rather than passing multiple API props through every component.
  • Ensure the API objects are stable constants. If they are ever stored in reactive state, consider markRaw to avoid unnecessary Vue proxying of class/API objects.

Overall: the diff is syntactically fine, but the key thing to check is that TriggerApplicationAction explicitly declares and correctly types the new applicationApi prop, and that SystemResourceApplicationApi is properly imported/exposed.

Expand Down
2 changes: 1 addition & 1 deletion ui/src/views/tool/tool-card/action-dropdown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ export { default as ToolWorkflowAction } from './ToolWorkflowAction.vue'
export { default as AuthorizeToolAction } from './AuthorizeToolAction.vue'
export { default as RelatedResourcesToolAction } from './RelatedResourcesToolAction.vue'
export { default as ExecutionRecordToolAction } from './ExecutionRecordToolAction.vue'
export { default as TriggerToolAction } from './TriggerToolAction.vue'
export { default as TriggerToolAction } from './trigger-action/TriggerToolAction.vue'
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,25 @@ import type ToolApi from '@/api/admin/workspace/tool/tool'
import type SystemToolApi from '@/api/admin/system/resource-management/tool/tool'
import type ToolWorkflowApi from '@/api/admin/workspace/tool/workflow'

import { nextTick, ref, useTemplateRef } from 'vue'
import { computed, nextTick, ref, useTemplateRef } from 'vue'
import type ResourceTriggerApi from '@/api/admin/workspace/trigger/resource-trigger'
import type { ResourceTrigger, ResourceTriggerResource, TriggerSetting } from '@/api/types'
import { TRIGGER_TYPE, SCHEDULE_TYPE } from '@/api/enums'
import type { ToolItem, ResourceTrigger, ResourceTriggerResource, TriggerSetting } from '@/api/types'
import { RESOURCE_TYPE, TRIGGER_TYPE, SCHEDULE_TYPE } from '@/api/enums'
import { MsgSuccess } from '@/utils/message'
import TriggerFormDrawer from '../trigger-form/TriggerFormDrawer.vue'
import TriggerFormDrawer from '@/views/trigger/trigger-form/TriggerFormDrawer.vue'

const props = defineProps<{
toolApi?: typeof ToolApi | typeof SystemToolApi
toolWorkflowApi?: typeof ToolWorkflowApi
api: typeof ResourceTriggerApi
resource: ResourceTriggerResource
tool: ToolItem
}>()
const emit = defineEmits<{ closed: [] }>()
const resource = computed<ResourceTriggerResource>(() => ({
source_type: RESOURCE_TYPE.TOOL,
source_id: props.tool.id,
workspace_id: props.tool.workspace_id,
}))

/* 当前资源的触发器列表 */
const visible = ref(false)
Expand All @@ -25,7 +30,7 @@ const triggersData = ref<ResourceTrigger[]>([])
function loadTriggers() {
loading.value = true
return props.api
.getResourceTriggerList(props.resource)
.getResourceTriggerList(resource.value)
.then((result) => {
triggersData.value = result
})
Expand Down Expand Up @@ -74,7 +79,7 @@ function handleRemoveTrigger(trigger: ResourceTrigger) {
if (loading.value) return
loading.value = true
return props.api
.deleteResourceTrigger(props.resource, trigger.id)
.deleteResourceTrigger(resource.value, trigger.id)
.then(() => {
MsgSuccess('移除成功')
return loadTriggers()
Expand Down Expand Up @@ -140,5 +145,3 @@ defineExpose({ open })
@closed="handleFormClosed"
/>
</template>

<style scoped lang="scss"></style>
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,22 @@ import type ToolApi from '@/api/admin/workspace/tool/tool'
import type SystemToolApi from '@/api/admin/system/resource-management/tool/tool'
import type ToolWorkflowApi from '@/api/admin/workspace/tool/workflow'

import { computed, nextTick, ref, useTemplateRef } from 'vue'
import { nextTick, ref, useTemplateRef } from 'vue'
import type ResourceTriggerApi from '@/api/admin/workspace/trigger/resource-trigger'
import type { ResourceTriggerResource, ToolItem } from '@/api/types'
import { RESOURCE_TYPE } from '@/api/enums'
import ResourceTriggerDialog from '@/views/trigger/resource-trigger/ResourceTriggerDialog.vue'
import type { ToolItem } from '@/api/types'
import ToolTriggerDialog from './ToolTriggerDialog.vue'

const props = defineProps<{
defineProps<{
toolApi?: typeof ToolApi | typeof SystemToolApi
toolWorkflowApi?: typeof ToolWorkflowApi
api: typeof ResourceTriggerApi
tool: ToolItem
label: string
}>()
const resource = computed<ResourceTriggerResource>(() => ({
source_type: RESOURCE_TYPE.TOOL,
source_id: props.tool.id,
workspace_id: props.tool.workspace_id,
}))

/* 点击后挂载资源触发器列表。 */
const dialogMounted = ref(false)
const resourceTriggerRef = useTemplateRef<InstanceType<typeof ResourceTriggerDialog>>('resourceTriggerRef')
const resourceTriggerRef = useTemplateRef<InstanceType<typeof ToolTriggerDialog>>('resourceTriggerRef')
function handleOpenTriggers() {
dialogMounted.value = true
return nextTick(() => resourceTriggerRef.value?.open())
Expand All @@ -40,13 +34,13 @@ function handleDialogClosed() {
<template #icon><MkIcon name="icon-laser" /></template>
<span>{{ label }}</span>
</MkDropdownItem>
<ResourceTriggerDialog
<ToolTriggerDialog
:tool-api="toolApi"
:tool-workflow-api="toolWorkflowApi"
v-if="dialogMounted"
ref="resourceTriggerRef"
:api="api"
:resource="resource"
:tool="tool"
@closed="handleDialogClosed"
/>
</template>
Loading
Loading