feat(scheduler): unify cron jobs and triggers into a tabbed Scheduler page

This commit is contained in:
matevip 2026-05-19 21:23:18 +08:00
parent c0f9d1d9fb
commit daeef4e3c2
10 changed files with 1778 additions and 537 deletions

View File

@ -0,0 +1,758 @@
<template>
<div class="cron-field">
<div class="cron-field-row">
<input
class="cron-input mono"
:value="modelValue"
spellcheck="false"
:placeholder="withSeconds ? t('cronField.placeholderSeconds') : t('cronField.placeholder')"
@input="onRawInput"
/>
<button type="button" class="cron-edit-btn" @click="openDialog">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
</svg>
{{ t('cronField.edit') }}
</button>
</div>
<div v-if="describe(modelValue)" class="cron-desc">{{ describe(modelValue) }}</div>
<Teleport to="body">
<div v-if="dialogOpen" class="cron-dialog-overlay" @click.self="dialogOpen = false">
<section class="cron-dialog" role="dialog" aria-modal="true">
<header class="cron-dialog-header">
<strong>{{ t('cronField.dialogTitle') }}</strong>
<button type="button" class="cron-x" @click="dialogOpen = false" aria-label="close">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</header>
<div class="cron-dialog-body">
<!-- Presets -->
<div class="cron-presets">
<span class="cron-presets-label">{{ t('cronField.presets') }}</span>
<div class="cron-presets-chips">
<button
v-for="p in presets"
:key="p.key"
type="button"
class="cron-preset-chip"
@click="applyPreset(p.cron)"
>{{ t('cronField.presetItems.' + p.key) }}</button>
</div>
</div>
<!-- Field tabs -->
<nav class="cron-tabs" role="tablist">
<button
v-for="(f, idx) in fields"
:key="f.key"
type="button"
role="tab"
class="cron-tab"
:class="{ active: activeIdx === idx }"
@click="activeIdx = idx"
>
<span class="cron-tab-name">{{ t('cronField.tabs.' + f.key) }}</span>
<span class="cron-tab-val mono">{{ segToStr(draft[f.key], f) }}</span>
</button>
</nav>
<!-- Active field editor -->
<div class="cron-editor">
<div class="cron-type-row">
<label
v-for="ty in TYPES"
:key="ty"
class="cron-type-opt"
:class="{ active: draft[activeField.key].type === ty }"
>
<input type="radio" :value="ty" v-model="draft[activeField.key].type" />
{{ t('cronField.type.' + ty) }}
</label>
</div>
<div class="cron-type-body">
<p v-if="draft[activeField.key].type === 'every'" class="cron-line cron-muted">
{{ t('cronField.everyDesc', { unit: t('cronField.unit.' + activeField.key) }) }}
</p>
<div v-else-if="draft[activeField.key].type === 'range'" class="cron-line">
<input
type="number" class="cron-num"
:min="activeField.min" :max="activeField.max"
v-model.number="draft[activeField.key].rangeStart"
/>
<span class="cron-sep">{{ t('cronField.rangeSep') }}</span>
<input
type="number" class="cron-num"
:min="activeField.min" :max="activeField.max"
v-model.number="draft[activeField.key].rangeEnd"
/>
</div>
<div v-else-if="draft[activeField.key].type === 'interval'" class="cron-line">
<span>{{ t('cronField.intervalStart') }}</span>
<input
type="number" class="cron-num"
:min="activeField.min" :max="activeField.max"
v-model.number="draft[activeField.key].intervalStart"
/>
<span>{{ t('cronField.intervalEvery') }}</span>
<input
type="number" class="cron-num" :min="1" :max="activeField.max"
v-model.number="draft[activeField.key].intervalStep"
/>
<span>{{ t('cronField.unit.' + activeField.key) }}{{ t('cronField.intervalExec') }}</span>
</div>
<div v-else class="cron-specific">
<p v-if="!draft[activeField.key].specific.length" class="cron-muted">
{{ t('cronField.specificEmpty') }}
</p>
<div class="cron-chip-grid">
<button
v-for="opt in activeField.options"
:key="opt.value"
type="button"
class="cron-val-chip"
:class="{ active: draft[activeField.key].specific.includes(opt.value) }"
@click="toggleSpecific(activeField.key, opt.value)"
>{{ opt.label }}</button>
</div>
</div>
</div>
</div>
<!-- Live preview -->
<div class="cron-preview">
<div class="cron-preview-expr mono">{{ draftExpr }}</div>
<div v-if="describe(draftExpr)" class="cron-preview-desc">{{ describe(draftExpr) }}</div>
</div>
</div>
<footer class="cron-dialog-footer">
<button type="button" class="cron-btn-ghost" @click="dialogOpen = false">
{{ t('cronField.cancel') }}
</button>
<button type="button" class="cron-btn-primary" @click="confirm">
{{ t('cronField.confirm') }}
</button>
</footer>
</section>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
interface Props {
/** The cron expression — 5-field (min hour dom mon dow) or 6-field with seconds. */
modelValue: string
/** When true the editor adds a leading seconds field and emits a 6-field cron. */
withSeconds?: boolean
}
const props = withDefaults(defineProps<Props>(), { withSeconds: false })
const emit = defineEmits<{ 'update:modelValue': [string] }>()
const { t } = useI18n()
type SegType = 'every' | 'range' | 'interval' | 'specific'
const TYPES: SegType[] = ['every', 'range', 'interval', 'specific']
interface Segment {
type: SegType
rangeStart: number
rangeEnd: number
intervalStart: number
intervalStep: number
specific: number[]
}
interface FieldDef {
key: 'second' | 'minute' | 'hour' | 'day' | 'month' | 'week'
min: number
max: number
options: { value: number; label: string }[]
}
// Day-of-week chip labels (0 = Sunday) read from i18n.
function weekLabel(n: number): string {
return t('cronField.weekShort.' + n)
}
function range(min: number, max: number): number[] {
const out: number[] = []
for (let i = min; i <= max; i++) out.push(i)
return out
}
const ALL_FIELDS = computed<FieldDef[]>(() => {
const numOpts = (min: number, max: number) =>
range(min, max).map((v) => ({ value: v, label: String(v) }))
return [
{ key: 'second', min: 0, max: 59, options: numOpts(0, 59) },
{ key: 'minute', min: 0, max: 59, options: numOpts(0, 59) },
{ key: 'hour', min: 0, max: 23, options: numOpts(0, 23) },
{ key: 'day', min: 1, max: 31, options: numOpts(1, 31) },
{ key: 'month', min: 1, max: 12, options: numOpts(1, 12) },
{ key: 'week', min: 0, max: 6, options: range(0, 6).map((v) => ({ value: v, label: weekLabel(v) })) },
]
})
// Visible fields seconds only when the parent asks for a 6-field cron.
const fields = computed<FieldDef[]>(() =>
props.withSeconds ? ALL_FIELDS.value : ALL_FIELDS.value.filter((f) => f.key !== 'second'),
)
function blankSegment(field: FieldDef): Segment {
return {
type: 'every',
rangeStart: field.min,
rangeEnd: field.max,
intervalStart: field.min,
intervalStep: 1,
specific: [],
}
}
// Draft segments keyed by field always holds all six so toggling
// withSeconds (per-usage, not runtime) never leaves a hole.
const draft = reactive<Record<string, Segment>>({})
ALL_FIELDS.value.forEach((f) => {
draft[f.key] = blankSegment(f)
})
const dialogOpen = ref(false)
const activeIdx = ref(0)
const activeField = computed(() => fields.value[activeIdx.value] ?? fields.value[0])
// Segment <-> string
function clamp(n: number, min: number, max: number): number {
if (Number.isNaN(n)) return min
return Math.min(Math.max(Math.round(n), min), max)
}
function segToStr(seg: Segment, field: FieldDef): string {
if (!seg) return '*'
switch (seg.type) {
case 'range':
return `${clamp(seg.rangeStart, field.min, field.max)}-${clamp(seg.rangeEnd, field.min, field.max)}`
case 'interval': {
const start = clamp(seg.intervalStart, field.min, field.max)
const step = clamp(seg.intervalStep, 1, field.max)
// `*/n` covers the common "every n" case; a non-min start needs an
// explicit range so Spring's CronExpression accepts the step.
return start <= field.min ? `*/${step}` : `${start}-${field.max}/${step}`
}
case 'specific':
return seg.specific.length
? [...seg.specific].sort((a, b) => a - b).join(',')
: '*'
default:
return '*'
}
}
function strToSeg(raw: string, field: FieldDef): Segment {
const seg = blankSegment(field)
const str = (raw || '').trim()
if (!str || str === '*' || str === '?') return seg
if (str.includes('/')) {
const [base, stepRaw] = str.split('/')
seg.type = 'interval'
seg.intervalStep = clamp(parseInt(stepRaw, 10), 1, field.max)
if (base === '*' || base === '') {
seg.intervalStart = field.min
} else if (base.includes('-')) {
seg.intervalStart = clamp(parseInt(base.split('-')[0], 10), field.min, field.max)
} else {
seg.intervalStart = clamp(parseInt(base, 10), field.min, field.max)
}
return seg
}
if (str.includes('-')) {
const [a, b] = str.split('-')
seg.type = 'range'
seg.rangeStart = clamp(parseInt(a, 10), field.min, field.max)
seg.rangeEnd = clamp(parseInt(b, 10), field.min, field.max)
return seg
}
if (/^\d+(,\d+)*$/.test(str)) {
seg.type = 'specific'
seg.specific = str
.split(',')
.map((v) => clamp(parseInt(v, 10), field.min, field.max))
.filter((v, i, arr) => arr.indexOf(v) === i)
return seg
}
return seg
}
function assemble(): string {
return fields.value.map((f) => segToStr(draft[f.key], f)).join(' ')
}
const draftExpr = computed(() => assemble())
function toggleSpecific(key: string, value: number) {
const list = draft[key].specific
const idx = list.indexOf(value)
if (idx >= 0) list.splice(idx, 1)
else list.push(value)
}
// Dialog lifecycle
function openDialog() {
const parts = (props.modelValue || '').trim().split(/\s+/).filter(Boolean)
// Accept a value that has the other arity by padding / trimming the
// seconds field, so editing never silently discards the expression.
let core = parts
if (props.withSeconds && parts.length === 5) core = ['0', ...parts]
else if (!props.withSeconds && parts.length === 6) core = parts.slice(1)
if (core.length === fields.value.length) {
fields.value.forEach((f, i) => {
draft[f.key] = strToSeg(core[i], f)
})
} else {
fields.value.forEach((f) => {
draft[f.key] = blankSegment(f)
})
}
activeIdx.value = 0
dialogOpen.value = true
}
function applyPreset(cron: string) {
const parts = cron.trim().split(/\s+/)
fields.value.forEach((f, i) => {
draft[f.key] = strToSeg(parts[i] ?? '*', f)
})
}
function confirm() {
emit('update:modelValue', assemble())
dialogOpen.value = false
}
function onRawInput(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
// Presets
const PRESET_DEFS = [
{ key: 'everyMinute', cron5: '* * * * *' },
{ key: 'every5Min', cron5: '*/5 * * * *' },
{ key: 'every30Min', cron5: '*/30 * * * *' },
{ key: 'hourly', cron5: '0 * * * *' },
{ key: 'daily0', cron5: '0 0 * * *' },
{ key: 'daily9', cron5: '0 9 * * *' },
{ key: 'weekday9', cron5: '0 9 * * 1-5' },
{ key: 'weekly1', cron5: '0 9 * * 1' },
{ key: 'monthly1', cron5: '0 0 1 * *' },
]
const presets = computed(() =>
PRESET_DEFS.map((p) => ({
key: p.key,
cron: props.withSeconds ? `0 ${p.cron5}` : p.cron5,
})),
)
// Human-readable description
function pad(n: string | number): string {
const s = String(n)
return s.length < 2 ? '0' + s : s
}
function describeDow(dow: string): string {
const fmt = (n: string) => weekLabel(Number(n) % 7)
if (/^\d+-\d+$/.test(dow)) {
const [a, b] = dow.split('-')
return `${fmt(a)}-${fmt(b)}`
}
if (/^\d+(,\d+)+$/.test(dow)) {
return dow.split(',').map(fmt).join('/')
}
if (/^\d+$/.test(dow)) return fmt(dow)
return dow
}
/** Best-effort plain-language summary; returns '' for shapes it can't phrase. */
function describe(expr: string): string {
if (!expr || !expr.trim()) return ''
const parts = expr.trim().split(/\s+/)
let core: string[]
if (parts.length === 6) core = parts.slice(1)
else if (parts.length === 5) core = parts
else return ''
const [mi, ho, dom, mon, dow] = core
if (core.every((p) => p === '*')) return t('cronField.desc.everyMinute')
if (/^\*\/\d+$/.test(mi) && ho === '*' && dom === '*' && mon === '*' && dow === '*') {
return t('cronField.desc.everyNMin', { n: mi.slice(2) })
}
if (mi === '0' && /^\*\/\d+$/.test(ho) && dom === '*' && mon === '*' && dow === '*') {
return t('cronField.desc.everyNHour', { n: ho.slice(2) })
}
if (/^\d+$/.test(mi) && ho === '*' && dom === '*' && mon === '*' && dow === '*') {
return t('cronField.desc.hourlyAt', { m: mi })
}
if (/^\d+$/.test(mi) && /^\d+$/.test(ho)) {
const time = `${pad(ho)}:${pad(mi)}`
if (dom === '*' && mon === '*' && dow === '*') {
return t('cronField.desc.dailyAt', { time })
}
if (dom === '*' && mon === '*' && dow !== '*') {
return t('cronField.desc.weeklyAt', { days: describeDow(dow), time })
}
if (/^\d+$/.test(dom) && mon === '*' && dow === '*') {
return t('cronField.desc.monthlyAt', { day: dom, time })
}
}
return ''
}
</script>
<style scoped>
.cron-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.cron-field-row {
display: flex;
gap: 8px;
}
.cron-input {
flex: 1 1 auto;
min-width: 0;
padding: 8px 12px;
border: 1px solid var(--mc-border);
border-radius: 8px;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
font-size: 14px;
outline: none;
}
.cron-input:focus {
border-color: var(--mc-primary);
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1);
}
.cron-input.mono,
.mono {
font-family: 'JetBrains Mono', Consolas, monospace;
}
.cron-edit-btn {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
padding: 8px 12px;
border: 1px solid var(--mc-border);
border-radius: 8px;
background: var(--mc-bg-elevated);
color: var(--mc-text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.cron-edit-btn:hover {
border-color: var(--mc-primary);
color: var(--mc-primary);
}
.cron-desc {
font-size: 12px;
color: var(--mc-text-tertiary);
line-height: 1.4;
}
/* ── Dialog ── */
.cron-dialog-overlay {
position: fixed;
inset: 0;
z-index: 2600;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(17, 12, 8, 0.46);
backdrop-filter: blur(6px);
}
.cron-dialog {
width: min(560px, calc(100vw - 40px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--mc-border);
border-radius: 14px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.3);
}
.cron-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--mc-border-light);
}
.cron-dialog-header strong {
font-size: 15px;
font-weight: 700;
}
.cron-x {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 7px;
background: none;
color: var(--mc-text-tertiary);
cursor: pointer;
}
.cron-x:hover {
background: var(--mc-bg-sunken);
}
.cron-dialog-body {
padding: 16px 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
}
.cron-presets {
display: flex;
flex-direction: column;
gap: 6px;
}
.cron-presets-label,
.cron-tab-name {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--mc-text-tertiary);
}
.cron-presets-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cron-preset-chip {
padding: 4px 10px;
border: 1px solid var(--mc-border);
border-radius: 999px;
background: var(--mc-bg-sunken);
color: var(--mc-text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.12s;
}
.cron-preset-chip:hover {
border-color: var(--mc-primary);
color: var(--mc-primary);
}
.cron-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--mc-border);
}
.cron-tab {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
border: none;
background: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
}
.cron-tab-val {
display: block;
width: 100%;
padding: 3px 4px;
border-radius: 5px;
background: var(--mc-bg-sunken);
color: var(--mc-text-secondary);
font-size: 11px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cron-tab.active .cron-tab-name {
color: var(--mc-primary);
}
.cron-tab.active .cron-tab-val {
background: var(--mc-primary);
color: #fff;
}
.cron-tab.active {
border-bottom-color: var(--mc-primary);
}
.cron-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.cron-type-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cron-type-opt {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
border: 1px solid var(--mc-border);
border-radius: 8px;
font-size: 13px;
color: var(--mc-text-secondary);
cursor: pointer;
transition: all 0.12s;
}
.cron-type-opt:hover {
border-color: var(--mc-primary);
}
.cron-type-opt.active {
border-color: var(--mc-primary);
background: var(--mc-primary-bg);
color: var(--mc-primary);
}
.cron-type-opt input {
display: none;
}
.cron-type-body {
min-height: 40px;
}
.cron-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 13px;
color: var(--mc-text-secondary);
}
.cron-muted {
font-size: 13px;
color: var(--mc-text-tertiary);
margin: 0;
}
.cron-sep {
color: var(--mc-text-tertiary);
}
.cron-num {
width: 68px;
padding: 6px 8px;
border: 1px solid var(--mc-border);
border-radius: 7px;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
font-size: 13px;
outline: none;
}
.cron-num:focus {
border-color: var(--mc-primary);
}
.cron-specific {
display: flex;
flex-direction: column;
gap: 8px;
}
.cron-chip-grid {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.cron-val-chip {
min-width: 30px;
padding: 4px 6px;
border: 1px solid var(--mc-border);
border-radius: 6px;
background: var(--mc-bg-sunken);
color: var(--mc-text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.1s;
}
.cron-val-chip:hover {
border-color: var(--mc-primary);
}
.cron-val-chip.active {
border-color: var(--mc-primary);
background: var(--mc-primary);
color: #fff;
}
.cron-preview {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid var(--mc-border-light);
border-radius: 10px;
background: var(--mc-bg-sunken);
}
.cron-preview-expr {
font-size: 14px;
font-weight: 700;
color: var(--mc-primary);
word-break: break-all;
}
.cron-preview-desc {
font-size: 12px;
color: var(--mc-text-tertiary);
}
.cron-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 18px;
border-top: 1px solid var(--mc-border-light);
}
.cron-btn-ghost,
.cron-btn-primary {
padding: 8px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.cron-btn-ghost {
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
color: var(--mc-text-secondary);
}
.cron-btn-ghost:hover {
background: var(--mc-bg-sunken);
}
.cron-btn-primary {
border: 1px solid var(--mc-primary);
background: var(--mc-primary);
color: #fff;
}
.cron-btn-primary:hover {
background: var(--mc-primary-hover);
}
</style>

View File

@ -4,15 +4,12 @@
<template v-if="patternType === 'cron'">
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.cronExpression') }}</span>
<input
v-model.trim="form.cron"
class="pf-input mono"
:placeholder="t('triggers.pattern.cronExpressionPlaceholder')"
spellcheck="false"
@input="emitFromForm"
<CronExpressionField
:model-value="form.cron || ''"
with-seconds
@update:model-value="onCronChange"
/>
<small v-if="errors.cron" class="pf-err">{{ errors.cron }}</small>
<small v-else class="pf-hint">{{ t('triggers.pattern.cronExpressionHint') }}</small>
</div>
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.cronTimezone') }}</span>
@ -167,6 +164,7 @@
import { reactive, ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { WorkflowSummary } from '@/api'
import CronExpressionField from '@/components/common/CronExpressionField.vue'
interface Props {
/** Pattern type (cron / channel_message / …) drives which form is shown. */
@ -313,6 +311,13 @@ function emitFromForm() {
emit('validation', { ...errors })
}
// Cron is edited through the shared CronExpressionField; mirror its value
// into the form and re-run the standard emit + validation pipeline.
function onCronChange(value: string) {
form.cron = value
emitFromForm()
}
function onRawInput(e: Event) {
const next = (e.target as HTMLTextAreaElement).value
rawError.value = null

View File

@ -403,6 +403,7 @@ export default {
security: 'Security',
tokenUsage: 'Token Usage',
cronJobs: 'Cron Jobs',
scheduler: 'Scheduler',
workflows: 'Workflows',
triggers: 'Triggers',
settings: 'Settings',
@ -2198,6 +2199,83 @@ export default {
empty: 'No graph data — process some raw materials first',
},
},
cronField: {
edit: 'Visual editor',
placeholder: 'min hour day month weekday',
placeholderSeconds: 'sec min hour day month weekday',
dialogTitle: 'Cron Expression Builder',
presets: 'Presets',
confirm: 'Confirm',
cancel: 'Cancel',
rangeSep: 'to',
intervalStart: 'from',
intervalEvery: 'every',
intervalExec: '',
specificEmpty: 'Tap the values below to select',
everyDesc: 'Runs every {unit}',
tabs: { second: 'Second', minute: 'Minute', hour: 'Hour', day: 'Day', month: 'Month', week: 'Week' },
type: { every: 'Any', range: 'Range', interval: 'Interval', specific: 'Specific' },
unit: { second: 'second', minute: 'minute', hour: 'hour', day: 'day', month: 'month', week: 'weekday' },
weekShort: { 0: 'Sun', 1: 'Mon', 2: 'Tue', 3: 'Wed', 4: 'Thu', 5: 'Fri', 6: 'Sat' },
presetItems: {
everyMinute: 'Every minute',
every5Min: 'Every 5 minutes',
every30Min: 'Every 30 minutes',
hourly: 'Hourly',
daily0: 'Daily at 0:00',
daily9: 'Daily at 9:00',
weekday9: 'Weekdays at 9:00',
weekly1: 'Mondays at 9:00',
monthly1: 'Monthly on the 1st',
},
desc: {
everyMinute: 'Runs every minute',
everyNMin: 'Runs every {n} minutes',
everyNHour: 'Runs every {n} hours',
hourlyAt: 'Runs at minute {m} of every hour',
dailyAt: 'Runs daily at {time}',
weeklyAt: 'Runs on {days} at {time}',
monthlyAt: 'Runs on day {day} every month at {time}',
},
},
scheduler: {
kicker: 'Automation',
title: 'Scheduler',
desc: 'Scheduled jobs, event triggers and run history — managed in one place.',
tabs: {
jobs: 'Scheduled Jobs',
triggers: 'Event Triggers',
history: 'Run History',
},
actions: {
newJob: 'New Job',
newTrigger: 'New Trigger',
refresh: 'Refresh',
},
history: {
empty: 'No run history yet',
emptyDesc: 'Once a scheduled job runs, its execution records show up here.',
unknownJob: 'Job #{id}',
columns: {
job: 'Job',
trigger: 'Trigger',
status: 'Status',
startedAt: 'Started',
duration: 'Duration',
tokens: 'Tokens',
},
status: {
running: 'Running',
completed: 'Completed',
succeeded: 'Succeeded',
failed: 'Failed',
},
triggerType: {
scheduled: 'Scheduled',
manual: 'Manual',
},
},
},
cronJobs: {
kicker: 'Automation',
title: 'Cron Jobs',

View File

@ -403,6 +403,7 @@ export default {
security: '安全',
tokenUsage: 'Token 统计',
cronJobs: '定时任务',
scheduler: '调度中心',
workflows: '工作流',
triggers: '触发器',
settings: '设置',
@ -2210,6 +2211,83 @@ export default {
empty: '暂无图谱数据,请先处理原始材料',
},
},
cronField: {
edit: '可视化编辑',
placeholder: '分 时 日 月 周',
placeholderSeconds: '秒 分 时 日 月 周',
dialogTitle: 'Cron 表达式生成器',
presets: '常用预设',
confirm: '确定',
cancel: '取消',
rangeSep: '至',
intervalStart: '从',
intervalEvery: '开始,每',
intervalExec: '执行一次',
specificEmpty: '点击下方数字进行选择',
everyDesc: '每{unit}都执行',
tabs: { second: '秒', minute: '分钟', hour: '小时', day: '日', month: '月', week: '周' },
type: { every: '任意值', range: '范围', interval: '间隔', specific: '指定' },
unit: { second: '秒', minute: '分', hour: '时', day: '日', month: '月', week: '周' },
weekShort: { 0: '周日', 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六' },
presetItems: {
everyMinute: '每分钟',
every5Min: '每 5 分钟',
every30Min: '每 30 分钟',
hourly: '每小时',
daily0: '每天 0 点',
daily9: '每天 9 点',
weekday9: '工作日 9 点',
weekly1: '每周一 9 点',
monthly1: '每月 1 号',
},
desc: {
everyMinute: '每分钟执行一次',
everyNMin: '每 {n} 分钟执行一次',
everyNHour: '每 {n} 小时执行一次',
hourlyAt: '每小时第 {m} 分钟执行',
dailyAt: '每天 {time} 执行',
weeklyAt: '每周 {days} {time} 执行',
monthlyAt: '每月 {day} 号 {time} 执行',
},
},
scheduler: {
kicker: '自动化',
title: '调度中心',
desc: '定时任务、事件触发器与运行历史,集中在一个地方管理。',
tabs: {
jobs: '定时任务',
triggers: '事件触发器',
history: '运行历史',
},
actions: {
newJob: '新建任务',
newTrigger: '新建触发器',
refresh: '刷新',
},
history: {
empty: '暂无运行记录',
emptyDesc: '定时任务执行后,运行记录会显示在这里。',
unknownJob: '任务 #{id}',
columns: {
job: '任务',
trigger: '触发方式',
status: '状态',
startedAt: '开始时间',
duration: '耗时',
tokens: 'Token',
},
status: {
running: '执行中',
completed: '已完成',
succeeded: '成功',
failed: '失败',
},
triggerType: {
scheduled: '定时',
manual: '手动',
},
},
},
cronJobs: {
kicker: '自动执行',
title: '定时任务',

View File

@ -177,10 +177,12 @@ const router = createRouter({
meta: { title: 'Settings - Agent Context', requiredCapability: 'manage:agents' },
},
{
path: 'cron-jobs',
name: 'SettingsCronJobs',
component: () => import('@/views/CronJobs.vue'),
meta: { title: 'Settings - Cron Jobs', requiredCapability: 'manage:agents' },
// Unified scheduler — scheduled jobs, event triggers and run
// history under one tabbed page (?tab= selects the tab).
path: 'scheduler',
name: 'SettingsScheduler',
component: () => import('@/views/Scheduler/index.vue'),
meta: { title: 'Settings - Scheduler', requiredCapability: 'manage:agents' },
},
{
path: 'skill-curator',
@ -194,12 +196,6 @@ const router = createRouter({
component: () => import('@/views/Workflows.vue'),
meta: { title: 'Settings - Workflows', requiredCapability: 'manage:settings' },
},
{
path: 'triggers',
name: 'SettingsTriggers',
component: () => import('@/views/Triggers.vue'),
meta: { title: 'Settings - Triggers', requiredCapability: 'manage:settings' },
},
{
path: 'datasources',
name: 'SettingsDatasources',
@ -286,7 +282,10 @@ const router = createRouter({
// RFC-090 Phase 4: Activity 提升到顶层
{ path: 'security/activity', redirect: '/activity' },
{ path: 'settings/activity', redirect: '/activity' },
{ path: 'cron-jobs', redirect: '/settings/cron-jobs' },
// Scheduler absorbs the former Cron Jobs + Triggers pages.
{ path: 'cron-jobs', redirect: '/settings/scheduler' },
{ path: 'settings/cron-jobs', redirect: '/settings/scheduler' },
{ path: 'settings/triggers', redirect: { path: '/settings/scheduler', query: { tab: 'triggers' } } },
{ path: 'datasources', redirect: '/settings/datasources' },
{ path: 'mcp-servers', redirect: '/settings/mcp-servers' },
{ path: 'token-usage', redirect: '/settings/token-usage' },

View File

@ -1,132 +1,114 @@
<template>
<div class="page-container">
<div class="page-shell">
<div class="page-header">
<div class="page-lead">
<div class="page-kicker">{{ t('cronJobs.kicker') }}</div>
<h1 class="page-title">{{ t('cronJobs.title') }}</h1>
<p class="page-desc">{{ t('cronJobs.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('cronJobs.createJob') }}
</button>
</div>
<div class="page-stage">
<div class="table-wrap">
<table class="data-table">
<colgroup>
<col style="width: 22%; min-width: 160px" />
<col style="width: 18%; min-width: 140px" />
<col style="width: 16%; min-width: 130px" />
<col style="width: 12%; min-width: 100px" />
<col style="width: 12%; min-width: 100px" />
<col style="width: 8%; min-width: 70px" />
<col style="width: 12%; min-width: 100px" />
</colgroup>
<thead>
<tr>
<th>{{ t('cronJobs.columns.name') }}</th>
<th>{{ t('cronJobs.columns.cron') }}</th>
<th>{{ t('tokenUsage.date') }}</th>
<th>{{ t('cronJobs.columns.channel') }}</th>
<th>{{ t('cronJobs.columns.lastDelivery') }}</th>
<th>{{ t('cronJobs.columns.enabled') }}</th>
<th>{{ t('cronJobs.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="job in store.jobs" :key="job.id" class="data-row">
<td>
<div class="job-main">
<div class="job-name" :title="job.name">{{ job.name }}</div>
<div class="job-meta-row">
<span v-if="job.taskType !== 'wiki_process'" class="agent-badge" :title="job.agentName || 'Unknown'">{{ job.agentName || 'Unknown' }}</span>
<span class="type-badge" :class="'type-' + job.taskType">
{{ t('cronJobs.taskTypes.' + job.taskType) }}
</span>
</div>
</div>
</td>
<td>
<code class="cron-code" :title="cronToHumanReadable(job.cronExpression, job.timezone)">
{{ job.cronExpression }}
</code>
<div class="cron-readable">{{ cronToHumanReadable(job.cronExpression, job.timezone) }}</div>
</td>
<td>
<div class="runtime-stack">
<span v-if="job.nextRunTime" class="time-text" :title="`${t('cronJobs.columns.nextRun')}: ${formatTime(job.nextRunTime)}`">{{ formatTime(job.nextRunTime) }}</span>
<span v-else class="time-empty">-</span>
<span v-if="job.lastRunTime" class="time-subtext" :title="`${t('cronJobs.columns.lastRun')}: ${formatTime(job.lastRunTime)}`">{{ t('cronJobs.columns.lastRun') }}: {{ formatTime(job.lastRunTime) }}</span>
</div>
</td>
<td>
<!-- Channel binding visibility RFC-063r post-deploy fix.
Cron created from web (no channelId) shows "—". -->
<span v-if="job.channelId" class="channel-binding"
:title="job.deliveryConfig?.targetId ? t('cronJobs.columns.targetId') + ': ' + job.deliveryConfig.targetId : ''">
{{ job.channelName || ('#' + job.channelId) }}
<div class="cron-panel">
<div class="table-wrap">
<table class="data-table">
<colgroup>
<col style="width: 22%; min-width: 160px" />
<col style="width: 18%; min-width: 140px" />
<col style="width: 16%; min-width: 130px" />
<col style="width: 12%; min-width: 100px" />
<col style="width: 12%; min-width: 100px" />
<col style="width: 8%; min-width: 70px" />
<col style="width: 12%; min-width: 100px" />
</colgroup>
<thead>
<tr>
<th>{{ t('cronJobs.columns.name') }}</th>
<th>{{ t('cronJobs.columns.cron') }}</th>
<th>{{ t('tokenUsage.date') }}</th>
<th>{{ t('cronJobs.columns.channel') }}</th>
<th>{{ t('cronJobs.columns.lastDelivery') }}</th>
<th>{{ t('cronJobs.columns.enabled') }}</th>
<th>{{ t('cronJobs.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="job in store.jobs" :key="job.id" class="data-row">
<td>
<div class="job-main">
<div class="job-name" :title="job.name">{{ job.name }}</div>
<div class="job-meta-row">
<span v-if="job.taskType !== 'wiki_process'" class="agent-badge" :title="job.agentName || 'Unknown'">{{ job.agentName || 'Unknown' }}</span>
<span class="type-badge" :class="'type-' + job.taskType">
{{ t('cronJobs.taskTypes.' + job.taskType) }}
</span>
<span v-else class="time-empty"></span>
</td>
<td>
<!-- RFC-063r §2.14: most-recent delivery status badge.
hover surfaces the error detail when not delivered. -->
<span class="delivery-badge" :class="'delivery-' + (job.lastDeliveryStatus || 'NONE').toLowerCase()"
:title="job.lastDeliveryError || t('cronJobs.lastDelivery.' + (job.lastDeliveryStatus || 'NONE').toLowerCase())">
{{ t('cronJobs.lastDelivery.' + (job.lastDeliveryStatus || 'NONE').toLowerCase()) }}
</span>
</td>
<td>
<label class="toggle-switch">
<input type="checkbox" :checked="job.enabled" @change="handleToggle(job)" />
<span class="toggle-slider"></span>
</label>
</td>
<td>
<div class="row-actions">
<button class="row-btn" :title="t('common.view')" @click="openDetailModal(job)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M2.05 12a9.94 9.94 0 0 1 19.9 0 9.94 9.94 0 0 1-19.9 0z"/>
</svg>
</button>
<button class="row-btn" :title="t('cronJobs.actions.runNow')" @click="handleRunNow(job)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
</button>
<button class="row-btn" :title="t('cronJobs.actions.edit')" @click="openEditModal(job)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="row-btn danger" :title="t('cronJobs.actions.delete')" @click="handleDelete(job)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="store.jobs.length === 0">
<td colspan="7" class="empty-row">
<div class="empty-state">
<span class="empty-icon">&#9201;</span>
<p>{{ t('cronJobs.noJobs') }}</p>
<button class="btn-primary btn-sm" @click="openCreateModal">{{ t('cronJobs.createFirst') }}</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</td>
<td>
<code class="cron-code" :title="cronToHumanReadable(job.cronExpression, job.timezone)">
{{ job.cronExpression }}
</code>
<div class="cron-readable">{{ cronToHumanReadable(job.cronExpression, job.timezone) }}</div>
</td>
<td>
<div class="runtime-stack">
<span v-if="job.nextRunTime" class="time-text" :title="`${t('cronJobs.columns.nextRun')}: ${formatTime(job.nextRunTime)}`">{{ formatTime(job.nextRunTime) }}</span>
<span v-else class="time-empty">-</span>
<span v-if="job.lastRunTime" class="time-subtext" :title="`${t('cronJobs.columns.lastRun')}: ${formatTime(job.lastRunTime)}`">{{ t('cronJobs.columns.lastRun') }}: {{ formatTime(job.lastRunTime) }}</span>
</div>
</td>
<td>
<!-- Channel binding visibility RFC-063r post-deploy fix.
Cron created from web (no channelId) shows "—". -->
<span v-if="job.channelId" class="channel-binding"
:title="job.deliveryConfig?.targetId ? t('cronJobs.columns.targetId') + ': ' + job.deliveryConfig.targetId : ''">
{{ job.channelName || ('#' + job.channelId) }}
</span>
<span v-else class="time-empty"></span>
</td>
<td>
<!-- RFC-063r §2.14: most-recent delivery status badge.
hover surfaces the error detail when not delivered. -->
<span class="delivery-badge" :class="'delivery-' + (job.lastDeliveryStatus || 'NONE').toLowerCase()"
:title="job.lastDeliveryError || t('cronJobs.lastDelivery.' + (job.lastDeliveryStatus || 'NONE').toLowerCase())">
{{ t('cronJobs.lastDelivery.' + (job.lastDeliveryStatus || 'NONE').toLowerCase()) }}
</span>
</td>
<td>
<label class="toggle-switch">
<input type="checkbox" :checked="job.enabled" @change="handleToggle(job)" />
<span class="toggle-slider"></span>
</label>
</td>
<td>
<div class="row-actions">
<button class="row-btn" :title="t('common.view')" @click="openDetailModal(job)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M2.05 12a9.94 9.94 0 0 1 19.9 0 9.94 9.94 0 0 1-19.9 0z"/>
</svg>
</button>
<button class="row-btn" :title="t('cronJobs.actions.runNow')" @click="handleRunNow(job)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
</button>
<button class="row-btn" :title="t('cronJobs.actions.edit')" @click="openEditModal(job)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="row-btn danger" :title="t('cronJobs.actions.delete')" @click="handleDelete(job)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="store.jobs.length === 0">
<td colspan="7" class="empty-row">
<div class="empty-state">
<span class="empty-icon">&#9201;</span>
<p>{{ t('cronJobs.noJobs') }}</p>
<button class="btn-primary btn-sm" @click="openCreateModal">{{ t('cronJobs.createFirst') }}</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="detailJob" class="modal-overlay">
@ -299,38 +281,8 @@
</template>
<div class="form-group">
<label class="form-label">{{ t('cronJobs.fields.cronFrequency') }}</label>
<div class="radio-group">
<label v-for="ct in cronTypeOptions" :key="ct" class="radio-option" :class="{ active: cronType === ct }">
<input type="radio" v-model="cronType" :value="ct" />
{{ t('cronJobs.cronTypes.' + ct) }}
</label>
</div>
</div>
<div v-if="cronType === 'daily' || cronType === 'weekly'" class="form-row">
<div v-if="cronType === 'weekly'" class="form-group">
<label class="form-label">{{ t('cronJobs.fields.cronDays') }}</label>
<div class="day-picker">
<label v-for="(dayKey, idx) in dayKeys" :key="dayKey" class="day-chip"
:class="{ active: selectedDays.includes(idx + 1) }">
<input type="checkbox" :value="idx + 1"
:checked="selectedDays.includes(idx + 1)"
@change="toggleDay(idx + 1)" />
{{ t('cronJobs.days.' + dayKey) }}
</label>
</div>
</div>
<div class="form-group">
<label class="form-label">{{ t('cronJobs.fields.cronTime') }}</label>
<input type="time" v-model="cronTime" class="form-input" />
</div>
</div>
<div v-if="cronType === 'custom'" class="form-group">
<label class="form-label">{{ t('cronJobs.fields.cronExpression') }}</label>
<input v-model="form.cronExpression" class="form-input mono"
:placeholder="t('cronJobs.fields.cronExpressionPlaceholder')" />
<label class="form-label">{{ t('cronJobs.fields.cronExpression') }} *</label>
<CronExpressionField v-model="form.cronExpression" />
</div>
<div class="form-group">
@ -368,6 +320,7 @@ import { useCronJobStore } from '@/stores/useCronJobStore'
import { useAgentStore } from '@/stores/useAgentStore'
import { wikiApi } from '@/api/index'
import type { CronJob } from '@/types/index'
import CronExpressionField from '@/components/common/CronExpressionField.vue'
interface WikiKbOption {
id: number | string
@ -379,16 +332,17 @@ const store = useCronJobStore()
const agentStore = useAgentStore()
const agents = computed(() => agentStore.agents)
// Report the job count up to the Scheduler shell so the tab badge stays
// in sync without the shell having to fetch the list itself.
const emit = defineEmits<{ count: [number] }>()
const showModal = ref(false)
const editing = ref<CronJob | null>(null)
const detailJob = ref<CronJob | null>(null)
const wikiKbs = ref<WikiKbOption[]>([])
const wikiKbsLoaded = ref(false)
const cronTypeOptions = ['hourly', 'daily', 'weekly', 'custom'] as const
const cronType = ref<string>('daily')
const cronTime = ref('09:00')
const selectedDays = ref<number[]>([1, 2, 3, 4, 5])
// Weekday keys for the table's human-readable cron rendering.
const dayKeys = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
const timezones = [
@ -400,7 +354,7 @@ const timezones = [
const defaultForm = (): Partial<CronJob> & { wikiKbId?: number | string; wikiForce?: boolean } => ({
name: '',
cronExpression: '',
cronExpression: '0 9 * * *',
timezone: 'Asia/Shanghai',
agentId: undefined,
taskType: 'text',
@ -421,7 +375,7 @@ const canSave = computed(() => {
if (form.value.taskType === 'agent' && !form.value.requestBody) return false
if (form.value.taskType === 'wiki_process'
&& (form.value.wikiKbId == null || form.value.wikiKbId === '')) return false
if (cronType.value === 'custom' && !form.value.cronExpression?.trim()) return false
if (!form.value.cronExpression?.trim()) return false
return true
})
@ -470,34 +424,11 @@ onMounted(() => {
agentStore.fetchAgents()
})
watch([cronType, cronTime, selectedDays], () => {
if (cronType.value === 'custom') return
const [h, m] = cronTime.value.split(':').map(Number)
if (cronType.value === 'hourly') {
form.value.cronExpression = '0 * * * *'
} else if (cronType.value === 'daily') {
form.value.cronExpression = `${m} ${h} * * *`
} else if (cronType.value === 'weekly') {
const days = selectedDays.value.length > 0 ? selectedDays.value.sort().join(',') : '*'
form.value.cronExpression = `${m} ${h} * * ${days}`
}
}, { deep: true })
function toggleDay(day: number) {
const idx = selectedDays.value.indexOf(day)
if (idx >= 0) {
selectedDays.value.splice(idx, 1)
} else {
selectedDays.value.push(day)
}
}
watch(() => store.jobs.length, (n) => emit('count', n), { immediate: true })
function openCreateModal() {
editing.value = null
form.value = defaultForm()
cronType.value = 'daily'
cronTime.value = '09:00'
selectedDays.value = [1, 2, 3, 4, 5]
showModal.value = true
}
@ -508,10 +439,6 @@ function openEditModal(job: CronJob) {
applyWikiProcessForm(form.value, job.requestBody)
loadWikiKbs()
}
const parsed = parseCronToForm(job.cronExpression)
cronType.value = parsed.type
cronTime.value = parsed.time
selectedDays.value = [...parsed.days]
showModal.value = true
}
@ -629,44 +556,6 @@ async function handleRunNow(job: CronJob) {
}
}
interface CronFormParts {
type: string
time: string
days: number[]
}
function isSimpleIntList(s: string): boolean {
return s.split(',').every((v) => /^\d+$/.test(v.trim()))
}
function parseCronToForm(expr: string): CronFormParts {
const parts = expr.trim().split(/\s+/)
if (parts.length !== 5) return { type: 'custom', time: '09:00', days: [] }
const [min, hour, dom, mon, dow] = parts
if (min === '0' && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
return { type: 'hourly', time: '00:00', days: [] }
}
if (!/^\d+$/.test(min) || !/^\d+$/.test(hour)) {
return { type: 'custom', time: '09:00', days: [] }
}
const timeStr = pad(+hour) + ':' + pad(+min)
if (dom === '*' && mon === '*' && dow === '*') {
return { type: 'daily', time: timeStr, days: [] }
}
if (dom === '*' && mon === '*' && dow !== '*' && isSimpleIntList(dow)) {
const days = dow.split(',').map(Number)
return { type: 'weekly', time: timeStr, days }
}
return { type: 'custom', time: timeStr, days: [] }
}
function pad(n: number): string {
return n < 10 ? '0' + n : '' + n
}
@ -707,68 +596,17 @@ function formatTime(datetime: string | undefined): string {
return datetime
}
}
// The Scheduler shell owns the "+ New Job" header button; expose the modal
// opener so it can drive this panel when the Scheduled Jobs tab is active.
defineExpose({ openCreate: openCreateModal })
</script>
<style scoped>
.page-container {
height: 100%;
overflow-y: auto;
padding: 0;
background: transparent;
}
.page-shell {
min-height: 100%;
.cron-panel {
display: flex;
flex-direction: column;
gap: 20px;
padding: 0;
background: transparent;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 24px 24px 0;
}
.page-lead {
display: flex;
flex-direction: column;
gap: 8px;
}
.page-kicker {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 6px 12px;
border: 1px solid color-mix(in srgb, var(--mc-primary) 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--mc-primary-bg) 72%, var(--mc-bg-elevated) 28%);
color: var(--mc-primary-hover);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.page-title {
font-size: clamp(28px, 4vw, 40px);
line-height: 0.95;
font-weight: 800;
color: var(--mc-text-primary);
margin: 0;
}
.page-desc {
max-width: 620px;
font-size: 15px;
line-height: 1.55;
color: var(--mc-text-secondary);
margin: 0;
}
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; }
@ -778,10 +616,6 @@ function formatTime(datetime: string | undefined): string {
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.page-stage {
padding: 0 24px 24px;
}
.table-wrap {
width: 100%;
background:
@ -964,8 +798,6 @@ function formatTime(datetime: string | undefined): string {
.form-input.mono { font-family: monospace; }
.form-textarea { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); width: 100%; resize: vertical; font-family: inherit; }
.form-textarea:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.form-row { display: flex; gap: 16px; }
.form-row .form-group { flex: 1; }
.radio-group { display: flex; gap: 8px; flex-wrap: wrap; }
.radio-option { display: flex; align-items: center; gap: 4px; padding: 6px 14px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; }
@ -973,30 +805,9 @@ function formatTime(datetime: string | undefined): string {
.radio-option.active { border-color: var(--mc-primary); background: var(--mc-primary-bg); color: var(--mc-primary); }
.radio-option input { display: none; }
.day-picker { display: flex; gap: 6px; flex-wrap: wrap; }
.day-chip { display: flex; align-items: center; justify-content: center; padding: 4px 10px; border: 1px solid var(--mc-border); border-radius: 6px; font-size: 12px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; user-select: none; }
.day-chip:hover { border-color: var(--mc-primary); }
.day-chip.active { border-color: var(--mc-primary); background: var(--mc-primary-bg); color: var(--mc-primary); }
.day-chip input { display: none; }
.toggle-label { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--mc-text-primary); }
@media (max-width: 900px) {
.page-header {
flex-direction: column;
align-items: stretch;
padding: 16px 16px 0;
}
.btn-primary {
width: 100%;
justify-content: center;
}
.page-stage {
padding: 0 16px 16px;
}
.detail-grid {
grid-template-columns: 1fr;
}

View File

@ -0,0 +1,289 @@
<template>
<div class="history-panel">
<div v-if="loading" class="state-block">
<span class="state-spinner" aria-hidden="true" />
<span>{{ t('common.loading') }}</span>
</div>
<div v-else-if="loadError" class="state-block state-block--error">
<span class="state-icon">!</span>
<p>{{ loadError }}</p>
<button class="btn-secondary" @click="refresh">{{ t('common.retry') }}</button>
</div>
<div v-else-if="!runs.length" class="state-block">
<span class="state-icon empty">&#9203;</span>
<p class="state-title">{{ t('scheduler.history.empty') }}</p>
<p class="state-desc">{{ t('scheduler.history.emptyDesc') }}</p>
</div>
<div v-else class="table-wrap">
<table class="data-table">
<colgroup>
<col style="width: 26%; min-width: 180px" />
<col style="width: 12%; min-width: 96px" />
<col style="width: 14%; min-width: 110px" />
<col style="width: 20%; min-width: 150px" />
<col style="width: 14%; min-width: 100px" />
<col style="width: 14%; min-width: 90px" />
</colgroup>
<thead>
<tr>
<th>{{ t('scheduler.history.columns.job') }}</th>
<th>{{ t('scheduler.history.columns.trigger') }}</th>
<th>{{ t('scheduler.history.columns.status') }}</th>
<th>{{ t('scheduler.history.columns.startedAt') }}</th>
<th>{{ t('scheduler.history.columns.duration') }}</th>
<th>{{ t('scheduler.history.columns.tokens') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="run in runs" :key="run.id" class="data-row">
<td>
<div class="job-name" :title="jobName(run.cronJobId)">{{ jobName(run.cronJobId) }}</div>
<div v-if="run.errorMessage" class="run-error" :title="run.errorMessage">
{{ run.errorMessage }}
</div>
</td>
<td>
<span class="trigger-pill" :class="'trigger-' + (run.triggerType || 'scheduled')">
{{ triggerLabel(run.triggerType) }}
</span>
</td>
<td>
<span class="status-badge" :class="'status-' + (run.status || 'running')">
{{ statusLabel(run.status) }}
</span>
</td>
<td class="time-cell" :title="formatTime(run.startedAt)">{{ formatTime(run.startedAt) }}</td>
<td class="time-cell">{{ formatDuration(run.startedAt, run.finishedAt) }}</td>
<td class="time-cell">{{ run.tokenUsage != null ? run.tokenUsage : '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { dashboardApi } from '@/api'
import { useCronJobStore } from '@/stores/useCronJobStore'
/** One row of cron run history, mirroring the backend CronJobRunEntity. */
interface CronJobRun {
id: string | number
cronJobId: string | number
conversationId?: string
status?: string
triggerType?: string
startedAt?: string
finishedAt?: string
errorMessage?: string
tokenUsage?: number | null
}
const { t } = useI18n()
const store = useCronJobStore()
// Report the run count up to the Scheduler shell for the tab badge.
const emit = defineEmits<{ count: [number] }>()
const runs = ref<CronJobRun[]>([])
const loading = ref(false)
const loadError = ref('')
async function refresh() {
loading.value = true
loadError.value = ''
try {
// The cron job list resolves run.cronJobId to a human-readable name.
if (!store.jobs.length) await store.fetchJobs()
const res: any = await dashboardApi.recentRuns(50)
runs.value = (res?.data || res || []) as CronJobRun[]
} catch (e: any) {
runs.value = []
loadError.value = e?.message || String(e)
} finally {
loading.value = false
}
}
// Resolve a run's cron job id to its name. Compared as strings so 19-digit
// Snowflake ids never lose precision through a Number coercion.
function jobName(cronJobId: string | number): string {
const match = store.jobs.find((j) => String(j.id) === String(cronJobId))
return match?.name || t('scheduler.history.unknownJob', { id: cronJobId })
}
function statusLabel(status?: string): string {
const key = `scheduler.history.status.${status || 'running'}`
const localized = t(key, '')
return localized && localized !== key ? localized : (status || '-')
}
function triggerLabel(type?: string): string {
const key = `scheduler.history.triggerType.${type || 'scheduled'}`
const localized = t(key, '')
return localized && localized !== key ? localized : (type || '-')
}
function formatTime(datetime?: string): string {
if (!datetime) return '-'
try {
const d = new Date(datetime)
return isNaN(d.getTime()) ? datetime : d.toLocaleString()
} catch {
return datetime
}
}
// Compact run duration: ms / s / "Nm Ns" / "Nh Nm". A run still in flight
// (no finishedAt) shows an em dash rather than a misleading zero.
function formatDuration(start?: string, end?: string): string {
if (!start || !end) return '—'
const ms = new Date(end).getTime() - new Date(start).getTime()
if (isNaN(ms) || ms < 0) return '—'
if (ms < 1000) return `${ms}ms`
const totalSec = Math.floor(ms / 1000)
if (totalSec < 60) return `${totalSec}s`
const min = Math.floor(totalSec / 60)
const sec = totalSec % 60
if (min < 60) return sec ? `${min}m ${sec}s` : `${min}m`
const hour = Math.floor(min / 60)
return `${hour}h ${min % 60}m`
}
onMounted(refresh)
watch(() => runs.value.length, (n) => emit('count', n), { immediate: true })
// The Scheduler shell owns the "Refresh" header button for this tab.
defineExpose({ refresh })
</script>
<style scoped>
.history-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.btn-secondary {
padding: 8px 16px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
border: 1px solid var(--mc-border);
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.btn-secondary:hover { background: var(--mc-bg-sunken); }
/* Loading / error / empty share one centered block. */
.state-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 56px 24px;
color: var(--mc-text-tertiary);
text-align: center;
}
.state-icon { font-size: 30px; line-height: 1; }
.state-icon.empty { font-size: 34px; }
.state-block--error .state-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 18px;
font-weight: 700;
background: var(--mc-danger-bg, rgba(239, 68, 68, 0.12));
color: var(--mc-danger, rgb(239, 68, 68));
}
.state-title { font-size: 14px; font-weight: 600; color: var(--mc-text-secondary); margin: 0; }
.state-desc { font-size: 13px; margin: 0; max-width: 380px; }
.state-spinner {
width: 22px;
height: 22px;
border: 2px solid var(--mc-border);
border-top-color: var(--mc-primary);
border-radius: 50%;
animation: history-spin 0.7s linear infinite;
}
@keyframes history-spin {
to { transform: rotate(360deg); }
}
.table-wrap {
width: 100%;
background:
linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 96%, white 4%) 0%, var(--mc-bg-elevated) 100%);
border: 1px solid var(--mc-border);
border-radius: 24px;
overflow-x: auto;
overflow-y: hidden;
box-shadow: 0 20px 48px rgba(128, 84, 60, 0.08);
}
.data-table { width: 100%; table-layout: fixed; border-collapse: collapse; }
.data-table th {
position: sticky;
top: 0;
z-index: 1;
padding: 12px 16px;
text-align: left;
font-size: 11px;
font-weight: 700;
color: var(--mc-text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
background: color-mix(in srgb, var(--mc-bg-sunken) 86%, white 14%);
border-bottom: 1px solid var(--mc-border);
white-space: nowrap;
}
.data-row { border-bottom: 1px solid var(--mc-border-light); transition: background 0.1s; }
.data-row:hover { background: var(--mc-bg-sunken); }
.data-row:last-child { border-bottom: none; }
.data-table td { padding: 14px 16px; font-size: 14px; color: var(--mc-text-primary); vertical-align: top; }
.job-name {
font-weight: 600;
color: var(--mc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-error {
margin-top: 4px;
font-size: 12px;
line-height: 1.4;
color: var(--mc-danger, rgb(239, 68, 68));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time-cell { font-size: 13px; color: var(--mc-text-secondary); }
.trigger-pill,
.status-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.trigger-pill { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
.trigger-manual { background: var(--mc-primary-bg); color: var(--mc-primary); }
.status-running { background: rgba(59, 130, 246, 0.12); color: rgb(59, 130, 246); }
.status-completed,
.status-succeeded { background: rgba(34, 197, 94, 0.12); color: rgb(34, 197, 94); }
.status-failed { background: rgba(239, 68, 68, 0.12); color: rgb(239, 68, 68); }
</style>

View File

@ -1,190 +1,177 @@
<template>
<div class="mc-page-shell">
<div class="mc-page-frame">
<div class="mc-page-inner triggers-page">
<div class="mc-page-header">
<div>
<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">{{ t('triggers.newTrigger') }}</button>
</div>
<div v-if="!triggers.length" class="trigger-empty mc-surface-card">
<div class="trigger-empty-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z" />
</svg>
</div>
<h2 class="trigger-empty-title">{{ t('triggers.empty') }}</h2>
<p class="trigger-empty-desc">{{ t('triggers.emptyDesc') }}</p>
<button class="btn-primary" @click="openCreate">{{ t('triggers.newTrigger') }}</button>
</div>
<div v-else class="trigger-list">
<article
v-for="row in triggers"
:key="row.id"
class="trigger-card mc-surface-card"
:class="{ 'is-disabled': !row.enabled }"
>
<span
class="trigger-status-dot"
:class="row.enabled ? 'is-on' : 'is-off'"
:title="row.enabled ? t('triggers.enabled') : t('triggers.disabled')"
/>
<div class="trigger-main">
<div class="trigger-headline">
<span class="trigger-name">{{ row.name || t('triggers.unnamed') }}</span>
<span v-if="row.lastError" class="trigger-error-chip" :title="row.lastError">
{{ truncateError(row.lastError) }}
</span>
</div>
<div class="trigger-rule">
<span class="trigger-type-pill" :title="row.patternType">
{{ patternTypeLabel(row.patternType) }}
</span>
<span class="trigger-rule-arrow" aria-hidden="true"></span>
<span class="trigger-target" :title="'#' + row.targetId">{{ targetName(row) }}</span>
</div>
<div class="trigger-meta">
<span class="trigger-meta-summary" :title="row.patternJson">{{ patternSummary(row) }}</span>
<span class="trigger-meta-sep" aria-hidden="true">·</span>
<span>{{ activityLine(row) }}</span>
</div>
</div>
<div class="trigger-controls">
<button
type="button"
class="trigger-toggle"
:class="{ 'is-on': row.enabled }"
:title="row.enabled ? t('triggers.enabled') : t('triggers.disabled')"
@click="toggleEnabled(row)"
>
<span class="trigger-toggle-knob" />
</button>
<button class="row-btn" :title="t('triggers.actions.edit')" @click="openEdit(row)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</button>
<button class="row-btn danger" :title="t('triggers.actions.delete')" @click="remove(row)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
</svg>
</button>
</div>
</article>
</div>
<Teleport to="body">
<div v-if="formOpen" class="trigger-modal-overlay" @click.self="closeForm">
<section class="trigger-modal" role="dialog" aria-modal="true">
<header class="trigger-modal-header">
<strong>{{ editing?.id ? t('triggers.formTitleEdit') : t('triggers.formTitleNew') }}</strong>
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.close') }}</button>
</header>
<div class="trigger-modal-body">
<div class="form-grid">
<label>{{ t('triggers.fields.name') }}
<input v-model="formState.name" :placeholder="t('triggers.fields.namePlaceholder')" />
</label>
<label>{{ t('triggers.fields.patternType') }}
<!-- v0 ships four pattern types in the manual create UI:
cron, channel_message (which now carries the keyword
filter that content_match used to provide),
workflow_completion, webhook. content_match and
agent_lifecycle remain supported by the matcher for
legacy rows but are hidden from the new-trigger
dropdown so the manual entry matches what the
natural-language generator surfaces. They re-appear
only when editing a row that already uses one, so
the operator can tweak/disable/delete it without
being stuck on a dropdown that excludes its type. -->
<select v-model="formState.patternType">
<option value="cron">cron</option>
<option value="channel_message">channel_message</option>
<option value="workflow_completion">workflow_completion</option>
<option value="webhook">webhook</option>
<option v-if="showLegacyContentMatch" value="content_match">content_match (legacy)</option>
<option v-if="showLegacyAgentLifecycle" value="agent_lifecycle">agent_lifecycle (legacy)</option>
</select>
</label>
<div class="span-2">
<span class="form-grid-label">{{ t('triggers.fields.patternJson') }}</span>
<TriggerPatternForm
v-model="formState.patternJson"
:pattern-type="formState.patternType"
:available-workflows="availableWorkflows"
@validation="onPatternValidation"
/>
</div>
<label>{{ t('triggers.fields.targetType') }}
<!-- v0 only ships the workflow dispatcher; agent target is
reserved for v1 and rejected by the API to avoid the
"looks enabled, never fires" trap. -->
<select v-model="formState.targetType" disabled>
<option value="workflow">workflow</option>
</select>
</label>
<label>{{ t('triggers.fields.targetId') }}
<!-- Keep targetId as a string so 19-digit Snowflake IDs survive
the v-model round trip; .number would truncate to JS's
53-bit safe-integer ceiling. -->
<select v-model="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>{{ 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 class="checkbox-field">
<input type="checkbox" v-model="formState.botSelfFilter" />
<span>{{ t('triggers.fields.botSelfFilter') }}</span>
</label>
<label class="span-2">{{ t('triggers.fields.payloadTemplate') }}
<textarea v-model="formState.payloadTemplate" rows="3"
:placeholder="t('triggers.fields.payloadTemplatePlaceholder')" />
</label>
<label class="span-2 checkbox-field">
<input type="checkbox" v-model="formState.enabled" />
<span>{{ t('triggers.fields.enabled') }}</span>
</label>
</div>
</div>
<footer class="trigger-modal-footer">
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.cancel') }}</button>
<button
class="btn-primary"
:disabled="busy || patternHasErrors"
:title="patternHasErrors ? Object.values(patternErrors).join('; ') : ''"
@click="save"
>{{ t('triggers.actions.save') }}</button>
</footer>
</section>
</div>
</Teleport>
<div class="triggers-panel">
<div v-if="!triggers.length" class="trigger-empty mc-surface-card">
<div class="trigger-empty-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z" />
</svg>
</div>
<h2 class="trigger-empty-title">{{ t('triggers.empty') }}</h2>
<p class="trigger-empty-desc">{{ t('triggers.emptyDesc') }}</p>
<button class="btn-primary" @click="openCreate">{{ t('triggers.newTrigger') }}</button>
</div>
<div v-else class="trigger-list">
<article
v-for="row in triggers"
:key="row.id"
class="trigger-card mc-surface-card"
:class="{ 'is-disabled': !row.enabled }"
>
<span
class="trigger-status-dot"
:class="row.enabled ? 'is-on' : 'is-off'"
:title="row.enabled ? t('triggers.enabled') : t('triggers.disabled')"
/>
<div class="trigger-main">
<div class="trigger-headline">
<span class="trigger-name">{{ row.name || t('triggers.unnamed') }}</span>
<span v-if="row.lastError" class="trigger-error-chip" :title="row.lastError">
{{ truncateError(row.lastError) }}
</span>
</div>
<div class="trigger-rule">
<span class="trigger-type-pill" :title="row.patternType">
{{ patternTypeLabel(row.patternType) }}
</span>
<span class="trigger-rule-arrow" aria-hidden="true"></span>
<span class="trigger-target" :title="'#' + row.targetId">{{ targetName(row) }}</span>
</div>
<div class="trigger-meta">
<span class="trigger-meta-summary" :title="row.patternJson">{{ patternSummary(row) }}</span>
<span class="trigger-meta-sep" aria-hidden="true">·</span>
<span>{{ activityLine(row) }}</span>
</div>
</div>
<div class="trigger-controls">
<button
type="button"
class="trigger-toggle"
:class="{ 'is-on': row.enabled }"
:title="row.enabled ? t('triggers.enabled') : t('triggers.disabled')"
@click="toggleEnabled(row)"
>
<span class="trigger-toggle-knob" />
</button>
<button class="row-btn" :title="t('triggers.actions.edit')" @click="openEdit(row)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</button>
<button class="row-btn danger" :title="t('triggers.actions.delete')" @click="remove(row)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
</svg>
</button>
</div>
</article>
</div>
<Teleport to="body">
<div v-if="formOpen" class="trigger-modal-overlay" @click.self="closeForm">
<section class="trigger-modal" role="dialog" aria-modal="true">
<header class="trigger-modal-header">
<strong>{{ editing?.id ? t('triggers.formTitleEdit') : t('triggers.formTitleNew') }}</strong>
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.close') }}</button>
</header>
<div class="trigger-modal-body">
<div class="form-grid">
<label>{{ t('triggers.fields.name') }}
<input v-model="formState.name" :placeholder="t('triggers.fields.namePlaceholder')" />
</label>
<label>{{ t('triggers.fields.patternType') }}
<!-- v0 ships four pattern types in the manual create UI:
cron, channel_message (which now carries the keyword
filter that content_match used to provide),
workflow_completion, webhook. content_match and
agent_lifecycle remain supported by the matcher for
legacy rows but are hidden from the new-trigger
dropdown so the manual entry matches what the
natural-language generator surfaces. They re-appear
only when editing a row that already uses one, so
the operator can tweak/disable/delete it without
being stuck on a dropdown that excludes its type. -->
<select v-model="formState.patternType">
<option value="cron">cron</option>
<option value="channel_message">channel_message</option>
<option value="workflow_completion">workflow_completion</option>
<option value="webhook">webhook</option>
<option v-if="showLegacyContentMatch" value="content_match">content_match (legacy)</option>
<option v-if="showLegacyAgentLifecycle" value="agent_lifecycle">agent_lifecycle (legacy)</option>
</select>
</label>
<div class="span-2">
<span class="form-grid-label">{{ t('triggers.fields.patternJson') }}</span>
<TriggerPatternForm
v-model="formState.patternJson"
:pattern-type="formState.patternType"
:available-workflows="availableWorkflows"
@validation="onPatternValidation"
/>
</div>
<label>{{ t('triggers.fields.targetType') }}
<!-- v0 only ships the workflow dispatcher; agent target is
reserved for v1 and rejected by the API to avoid the
"looks enabled, never fires" trap. -->
<select v-model="formState.targetType" disabled>
<option value="workflow">workflow</option>
</select>
</label>
<label>{{ t('triggers.fields.targetId') }}
<!-- Keep targetId as a string so 19-digit Snowflake IDs survive
the v-model round trip; .number would truncate to JS's
53-bit safe-integer ceiling. -->
<select v-model="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>{{ 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 class="checkbox-field">
<input type="checkbox" v-model="formState.botSelfFilter" />
<span>{{ t('triggers.fields.botSelfFilter') }}</span>
</label>
<label class="span-2">{{ t('triggers.fields.payloadTemplate') }}
<textarea v-model="formState.payloadTemplate" rows="3"
:placeholder="t('triggers.fields.payloadTemplatePlaceholder')" />
</label>
<label class="span-2 checkbox-field">
<input type="checkbox" v-model="formState.enabled" />
<span>{{ t('triggers.fields.enabled') }}</span>
</label>
</div>
</div>
<footer class="trigger-modal-footer">
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.cancel') }}</button>
<button
class="btn-primary"
:disabled="busy || patternHasErrors"
:title="patternHasErrors ? Object.values(patternErrors).join('; ') : ''"
@click="save"
>{{ t('triggers.actions.save') }}</button>
</footer>
</section>
</div>
</Teleport>
</div>
</template>
@ -203,6 +190,9 @@ const workspaceId = computed(() => workspaceStore.currentWorkspaceId)
const triggers = ref<TriggerSummary[]>([])
const workflows = ref<WorkflowSummary[]>([])
// Report the trigger count up to the Scheduler shell for the tab badge.
const emit = defineEmits<{ count: [number] }>()
const availableWorkflows = computed(() =>
workflows.value.filter((w) => w.latestRevisionId)
)
@ -431,17 +421,19 @@ async function remove(row: TriggerSummary) {
onMounted(reload)
watch(workspaceId, reload)
watch(() => triggers.value.length, (n) => emit('count', n), { immediate: true })
// The Scheduler shell owns the "+ New Trigger" header button; expose the
// modal opener so it can drive this panel when the Event Triggers tab is active.
defineExpose({ openCreate })
</script>
<style scoped>
.triggers-page {
.triggers-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.mc-page-header .btn-primary {
white-space: nowrap;
}
/* ── Trigger list — one card per rule, reads as "when X → run Y" ── */
.trigger-list {
display: flex;
@ -784,8 +776,8 @@ button:disabled {
cursor: not-allowed;
}
/* Mobile / tablet: let the rule line wrap instead of truncating, collapse
the form to one column, and stack the page header. */
/* Mobile / tablet: let the rule line wrap instead of truncating and collapse
the form to one column. */
@media (max-width: 720px) {
.trigger-rule {
flex-wrap: wrap;
@ -796,10 +788,5 @@ button:disabled {
.form-grid .span-2 {
grid-column: span 1;
}
.mc-page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
</style>

View File

@ -0,0 +1,242 @@
<template>
<div class="sched-page">
<header class="sched-header">
<div class="sched-lead">
<div class="sched-kicker">{{ t('scheduler.kicker') }}</div>
<h1 class="sched-title">{{ t('scheduler.title') }}</h1>
<p class="sched-desc">{{ t('scheduler.desc') }}</p>
</div>
<button class="sched-action" @click="onAction">
<svg v-if="activeTab === 'history'" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 4v6h-6" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
{{ actionLabel }}
</button>
</header>
<nav class="sched-tabs" role="tablist">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
role="tab"
class="sched-tab"
:class="{ active: activeTab === tab.id }"
:aria-selected="activeTab === tab.id"
@click="activeTab = tab.id"
>
{{ tab.label }}
<span v-if="counts[tab.id]" class="tab-badge">{{ counts[tab.id] }}</span>
</button>
</nav>
<div class="sched-body">
<CronJobsPanel v-if="activeTab === 'jobs'" ref="jobsPanel" @count="counts.jobs = $event" />
<TriggersPanel v-else-if="activeTab === 'triggers'" ref="triggersPanel" @count="counts.triggers = $event" />
<RunHistoryPanel v-else ref="historyPanel" @count="counts.history = $event" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import CronJobsPanel from './CronJobsPanel.vue'
import TriggersPanel from './TriggersPanel.vue'
import RunHistoryPanel from './RunHistoryPanel.vue'
type TabId = 'jobs' | 'triggers' | 'history'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const TAB_IDS: TabId[] = ['jobs', 'triggers', 'history']
function isTabId(value: unknown): value is TabId {
return typeof value === 'string' && (TAB_IDS as string[]).includes(value)
}
// The active tab is mirrored into the `?tab=` query so a reload or a
// redirect from the legacy /settings/triggers route lands on the right tab.
const activeTab = ref<TabId>(isTabId(route.query.tab) ? route.query.tab : 'jobs')
watch(activeTab, (tab) => {
if (route.query.tab !== tab) {
router.replace({ query: { ...route.query, tab } })
}
})
watch(
() => route.query.tab,
(tab) => {
if (isTabId(tab) && tab !== activeTab.value) activeTab.value = tab
},
)
const tabs = computed(() => [
{ id: 'jobs' as TabId, label: t('scheduler.tabs.jobs') },
{ id: 'triggers' as TabId, label: t('scheduler.tabs.triggers') },
{ id: 'history' as TabId, label: t('scheduler.tabs.history') },
])
// Per-tab item counts shown as badges. Each panel emits `count` on mount and
// whenever its list size changes; the value sticks until the next emit.
const counts = ref<Record<TabId, number>>({ jobs: 0, triggers: 0, history: 0 })
const actionLabel = computed(() => {
if (activeTab.value === 'history') return t('scheduler.actions.refresh')
if (activeTab.value === 'triggers') return t('scheduler.actions.newTrigger')
return t('scheduler.actions.newJob')
})
// The header button drives the active panel through the method it exposes:
// `openCreate` for the job/trigger tabs, `refresh` for the history tab. Each
// panel keeps its own ref so the call always lands on the mounted instance.
const jobsPanel = ref<InstanceType<typeof CronJobsPanel> | null>(null)
const triggersPanel = ref<InstanceType<typeof TriggersPanel> | null>(null)
const historyPanel = ref<InstanceType<typeof RunHistoryPanel> | null>(null)
function onAction() {
if (activeTab.value === 'jobs') jobsPanel.value?.openCreate()
else if (activeTab.value === 'triggers') triggersPanel.value?.openCreate()
else historyPanel.value?.refresh()
}
</script>
<style scoped>
.sched-page {
display: flex;
flex-direction: column;
gap: 20px;
}
.sched-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.sched-lead {
display: flex;
flex-direction: column;
gap: 8px;
}
.sched-kicker {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 6px 12px;
border: 1px solid color-mix(in srgb, var(--mc-primary) 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--mc-primary-bg) 72%, var(--mc-bg-elevated) 28%);
color: var(--mc-primary-hover);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.sched-title {
font-size: clamp(24px, 3vw, 34px);
line-height: 1.05;
font-weight: 800;
color: var(--mc-text-primary);
margin: 0;
}
.sched-desc {
max-width: 620px;
font-size: 14px;
line-height: 1.6;
color: var(--mc-text-secondary);
margin: 0;
}
.sched-action {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
background: var(--mc-primary);
color: white;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.sched-action:hover { background: var(--mc-primary-hover); }
.sched-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--mc-border);
}
.sched-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: none;
background: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: 14px;
font-weight: 600;
color: var(--mc-text-secondary);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.sched-tab:hover { color: var(--mc-text-primary); }
.sched-tab.active {
color: var(--mc-primary);
border-bottom-color: var(--mc-primary);
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
border-radius: 999px;
background: var(--mc-bg-sunken);
color: var(--mc-text-tertiary);
font-size: 11px;
font-weight: 700;
}
.sched-tab.active .tab-badge {
background: var(--mc-primary-bg);
color: var(--mc-primary);
}
.sched-body {
min-height: 0;
}
@media (max-width: 720px) {
.sched-header {
flex-direction: column;
align-items: stretch;
}
.sched-action {
justify-content: center;
}
.sched-tabs {
overflow-x: auto;
}
.sched-tab {
white-space: nowrap;
}
}
</style>

View File

@ -53,7 +53,7 @@ const { t } = useI18n()
// Routes that benefit from extra editor width the sub-nav auto-collapses
// to a 56px rail unless the user has explicitly toggled it open.
const COMPACT_ROUTES = ['/settings/workflows', '/settings/triggers']
const COMPACT_ROUTES = ['/settings/workflows']
const navCollapsed = ref(localStorage.getItem('mc-settings-nav-collapsed') === 'true')
const userExplicit = ref(localStorage.getItem('mc-settings-nav-collapsed') !== null)
@ -150,9 +150,9 @@ const sections = computed(() => [
// Divider: Advanced
{ id: 'divider-advanced', path: '', label: t('settings.sections.advanced'), icon: '', isDivider: true },
{
id: 'cron-jobs',
path: '/settings/cron-jobs',
label: t('nav.cronJobs'),
id: 'scheduler',
path: '/settings/scheduler',
label: t('nav.scheduler', 'Scheduler'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
},
{
@ -161,12 +161,6 @@ const sections = computed(() => [
label: t('nav.workflows', 'Workflows'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
},
{
id: 'triggers',
path: '/settings/triggers',
label: t('nav.triggers', 'Triggers'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
},
{
id: 'skill-curator',
path: '/settings/skill-curator',