mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui): i18n + step templates for workflow editor; trigger UX polish
This commit is contained in:
parent
20884c1773
commit
2a25f4fcfb
@ -301,6 +301,8 @@ export default {
|
||||
security: 'Security',
|
||||
tokenUsage: 'Token Usage',
|
||||
cronJobs: 'Cron Jobs',
|
||||
workflows: 'Workflows',
|
||||
triggers: 'Triggers',
|
||||
settings: 'Settings',
|
||||
logout: 'Logout',
|
||||
memory: 'Memory',
|
||||
@ -1904,6 +1906,129 @@ export default {
|
||||
goalRequired: 'Goal is required',
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
kicker: 'Workflows',
|
||||
title: 'Workflow Editor',
|
||||
desc: 'Author multi-step workflows. Save the draft as you go, compile to surface diagnostics, then publish a revision when it is ready to run.',
|
||||
newWorkflow: '+ New Workflow',
|
||||
defined: 'Defined ({count})',
|
||||
refresh: 'Refresh',
|
||||
unnamed: '(unnamed)',
|
||||
draftBadge: 'draft',
|
||||
publishedBadge: 'v{rev}',
|
||||
listEmpty: 'No workflows yet — create one to get started.',
|
||||
namePlaceholder: 'Workflow name',
|
||||
descPlaceholder: 'Optional description',
|
||||
actions: {
|
||||
saveMeta: 'Save Meta',
|
||||
saveDraft: 'Save Draft',
|
||||
compile: 'Compile',
|
||||
publish: 'Publish',
|
||||
delete: 'Delete',
|
||||
},
|
||||
bodyPlaceholder: '{"steps":[{"name":"step-a","agentName":"...","mode":{"type":"sequential"},"promptTemplate":"..."}]}',
|
||||
compileErrorsTitle: '{count} compile error(s)',
|
||||
templates: {
|
||||
label: 'Insert template',
|
||||
placeholder: 'Pick a step template',
|
||||
sequential: 'sequential (linear step)',
|
||||
fan_out: 'fan_out (parallel)',
|
||||
collect: 'collect (join fan_out)',
|
||||
conditional: 'conditional (gated)',
|
||||
await_approval: 'await_approval (pause)',
|
||||
dispatch_channel: 'dispatch_channel (broadcast)',
|
||||
write_memory: 'write_memory (persist)',
|
||||
},
|
||||
jsonInvalid: 'JSON parse failed: {msg}',
|
||||
jsonOk: 'JSON syntax OK.',
|
||||
selectHint: 'Select a workflow on the left, or click "New Workflow" to start a fresh draft.',
|
||||
runs: {
|
||||
header: 'Recent runs ({count})',
|
||||
empty: 'No runs yet.',
|
||||
detailTitle: 'Run #{id} — {state}',
|
||||
runHash: 'run #{id}',
|
||||
},
|
||||
prompts: {
|
||||
newName: 'New workflow name:',
|
||||
defaultName: 'untitled-workflow',
|
||||
publishNote: 'Optional publish note:',
|
||||
deleteConfirm: 'Delete workflow "{name}"? This is reversible via the audit log.',
|
||||
},
|
||||
status: {
|
||||
metaSaved: 'Metadata saved.',
|
||||
draftSaved: 'Draft saved.',
|
||||
compileOk: 'Compile OK.',
|
||||
published: 'Published.',
|
||||
deleted: 'Deleted.',
|
||||
createFailed: 'Create failed: {msg}',
|
||||
saveFailed: 'Save failed: {msg}',
|
||||
saveDraftFailed: 'Save draft failed: {msg}',
|
||||
deleteFailed: 'Delete failed: {msg}',
|
||||
compileFailed: '{count} compile error(s) — see panel below.',
|
||||
compileFallback: 'Compile / publish failed.',
|
||||
},
|
||||
},
|
||||
triggers: {
|
||||
kicker: 'Triggers',
|
||||
title: 'Workflow Triggers',
|
||||
desc: 'Connect cron schedules and channel events to your published workflows. pattern_version increments on every cron expression change so multi-node deployments coordinate correctly.',
|
||||
newTrigger: '+ New Trigger',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
pattern: 'Pattern',
|
||||
target: 'Target Workflow',
|
||||
rate: 'Rate',
|
||||
fires: 'Fires',
|
||||
patternVersion: 'pattern_v',
|
||||
lastFired: 'Last fired',
|
||||
state: 'State',
|
||||
actions: 'Actions',
|
||||
},
|
||||
targetWorkflowSelect: 'Pick a published workflow',
|
||||
targetWorkflowEmpty: 'No published workflows yet.',
|
||||
patternHints: {
|
||||
cron: 'Example: {"cron":"0 0 * * * *","timezone":"UTC"} — every cron change bumps pattern_version.',
|
||||
channel_message: 'Optional channelType (e.g. feishu) and senderEquals narrow the match.',
|
||||
webhook: 'Pass-through match. Ensure the HTTP entry validates the webhook secret.',
|
||||
content_match: 'Requires substring (case-insensitive). Empty strings refuse to fire.',
|
||||
agent_lifecycle: 'Optional agentId and phase (spawned / terminated / crashed).',
|
||||
workflow_completion: 'Optional sourceWorkflowId and stateFilter (completed / failed / any).',
|
||||
},
|
||||
unnamed: '(unnamed)',
|
||||
rateUnit: '{count}/min',
|
||||
enabled: 'enabled',
|
||||
disabled: 'disabled',
|
||||
empty: 'No triggers configured.',
|
||||
actions: {
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
close: 'Close',
|
||||
},
|
||||
formTitleNew: 'New Trigger',
|
||||
formTitleEdit: 'Edit Trigger',
|
||||
fields: {
|
||||
name: 'Name',
|
||||
namePlaceholder: 'hourly-cleanup',
|
||||
patternType: 'Pattern type',
|
||||
patternJson: 'Pattern JSON',
|
||||
patternJsonPlaceholder: '{"cron":"0 0 * * * *","timezone":"UTC"}',
|
||||
targetType: 'Target type',
|
||||
targetId: 'Target id',
|
||||
ratePerMin: 'Rate / min',
|
||||
dedupWindowSecs: 'Dedup window (s)',
|
||||
maxFires: 'Max fires (0 = unlimited)',
|
||||
botSelfFilter: 'Bot-self filter (recommended)',
|
||||
payloadTemplate: 'Payload template (Pebble)',
|
||||
payloadTemplatePlaceholder: '{"who":"{{ event.who }}"}',
|
||||
enabled: 'Enabled',
|
||||
},
|
||||
deleteConfirm: 'Delete trigger "{name}"?',
|
||||
saveFailed: 'Save failed: {msg}',
|
||||
toggleFailed: 'Toggle failed: {msg}',
|
||||
deleteFailed: 'Delete failed: {msg}',
|
||||
},
|
||||
login: {
|
||||
kicker: 'AI Operating System',
|
||||
subtitle: 'Your intelligent AI assistant',
|
||||
|
||||
@ -301,6 +301,8 @@ export default {
|
||||
security: '安全',
|
||||
tokenUsage: 'Token 统计',
|
||||
cronJobs: '定时任务',
|
||||
workflows: '工作流',
|
||||
triggers: '触发器',
|
||||
settings: '设置',
|
||||
logout: '退出登录',
|
||||
themeLight: '浅色',
|
||||
@ -1916,6 +1918,129 @@ export default {
|
||||
goalRequired: '执行目标不能为空',
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
kicker: '工作流',
|
||||
title: '工作流编辑器',
|
||||
desc: '编排多步骤工作流:边写边存草稿,编译查看诊断,准备好后发布版本。',
|
||||
newWorkflow: '+ 新建工作流',
|
||||
defined: '已定义 ({count})',
|
||||
refresh: '刷新',
|
||||
unnamed: '(未命名)',
|
||||
draftBadge: '草稿',
|
||||
publishedBadge: 'v{rev}',
|
||||
listEmpty: '还没有任何工作流,新建一个开始吧。',
|
||||
namePlaceholder: '工作流名称',
|
||||
descPlaceholder: '可选描述',
|
||||
actions: {
|
||||
saveMeta: '保存元数据',
|
||||
saveDraft: '保存草稿',
|
||||
compile: '编译',
|
||||
publish: '发布',
|
||||
delete: '删除',
|
||||
},
|
||||
bodyPlaceholder: '{"steps":[{"name":"step-a","agentName":"...","mode":{"type":"sequential"},"promptTemplate":"..."}]}',
|
||||
compileErrorsTitle: '{count} 条编译错误',
|
||||
templates: {
|
||||
label: '插入模板',
|
||||
placeholder: '选择步骤模板',
|
||||
sequential: 'sequential(顺序步骤)',
|
||||
fan_out: 'fan_out(并行扇出)',
|
||||
collect: 'collect(汇聚结果)',
|
||||
conditional: 'conditional(条件分支)',
|
||||
await_approval: 'await_approval(等待审批)',
|
||||
dispatch_channel: 'dispatch_channel(消息分发)',
|
||||
write_memory: 'write_memory(写入记忆)',
|
||||
},
|
||||
jsonInvalid: 'JSON 解析失败:{msg}',
|
||||
jsonOk: 'JSON 语法 OK。',
|
||||
selectHint: '在左侧选择一个工作流,或点击「新建工作流」开始。',
|
||||
runs: {
|
||||
header: '近期运行 ({count})',
|
||||
empty: '暂无运行记录。',
|
||||
detailTitle: 'Run #{id} — {state}',
|
||||
runHash: 'run #{id}',
|
||||
},
|
||||
prompts: {
|
||||
newName: '新工作流名称:',
|
||||
defaultName: 'untitled-workflow',
|
||||
publishNote: '可选发布备注:',
|
||||
deleteConfirm: '删除工作流「{name}」?此操作可通过审计日志撤销。',
|
||||
},
|
||||
status: {
|
||||
metaSaved: '元数据已保存。',
|
||||
draftSaved: '草稿已保存。',
|
||||
compileOk: '编译通过。',
|
||||
published: '已发布。',
|
||||
deleted: '已删除。',
|
||||
createFailed: '创建失败:{msg}',
|
||||
saveFailed: '保存失败:{msg}',
|
||||
saveDraftFailed: '草稿保存失败:{msg}',
|
||||
deleteFailed: '删除失败:{msg}',
|
||||
compileFailed: '{count} 条编译错误,详见下方面板。',
|
||||
compileFallback: '编译/发布失败。',
|
||||
},
|
||||
},
|
||||
triggers: {
|
||||
kicker: '触发器',
|
||||
title: '工作流触发器',
|
||||
desc: '把 cron 调度和渠道事件接到已发布的工作流上。每次修改 cron 表达式都会让 pattern_version 自增,多节点部署据此协调。',
|
||||
newTrigger: '+ 新建触发器',
|
||||
columns: {
|
||||
name: '名称',
|
||||
pattern: '模式',
|
||||
target: '目标工作流',
|
||||
rate: '速率',
|
||||
fires: '触发次数',
|
||||
patternVersion: 'pattern_v',
|
||||
lastFired: '上次触发',
|
||||
state: '状态',
|
||||
actions: '操作',
|
||||
},
|
||||
targetWorkflowSelect: '选择已发布的工作流',
|
||||
targetWorkflowEmpty: '尚无已发布的工作流。',
|
||||
patternHints: {
|
||||
cron: '示例:{"cron":"0 0 * * * *","timezone":"Asia/Shanghai"} —— 每次修改 cron 都会让 pattern_version 自增。',
|
||||
channel_message: '可填 channelType(如 feishu)和 senderEquals 进一步过滤。',
|
||||
webhook: '透传匹配,请确保 HTTP 入口已校验 webhook 密钥。',
|
||||
content_match: '需要 substring(不区分大小写)。空字符串将拒绝触发。',
|
||||
agent_lifecycle: '可选 agentId 和 phase(spawned / terminated / crashed)。',
|
||||
workflow_completion: '可选 sourceWorkflowId 和 stateFilter(completed / failed / any)。',
|
||||
},
|
||||
unnamed: '(未命名)',
|
||||
rateUnit: '{count}/分钟',
|
||||
enabled: '已启用',
|
||||
disabled: '已停用',
|
||||
empty: '暂无触发器配置。',
|
||||
actions: {
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
close: '关闭',
|
||||
},
|
||||
formTitleNew: '新建触发器',
|
||||
formTitleEdit: '编辑触发器',
|
||||
fields: {
|
||||
name: '名称',
|
||||
namePlaceholder: 'hourly-cleanup',
|
||||
patternType: '模式类型',
|
||||
patternJson: '模式 JSON',
|
||||
patternJsonPlaceholder: '{"cron":"0 0 * * * *","timezone":"UTC"}',
|
||||
targetType: '目标类型',
|
||||
targetId: '目标 ID',
|
||||
ratePerMin: '每分钟速率',
|
||||
dedupWindowSecs: '去重窗口(秒)',
|
||||
maxFires: '最大触发数(0 = 不限)',
|
||||
botSelfFilter: 'Bot 自循环过滤(推荐)',
|
||||
payloadTemplate: '载荷模板(Pebble)',
|
||||
payloadTemplatePlaceholder: '{"who":"{{ event.who }}"}',
|
||||
enabled: '启用',
|
||||
},
|
||||
deleteConfirm: '删除触发器「{name}」?',
|
||||
saveFailed: '保存失败:{msg}',
|
||||
toggleFailed: '切换失败:{msg}',
|
||||
deleteFailed: '删除失败:{msg}',
|
||||
},
|
||||
login: {
|
||||
kicker: 'AI Operating System',
|
||||
subtitle: '你的智能 AI 助手',
|
||||
|
||||
@ -4,52 +4,52 @@
|
||||
<div class="mc-page-inner triggers-page">
|
||||
<div class="mc-page-header">
|
||||
<div>
|
||||
<div class="mc-page-kicker">Triggers</div>
|
||||
<h1 class="mc-page-title">Workflow Triggers</h1>
|
||||
<p class="mc-page-desc">
|
||||
Connect cron schedules and channel events to your published workflows.
|
||||
Pattern_version increments on every cron expression change so multi-node
|
||||
deployments coordinate correctly.
|
||||
</p>
|
||||
<div class="mc-page-kicker">{{ t('triggers.kicker') }}</div>
|
||||
<h1 class="mc-page-title">{{ t('triggers.title') }}</h1>
|
||||
<p class="mc-page-desc">{{ t('triggers.desc') }}</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openCreate">+ New Trigger</button>
|
||||
<button class="btn-primary" @click="openCreate">{{ t('triggers.newTrigger') }}</button>
|
||||
</div>
|
||||
|
||||
<table class="triggers-table mc-surface-card">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Pattern</th>
|
||||
<th>Target Workflow</th>
|
||||
<th>Rate</th>
|
||||
<th>Fires</th>
|
||||
<th>State</th>
|
||||
<th>Actions</th>
|
||||
<th>{{ t('triggers.columns.name') }}</th>
|
||||
<th>{{ t('triggers.columns.pattern') }}</th>
|
||||
<th>{{ t('triggers.columns.target') }}</th>
|
||||
<th>{{ t('triggers.columns.rate') }}</th>
|
||||
<th>{{ t('triggers.columns.fires') }}</th>
|
||||
<th>{{ t('triggers.columns.patternVersion') }}</th>
|
||||
<th>{{ t('triggers.columns.lastFired') }}</th>
|
||||
<th>{{ t('triggers.columns.state') }}</th>
|
||||
<th>{{ t('triggers.columns.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in triggers" :key="t.id">
|
||||
<td>{{ t.name || '(unnamed)' }}</td>
|
||||
<tr v-for="row in triggers" :key="row.id">
|
||||
<td>{{ row.name || t('triggers.unnamed') }}</td>
|
||||
<td>
|
||||
<code>{{ t.patternType }}</code>
|
||||
<div class="pattern-detail">{{ t.patternJson }}</div>
|
||||
<code>{{ row.patternType }}</code>
|
||||
<div class="pattern-detail">{{ row.patternJson }}</div>
|
||||
</td>
|
||||
<td>{{ t.targetType }}#{{ t.targetId }}</td>
|
||||
<td>{{ t.rateLimitPerMin }}/min</td>
|
||||
<td>{{ t.fireCount }}<span v-if="t.maxFires > 0"> / {{ t.maxFires }}</span></td>
|
||||
<td>{{ formatTarget(row) }}</td>
|
||||
<td>{{ t('triggers.rateUnit', { count: row.rateLimitPerMin }) }}</td>
|
||||
<td>{{ row.fireCount }}<span v-if="row.maxFires > 0"> / {{ row.maxFires }}</span></td>
|
||||
<td>{{ row.patternVersion }}</td>
|
||||
<td>{{ formatTime(row.lastFiredAt) }}</td>
|
||||
<td>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" :checked="t.enabled" @change="toggleEnabled(t)" />
|
||||
<span>{{ t.enabled ? 'enabled' : 'disabled' }}</span>
|
||||
<input type="checkbox" :checked="row.enabled" @change="toggleEnabled(row)" />
|
||||
<span>{{ row.enabled ? t('triggers.enabled') : t('triggers.disabled') }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn-ghost" @click="openEdit(t)">Edit</button>
|
||||
<button class="btn-danger" @click="remove(t)">Delete</button>
|
||||
<button class="btn-ghost" @click="openEdit(row)">{{ t('triggers.actions.edit') }}</button>
|
||||
<button class="btn-danger" @click="remove(row)">{{ t('triggers.actions.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!triggers.length">
|
||||
<td colspan="7" class="empty-row">No triggers configured.</td>
|
||||
<td colspan="9" class="empty-row">{{ t('triggers.empty') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -57,12 +57,14 @@
|
||||
<!-- Inline form (replaces modal for v0 simplicity) -->
|
||||
<section v-if="formOpen" class="form-card mc-surface-card">
|
||||
<header>
|
||||
<strong>{{ editing?.id ? 'Edit Trigger' : 'New Trigger' }}</strong>
|
||||
<button class="btn-ghost" @click="closeForm">Close</button>
|
||||
<strong>{{ editing?.id ? t('triggers.formTitleEdit') : t('triggers.formTitleNew') }}</strong>
|
||||
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.close') }}</button>
|
||||
</header>
|
||||
<div class="form-grid">
|
||||
<label>Name <input v-model="formState.name" placeholder="hourly-cleanup" /></label>
|
||||
<label>Pattern type
|
||||
<label>{{ t('triggers.fields.name') }}
|
||||
<input v-model="formState.name" :placeholder="t('triggers.fields.namePlaceholder')" />
|
||||
</label>
|
||||
<label>{{ t('triggers.fields.patternType') }}
|
||||
<select v-model="formState.patternType">
|
||||
<option value="cron">cron</option>
|
||||
<option value="channel_message">channel_message</option>
|
||||
@ -70,38 +72,55 @@
|
||||
<option value="workflow_completion">workflow_completion</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="span-2">Pattern JSON
|
||||
<label class="span-2">{{ t('triggers.fields.patternJson') }}
|
||||
<textarea v-model="formState.patternJson" rows="3"
|
||||
placeholder='{"cron":"0 0 * * * *","timezone":"UTC"}' />
|
||||
:placeholder="t('triggers.fields.patternJsonPlaceholder')" />
|
||||
<small class="pattern-hint">{{ patternHint }}</small>
|
||||
</label>
|
||||
<label>Target type
|
||||
<label>{{ t('triggers.fields.targetType') }}
|
||||
<select v-model="formState.targetType">
|
||||
<option value="workflow">workflow</option>
|
||||
<option value="agent">agent</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Target id <input v-model.number="formState.targetId" type="number" /></label>
|
||||
<label>Rate / min <input v-model.number="formState.rateLimitPerMin" type="number" /></label>
|
||||
<label>Dedup window (s) <input v-model.number="formState.dedupWindowSecs" type="number" /></label>
|
||||
<label>Max fires (0 = unlimited)
|
||||
<label v-if="formState.targetType === 'workflow'">{{ t('triggers.fields.targetId') }}
|
||||
<select v-model.number="formState.targetId">
|
||||
<option v-if="!availableWorkflows.length" :value="0">
|
||||
{{ t('triggers.targetWorkflowEmpty') }}
|
||||
</option>
|
||||
<option v-else v-for="wf in availableWorkflows" :key="wf.id" :value="wf.id">
|
||||
#{{ wf.id }} — {{ wf.name || t('triggers.unnamed') }}
|
||||
</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>
|
||||
<label>{{ t('triggers.fields.dedupWindowSecs') }}
|
||||
<input v-model.number="formState.dedupWindowSecs" type="number" />
|
||||
</label>
|
||||
<label>{{ t('triggers.fields.maxFires') }}
|
||||
<input v-model.number="formState.maxFires" type="number" />
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="formState.botSelfFilter" />
|
||||
Bot-self filter (recommended)
|
||||
{{ t('triggers.fields.botSelfFilter') }}
|
||||
</label>
|
||||
<label class="span-2">Payload template (Pebble)
|
||||
<label class="span-2">{{ t('triggers.fields.payloadTemplate') }}
|
||||
<textarea v-model="formState.payloadTemplate" rows="3"
|
||||
placeholder='{"who":"{{ event.who }}"}' />
|
||||
:placeholder="t('triggers.fields.payloadTemplatePlaceholder')" />
|
||||
</label>
|
||||
<label class="span-2">
|
||||
<input type="checkbox" v-model="formState.enabled" />
|
||||
Enabled
|
||||
{{ t('triggers.fields.enabled') }}
|
||||
</label>
|
||||
</div>
|
||||
<footer>
|
||||
<button class="btn-ghost" @click="closeForm">Cancel</button>
|
||||
<button class="btn-primary" :disabled="busy" @click="save">Save</button>
|
||||
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.cancel') }}</button>
|
||||
<button class="btn-primary" :disabled="busy" @click="save">{{ t('triggers.actions.save') }}</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
@ -111,12 +130,24 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { triggerApi, type TriggerSummary } from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { triggerApi, type TriggerSummary, workflowApi, type WorkflowSummary } from '@/api'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workspaceId = computed(() => workspaceStore.currentWorkspaceId)
|
||||
const triggers = ref<TriggerSummary[]>([])
|
||||
const workflows = ref<WorkflowSummary[]>([])
|
||||
|
||||
const availableWorkflows = computed(() =>
|
||||
workflows.value.filter((w) => w.latestRevisionId)
|
||||
)
|
||||
|
||||
const patternHint = computed(() => {
|
||||
const key = `triggers.patternHints.${formState.value.patternType}`
|
||||
return t(key, '')
|
||||
})
|
||||
|
||||
const formOpen = ref(false)
|
||||
const editing = ref<TriggerSummary | null>(null)
|
||||
@ -162,6 +193,25 @@ async function reload() {
|
||||
} catch (e) {
|
||||
console.error('listTriggers failed', e)
|
||||
}
|
||||
try {
|
||||
const res = await workflowApi.list(workspaceId.value)
|
||||
workflows.value = (res.data as unknown as WorkflowSummary[]) ?? []
|
||||
} catch (e) {
|
||||
console.error('listWorkflows failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function formatTarget(row: TriggerSummary) {
|
||||
if (row.targetType === 'workflow') {
|
||||
const wf = workflows.value.find((w) => w.id === row.targetId)
|
||||
if (wf?.name) return `${wf.name} (#${row.targetId})`
|
||||
}
|
||||
return `${row.targetType}#${row.targetId}`
|
||||
}
|
||||
|
||||
function formatTime(iso?: string) {
|
||||
if (!iso) return '-'
|
||||
return iso.replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
@ -170,20 +220,20 @@ function openCreate() {
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(t: TriggerSummary) {
|
||||
editing.value = t
|
||||
function openEdit(row: TriggerSummary) {
|
||||
editing.value = row
|
||||
formState.value = {
|
||||
name: t.name ?? '',
|
||||
patternType: t.patternType,
|
||||
patternJson: t.patternJson,
|
||||
targetType: t.targetType,
|
||||
targetId: t.targetId,
|
||||
rateLimitPerMin: t.rateLimitPerMin ?? 60,
|
||||
dedupWindowSecs: t.dedupWindowSecs ?? 60,
|
||||
botSelfFilter: t.botSelfFilter ?? true,
|
||||
enabled: t.enabled,
|
||||
maxFires: t.maxFires ?? 0,
|
||||
payloadTemplate: t.payloadTemplate ?? '',
|
||||
name: row.name ?? '',
|
||||
patternType: row.patternType,
|
||||
patternJson: row.patternJson,
|
||||
targetType: row.targetType,
|
||||
targetId: row.targetId,
|
||||
rateLimitPerMin: row.rateLimitPerMin ?? 60,
|
||||
dedupWindowSecs: row.dedupWindowSecs ?? 60,
|
||||
botSelfFilter: row.botSelfFilter ?? true,
|
||||
enabled: row.enabled,
|
||||
maxFires: row.maxFires ?? 0,
|
||||
payloadTemplate: row.payloadTemplate ?? '',
|
||||
}
|
||||
formOpen.value = true
|
||||
}
|
||||
@ -208,28 +258,28 @@ async function save() {
|
||||
formOpen.value = false
|
||||
await reload()
|
||||
} catch (e) {
|
||||
window.alert('Save failed: ' + (e as Error).message)
|
||||
window.alert(t('triggers.saveFailed', { msg: (e as Error).message }))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(t: TriggerSummary) {
|
||||
async function toggleEnabled(row: TriggerSummary) {
|
||||
try {
|
||||
await triggerApi.update(t.id, { ...t, enabled: !t.enabled })
|
||||
await triggerApi.update(row.id, { ...row, enabled: !row.enabled })
|
||||
await reload()
|
||||
} catch (e) {
|
||||
window.alert('Toggle failed: ' + (e as Error).message)
|
||||
window.alert(t('triggers.toggleFailed', { msg: (e as Error).message }))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(t: TriggerSummary) {
|
||||
if (!window.confirm(`Delete trigger "${t.name || t.id}"?`)) return
|
||||
async function remove(row: TriggerSummary) {
|
||||
if (!window.confirm(t('triggers.deleteConfirm', { name: row.name || String(row.id) }))) return
|
||||
try {
|
||||
await triggerApi.delete(t.id)
|
||||
await triggerApi.delete(row.id)
|
||||
await reload()
|
||||
} catch (e) {
|
||||
window.alert('Delete failed: ' + (e as Error).message)
|
||||
window.alert(t('triggers.deleteFailed', { msg: (e as Error).message }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -321,6 +371,15 @@ watch(workspaceId, reload)
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pattern-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
opacity: 0.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.form-card footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
|
||||
@ -4,22 +4,19 @@
|
||||
<div class="mc-page-inner workflows-page">
|
||||
<div class="mc-page-header">
|
||||
<div>
|
||||
<div class="mc-page-kicker">Workflows</div>
|
||||
<h1 class="mc-page-title">Workflow Editor</h1>
|
||||
<p class="mc-page-desc">
|
||||
Author multi-step workflows. Save the draft as you go, compile to surface diagnostics,
|
||||
then publish a revision when it is ready to run.
|
||||
</p>
|
||||
<div class="mc-page-kicker">{{ t('workflows.kicker') }}</div>
|
||||
<h1 class="mc-page-title">{{ t('workflows.title') }}</h1>
|
||||
<p class="mc-page-desc">{{ t('workflows.desc') }}</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openCreate">+ New Workflow</button>
|
||||
<button class="btn-primary" @click="openCreate">{{ t('workflows.newWorkflow') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="workflows-grid">
|
||||
<!-- left: list -->
|
||||
<aside class="workflows-list mc-surface-card">
|
||||
<div class="list-header">
|
||||
<span>Defined ({{ workflows.length }})</span>
|
||||
<button class="btn-ghost" @click="reload">Refresh</button>
|
||||
<span>{{ t('workflows.defined', { count: workflows.length }) }}</span>
|
||||
<button class="btn-ghost" @click="reload">{{ t('workflows.refresh') }}</button>
|
||||
</div>
|
||||
<ul class="list-body">
|
||||
<li
|
||||
@ -30,37 +27,53 @@
|
||||
@click="select(wf.id)"
|
||||
>
|
||||
<div class="list-row-name">
|
||||
{{ wf.name || '(unnamed)' }}
|
||||
<span v-if="wf.latestRevisionId" class="badge published">v{{ wf.latestRevisionId }}</span>
|
||||
<span v-else class="badge draft">draft</span>
|
||||
{{ wf.name || t('workflows.unnamed') }}
|
||||
<span v-if="wf.latestRevisionId" class="badge published">{{ t('workflows.publishedBadge', { rev: wf.latestRevisionId }) }}</span>
|
||||
<span v-else class="badge draft">{{ t('workflows.draftBadge') }}</span>
|
||||
</div>
|
||||
<div class="list-row-desc">{{ wf.description || '-' }}</div>
|
||||
</li>
|
||||
<li v-if="!workflows.length" class="list-empty">No workflows yet — create one to get started.</li>
|
||||
<li v-if="!workflows.length" class="list-empty">{{ t('workflows.listEmpty') }}</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- middle: editor -->
|
||||
<section class="workflows-editor mc-surface-card" v-if="selected">
|
||||
<header class="editor-header">
|
||||
<input v-model="selected.name" class="editor-name" placeholder="Workflow name" />
|
||||
<input v-model="selected.description" class="editor-desc" placeholder="Optional description" />
|
||||
<input v-model="selected.name" class="editor-name" :placeholder="t('workflows.namePlaceholder')" />
|
||||
<input v-model="selected.description" class="editor-desc" :placeholder="t('workflows.descPlaceholder')" />
|
||||
<div class="editor-actions">
|
||||
<button class="btn-ghost" :disabled="busy" @click="saveMeta">Save Meta</button>
|
||||
<button class="btn-ghost" :disabled="busy" @click="saveDraft">Save Draft</button>
|
||||
<button class="btn-ghost" :disabled="busy" @click="compile">Compile</button>
|
||||
<button class="btn-primary" :disabled="busy" @click="publish">Publish</button>
|
||||
<button class="btn-danger" :disabled="busy" @click="remove">Delete</button>
|
||||
<button class="btn-ghost" :disabled="busy" @click="saveMeta">{{ t('workflows.actions.saveMeta') }}</button>
|
||||
<button class="btn-ghost" :disabled="busy" @click="saveDraft">{{ t('workflows.actions.saveDraft') }}</button>
|
||||
<button class="btn-ghost" :disabled="busy" @click="compile">{{ t('workflows.actions.compile') }}</button>
|
||||
<button class="btn-primary" :disabled="busy" @click="publish">{{ t('workflows.actions.publish') }}</button>
|
||||
<button class="btn-danger" :disabled="busy" @click="remove">{{ t('workflows.actions.delete') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="editor-toolbar">
|
||||
<label class="template-picker">
|
||||
<span>{{ t('workflows.templates.label') }}</span>
|
||||
<select v-model="templateChoice" @change="insertTemplate">
|
||||
<option value="">{{ t('workflows.templates.placeholder') }}</option>
|
||||
<option value="sequential">{{ t('workflows.templates.sequential') }}</option>
|
||||
<option value="fan_out">{{ t('workflows.templates.fan_out') }}</option>
|
||||
<option value="collect">{{ t('workflows.templates.collect') }}</option>
|
||||
<option value="conditional">{{ t('workflows.templates.conditional') }}</option>
|
||||
<option value="await_approval">{{ t('workflows.templates.await_approval') }}</option>
|
||||
<option value="dispatch_channel">{{ t('workflows.templates.dispatch_channel') }}</option>
|
||||
<option value="write_memory">{{ t('workflows.templates.write_memory') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="json-hint" :class="jsonHintKind">{{ jsonHint }}</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="draftJson"
|
||||
class="editor-body"
|
||||
spellcheck="false"
|
||||
placeholder='{"steps":[{"name":"step-a","agentName":"...","mode":{"type":"sequential"},"promptTemplate":"..."}]}'
|
||||
:placeholder="t('workflows.bodyPlaceholder')"
|
||||
/>
|
||||
<div v-if="compileErrors.length" class="errors-panel">
|
||||
<div class="errors-title">{{ compileErrors.length }} compile error(s)</div>
|
||||
<div class="errors-title">{{ t('workflows.compileErrorsTitle', { count: compileErrors.length }) }}</div>
|
||||
<ul>
|
||||
<li v-for="(err, idx) in compileErrors" :key="idx">
|
||||
<code>{{ err.code }}</code>
|
||||
@ -75,14 +88,14 @@
|
||||
</section>
|
||||
|
||||
<section class="workflows-empty mc-surface-card" v-else>
|
||||
<p>Select a workflow on the left, or click "New Workflow" to start a fresh draft.</p>
|
||||
<p>{{ t('workflows.selectHint') }}</p>
|
||||
</section>
|
||||
|
||||
<!-- right: runs -->
|
||||
<aside class="workflows-runs mc-surface-card" v-if="selected">
|
||||
<header class="runs-header">
|
||||
<span>Recent runs ({{ runs.length }})</span>
|
||||
<button class="btn-ghost" @click="reloadRuns">Refresh</button>
|
||||
<span>{{ t('workflows.runs.header', { count: runs.length }) }}</span>
|
||||
<button class="btn-ghost" @click="reloadRuns">{{ t('workflows.refresh') }}</button>
|
||||
</header>
|
||||
<ul class="runs-list">
|
||||
<li v-for="run in runs" :key="run.id" class="run-row" @click="loadRun(run.id)">
|
||||
@ -91,19 +104,19 @@
|
||||
<span class="run-time">{{ formatTime(run.startedAt) }}</span>
|
||||
</div>
|
||||
<div class="run-row-meta">
|
||||
<span>run #{{ run.id }}</span>
|
||||
<span>{{ t('workflows.runs.runHash', { id: run.id }) }}</span>
|
||||
<span v-if="run.triggeredBy">· {{ run.triggeredBy }}</span>
|
||||
<span v-if="run.errorMessage" class="run-err">· {{ run.errorMessage }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!runs.length" class="runs-empty">No runs yet.</li>
|
||||
<li v-if="!runs.length" class="runs-empty">{{ t('workflows.runs.empty') }}</li>
|
||||
</ul>
|
||||
<section v-if="runDetail" class="run-detail">
|
||||
<div class="run-detail-title">Run #{{ runDetail.run.id }} — {{ runDetail.run.state }}</div>
|
||||
<div class="run-detail-title">{{ t('workflows.runs.detailTitle', { id: runDetail.run.id, state: runDetail.run.state }) }}</div>
|
||||
<ol class="run-steps">
|
||||
<li v-for="step in runDetail.steps" :key="step.id">
|
||||
<span class="step-state" :class="'state-' + step.state">{{ step.state }}</span>
|
||||
<span class="step-name">{{ step.stepName || '(unnamed)' }}</span>
|
||||
<span class="step-name">{{ step.stepName || t('workflows.unnamed') }}</span>
|
||||
<span v-if="step.durationMs != null" class="step-duration">{{ step.durationMs }} ms</span>
|
||||
<span v-if="step.errorMessage" class="step-err">{{ step.errorMessage }}</span>
|
||||
</li>
|
||||
@ -118,6 +131,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
workflowApi,
|
||||
type WorkflowSummary,
|
||||
@ -128,6 +142,7 @@ import {
|
||||
} from '@/api'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workspaceId = computed(() => workspaceStore.currentWorkspaceId)
|
||||
|
||||
@ -143,6 +158,97 @@ const busy = ref(false)
|
||||
const runs = ref<WorkflowRun[]>([])
|
||||
const runDetail = ref<{ run: WorkflowRun; steps: WorkflowRunStep[] } | null>(null)
|
||||
|
||||
const templateChoice = ref('')
|
||||
|
||||
// Live JSON-syntax check on the textarea so the operator sees parse errors
|
||||
// immediately, instead of waiting for compile to round-trip.
|
||||
const jsonHint = computed(() => {
|
||||
if (!draftJson.value.trim()) return ''
|
||||
try {
|
||||
JSON.parse(draftJson.value)
|
||||
return t('workflows.jsonOk')
|
||||
} catch (e) {
|
||||
return t('workflows.jsonInvalid', { msg: (e as Error).message })
|
||||
}
|
||||
})
|
||||
const jsonHintKind = computed(() => (jsonHint.value === t('workflows.jsonOk') ? 'ok' : 'err'))
|
||||
|
||||
const STEP_TEMPLATES: Record<string, object> = {
|
||||
sequential: {
|
||||
name: 'step-sequential',
|
||||
agentName: 'agent-name',
|
||||
mode: { type: 'sequential' },
|
||||
promptTemplate: 'Process: {{ inputs.payload }}',
|
||||
},
|
||||
fan_out: {
|
||||
name: 'step-fan',
|
||||
agentName: 'agent-name',
|
||||
mode: { type: 'fan_out' },
|
||||
promptTemplate: 'Branch task',
|
||||
},
|
||||
collect: {
|
||||
name: 'step-collect',
|
||||
agentName: 'agent-name',
|
||||
mode: { type: 'collect' },
|
||||
promptTemplate: 'Combine: {{ inputs.payload }}',
|
||||
},
|
||||
conditional: {
|
||||
name: 'step-conditional',
|
||||
agentName: 'agent-name',
|
||||
mode: { type: 'conditional', expression: '{{ inputs.payload != null }}' },
|
||||
promptTemplate: 'Run only when condition holds',
|
||||
},
|
||||
await_approval: {
|
||||
name: 'step-approval',
|
||||
mode: {
|
||||
type: 'await_approval',
|
||||
approvalKind: 'manual',
|
||||
approvalMessage: 'Please review and approve',
|
||||
timeoutSecs: 3600,
|
||||
},
|
||||
},
|
||||
dispatch_channel: {
|
||||
name: 'step-dispatch',
|
||||
mode: {
|
||||
type: 'dispatch_channel',
|
||||
channels: ['feishu'],
|
||||
targets: { feishu: 'group-id-here' },
|
||||
content: 'Notification: {{ inputs.payload }}',
|
||||
},
|
||||
},
|
||||
write_memory: {
|
||||
name: 'step-memory',
|
||||
mode: {
|
||||
type: 'write_memory',
|
||||
employeeId: 'employee-id',
|
||||
file: 'workspace.md',
|
||||
mergeStrategy: 'append',
|
||||
content: '{{ inputs.payload }}',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function insertTemplate() {
|
||||
const choice = templateChoice.value
|
||||
if (!choice) return
|
||||
const stepBlock = STEP_TEMPLATES[choice]
|
||||
if (!stepBlock) return
|
||||
// Try to inject into an existing draft's `steps` array; fall back to a
|
||||
// fresh skeleton if the current text isn't valid JSON or doesn't have
|
||||
// the expected shape.
|
||||
let next: string
|
||||
try {
|
||||
const parsed = JSON.parse(draftJson.value || '{}') as { steps?: unknown[] }
|
||||
if (!Array.isArray(parsed.steps)) parsed.steps = []
|
||||
parsed.steps.push(stepBlock)
|
||||
next = JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
next = JSON.stringify({ steps: [stepBlock] }, null, 2)
|
||||
}
|
||||
draftJson.value = next
|
||||
templateChoice.value = ''
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
if (!workspaceId.value) return
|
||||
try {
|
||||
@ -188,7 +294,7 @@ async function loadRun(runId: number) {
|
||||
|
||||
async function openCreate() {
|
||||
if (!workspaceId.value) return
|
||||
const name = window.prompt('New workflow name:', 'untitled-workflow')
|
||||
const name = window.prompt(t('workflows.prompts.newName'), t('workflows.prompts.defaultName'))
|
||||
if (!name) return
|
||||
busy.value = true
|
||||
try {
|
||||
@ -201,7 +307,7 @@ async function openCreate() {
|
||||
await reload()
|
||||
if (created?.id) await select(created.id)
|
||||
} catch (e) {
|
||||
setStatus('Create failed: ' + (e as Error).message, 'err')
|
||||
setStatus(t('workflows.status.createFailed', { msg: (e as Error).message }), 'err')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
@ -216,10 +322,10 @@ async function saveMeta() {
|
||||
description: selected.value.description,
|
||||
enabled: selected.value.enabled,
|
||||
})
|
||||
setStatus('Metadata saved.', 'ok')
|
||||
setStatus(t('workflows.status.metaSaved'), 'ok')
|
||||
await reload()
|
||||
} catch (e) {
|
||||
setStatus('Save failed: ' + (e as Error).message, 'err')
|
||||
setStatus(t('workflows.status.saveFailed', { msg: (e as Error).message }), 'err')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
@ -230,9 +336,9 @@ async function saveDraft() {
|
||||
busy.value = true
|
||||
try {
|
||||
await workflowApi.saveDraft(selected.value.id, draftJson.value)
|
||||
setStatus('Draft saved.', 'ok')
|
||||
setStatus(t('workflows.status.draftSaved'), 'ok')
|
||||
} catch (e) {
|
||||
setStatus('Save draft failed: ' + (e as Error).message, 'err')
|
||||
setStatus(t('workflows.status.saveDraftFailed', { msg: (e as Error).message }), 'err')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
@ -245,7 +351,7 @@ async function compile() {
|
||||
try {
|
||||
await workflowApi.saveDraft(selected.value.id, draftJson.value)
|
||||
await workflowApi.compile(selected.value.id)
|
||||
setStatus('Compile OK.', 'ok')
|
||||
setStatus(t('workflows.status.compileOk'), 'ok')
|
||||
} catch (e) {
|
||||
handleCompileError(e)
|
||||
} finally {
|
||||
@ -259,9 +365,9 @@ async function publish() {
|
||||
compileErrors.value = []
|
||||
try {
|
||||
await workflowApi.saveDraft(selected.value.id, draftJson.value)
|
||||
const note = window.prompt('Optional publish note:', '') ?? undefined
|
||||
const note = window.prompt(t('workflows.prompts.publishNote'), '') ?? undefined
|
||||
await workflowApi.publish(selected.value.id, note)
|
||||
setStatus('Published.', 'ok')
|
||||
setStatus(t('workflows.status.published'), 'ok')
|
||||
await reload()
|
||||
} catch (e) {
|
||||
handleCompileError(e)
|
||||
@ -272,7 +378,7 @@ async function publish() {
|
||||
|
||||
async function remove() {
|
||||
if (!selected.value) return
|
||||
if (!window.confirm(`Delete workflow "${selected.value.name}"? This is reversible via the audit log.`)) return
|
||||
if (!window.confirm(t('workflows.prompts.deleteConfirm', { name: selected.value.name ?? '' }))) return
|
||||
busy.value = true
|
||||
try {
|
||||
await workflowApi.delete(selected.value.id)
|
||||
@ -280,9 +386,9 @@ async function remove() {
|
||||
selectedId.value = null
|
||||
draftJson.value = ''
|
||||
await reload()
|
||||
setStatus('Deleted.', 'ok')
|
||||
setStatus(t('workflows.status.deleted'), 'ok')
|
||||
} catch (e) {
|
||||
setStatus('Delete failed: ' + (e as Error).message, 'err')
|
||||
setStatus(t('workflows.status.deleteFailed', { msg: (e as Error).message }), 'err')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
@ -296,9 +402,9 @@ function handleCompileError(e: unknown) {
|
||||
const failure = err.response?.data?.data
|
||||
if (failure?.errors?.length) {
|
||||
compileErrors.value = failure.errors
|
||||
setStatus(`${failure.errorCount} compile error(s) — see panel below.`, 'err')
|
||||
setStatus(t('workflows.status.compileFailed', { count: failure.errorCount ?? failure.errors.length }), 'err')
|
||||
} else {
|
||||
setStatus(err.message || 'Compile / publish failed.', 'err')
|
||||
setStatus(err.message || t('workflows.status.compileFallback'), 'err')
|
||||
}
|
||||
}
|
||||
|
||||
@ -430,6 +536,36 @@ watch(workspaceId, reload)
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.template-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.template-picker select {
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.json-hint {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
flex: 1;
|
||||
}
|
||||
.json-hint.ok { color: #1e8449; }
|
||||
.json-hint.err { color: #c0392b; }
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user