mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(scheduler): unify cron jobs and triggers into a tabbed Scheduler page
This commit is contained in:
parent
c0f9d1d9fb
commit
daeef4e3c2
758
mateclaw-ui/src/components/common/CronExpressionField.vue
Normal file
758
mateclaw-ui/src/components/common/CronExpressionField.vue
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '定时任务',
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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">⏱</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">⏱</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;
|
||||
}
|
||||
289
mateclaw-ui/src/views/Scheduler/RunHistoryPanel.vue
Normal file
289
mateclaw-ui/src/views/Scheduler/RunHistoryPanel.vue
Normal 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">⏳</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>
|
||||
@ -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>
|
||||
242
mateclaw-ui/src/views/Scheduler/index.vue
Normal file
242
mateclaw-ui/src/views/Scheduler/index.vue
Normal 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>
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user