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, chatStore, useAgentsStore } from '../../stores'; type FeedbackTone = 'success' | 'error' | 'info' | 'warning'; type FeedbackState = { tone: FeedbackTone; message: string; } | null; type DialogProps = { open: boolean; job: CronJob | null; saving: boolean; agents: AgentSummary[]; 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; label: 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', label: '每分钟', value: '* * * * *' }, { key: 'every-5-minutes', label: '每 5 分钟', value: '*/5 * * * *' }, { key: 'every-15-minutes', label: '每 15 分钟', value: '*/15 * * * *' }, { key: 'every-hour', label: '每小时整点', value: '0 * * * *' }, { key: 'daily-9', label: '每天 09:00', value: '0 9 * * *' }, { key: 'daily-18', label: '每天 18:00', value: '0 18 * * *' }, { key: 'weekly-mon', label: '每周一 09:00', value: '0 9 * * 1' }, { key: 'monthly-first', label: '每月 1 日 09:00', value: '0 9 1 * *' }, ]; const CHANNEL_DISPLAY_NAMES: Record = { douyin: '抖音', feishu: '飞书', fliggy: '飞猪', meituan: '美团', qqbot: 'QQ 机器人', telegram: 'Telegram', wechat: '微信', wecom: '企业微信', }; const FALLBACK_CRON_JOBS: CronJob[] = [ { id: 'cron-morning-briefing', name: '晨间营业播报', message: '每天开店前提醒值班同事检查渠道状态和当日房态。', schedule: '0 9 * * *', agentId: 'main', delivery: { mode: 'announce', channel: 'wecom', accountId: 'default', to: '早班值守群', }, 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: '渠道异常巡检', message: '每 15 分钟轮询飞猪、美团和抖音渠道在线状态。', 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: '评论汇总提醒', message: '每日晚间生成评论待处理清单并推送给运营。', schedule: '0 18 * * *', agentId: 'main', delivery: { mode: 'announce', channel: 'feishu', accountId: 'default', to: '运营复盘群', }, 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: '渠道响应超时,晚间汇总未完成,请检查网关与服务状态。', }, }, ]; 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']): string { if (typeof schedule !== 'string') { if (schedule.kind === 'at') { return `单次执行 · ${formatDateTime(schedule.at)}`; } if (schedule.kind === 'every') { const everyMs = schedule.everyMs; if (everyMs < 60_000) return `每 ${Math.round(everyMs / 1000)} 秒`; if (everyMs < 3_600_000) return `每 ${Math.round(everyMs / 60_000)} 分钟`; if (everyMs < 86_400_000) return `每 ${Math.round(everyMs / 3_600_000)} 小时`; return `每 ${Math.round(everyMs / 86_400_000)} 天`; } return schedule.expr; } const preset = SCHEDULE_PRESETS.find((item) => item.value === schedule); if (preset) return preset.label; const parts = schedule.trim().split(/\s+/); if (parts.length !== 5) return schedule; const [minute, hour, dayOfMonth, , dayOfWeek] = parts; if (minute === '*' && hour === '*') return '每分钟'; if (minute.startsWith('*/')) return `每 ${minute.slice(2)} 分钟`; if (hour === '*' && minute === '0') return '每小时整点'; if (dayOfWeek !== '*' && dayOfMonth === '*') return `每周 ${formatWeekday(dayOfWeek)} ${hour}:${minute.padStart(2, '0')}`; if (dayOfMonth !== '*') return `每月 ${dayOfMonth} 日 ${hour}:${minute.padStart(2, '0')}`; if (hour !== '*') return `每天 ${hour}:${minute.padStart(2, '0')}`; 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 ? formatDateTime(next.toISOString()) : null; } function formatWeekday(weekday: string): string { const weekdayMap: Record = { '0': '周日', '1': '周一', '2': '周二', '3': '周三', '4': '周四', '5': '周五', '6': '周六', '7': '周日', }; return weekdayMap[weekday] ?? weekday; } function formatDateTime(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } function formatRelativeTime(value: string): 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 '刚刚'; if (seconds < 60) return `${seconds} 秒前`; if (minutes < 60) return `${minutes} 分钟前`; if (hours < 24) return `${hours} 小时前`; if (days < 30) return `${days} 天前`; return formatDateTime(value); } 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, ): 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: '当前任务里已经保存的自定义目标', 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): string { const normalized = normalizeChannelType(channelType); if (!normalized) return '未命名渠道'; if (CHANNEL_DISPLAY_NAMES[normalized]) return CHANNEL_DISPLAY_NAMES[normalized]; return normalized .split(/[-_]/) .filter(Boolean) .map((token) => token.slice(0, 1).toUpperCase() + token.slice(1)) .join(' '); } function getDeliveryAccountDisplayName(account: CronDeliveryChannelAccount | undefined): string { if (!account) return '主账号'; if (account.accountId === 'default' && (account.name === 'default' || !account.name.trim())) return '主账号'; return account.name; } function getAgentLabel( agentId: string | null | undefined, agentsById: Map, defaultAgentId: string, ): 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 '主 Agent'; 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, ): string { const resolvedId = normalizeAgentId(agentId || defaultAgentId || DEFAULT_AGENT_ID); const resolvedAgent = agentsById.get(resolvedId); if (resolvedAgent?.workspace) { return `工作区 ${formatPathTail(resolvedAgent.workspace)}`; } if (resolvedAgent?.agentDir) { return `目录 ${formatPathTail(resolvedAgent.agentDir)}`; } if (resolvedId === normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID) || resolvedAgent?.isDefault) { return '共享主工作区'; } return '工作区待同步'; } function describeDelivery( delivery: CronJobDelivery | undefined, channelGroups: CronDeliveryChannelGroup[], ): string { if (!delivery || delivery.mode !== 'announce') { return '仅执行任务,不额外发送'; } const channelType = normalizeChannelType(delivery.channel); if (!channelType) { return '公告发送待配置'; } 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), account ? getDeliveryAccountDisplayName(account) : null, target || '目标待填写', ] .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 disabled = busyAction !== null; const isAnnouncement = job.delivery?.mode === 'announce'; return (

{job.name}

{isAnnouncement ? '执行并发送' : '仅执行'}
{parseCronSchedule(job.schedule)}

{job.message}

Agent {agentLabel} {agentDetail}
送达 {deliverySummary}
{job.lastRun ? ( 最近执行:{formatRelativeTime(job.lastRun.time)} {job.lastRun.success ? '成功' : '失败'} ) : null} {job.nextRun && job.enabled ? ( 下次执行:{formatDateTime(job.nextRun)} ) : null}
{job.lastRun && !job.lastRun.success && job.lastRun.error ? (
{job.lastRun.error}
) : null}
); } function getSuggestedAgentId(agents: AgentSummary[], defaultAgentId: string, currentJob: CronJob | null): string { const jobAgentId = getString(currentJob?.agentId); if (jobAgentId) return normalizeAgentId(jobAgentId); const currentChatAgentId = getString(chatStore.getState().currentAgentId); const normalizedCurrentChatAgentId = normalizeAgentId(currentChatAgentId || defaultAgentId || DEFAULT_AGENT_ID); if (agents.some((agent) => agent.id === normalizedCurrentChatAgentId)) { return normalizedCurrentChatAgentId; } if (agents.some((agent) => agent.id === normalizeAgentId(defaultAgentId))) { return normalizeAgentId(defaultAgentId); } return agents[0]?.id || normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID); } function CronTaskDialog({ open, job, saving, agents, defaultAgentId, channelGroups, channelsLoading, channelsError, onClose, onSave, }: DialogProps) { const [name, setName] = useState(''); const [selectedAgentId, setSelectedAgentId] = useState(normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID)); 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(''); setSelectedAgentId(getSuggestedAgentId(agents, defaultAgentId, job)); setValidationError(null); }, [agents, defaultAgentId, 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), [deliveryTarget, loadedDeliveryTargetOptions], ); 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) return; if (agents.length === 0) return; if (agents.some((agent) => agent.id === selectedAgentId)) return; setSelectedAgentId(getSuggestedAgentId(agents, defaultAgentId, job)); }, [agents, defaultAgentId, job, open, selectedAgentId]); 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 hasSelectableAgents = agents.length > 0; const selectedAgent = agents.find((agent) => agent.id === selectedAgentId); const selectedAccount = selectedChannelGroup?.accounts.find((account) => account.accountId === selectedDeliveryAccountId); const deliveryPreview = deliveryMode === 'announce' ? describeDelivery( { mode: 'announce', channel: deliveryChannel, accountId: selectedDeliveryAccountId, to: deliveryTarget, }, availableChannelGroups, ) : '仅执行任务,不额外发送'; async function handleSubmit(): Promise { if (!name.trim()) { setValidationError('请填写任务名称。'); return; } if (!selectedAgentId.trim()) { setValidationError('请先选择要执行任务的 Agent。'); return; } if (!message.trim()) { setValidationError('请填写提醒内容。'); return; } if (!finalSchedule) { setValidationError('请填写执行计划。'); return; } if (deliveryMode === 'announce') { if (!deliveryChannel) { setValidationError('请选择一个发送渠道。'); return; } if (!deliveryTarget.trim()) { setValidationError('请填写发送目标,例如群组名、用户标识或 Webhook。'); return; } } setValidationError(null); await onSave({ name: name.trim(), agentId: normalizeAgentId(selectedAgentId), 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 ? '编辑定时任务' : '新建定时任务'}

现在可以为任务指定 Agent、发送渠道和目标收件人,和 ClawX 的调度职责保持一致。

setName(event.target.value)} placeholder="例如:晨间播报" 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]" />
setSelectedAgentId(event.target.value)} disabled={!hasSelectableAgents} > {hasSelectableAgents ? ( agents.map((agent) => ( )) ) : ( )}

{selectedAgent ? getAgentDetail(selectedAgent.id, new Map(agents.map((agent) => [agent.id, agent])), defaultAgentId) : 'Agent 列表加载后可选择执行归属。'}