定时任务 Cron 表达式支持(工作区遗留改动收尾)
- TestSchedule 新增可选 5 段 cron(分 时 日 月 周,本地时区),优先于 intervalMinutes;runLog 调度日志 - cron.ts 解析与匹配引擎 + 单测;scheduler 按最近下次触发时间调度 - ScheduleDialog / TestingView 支持 cron 输入与下次运行提示;AiCaseDialog 微调 - 服务端 internal/echo 调试回显端点;修复 model.ts 注释内 */ 导致的语法错误 - 全量 53 前端用例绿
This commit is contained in:
1 parent
ce6d2d1eff
commit
045e8c65c3
14 files changed
+657
-32
No files matched your search
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import type { AiCaseDraft } from "../../ai";
|
||||
import { aiGenerateTestCase } from "../../ai";
|
||||
import { runTestCase } from "../../testRunner";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { Modal } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
@@ -39,14 +40,35 @@ export function AiCaseDialog({ onClose, onSaved }: { onClose: () => void; onSave
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!draft) return;
|
||||
const persistDraft = (): string | null => {
|
||||
if (!draft) return null;
|
||||
const id = addTestCase();
|
||||
updateTestCase(id, { name: draft.name, description: draft.description, steps: draft.steps });
|
||||
return id;
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const id = persistDraft();
|
||||
if (!id) return;
|
||||
onSaved(id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const saveAndRun = async () => {
|
||||
const id = persistDraft();
|
||||
if (!id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await runTestCase(id, "ai");
|
||||
onSaved(id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="AI 生成场景用例"
|
||||
@@ -60,9 +82,14 @@ export function AiCaseDialog({ onClose, onSaved }: { onClose: () => void; onSave
|
||||
取消
|
||||
</button>
|
||||
{draft ? (
|
||||
<button className="ops-btn ops-btn-primary" onClick={save}>
|
||||
<Icon name="circle-check" size={13} /> 保存用例
|
||||
</button>
|
||||
<>
|
||||
<button className="ops-btn ops-btn-secondary" disabled={busy} onClick={save}>
|
||||
<Icon name="circle-check" size={13} /> 仅保存
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-primary" disabled={busy} onClick={saveAndRun}>
|
||||
<Icon name="play" size={13} /> {busy ? "运行中…" : "保存并运行"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="ops-btn ops-btn-primary"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 定时任务管理:为每个用例配置启用状态与运行间隔(应用运行期间生效)。
|
||||
*/
|
||||
import { isValidCron } from "@opsxapi/core";
|
||||
import { nextRunAt } from "../../scheduler";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { Modal, cx } from "../common";
|
||||
@@ -30,7 +31,7 @@ export function ScheduleDialog({ onClose }: { onClose: () => void }) {
|
||||
width="640px"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="ops-faint text-[11px]">应用保持运行时按间隔自动执行并记录报告;关闭应用后暂停</span>
|
||||
<span className="ops-faint text-[11px]">应用保持运行时按 Cron 或间隔自动执行;写入运行报告与调度日志;关闭应用后暂停</span>
|
||||
<button className="ops-btn ops-btn-primary" onClick={onClose}>
|
||||
完成
|
||||
</button>
|
||||
@@ -50,17 +51,30 @@ export function ScheduleDialog({ onClose }: { onClose: () => void }) {
|
||||
<div className="truncate text-[13px] font-medium">{tc.name}</div>
|
||||
<div className="ops-faint text-[11px]">
|
||||
{sc?.enabled
|
||||
? `下次约 ${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt)).toLocaleTimeString("zh-CN")} 运行`
|
||||
? `下次约 ${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt, sc.cron)).toLocaleTimeString("zh-CN")} 运行`
|
||||
: "未启用定时"}
|
||||
{sc?.lastRunAt ? ` · 上次 ${new Date(sc.lastRunAt).toLocaleString("zh-CN")}` : ""}
|
||||
{last ? ` · ${last.passed ? "通过" : "失败"}` : ""}
|
||||
{sc?.runLog?.[0] ? ` · 日志 ${sc.runLog[0].message}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className="ops-input ops-input-sm !w-36 font-mono text-[11px]"
|
||||
placeholder="Cron */5 * * * *"
|
||||
disabled={!sc}
|
||||
value={sc?.cron ?? ""}
|
||||
title="5 段 Cron;填写后优先于间隔"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
upsertSchedule(tc.id, { cron: v || null });
|
||||
}}
|
||||
style={sc?.cron && !isValidCron(sc.cron) ? { borderColor: "var(--danger)" } : undefined}
|
||||
/>
|
||||
<select
|
||||
className="ops-input ops-input-sm !w-28"
|
||||
value={sc?.intervalMinutes ?? 30}
|
||||
disabled={!sc}
|
||||
onChange={(e) => upsertSchedule(tc.id, { intervalMinutes: Number(e.target.value) })}
|
||||
disabled={!sc || Boolean(sc?.cron?.trim())}
|
||||
onChange={(e) => upsertSchedule(tc.id, { intervalMinutes: Number(e.target.value), cron: null })}
|
||||
>
|
||||
{INTERVALS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
|
||||
Reference in new issue
Block a user