feat(workflow): trigger workspace isolation + step property editor + custom dialogs

This commit is contained in:
matevip 2026-05-08 15:06:16 +08:00
parent 789af09772
commit dac774f15e
10 changed files with 1272 additions and 147 deletions

View File

@ -28,37 +28,52 @@ public class TriggerController {
private final TriggerService triggerService;
private final TriggerEventIngestService ingestService;
@Operation(summary = "List triggers in a workspace.")
@Operation(summary = "List triggers in the caller's workspace.")
@GetMapping
public R<List<TriggerEntity>> list(@RequestParam("workspaceId") long workspaceId) {
public R<List<TriggerEntity>> list(@RequestHeader("X-Workspace-Id") long workspaceId) {
return R.ok(triggerService.listByWorkspace(workspaceId));
}
@Operation(summary = "Get a trigger by id.")
@Operation(summary = "Get a trigger by id, scoped to the caller's workspace.")
@GetMapping("/{id}")
public R<TriggerEntity> get(@PathVariable long id) {
TriggerEntity row = triggerService.get(id);
public R<TriggerEntity> get(@PathVariable long id,
@RequestHeader("X-Workspace-Id") long workspaceId) {
TriggerEntity row = triggerService.get(id, workspaceId);
if (row == null) return R.fail("trigger not found: " + id);
return R.ok(row);
}
@Operation(summary = "Create a trigger; if enabled, registers it with the scheduler.")
@PostMapping
public R<TriggerEntity> create(@RequestBody TriggerEntity trigger) {
return R.ok(triggerService.create(trigger));
public R<TriggerEntity> create(@RequestBody TriggerEntity trigger,
@RequestHeader("X-Workspace-Id") long workspaceId) {
// The controller forces workspace from the trusted header the
// body's workspaceId is ignored so a caller can't plant a trigger
// into another workspace by tweaking the JSON.
try {
return R.ok(triggerService.create(trigger, workspaceId));
} catch (IllegalArgumentException e) {
return R.fail(e.getMessage());
}
}
@Operation(summary = "Update a trigger; pattern_version bumps when the cron expression changes.")
@PutMapping("/{id}")
public R<TriggerEntity> update(@PathVariable long id, @RequestBody TriggerEntity trigger) {
trigger.setId(id);
return R.ok(triggerService.update(trigger));
public R<TriggerEntity> update(@PathVariable long id,
@RequestBody TriggerEntity trigger,
@RequestHeader("X-Workspace-Id") long workspaceId) {
try {
return R.ok(triggerService.update(id, workspaceId, trigger));
} catch (IllegalArgumentException e) {
return R.fail(e.getMessage());
}
}
@Operation(summary = "Delete a trigger and unregister its schedule.")
@DeleteMapping("/{id}")
public R<Void> delete(@PathVariable long id) {
triggerService.delete(id);
public R<Void> delete(@PathVariable long id,
@RequestHeader("X-Workspace-Id") long workspaceId) {
triggerService.delete(id, workspaceId);
return R.ok();
}

View File

@ -3,14 +3,18 @@ package vip.mate.trigger.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.trigger.model.TriggerEntity;
import vip.mate.trigger.repository.TriggerMapper;
import vip.mate.trigger.scheduler.TriggerScheduler;
import vip.mate.workflow.model.WorkflowEntity;
import vip.mate.workflow.repository.WorkflowMapper;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* CRUD facade for {@code mate_trigger} that keeps the in-memory cron
@ -29,8 +33,19 @@ import java.util.Objects;
@RequiredArgsConstructor
public class TriggerService {
/** Pattern types accepted by the v0 matcher; anything else fails closed at ingest. */
private static final Set<String> SUPPORTED_PATTERNS = Set.of(
"cron", "channel_message", "webhook", "agent_lifecycle",
"content_match", "workflow_completion");
/** v0 only dispatches workflow targets; agent target requires a v1 dispatcher. */
private static final Set<String> SUPPORTED_TARGETS = Set.of("workflow");
private final TriggerMapper triggerMapper;
private final TriggerScheduler scheduler;
/** Optional — only present in production. Tests can null it out via constructor. */
@Autowired(required = false)
private WorkflowMapper workflowMapper;
public List<TriggerEntity> listByWorkspace(long workspaceId) {
return triggerMapper.selectList(new LambdaQueryWrapper<TriggerEntity>()
@ -38,12 +53,57 @@ public class TriggerService {
.orderByDesc(TriggerEntity::getCreateTime));
}
/**
* Lookup that scopes to a single workspace. Returns {@code null} when the
* trigger doesn't exist OR when it belongs to another workspace, so the
* caller can surface the same "not found" status either way and avoid
* leaking foreign trigger ids.
*/
public TriggerEntity get(long id, long workspaceId) {
TriggerEntity row = triggerMapper.selectById(id);
if (row == null || row.getWorkspaceId() == null || row.getWorkspaceId() != workspaceId) {
return null;
}
return row;
}
/** Backwards-compatible single-arg get; only used by internal pipelines that
* already know they hold a trusted id (scheduler, ingest). New callers must
* use {@link #get(long, long)}. */
public TriggerEntity get(long id) {
return triggerMapper.selectById(id);
}
@Transactional
public TriggerEntity create(TriggerEntity trigger, long workspaceId) {
// Ignore whatever workspace / id the caller put on the body we
// trust the workspace from the request header alone.
trigger.setId(null);
trigger.setWorkspaceId(workspaceId);
validatePatternAndTargetShape(trigger);
validateTargetOwnership(trigger, workspaceId);
ensureDefaults(trigger);
trigger.setPatternVersion(1L);
trigger.setFireCount(0L);
triggerMapper.insert(trigger);
if (Boolean.TRUE.equals(trigger.getEnabled())) {
scheduler.register(trigger);
}
return trigger;
}
/** @deprecated use {@link #create(TriggerEntity, long)} so the workspace
* isn't trusted from the body. Kept for tests that already supply a
* workspace id on the entity and reference fixture workflow ids that
* may not have a real row in mate_workflow. */
@Deprecated
@Transactional
public TriggerEntity create(TriggerEntity trigger) {
Long ws = trigger.getWorkspaceId();
if (ws == null) {
throw new IllegalArgumentException("workspaceId required");
}
validatePatternAndTargetShape(trigger);
ensureDefaults(trigger);
trigger.setPatternVersion(1L);
trigger.setFireCount(0L);
@ -54,13 +114,32 @@ public class TriggerService {
return trigger;
}
@Transactional
public TriggerEntity update(long id, long workspaceId, TriggerEntity updated) {
TriggerEntity existing = get(id, workspaceId);
if (existing == null) {
throw new IllegalArgumentException("trigger not found: " + id);
}
// Force the canonical id + workspace; reject any body-side override.
updated.setId(id);
updated.setWorkspaceId(workspaceId);
validatePatternAndTargetShape(updated);
validateTargetOwnership(updated, workspaceId);
return updateInternal(existing, updated);
}
/** @deprecated use the workspace-scoped overload. */
@Deprecated
@Transactional
public TriggerEntity update(TriggerEntity updated) {
TriggerEntity existing = triggerMapper.selectById(updated.getId());
if (existing == null) {
throw new IllegalArgumentException("trigger not found: " + updated.getId());
}
return updateInternal(existing, updated);
}
private TriggerEntity updateInternal(TriggerEntity existing, TriggerEntity updated) {
// Bump pattern_version whenever ANY field that changes the
// schedule's behavior, payload rendering, or rate decisions
// changes. This is the lamport other instances rely on at fire
@ -85,7 +164,7 @@ public class TriggerService {
} else {
updated.setPatternVersion(existing.getPatternVersion());
}
// Preserve fireCount / lastFiredAt those are scheduler-owned.
// Preserve fireCount / lastFiredAt / lastError those are scheduler / ingest owned.
updated.setFireCount(existing.getFireCount());
updated.setLastFiredAt(existing.getLastFiredAt());
@ -99,12 +178,63 @@ public class TriggerService {
return updated;
}
@Transactional
public void delete(long id, long workspaceId) {
TriggerEntity row = get(id, workspaceId);
if (row == null) return; // 404-equivalent: idempotent for missing rows
scheduler.unregister(id);
triggerMapper.deleteById(id);
}
/** @deprecated workspace-blind delete; only retained for tests. */
@Deprecated
@Transactional
public void delete(long id) {
scheduler.unregister(id);
triggerMapper.deleteById(id);
}
/**
* Pattern + target shape validation runs on every entry path so a
* trigger can never silently land in a "looks enabled, never fires"
* state. The acceptance set deliberately mirrors what
* {@code TriggerPatternMatcher} understands AND what
* {@code TriggerDispatcher} can actually route extending one
* without the other would re-introduce the silent-skip bug.
*/
private static void validatePatternAndTargetShape(TriggerEntity t) {
String pt = t.getPatternType();
if (pt == null || !SUPPORTED_PATTERNS.contains(pt)) {
throw new IllegalArgumentException("unsupported patternType: " + pt
+ " (expected one of " + SUPPORTED_PATTERNS + ")");
}
String tt = t.getTargetType();
if (tt == null || !SUPPORTED_TARGETS.contains(tt)) {
throw new IllegalArgumentException("unsupported targetType: " + tt
+ " (v0 only supports 'workflow')");
}
}
/**
* Cross-workspace ownership check a trigger in workspace A must
* not be able to point at a workflow in workspace B. Only runs on
* the workspace-aware entry points (create / update with explicit
* workspaceId). The deprecated overloads skip this so legacy tests
* that reference fixture workflow ids without inserting them keep
* working.
*/
private void validateTargetOwnership(TriggerEntity t, long workspaceId) {
if ("workflow".equals(t.getTargetType()) && t.getTargetId() != null
&& workflowMapper != null) {
WorkflowEntity wf = workflowMapper.selectById(t.getTargetId());
if (wf == null || wf.getWorkspaceId() == null
|| wf.getWorkspaceId() != workspaceId) {
throw new IllegalArgumentException(
"target workflow not found in workspace: " + t.getTargetId());
}
}
}
private static void ensureDefaults(TriggerEntity t) {
if (t.getRateLimitPerMin() == null) t.setRateLimitPerMin(60);
if (t.getDedupWindowSecs() == null) t.setDedupWindowSecs(60);

View File

@ -1,53 +1,60 @@
<template>
<ElDialog
v-model="visible"
:title="t('workflows.dialogs.createTitle')"
width="480px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<ElForm :model="form" label-position="top" @submit.prevent="handleSubmit">
<ElFormItem :label="t('workflows.dialogs.fieldName')" required>
<ElInput
v-model="form.name"
:placeholder="t('workflows.dialogs.namePlaceholder')"
maxlength="128"
show-word-limit
autofocus
@keyup.enter="handleSubmit"
/>
</ElFormItem>
<ElFormItem :label="t('workflows.dialogs.fieldDescription')">
<ElInput
v-model="form.description"
type="textarea"
:rows="2"
maxlength="1024"
show-word-limit
:placeholder="t('workflows.dialogs.descriptionPlaceholder')"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleClose">{{ t('common.cancel') }}</ElButton>
<ElButton type="primary" :loading="loading" :disabled="!form.name.trim()" @click="handleSubmit">
{{ t('workflows.dialogs.createSubmit') }}
</ElButton>
</template>
</ElDialog>
<Teleport to="body">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h3>{{ t('workflows.dialogs.createTitle') }}</h3>
<button class="modal-close" @click="close" aria-label="close">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="wf-create-name">{{ t('workflows.dialogs.fieldName') }}</label>
<input
id="wf-create-name"
ref="nameInput"
v-model="form.name"
class="form-input"
:placeholder="t('workflows.dialogs.namePlaceholder')"
maxlength="128"
spellcheck="false"
@keyup.enter="handleSubmit"
/>
</div>
<div class="form-group">
<label for="wf-create-desc">{{ t('workflows.dialogs.fieldDescription') }}</label>
<textarea
id="wf-create-desc"
v-model="form.description"
class="form-input form-textarea"
:placeholder="t('workflows.dialogs.descriptionPlaceholder')"
maxlength="1024"
rows="2"
/>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="close">{{ t('common.cancel') }}</button>
<button
class="btn-primary"
:disabled="loading || !form.name.trim()"
@click="handleSubmit"
>
{{ loading ? t('common.loading') : t('workflows.dialogs.createSubmit') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { nextTick, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElDialog, ElForm, ElFormItem, ElInput, ElButton } from 'element-plus'
interface Props {
modelValue: boolean
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), { loading: false })
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
@ -58,22 +65,33 @@ const { t } = useI18n()
const visible = ref(props.modelValue)
const form = reactive({ name: '', description: '' })
const nameInput = ref<HTMLInputElement | null>(null)
// Reset form whenever the dialog re-opens so a stale value from the
// last create attempt doesn't leak in.
watch(
() => props.modelValue,
(open) => {
async (open) => {
visible.value = open
if (open) {
form.name = ''
form.description = ''
// Defer focus to after the Teleport mounts the DOM, otherwise the
// input ref isn't available on the first tick.
await nextTick()
nameInput.value?.focus()
// Esc close, scoped to the dialog lifetime.
document.addEventListener('keydown', onKey)
} else {
document.removeEventListener('keydown', onKey)
}
}
)
watch(visible, (v) => emit('update:modelValue', v))
function handleClose() {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && visible.value) close()
}
function close() {
visible.value = false
}
@ -83,3 +101,144 @@ function handleSubmit() {
emit('submit', { name: trimmed, description: form.description.trim() })
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 10, 8, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 2100;
animation: fadeIn 0.15s ease;
}
.modal {
width: 480px;
max-width: 100%;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
box-shadow: 0 16px 48px rgba(25, 14, 8, 0.18);
overflow: hidden;
animation: slideUp 0.2s ease;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--mc-border-light);
}
.modal-header h3 {
font-size: 16px;
font-weight: 600;
color: var(--mc-text-primary);
margin: 0;
}
.modal-close {
width: 26px;
height: 26px;
border: none;
background: none;
color: var(--mc-text-tertiary);
font-size: 22px;
line-height: 1;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: background 0.15s;
}
.modal-close:hover {
background: var(--mc-bg-muted);
color: var(--mc-text-primary);
}
.modal-body {
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 12.5px;
font-weight: 500;
color: var(--mc-text-secondary);
}
.form-input {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--mc-border);
border-radius: 8px;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
font-size: 13.5px;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
font-family: inherit;
}
.form-input:focus {
outline: none;
border-color: var(--mc-primary);
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
}
.form-textarea {
resize: vertical;
font-family: inherit;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px 16px;
border-top: 1px solid var(--mc-border-light);
}
.btn-primary,
.btn-secondary {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.btn-primary {
background: var(--mc-primary);
color: var(--mc-text-inverse, #ffffff);
border-color: var(--mc-primary);
}
.btn-primary:hover:not(:disabled) {
background: var(--mc-primary-hover, var(--mc-primary));
}
.btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
color: var(--mc-text-secondary);
border-color: var(--mc-border);
}
.btn-secondary:hover {
background: var(--mc-bg-muted);
color: var(--mc-text-primary);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>

View File

@ -1,45 +1,45 @@
<template>
<ElDialog
v-model="visible"
:title="t('workflows.dialogs.publishTitle')"
width="480px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<p class="publish-hint">{{ t('workflows.dialogs.publishHint') }}</p>
<ElForm :model="form" label-position="top" @submit.prevent="handleSubmit">
<ElFormItem :label="t('workflows.dialogs.publishNote')">
<ElInput
v-model="form.note"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
:placeholder="t('workflows.dialogs.publishNotePlaceholder')"
autofocus
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleClose">{{ t('common.cancel') }}</ElButton>
<ElButton type="primary" :loading="loading" @click="handleSubmit">
{{ t('workflows.actions.publish') }}
</ElButton>
</template>
</ElDialog>
<Teleport to="body">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h3>{{ t('workflows.dialogs.publishTitle') }}</h3>
<button class="modal-close" @click="close" aria-label="close">×</button>
</div>
<div class="modal-body">
<p class="publish-hint">{{ t('workflows.dialogs.publishHint') }}</p>
<div class="form-group">
<label for="wf-publish-note">{{ t('workflows.dialogs.publishNote') }}</label>
<textarea
id="wf-publish-note"
ref="noteInput"
v-model="form.note"
class="form-input form-textarea"
:placeholder="t('workflows.dialogs.publishNotePlaceholder')"
maxlength="500"
rows="3"
/>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="close">{{ t('common.cancel') }}</button>
<button class="btn-primary" :disabled="loading" @click="handleSubmit">
{{ loading ? t('common.loading') : t('workflows.actions.publish') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { nextTick, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElDialog, ElForm, ElFormItem, ElInput, ElButton } from 'element-plus'
interface Props {
modelValue: boolean
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), { loading: false })
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
@ -50,17 +50,28 @@ const { t } = useI18n()
const visible = ref(props.modelValue)
const form = reactive({ note: '' })
const noteInput = ref<HTMLTextAreaElement | null>(null)
watch(
() => props.modelValue,
(open) => {
async (open) => {
visible.value = open
if (open) form.note = ''
if (open) {
form.note = ''
await nextTick()
noteInput.value?.focus()
document.addEventListener('keydown', onKey)
} else {
document.removeEventListener('keydown', onKey)
}
}
)
watch(visible, (v) => emit('update:modelValue', v))
function handleClose() {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && visible.value) close()
}
function close() {
visible.value = false
}
function handleSubmit() {
@ -69,9 +80,147 @@ function handleSubmit() {
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 10, 8, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 2100;
animation: fadeIn 0.15s ease;
}
.modal {
width: 480px;
max-width: 100%;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
box-shadow: 0 16px 48px rgba(25, 14, 8, 0.18);
overflow: hidden;
animation: slideUp 0.2s ease;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--mc-border-light);
}
.modal-header h3 {
font-size: 16px;
font-weight: 600;
color: var(--mc-text-primary);
margin: 0;
}
.modal-close {
width: 26px;
height: 26px;
border: none;
background: none;
color: var(--mc-text-tertiary);
font-size: 22px;
line-height: 1;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.modal-close:hover {
background: var(--mc-bg-muted);
color: var(--mc-text-primary);
}
.modal-body {
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.publish-hint {
margin: 0 0 12px;
font-size: 12px;
opacity: 0.75;
font-size: 12.5px;
color: var(--mc-text-secondary);
margin: 0;
line-height: 1.5;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 12.5px;
font-weight: 500;
color: var(--mc-text-secondary);
}
.form-input {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--mc-border);
border-radius: 8px;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
font-size: 13.5px;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
font-family: inherit;
}
.form-input:focus {
outline: none;
border-color: var(--mc-primary);
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
}
.form-textarea {
resize: vertical;
font-family: inherit;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px 16px;
border-top: 1px solid var(--mc-border-light);
}
.btn-primary,
.btn-secondary {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.btn-primary {
background: var(--mc-primary);
color: var(--mc-text-inverse, #ffffff);
border-color: var(--mc-primary);
}
.btn-primary:hover:not(:disabled) {
background: var(--mc-primary-hover, var(--mc-primary));
}
.btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
color: var(--mc-text-secondary);
border-color: var(--mc-border);
}
.btn-secondary:hover {
background: var(--mc-bg-muted);
color: var(--mc-text-primary);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>

View File

@ -0,0 +1,460 @@
<template>
<aside class="step-panel" v-if="step">
<header class="panel-header">
<span class="panel-title">{{ t('workflows.canvas.inspector.title') }}</span>
<div class="panel-actions">
<button class="panel-btn" :title="t('workflows.canvas.inspector.duplicate')" @click="onDuplicate">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button class="panel-btn danger" :title="t('workflows.canvas.inspector.delete')" @click="onDelete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-2 14H7L5 6"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div>
</header>
<!-- Shared fields every mode honors. -->
<fieldset class="panel-section">
<legend>{{ t('workflows.canvas.inspector.shared') }}</legend>
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.name') }}</span>
<input
class="mc-input"
:value="step.name ?? ''"
@input="patch({ name: ($event.target as HTMLInputElement).value })"
spellcheck="false"
:placeholder="t('workflows.canvas.fields.namePlaceholder')"
/>
</label>
<label class="panel-field" v-if="modeNeedsAgent">
<span class="field-label">{{ t('workflows.canvas.nodeAgent') }}</span>
<input
class="mc-input"
:value="step.agentName ?? ''"
@input="patch({ agentName: ($event.target as HTMLInputElement).value })"
spellcheck="false"
:placeholder="t('workflows.canvas.fields.agentPlaceholder')"
/>
</label>
<label class="panel-field" v-if="modeNeedsAgent">
<span class="field-label">{{ t('workflows.canvas.fields.promptTemplate') }}</span>
<textarea
class="mc-textarea"
:value="step.promptTemplate ?? ''"
@input="patch({ promptTemplate: ($event.target as HTMLTextAreaElement).value })"
spellcheck="false"
rows="3"
:placeholder="t('workflows.canvas.fields.promptPlaceholder')"
/>
</label>
<div class="panel-row" v-if="modeNeedsAgent">
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.outputVar') }}</span>
<input
class="mc-input"
:value="step.outputVar ?? ''"
@input="patch({ outputVar: ($event.target as HTMLInputElement).value })"
spellcheck="false"
:placeholder="t('workflows.canvas.fields.outputVarPlaceholder')"
/>
</label>
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.outputContentType') }}</span>
<select
class="mc-input"
:value="step.outputContentType ?? 'text'"
@change="patch({ outputContentType: ($event.target as HTMLSelectElement).value })"
>
<option value="text">text</option>
<option value="json">json</option>
<option value="bytes">bytes</option>
</select>
</label>
</div>
</fieldset>
<!-- Per-mode editor. -->
<fieldset class="panel-section">
<legend>{{ t('workflows.canvas.inspector.modeFields', { mode: localizedModeLabel }) }}</legend>
<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>
</select>
</label>
<!-- conditional -->
<label class="panel-field" v-if="modeType === 'conditional'">
<span class="field-label">{{ t('workflows.canvas.nodeExpression') }}</span>
<input
class="mc-input mono"
:value="modeField('expression', '')"
@input="patchMode({ expression: ($event.target as HTMLInputElement).value })"
spellcheck="false"
placeholder="{{ inputs.payload != null }}"
/>
</label>
<!-- await_approval -->
<template v-if="modeType === 'await_approval'">
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.approvalKind') }}</span>
<input
class="mc-input"
:value="modeField('approvalKind', '')"
@input="patchMode({ approvalKind: ($event.target as HTMLInputElement).value })"
spellcheck="false"
placeholder="manual / manager / oncall"
/>
</label>
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.approverChannels') }}</span>
<input
class="mc-input"
:value="(modeField('approverChannels', []) as string[]).join(', ')"
@change="patchMode({ approverChannels: parseList(($event.target as HTMLInputElement).value) })"
spellcheck="false"
placeholder="web, feishu"
/>
</label>
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.approvalMessage') }}</span>
<input
class="mc-input"
:value="modeField('approvalMessage', '')"
@input="patchMode({ approvalMessage: ($event.target as HTMLInputElement).value })"
spellcheck="false"
/>
</label>
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.timeoutSecs') }}</span>
<input
type="number"
class="mc-input"
:value="modeField('timeoutSecs', 3600)"
@input="patchMode({ timeoutSecs: parseInt(($event.target as HTMLInputElement).value, 10) || null })"
min="0"
/>
</label>
</template>
<!-- dispatch_channel -->
<template v-if="modeType === 'dispatch_channel'">
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.channels') }}</span>
<input
class="mc-input"
:value="(modeField('channels', []) as string[]).join(', ')"
@change="patchMode({ channels: parseList(($event.target as HTMLInputElement).value) })"
spellcheck="false"
placeholder="feishu, dingtalk"
/>
</label>
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.dispatchContent') }}</span>
<textarea
class="mc-textarea"
:value="modeField('content', '')"
@input="patchMode({ content: ($event.target as HTMLTextAreaElement).value })"
rows="3"
spellcheck="false"
placeholder="Notification: {{ inputs.payload }}"
/>
</label>
</template>
<!-- write_memory -->
<template v-if="modeType === 'write_memory'">
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.employeeId') }}</span>
<input
class="mc-input"
:value="modeField('employeeId', '')"
@input="patchMode({ employeeId: ($event.target as HTMLInputElement).value })"
spellcheck="false"
/>
</label>
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.memoryFile') }}</span>
<input
class="mc-input"
:value="modeField('file', '')"
@input="patchMode({ file: ($event.target as HTMLInputElement).value })"
spellcheck="false"
placeholder="workspace.md"
/>
</label>
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.nodeMergeStrategy') }}</span>
<select
class="mc-input"
:value="modeField('mergeStrategy', 'append')"
@change="patchMode({ mergeStrategy: ($event.target as HTMLSelectElement).value })"
>
<option value="append">append</option>
<option value="prepend">prepend</option>
<option value="replace_section">replace_section</option>
<option value="overwrite">overwrite</option>
</select>
</label>
<label class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.memoryContent') }}</span>
<textarea
class="mc-textarea"
:value="modeField('content', '')"
@input="patchMode({ content: ($event.target as HTMLTextAreaElement).value })"
rows="3"
spellcheck="false"
/>
</label>
</template>
<p v-if="modeType === 'fan_out' || modeType === 'collect' || modeType === 'sequential'" class="panel-hint">
{{ t('workflows.canvas.inspector.modeNoFields') }}
</p>
</fieldset>
<details class="panel-section raw-section">
<summary>{{ t('workflows.canvas.inspector.rawHeader') }}</summary>
<pre class="raw-json">{{ rawJson }}</pre>
</details>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RawStep } from '@/composables/useWorkflowGraph'
interface Props {
/** The step the panel currently edits, or null. */
step: RawStep | null
/** Index of the step inside `steps[]` — used by the parent to scope patches. */
index: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'patch', payload: { index: number; patch: Partial<RawStep> }): void
(e: 'delete', payload: { index: number }): void
(e: 'duplicate', payload: { index: number }): void
}>()
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
})
const modeNeedsAgent = computed(() => {
// fan_out / collect / await_approval / dispatch_channel / write_memory
// don't take an agent the runtime calls a service adapter instead.
return ['sequential', 'conditional'].includes(modeType.value)
})
const rawJson = computed(() => {
try { return JSON.stringify(props.step ?? {}, null, 2) } catch { return '' }
})
function modeField<T>(key: string, fallback: T): T {
const v = props.step?.mode?.[key as keyof typeof props.step.mode]
return (v ?? fallback) as T
}
function parseList(raw: string): string[] {
return raw.split(',').map((s) => s.trim()).filter(Boolean)
}
function patch(p: Partial<RawStep>) {
emit('patch', { index: props.index, patch: p })
}
function patchMode(modePatch: Record<string, unknown>) {
emit('patch', {
index: props.index,
patch: { mode: { ...(props.step?.mode ?? {}), ...modePatch } as RawStep['mode'] },
})
}
function onModeChange(e: Event) {
const next = (e.target as HTMLSelectElement).value
// Reset mode-only fields when the type changes keep `type` as the
// single carry-over so the schema validator doesn't complain about
// stale fields like `expression` lingering on a sequential step.
emit('patch', {
index: props.index,
patch: { mode: { type: next } as RawStep['mode'] },
})
}
function onDelete() {
emit('delete', { index: props.index })
}
function onDuplicate() {
emit('duplicate', { index: props.index })
}
</script>
<style scoped>
.step-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 10px 12px;
background: var(--mc-bg-elevated, #ffffff);
border: 1px solid var(--mc-border-light, rgba(0, 0, 0, 0.08));
border-radius: 8px;
color: var(--mc-text-primary, inherit);
overflow-y: auto;
font-size: 12.5px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border-bottom: 1px solid var(--mc-border-light, rgba(0, 0, 0, 0.06));
padding-bottom: 8px;
}
.panel-title {
font-weight: 600;
font-size: 13px;
}
.panel-actions {
display: flex;
gap: 4px;
}
.panel-btn {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 5px;
border: 1px solid var(--mc-border-light, rgba(0, 0, 0, 0.08));
background: transparent;
color: var(--mc-text-secondary, inherit);
cursor: pointer;
}
.panel-btn:hover {
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.04));
color: var(--mc-text-primary, inherit);
}
.panel-btn.danger:hover {
color: var(--mc-danger, #c0392b);
border-color: var(--mc-danger-border, rgba(231, 76, 60, 0.4));
}
.panel-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 0 0;
border: none;
margin: 0;
}
.panel-section legend {
font-size: 10.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--mc-text-tertiary, #888);
font-weight: 600;
padding: 0;
margin-bottom: 2px;
}
:lang(zh-CN) .panel-section legend {
text-transform: none;
}
.panel-field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12px;
}
.panel-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.field-label {
font-size: 10.5px;
letter-spacing: 0.04em;
color: var(--mc-text-tertiary, #888);
}
:lang(en) .field-label {
text-transform: uppercase;
}
.mc-input,
.mc-textarea {
padding: 6px 8px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
border-radius: 5px;
background: var(--mc-bg, transparent);
color: inherit;
font: inherit;
font-size: 12.5px;
outline: none;
transition: border-color 0.12s ease;
width: 100%;
}
.mc-input:focus,
.mc-textarea:focus {
border-color: var(--mc-primary, #4084ff);
}
.mc-textarea {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11.5px;
resize: vertical;
}
.mc-input.mono {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11.5px;
}
.panel-hint {
font-size: 11.5px;
color: var(--mc-text-tertiary, #888);
font-style: italic;
margin: 0;
}
.raw-section summary {
font-size: 11px;
color: var(--mc-text-tertiary, #888);
cursor: pointer;
padding: 4px 0;
}
.raw-json {
margin: 4px 0 0;
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 10.5px;
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.04));
border-radius: 4px;
padding: 8px;
white-space: pre-wrap;
word-break: break-word;
max-height: 220px;
overflow: auto;
}
</style>

View File

@ -0,0 +1,133 @@
import type { RawStep, RawWorkflow } from './useWorkflowGraph'
/**
* Surgical helpers for editing the workflow draft JSON without going
* through a serialize-deserialize round-trip on every keystroke.
*
* Each helper preserves unknown fields (e.g. `outputVar`, `timeoutSecs`,
* future schema additions) by deep-cloning the original object and only
* overwriting the fields the caller specifies vs. constructing a
* fresh step shape that would silently drop anything we forgot to copy.
*/
export interface DraftDoc {
/** Parsed `{ steps: [...] }` JSON. Always has a `steps` array,
* even when the source was empty / malformed. */
raw: RawWorkflow
/** True when the source string parsed cleanly; callers should treat
* a false here as "do not write back, surface the parse error". */
ok: boolean
/** Parser error message when `ok === false`; else null. */
error: string | null
}
export function parseDraft(json: string): DraftDoc {
const fallback: RawWorkflow = { steps: [] }
if (!json || !json.trim()) return { raw: fallback, ok: true, error: null }
try {
const parsed = JSON.parse(json) as RawWorkflow
if (!parsed || typeof parsed !== 'object') {
return { raw: fallback, ok: false, error: 'workflow root must be an object' }
}
if (!Array.isArray(parsed.steps)) parsed.steps = []
return { raw: parsed, ok: true, error: null }
} catch (e) {
return { raw: fallback, ok: false, error: (e as Error).message }
}
}
export function serializeDraft(doc: RawWorkflow): string {
// Pretty-printed with 2 spaces matches the templates dropdown output
// and what the operator sees in the JSON tab — keeps the diffs clean
// when a single field changes.
return JSON.stringify(doc, null, 2)
}
/** Returns a deep clone so the caller can mutate without affecting the
* original. Falls back to a shallow strategy if structuredClone isn't
* available (very old browsers). */
function clone<T>(value: T): T {
if (typeof structuredClone === 'function') return structuredClone(value)
return JSON.parse(JSON.stringify(value))
}
/**
* Merge a sparse patch into one step at {@code index}, preserving the
* step's unknown fields. Returns a new draft string (always re-serialised
* even when the patch was a no-op so callers can safely write back).
*/
export function updateStepAtIndex(
json: string,
index: number,
patch: Partial<RawStep>
): string {
const doc = parseDraft(json)
if (!doc.ok || !Array.isArray(doc.raw.steps) || index < 0 || index >= doc.raw.steps.length) {
return json
}
const next = clone(doc.raw)
const cur = next.steps![index] as RawStep
// Mode field is the only one where a sub-patch should also merge
// (e.g. just changing `expression` shouldn't drop `type`). For the
// shared scalar fields a flat overwrite is correct.
if (patch.mode && cur.mode) {
next.steps![index] = {
...cur,
...patch,
mode: { ...cur.mode, ...patch.mode },
}
} else {
next.steps![index] = { ...cur, ...patch }
}
return serializeDraft(next)
}
/** Insert a fresh step right after the one at {@code afterIndex}. Pass
* -1 to prepend at position 0. Returns a new draft string. */
export function insertStepAfter(json: string, afterIndex: number, step: RawStep): string {
const doc = parseDraft(json)
if (!doc.ok) return json
const next = clone(doc.raw)
const arr = Array.isArray(next.steps) ? next.steps! : (next.steps = [], next.steps!)
const insertAt = afterIndex < 0 ? 0 : Math.min(arr.length, afterIndex + 1)
arr.splice(insertAt, 0, step)
return serializeDraft(next)
}
/** Drop one step. Returns the new draft (or the original on bounds
* violations / parse failures). */
export function deleteStepAtIndex(json: string, index: number): string {
const doc = parseDraft(json)
if (!doc.ok || !Array.isArray(doc.raw.steps)
|| index < 0 || index >= doc.raw.steps.length) {
return json
}
const next = clone(doc.raw)
next.steps!.splice(index, 1)
return serializeDraft(next)
}
/** Duplicate one step right after itself, suffixing the name with
* `-copy` so the editor's de-dup-on-name UK doesn't immediately fail. */
export function duplicateStepAtIndex(json: string, index: number): string {
const doc = parseDraft(json)
if (!doc.ok || !Array.isArray(doc.raw.steps)
|| index < 0 || index >= doc.raw.steps.length) {
return json
}
const original = doc.raw.steps![index] as RawStep
const copy: RawStep = clone(original)
if (typeof copy.name === 'string') copy.name = `${copy.name}-copy`
return insertStepAfter(json, index, copy)
}
/** Resolve the step at {@code index} in the given JSON without
* mutating it. Returns null on bounds / parse error. */
export function readStepAtIndex(json: string, index: number): RawStep | null {
const doc = parseDraft(json)
if (!doc.ok || !Array.isArray(doc.raw.steps)
|| index < 0 || index >= doc.raw.steps.length) {
return null
}
return doc.raw.steps![index] as RawStep
}

View File

@ -1953,6 +1953,7 @@ export default {
parseError: 'JSON parse failed: {msg}',
fullscreenEnter: 'Fullscreen',
fullscreenExit: 'Exit fullscreen',
modeLabel: 'Mode',
nodeAgent: 'Agent',
nodeExpression: 'Condition',
nodeApprovalKind: 'Approval',
@ -1970,9 +1971,33 @@ export default {
write_memory: 'write memory',
},
inspector: {
title: 'Step inspector',
title: 'Step properties',
empty: 'Click a node on the canvas to inspect it.',
rawHeader: 'Raw step JSON',
shared: 'Shared fields',
modeFields: '{mode} fields',
modeNoFields: 'This mode takes no extra fields.',
duplicate: 'Duplicate step',
delete: 'Delete step',
},
fields: {
name: 'Name',
namePlaceholder: 'step-name',
agentPlaceholder: 'agent-name',
promptTemplate: 'Prompt template',
promptPlaceholder: 'Hello {{ inputs.payload }}',
outputVar: 'Output variable',
outputVarPlaceholder: 'data',
outputContentType: 'Output type',
approvalKind: 'Approval kind',
approverChannels: 'Approver channels (comma-sep)',
approvalMessage: 'Approval message',
timeoutSecs: 'Timeout (s)',
channels: 'Channels (comma-sep)',
dispatchContent: 'Message content',
employeeId: 'Employee ID',
memoryFile: 'Memory file',
memoryContent: 'Content template',
},
},
dialogs: {

View File

@ -1965,6 +1965,7 @@ export default {
parseError: 'JSON 解析失败:{msg}',
fullscreenEnter: '全屏',
fullscreenExit: '退出全屏',
modeLabel: '模式',
nodeAgent: '智能体',
nodeExpression: '条件',
nodeApprovalKind: '审批类型',
@ -1982,9 +1983,33 @@ export default {
write_memory: '写入记忆',
},
inspector: {
title: '步骤详情',
title: '步骤属性',
empty: '点击画布上的节点查看详情。',
rawHeader: '原始 JSON',
shared: '通用字段',
modeFields: '{mode} 字段',
modeNoFields: '此模式没有额外字段。',
duplicate: '复制此步骤',
delete: '删除此步骤',
},
fields: {
name: '名称',
namePlaceholder: 'step-name',
agentPlaceholder: '智能体名称',
promptTemplate: 'Prompt 模板',
promptPlaceholder: 'Hello {{ inputs.payload }}',
outputVar: '输出变量名',
outputVarPlaceholder: 'data',
outputContentType: '输出类型',
approvalKind: '审批种类',
approverChannels: '通知渠道(逗号分隔)',
approvalMessage: '审批消息',
timeoutSecs: '超时(秒)',
channels: '渠道(逗号分隔)',
dispatchContent: '消息内容',
employeeId: '员工 ID',
memoryFile: '记忆文件',
memoryContent: '内容模板',
},
},
dialogs: {

View File

@ -78,12 +78,14 @@
<small class="pattern-hint">{{ patternHint }}</small>
</label>
<label>{{ t('triggers.fields.targetType') }}
<select v-model="formState.targetType">
<!-- v0 only ships the workflow dispatcher; agent target is
reserved for v1 and rejected by the API to avoid the
"looks enabled, never fires" trap. -->
<select v-model="formState.targetType" disabled>
<option value="workflow">workflow</option>
<option value="agent">agent</option>
</select>
</label>
<label v-if="formState.targetType === 'workflow'">{{ t('triggers.fields.targetId') }}
<label>{{ t('triggers.fields.targetId') }}
<select v-model.number="formState.targetId">
<option v-if="!availableWorkflows.length" :value="0">
{{ t('triggers.targetWorkflowEmpty') }}
@ -93,9 +95,6 @@
</option>
</select>
</label>
<label v-else>{{ t('triggers.fields.targetId') }}
<input v-model.number="formState.targetId" type="number" />
</label>
<label>{{ t('triggers.fields.ratePerMin') }}
<input v-model.number="formState.rateLimitPerMin" type="number" />
</label>
@ -131,6 +130,8 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
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'
@ -258,7 +259,7 @@ async function save() {
formOpen.value = false
await reload()
} catch (e) {
window.alert(t('triggers.saveFailed', { msg: (e as Error).message }))
ElMessage.error(t('triggers.saveFailed', { msg: (e as Error).message }))
} finally {
busy.value = false
}
@ -269,17 +270,23 @@ async function toggleEnabled(row: TriggerSummary) {
await triggerApi.update(row.id, { ...row, enabled: !row.enabled })
await reload()
} catch (e) {
window.alert(t('triggers.toggleFailed', { msg: (e as Error).message }))
ElMessage.error(t('triggers.toggleFailed', { msg: (e as Error).message }))
}
}
async function remove(row: TriggerSummary) {
if (!window.confirm(t('triggers.deleteConfirm', { name: row.name || String(row.id) }))) return
const ok = await mcConfirm({
title: t('triggers.actions.delete'),
message: t('triggers.deleteConfirm', { name: row.name || String(row.id) }),
confirmText: t('triggers.actions.delete'),
tone: 'danger',
})
if (!ok) return
try {
await triggerApi.delete(row.id)
await reload()
} catch (e) {
window.alert(t('triggers.deleteFailed', { msg: (e as Error).message }))
ElMessage.error(t('triggers.deleteFailed', { msg: (e as Error).message }))
}
}

View File

@ -66,21 +66,14 @@
:canvas-id="`wf-${selected.id}`"
@select-step="onCanvasSelect"
/>
<aside v-if="canvasSelection" class="canvas-inspector">
<header>{{ t('workflows.canvas.inspector.title') }}</header>
<dl class="inspector-grid">
<dt>{{ t('workflows.dialogs.fieldName') }}</dt><dd>{{ canvasSelection.name }}</dd>
<dt>mode</dt><dd>{{ canvasSelection.modeType }}</dd>
<dt v-if="canvasSelection.agentName">{{ t('workflows.canvas.nodeAgent') }}</dt>
<dd v-if="canvasSelection.agentName">{{ canvasSelection.agentName }}</dd>
<dt v-if="canvasSelection.expression">{{ t('workflows.canvas.nodeExpression') }}</dt>
<dd v-if="canvasSelection.expression"><code>{{ canvasSelection.expression }}</code></dd>
</dl>
<details>
<summary>{{ t('workflows.canvas.inspector.rawHeader') }}</summary>
<pre class="inspector-raw">{{ inspectorJson }}</pre>
</details>
</aside>
<StepPropertyPanel
v-if="canvasSelection"
:step="selectedStep"
:index="canvasSelection.index"
@patch="onStepPatch"
@duplicate="onStepDuplicate"
@delete="onStepDelete"
/>
</div>
<div v-else class="json-pane">
@ -171,7 +164,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessageBox } from 'element-plus'
import { mcConfirm } from '@/components/common/useConfirm'
import {
workflowApi,
type WorkflowSummary,
@ -182,9 +175,16 @@ import {
} from '@/api'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import WorkflowCanvas from '@/components/workflow/WorkflowCanvas.vue'
import StepPropertyPanel from '@/components/workflow/StepPropertyPanel.vue'
import CreateWorkflowDialog from '@/components/workflow/CreateWorkflowDialog.vue'
import PublishDialog from '@/components/workflow/PublishDialog.vue'
import type { StepNodeData } from '@/composables/useWorkflowGraph'
import type { StepNodeData, RawStep } from '@/composables/useWorkflowGraph'
import {
readStepAtIndex,
updateStepAtIndex,
deleteStepAtIndex,
duplicateStepAtIndex,
} from '@/composables/useWorkflowDraft'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
@ -222,15 +222,32 @@ const canvasSelection = ref<StepNodeData | null>(null)
function onCanvasSelect(payload: StepNodeData | null) {
canvasSelection.value = payload
}
const inspectorJson = computed(() => {
if (!canvasSelection.value) return ''
try {
return JSON.stringify(canvasSelection.value.raw, null, 2)
} catch {
return ''
}
// The currently-selected step, re-resolved from the JSON whenever the
// JSON changes this is what keeps the property panel in sync after
// the user edits a field. Falls back to the StepNodeData snapshot the
// canvas captured if the JSON parse fails (e.g. mid-edit invalid JSON).
const selectedStep = computed<RawStep | null>(() => {
const sel = canvasSelection.value
if (!sel) return null
const live = readStepAtIndex(draftJson.value, sel.index)
return live ?? (sel.raw as RawStep)
})
function onStepPatch(payload: { index: number; patch: Partial<RawStep> }) {
draftJson.value = updateStepAtIndex(draftJson.value, payload.index, payload.patch)
// Keep the selection alive so the panel doesn't blink between renders.
// The re-derived selectedStep computed will pick up the new fields.
}
function onStepDuplicate(payload: { index: number }) {
draftJson.value = duplicateStepAtIndex(draftJson.value, payload.index)
}
function onStepDelete(payload: { index: number }) {
draftJson.value = deleteStepAtIndex(draftJson.value, payload.index)
canvasSelection.value = null
}
const createDialogOpen = ref(false)
const publishDialogOpen = ref(false)
@ -277,6 +294,11 @@ const STEP_TEMPLATES: Record<string, object> = {
mode: {
type: 'await_approval',
approvalKind: 'manual',
// approverChannels is required by the schema validator. Default
// to ['web'] the operator UI surface so an inserted template
// compiles right away. Authors can swap to feishu / dingtalk /
// wecom etc. once they wire those channels.
approverChannels: ['web'],
approvalMessage: 'Please review and approve',
timeoutSecs: 3600,
},
@ -486,20 +508,13 @@ async function onPublishSubmit(payload: { note: string }) {
async function remove() {
if (!selected.value) return
const name = selected.value.name ?? ''
try {
await ElMessageBox.confirm(
t('workflows.dialogs.deleteContent', { name }),
t('workflows.dialogs.deleteTitle'),
{
confirmButtonText: t('workflows.actions.delete'),
cancelButtonText: t('common.cancel'),
type: 'warning',
}
)
} catch {
// user cancelled nothing to do
return
}
const ok = await mcConfirm({
title: t('workflows.dialogs.deleteTitle'),
message: t('workflows.dialogs.deleteContent', { name }),
confirmText: t('workflows.actions.delete'),
tone: 'danger',
})
if (!ok) return
busy.value = true
try {
await workflowApi.delete(selected.value.id)
@ -874,8 +889,15 @@ button:disabled {
flex: 1 1 auto;
min-width: 0;
}
.canvas-pane > .canvas-inspector {
flex: 0 0 240px;
.canvas-pane > .step-panel {
flex: 0 0 280px;
max-height: 540px;
}
@media (max-width: 1100px) {
.canvas-pane > .step-panel {
flex: 0 0 auto;
max-height: 360px;
}
}
.canvas-inspector {
background: var(--mc-bg-elevated, rgba(0, 0, 0, 0.02));