- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing, full-width parens around Chinese, single ellipsis, 「」 quotes, period rules) - scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX text (not comments, types, keys, match/compare strings); --fix, --strict, --by-file; wired into deno task ci as check:copy in warn mode - --fix applied to web, server and contracts; files other lanes were editing (subscriptions, sites, browser, agent, auth/account, inventory) left for them - tests updated for the changed messages Co-Authored-By: Claude Opus 5.5 <[email protected]>
223 lines
8.6 KiB
TypeScript
223 lines
8.6 KiB
TypeScript
import type { AgentCheckConfig, ScheduledJob, ScheduleSpecInput } from "@umt/contracts";
|
|
import { History, LoaderCircle, Pencil, Play, RotateCcw, SkipForward, Trash2 } from "lucide-react";
|
|
import { useEffect, useRef } from "react";
|
|
import { toast } from "sonner";
|
|
import { useDeleteSchedule, useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts";
|
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { Switch } from "~/components/ui/switch.tsx";
|
|
import { errorMessage } from "~/lib/api.ts";
|
|
import { formatRelative } from "~/lib/labels.ts";
|
|
import { cn } from "~/lib/utils.ts";
|
|
import { IntervalEditor } from "./interval-editor.tsx";
|
|
import { CHECK_TRIGGER_LABEL, countdown, formatMs, RUN_STATUS } from "./labels.ts";
|
|
|
|
/** 下次运行一栏:运行中 > 排队中 > 已停用 > 倒计时 */
|
|
function NextRun({ job, now }: { job: ScheduledJob; now: number }) {
|
|
if (job.runningSince)
|
|
return (
|
|
<span className="inline-flex items-center gap-1 text-foreground">
|
|
<LoaderCircle className="h-3 w-3 animate-spin" />
|
|
运行中 · 已 {formatMs(Math.max(0, now - new Date(job.runningSince).getTime()))}
|
|
</span>
|
|
);
|
|
if (job.runRequestedAt) return <span className="text-foreground">排队中</span>;
|
|
if (!job.available) return <span>未配置</span>;
|
|
if (!job.enabled) return <span>已停用</span>;
|
|
if (!job.nextRunAt) return <span>待调度</span>;
|
|
return <span className="tabular-nums text-foreground">{countdown(job.nextRunAt, now)}</span>;
|
|
}
|
|
|
|
function AgentCheckSummary({ config }: { config: Partial<AgentCheckConfig> }) {
|
|
const budget = config.budget ?? {};
|
|
const allow = config.capabilityAllowlist ?? [];
|
|
return (
|
|
<div className="grid gap-1.5 rounded-tight border border-border/70 px-2.5 py-2 text-meta text-muted-foreground">
|
|
<p className="line-clamp-2 text-caption text-foreground" title={config.goalTemplate}>
|
|
{config.goalTemplate ?? "—"}
|
|
</p>
|
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
|
<span>条件:{CHECK_TRIGGER_LABEL[config.triggerCondition ?? "always"]}</span>
|
|
<span>
|
|
预算:{budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : "默认"}
|
|
{budget.maxDurationSec ? ` · ${budget.maxDurationSec} 秒` : ""}
|
|
</span>
|
|
<span>能力:{allow.length ? `${allow.length} 项` : "全部只读能力"}</span>
|
|
{config.allowRecover ? <span>允许授权内恢复</span> : null}
|
|
{config.reportAsDecision ? <span>写入待处理</span> : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ScheduleCard({
|
|
job,
|
|
now,
|
|
onHistory,
|
|
onEdit,
|
|
highlight = false,
|
|
}: {
|
|
job: ScheduledJob;
|
|
now: number;
|
|
onHistory: (job: ScheduledJob) => void;
|
|
onEdit: (job: ScheduledJob) => void;
|
|
/** 从任务流跳来时高亮并滚到可见 */
|
|
highlight?: boolean;
|
|
}) {
|
|
const update = useUpdateSchedule();
|
|
const runNow = useRunScheduleNow();
|
|
const skip = useSkipSchedule();
|
|
const del = useDeleteSchedule();
|
|
const ref = useRef<HTMLElement>(null);
|
|
useEffect(() => {
|
|
if (highlight) ref.current?.scrollIntoView({ block: "center" });
|
|
}, [highlight]);
|
|
const busy = !!job.runningSince || !!job.runRequestedAt;
|
|
const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null;
|
|
|
|
const patch = (body: Parameters<typeof update.mutate>[0]["body"], ok: string) =>
|
|
update.mutate(
|
|
{ id: job.id, body },
|
|
{ onSuccess: () => toast.success(ok), onError: (e) => toast.error(errorMessage(e)) },
|
|
);
|
|
|
|
return (
|
|
<article
|
|
id={`job-${job.id}`}
|
|
ref={ref}
|
|
className={cn(
|
|
"pg-slab grid min-w-0 scroll-mt-20 gap-3 rounded-xl px-4 py-3.5",
|
|
highlight && "ring-2 ring-ring/50",
|
|
)}
|
|
aria-label={job.name}
|
|
>
|
|
<header className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h3 className="truncate text-lead font-medium text-foreground" title={job.name}>
|
|
{job.name}
|
|
</h3>
|
|
{job.description ? (
|
|
<p className="mt-0.5 line-clamp-2 text-meta text-muted-foreground">{job.description}</p>
|
|
) : null}
|
|
</div>
|
|
<Switch
|
|
aria-label={job.enabled ? "停用" : "启用"}
|
|
checked={job.enabled && job.available}
|
|
disabled={!job.available || update.isPending}
|
|
onCheckedChange={(v) => patch({ enabled: v }, v ? "已启用" : "已停用")}
|
|
/>
|
|
</header>
|
|
|
|
{job.kind === "agent_check" ? <AgentCheckSummary config={job.config as Partial<AgentCheckConfig>} /> : null}
|
|
|
|
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1.5 text-meta text-muted-foreground">
|
|
<dt>间隔</dt>
|
|
<dd className="min-w-0">
|
|
<IntervalEditor
|
|
schedule={job.schedule}
|
|
disabled={!job.available}
|
|
pending={update.isPending}
|
|
onSave={(schedule: ScheduleSpecInput) => patch({ schedule }, "间隔已更新")}
|
|
/>
|
|
</dd>
|
|
<dt>上次</dt>
|
|
<dd className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
|
|
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
|
{job.lastDurationMs !== null ? <span>耗时 {formatMs(job.lastDurationMs)}</span> : null}
|
|
</dd>
|
|
<dt>下次</dt>
|
|
<dd className="min-w-0">
|
|
<NextRun job={job} now={now} />
|
|
</dd>
|
|
</dl>
|
|
|
|
{job.lastStatus === "failed" && job.lastError ? (
|
|
<details className="group rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
|
|
<summary className="cursor-pointer truncate group-open:whitespace-normal">{job.lastError}</summary>
|
|
<p className="mt-1 break-words whitespace-pre-wrap">{job.lastError}</p>
|
|
</details>
|
|
) : null}
|
|
|
|
<footer className="flex flex-wrap items-center gap-1.5">
|
|
<Button
|
|
size="sm"
|
|
disabled={!job.available || busy || runNow.isPending}
|
|
onClick={() =>
|
|
runNow.mutate(
|
|
{ id: job.id },
|
|
{
|
|
onSuccess: (r) => toast.success(r.queued ? "已排队执行" : "已在排队或运行中"),
|
|
onError: (e) => toast.error(errorMessage(e)),
|
|
},
|
|
)
|
|
}
|
|
>
|
|
{runNow.isPending ? <LoaderCircle className="animate-spin" /> : <Play />}
|
|
立即执行
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={!job.available || !job.enabled || !job.nextRunAt || skip.isPending}
|
|
onClick={() =>
|
|
skip.mutate(
|
|
{ id: job.id },
|
|
{ onSuccess: () => toast.success("已跳过本次"), onError: (e) => toast.error(errorMessage(e)) },
|
|
)
|
|
}
|
|
>
|
|
<SkipForward />
|
|
跳过本次
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => onHistory(job)}>
|
|
<History />
|
|
运行记录
|
|
</Button>
|
|
<span className="ml-auto flex items-center gap-1">
|
|
{job.customized ? (
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
aria-label="恢复默认"
|
|
title="恢复默认"
|
|
disabled={update.isPending}
|
|
onClick={() => patch({ reset: true }, "已恢复默认")}
|
|
>
|
|
<RotateCcw />
|
|
</Button>
|
|
) : null}
|
|
{job.kind === "agent_check" ? (
|
|
<Button size="icon-sm" variant="ghost" aria-label="编辑巡检" title="编辑巡检" onClick={() => onEdit(job)}>
|
|
<Pencil />
|
|
</Button>
|
|
) : null}
|
|
{!job.builtin ? (
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button size="icon-sm" variant="ghost" aria-label="删除巡检" title="删除巡检">
|
|
<Trash2 />
|
|
</Button>
|
|
}
|
|
title={`删除「${job.name}」?`}
|
|
description="运行记录一并删除,已产生的 Agent 会话保留。"
|
|
confirmLabel="删除巡检"
|
|
destructive
|
|
onConfirm={() =>
|
|
del.mutateAsync({ id: job.id }).then(
|
|
() => toast.success("已删除"),
|
|
(e) => {
|
|
toast.error(errorMessage(e));
|
|
throw e;
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
) : null}
|
|
</span>
|
|
</footer>
|
|
</article>
|
|
);
|
|
}
|