Files
beaver_project/app-instance/frontend/app/(app)/tasks/page.tsx
steven_li fc9fd93c36 feat: 支持多语言提示词本地化和界面优化
- 添加 prompt_locale 参数支持简体中文、繁体中文和英文提示词本地化
- 移除内置 agents 配置以简化系统架构
- 更新 ContextBuilder 使用动态提示词模板而非硬编码内容
- 在 AgentLoop、Web 接口和 AgentService 中传递 locale 参数
- 添加输出语言指令确保用户界面内容按指定语言生成
- 扩展前端 LanguageSwitcher 组件支持三种语言选项
- 优化 Header 和侧边栏组件的响应式布局和文本截断处理
- 更新测试用例验证不同语言环境下的提示词正确性
2026-06-10 16:11:05 +08:00

723 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import React, { useEffect, useMemo, useState } from 'react';
import { AlertCircle, ArrowRight, Clock3, FolderDown, ListTodo, Loader2, Play, Plus, RefreshCw, Trash2, X } from 'lucide-react';
import { formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { addCronJob, deleteBackendTask, listBackendTasks, listCronJobs, removeCronJob, runCronJob, toggleCronJob } from '@/lib/api';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import type { BackendTask, CronJob } from '@/types';
export default function TasksPage() {
const { locale } = useAppI18n();
const searchParams = useSearchParams();
const tab = searchParams.get('tab') === 'scheduled' ? 'scheduled' : 'ordinary';
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-normal">{pickAppText(locale, '任务', 'Tasks')}</h1>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
{pickAppText(locale, '普通任务展示用户发起或接入的工作任务;定时任务触发后会先进入通知,需要修改时再接入 Task。', 'Ordinary tasks show user-started or engaged work. Scheduled jobs first create notifications and only become Tasks when revised.')}
</p>
</div>
<TaskManagementTabs />
</div>
{tab === 'scheduled' ? <ScheduledTasks /> : <OrdinaryTasks />}
</div>
);
}
function OrdinaryTasks() {
const { locale } = useAppI18n();
const [backendTasks, setBackendTasks] = useState<BackendTask[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const visibleTasks = useMemo(
() => backendTasks.filter((task) => {
if (task.creator !== 'cron') return true;
return Boolean(task.metadata?.user_engaged || task.metadata?.requires_followup);
}),
[backendTasks]
);
const loadBackendTasks = React.useCallback(() => {
let cancelled = false;
setLoading(true);
setError(null);
listBackendTasks()
.then((items) => {
if (!cancelled) setBackendTasks(Array.isArray(items) ? items : []);
})
.catch((err: any) => {
if (!cancelled) {
setBackendTasks([]);
setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load tasks'));
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [locale]);
useEffect(() => loadBackendTasks(), [loadBackendTasks]);
const handleDeleteBackendTask = async (task: BackendTask) => {
const title = task.short_title || task.description || task.goal || task.task_id;
if (!window.confirm(pickAppText(locale, `删除任务“${title}”?`, `Delete task "${title}"?`))) {
return;
}
setError(null);
try {
await deleteBackendTask(task.task_id);
setBackendTasks((items) => items.filter((item) => item.task_id !== task.task_id));
} catch (err: any) {
setError(err.message || pickAppText(locale, '删除任务失败', 'Failed to delete task'));
}
};
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{pickAppText(locale, '加载任务中', 'Loading tasks')}
</CardContent>
</Card>
);
}
if (visibleTasks.length === 0 && !error) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<ListTodo className="h-10 w-10 text-muted-foreground/50" />
<h2 className="mt-4 text-xl font-semibold">{pickAppText(locale, '暂无普通任务', 'No ordinary tasks yet')}</h2>
<p className="mt-2 max-w-xl text-sm text-muted-foreground">
{pickAppText(locale, '从对话页发起复杂任务后,这里会保留普通任务列表;即使来源会话被归档,任务仍会显示。', 'Complex tasks created from chat appear here. Tasks remain visible even when their source session is archived.')}
</p>
<Button asChild className="mt-6">
<Link href="/">{pickAppText(locale, '回到对话', 'Back to chat')}</Link>
</Button>
</CardContent>
</Card>
);
}
return (
<div className="space-y-3">
{error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
</CardContent>
</Card>
)}
{visibleTasks.length > 0 ? (
<>
<div className="grid gap-3 xl:hidden">
{visibleTasks.map((task) => (
<OrdinaryTaskCard
key={task.task_id}
task={task}
locale={locale}
onDelete={() => void handleDeleteBackendTask(task)}
/>
))}
</div>
<Card className="hidden xl:block">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{pickAppText(locale, '任务', 'Task')}</TableHead>
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
<TableHead>{pickAppText(locale, '来源', 'Source')}</TableHead>
<TableHead>{pickAppText(locale, '运行次数', 'Runs')}</TableHead>
<TableHead>{pickAppText(locale, '使用技能', 'Skills')}</TableHead>
<TableHead>{pickAppText(locale, '更新时间', 'Updated')}</TableHead>
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleTasks.map((task) => (
<TableRow key={task.task_id}>
<TableCell>
<div className="max-w-[360px]">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate font-medium">{task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id}</div>
{task.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{task.description || task.session_id} · {task.creator}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={task.status === 'awaiting_acceptance' || task.status === 'closed' ? 'default' : 'secondary'}>
{taskStatusLabel(task.status, locale)}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">{taskSourceLabel(task, locale)}</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{task.run_ids.length}</TableCell>
<TableCell className="text-sm text-muted-foreground">{task.skill_names.length}</TableCell>
<TableCell className="text-xs text-muted-foreground">{formatTaskRuntimeTime(task.updated_at, locale)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button asChild size="sm" variant="outline" className="h-11">
<Link href={`/tasks/${encodeURIComponent(task.task_id)}`}>
{pickAppText(locale, '进入', 'Open')}
<ArrowRight className="ml-2 h-3.5 w-3.5" />
</Link>
</Button>
<Button
size="icon"
variant="ghost"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={() => void handleDeleteBackendTask(task)}
aria-label={pickAppText(locale, `删除任务 ${task.short_title || task.task_id}`, `Delete task ${task.short_title || task.task_id}`)}
title={pickAppText(locale, '删除任务', 'Delete task')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</>
) : null}
</div>
);
}
function OrdinaryTaskCard({
task,
locale,
onDelete,
}: {
task: BackendTask;
locale: string;
onDelete: () => void;
}) {
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
return (
<Card className="rounded-md">
<CardContent className="space-y-4 p-4">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="min-w-0 flex-1 text-base font-semibold">{title}</h2>
{task.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{task.description || task.session_id}
</p>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
<div>
<div className="text-muted-foreground">{pickAppText(locale, '状态', 'Status')}</div>
<Badge className="mt-1" variant={task.status === 'awaiting_acceptance' || task.status === 'closed' ? 'default' : 'secondary'}>
{taskStatusLabel(task.status, locale)}
</Badge>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '来源', 'Source')}</div>
<div className="mt-1 text-sm">{taskSourceLabel(task, locale)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '运行 / 技能', 'Runs / skills')}</div>
<div className="mt-1 text-sm">{task.run_ids.length} / {task.skill_names.length}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '更新时间', 'Updated')}</div>
<div className="mt-1 text-sm">{formatTaskRuntimeTime(task.updated_at, locale)}</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-border pt-3">
<Button
size="icon"
variant="ghost"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={onDelete}
aria-label={pickAppText(locale, `删除任务 ${title}`, `Delete task ${title}`)}
title={pickAppText(locale, '删除任务', 'Delete task')}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button asChild variant="outline" className="h-11">
<Link href={`/tasks/${encodeURIComponent(task.task_id)}`}>
{pickAppText(locale, '进入任务', 'Open task')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
function taskStatusLabel(status: string, locale: string) {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
needs_revision: ['需要修改', 'Needs revision'],
closed: ['已完成', 'Closed'],
abandoned: ['已放弃', 'Abandoned'],
};
const label = labels[status];
return label ? pickAppText(locale, label[0], label[1]) : status;
}
function taskSourceLabel(task: BackendTask, locale: string) {
if (task.metadata?.source === 'scheduled_run') {
return pickAppText(locale, '定时通知修改', 'Scheduled notification revision');
}
if (task.metadata?.source === 'scheduled_cron') {
return pickAppText(locale, '定时任务', 'Scheduled task');
}
if (task.creator === 'cron') {
return pickAppText(locale, '定时任务', 'Scheduled task');
}
return pickAppText(locale, '对话任务', 'Chat task');
}
function ScheduledTasks() {
const { locale } = useAppI18n();
const sessionId = useChatStore((state) => state.sessionId);
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const targetSessionKey = sessionId.startsWith('web:') ? sessionId : 'web:default';
const loadJobs = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setJobs(await listCronJobs(true));
} catch (err: any) {
setError(err.message || pickAppText(locale, '加载定时任务失败', 'Failed to load scheduled tasks'));
} finally {
setLoading(false);
}
}, [locale]);
useEffect(() => {
void loadJobs();
}, [loadJobs]);
const formatTime = (ms: number | null) => {
if (!ms) return '-';
return new Date(ms).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
};
const runJobAction = async (action: () => Promise<unknown>) => {
try {
await action();
await loadJobs();
} catch (err: any) {
setError(err.message || pickAppText(locale, '操作失败', 'Action failed'));
}
};
const handleRemoveJob = (job: CronJob) => {
if (!window.confirm(pickAppText(locale, `删除定时任务“${job.name}”?`, `Delete scheduled task "${job.name}"?`))) {
return;
}
void runJobAction(() => removeCronJob(job.id));
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock3 className="h-4 w-4" />
{pickAppText(locale, '每次触发会生成通知记录;需要修改时再接入 Task。', 'Each trigger creates a notification record; connect it to a Task when revision is needed.')}
</div>
<div className="flex items-center gap-2">
<Button onClick={() => void loadJobs()} variant="outline" size="sm" className="h-11">
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowAdd(true)} size="sm" className="h-11">
<Plus className="mr-2 h-4 w-4" />
{pickAppText(locale, '新建定时任务', 'New scheduled task')}
</Button>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
</CardContent>
</Card>
)}
{showAdd && (
<AddJobForm
targetSessionKey={targetSessionKey}
onCancel={() => setShowAdd(false)}
onAdd={(params) =>
runJobAction(async () => {
await addCronJob({ ...params, session_key: targetSessionKey, mode: 'notification' });
setShowAdd(false);
})
}
/>
)}
{!loading && jobs.length > 0 ? (
<div className="grid gap-3 xl:hidden">
{jobs.map((job) => (
<ScheduledJobCard
key={job.id}
job={job}
locale={locale}
formatTime={formatTime}
onToggle={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))}
onRun={() => void runJobAction(() => runCronJob(job.id))}
onRemove={() => handleRemoveJob(job)}
/>
))}
</div>
) : null}
<Card className={!loading && jobs.length > 0 ? 'hidden xl:block' : undefined}>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{pickAppText(locale, '加载中', 'Loading')}
</div>
) : jobs.length === 0 ? (
<div className="py-14 text-center text-muted-foreground">
<Clock3 className="mx-auto mb-3 h-10 w-10 opacity-40" />
<p className="font-medium">{pickAppText(locale, '暂无定时任务', 'No scheduled tasks yet')}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">{pickAppText(locale, '启用', 'Enabled')}</TableHead>
<TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
<TableHead>{pickAppText(locale, '计划', 'Schedule')}</TableHead>
<TableHead>{pickAppText(locale, '消息', 'Message')}</TableHead>
<TableHead>{pickAppText(locale, '运行历史', 'History')}</TableHead>
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
<TableHead className="w-28">{pickAppText(locale, '操作', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => (
<TableRow key={job.id}>
<TableCell>
<Switch
checked={job.enabled}
onCheckedChange={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))}
aria-label={pickAppText(locale, `切换定时任务 ${job.name}`, `Toggle scheduled task ${job.name}`)}
/>
</TableCell>
<TableCell>
<div className="font-medium">{job.name}</div>
<div className="text-xs text-muted-foreground">{job.id}</div>
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{job.schedule_display}</code>
<div className="mt-1 text-xs text-muted-foreground">{pickAppText(locale, '下次', 'Next')}: {formatTime(job.next_run_at_ms)}</div>
</TableCell>
<TableCell>
<span className="block max-w-[260px] truncate text-sm">{job.message}</span>
</TableCell>
<TableCell>
<Button asChild size="sm" variant="outline" className="h-11" disabled={!job.last_scheduled_run_id && !job.last_task_id}>
<Link href={job.last_scheduled_run_id ? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}` : job.last_task_id ? `/tasks/${encodeURIComponent(job.last_task_id)}` : '/tasks'}>
<FolderDown className="mr-2 h-3.5 w-3.5" />
{formatTime(job.last_run_at_ms)}
</Link>
</Button>
</TableCell>
<TableCell>
{job.last_status === 'ok' ? (
<Badge>{pickAppText(locale, '成功', 'OK')}</Badge>
) : job.last_status === 'error' ? (
<Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => void runJobAction(() => runCronJob(job.id))}
aria-label={pickAppText(locale, `立即运行 ${job.name}`, `Run ${job.name} now`)}
title={pickAppText(locale, '立即运行', 'Run now')}
>
<Play className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={() => handleRemoveJob(job)}
aria-label={pickAppText(locale, `删除定时任务 ${job.name}`, `Delete scheduled task ${job.name}`)}
title={pickAppText(locale, '删除定时任务', 'Delete scheduled task')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function ScheduledJobCard({
job,
locale,
formatTime,
onToggle,
onRun,
onRemove,
}: {
job: CronJob;
locale: string;
formatTime: (ms: number | null) => string;
onToggle: (checked: boolean) => void;
onRun: () => void;
onRemove: () => void;
}) {
const historyHref = job.last_scheduled_run_id
? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}`
: job.last_task_id
? `/tasks/${encodeURIComponent(job.last_task_id)}`
: '/tasks';
return (
<Card className="rounded-md">
<CardContent className="space-y-4 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-base font-semibold">{job.name}</h2>
<p className="mt-1 break-all text-xs text-muted-foreground">{job.id}</p>
</div>
<label className="flex min-h-11 shrink-0 items-center gap-2 text-sm">
<span className="sr-only">{pickAppText(locale, '启用', 'Enabled')}</span>
<Switch
checked={job.enabled}
onCheckedChange={onToggle}
aria-label={pickAppText(locale, `切换定时任务 ${job.name}`, `Toggle scheduled task ${job.name}`)}
/>
</label>
</div>
<p className="text-sm leading-6 text-muted-foreground">{job.message}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
<div>
<div className="text-muted-foreground">{pickAppText(locale, '计划', 'Schedule')}</div>
<code className="mt-1 inline-block rounded bg-muted px-1.5 py-0.5">{job.schedule_display}</code>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '下次运行', 'Next run')}</div>
<div className="mt-1 text-sm">{formatTime(job.next_run_at_ms)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '上次运行', 'Last run')}</div>
<div className="mt-1 text-sm">{formatTime(job.last_run_at_ms)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '状态', 'Status')}</div>
<div className="mt-1">
{job.last_status === 'ok' ? (
<Badge>{pickAppText(locale, '成功', 'OK')}</Badge>
) : job.last_status === 'error' ? (
<Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
<Button
asChild
variant="outline"
className="h-11"
disabled={!job.last_scheduled_run_id && !job.last_task_id}
>
<Link href={historyHref}>
<FolderDown className="mr-2 h-4 w-4" />
{pickAppText(locale, '运行历史', 'History')}
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={onRun}
aria-label={pickAppText(locale, `立即运行 ${job.name}`, `Run ${job.name} now`)}
title={pickAppText(locale, '立即运行', 'Run now')}
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={onRemove}
aria-label={pickAppText(locale, `删除定时任务 ${job.name}`, `Delete scheduled task ${job.name}`)}
title={pickAppText(locale, '删除定时任务', 'Delete scheduled task')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
}
function AddJobForm({
targetSessionKey,
onAdd,
onCancel,
}: {
targetSessionKey: string;
onAdd: (params: { name: string; message: string; every_seconds?: number; cron_expr?: string; at_iso?: string }) => void;
onCancel: () => void;
}) {
const { locale } = useAppI18n();
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [scheduleType, setScheduleType] = useState<'every' | 'cron' | 'at'>('every');
const [everySeconds, setEverySeconds] = useState('3600');
const [cronExpr, setCronExpr] = useState('0 9 * * *');
const [atIso, setAtIso] = useState('');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!name.trim() || !message.trim()) return;
if (scheduleType === 'every') {
onAdd({ name: name.trim(), message: message.trim(), every_seconds: Number.parseInt(everySeconds, 10) || 3600 });
return;
}
if (scheduleType === 'cron') {
onAdd({ name: name.trim(), message: message.trim(), cron_expr: cronExpr.trim() });
return;
}
onAdd({ name: name.trim(), message: message.trim(), at_iso: atIso });
};
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={onCancel}
aria-label={pickAppText(locale, '关闭新建定时任务表单', 'Close new scheduled task form')}
title={pickAppText(locale, '关闭', 'Close')}
>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="job-name">{pickAppText(locale, '任务名称', 'Task name')}</Label>
<Input id="job-name" value={name} onChange={(event) => setName(event.target.value)} />
</div>
<div className="space-y-2">
<Label>{pickAppText(locale, '调度类型', 'Schedule type')}</Label>
<Select value={scheduleType} onValueChange={(value) => setScheduleType(value as 'every' | 'cron' | 'at')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="every">{pickAppText(locale, '固定间隔', 'Fixed interval')}</SelectItem>
<SelectItem value="cron">Cron</SelectItem>
<SelectItem value="at">{pickAppText(locale, '一次性', 'One-time')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{scheduleType === 'every' ? (
<div className="space-y-2">
<Label htmlFor="every-seconds">{pickAppText(locale, '间隔秒数', 'Interval seconds')}</Label>
<Input id="every-seconds" type="number" min="10" value={everySeconds} onChange={(event) => setEverySeconds(event.target.value)} />
</div>
) : scheduleType === 'cron' ? (
<div className="space-y-2">
<Label htmlFor="cron-expr">Cron</Label>
<Input id="cron-expr" value={cronExpr} onChange={(event) => setCronExpr(event.target.value)} />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="at-iso">{pickAppText(locale, '触发时间', 'Run at')}</Label>
<Input id="at-iso" type="datetime-local" value={atIso} onChange={(event) => setAtIso(event.target.value)} />
</div>
)}
<div className="space-y-2">
<Label htmlFor="job-message">{pickAppText(locale, '任务消息', 'Task message')}</Label>
<Input id="job-message" value={message} onChange={(event) => setMessage(event.target.value)} />
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '触发后会发送到固定通知 session需要修改时再接入 Task。来源会话', 'Triggers are sent to the fixed notification session; connect to a Task for revisions. Source session: ')}
<code className="rounded bg-muted px-1 py-0.5">{targetSessionKey}</code>
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" className="h-11" onClick={onCancel}>{pickAppText(locale, '取消', 'Cancel')}</Button>
<Button type="submit" className="h-11" disabled={!name.trim() || !message.trim()}>
<Plus className="mr-2 h-4 w-4" />
{pickAppText(locale, '创建', 'Create')}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}