feat: add runtime event handling for providers in ProvidersSection feat: update routing to include Channels and Agents pages feat: extend route types and navigation items for Channels and Agents feat: implement agents store for managing agent data and interactions fix: update chat store to utilize agents store for agent-related functionality chore: export agents store from index fix: enhance runtime types for better event handling fix: update Vite config to handle dev server URL correctly
2222 lines
79 KiB
TypeScript
2222 lines
79 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, 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<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;
|
||
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<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', 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<string, string> = {
|
||
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 | 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']): 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<string, string> = {
|
||
'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<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,
|
||
): 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<string, AgentSummary>,
|
||
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<string, AgentSummary>,
|
||
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 (
|
||
<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-[24px] border border-transparent bg-[#F4F3EB]/60 p-5 transition-colors hover:bg-[#F4F3EB] 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 disabled = busyAction !== null;
|
||
const isAnnouncement = job.delivery?.mode === 'announce';
|
||
|
||
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]">{job.name}</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 ? '启用中' : '已暂停'}
|
||
/>
|
||
<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 ? '执行并发送' : '仅执行'}
|
||
</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)}</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 ? '暂停任务' : '启用任务'}
|
||
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-[#F4F3EB]/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">Agent</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">送达</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>最近执行:{formatRelativeTime(job.lastRun.time)}</span>
|
||
<span className={job.lastRun.success ? 'text-green-500' : 'text-red-500'}>
|
||
{job.lastRun.success ? '成功' : '失败'}
|
||
</span>
|
||
</span>
|
||
) : null}
|
||
{job.nextRun && job.enabled ? (
|
||
<span className="flex items-center gap-1.5">
|
||
<CalendarIcon className="h-3.5 w-3.5" />
|
||
<span>下次执行:{formatDateTime(job.nextRun)}</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" />}
|
||
立即执行
|
||
</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" />
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
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<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('');
|
||
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<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 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<void> {
|
||
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 (
|
||
<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-[#F4F3EB] 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-[#F4F3EB] px-8 py-6 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 ? '编辑定时任务' : '新建定时任务'}
|
||
</h2>
|
||
<p className="text-[14px] text-[#99A0AE] dark:text-gray-500">
|
||
现在可以为任务指定 Agent、发送渠道和目标收件人,和 ClawX 的调度职责保持一致。
|
||
</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-210px)] overflow-y-auto px-8 py-8">
|
||
<div className="space-y-6">
|
||
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1.1fr_0.9fr]">
|
||
<div>
|
||
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">任务名称</label>
|
||
<input
|
||
value={name}
|
||
onChange={(event) => 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]"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">执行 Agent</label>
|
||
<SelectField
|
||
value={selectedAgentId}
|
||
onChange={(event) => setSelectedAgentId(event.target.value)}
|
||
disabled={!hasSelectableAgents}
|
||
>
|
||
{hasSelectableAgents ? (
|
||
agents.map((agent) => (
|
||
<option key={agent.id} value={agent.id}>
|
||
{agent.name}
|
||
{agent.isDefault ? ' · 默认' : ''}
|
||
</option>
|
||
))
|
||
) : (
|
||
<option value="">暂无可用 Agent</option>
|
||
)}
|
||
</SelectField>
|
||
<p className="mt-2 text-[12px] font-medium text-[#99A0AE] dark:text-gray-500">
|
||
{selectedAgent ? getAgentDetail(selectedAgent.id, new Map(agents.map((agent) => [agent.id, agent])), defaultAgentId) : 'Agent 列表加载后可选择执行归属。'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">提醒内容</label>
|
||
<textarea
|
||
value={message}
|
||
onChange={(event) => setMessage(event.target.value)}
|
||
placeholder="描述任务要执行或广播的内容"
|
||
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">执行计划</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" />
|
||
{preset.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<input
|
||
value={customSchedule}
|
||
onChange={(event) => setCustomSchedule(event.target.value)}
|
||
placeholder="输入 Cron 表达式,例如 0 9 * * *"
|
||
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 ? `下次执行:${nextRunPreview}` : '选择预设或填写自定义 Cron 表达式'}
|
||
</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 ? '使用预设' : '使用自定义 Cron'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-1 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">发送方式</div>
|
||
<p className="mb-3 text-[12px] text-[#99A0AE] dark:text-gray-500">任务可以只执行,也可以在完成后把结果发送到指定渠道和目标。</p>
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||
{[
|
||
{ key: 'none', title: '仅执行任务', desc: '不额外推送到渠道' },
|
||
{ key: 'announce', title: '执行并发送', desc: '选择渠道账号与目标收件人' },
|
||
].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">发送渠道</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)}
|
||
</option>
|
||
))
|
||
) : (
|
||
<option value="">暂无可用渠道</option>
|
||
)}
|
||
</SelectField>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">账号</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)}
|
||
</option>
|
||
))
|
||
) : (
|
||
<option value="">{channelsLoading ? '正在加载渠道账号…' : '当前渠道没有账号列表'}</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">目标</label>
|
||
<input
|
||
list={deliveryTargetOptions.length > 0 ? targetListId : undefined}
|
||
value={deliveryTarget}
|
||
onChange={(event) => setDeliveryTarget(event.target.value)}
|
||
placeholder="例如:值班群、room-ops、https://example.com/webhook"
|
||
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
|
||
? '正在为当前渠道账号加载推荐目标...'
|
||
: deliveryTargetOptions.length > 0
|
||
? '推荐目标已就绪,也可以继续手工输入群组、用户标识或 Webhook。'
|
||
: '暂未发现推荐目标,仍可手工输入群组、用户标识或 Webhook。'}
|
||
</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>预览:{deliveryPreview}</span>
|
||
{selectedAccount ? <span>当前账号:{getDeliveryAccountDisplayName(selectedAccount)}</span> : null}
|
||
</div>
|
||
</div>
|
||
|
||
{channelsError ? <Notice tone="warning" message={`渠道账号加载失败,仍可保留现有配置。${channelsError}`} /> : null}
|
||
{targetsError ? <Notice tone="warning" message={`目标候选加载失败,仍可手工输入。${targetsError}`} /> : null}
|
||
{!channelsError && !channelsLoading && availableChannelGroups.length === 0 ? (
|
||
<Notice tone="warning" message="当前没有可用的渠道账号,暂时无法新建发送型任务。" />
|
||
) : 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">立即启用</div>
|
||
<div className="mt-0.5 text-[13px] text-[#99A0AE] dark:text-gray-500">保存后按当前计划自动进入调度。</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}
|
||
>
|
||
取消
|
||
</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 || !hasSelectableAgents}
|
||
>
|
||
{saving ? <SpinnerIcon className="mr-2 h-4 w-4 animate-spin" /> : <PlusIcon className="mr-2 h-4 w-4" />}
|
||
{saving ? '保存中...' : job ? '保存修改' : '创建任务'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function CronPage() {
|
||
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 : FALLBACK_CRON_JOBS));
|
||
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: '定时任务已更新。' });
|
||
} 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: '定时任务已创建。' });
|
||
}
|
||
|
||
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 ? '任务已启用。' : '任务已暂停。' });
|
||
} 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: '任务已触发执行。' });
|
||
} 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 confirmed = window.confirm(`确认删除“${job.name}”吗?删除后将无法恢复。`);
|
||
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: '任务已删除。' });
|
||
} 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" }}
|
||
>
|
||
定时任务
|
||
</h1>
|
||
<p className="text-[17px] font-medium text-[#171717]/70 dark:text-[#9ca3af]">
|
||
为 Cron 任务绑定执行 Agent 与发送渠道,让调度、执行和投递信息在一个页面里闭环。
|
||
</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')} />
|
||
刷新
|
||
</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" />
|
||
新建任务
|
||
</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={`Agents 数据加载失败,任务仍会使用当前快照继续展示。${agentsError}`} /> : null}
|
||
{channelsError ? <Notice tone="warning" message={`渠道账号加载失败,发送配置将以占位信息展示。${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>正在加载任务...</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
<StatCard label="全部任务" value={jobs.length} tone="neutral" icon={ClockIcon} />
|
||
<StatCard label="启用中" value={activeJobs.length} tone="green" icon={PlayIcon} />
|
||
<StatCard label="已暂停" value={pausedJobs.length} tone="amber" icon={PauseIcon} />
|
||
<StatCard label="最近失败" value={failedJobs.length} tone="red" icon={AlertIcon} />
|
||
</div>
|
||
|
||
{jobs.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center rounded-3xl border border-transparent bg-[#F4F3EB]/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]">还没有定时任务</h3>
|
||
<p className="mb-6 max-w-md text-center text-[14px]">
|
||
现在创建的任务可以直接绑定执行 Agent,并预留好渠道账号和发送目标。
|
||
</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" />
|
||
创建任务
|
||
</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)}
|
||
agentDetail={getAgentDetail(job.agentId, agentsById, defaultAgentId)}
|
||
deliverySummary={describeDelivery(job.delivery, channelGroups)}
|
||
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}
|
||
agents={agents}
|
||
defaultAgentId={defaultAgentId}
|
||
channelGroups={channelGroups}
|
||
channelsLoading={channelsLoading}
|
||
channelsError={channelsError}
|
||
onClose={() => {
|
||
if (dialogSaving) return;
|
||
setDialogOpen(false);
|
||
setEditingJob(null);
|
||
}}
|
||
onSave={handleSave}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|