定时任务 Cron 表达式支持(工作区遗留改动收尾)

- TestSchedule 新增可选 5 段 cron(分 时 日 月 周,本地时区),优先于 intervalMinutes;runLog 调度日志
- cron.ts 解析与匹配引擎 + 单测;scheduler 按最近下次触发时间调度
- ScheduleDialog / TestingView 支持 cron 输入与下次运行提示;AiCaseDialog 微调
- 服务端 internal/echo 调试回显端点;修复 model.ts 注释内 */ 导致的语法错误
- 全量 53 前端用例绿
This commit is contained in:
logan committed 2026-09-18 11:42:34 +08:00
1 parent ce6d2d1eff
commit 045e8c65c3
14 files changed
+657 -32

No files matched your search

+72 -9
View File
@@ -13,7 +13,10 @@ import type {
} from "@opsxapi/core";
import {
ASSERT_OP_LABELS,
createSeedWorkspace,
exportRunJson,
exportRunMarkdown,
isValidCron,
newAssertion,
newExtract,
newStep,
@@ -54,6 +57,38 @@ function useHttpNodeOptions(): { id: string; label: string; method: string }[] {
}, [collections]);
}
const REGRESSION_CASE_ID = "tc-opsx-http8-regression";
/** 装载 / 刷新本地 Gin echo 八组合回归用例(不整库清空) */
function installHttp8Regression(): string {
const seed = createSeedWorkspace();
const state = useWorkspace.getState();
const seedCase = seed.testCases.find((c) => c.id === REGRESSION_CASE_ID) ?? seed.testCases[0]!;
const localEnv = seed.environments.find((e) => e.name === "本地") ?? seed.environments[0];
const hasCase = state.testCases.some((c) => c.id === REGRESSION_CASE_ID);
const hasCollection = state.collections.some((c) => c.name === "示例集合");
const localExisting = state.environments.find((e) => e.name === "本地");
state.loadWorkspaceData({
version: 2,
collections: hasCollection
? state.collections.map((c) => (c.name === "示例集合" ? seed.collections[0]! : c))
: [...state.collections, ...seed.collections],
environments: localExisting
? state.environments
: [...state.environments, ...seed.environments.filter((e) => e.name === "本地")],
activeEnvironmentId: localExisting?.id ?? localEnv?.id ?? state.activeEnvironmentId,
history: state.history,
settings: state.settings,
testCases: hasCase
? state.testCases.map((c) => (c.id === REGRESSION_CASE_ID ? seedCase : c))
: [...state.testCases, seedCase],
testRuns: state.testRuns,
schedules: state.schedules,
});
return seedCase.id;
}
export function TestingView() {
const testCases = useWorkspace((s) => s.testCases);
const testRuns = useWorkspace((s) => s.testRuns);
@@ -96,6 +131,13 @@ export function TestingView() {
<Icon name="bot" size={13} /> AI 生成
</button>
</div>
<button
className="ops-btn ops-btn-secondary mt-1.5 w-full !px-2"
title="装载本地 Gin /v1/echo 八组合回归用例"
onClick={() => setSelected(installHttp8Regression())}
>
<Icon name="play" size={13} /> 装载回归示例
</button>
</div>
<div className="ops-scroll min-h-0 flex-1 p-2">
{testCases.length === 0 && (
@@ -498,13 +540,28 @@ function ScheduleInline({ caseId }: { caseId: string }) {
</button>
);
}
const cron = sc.cron ?? "";
const cronOk = !cron.trim() || isValidCron(cron.trim());
return (
<span className="flex items-center gap-1">
<span className="flex flex-wrap items-center gap-1">
<input
className="ops-input ops-input-sm !w-36 font-mono text-[11px]"
placeholder="Cron 可选 */5 * * * *"
value={cron}
title={cronOk ? "5 段 Cron(分 时 日 月 周);留空则用间隔" : "Cron 格式无效"}
onChange={(e) => upsertSchedule(caseId, { cron: e.target.value.trim() || null })}
style={!cronOk ? { borderColor: "var(--danger)" } : undefined}
/>
<select
className="ops-input ops-input-sm !w-24"
value={sc.intervalMinutes}
onChange={(e) => upsertSchedule(caseId, { intervalMinutes: Number(e.target.value) })}
title={`下次运行:${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt)).toLocaleTimeString("zh-CN")}`}
disabled={Boolean(cron.trim())}
onChange={(e) => upsertSchedule(caseId, { intervalMinutes: Number(e.target.value), cron: null })}
title={
cron.trim()
? "已启用 Cron,间隔停用"
: `下次运行:${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt, sc.cron)).toLocaleTimeString("zh-CN")}`
}
>
{[5, 15, 30, 60, 360, 1440].map((m) => (
<option key={m} value={m}>
@@ -554,13 +611,16 @@ function ReportPanel({ caseId }: { caseId: string }) {
const assertCount = run.stepResults.reduce((acc, s) => acc + s.assertionResults.length, 0);
const shown = run.stepResults.filter((s) => (filter === "all" ? true : filter === "pass" ? s.ok : !s.ok));
const download = () => {
const md = exportRunMarkdown(run);
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
const download = (format: "md" | "json") => {
const body = format === "json" ? exportRunJson(run) : exportRunMarkdown(run);
const blob = new Blob([body], {
type: format === "json" ? "application/json;charset=utf-8" : "text/markdown;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${run.caseName}-报告-${new Date(run.at).toISOString().slice(0, 10)}.md`;
const day = new Date(run.at).toISOString().slice(0, 10);
a.download = `${run.caseName}-报告-${day}.${format === "json" ? "json" : "md"}`;
a.click();
URL.revokeObjectURL(url);
};
@@ -596,8 +656,11 @@ function ReportPanel({ caseId }: { caseId: string }) {
>
<Icon name="rotate-cw" size={13} /> 再次运行
</button>
<button className="ops-btn ops-btn-secondary" onClick={download}>
<Icon name="file-down" size={13} /> 导出报告
<button className="ops-btn ops-btn-secondary" onClick={() => download("md")}>
<Icon name="file-down" size={13} /> 导出 MD
</button>
<button className="ops-btn ops-btn-secondary" onClick={() => download("json")}>
<Icon name="file-down" size={13} /> 导出 JSON
</button>
</div>
</div>