feat(ui/triggers): structured per-pattern form for all 6 pattern types

This commit is contained in:
matevip 2026-05-08 15:07:25 +08:00
parent 231724d6a9
commit 6bcf6b5ee4
5 changed files with 493 additions and 23 deletions

View File

@ -93,13 +93,13 @@
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.modeLabel') }}</span>
<select class="mc-input" :value="modeType" @change="onModeChange">
<option value="sequential">sequential</option>
<option value="fan_out">fan_out</option>
<option value="collect">collect</option>
<option value="conditional">conditional</option>
<option value="await_approval">await_approval</option>
<option value="dispatch_channel">dispatch_channel</option>
<option value="write_memory">write_memory</option>
<option value="sequential">{{ modeOptionLabel('sequential') }}</option>
<option value="fan_out">{{ modeOptionLabel('fan_out') }}</option>
<option value="collect">{{ modeOptionLabel('collect') }}</option>
<option value="conditional">{{ modeOptionLabel('conditional') }}</option>
<option value="await_approval">{{ modeOptionLabel('await_approval') }}</option>
<option value="dispatch_channel">{{ modeOptionLabel('dispatch_channel') }}</option>
<option value="write_memory">{{ modeOptionLabel('write_memory') }}</option>
</select>
</label>
@ -265,11 +265,15 @@ const { t } = useI18n()
const modeType = computed(() => (props.step?.mode?.type ?? 'sequential') as string)
const localizedModeLabel = computed(() => {
const key = `workflows.canvas.modeLabels.${modeType.value}`
const localized = t(key, '')
return localized && localized !== key ? localized : modeType.value
return modeOptionLabel(modeType.value)
})
function modeOptionLabel(type: string): string {
const key = `workflows.canvas.modeLabels.${type}`
const localized = t(key, '')
return localized && localized !== key ? localized : type
}
const modeNeedsAgent = computed(() => {
// fan_out / collect / await_approval / dispatch_channel / write_memory
// don't take an agent the runtime calls a service adapter instead.

View File

@ -0,0 +1,386 @@
<template>
<div class="pattern-form">
<!-- cron schedule expression + timezone -->
<template v-if="patternType === 'cron'">
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.cronExpression') }}</span>
<input
v-model.trim="form.cron"
class="pf-input mono"
:placeholder="t('triggers.pattern.cronExpressionPlaceholder')"
spellcheck="false"
@input="emitFromForm"
/>
<small v-if="errors.cron" class="pf-err">{{ errors.cron }}</small>
<small v-else class="pf-hint">{{ t('triggers.pattern.cronExpressionHint') }}</small>
</div>
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.cronTimezone') }}</span>
<input
v-model.trim="form.timezone"
class="pf-input"
:placeholder="t('triggers.pattern.cronTimezonePlaceholder')"
spellcheck="false"
@input="emitFromForm"
/>
</div>
</template>
<!-- channel_message adapter dropdown + optional senderEquals -->
<template v-else-if="patternType === 'channel_message'">
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.channelType') }}</span>
<select v-model="form.channelType" class="pf-input" @change="emitFromForm">
<option value="">{{ t('triggers.pattern.channelTypeAny') }}</option>
<option value="feishu">feishu</option>
<option value="dingtalk">dingtalk</option>
<option value="wecom">wecom</option>
<option value="telegram">telegram</option>
<option value="discord">discord</option>
<option value="slack">slack</option>
<option value="qq">qq</option>
<option value="web">web</option>
</select>
</div>
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.senderEquals') }}</span>
<input
v-model.trim="form.senderEquals"
class="pf-input"
:placeholder="t('triggers.pattern.senderEqualsPlaceholder')"
spellcheck="false"
@input="emitFromForm"
/>
<small class="pf-hint">{{ t('triggers.pattern.senderEqualsHint') }}</small>
</div>
</template>
<!-- content_match substring (required) -->
<template v-else-if="patternType === 'content_match'">
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.substring') }} *</span>
<input
v-model.trim="form.substring"
class="pf-input"
:placeholder="t('triggers.pattern.substringPlaceholder')"
spellcheck="false"
@input="emitFromForm"
/>
<small v-if="errors.substring" class="pf-err">{{ errors.substring }}</small>
<small v-else class="pf-hint">{{ t('triggers.pattern.substringHint') }}</small>
</div>
</template>
<!-- agent_lifecycle agentId + phase -->
<template v-else-if="patternType === 'agent_lifecycle'">
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.agentId') }}</span>
<input
v-model.number="form.agentId"
type="number"
min="0"
class="pf-input"
:placeholder="t('triggers.pattern.agentIdPlaceholder')"
@input="emitFromForm"
/>
<small class="pf-hint">{{ t('triggers.pattern.agentIdHint') }}</small>
</div>
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.phase') }}</span>
<select v-model="form.phase" class="pf-input" @change="emitFromForm">
<option value="">{{ t('triggers.pattern.phaseAny') }}</option>
<option value="spawned">spawned</option>
<option value="enabled">enabled</option>
<option value="disabled">disabled</option>
<option value="terminated">terminated</option>
<option value="crashed">crashed</option>
</select>
</div>
</template>
<!-- workflow_completion sourceWorkflowId + stateFilter -->
<template v-else-if="patternType === 'workflow_completion'">
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.sourceWorkflowId') }}</span>
<select v-model.number="form.sourceWorkflowId" class="pf-input" @change="emitFromForm">
<option :value="undefined">{{ t('triggers.pattern.sourceWorkflowAny') }}</option>
<option v-for="wf in availableWorkflows" :key="wf.id" :value="wf.id">
#{{ wf.id }} {{ wf.name || '(unnamed)' }}
</option>
</select>
</div>
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.stateFilter') }}</span>
<select v-model="form.stateFilter" class="pf-input" @change="emitFromForm">
<option value="">{{ t('triggers.pattern.stateFilterAny') }}</option>
<option value="completed">completed</option>
<option value="succeeded">succeeded</option>
<option value="failed">failed</option>
</select>
<small class="pf-hint">{{ t('triggers.pattern.stateFilterHint') }}</small>
</div>
</template>
<!-- webhook secret hint, no fields -->
<template v-else-if="patternType === 'webhook'">
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.webhookHeader') }}</span>
<p class="pf-prose">{{ t('triggers.pattern.webhookBody') }}</p>
</div>
</template>
<!-- raw JSON escape hatch collapsible details so it doesn't get
in the way for the typical operator who lets the form generate
everything. -->
<details class="pf-raw" :open="expanded">
<summary @click.prevent="expanded = !expanded">
{{ t('triggers.pattern.rawHeader') }}
</summary>
<textarea
:value="modelValue"
class="pf-input mono"
rows="4"
spellcheck="false"
@input="onRawInput"
/>
<small v-if="rawError" class="pf-err">{{ rawError }}</small>
</details>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { WorkflowSummary } from '@/api'
interface Props {
/** Pattern type (cron / channel_message / …) drives which form is shown. */
patternType: string
/** Current pattern_json string — also the only thing emitted back. */
modelValue: string
/** Optional list of published workflows for the workflow_completion form. */
availableWorkflows?: WorkflowSummary[]
}
const props = withDefaults(defineProps<Props>(), {
availableWorkflows: () => [],
})
const emit = defineEmits<{
(e: 'update:modelValue', json: string): void
(e: 'validation', err: Record<string, string>): void
}>()
const { t } = useI18n()
interface FormState {
cron?: string
timezone?: string
channelType?: string
senderEquals?: string
substring?: string
agentId?: number | null
phase?: string
sourceWorkflowId?: number
stateFilter?: string
}
const form = reactive<FormState>({})
const errors = reactive<Record<string, string>>({})
const rawError = ref<string | null>(null)
const expanded = ref(false)
// Parse the incoming JSON into the form state. Anything we can't parse
// is treated as an empty draft the operator can either fix the raw
// JSON (escape hatch) or fill the form anew, which will re-serialize
// over the broken text.
function loadFromJson(json: string) {
rawError.value = null
if (!json || !json.trim()) {
Object.keys(form).forEach((k) => delete (form as Record<string, unknown>)[k])
return
}
try {
const parsed = JSON.parse(json) as Record<string, unknown>
Object.keys(form).forEach((k) => delete (form as Record<string, unknown>)[k])
if (typeof parsed.cron === 'string') form.cron = parsed.cron
if (typeof parsed.timezone === 'string') form.timezone = parsed.timezone
if (typeof parsed.channelType === 'string') form.channelType = parsed.channelType
if (typeof parsed.senderEquals === 'string') form.senderEquals = parsed.senderEquals
if (typeof parsed.substring === 'string') form.substring = parsed.substring
if (typeof parsed.agentId === 'number') form.agentId = parsed.agentId
if (typeof parsed.phase === 'string') form.phase = parsed.phase
if (typeof parsed.sourceWorkflowId === 'number') form.sourceWorkflowId = parsed.sourceWorkflowId
if (typeof parsed.stateFilter === 'string') form.stateFilter = parsed.stateFilter
} catch (e) {
rawError.value = (e as Error).message
}
}
// Build the JSON string from the form, dropping empty / undefined fields
// so the matcher's per-field "narrow if present" logic stays clean.
function buildJsonFromForm(): string {
const out: Record<string, unknown> = {}
switch (props.patternType) {
case 'cron':
if (form.cron) out.cron = form.cron
if (form.timezone) out.timezone = form.timezone
break
case 'channel_message':
if (form.channelType) out.channelType = form.channelType
if (form.senderEquals) out.senderEquals = form.senderEquals
break
case 'content_match':
if (form.substring) out.substring = form.substring
break
case 'agent_lifecycle':
if (typeof form.agentId === 'number' && !Number.isNaN(form.agentId)) {
out.agentId = form.agentId
}
if (form.phase) out.phase = form.phase
break
case 'workflow_completion':
if (typeof form.sourceWorkflowId === 'number') {
out.sourceWorkflowId = form.sourceWorkflowId
}
if (form.stateFilter) out.stateFilter = form.stateFilter
break
case 'webhook':
// Webhook is opaque pass-through; no fields.
break
}
return JSON.stringify(out)
}
// Local validation required-field checks that mirror the matcher's
// fail-closed rules so the operator hears about a mistake before the
// trigger silently misses every event.
function validate(): Record<string, string> {
const errs: Record<string, string> = {}
if (props.patternType === 'cron') {
if (!form.cron || !form.cron.trim()) {
errs.cron = t('triggers.pattern.errCronRequired')
} else {
// Quartz-style 6-field cron is what the scheduler accepts; allow
// 5- or 6-field for ergonomics, just check field count.
const parts = form.cron.trim().split(/\s+/)
if (parts.length < 5 || parts.length > 7) {
errs.cron = t('triggers.pattern.errCronShape')
}
}
}
if (props.patternType === 'content_match') {
if (!form.substring || !form.substring.trim()) {
errs.substring = t('triggers.pattern.errSubstringRequired')
}
}
return errs
}
function emitFromForm() {
Object.keys(errors).forEach((k) => delete errors[k])
Object.assign(errors, validate())
emit('update:modelValue', buildJsonFromForm())
emit('validation', { ...errors })
}
function onRawInput(e: Event) {
const next = (e.target as HTMLTextAreaElement).value
rawError.value = null
if (next.trim()) {
try { JSON.parse(next) } catch (err) { rawError.value = (err as Error).message }
}
emit('update:modelValue', next)
// Refresh form state to reflect whatever the operator typed.
loadFromJson(next)
}
// Re-derive form state when the parent flips patternType or the JSON
// changes from outside (e.g. after save or when editing a different
// trigger). We don't deep-watch form back into the JSON on these that's
// what emitFromForm covers.
watch(
() => [props.patternType, props.modelValue] as const,
() => loadFromJson(props.modelValue),
{ immediate: true }
)
// Surface the no-op-when-empty error map at mount so the parent can
// disable the save button right away if a required field is missing.
watch(() => props.patternType, () => {
Object.keys(errors).forEach((k) => delete errors[k])
Object.assign(errors, validate())
emit('validation', { ...errors })
}, { immediate: true })
// Expose computed only so the template can read availableWorkflows
// without verbosity.
const availableWorkflows = computed(() => props.availableWorkflows ?? [])
</script>
<style scoped>
.pattern-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.pf-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.pf-label {
font-size: 11px;
font-weight: 600;
color: var(--mc-text-secondary, #555);
letter-spacing: 0.02em;
}
.pf-input {
padding: 6px 8px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
border-radius: 5px;
background: var(--mc-bg, transparent);
color: var(--mc-text-primary, inherit);
font: inherit;
font-size: 12.5px;
outline: none;
transition: border-color 0.12s ease;
width: 100%;
}
.pf-input:focus {
border-color: var(--mc-primary, #4084ff);
}
.pf-input.mono {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11.5px;
}
.pf-hint {
font-size: 11px;
opacity: 0.65;
font-family: 'JetBrains Mono', Consolas, monospace;
}
.pf-err {
font-size: 11px;
color: var(--mc-danger, #c0392b);
}
.pf-prose {
font-size: 12px;
margin: 0;
line-height: 1.5;
color: var(--mc-text-secondary, #555);
}
.pf-raw {
margin-top: 6px;
border-top: 1px dashed var(--mc-border-light, rgba(0, 0, 0, 0.08));
padding-top: 8px;
}
.pf-raw summary {
font-size: 11px;
color: var(--mc-text-tertiary, #888);
cursor: pointer;
margin-bottom: 6px;
}
.pf-raw textarea {
resize: vertical;
}
</style>

View File

@ -2081,6 +2081,37 @@ export default {
agent_lifecycle: 'Optional agentId and phase (spawned / terminated / crashed).',
workflow_completion: 'Optional sourceWorkflowId and stateFilter (completed / failed / any).',
},
pattern: {
cronExpression: 'Cron expression',
cronExpressionPlaceholder: '0 0 * * * *',
cronExpressionHint: '5 fields (min hour day month weekday) or 6 fields (with seconds). Each change bumps pattern_version.',
cronTimezone: 'Timezone',
cronTimezonePlaceholder: 'UTC',
channelType: 'Channel type',
channelTypeAny: 'Any channel',
senderEquals: 'Sender equals',
senderEqualsPlaceholder: 'Optional sender ID',
senderEqualsHint: 'Empty matches any sender; filled narrows to that one sender.',
substring: 'Substring',
substringPlaceholder: 'order',
substringHint: 'Case-insensitive. Empty strings refuse to fire (no broadcast trap).',
agentId: 'Agent ID',
agentIdPlaceholder: '0 means any',
agentIdHint: 'Empty or 0 matches any agent.',
phase: 'Lifecycle phase',
phaseAny: 'Any phase',
sourceWorkflowId: 'Source workflow',
sourceWorkflowAny: 'Any upstream',
stateFilter: 'Completion state',
stateFilterAny: 'Any state',
stateFilterHint: 'completed is an alias for succeeded; failed only matches failed terminal runs.',
webhookHeader: 'Webhook pass-through',
webhookBody: 'No filtering on the UI side — make sure the HTTP entry validates the webhook secret; every accepted request reaches the trigger.',
rawHeader: 'Raw pattern_json (advanced)',
errCronRequired: 'Cron expression cannot be empty',
errCronShape: 'Cron must be 57 fields',
errSubstringRequired: 'Substring cannot be empty or the trigger never fires',
},
unnamed: '(unnamed)',
rateUnit: '{count}/min',
enabled: 'enabled',

View File

@ -1943,10 +1943,10 @@ export default {
templates: {
label: '插入模板',
placeholder: '选择步骤模板',
sequential: 'sequential顺序步骤',
fan_out: 'fan_out并行扇出',
sequential: '顺序步骤',
fan_out: '并行扇出',
collect: 'collect汇聚结果',
conditional: 'conditional条件分支',
conditional: '条件分支',
await_approval: 'await_approval等待审批',
dispatch_channel: 'dispatch_channel消息分发',
write_memory: 'write_memory写入记忆',
@ -2093,6 +2093,37 @@ export default {
agent_lifecycle: '可选 agentId 和 phasespawned / terminated / crashed。',
workflow_completion: '可选 sourceWorkflowId 和 stateFiltercompleted / failed / any。',
},
pattern: {
cronExpression: 'Cron 表达式',
cronExpressionPlaceholder: '0 0 * * * *',
cronExpressionHint: '5 字段(分 时 日 月 周)或 6 字段(含秒)。修改后 pattern_version 自增。',
cronTimezone: '时区',
cronTimezonePlaceholder: 'Asia/Shanghai',
channelType: '渠道类型',
channelTypeAny: '任意渠道',
senderEquals: '发送者匹配',
senderEqualsPlaceholder: '可选,发送者 ID',
senderEqualsHint: '留空表示任意发送者;填写后只匹配该发送者的消息。',
substring: '子串匹配',
substringPlaceholder: '订单 / order',
substringHint: '不区分大小写。空字符串将拒绝触发(避免无意义广播)。',
agentId: '智能体 ID',
agentIdPlaceholder: '0 表示任意',
agentIdHint: '留空或填 0 表示任意智能体。',
phase: '生命周期阶段',
phaseAny: '任意阶段',
sourceWorkflowId: '上游工作流',
sourceWorkflowAny: '任意上游',
stateFilter: '完成状态',
stateFilterAny: '任意状态',
stateFilterHint: 'completed 等价于 succeededfailed 仅匹配失败结束。',
webhookHeader: 'Webhook 透传匹配',
webhookBody: '此模式不在前端做过滤——请确保 HTTP 入口校验 webhook 密钥;通过校验的请求会全部进入触发器。',
rawHeader: '原始 pattern_json高级',
errCronRequired: 'Cron 表达式不能为空',
errCronShape: 'Cron 表达式格式不正确5 ~ 7 字段)',
errSubstringRequired: '子串不能为空,否则触发器不会激活',
},
unnamed: '(未命名)',
rateUnit: '{count}/分钟',
enabled: '已启用',

View File

@ -74,11 +74,15 @@
<option value="webhook">webhook</option>
</select>
</label>
<label class="span-2">{{ t('triggers.fields.patternJson') }}
<textarea v-model="formState.patternJson" rows="3"
:placeholder="t('triggers.fields.patternJsonPlaceholder')" />
<small class="pattern-hint">{{ patternHint }}</small>
</label>
<div class="span-2">
<span class="form-grid-label">{{ t('triggers.fields.patternJson') }}</span>
<TriggerPatternForm
v-model="formState.patternJson"
:pattern-type="formState.patternType"
:available-workflows="availableWorkflows"
@validation="onPatternValidation"
/>
</div>
<label>{{ t('triggers.fields.targetType') }}
<!-- v0 only ships the workflow dispatcher; agent target is
reserved for v1 and rejected by the API to avoid the
@ -121,7 +125,12 @@
</div>
<footer>
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.cancel') }}</button>
<button class="btn-primary" :disabled="busy" @click="save">{{ t('triggers.actions.save') }}</button>
<button
class="btn-primary"
:disabled="busy || patternHasErrors"
:title="patternHasErrors ? Object.values(patternErrors).join('; ') : ''"
@click="save"
>{{ t('triggers.actions.save') }}</button>
</footer>
</section>
</div>
@ -136,6 +145,7 @@ import { ElMessage } from 'element-plus'
import { mcConfirm } from '@/components/common/useConfirm'
import { triggerApi, type TriggerSummary, workflowApi, type WorkflowSummary } from '@/api'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import TriggerPatternForm from '@/components/workflow/TriggerPatternForm.vue'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
@ -147,10 +157,11 @@ const availableWorkflows = computed(() =>
workflows.value.filter((w) => w.latestRevisionId)
)
const patternHint = computed(() => {
const key = `triggers.patternHints.${formState.value.patternType}`
return t(key, '')
})
const patternErrors = ref<Record<string, string>>({})
function onPatternValidation(errs: Record<string, string>) {
patternErrors.value = errs
}
const patternHasErrors = computed(() => Object.keys(patternErrors.value).length > 0)
const formOpen = ref(false)
const editing = ref<TriggerSummary | null>(null)
@ -389,6 +400,13 @@ watch(workspaceId, reload)
opacity: 0.7;
white-space: pre-wrap;
}
.form-grid-label {
display: block;
font-size: 12px;
font-weight: 500;
margin-bottom: 4px;
color: var(--mc-text-secondary, inherit);
}
.form-card footer {
margin-top: 12px;
display: flex;