import { useEffect, useMemo, useState, type ReactNode, type SVGProps, type SelectHTMLAttributes, } from 'react'; import { DEFAULT_AGENT_ID, normalizeAgentId, normalizeChannelType, type AgentSummary } from '@runtime/lib/agents'; import { onGatewayEvent } from '../../lib/gateway-client'; import { hostApiFetch } from '../../lib/host-api'; import type { ChannelTargetCatalogItem, ChannelTargetsCatalogResponse, } from '../../lib/channel-types'; import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../lib/runtime-events'; import type { CronDeliveryChannelAccount, CronDeliveryChannelGroup, CronJob, CronJobCreateInput, CronJobDelivery, } from '../../lib/cron-types'; import { agentsStore, useAgentsStore } from '../../stores'; import { useI18n, type LanguageCode } from '../../i18n'; type FeedbackTone = 'success' | 'error' | 'info' | 'warning'; type FeedbackState = { tone: FeedbackTone; message: string; } | null; type Translate = (path: string, params?: Record) => string; type DialogProps = { open: boolean; job: CronJob | null; saving: boolean; defaultAgentId: string; channelGroups: CronDeliveryChannelGroup[]; channelsLoading: boolean; channelsError: string | null; onClose: () => void; onSave: (input: CronJobCreateInput) => Promise; }; type JobCardProps = { job: CronJob; agentLabel: string; agentDetail: string; deliverySummary: string; busyAction: string | null; onToggle: (enabled: boolean) => void; onEdit: () => void; onDelete: () => void; onTrigger: () => void; }; type StatCardProps = { label: string; value: number; tone: 'neutral' | 'green' | 'amber' | 'red'; icon: (props: SVGProps) => JSX.Element; }; type SchedulePreset = { key: string; value: string; }; type CronJobsResponse = | CronJob[] | { success?: boolean; jobs?: unknown; data?: unknown; items?: unknown; error?: string; }; type ChannelAccountsResponse = | unknown[] | { success?: boolean; channels?: unknown; groups?: unknown; accounts?: unknown; data?: unknown; error?: string; }; type ChannelTargetsResponse = | unknown[] | ChannelTargetsCatalogResponse | { success?: boolean; targets?: unknown; items?: unknown; data?: unknown; error?: string; }; type DeliveryChannelGroupLike = Partial & { name?: string; label?: string; channelName?: string; accounts?: unknown; }; type DeliveryChannelAccountLike = Partial & { id?: string; label?: string; channelName?: string; channelType?: string; name?: string; default?: boolean; }; type DeliveryTargetOptionLike = Partial & { name?: string; title?: string; desc?: string; }; const SCHEDULE_PRESETS: SchedulePreset[] = [ { key: 'every-minute', value: '* * * * *' }, { key: 'every-5-minutes', value: '*/5 * * * *' }, { key: 'every-15-minutes', value: '*/15 * * * *' }, { key: 'every-hour', value: '0 * * * *' }, { key: 'daily-9', value: '0 9 * * *' }, { key: 'daily-18', value: '0 18 * * *' }, { key: 'weekly-mon', value: '0 9 * * 1' }, { key: 'monthly-first', value: '0 9 1 * *' }, ]; const CHANNEL_NAME_KEYS: Record = { douyin: 'cron.channels.douyin', feishu: 'cron.channels.feishu', fliggy: 'cron.channels.fliggy', meituan: 'cron.channels.meituan', qqbot: 'cron.channels.qqbot', telegram: 'cron.channels.telegram', wechat: 'cron.channels.wechat', wecom: 'cron.channels.wecom', }; function buildFallbackCronJobs(t: Translate): CronJob[] { return [ { id: 'cron-morning-briefing', name: t('cron.fallback.jobs.morningBriefing.name'), message: t('cron.fallback.jobs.morningBriefing.message'), schedule: '0 9 * * *', agentId: 'main', delivery: { mode: 'announce', channel: 'wecom', accountId: 'default', to: t('cron.fallback.jobs.morningBriefing.target'), }, enabled: true, createdAt: '2026-04-10T09:00:00.000Z', updatedAt: '2026-04-16T09:00:00.000Z', nextRun: new Date(Date.now() + 1000 * 60 * 60 * 7).toISOString(), lastRun: { time: new Date(Date.now() - 1000 * 60 * 60 * 15).toISOString(), success: true, }, }, { id: 'cron-channel-check', name: t('cron.fallback.jobs.channelCheck.name'), message: t('cron.fallback.jobs.channelCheck.message'), schedule: '*/15 * * * *', agentId: 'main', delivery: { mode: 'none' }, enabled: true, createdAt: '2026-04-11T08:30:00.000Z', updatedAt: '2026-04-16T08:30:00.000Z', nextRun: new Date(Date.now() + 1000 * 60 * 12).toISOString(), lastRun: { time: new Date(Date.now() - 1000 * 60 * 6).toISOString(), success: true, }, }, { id: 'cron-review-summary', name: t('cron.fallback.jobs.reviewSummary.name'), message: t('cron.fallback.jobs.reviewSummary.message'), schedule: '0 18 * * *', agentId: 'main', delivery: { mode: 'announce', channel: 'feishu', accountId: 'default', to: t('cron.fallback.jobs.reviewSummary.target'), }, enabled: false, createdAt: '2026-04-09T18:00:00.000Z', updatedAt: '2026-04-15T18:00:00.000Z', lastRun: { time: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(), success: false, error: t('cron.fallback.jobs.reviewSummary.error'), }, }, ]; } function cn(...tokens: Array): string { return tokens.filter(Boolean).join(' '); } function getString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function IconBase({ children, className, ...props }: SVGProps & { children: ReactNode }) { return ( {children} ); } function RefreshIcon(props: SVGProps) { return ( ); } function PlusIcon(props: SVGProps) { return ( ); } function ClockIcon(props: SVGProps) { return ( ); } function PlayIcon(props: SVGProps) { return ( ); } function PauseIcon(props: SVGProps) { return ( ); } function AlertIcon(props: SVGProps) { return ( ); } function MessageIcon(props: SVGProps) { return ( ); } function HistoryIcon(props: SVGProps) { return ( ); } function CalendarIcon(props: SVGProps) { return ( ); } function TrashIcon(props: SVGProps) { return ( ); } function CloseIcon(props: SVGProps) { return ( ); } function SpinnerIcon(props: SVGProps) { return ( ); } function BotIcon(props: SVGProps) { return ( ); } function SendIcon(props: SVGProps) { return ( ); } function ChevronDownIcon(props: SVGProps) { return ( ); } function getScheduleExpression(schedule: CronJob['schedule']): string { if (typeof schedule === 'string') return schedule; if (schedule.kind === 'cron') return schedule.expr; if (schedule.kind === 'at') return schedule.at; if (schedule.kind === 'every') return String(schedule.everyMs); return ''; } function parseCronSchedule(schedule: CronJob['schedule'], t: Translate, locale: LanguageCode): string { if (typeof schedule !== 'string') { if (schedule.kind === 'at') { return t('cron.schedule.once', { time: formatDateTime(schedule.at, locale) }); } if (schedule.kind === 'every') { const everyMs = schedule.everyMs; if (everyMs < 60_000) return t('cron.schedule.everySeconds', { count: Math.round(everyMs / 1000) }); if (everyMs < 3_600_000) return t('cron.schedule.everyMinutes', { count: Math.round(everyMs / 60_000) }); if (everyMs < 86_400_000) return t('cron.schedule.everyHours', { count: Math.round(everyMs / 3_600_000) }); return t('cron.schedule.everyDays', { count: Math.round(everyMs / 86_400_000) }); } return schedule.expr; } const preset = SCHEDULE_PRESETS.find((item) => item.value === schedule); if (preset) return t(`cron.schedule.presets.${preset.key}`); const parts = schedule.trim().split(/\s+/); if (parts.length !== 5) return schedule; const [minute, hour, dayOfMonth, , dayOfWeek] = parts; const time = `${hour}:${minute.padStart(2, '0')}`; if (minute === '*' && hour === '*') return t('cron.schedule.everyMinute'); if (minute.startsWith('*/')) return t('cron.schedule.everyNMinutes', { count: minute.slice(2) }); if (hour === '*' && minute === '0') return t('cron.schedule.hourly'); if (dayOfWeek !== '*' && dayOfMonth === '*') return t('cron.schedule.weekly', { weekday: formatWeekday(dayOfWeek, t), time }); if (dayOfMonth !== '*') return t('cron.schedule.monthly', { day: dayOfMonth, time }); if (hour !== '*') return t('cron.schedule.daily', { time }); return schedule; } function estimateNextRunDate(scheduleExpr: string): Date | null { const now = new Date(); const next = new Date(now); if (scheduleExpr === '* * * * *') { next.setSeconds(0, 0); next.setMinutes(next.getMinutes() + 1); return next; } if (scheduleExpr === '*/5 * * * *') { const remainder = next.getMinutes() % 5; next.setSeconds(0, 0); next.setMinutes(next.getMinutes() + (remainder === 0 ? 5 : 5 - remainder)); return next; } if (scheduleExpr === '*/15 * * * *') { const remainder = next.getMinutes() % 15; next.setSeconds(0, 0); next.setMinutes(next.getMinutes() + (remainder === 0 ? 15 : 15 - remainder)); return next; } if (scheduleExpr === '0 * * * *') { next.setMinutes(0, 0, 0); next.setHours(next.getHours() + 1); return next; } if (scheduleExpr === '0 9 * * *' || scheduleExpr === '0 18 * * *') { const targetHour = scheduleExpr === '0 9 * * *' ? 9 : 18; next.setHours(targetHour, 0, 0, 0); if (next <= now) next.setDate(next.getDate() + 1); return next; } if (scheduleExpr === '0 9 * * 1') { next.setHours(9, 0, 0, 0); const weekday = next.getDay(); const daysUntilMonday = weekday === 1 ? 7 : (8 - weekday) % 7; next.setDate(next.getDate() + daysUntilMonday); return next; } if (scheduleExpr === '0 9 1 * *') { next.setDate(1); next.setHours(9, 0, 0, 0); if (next <= now) next.setMonth(next.getMonth() + 1); return next; } return null; } function estimateNextRun(scheduleExpr: string): string | null { const next = estimateNextRunDate(scheduleExpr); return next ? next.toISOString() : null; } function formatWeekday(weekday: string, t: Translate): string { const weekdayMap: Record = { '0': 'sun', '1': 'mon', '2': 'tue', '3': 'wed', '4': 'thu', '5': 'fri', '6': 'sat', '7': 'sun', }; const key = weekdayMap[weekday]; return key ? t(`cron.weekdays.${key}`) : weekday; } function resolveDateLocale(locale: LanguageCode): string { if (locale === 'zh') return 'zh-CN'; if (locale === 'ja') return 'ja-JP'; return 'en-US'; } function formatDateTime(value: string, locale: LanguageCode): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString(resolveDateLocale(locale), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } function formatRelativeTime(value: string, t: Translate, locale: LanguageCode): string { const target = new Date(value).getTime(); if (Number.isNaN(target)) return value; const diff = Date.now() - target; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (seconds < 10) return t('cron.time.justNow'); if (seconds < 60) return t('cron.time.secondsAgo', { count: seconds }); if (minutes < 60) return t('cron.time.minutesAgo', { count: minutes }); if (hours < 24) return t('cron.time.hoursAgo', { count: hours }); if (days < 30) return t('cron.time.daysAgo', { count: days }); return formatDateTime(value, locale); } function normalizeCronScheduleValue(value: unknown): CronJob['schedule'] { if (typeof value === 'string' && value.trim()) return value.trim(); if (!isRecord(value)) return '0 9 * * *'; const kind = getString(value.kind); if (kind === 'cron') { const expr = getString(value.expr); return { kind: 'cron', expr: expr || '0 9 * * *', tz: getString(value.tz) || undefined }; } if (kind === 'at') { const at = getString(value.at); return { kind: 'at', at: at || new Date().toISOString() }; } if (kind === 'every') { const everyMs = typeof value.everyMs === 'number' && Number.isFinite(value.everyMs) ? value.everyMs : 60_000; const anchorMs = typeof value.anchorMs === 'number' && Number.isFinite(value.anchorMs) ? value.anchorMs : undefined; return { kind: 'every', everyMs, anchorMs }; } return '0 9 * * *'; } function normalizeCronDelivery(value: unknown): CronJobDelivery { if (!isRecord(value)) return { mode: 'none' }; const mode = getString(value.mode) === 'announce' ? 'announce' : 'none'; const channel = getString(value.channel) || undefined; const to = getString(value.to) || undefined; const accountId = getString(value.accountId) || undefined; if (mode === 'none') { return { mode: 'none' }; } return { mode, channel, to, accountId, }; } function normalizeCronJob(value: unknown): CronJob | null { if (!isRecord(value)) return null; const id = getString(value.id); if (!id) return null; const name = getString(value.name); const message = getString(value.message); const schedule = normalizeCronScheduleValue(value.schedule); const enabled = value.enabled !== false; const createdAt = getString(value.createdAt) || new Date().toISOString(); const updatedAt = getString(value.updatedAt) || createdAt; const agentId = getString(value.agentId) ? normalizeAgentId(value.agentId) : undefined; const nextRun = getString(value.nextRun) || undefined; const lastRun = isRecord(value.lastRun) ? { time: getString(value.lastRun.time) || new Date().toISOString(), success: value.lastRun.success !== false, error: getString(value.lastRun.error) || undefined, duration: typeof value.lastRun.duration === 'number' ? value.lastRun.duration : undefined, } : undefined; return { id, name, message, schedule, agentId, delivery: normalizeCronDelivery(value.delivery), enabled, createdAt, updatedAt, lastRun, nextRun, }; } function normalizeCronJobs(payload: unknown): CronJob[] { if (Array.isArray(payload)) { return payload.map(normalizeCronJob).filter((job): job is CronJob => Boolean(job)); } if (!isRecord(payload)) return []; const collection = Array.isArray(payload.jobs) ? payload.jobs : Array.isArray(payload.items) ? payload.items : Array.isArray(payload.data) ? payload.data : []; return collection.map(normalizeCronJob).filter((job): job is CronJob => Boolean(job)); } function normalizeCronJobResult(payload: unknown): CronJob | null { if (isRecord(payload) && 'job' in payload) { return normalizeCronJob(payload.job); } if (isRecord(payload) && 'data' in payload && !Array.isArray(payload.data)) { return normalizeCronJob(payload.data); } return normalizeCronJob(payload); } function normalizeDeliveryChannelAccount( value: unknown, fallbackDefaultAccountId?: string, ): CronDeliveryChannelAccount | null { if (!isRecord(value)) return null; const account = value as DeliveryChannelAccountLike; const accountId = getString(account.accountId) || getString(account.id) || 'default'; const name = getString(account.name) || getString(account.label) || getString(account.channelName) || accountId; return { accountId, name, isDefault: Boolean(account.isDefault || account.default || accountId === fallbackDefaultAccountId || accountId === 'default'), }; } function normalizeGroupedChannelEntry(value: unknown): CronDeliveryChannelGroup | null { if (!isRecord(value)) return null; const group = value as DeliveryChannelGroupLike; const channelType = normalizeChannelType(getString(group.channelType)); if (!channelType) return null; const fallbackDefaultAccountId = getString(group.defaultAccountId) || 'default'; const accounts = Array.isArray(group.accounts) ? group.accounts .map((account) => normalizeDeliveryChannelAccount(account, fallbackDefaultAccountId)) .filter((account): account is CronDeliveryChannelAccount => Boolean(account)) : []; const uniqueAccounts = dedupeDeliveryAccounts(accounts); const defaultAccountId = uniqueAccounts.find((account) => account.isDefault)?.accountId || fallbackDefaultAccountId || uniqueAccounts[0]?.accountId || 'default'; return { channelType, defaultAccountId, accounts: uniqueAccounts.map((account) => account.accountId === defaultAccountId ? { ...account, isDefault: true } : account, ), }; } function dedupeDeliveryAccounts(accounts: CronDeliveryChannelAccount[]): CronDeliveryChannelAccount[] { const byId = new Map(); for (const account of accounts) { const existing = byId.get(account.accountId); if (!existing) { byId.set(account.accountId, account); continue; } byId.set(account.accountId, { accountId: account.accountId, name: existing.name || account.name, isDefault: existing.isDefault || account.isDefault, }); } return Array.from(byId.values()).sort((left, right) => { if (left.isDefault !== right.isDefault) return left.isDefault ? -1 : 1; return left.name.localeCompare(right.name, 'zh-CN'); }); } function normalizeFlatChannelAccounts(payload: unknown[]): CronDeliveryChannelGroup[] { const groupMap = new Map(); for (const entry of payload) { if (!isRecord(entry)) continue; const account = entry as DeliveryChannelAccountLike; const channelType = normalizeChannelType(getString(account.channelType)); if (!channelType) continue; const normalizedAccount = normalizeDeliveryChannelAccount(entry); if (!normalizedAccount) continue; const currentAccounts = groupMap.get(channelType) ?? []; currentAccounts.push(normalizedAccount); groupMap.set(channelType, currentAccounts); } return Array.from(groupMap.entries()) .map(([channelType, accounts]) => { const uniqueAccounts = dedupeDeliveryAccounts(accounts); const defaultAccountId = uniqueAccounts.find((account) => account.isDefault)?.accountId || uniqueAccounts[0]?.accountId || 'default'; return { channelType, defaultAccountId, accounts: uniqueAccounts.map((account) => account.accountId === defaultAccountId ? { ...account, isDefault: true } : account, ), }; }) .sort((left, right) => getChannelDisplayName(left.channelType).localeCompare(getChannelDisplayName(right.channelType), 'zh-CN')); } function normalizeDeliveryChannelGroups(payload: unknown): CronDeliveryChannelGroup[] { if (Array.isArray(payload)) { const groupedEntries = payload .map(normalizeGroupedChannelEntry) .filter((entry): entry is CronDeliveryChannelGroup => Boolean(entry)); if (groupedEntries.length > 0) { return groupedEntries.sort((left, right) => getChannelDisplayName(left.channelType).localeCompare(getChannelDisplayName(right.channelType), 'zh-CN'), ); } return normalizeFlatChannelAccounts(payload); } if (!isRecord(payload)) return []; if (Array.isArray(payload.channels)) { return normalizeDeliveryChannelGroups(payload.channels); } if (Array.isArray(payload.groups)) { return normalizeDeliveryChannelGroups(payload.groups); } if (Array.isArray(payload.accounts)) { return normalizeDeliveryChannelGroups(payload.accounts); } if (Array.isArray(payload.data)) { return normalizeDeliveryChannelGroups(payload.data); } return []; } function normalizeDeliveryTargetOption(value: unknown): ChannelTargetCatalogItem | null { if (!isRecord(value)) return null; const option = value as DeliveryTargetOptionLike; const targetValue = getString(option.value); if (!targetValue) return null; const kind = option.kind === 'name' || option.kind === 'identifier' || option.kind === 'webhook' || option.kind === 'url' ? option.kind : undefined; const source = option.source === 'channel-name' || option.source === 'account-id' || option.source === 'remote' || option.source === 'query-param' || option.source === 'hash-param' || option.source === 'channel-url' || option.source === 'fallback' ? option.source : undefined; return { value: targetValue, label: getString(option.label) || getString(option.title) || getString(option.name) || targetValue, description: getString(option.description) || getString(option.desc) || undefined, kind, source, channelType: getString(option.channelType) || undefined, accountId: getString(option.accountId) || undefined, }; } function dedupeDeliveryTargetOptions(options: ChannelTargetCatalogItem[]): ChannelTargetCatalogItem[] { const byValue = new Map(); for (const option of options) { const existing = byValue.get(option.value); if (!existing) { byValue.set(option.value, option); continue; } byValue.set(option.value, { ...existing, label: existing.label || option.label, description: existing.description || option.description, kind: existing.kind || option.kind, source: existing.source || option.source, channelType: existing.channelType || option.channelType, accountId: existing.accountId || option.accountId, }); } const kindOrder: Record, number> = { name: 0, identifier: 1, webhook: 2, url: 3, }; return Array.from(byValue.values()).sort((left, right) => { const leftOrder = left.kind ? kindOrder[left.kind] : 99; const rightOrder = right.kind ? kindOrder[right.kind] : 99; if (leftOrder !== rightOrder) return leftOrder - rightOrder; return left.label.localeCompare(right.label, 'zh-CN'); }); } function normalizeDeliveryTargetOptions(payload: unknown): ChannelTargetCatalogItem[] { if (Array.isArray(payload)) { return dedupeDeliveryTargetOptions( payload .map(normalizeDeliveryTargetOption) .filter((option): option is ChannelTargetCatalogItem => Boolean(option)), ); } if (!isRecord(payload)) return []; if (Array.isArray(payload.targets)) { return normalizeDeliveryTargetOptions(payload.targets); } if (Array.isArray(payload.items)) { return normalizeDeliveryTargetOptions(payload.items); } if (Array.isArray(payload.data)) { return normalizeDeliveryTargetOptions(payload.data); } return []; } function mergeDeliveryTargetOptions( options: ChannelTargetCatalogItem[], currentValue: string, t: Translate, ): ChannelTargetCatalogItem[] { const trimmedValue = currentValue.trim(); if (!trimmedValue) { return options; } if (options.some((option) => option.value === trimmedValue)) { return options; } return dedupeDeliveryTargetOptions([ { value: trimmedValue, label: trimmedValue, description: t('cron.deliveryTarget.savedCustom'), source: 'fallback', }, ...options, ]); } function ensureChannelGroupSelection( groups: CronDeliveryChannelGroup[], channelType: string, accountId?: string, ): CronDeliveryChannelGroup[] { const normalizedChannelType = normalizeChannelType(channelType); if (!normalizedChannelType) return groups; if (groups.some((group) => group.channelType === normalizedChannelType)) return groups; return [ ...groups, { channelType: normalizedChannelType, defaultAccountId: accountId || 'default', accounts: accountId ? [{ accountId, name: accountId, isDefault: true }] : [], }, ]; } function getChannelDisplayName(channelType: string, t: Translate): string { const normalized = normalizeChannelType(channelType); if (!normalized) return t('cron.channels.unnamed'); const key = CHANNEL_NAME_KEYS[normalized]; if (key) { const translated = t(key); if (translated !== key) return translated; } return normalized .split(/[-_]/) .filter(Boolean) .map((token) => token.slice(0, 1).toUpperCase() + token.slice(1)) .join(' '); } function getDeliveryAccountDisplayName(account: CronDeliveryChannelAccount | undefined, t: Translate): string { if (!account) return t('cron.channels.mainAccount'); if (account.accountId === 'default' && (account.name === 'default' || !account.name.trim())) return t('cron.channels.mainAccount'); return account.name; } function getAgentLabel( agentId: string | null | undefined, agentsById: Map, defaultAgentId: string, t: Translate, ): string { const resolvedId = normalizeAgentId(agentId || defaultAgentId || DEFAULT_AGENT_ID); const resolvedAgent = agentsById.get(resolvedId); if (resolvedAgent?.name) return resolvedAgent.name; if (resolvedId === normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID)) return t('cron.agent.main'); return resolvedId; } function formatPathTail(value: string): string { const normalized = value.replace(/\\/g, '/').trim(); if (!normalized) return ''; const parts = normalized.split('/').filter(Boolean); if (parts.length <= 2) return normalized; return `.../${parts.slice(-2).join('/')}`; } function getAgentDetail( agentId: string | null | undefined, agentsById: Map, defaultAgentId: string, t: Translate, ): string { const resolvedId = normalizeAgentId(agentId || defaultAgentId || DEFAULT_AGENT_ID); const resolvedAgent = agentsById.get(resolvedId); if (resolvedAgent?.workspace) { return t('cron.agent.workspace', { path: formatPathTail(resolvedAgent.workspace) }); } if (resolvedAgent?.agentDir) { return t('cron.agent.dir', { path: formatPathTail(resolvedAgent.agentDir) }); } if (resolvedId === normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID) || resolvedAgent?.isDefault) { return t('cron.agent.sharedMainWorkspace'); } return t('cron.agent.syncPending'); } function describeDelivery( delivery: CronJobDelivery | undefined, channelGroups: CronDeliveryChannelGroup[], t: Translate, ): string { if (!delivery || delivery.mode !== 'announce') { return t('cron.delivery.none'); } const channelType = normalizeChannelType(delivery.channel); if (!channelType) { return t('cron.delivery.pending'); } const channelGroup = channelGroups.find((group) => group.channelType === channelType); const accountId = delivery.accountId || channelGroup?.defaultAccountId; const account = channelGroup?.accounts.find((item) => item.accountId === accountId) || channelGroup?.accounts[0]; const target = getString(delivery.to); return [ getChannelDisplayName(channelType, t), account ? getDeliveryAccountDisplayName(account, t) : null, target || t('cron.delivery.targetPending'), ] .filter(Boolean) .join(' / '); } function buildLocalCronJob(input: CronJobCreateInput, current?: CronJob | null): CronJob { const schedule = input.schedule.trim(); const nextRun = estimateNextRunDate(schedule)?.toISOString() ?? current?.nextRun; return { id: current?.id ?? `cron-${Date.now()}`, name: input.name.trim(), message: input.message.trim(), schedule, agentId: input.agentId ? normalizeAgentId(input.agentId) : current?.agentId, enabled: input.enabled ?? current?.enabled ?? true, delivery: input.delivery ?? current?.delivery ?? { mode: 'none' }, createdAt: current?.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), lastRun: current?.lastRun, nextRun, }; } function toneClasses(tone: FeedbackTone): string { if (tone === 'success') { return 'border-green-200 bg-green-50 text-green-700 dark:border-green-900/70 dark:bg-green-900/20 dark:text-green-300'; } if (tone === 'error') { return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900/70 dark:bg-red-900/20 dark:text-red-300'; } if (tone === 'warning') { return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-900/20 dark:text-amber-300'; } return 'border-[#dfeaf6] bg-[#f8fbff] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#232327] dark:text-gray-300'; } function Notice({ tone, message, }: { tone: FeedbackTone; message: string; }) { return (
{message}
); } function SelectField({ className, children, ...props }: SelectHTMLAttributes & { children: ReactNode }) { return (
); } function StatCard({ label, value, tone, icon: Icon }: StatCardProps) { const iconWrapperClass = tone === 'green' ? 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400' : tone === 'amber' ? 'bg-amber-100 text-amber-600 dark:bg-amber-900/20 dark:text-amber-400' : tone === 'red' ? 'bg-red-100 text-red-500 dark:bg-red-900/20 dark:text-red-400' : 'bg-[#E8E6DE] text-[#7A7668] dark:bg-[#2a2a2d] dark:text-gray-400'; return (
{value}
{label}
); } function CronJobCard({ job, agentLabel, agentDetail, deliverySummary, busyAction, onToggle, onEdit, onDelete, onTrigger, }: JobCardProps) { const { t, locale } = useI18n(); const disabled = busyAction !== null; const isAnnouncement = job.delivery?.mode === 'announce'; const jobName = job.name.trim() ? job.name : t('cron.common.unnamedJob'); return (

{jobName}

{isAnnouncement ? t('cron.card.modeAnnounce') : t('cron.card.modeNone')}
{parseCronSchedule(job.schedule, t, locale)}

{job.message}

{t('cron.card.agentLabel')} {agentLabel} {agentDetail}
{t('cron.card.deliveryLabel')} {deliverySummary}
{job.lastRun ? ( {t('cron.card.lastRun', { time: formatRelativeTime(job.lastRun.time, t, locale) })} {job.lastRun.success ? t('cron.card.success') : t('cron.card.failed')} ) : null} {job.nextRun && job.enabled ? ( {t('cron.card.nextRun', { time: formatDateTime(job.nextRun, locale) })} ) : null}
{job.lastRun && !job.lastRun.success && job.lastRun.error ? (
{job.lastRun.error}
) : null}
); } function CronTaskDialog({ open, job, saving, defaultAgentId, channelGroups, channelsLoading, channelsError, onClose, onSave, }: DialogProps) { const { t, locale } = useI18n(); const [name, setName] = useState(''); const [message, setMessage] = useState(''); const [schedule, setSchedule] = useState('0 9 * * *'); const [enabled, setEnabled] = useState(true); const [useCustom, setUseCustom] = useState(false); const [customSchedule, setCustomSchedule] = useState(''); const [deliveryMode, setDeliveryMode] = useState<'none' | 'announce'>('none'); const [deliveryChannel, setDeliveryChannel] = useState(''); const [selectedDeliveryAccountId, setSelectedDeliveryAccountId] = useState(''); const [deliveryTarget, setDeliveryTarget] = useState(''); const [loadedDeliveryTargetOptions, setLoadedDeliveryTargetOptions] = useState([]); const [targetsLoading, setTargetsLoading] = useState(false); const [targetsError, setTargetsError] = useState(null); const [deliveryTargetScopeKey, setDeliveryTargetScopeKey] = useState(''); const [validationError, setValidationError] = useState(null); useEffect(() => { if (!open) return; const initialSchedule = job ? getScheduleExpression(job.schedule) || '0 9 * * *' : '0 9 * * *'; const matchedPreset = SCHEDULE_PRESETS.some((item) => item.value === initialSchedule); setName(job?.name ?? ''); setMessage(job?.message ?? ''); setSchedule(initialSchedule); setEnabled(job?.enabled ?? true); setUseCustom(!matchedPreset); setCustomSchedule(matchedPreset ? '' : initialSchedule); setDeliveryMode(job?.delivery?.mode === 'announce' ? 'announce' : 'none'); setDeliveryChannel(getString(job?.delivery?.channel)); setSelectedDeliveryAccountId(getString(job?.delivery?.accountId)); setDeliveryTarget(getString(job?.delivery?.to)); setLoadedDeliveryTargetOptions([]); setTargetsLoading(false); setTargetsError(null); setDeliveryTargetScopeKey(''); setValidationError(null); }, [job, open]); const availableChannelGroups = useMemo( () => ensureChannelGroupSelection(channelGroups, deliveryChannel, selectedDeliveryAccountId), [channelGroups, deliveryChannel, selectedDeliveryAccountId], ); const selectedChannelGroup = useMemo( () => availableChannelGroups.find((group) => group.channelType === normalizeChannelType(deliveryChannel)), [availableChannelGroups, deliveryChannel], ); const deliveryTargetOptions = useMemo( () => mergeDeliveryTargetOptions(loadedDeliveryTargetOptions, deliveryTarget, t), [deliveryTarget, loadedDeliveryTargetOptions, t], ); const targetListId = useMemo( () => `cron-delivery-targets-${job?.id ?? 'draft'}`, [job?.id], ); useEffect(() => { if (!open || deliveryMode !== 'announce') return; if (!deliveryChannel && availableChannelGroups[0]) { setDeliveryChannel(availableChannelGroups[0].channelType); } }, [availableChannelGroups, deliveryChannel, deliveryMode, open]); useEffect(() => { if (!open || deliveryMode !== 'announce') return; const resolvedDefaultAccountId = selectedChannelGroup?.defaultAccountId || selectedChannelGroup?.accounts[0]?.accountId || ''; if (!resolvedDefaultAccountId) { setSelectedDeliveryAccountId(''); return; } const hasSelectedAccount = selectedChannelGroup?.accounts.some((account) => account.accountId === selectedDeliveryAccountId); if (!selectedDeliveryAccountId || !hasSelectedAccount) { setSelectedDeliveryAccountId(resolvedDefaultAccountId); } }, [deliveryMode, open, selectedChannelGroup, selectedDeliveryAccountId]); useEffect(() => { if (!open || deliveryMode !== 'announce') { setDeliveryTargetScopeKey(''); return; } const scopeKey = `${normalizeChannelType(deliveryChannel)}:${getString(selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId)}`; if (!scopeKey || scopeKey === ':') { setDeliveryTargetScopeKey(''); return; } setDeliveryTargetScopeKey((currentScopeKey) => { if (currentScopeKey && currentScopeKey !== scopeKey) { setDeliveryTarget(''); } return scopeKey; }); }, [ deliveryChannel, deliveryMode, open, selectedChannelGroup?.defaultAccountId, selectedDeliveryAccountId, ]); useEffect(() => { if (!open || deliveryMode !== 'announce') { setLoadedDeliveryTargetOptions([]); setTargetsLoading(false); setTargetsError(null); return; } const normalizedChannelType = normalizeChannelType(deliveryChannel); const normalizedAccountId = getString(selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId); if (!normalizedChannelType) { setLoadedDeliveryTargetOptions([]); setTargetsLoading(false); setTargetsError(null); return; } let cancelled = false; const query = new URLSearchParams({ channelType: normalizedChannelType }); if (normalizedAccountId) { query.set('accountId', normalizedAccountId); } setTargetsLoading(true); setTargetsError(null); void hostApiFetch(`/api/channels/targets?${query.toString()}`) .then((response) => { if (cancelled) return; setLoadedDeliveryTargetOptions(normalizeDeliveryTargetOptions(response)); }) .catch((requestError) => { if (cancelled) return; setLoadedDeliveryTargetOptions([]); setTargetsError(requestError instanceof Error ? requestError.message : String(requestError)); }) .finally(() => { if (!cancelled) { setTargetsLoading(false); } }); return () => { cancelled = true; }; }, [ deliveryChannel, deliveryMode, open, selectedChannelGroup?.defaultAccountId, selectedDeliveryAccountId, ]); if (!open) return null; const finalSchedule = useCustom ? customSchedule.trim() : schedule; const nextRunPreview = finalSchedule ? estimateNextRun(finalSchedule) : null; const resolvedAgentId = normalizeAgentId(getString(job?.agentId) || defaultAgentId || DEFAULT_AGENT_ID); const selectedAccount = selectedChannelGroup?.accounts.find((account) => account.accountId === selectedDeliveryAccountId); const deliveryPreview = deliveryMode === 'announce' ? describeDelivery( { mode: 'announce', channel: deliveryChannel, accountId: selectedDeliveryAccountId, to: deliveryTarget, }, availableChannelGroups, t, ) : t('cron.delivery.none'); async function handleSubmit(): Promise { if (!name.trim()) { setValidationError(t('cron.validation.nameRequired')); return; } if (!message.trim()) { setValidationError(t('cron.validation.messageRequired')); return; } if (!finalSchedule) { setValidationError(t('cron.validation.scheduleRequired')); return; } if (deliveryMode === 'announce') { if (!deliveryChannel) { setValidationError(t('cron.validation.channelRequired')); return; } if (!deliveryTarget.trim()) { setValidationError(t('cron.validation.targetRequired')); return; } } setValidationError(null); await onSave({ name: name.trim(), agentId: resolvedAgentId, message: message.trim(), schedule: finalSchedule, enabled, delivery: deliveryMode === 'announce' ? { mode: 'announce', channel: normalizeChannelType(deliveryChannel), accountId: selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId || undefined, to: deliveryTarget.trim(), } : { mode: 'none' }, }); } return (

{job ? t('cron.dialog.editTitle') : t('cron.dialog.createTitle')}

{t('cron.dialog.subtitle')}

setName(event.target.value)} placeholder={t('cron.dialog.namePlaceholder')} className="h-11 w-full rounded-xl border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]" />