Add comprehensive i18n messages for the new scheduled tasks (cron) feature. Includes UI labels, actions, stats, validation, and example tasks for English, Simplified Chinese, and Japanese locales.
2198 lines
78 KiB
TypeScript
2198 lines
78 KiB
TypeScript
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, string | number>) => 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<void>;
|
|
};
|
|
|
|
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<SVGSVGElement>) => 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<CronDeliveryChannelGroup> & {
|
|
name?: string;
|
|
label?: string;
|
|
channelName?: string;
|
|
accounts?: unknown;
|
|
};
|
|
|
|
type DeliveryChannelAccountLike = Partial<CronDeliveryChannelAccount> & {
|
|
id?: string;
|
|
label?: string;
|
|
channelName?: string;
|
|
channelType?: string;
|
|
name?: string;
|
|
default?: boolean;
|
|
};
|
|
|
|
type DeliveryTargetOptionLike = Partial<ChannelTargetCatalogItem> & {
|
|
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<string, string> = {
|
|
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 | false | null | undefined>): string {
|
|
return tokens.filter(Boolean).join(' ');
|
|
}
|
|
|
|
function getString(value: unknown): string {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function IconBase({
|
|
children,
|
|
className,
|
|
...props
|
|
}: SVGProps<SVGSVGElement> & { children: ReactNode }) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function RefreshIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M20 12A8 8 0 1 1 17.2 6" />
|
|
<path d="M20 4V9H15" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function PlusIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M12 5V19" />
|
|
<path d="M5 12H19" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function ClockIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<circle cx="12" cy="12" r="8" />
|
|
<path d="M12 8V12L15 14" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function PlayIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M8 6.5V17.5L17 12L8 6.5Z" fill="currentColor" stroke="none" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function PauseIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M9 7V17" />
|
|
<path d="M15 7V17" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function AlertIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M12 9V13" />
|
|
<path d="M12 17H12.01" />
|
|
<path d="M10.3 4.9L3.8 16.3A1.5 1.5 0 0 0 5.1 18.5H18.9A1.5 1.5 0 0 0 20.2 16.3L13.7 4.9A1.5 1.5 0 0 0 10.3 4.9Z" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function MessageIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M6 8.5H18" />
|
|
<path d="M6 12H14" />
|
|
<path d="M5 5.5H19A2 2 0 0 1 21 7.5V13.5A2 2 0 0 1 19 15.5H12L8 18.5V15.5H5A2 2 0 0 1 3 13.5V7.5A2 2 0 0 1 5 5.5Z" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function HistoryIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M4 12A8 8 0 1 0 6.3 6.3" />
|
|
<path d="M4 4V9H9" />
|
|
<path d="M12 8V12L15 14" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function CalendarIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M7 3V6" />
|
|
<path d="M17 3V6" />
|
|
<path d="M4 9H20" />
|
|
<rect x="4" y="5" width="16" height="15" rx="2" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function TrashIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M5 7H19" />
|
|
<path d="M10 11V16" />
|
|
<path d="M14 11V16" />
|
|
<path d="M8 7L9 4H15L16 7" />
|
|
<path d="M18 7L17.2 18.2A2 2 0 0 1 15.2 20H8.8A2 2 0 0 1 6.8 18.2L6 7" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function CloseIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M6 6L18 18" />
|
|
<path d="M18 6L6 18" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function SpinnerIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M12 4A8 8 0 0 1 20 12" />
|
|
<path d="M20 12A8 8 0 1 1 12 4" opacity="0.2" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function BotIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<rect x="5" y="8" width="14" height="10" rx="3" />
|
|
<path d="M12 4V8" />
|
|
<path d="M9.5 13H9.51" />
|
|
<path d="M14.5 13H14.51" />
|
|
<path d="M9 18V20" />
|
|
<path d="M15 18V20" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function SendIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M21 3L10 14" />
|
|
<path d="M21 3L14 21L10 14L3 10L21 3Z" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
function ChevronDownIcon(props: SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<IconBase {...props}>
|
|
<path d="M7 10L12 15L17 10" />
|
|
</IconBase>
|
|
);
|
|
}
|
|
|
|
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<string, string> = {
|
|
'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<string, CronDeliveryChannelAccount>();
|
|
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<string, CronDeliveryChannelAccount[]>();
|
|
|
|
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<string, ChannelTargetCatalogItem>();
|
|
|
|
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<NonNullable<ChannelTargetCatalogItem['kind']>, 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<string, AgentSummary>,
|
|
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<string, AgentSummary>,
|
|
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 (
|
|
<div className={cn('mb-4 flex items-start gap-2 rounded-xl border px-4 py-3 text-sm font-medium', toneClasses(tone))}>
|
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<span>{message}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SelectField({ className, children, ...props }: SelectHTMLAttributes<HTMLSelectElement> & { children: ReactNode }) {
|
|
return (
|
|
<div className="relative">
|
|
<select
|
|
className={cn(
|
|
'h-11 w-full appearance-none rounded-xl border border-transparent bg-[#EDECE4] px-4 pr-10 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] disabled:cursor-not-allowed disabled:opacity-60 dark:bg-[#222225] dark:text-[#f3f4f6]',
|
|
className,
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</select>
|
|
<ChevronDownIcon className="pointer-events-none absolute right-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#99A0AE] dark:text-gray-500" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="min-h-[130px] rounded-2xl border border-transparent bg-[#eeece3] /30 p-5 transition-colors hover:bg-[#eeece3] /60 dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:hover:bg-[#222225]">
|
|
<div className="flex items-center justify-between">
|
|
<div className={cn('flex h-11 w-11 items-center justify-center rounded-full', iconWrapperClass)}>
|
|
<Icon className="h-5 w-5" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex items-baseline gap-3">
|
|
<div className="font-serif text-[40px] leading-none text-[#171717] dark:text-[#f3f4f6]">{value}</div>
|
|
<div className="text-[14px] font-medium text-[#525866] dark:text-gray-400">{label}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<article
|
|
className="group flex cursor-pointer flex-col rounded-2xl border border-transparent bg-transparent p-5 transition-all hover:bg-black/[0.03] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:hover:bg-[#2a2a2d]"
|
|
onClick={onEdit}
|
|
>
|
|
<div className="mb-4 flex items-start justify-between gap-4">
|
|
<div className="flex min-w-0 items-center gap-4">
|
|
<div className="flex h-[46px] w-[46px] shrink-0 items-center justify-center rounded-full border border-black/5 bg-black/5 text-[#171717] shadow-sm transition-transform group-hover:scale-105 dark:border-white/5 dark:bg-white/5 dark:text-[#f3f4f6]">
|
|
<ClockIcon className={cn('h-5 w-5', job.enabled ? 'text-current' : 'text-[#99A0AE] dark:text-gray-500')} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="mb-1 flex items-center gap-2">
|
|
<h3 className="truncate text-[16px] font-semibold text-[#171717] dark:text-[#f3f4f6]">{jobName}</h3>
|
|
<span
|
|
className={cn(
|
|
'h-2 w-2 shrink-0 rounded-full',
|
|
job.enabled ? 'bg-green-500' : 'bg-[#99A0AE] dark:bg-gray-500',
|
|
)}
|
|
aria-label={job.enabled ? t('cron.card.enabled') : t('cron.card.paused')}
|
|
/>
|
|
<span
|
|
className={cn(
|
|
'rounded-full px-2.5 py-1 text-[11px] font-medium',
|
|
isAnnouncement
|
|
? 'bg-[#E8F1FF] text-[#2B7FFF] dark:bg-[#232b36] dark:text-[#8ab4ff]'
|
|
: 'bg-[#E8E6DE] text-[#7A7668] dark:bg-[#2a2a2d] dark:text-gray-400',
|
|
)}
|
|
>
|
|
{isAnnouncement ? t('cron.card.modeAnnounce') : t('cron.card.modeNone')}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-[13px] text-[#525866] dark:text-gray-400">
|
|
<ClockIcon className="h-3.5 w-3.5" />
|
|
<span>{parseCronSchedule(job.schedule, t, locale)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
|
job.enabled ? 'bg-[#2B7FFF]' : 'bg-[#d1d5db] dark:bg-[#3b3b40]',
|
|
disabled && 'cursor-not-allowed opacity-60',
|
|
)}
|
|
aria-label={job.enabled ? t('cron.card.pauseTask') : t('cron.card.enableTask')}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if (!disabled) {
|
|
onToggle(!job.enabled);
|
|
}
|
|
}}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition-transform',
|
|
job.enabled && 'translate-x-5',
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-2 flex flex-1 flex-col justify-end pl-[62px]">
|
|
<div className="mb-3 flex items-start gap-2">
|
|
<MessageIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#99A0AE] dark:text-gray-500" />
|
|
<p className="line-clamp-2 text-[13.5px] leading-[1.5] text-[#525866] dark:text-gray-400">{job.message}</p>
|
|
</div>
|
|
|
|
<div className="mb-3 grid gap-2 rounded-2xl bg-white /70 px-3.5 py-3 text-[12.5px] dark:bg-[#222225]">
|
|
<div className="flex items-start gap-2 text-[#525866] dark:text-gray-400">
|
|
<BotIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#99A0AE] dark:text-gray-500" />
|
|
<span className="min-w-0">
|
|
<span className="mr-1 text-[#171717]/75 dark:text-[#f3f4f6]/75">{t('cron.card.agentLabel')}</span>
|
|
<span className="font-medium text-[#171717] dark:text-[#f3f4f6]">{agentLabel}</span>
|
|
<span className="ml-2 text-[#99A0AE] dark:text-gray-500">{agentDetail}</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex items-start gap-2 text-[#525866] dark:text-gray-400">
|
|
<SendIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#99A0AE] dark:text-gray-500" />
|
|
<span className="min-w-0">
|
|
<span className="mr-1 text-[#171717]/75 dark:text-[#f3f4f6]/75">{t('cron.card.deliveryLabel')}</span>
|
|
<span className="font-medium text-[#171717] dark:text-[#f3f4f6]">{deliverySummary}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-[12px] font-medium text-[#99A0AE] dark:text-gray-500">
|
|
{job.lastRun ? (
|
|
<span className="flex items-center gap-1.5">
|
|
<HistoryIcon className="h-3.5 w-3.5" />
|
|
<span>{t('cron.card.lastRun', { time: formatRelativeTime(job.lastRun.time, t, locale) })}</span>
|
|
<span className={job.lastRun.success ? 'text-green-500' : 'text-red-500'}>
|
|
{job.lastRun.success ? t('cron.card.success') : t('cron.card.failed')}
|
|
</span>
|
|
</span>
|
|
) : null}
|
|
{job.nextRun && job.enabled ? (
|
|
<span className="flex items-center gap-1.5">
|
|
<CalendarIcon className="h-3.5 w-3.5" />
|
|
<span>{t('cron.card.nextRun', { time: formatDateTime(job.nextRun, locale) })}</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{job.lastRun && !job.lastRun.success && job.lastRun.error ? (
|
|
<div className="mb-3 flex items-start gap-2 rounded-xl border border-red-500/20 bg-red-500/10 p-2.5 text-[13px] text-red-600 dark:text-red-400">
|
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<span className="line-clamp-2">{job.lastRun.error}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-auto flex justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<button
|
|
type="button"
|
|
className="flex h-8 items-center rounded-lg px-3 text-[13px] font-medium text-[#2B7FFF] transition-colors hover:bg-[#EFF6FF] dark:hover:bg-[#232327]"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if (!disabled) {
|
|
onTrigger();
|
|
}
|
|
}}
|
|
>
|
|
{busyAction === 'trigger' ? <SpinnerIcon className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <PlayIcon className="mr-1.5 h-3.5 w-3.5" />}
|
|
{t('cron.actions.runNow')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex h-8 items-center rounded-lg px-3 text-[13px] font-medium text-red-500 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if (!disabled) {
|
|
onDelete();
|
|
}
|
|
}}
|
|
>
|
|
<TrashIcon className="mr-1.5 h-3.5 w-3.5" />
|
|
{t('cron.actions.delete')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
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<ChannelTargetCatalogItem[]>([]);
|
|
const [targetsLoading, setTargetsLoading] = useState(false);
|
|
const [targetsError, setTargetsError] = useState<string | null>(null);
|
|
const [deliveryTargetScopeKey, setDeliveryTargetScopeKey] = useState('');
|
|
const [validationError, setValidationError] = useState<string | null>(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<ChannelTargetsResponse>(`/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<void> {
|
|
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 (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4 backdrop-blur-[1px]">
|
|
<div className="max-h-[calc(100vh-48px)] w-full max-w-[720px] overflow-hidden rounded-[20px] bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.2)] dark:bg-[#1f1f22]">
|
|
<div className="flex items-start justify-between border-b border-black/5 bg-white px-8 py-4 dark:border-[#2a2a2d] dark:bg-[#1f1f22]">
|
|
<div>
|
|
<h2
|
|
className="mb-2 text-[24px] font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6]"
|
|
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
|
>
|
|
{job ? t('cron.dialog.editTitle') : t('cron.dialog.createTitle')}
|
|
</h2>
|
|
<p className="text-[14px] text-[#99A0AE] dark:text-gray-500">
|
|
{t('cron.dialog.subtitle')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="mt-1 rounded-full p-1 text-[#99A0AE] transition-colors hover:text-[#171717] dark:hover:text-[#f3f4f6]"
|
|
onClick={onClose}
|
|
>
|
|
<CloseIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="max-h-[calc(100vh-230px)] overflow-y-auto p-4">
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 gap-4">
|
|
<div>
|
|
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.nameLabel')}</label>
|
|
<input
|
|
value={name}
|
|
onChange={(event) => 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]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.messageLabel')}</label>
|
|
<textarea
|
|
value={message}
|
|
onChange={(event) => setMessage(event.target.value)}
|
|
placeholder={t('cron.dialog.messagePlaceholder')}
|
|
rows={4}
|
|
className="w-full resize-none rounded-xl border border-transparent bg-[#EDECE4] px-4 py-3 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.scheduleLabel')}</label>
|
|
{!useCustom ? (
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
{SCHEDULE_PRESETS.map((preset) => {
|
|
const active = schedule === preset.value;
|
|
return (
|
|
<button
|
|
key={preset.key}
|
|
type="button"
|
|
className={cn(
|
|
'flex h-10 items-center rounded-xl px-3 text-left text-[13px] font-medium transition-colors',
|
|
active
|
|
? 'bg-[#3B6DE8] text-white'
|
|
: 'bg-[#EDECE4] text-[#4B4B4B] hover:bg-[#E5E4DC] dark:bg-[#222225] dark:text-gray-300 dark:hover:bg-[#2a2a2d]',
|
|
)}
|
|
onClick={() => setSchedule(preset.value)}
|
|
>
|
|
<ClockIcon className="mr-2 h-4 w-4 opacity-80" />
|
|
{t(`cron.schedule.presets.${preset.key}`)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<input
|
|
value={customSchedule}
|
|
onChange={(event) => setCustomSchedule(event.target.value)}
|
|
placeholder={t('cron.dialog.customCronPlaceholder')}
|
|
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]"
|
|
/>
|
|
)}
|
|
|
|
<div className="mt-2 flex items-center justify-between gap-4">
|
|
<p className="text-[12px] font-medium text-[#99A0AE] dark:text-gray-500">
|
|
{nextRunPreview
|
|
? t('cron.dialog.nextRun', { time: formatDateTime(nextRunPreview, locale) })
|
|
: t('cron.dialog.scheduleHint')}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="text-[12px] font-medium text-[#2B7FFF]"
|
|
onClick={() => {
|
|
if (useCustom) {
|
|
const fallbackPreset = SCHEDULE_PRESETS.find((item) => item.value === customSchedule.trim()) ?? SCHEDULE_PRESETS[4];
|
|
setSchedule(fallbackPreset.value);
|
|
} else {
|
|
setCustomSchedule(schedule);
|
|
}
|
|
setUseCustom((current) => !current);
|
|
}}
|
|
>
|
|
{useCustom ? t('cron.dialog.usePreset') : t('cron.dialog.useCustom')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="mb-1 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.deliveryTitle')}</div>
|
|
<p className="mb-3 text-[12px] text-[#99A0AE] dark:text-gray-500">{t('cron.dialog.deliverySubtitle')}</p>
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
{[
|
|
{ key: 'none', title: t('cron.dialog.deliveryOptions.noneTitle'), desc: t('cron.dialog.deliveryOptions.noneDesc') },
|
|
{ key: 'announce', title: t('cron.dialog.deliveryOptions.announceTitle'), desc: t('cron.dialog.deliveryOptions.announceDesc') },
|
|
].map((item) => {
|
|
const active = deliveryMode === item.key;
|
|
return (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
className={cn(
|
|
'rounded-xl px-4 py-3 text-left transition-colors',
|
|
active
|
|
? 'bg-[#3B6DE8] text-white'
|
|
: 'bg-[#EDECE4] text-[#4B4B4B] hover:bg-[#E5E4DC] dark:bg-[#222225] dark:text-gray-300 dark:hover:bg-[#2a2a2d]',
|
|
)}
|
|
onClick={() => setDeliveryMode(item.key as 'none' | 'announce')}
|
|
>
|
|
<div className="text-[13px] font-semibold">{item.title}</div>
|
|
<div className={cn('mt-1 text-[11px]', active ? 'text-white/85' : 'text-inherit opacity-80')}>{item.desc}</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{deliveryMode === 'announce' ? (
|
|
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div>
|
|
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.channelLabel')}</label>
|
|
<SelectField
|
|
value={deliveryChannel}
|
|
onChange={(event) => setDeliveryChannel(event.target.value)}
|
|
disabled={channelsLoading || availableChannelGroups.length === 0}
|
|
>
|
|
{availableChannelGroups.length > 0 ? (
|
|
availableChannelGroups.map((group) => (
|
|
<option key={group.channelType} value={group.channelType}>
|
|
{getChannelDisplayName(group.channelType, t)}
|
|
</option>
|
|
))
|
|
) : (
|
|
<option value="">{t('cron.dialog.channelEmpty')}</option>
|
|
)}
|
|
</SelectField>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.accountLabel')}</label>
|
|
<SelectField
|
|
value={selectedDeliveryAccountId}
|
|
onChange={(event) => setSelectedDeliveryAccountId(event.target.value)}
|
|
disabled={channelsLoading || (selectedChannelGroup?.accounts.length ?? 0) === 0}
|
|
>
|
|
{(selectedChannelGroup?.accounts.length ?? 0) > 0 ? (
|
|
selectedChannelGroup?.accounts.map((account) => (
|
|
<option key={account.accountId} value={account.accountId}>
|
|
{getDeliveryAccountDisplayName(account, t)}
|
|
</option>
|
|
))
|
|
) : (
|
|
<option value="">
|
|
{channelsLoading ? t('cron.dialog.accountLoading') : t('cron.dialog.accountEmpty')}
|
|
</option>
|
|
)}
|
|
</SelectField>
|
|
</div>
|
|
|
|
<div className="md:col-span-2">
|
|
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.targetLabel')}</label>
|
|
<input
|
|
list={deliveryTargetOptions.length > 0 ? targetListId : undefined}
|
|
value={deliveryTarget}
|
|
onChange={(event) => setDeliveryTarget(event.target.value)}
|
|
placeholder={t('cron.dialog.targetPlaceholder')}
|
|
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]"
|
|
/>
|
|
{deliveryTargetOptions.length > 0 ? (
|
|
<datalist id={targetListId}>
|
|
{deliveryTargetOptions.map((option) => (
|
|
<option key={`${option.value}:${option.label}`} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</datalist>
|
|
) : null}
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{deliveryTargetOptions.slice(0, 8).map((option) => (
|
|
<button
|
|
key={`${option.value}:${option.label}`}
|
|
type="button"
|
|
className={cn(
|
|
'rounded-full border px-3 py-1.5 text-[12px] transition-colors',
|
|
deliveryTarget === option.value
|
|
? 'border-[#2B7FFF] bg-[#EFF6FF] text-[#2B7FFF] dark:border-[#3b82f6] dark:bg-[#1d2633] dark:text-[#93c5fd]'
|
|
: 'border-[#D9DCE3] bg-white text-[#525866] hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#343438] dark:bg-[#17171a] dark:text-gray-300 dark:hover:border-[#3b82f6] dark:hover:text-white',
|
|
)}
|
|
onClick={() => setDeliveryTarget(option.value)}
|
|
title={option.description || option.label}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mt-2 text-[12px] text-[#99A0AE] dark:text-gray-500">
|
|
{targetsLoading
|
|
? t('cron.dialog.targetsLoading')
|
|
: deliveryTargetOptions.length > 0
|
|
? t('cron.dialog.targetsReady')
|
|
: t('cron.dialog.targetsNone')}
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] font-medium text-[#99A0AE] dark:text-gray-500">
|
|
<span>{t('cron.dialog.preview', { preview: deliveryPreview })}</span>
|
|
{selectedAccount ? <span>{t('cron.dialog.currentAccount', { account: getDeliveryAccountDisplayName(selectedAccount, t) })}</span> : null}
|
|
</div>
|
|
</div>
|
|
|
|
{channelsError ? <Notice tone="warning" message={t('cron.warnings.channelsCatalogFailed', { error: channelsError })} /> : null}
|
|
{targetsError ? <Notice tone="warning" message={t('cron.warnings.targetsLoadFailed', { error: targetsError })} /> : null}
|
|
{!channelsError && !channelsLoading && availableChannelGroups.length === 0 ? (
|
|
<Notice tone="warning" message={t('cron.warnings.noChannelsAvailable')} />
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between rounded-2xl border border-black/5 bg-[#E8E6DE]/50 p-4 dark:border-[#2a2a2d] dark:bg-[#222225]">
|
|
<div>
|
|
<div className="text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">{t('cron.dialog.enableTitle')}</div>
|
|
<div className="mt-0.5 text-[13px] text-[#99A0AE] dark:text-gray-500">{t('cron.dialog.enableDesc')}</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
|
enabled ? 'bg-[#2B7FFF]' : 'bg-[#d1d5db] dark:bg-[#3b3b40]',
|
|
)}
|
|
onClick={() => setEnabled((current) => !current)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition-transform',
|
|
enabled && 'translate-x-5',
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{validationError ? (
|
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/70 dark:bg-red-900/20 dark:text-red-300">
|
|
{validationError}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 border-t border-black/5 px-8 py-5 dark:border-[#2a2a2d]">
|
|
<button
|
|
type="button"
|
|
className="h-[42px] rounded-full bg-[#EDECE4] px-6 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#E5E4DC] dark:bg-[#222225] dark:text-gray-300 dark:hover:bg-[#2a2a2d]"
|
|
onClick={onClose}
|
|
>
|
|
{t('dialog.cancel')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex h-[42px] items-center rounded-full bg-[#2B7FFF] px-6 text-[13px] font-semibold text-white transition-colors hover:bg-[#2B7FFF]/90 disabled:cursor-not-allowed disabled:opacity-70"
|
|
onClick={() => {
|
|
void handleSubmit();
|
|
}}
|
|
disabled={saving}
|
|
>
|
|
{saving ? <SpinnerIcon className="mr-2 h-4 w-4 animate-spin" /> : <PlusIcon className="mr-2 h-4 w-4" />}
|
|
{saving
|
|
? t('cron.dialog.saving')
|
|
: job
|
|
? t('cron.dialog.saveEdit')
|
|
: t('cron.dialog.create')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function CronPage() {
|
|
const { t } = useI18n();
|
|
const agents = useAgentsStore((state) => state.agents);
|
|
const agentsLoading = useAgentsStore((state) => state.loading);
|
|
const agentsError = useAgentsStore((state) => state.error);
|
|
const agentsWarning = useAgentsStore((state) => state.warning);
|
|
const defaultAgentId = useAgentsStore((state) => state.defaultAgentId);
|
|
|
|
const [jobs, setJobs] = useState<CronJob[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [feedback, setFeedback] = useState<FeedbackState>(null);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingJob, setEditingJob] = useState<CronJob | null>(null);
|
|
const [dialogSaving, setDialogSaving] = useState(false);
|
|
const [busyJobId, setBusyJobId] = useState<string | null>(null);
|
|
const [busyAction, setBusyAction] = useState<string | null>(null);
|
|
const [channelGroups, setChannelGroups] = useState<CronDeliveryChannelGroup[]>([]);
|
|
const [channelsLoading, setChannelsLoading] = useState(false);
|
|
const [channelsError, setChannelsError] = useState<string | null>(null);
|
|
|
|
const activeJobs = useMemo(() => jobs.filter((job) => job.enabled), [jobs]);
|
|
const pausedJobs = useMemo(() => jobs.filter((job) => !job.enabled), [jobs]);
|
|
const failedJobs = useMemo(() => jobs.filter((job) => job.lastRun && !job.lastRun.success), [jobs]);
|
|
const refreshing = loading || channelsLoading || agentsLoading;
|
|
|
|
const agentsById = useMemo(
|
|
() => new Map(agents.map((agent) => [normalizeAgentId(agent.id), agent])),
|
|
[agents],
|
|
);
|
|
|
|
async function loadJobs(): Promise<void> {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await hostApiFetch<CronJobsResponse>('/api/cron/jobs');
|
|
const normalizedJobs = normalizeCronJobs(response);
|
|
setJobs(normalizedJobs);
|
|
} catch (requestError) {
|
|
setJobs((currentJobs) => (currentJobs.length > 0 ? currentJobs : buildFallbackCronJobs(t)));
|
|
setError(requestError instanceof Error ? requestError.message : String(requestError));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function loadDeliveryChannels(): Promise<void> {
|
|
setChannelsLoading(true);
|
|
setChannelsError(null);
|
|
|
|
try {
|
|
const response = await hostApiFetch<ChannelAccountsResponse>('/api/channels/accounts');
|
|
setChannelGroups(normalizeDeliveryChannelGroups(response));
|
|
} catch (requestError) {
|
|
setChannelsError(requestError instanceof Error ? requestError.message : String(requestError));
|
|
} finally {
|
|
setChannelsLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void agentsStore.init();
|
|
void loadJobs();
|
|
void loadDeliveryChannels();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return onGatewayEvent((event) => {
|
|
if (!isRuntimeChangedGatewayEvent(event)) return;
|
|
if (!runtimeEventHasTopic(event, 'channels', 'providers', 'agents', 'channel-targets')) return;
|
|
void Promise.allSettled([loadDeliveryChannels(), agentsStore.load()]);
|
|
});
|
|
}, []);
|
|
|
|
async function handleRefresh(): Promise<void> {
|
|
setFeedback(null);
|
|
await Promise.allSettled([loadJobs(), loadDeliveryChannels(), agentsStore.load()]);
|
|
}
|
|
|
|
async function handleSave(input: CronJobCreateInput): Promise<void> {
|
|
setDialogSaving(true);
|
|
|
|
try {
|
|
if (editingJob) {
|
|
try {
|
|
const response = await hostApiFetch<CronJob | { job?: CronJob }>(`/api/cron/jobs/${encodeURIComponent(editingJob.id)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(input),
|
|
});
|
|
|
|
const normalizedJob = normalizeCronJobResult(response);
|
|
setJobs((currentJobs) =>
|
|
currentJobs.map((job) => (job.id === editingJob.id ? normalizedJob ?? buildLocalCronJob(input, editingJob) : job)),
|
|
);
|
|
} catch {
|
|
const fallback = buildLocalCronJob(input, editingJob);
|
|
setJobs((currentJobs) => currentJobs.map((job) => (job.id === editingJob.id ? fallback : job)));
|
|
}
|
|
|
|
setFeedback({ tone: 'success', message: t('cron.feedback.updated') });
|
|
} else {
|
|
try {
|
|
const response = await hostApiFetch<CronJob | { job?: CronJob }>('/api/cron/jobs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(input),
|
|
});
|
|
|
|
const normalizedJob = normalizeCronJobResult(response);
|
|
setJobs((currentJobs) => [...currentJobs, normalizedJob ?? buildLocalCronJob(input)]);
|
|
} catch {
|
|
setJobs((currentJobs) => [...currentJobs, buildLocalCronJob(input)]);
|
|
}
|
|
|
|
setFeedback({ tone: 'success', message: t('cron.feedback.created') });
|
|
}
|
|
|
|
setDialogOpen(false);
|
|
setEditingJob(null);
|
|
} catch (saveError) {
|
|
setFeedback({
|
|
tone: 'error',
|
|
message: saveError instanceof Error ? saveError.message : String(saveError),
|
|
});
|
|
} finally {
|
|
setDialogSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleToggle(job: CronJob, enabled: boolean): Promise<void> {
|
|
setBusyJobId(job.id);
|
|
setBusyAction('toggle');
|
|
|
|
try {
|
|
try {
|
|
await hostApiFetch('/api/cron/toggle', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: job.id, enabled }),
|
|
});
|
|
} finally {
|
|
setJobs((currentJobs) =>
|
|
currentJobs.map((currentJob) =>
|
|
currentJob.id === job.id
|
|
? { ...currentJob, enabled, updatedAt: new Date().toISOString() }
|
|
: currentJob,
|
|
),
|
|
);
|
|
}
|
|
|
|
setFeedback({ tone: 'success', message: enabled ? t('cron.feedback.enabled') : t('cron.feedback.paused') });
|
|
} catch (toggleError) {
|
|
setFeedback({
|
|
tone: 'error',
|
|
message: toggleError instanceof Error ? toggleError.message : String(toggleError),
|
|
});
|
|
} finally {
|
|
setBusyJobId(null);
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
async function handleTrigger(job: CronJob): Promise<void> {
|
|
setBusyJobId(job.id);
|
|
setBusyAction('trigger');
|
|
|
|
try {
|
|
try {
|
|
await hostApiFetch('/api/cron/trigger', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: job.id }),
|
|
});
|
|
} finally {
|
|
setJobs((currentJobs) =>
|
|
currentJobs.map((currentJob) =>
|
|
currentJob.id === job.id
|
|
? {
|
|
...currentJob,
|
|
updatedAt: new Date().toISOString(),
|
|
lastRun: {
|
|
time: new Date().toISOString(),
|
|
success: true,
|
|
},
|
|
}
|
|
: currentJob,
|
|
),
|
|
);
|
|
}
|
|
|
|
setFeedback({ tone: 'success', message: t('cron.feedback.triggered') });
|
|
} catch (triggerError) {
|
|
setFeedback({
|
|
tone: 'error',
|
|
message: triggerError instanceof Error ? triggerError.message : String(triggerError),
|
|
});
|
|
} finally {
|
|
setBusyJobId(null);
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(job: CronJob): Promise<void> {
|
|
const jobName = job.name.trim() ? job.name : t('cron.common.unnamedJob');
|
|
const confirmed = window.confirm(t('cron.confirmDelete', { name: jobName }));
|
|
if (!confirmed) return;
|
|
|
|
setBusyJobId(job.id);
|
|
setBusyAction('delete');
|
|
|
|
try {
|
|
try {
|
|
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(job.id)}`, {
|
|
method: 'DELETE',
|
|
});
|
|
} finally {
|
|
setJobs((currentJobs) => currentJobs.filter((currentJob) => currentJob.id !== job.id));
|
|
}
|
|
|
|
setFeedback({ tone: 'success', message: t('cron.feedback.deleted') });
|
|
} catch (deleteError) {
|
|
setFeedback({
|
|
tone: 'error',
|
|
message: deleteError instanceof Error ? deleteError.message : String(deleteError),
|
|
});
|
|
} finally {
|
|
setBusyJobId(null);
|
|
setBusyAction(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="h-full w-full min-h-0">
|
|
<div className="flex h-full w-full overflow-hidden rounded-[16px] bg-white dark:bg-[#1b1b1d]">
|
|
<div className="flex h-full w-full flex-col p-10 pt-12">
|
|
<div className="mb-6 flex shrink-0 flex-col justify-between gap-4 md:flex-row md:items-start">
|
|
<div>
|
|
<h1
|
|
className="mb-3 text-5xl font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6] md:text-6xl"
|
|
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
|
>
|
|
{t('cron.title')}
|
|
</h1>
|
|
<p className="text-[17px] font-medium text-[#171717]/70 dark:text-[#9ca3af]">
|
|
{t('cron.subtitle')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 md:mt-2">
|
|
<button
|
|
type="button"
|
|
className="flex h-9 shrink-0 items-center justify-center rounded-full border border-black/10 px-4 text-[13px] font-medium text-[#171717]/80 transition-colors hover:bg-black/5 hover:text-[#171717] dark:border-gray-700 dark:text-[#f3f4f6]/80 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]"
|
|
onClick={() => {
|
|
void handleRefresh();
|
|
}}
|
|
>
|
|
<RefreshIcon className={cn('mr-2 h-4 w-4', refreshing && 'animate-spin')} />
|
|
{t('cron.actions.refresh')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex h-9 shrink-0 items-center justify-center rounded-full bg-[#2B7FFF] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#2B7FFF]/90"
|
|
onClick={() => {
|
|
setEditingJob(null);
|
|
setDialogOpen(true);
|
|
}}
|
|
>
|
|
<PlusIcon className="mr-2 h-4 w-4" />
|
|
{t('cron.actions.newTask')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="-mr-2 min-h-0 flex-1 overflow-y-auto pb-10 pr-2">
|
|
{feedback ? <Notice tone={feedback.tone} message={feedback.message} /> : null}
|
|
{error ? <Notice tone="error" message={error} /> : null}
|
|
{agentsError ? <Notice tone="warning" message={t('cron.warnings.agentsLoadFailed', { error: agentsError })} /> : null}
|
|
{channelsError ? <Notice tone="warning" message={t('cron.warnings.channelsLoadFailed', { error: channelsError })} /> : null}
|
|
{agentsWarning ? <Notice tone="warning" message={agentsWarning} /> : null}
|
|
|
|
{loading && jobs.length === 0 ? (
|
|
<div className="flex h-full flex-col items-center justify-center text-[#525866] dark:text-gray-400">
|
|
<SpinnerIcon className="mb-4 h-10 w-10 animate-spin" />
|
|
<p>{t('cron.loading')}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<StatCard label={t('cron.stats.total')} value={jobs.length} tone="neutral" icon={ClockIcon} />
|
|
<StatCard label={t('cron.stats.active')} value={activeJobs.length} tone="green" icon={PlayIcon} />
|
|
<StatCard label={t('cron.stats.paused')} value={pausedJobs.length} tone="amber" icon={PauseIcon} />
|
|
<StatCard label={t('cron.stats.failed')} value={failedJobs.length} tone="red" icon={AlertIcon} />
|
|
</div>
|
|
|
|
{jobs.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center rounded-2xl border border-transparent bg-white /40 py-20 text-[#525866] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-400">
|
|
<ClockIcon className="mb-4 h-10 w-10 opacity-50" />
|
|
<h3 className="mb-2 text-lg font-medium text-[#171717] dark:text-[#f3f4f6]">{t('cron.empty.title')}</h3>
|
|
<p className="mb-6 max-w-md text-center text-[14px]">
|
|
{t('cron.empty.description')}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="flex h-10 items-center justify-center rounded-full bg-[#2B7FFF] px-6 text-[13px] font-medium text-white transition-colors hover:bg-[#2B7FFF]/90"
|
|
onClick={() => {
|
|
setEditingJob(null);
|
|
setDialogOpen(true);
|
|
}}
|
|
>
|
|
<PlusIcon className="mr-2 h-4 w-4" />
|
|
{t('cron.actions.createTask')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-x-6 gap-y-4 xl:grid-cols-2">
|
|
{jobs.map((job) => (
|
|
<CronJobCard
|
|
key={job.id}
|
|
job={job}
|
|
agentLabel={getAgentLabel(job.agentId, agentsById, defaultAgentId, t)}
|
|
agentDetail={getAgentDetail(job.agentId, agentsById, defaultAgentId, t)}
|
|
deliverySummary={describeDelivery(job.delivery, channelGroups, t)}
|
|
busyAction={busyJobId === job.id ? busyAction : null}
|
|
onToggle={(enabled) => {
|
|
void handleToggle(job, enabled);
|
|
}}
|
|
onEdit={() => {
|
|
setEditingJob(job);
|
|
setDialogOpen(true);
|
|
}}
|
|
onDelete={() => {
|
|
void handleDelete(job);
|
|
}}
|
|
onTrigger={() => {
|
|
void handleTrigger(job);
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<CronTaskDialog
|
|
open={dialogOpen}
|
|
job={editingJob}
|
|
saving={dialogSaving}
|
|
defaultAgentId={defaultAgentId}
|
|
channelGroups={channelGroups}
|
|
channelsLoading={channelsLoading}
|
|
channelsError={channelsError}
|
|
onClose={() => {
|
|
if (dialogSaving) return;
|
|
setDialogOpen(false);
|
|
setEditingJob(null);
|
|
}}
|
|
onSave={handleSave}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|