Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
158 lines
6.1 KiB
TypeScript
158 lines
6.1 KiB
TypeScript
import type {
|
|
AgentCheckTrigger,
|
|
ScheduleGroup,
|
|
ScheduleRunStatus,
|
|
ScheduleSpec,
|
|
ScheduleTrigger,
|
|
} from "@umt/contracts";
|
|
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
|
|
|
// 定时任务看板的标签映射(只在本页用,不放 lib/labels.ts)
|
|
|
|
export const GROUP_ORDER: ScheduleGroup[] = ["collect", "download", "organize", "maintain", "agent"];
|
|
/** 计划页「自动任务」一节的分组(智能体巡检单独一节,放在最上面);维护类合成一组「数据清理」 */
|
|
export const TASK_GROUPS: ScheduleGroup[] = ["collect", "download", "organize", "maintain"];
|
|
|
|
/** 定时任务视图两节的锚点:/console/logs?tab=schedules#auto、#checks(界面第二轮:计划页并入日志页的「定时任务」页签) */
|
|
export type PlanSection = "auto" | "checks";
|
|
export const PLAN_PATH = "/console/logs?tab=schedules";
|
|
export function planPath(opts: { section?: PlanSection; job?: string } = {}): string {
|
|
const q = opts.job ? `&job=${encodeURIComponent(opts.job)}` : "";
|
|
return `${PLAN_PATH}${q}${opts.section ? `#${opts.section}` : ""}`;
|
|
}
|
|
|
|
/** 日志页里某个定时任务的全部运行记录(含它触发的任务与修改留痕) */
|
|
export const scheduleStreamPath = (id: string) =>
|
|
`/console/logs?objectType=scheduled_job&objectId=${encodeURIComponent(id)}`;
|
|
|
|
export const GROUP_LABEL: Record<ScheduleGroup, string> = {
|
|
collect: "采集",
|
|
download: "下载",
|
|
organize: "整理",
|
|
maintain: "数据清理",
|
|
agent: "智能体巡检",
|
|
};
|
|
|
|
export const RUN_STATUS: Record<ScheduleRunStatus, { label: string; tone: StatusTone }> = {
|
|
ok: { label: "成功", tone: "success" },
|
|
failed: { label: "失败", tone: "destructive" },
|
|
skipped: { label: "已跳过", tone: "warning" },
|
|
running: { label: "运行中", tone: "neutral" },
|
|
};
|
|
|
|
export const TRIGGER_LABEL: Record<ScheduleTrigger, string> = {
|
|
schedule: "定时",
|
|
manual: "手动",
|
|
skip: "跳过",
|
|
};
|
|
|
|
export const CHECK_TRIGGER_LABEL: Record<AgentCheckTrigger, string> = {
|
|
always: "每次运行",
|
|
open_decisions: "有待处理时",
|
|
stalled_downloads: "有下载无进展时",
|
|
subscriptions_active: "有追踪订阅时",
|
|
};
|
|
|
|
export type IntervalUnit = "s" | "m" | "h";
|
|
export const UNIT_MS: Record<IntervalUnit, number> = { s: 1000, m: 60_000, h: 3600_000 };
|
|
export const UNIT_LABEL: Record<IntervalUnit, string> = { s: "秒", m: "分钟", h: "小时" };
|
|
|
|
/** 间隔下拉的常用选项(毫秒);当前值不在其中时下拉另列一项 */
|
|
export const INTERVAL_PRESETS: number[] = [
|
|
30_000,
|
|
60_000,
|
|
5 * 60_000,
|
|
10 * 60_000,
|
|
30 * 60_000,
|
|
3600_000,
|
|
6 * 3600_000,
|
|
12 * 3600_000,
|
|
24 * 3600_000,
|
|
7 * 24 * 3600_000,
|
|
];
|
|
|
|
/** 美元费用:两位小数,不足一分写 "< $0.01";空值 "—" */
|
|
export function formatUsd(v: number | null | undefined): string {
|
|
if (v === null || v === undefined) return "—";
|
|
if (v === 0) return "$0";
|
|
if (v < 0.01) return "< $0.01";
|
|
return `$${v.toFixed(2)}`;
|
|
}
|
|
|
|
/** 毫秒 → 最大整除单位 */
|
|
export function splitInterval(ms: number): { value: number; unit: IntervalUnit } {
|
|
if (ms % UNIT_MS.h === 0) return { value: ms / UNIT_MS.h, unit: "h" };
|
|
if (ms % UNIT_MS.m === 0) return { value: ms / UNIT_MS.m, unit: "m" };
|
|
return { value: Math.max(1, Math.round(ms / 1000)), unit: "s" };
|
|
}
|
|
|
|
/** 调度的简短描述:"每 30 秒" / "cron 0 3 * * *" */
|
|
export function describeSchedule(s: ScheduleSpec): string {
|
|
if (s.type === "cron") return `cron ${s.cron}`;
|
|
if (s.everyMs < 1000) return `每 ${s.everyMs} 毫秒`;
|
|
const { value, unit } = splitInterval(s.everyMs);
|
|
// 整天的间隔按天说(每 168 小时 → 每 7 天)
|
|
if (unit === "h" && value >= 24 && value % 24 === 0) return `每 ${value / 24} 天`;
|
|
return `每 ${value} ${UNIT_LABEL[unit]}`;
|
|
}
|
|
|
|
/** 毫秒耗时:"320 毫秒" / "2.4 秒" / "3 分 5 秒" */
|
|
export function formatMs(ms: number | null | undefined): string {
|
|
if (ms === null || ms === undefined) return "—";
|
|
if (ms < 1000) return `${ms} 毫秒`;
|
|
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} 秒`;
|
|
const sec = Math.round(ms / 1000);
|
|
if (sec < 3600) return `${Math.floor(sec / 60)} 分 ${sec % 60} 秒`;
|
|
return `${Math.floor(sec / 3600)} 小时 ${Math.floor((sec % 3600) / 60)} 分`;
|
|
}
|
|
|
|
/** 倒计时:"12 秒后" / "3 分 5 秒后" / "已到期" */
|
|
export function countdown(iso: string, now: number): string {
|
|
const diff = Math.round((new Date(iso).getTime() - now) / 1000);
|
|
if (Number.isNaN(diff)) return "—";
|
|
if (diff <= 0) return "即将运行";
|
|
if (diff < 60) return `${diff} 秒后`;
|
|
if (diff < 3600) return `${Math.floor(diff / 60)} 分 ${diff % 60} 秒后`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时 ${Math.floor((diff % 3600) / 60)} 分后`;
|
|
return `${Math.floor(diff / 86400)} 天 ${Math.floor((diff % 86400) / 3600)} 小时后`;
|
|
}
|
|
|
|
export function formatClock(iso: string | null | undefined): string {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return iso;
|
|
return d.toLocaleString("zh-CN", {
|
|
hour12: false,
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
}
|
|
|
|
/** 巡检目标模板的占位符(服务端 renderGoal 执行时替换);界面上显示成名称,不露 `{{now}}` */
|
|
export const GOAL_PLACEHOLDER_LABEL: Record<string, string> = {
|
|
now: "当前时间",
|
|
date: "今天日期",
|
|
openDecisions: "待处理事项数",
|
|
stalledDownloads: "异常下载数",
|
|
activeSubscriptions: "追踪中的订阅数",
|
|
};
|
|
|
|
/** 目标模板拆成文字与占位符两段(纯函数,单测覆盖);不认识的占位符原样当文字 */
|
|
export function goalTemplateParts(template: string): ({ text: string } | { placeholder: string; label: string })[] {
|
|
const out: ({ text: string } | { placeholder: string; label: string })[] = [];
|
|
let last = 0;
|
|
for (const m of template.matchAll(/\{\{\s*(\w+)\s*\}\}/g)) {
|
|
const key = m[1] ?? "";
|
|
const label = GOAL_PLACEHOLDER_LABEL[key];
|
|
if (!label) continue;
|
|
if (m.index > last) out.push({ text: template.slice(last, m.index) });
|
|
out.push({ placeholder: key, label });
|
|
last = m.index + m[0].length;
|
|
}
|
|
if (last < template.length) out.push({ text: template.slice(last) });
|
|
return out;
|
|
}
|