mateclaw/mateclaw-ui/src/components/workflow/TriggerPatternForm.vue

387 lines
13 KiB
Vue

<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>