- 添加 prompt_locale 参数支持简体中文、繁体中文和英文提示词本地化 - 移除内置 agents 配置以简化系统架构 - 更新 ContextBuilder 使用动态提示词模板而非硬编码内容 - 在 AgentLoop、Web 接口和 AgentService 中传递 locale 参数 - 添加输出语言指令确保用户界面内容按指定语言生成 - 扩展前端 LanguageSwitcher 组件支持三种语言选项 - 优化 Header 和侧边栏组件的响应式布局和文本截断处理 - 更新测试用例验证不同语言环境下的提示词正确性
723 lines
31 KiB
TypeScript
723 lines
31 KiB
TypeScript
'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>
|
||
);
|
||
}
|