mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(workflow): trigger workspace isolation + step property editor + custom dialogs
This commit is contained in:
parent
789af09772
commit
dac774f15e
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
460
mateclaw-ui/src/components/workflow/StepPropertyPanel.vue
Normal file
460
mateclaw-ui/src/components/workflow/StepPropertyPanel.vue
Normal 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>
|
||||
133
mateclaw-ui/src/composables/useWorkflowDraft.ts
Normal file
133
mateclaw-ui/src/composables/useWorkflowDraft.ts
Normal 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
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user