feat: unify cron delivery account and target selection (#642)
This commit is contained in:
committed by
GitHub
Unverified
parent
9aea3c9441
commit
9d40e1fa05
@@ -33,6 +33,39 @@
|
||||
"cronPlaceholder": "Cron expression (e.g., 0 9 * * *)",
|
||||
"usePresets": "Use presets",
|
||||
"useCustomCron": "Use custom cron",
|
||||
"deliveryTitle": "Delivery",
|
||||
"deliveryDescription": "Choose whether this task stays in ClawX or is pushed to an external channel.",
|
||||
"deliveryModeNone": "In ClawX only",
|
||||
"deliveryModeNoneDesc": "Run the task and keep the result in the app.",
|
||||
"deliveryModeAnnounce": "External channel",
|
||||
"deliveryModeAnnounceDesc": "Send the final result through a configured channel.",
|
||||
"deliveryChannel": "Channel",
|
||||
"channelUnsupportedTag": "Unsupported",
|
||||
"deliveryAccount": "Sending Account",
|
||||
"selectDeliveryAccount": "Select an account",
|
||||
"deliveryAccountDesc": "Uses the same configured account list shown on the Channels page.",
|
||||
"selectChannel": "Select a channel",
|
||||
"deliveryChannelUnsupported": "WeChat does not currently support scheduled outbound delivery because the plugin requires a live conversation context token.",
|
||||
"deliveryDefaultAccountHint": "Uses the channel's default account: {{account}}",
|
||||
"deliveryTarget": "Recipient / Target",
|
||||
"selectDeliveryTarget": "Select a delivery target",
|
||||
"loadingTargets": "Loading targets...",
|
||||
"currentTarget": "Current target",
|
||||
"deliveryTargetGroupDefault": "Recommended",
|
||||
"deliveryTargetGroupUsers": "Users",
|
||||
"deliveryTargetGroupChats": "Chats",
|
||||
"noDeliveryTargets": "No delivery targets are available for the selected Feishu account.",
|
||||
"deliveryTargetPlaceholder": "Enter the delivery target for this channel",
|
||||
"deliveryTargetPlaceholderFeishu": "e.g., user:ou_xxx or chat:oc_xxx",
|
||||
"deliveryTargetDesc": "This maps to delivery.to in OpenClaw and is sent as-is.",
|
||||
"deliveryTargetDescFeishu": "Use a Feishu user target like user:ou_xxx or a chat target like chat:oc_xxx.",
|
||||
"deliveryTargetDescAuto": "Select from targets discovered for the chosen channel account.",
|
||||
"deliveryTargetDescFeishuSelect": "Select a verified Feishu recipient from the available users or chats.",
|
||||
"deliveryTargetDescFeishuAccount": "Choose from the configured Feishu channel accounts, using the same account list as the Channels page.",
|
||||
"feishuMainTargetTitle": "Feishu main account",
|
||||
"feishuMainTargetDesc": "Auto-fill the current default Feishu account owner as the delivery target.",
|
||||
"useFeishuMainTarget": "Use main account",
|
||||
"resolvingTarget": "Resolving...",
|
||||
"targetChannel": "Target Channel",
|
||||
"noChannels": "No channels available. Add a channel first.",
|
||||
"discordChannelId": "Discord Channel ID",
|
||||
@@ -65,6 +98,11 @@
|
||||
"nameRequired": "Please enter a task name",
|
||||
"messageRequired": "Please enter a message",
|
||||
"channelRequired": "Please select a channel",
|
||||
"deliveryChannelUnsupported": "{{channel}} does not support scheduled delivery yet",
|
||||
"deliveryTargetRequired": "Please enter a delivery target",
|
||||
"deliveryTargetResolved": "Delivery target resolved",
|
||||
"failedLoadDeliveryTargets": "Failed to load delivery targets",
|
||||
"failedResolveDeliveryTarget": "Failed to resolve the default delivery target",
|
||||
"discordIdRequired": "Please enter a Discord Channel ID",
|
||||
"scheduleRequired": "Please select or enter a schedule"
|
||||
},
|
||||
|
||||
@@ -33,6 +33,37 @@
|
||||
"cronPlaceholder": "Cron式(例:0 9 * * *)",
|
||||
"usePresets": "プリセットを使用",
|
||||
"useCustomCron": "カスタムCronを使用",
|
||||
"deliveryTitle": "配信設定",
|
||||
"deliveryDescription": "結果を ClawX 内だけに残すか、外部チャンネルへ送信するかを選びます。",
|
||||
"deliveryModeNone": "ClawX 内のみ",
|
||||
"deliveryModeNoneDesc": "タスクを実行し、結果はアプリ内だけに残します。",
|
||||
"deliveryModeAnnounce": "外部チャンネル",
|
||||
"deliveryModeAnnounceDesc": "最終結果を設定済みチャンネルへ送信します。",
|
||||
"deliveryChannel": "チャンネル",
|
||||
"deliveryAccount": "送信アカウント",
|
||||
"selectDeliveryAccount": "アカウントを選択",
|
||||
"deliveryAccountDesc": "Channels ページと同じ設定済みアカウント一覧を使います。",
|
||||
"selectChannel": "チャンネルを選択",
|
||||
"deliveryDefaultAccountHint": "このチャンネルの既定アカウントを使います: {{account}}",
|
||||
"deliveryTarget": "送信先",
|
||||
"selectDeliveryTarget": "送信先を選択",
|
||||
"loadingTargets": "送信先を読み込み中...",
|
||||
"currentTarget": "現在の送信先",
|
||||
"deliveryTargetGroupDefault": "おすすめ",
|
||||
"deliveryTargetGroupUsers": "ユーザー",
|
||||
"deliveryTargetGroupChats": "グループ",
|
||||
"noDeliveryTargets": "この Feishu アカウントでは選択可能な送信先が見つかりませんでした。",
|
||||
"deliveryTargetPlaceholder": "このチャンネルの送信先を入力",
|
||||
"deliveryTargetPlaceholderFeishu": "例: user:ou_xxx または chat:oc_xxx",
|
||||
"deliveryTargetDesc": "この値は OpenClaw の delivery.to にそのまま保存されます。",
|
||||
"deliveryTargetDescFeishu": "Feishu では user:ou_xxx のユーザー宛て、または chat:oc_xxx のグループ宛てを指定できます。",
|
||||
"deliveryTargetDescAuto": "選択したチャンネルアカウントで見つかった送信先から選べます。",
|
||||
"deliveryTargetDescFeishuSelect": "利用可能な Feishu ユーザーまたはグループから検証済みの送信先を選択してください。",
|
||||
"deliveryTargetDescFeishuAccount": "Channels ページと同じ Feishu アカウント一覧を使い、選択したアカウントに対応するメイン送信先へ配信します。",
|
||||
"feishuMainTargetTitle": "Feishu メインアカウント",
|
||||
"feishuMainTargetDesc": "現在の既定 Feishu アカウントの所有者を送信先として自動入力します。",
|
||||
"useFeishuMainTarget": "メインアカウントを使う",
|
||||
"resolvingTarget": "解決中...",
|
||||
"targetChannel": "ターゲットチャンネル",
|
||||
"noChannels": "利用可能なチャンネルがありません。先にチャンネルを追加してください。",
|
||||
"discordChannelId": "DiscordチャンネルID",
|
||||
@@ -65,6 +96,10 @@
|
||||
"nameRequired": "タスク名を入力してください",
|
||||
"messageRequired": "メッセージを入力してください",
|
||||
"channelRequired": "チャンネルを選択してください",
|
||||
"deliveryTargetRequired": "送信先を入力してください",
|
||||
"deliveryTargetResolved": "送信先を解決しました",
|
||||
"failedLoadDeliveryTargets": "送信先の読み込みに失敗しました",
|
||||
"failedResolveDeliveryTarget": "既定の送信先を解決できませんでした",
|
||||
"discordIdRequired": "DiscordチャンネルIDを入力してください",
|
||||
"scheduleRequired": "スケジュールを選択または入力してください"
|
||||
},
|
||||
|
||||
@@ -33,6 +33,39 @@
|
||||
"cronPlaceholder": "Cron 表达式 (例如:0 9 * * *)",
|
||||
"usePresets": "使用预设",
|
||||
"useCustomCron": "使用自定义 Cron",
|
||||
"deliveryTitle": "投递设置",
|
||||
"deliveryDescription": "选择仅在 ClawX 内保留结果,或把最终结果推送到外部通道。",
|
||||
"deliveryModeNone": "仅在 ClawX 内",
|
||||
"deliveryModeNoneDesc": "任务照常运行,结果只保留在应用内。",
|
||||
"deliveryModeAnnounce": "发送到外部通道",
|
||||
"deliveryModeAnnounceDesc": "将最终结果投递到已配置的消息通道。",
|
||||
"deliveryChannel": "通道",
|
||||
"channelUnsupportedTag": "暂不支持",
|
||||
"deliveryAccount": "发送账号",
|
||||
"selectDeliveryAccount": "选择账号",
|
||||
"deliveryAccountDesc": "这里直接复用 Channels 页面里的已配置账号列表。",
|
||||
"selectChannel": "选择通道",
|
||||
"deliveryChannelUnsupported": "微信通道当前不支持定时任务主动投递,因为插件要求实时会话里的 contextToken。",
|
||||
"deliveryDefaultAccountHint": "将使用该通道当前的默认账号:{{account}}",
|
||||
"deliveryTarget": "接收目标",
|
||||
"selectDeliveryTarget": "选择接收目标",
|
||||
"loadingTargets": "正在加载目标...",
|
||||
"currentTarget": "当前目标",
|
||||
"deliveryTargetGroupDefault": "推荐",
|
||||
"deliveryTargetGroupUsers": "用户",
|
||||
"deliveryTargetGroupChats": "群聊",
|
||||
"noDeliveryTargets": "当前飞书账号暂无可选投递目标。",
|
||||
"deliveryTargetPlaceholder": "输入该通道的投递目标",
|
||||
"deliveryTargetPlaceholderFeishu": "例如:user:ou_xxx 或 chat:oc_xxx",
|
||||
"deliveryTargetDesc": "这里会直接写入 OpenClaw 的 delivery.to。",
|
||||
"deliveryTargetDescFeishu": "飞书可以填写用户目标 user:ou_xxx,或群聊目标 chat:oc_xxx。",
|
||||
"deliveryTargetDescAuto": "这里会展示该通道账号下已发现的可投递目标。",
|
||||
"deliveryTargetDescFeishuSelect": "请从可用的飞书用户或群聊中直接选择已校验的目标。",
|
||||
"deliveryTargetDescFeishuAccount": "这里直接复用频道页的飞书账号列表,按所选账号对应的主账号目标进行投递。",
|
||||
"feishuMainTargetTitle": "飞书主账号",
|
||||
"feishuMainTargetDesc": "自动把当前默认飞书账号的主账号填入为投递目标。",
|
||||
"useFeishuMainTarget": "使用主账号",
|
||||
"resolvingTarget": "解析中...",
|
||||
"targetChannel": "目标频道",
|
||||
"noChannels": "无可用频道。请先添加频道。",
|
||||
"discordChannelId": "Discord 频道 ID",
|
||||
@@ -64,7 +97,12 @@
|
||||
"failedDelete": "删除任务失败",
|
||||
"nameRequired": "请输入任务名称",
|
||||
"messageRequired": "请输入消息",
|
||||
"channelRequired": "请选择频道",
|
||||
"channelRequired": "请选择通道",
|
||||
"deliveryChannelUnsupported": "{{channel}} 暂不支持定时任务投递",
|
||||
"deliveryTargetRequired": "请输入投递目标",
|
||||
"deliveryTargetResolved": "已解析投递目标",
|
||||
"failedLoadDeliveryTargets": "加载投递目标失败",
|
||||
"failedResolveDeliveryTarget": "解析默认投递目标失败",
|
||||
"discordIdRequired": "请输入 Discord 频道 ID",
|
||||
"scheduleRequired": "请选择或输入调度计划"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Cron Page
|
||||
* Manage scheduled tasks
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, type ReactNode, type SelectHTMLAttributes } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
@@ -19,21 +19,24 @@ import {
|
||||
Timer,
|
||||
History,
|
||||
Pause,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { useCronStore } from '@/stores/cron';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { formatRelativeTime, cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron';
|
||||
import { CHANNEL_ICONS, type ChannelType } from '@/types/channel';
|
||||
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
@@ -170,14 +173,74 @@ function estimateNextRun(scheduleExpr: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface DeliveryChannelAccount {
|
||||
accountId: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
interface DeliveryChannelGroup {
|
||||
channelType: string;
|
||||
defaultAccountId: string;
|
||||
accounts: DeliveryChannelAccount[];
|
||||
}
|
||||
|
||||
interface ChannelTargetOption {
|
||||
value: string;
|
||||
label: string;
|
||||
kind: 'user' | 'group' | 'channel';
|
||||
}
|
||||
|
||||
function isKnownChannelType(value: string): value is ChannelType {
|
||||
return value in CHANNEL_NAMES;
|
||||
}
|
||||
|
||||
function getChannelDisplayName(value: string): string {
|
||||
return isKnownChannelType(value) ? CHANNEL_NAMES[value] : value;
|
||||
}
|
||||
|
||||
function getDeliveryAccountDisplayName(account: DeliveryChannelAccount, t: TFunction): string {
|
||||
return account.accountId === 'default' && account.name === account.accountId
|
||||
? t('channels:account.mainAccount')
|
||||
: account.name;
|
||||
}
|
||||
|
||||
const TESTED_CRON_DELIVERY_CHANNELS = new Set<string>(['feishu', 'telegram', 'qqbot', 'wecom']);
|
||||
|
||||
function isSupportedCronDeliveryChannel(channelType: string): boolean {
|
||||
return TESTED_CRON_DELIVERY_CHANNELS.has(channelType);
|
||||
}
|
||||
|
||||
interface SelectFieldProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SelectField({ className, children, ...props }: SelectFieldProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Select
|
||||
className={cn(
|
||||
'h-[44px] rounded-xl border-black/10 dark:border-white/10 bg-background text-[13px] pr-10 [background-image:none] appearance-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Select>
|
||||
<ChevronDown className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create/Edit Task Dialog
|
||||
interface TaskDialogProps {
|
||||
job?: CronJob;
|
||||
configuredChannels: DeliveryChannelGroup[];
|
||||
onClose: () => void;
|
||||
onSave: (input: CronJobCreateInput) => Promise<void>;
|
||||
}
|
||||
|
||||
function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProps) {
|
||||
const { t } = useTranslation('cron');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -197,7 +260,100 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
const [customSchedule, setCustomSchedule] = useState('');
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [enabled, setEnabled] = useState(job?.enabled ?? true);
|
||||
const [deliveryMode, setDeliveryMode] = useState<'none' | 'announce'>(job?.delivery?.mode === 'announce' ? 'announce' : 'none');
|
||||
const [deliveryChannel, setDeliveryChannel] = useState(job?.delivery?.channel || '');
|
||||
const [deliveryTarget, setDeliveryTarget] = useState(job?.delivery?.to || '');
|
||||
const [selectedDeliveryAccountId, setSelectedDeliveryAccountId] = useState(job?.delivery?.accountId || '');
|
||||
const [channelTargetOptions, setChannelTargetOptions] = useState<ChannelTargetOption[]>([]);
|
||||
const [loadingChannelTargets, setLoadingChannelTargets] = useState(false);
|
||||
const schedulePreview = estimateNextRun(useCustom ? customSchedule : schedule);
|
||||
const selectableChannels = configuredChannels.filter((group) => isSupportedCronDeliveryChannel(group.channelType));
|
||||
const availableChannels = selectableChannels.some((group) => group.channelType === deliveryChannel)
|
||||
? selectableChannels
|
||||
: (
|
||||
deliveryChannel && isSupportedCronDeliveryChannel(deliveryChannel)
|
||||
? [...selectableChannels, configuredChannels.find((group) => group.channelType === deliveryChannel) || { channelType: deliveryChannel, defaultAccountId: 'default', accounts: [] }]
|
||||
: selectableChannels
|
||||
);
|
||||
const effectiveDeliveryChannel = deliveryChannel
|
||||
|| (deliveryMode === 'announce' ? (availableChannels[0]?.channelType || '') : '');
|
||||
const unsupportedDeliveryChannel = !!effectiveDeliveryChannel && !isSupportedCronDeliveryChannel(effectiveDeliveryChannel);
|
||||
const selectedChannel = availableChannels.find((group) => group.channelType === effectiveDeliveryChannel);
|
||||
const deliveryAccountOptions = (selectedChannel?.accounts ?? []).map((account) => ({
|
||||
accountId: account.accountId,
|
||||
displayName: getDeliveryAccountDisplayName(account, t),
|
||||
}));
|
||||
const hasCurrentDeliveryTarget = !!deliveryTarget;
|
||||
const currentDeliveryTargetOption = hasCurrentDeliveryTarget
|
||||
? {
|
||||
value: deliveryTarget,
|
||||
label: `${t('dialog.currentTarget')} (${deliveryTarget})`,
|
||||
kind: 'user' as const,
|
||||
}
|
||||
: null;
|
||||
const effectiveDeliveryAccountId = selectedDeliveryAccountId
|
||||
|| selectedChannel?.defaultAccountId
|
||||
|| deliveryAccountOptions[0]?.accountId
|
||||
|| '';
|
||||
const showsAccountSelector = (selectedChannel?.accounts.length ?? 0) > 0;
|
||||
const selectedResolvedAccountId = effectiveDeliveryAccountId || undefined;
|
||||
const availableTargetOptions = currentDeliveryTargetOption
|
||||
? [currentDeliveryTargetOption, ...channelTargetOptions.filter((option) => option.value !== deliveryTarget)]
|
||||
: channelTargetOptions;
|
||||
|
||||
useEffect(() => {
|
||||
if (deliveryMode !== 'announce') {
|
||||
setSelectedDeliveryAccountId('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDeliveryAccountId && selectedChannel?.defaultAccountId) {
|
||||
setSelectedDeliveryAccountId(selectedChannel.defaultAccountId);
|
||||
}
|
||||
}, [deliveryMode, selectedChannel?.defaultAccountId, selectedDeliveryAccountId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deliveryMode !== 'announce' || !effectiveDeliveryChannel || unsupportedDeliveryChannel) {
|
||||
setChannelTargetOptions([]);
|
||||
setLoadingChannelTargets(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (showsAccountSelector && !selectedResolvedAccountId) {
|
||||
setChannelTargetOptions([]);
|
||||
setLoadingChannelTargets(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingChannelTargets(true);
|
||||
const params = new URLSearchParams({ channelType: effectiveDeliveryChannel });
|
||||
if (selectedResolvedAccountId) {
|
||||
params.set('accountId', selectedResolvedAccountId);
|
||||
}
|
||||
void hostApiFetch<{ success: boolean; targets?: ChannelTargetOption[]; error?: string }>(
|
||||
`/api/channels/targets?${params.toString()}`,
|
||||
).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to load channel targets');
|
||||
}
|
||||
setChannelTargetOptions(result.targets || []);
|
||||
}).catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.warn('Failed to load channel targets:', error);
|
||||
setChannelTargetOptions([]);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingChannelTargets(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [deliveryMode, effectiveDeliveryChannel, selectedResolvedAccountId, showsAccountSelector, unsupportedDeliveryChannel]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
@@ -217,10 +373,37 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const finalDelivery = deliveryMode === 'announce'
|
||||
? {
|
||||
mode: 'announce' as const,
|
||||
channel: effectiveDeliveryChannel.trim(),
|
||||
...(selectedResolvedAccountId
|
||||
? { accountId: effectiveDeliveryAccountId }
|
||||
: {}),
|
||||
to: deliveryTarget.trim(),
|
||||
}
|
||||
: { mode: 'none' as const };
|
||||
|
||||
if (finalDelivery.mode === 'announce') {
|
||||
if (!finalDelivery.channel) {
|
||||
toast.error(t('toast.channelRequired'));
|
||||
return;
|
||||
}
|
||||
if (!isSupportedCronDeliveryChannel(finalDelivery.channel)) {
|
||||
toast.error(t('toast.deliveryChannelUnsupported', { channel: getChannelDisplayName(finalDelivery.channel) }));
|
||||
return;
|
||||
}
|
||||
if (!finalDelivery.to) {
|
||||
toast.error(t('toast.deliveryTargetRequired'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
message: message.trim(),
|
||||
schedule: finalSchedule,
|
||||
delivery: finalDelivery,
|
||||
enabled,
|
||||
});
|
||||
onClose();
|
||||
@@ -318,6 +501,141 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[14px] text-foreground/80 font-bold">{t('dialog.deliveryTitle')}</Label>
|
||||
<p className="text-[12px] text-muted-foreground">{t('dialog.deliveryDescription')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={deliveryMode === 'none' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setDeliveryMode('none')}
|
||||
className={cn(
|
||||
'justify-start h-auto min-h-12 rounded-xl px-4 py-3 text-left',
|
||||
deliveryMode === 'none'
|
||||
? 'bg-primary hover:bg-primary/90 text-primary-foreground border-transparent'
|
||||
: 'bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold">{t('dialog.deliveryModeNone')}</div>
|
||||
<div className="text-[11px] opacity-80">{t('dialog.deliveryModeNoneDesc')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={deliveryMode === 'announce' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setDeliveryMode('announce')}
|
||||
className={cn(
|
||||
'justify-start h-auto min-h-12 rounded-xl px-4 py-3 text-left',
|
||||
deliveryMode === 'announce'
|
||||
? 'bg-primary hover:bg-primary/90 text-primary-foreground border-transparent'
|
||||
: 'bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold">{t('dialog.deliveryModeAnnounce')}</div>
|
||||
<div className="text-[11px] opacity-80">{t('dialog.deliveryModeAnnounceDesc')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{deliveryMode === 'announce' && (
|
||||
<div className="space-y-3 rounded-2xl border border-black/5 dark:border-white/5 bg-[#eeece3] dark:bg-muted p-4 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delivery-channel" className="text-[13px] text-foreground/80 font-bold">
|
||||
{t('dialog.deliveryChannel')}
|
||||
</Label>
|
||||
<SelectField
|
||||
id="delivery-channel"
|
||||
value={effectiveDeliveryChannel}
|
||||
onChange={(event) => {
|
||||
setDeliveryChannel(event.target.value);
|
||||
setSelectedDeliveryAccountId('');
|
||||
setDeliveryTarget('');
|
||||
}}
|
||||
>
|
||||
<option value="">{t('dialog.selectChannel')}</option>
|
||||
{availableChannels.map((group) => (
|
||||
<option key={group.channelType} value={group.channelType}>
|
||||
{!isSupportedCronDeliveryChannel(group.channelType)
|
||||
? `${getChannelDisplayName(group.channelType)} (${t('dialog.channelUnsupportedTag')})`
|
||||
: getChannelDisplayName(group.channelType)}
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
{availableChannels.length === 0 && (
|
||||
<p className="text-[12px] text-muted-foreground">{t('dialog.noChannels')}</p>
|
||||
)}
|
||||
{unsupportedDeliveryChannel && (
|
||||
<p className="text-[12px] text-destructive">{t('dialog.deliveryChannelUnsupported')}</p>
|
||||
)}
|
||||
{selectedChannel && (
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
{t('dialog.deliveryDefaultAccountHint', { account: selectedChannel.defaultAccountId })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showsAccountSelector && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delivery-account" className="text-[13px] text-foreground/80 font-bold">
|
||||
{t('dialog.deliveryAccount')}
|
||||
</Label>
|
||||
<SelectField
|
||||
id="delivery-account"
|
||||
value={effectiveDeliveryAccountId}
|
||||
onChange={(event) => {
|
||||
setSelectedDeliveryAccountId(event.target.value);
|
||||
setDeliveryTarget('');
|
||||
}}
|
||||
disabled={deliveryAccountOptions.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{t('dialog.selectDeliveryAccount')}
|
||||
</option>
|
||||
{deliveryAccountOptions.map((option) => (
|
||||
<option key={option.accountId} value={option.accountId}>
|
||||
{option.displayName}
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
<p className="text-[12px] text-muted-foreground">{t('dialog.deliveryAccountDesc')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delivery-target-select" className="text-[13px] text-foreground/80 font-bold">
|
||||
{t('dialog.deliveryTarget')}
|
||||
</Label>
|
||||
<SelectField
|
||||
id="delivery-target-select"
|
||||
value={deliveryTarget}
|
||||
onChange={(event) => setDeliveryTarget(event.target.value)}
|
||||
disabled={loadingChannelTargets || availableTargetOptions.length === 0}
|
||||
>
|
||||
<option value="">{loadingChannelTargets ? t('dialog.loadingTargets') : t('dialog.selectDeliveryTarget')}</option>
|
||||
{availableTargetOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
{availableTargetOptions.length > 0
|
||||
? t('dialog.deliveryTargetDescAuto')
|
||||
: t('dialog.noDeliveryTargets')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enabled */}
|
||||
<div className="flex items-center justify-between bg-[#eeece3] dark:bg-muted p-4 rounded-2xl shadow-sm border border-black/5 dark:border-white/5">
|
||||
<div>
|
||||
@@ -357,13 +675,14 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
// Job Card Component
|
||||
interface CronJobCardProps {
|
||||
job: CronJob;
|
||||
deliveryAccountName?: string;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onTrigger: () => Promise<void>;
|
||||
}
|
||||
|
||||
function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
|
||||
function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
|
||||
const { t } = useTranslation('cron');
|
||||
const [triggering, setTriggering] = useState(false);
|
||||
|
||||
@@ -386,6 +705,12 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
||||
onDelete();
|
||||
};
|
||||
|
||||
const deliveryChannel = typeof job.delivery?.channel === 'string' ? job.delivery.channel : '';
|
||||
const deliveryLabel = deliveryChannel ? getChannelDisplayName(deliveryChannel) : '';
|
||||
const deliveryIcon = deliveryChannel && isKnownChannelType(deliveryChannel)
|
||||
? CHANNEL_ICONS[deliveryChannel]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex flex-col p-5 rounded-2xl bg-transparent border border-transparent hover:bg-black/5 dark:hover:bg-white/5 transition-all relative overflow-hidden cursor-pointer"
|
||||
@@ -432,10 +757,15 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[12px] text-muted-foreground/80 font-medium mb-3">
|
||||
{job.target && (
|
||||
{job.delivery?.mode === 'announce' && deliveryChannel && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{CHANNEL_ICONS[job.target.channelType as ChannelType]}
|
||||
{job.target.channelName}
|
||||
{deliveryIcon}
|
||||
<span>{deliveryLabel}</span>
|
||||
{deliveryAccountName ? (
|
||||
<span className="max-w-[220px] truncate">{deliveryAccountName}</span>
|
||||
) : job.delivery.to && (
|
||||
<span className="max-w-[220px] truncate">{job.delivery.to}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -505,9 +835,25 @@ export function Cron() {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingJob, setEditingJob] = useState<CronJob | undefined>();
|
||||
const [jobToDelete, setJobToDelete] = useState<{ id: string } | null>(null);
|
||||
const [configuredChannels, setConfiguredChannels] = useState<DeliveryChannelGroup[]>([]);
|
||||
|
||||
const isGatewayRunning = gatewayStatus.state === 'running';
|
||||
|
||||
const fetchConfiguredChannels = useCallback(async () => {
|
||||
try {
|
||||
const response = await hostApiFetch<{ success: boolean; channels?: DeliveryChannelGroup[]; error?: string }>(
|
||||
'/api/channels/accounts',
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to load delivery channels');
|
||||
}
|
||||
setConfiguredChannels(response.channels || []);
|
||||
} catch (fetchError) {
|
||||
console.warn('Failed to load delivery channels:', fetchError);
|
||||
setConfiguredChannels([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch jobs on mount
|
||||
useEffect(() => {
|
||||
if (isGatewayRunning) {
|
||||
@@ -515,6 +861,10 @@ export function Cron() {
|
||||
}
|
||||
}, [fetchJobs, isGatewayRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchConfiguredChannels();
|
||||
}, [fetchConfiguredChannels]);
|
||||
|
||||
// Statistics
|
||||
const safeJobs = Array.isArray(jobs) ? jobs : [];
|
||||
const activeJobs = safeJobs.filter((j) => j.enabled);
|
||||
@@ -564,7 +914,10 @@ export function Cron() {
|
||||
<div className="flex items-center gap-3 md:mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={fetchJobs}
|
||||
onClick={() => {
|
||||
void fetchJobs();
|
||||
void fetchConfiguredChannels();
|
||||
}}
|
||||
disabled={!isGatewayRunning}
|
||||
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors"
|
||||
>
|
||||
@@ -680,10 +1033,15 @@ export function Cron() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
||||
{safeJobs.map((job) => (
|
||||
{safeJobs.map((job) => {
|
||||
const channelGroup = configuredChannels.find((group) => group.channelType === job.delivery?.channel);
|
||||
const account = channelGroup?.accounts.find((item) => item.accountId === job.delivery?.accountId);
|
||||
const deliveryAccountName = account ? getDeliveryAccountDisplayName(account, t) : undefined;
|
||||
return (
|
||||
<CronJobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
deliveryAccountName={deliveryAccountName}
|
||||
onToggle={(enabled) => handleToggle(job.id, enabled)}
|
||||
onEdit={() => {
|
||||
setEditingJob(job);
|
||||
@@ -692,7 +1050,8 @@ export function Cron() {
|
||||
onDelete={() => setJobToDelete({ id: job.id })}
|
||||
onTrigger={() => triggerJob(job.id)}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -703,6 +1062,7 @@ export function Cron() {
|
||||
{showDialog && (
|
||||
<TaskDialog
|
||||
job={editingJob}
|
||||
configuredChannels={configuredChannels}
|
||||
onClose={() => {
|
||||
setShowDialog(false);
|
||||
setEditingJob(undefined);
|
||||
|
||||
@@ -53,13 +53,13 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
|
||||
updateJob: async (id, input) => {
|
||||
try {
|
||||
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
|
||||
const updatedJob = await hostApiFetch<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
set((state) => ({
|
||||
jobs: state.jobs.map((job) =>
|
||||
job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job
|
||||
job.id === id ? updatedJob : job
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,13 +5,23 @@
|
||||
|
||||
import { ChannelType } from './channel';
|
||||
|
||||
export type CronJobDeliveryMode = 'none' | 'announce';
|
||||
|
||||
export interface CronJobDelivery {
|
||||
mode: CronJobDeliveryMode;
|
||||
channel?: ChannelType | string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron job target (where to send the result)
|
||||
*/
|
||||
export interface CronJobTarget {
|
||||
channelType: ChannelType;
|
||||
channelType: ChannelType | string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
recipient?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +51,7 @@ export interface CronJob {
|
||||
name: string;
|
||||
message: string;
|
||||
schedule: string | CronSchedule;
|
||||
delivery?: CronJobDelivery;
|
||||
target?: CronJobTarget;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
@@ -51,13 +62,12 @@ export interface CronJob {
|
||||
|
||||
/**
|
||||
* Input for creating a cron job from the UI.
|
||||
* No target/delivery — UI-created tasks push results to the ClawX chat page.
|
||||
* Tasks created via external channels are handled directly by the Gateway.
|
||||
*/
|
||||
export interface CronJobCreateInput {
|
||||
name: string;
|
||||
message: string;
|
||||
schedule: string;
|
||||
delivery?: CronJobDelivery;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -68,6 +78,7 @@ export interface CronJobUpdateInput {
|
||||
name?: string;
|
||||
message?: string;
|
||||
schedule?: string;
|
||||
delivery?: CronJobDelivery;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user