fix bug kéo thả của loop
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-13 14:20:23 +07:00
parent 9776e29d7d
commit 1716351016
6 changed files with 190 additions and 127 deletions

View File

@@ -1,33 +1,66 @@
{
"queue": [
{
"created_at": "2026-06-13T07:03:00Z",
"finished_at": "2026-06-13T07:03:01Z",
"id": "c636ad0d89937cd2",
"created_at": "2026-06-13T07:19:31Z",
"finished_at": "2026-06-13T07:19:32Z",
"id": "3a520bd342883c75",
"log": [
{
"level": "info",
"message": "Loop 1/1",
"ts": "2026-06-13T07:19:31Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-13T07:19:31Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-13T07:03:00Z"
"ts": "2026-06-13T07:19:31Z"
}
],
"mission": {
"actions": [
{
"id": "a1",
"children": [
{
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
"kind": "action",
"label": "Set PLC register",
"params": {
"action": "set",
"register": 1,
"value": 0
},
"type": "set_plc_register"
},
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
"kind": "action",
"label": "Wait",
"label": "Loop",
"params": {
"seconds": 1
"count": 1,
"mode": "count"
},
"type": "wait"
"type": "loop"
}
],
"description": "",
"group": "Missions",
"id": "5ae9dbcb0722dffb",
"name": "Test run",
"updated_at": "2026-06-13T04:44:03Z"
"updated_at": "2026-06-13T07:19:17.185Z"
},
"mission_group": "Missions",
"mission_id": "5ae9dbcb0722dffb",
@@ -36,37 +69,70 @@
"priority": 0,
"robot_id": "default",
"source": "ui",
"started_at": "2026-06-13T07:03:00Z",
"started_at": "2026-06-13T07:19:31Z",
"status": "completed"
},
{
"created_at": "2026-06-13T07:03:01Z",
"finished_at": "2026-06-13T07:03:02Z",
"id": "06048341b549f0ac",
"created_at": "2026-06-13T07:19:41Z",
"finished_at": "2026-06-13T07:19:43Z",
"id": "51de82d74cb4b1cd",
"log": [
{
"level": "info",
"message": "Loop 1/1",
"ts": "2026-06-13T07:19:41Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-13T07:19:41Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-13T07:03:01Z"
"ts": "2026-06-13T07:19:42Z"
}
],
"mission": {
"actions": [
{
"id": "a1",
"children": [
{
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
"kind": "action",
"label": "Set PLC register",
"params": {
"action": "set",
"register": 1,
"value": 0
},
"type": "set_plc_register"
},
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
"kind": "action",
"label": "Wait",
"label": "Loop",
"params": {
"seconds": 1
"count": 1,
"mode": "count"
},
"type": "wait"
"type": "loop"
}
],
"description": "",
"group": "Missions",
"id": "5ae9dbcb0722dffb",
"name": "Test run",
"updated_at": "2026-06-13T04:44:03Z"
"updated_at": "2026-06-13T07:19:17.185Z"
},
"mission_group": "Missions",
"mission_id": "5ae9dbcb0722dffb",
@@ -75,85 +141,7 @@
"priority": 0,
"robot_id": "default",
"source": "ui",
"started_at": "2026-06-13T07:03:01Z",
"status": "completed"
},
{
"created_at": "2026-06-13T07:03:01Z",
"finished_at": "2026-06-13T07:03:03Z",
"id": "887245afd51df357",
"log": [
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-13T07:03:02Z"
}
],
"mission": {
"actions": [
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"description": "",
"group": "Missions",
"id": "5ae9dbcb0722dffb",
"name": "Test run",
"updated_at": "2026-06-13T04:44:03Z"
},
"mission_group": "Missions",
"mission_id": "5ae9dbcb0722dffb",
"mission_name": "Test run",
"parameters": {},
"priority": 0,
"robot_id": "default",
"source": "modbus:1005",
"started_at": "2026-06-13T07:03:02Z",
"status": "completed"
},
{
"created_at": "2026-06-13T07:03:01Z",
"finished_at": "2026-06-13T07:03:04Z",
"id": "4365bd4d8beedfd2",
"log": [
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-13T07:03:03Z"
}
],
"mission": {
"actions": [
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"description": "",
"group": "Missions",
"id": "5ae9dbcb0722dffb",
"name": "Test run",
"updated_at": "2026-06-13T04:44:03Z"
},
"mission_group": "Missions",
"mission_id": "5ae9dbcb0722dffb",
"mission_name": "Test run",
"parameters": {},
"priority": 0,
"robot_id": "default",
"source": "fleet:pytest-schedule",
"started_at": "2026-06-13T07:03:03Z",
"started_at": "2026-06-13T07:19:41Z",
"status": "completed"
}
],
@@ -163,6 +151,6 @@
"message": "Hoàn thành: Test run",
"paused": false,
"state": "idle",
"updated_at": "2026-06-13T07:03:04Z"
"updated_at": "2026-06-13T07:19:43Z"
}
}

View File

@@ -14,20 +14,43 @@
{
"actions": [
{
"id": "a1",
"children": [
{
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
"kind": "action",
"label": "Set PLC register",
"params": {
"action": "set",
"register": 1,
"value": 0
},
"type": "set_plc_register"
},
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
"kind": "action",
"label": "Wait",
"label": "Loop",
"params": {
"seconds": 1
"count": 1,
"mode": "count"
},
"type": "wait"
"type": "loop"
}
],
"description": "",
"group": "Missions",
"id": "5ae9dbcb0722dffb",
"name": "Test run",
"updated_at": "2026-06-13T04:44:03Z"
"updated_at": "2026-06-13T07:19:17.185Z"
},
{
"actions": [

View File

@@ -411,7 +411,7 @@ void MissionQueue::runMissionActions(nlohmann::json& entry)
}
}
void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
const nlohmann::json& parameters,
nlohmann::json& log,
int loop_depth)
@@ -426,7 +426,7 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
while (paused_ && !stop_)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (stop_)
return;
return LoopControl::None;
const std::string action_id = action.value("id", "");
const std::string kind = action.value("kind", "action");
@@ -445,30 +445,58 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
{
const std::string ref_id = action.value("refId", "");
nlohmann::json ref_mission = nlohmann::json::object();
// Nested mission snapshot should be resolved by frontend before queueing.
(void)ref_id;
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Sub-mission: " + label}});
if (action.contains("resolved_mission") && action["resolved_mission"].is_object())
{
const auto& nested_actions = action["resolved_mission"]["actions"];
executeActionsUnlocked(nested_actions, parameters, log, loop_depth);
const LoopControl nested = executeActionsUnlocked(nested_actions, parameters, log, loop_depth);
if (nested == LoopControl::Break)
return LoopControl::Break;
if (nested == LoopControl::Continue)
continue;
}
continue;
}
if (type == "break")
{
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Break loop"}});
return LoopControl::Break;
}
if (type == "continue")
{
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Continue loop"}});
return LoopControl::Continue;
}
if (type == "loop")
{
const std::string mode = params.value("mode", "count");
const int count = static_cast<int>(paramNumber(params, "count", 1));
const auto& children =
action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array();
const int iterations = mode == "endless" ? 1 : std::max(1, count);
for (int i = 0; i < iterations; ++i)
const int iterations = mode == "endless" ? 10000 : std::max(1, count);
for (int i = 0; i < iterations && !stop_; ++i)
{
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "info"},
{"message", "Loop " + std::to_string(i + 1) + "/" + std::to_string(iterations)}});
executeActionsUnlocked(children, parameters, log, loop_depth + 1);
if (mode == "endless" && i == 0)
{
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "info"},
{"message", "Loop endless (simulated, max " + std::to_string(iterations) + ")"}});
}
else if (mode != "endless")
{
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "info"},
{"message", "Loop " + std::to_string(i + 1) + "/" + std::to_string(iterations)}});
}
const LoopControl ctrl = executeActionsUnlocked(children, parameters, log, loop_depth + 1);
if (ctrl == LoopControl::Break)
break;
if (ctrl == LoopControl::Continue)
continue;
}
continue;
}
@@ -520,6 +548,7 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
{{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}});
sleepMs(400);
}
return LoopControl::None;
}
void MissionQueue::sleepMs(int ms)

View File

@@ -31,6 +31,8 @@ public:
bool resume(std::string& err);
private:
enum class LoopControl { None, Break, Continue };
std::filesystem::path queue_path_;
mutable std::recursive_mutex mu_;
nlohmann::json queue_;
@@ -47,10 +49,10 @@ private:
void startWorkerIfNeeded();
void workerLoop();
void runMissionActions(nlohmann::json& entry);
void executeActionsUnlocked(const nlohmann::json& actions,
const nlohmann::json& parameters,
nlohmann::json& log,
int loop_depth);
LoopControl executeActionsUnlocked(const nlohmann::json& actions,
const nlohmann::json& parameters,
nlohmann::json& log,
int loop_depth);
void sleepMs(int ms);
void setRunnerState(const std::string& state, const std::string& message = "");
void insertByPriorityUnlocked(nlohmann::json& entry);

View File

@@ -302,11 +302,21 @@
}
}
function normalizeActionTree(actions) {
if (!Array.isArray(actions)) return;
actions.forEach((action) => {
if (action.type === "loop" && !Array.isArray(action.children)) {
action.children = [];
}
if (Array.isArray(action.children)) normalizeActionTree(action.children);
});
}
function findActionList(path) {
const draft = getDraft();
if (!draft) return null;
if (path === "root") return draft.actions;
const parts = path.split(".");
const parts = path.split(".").filter((p) => p !== "root");
let list = draft.actions;
for (const part of parts) {
const node = list.find((a) => a.id === part);
@@ -321,7 +331,7 @@
for (let i = 0; i < list.length; i += 1) {
const action = list[i];
if (action.id === actionId) return { action, list, index: i, path, parent };
if (action.children?.length) {
if (Array.isArray(action.children)) {
const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
if (hit) return hit;
}
@@ -765,12 +775,17 @@
const item = document.createElement("button");
item.type = "button";
item.className = "missionPaletteItem";
item.draggable = true;
item.innerHTML = `<span class="missionActionIcon">${def.isLoop ? "↻" : "▶"}</span><span>${escapeHtml(def.label)}</span>`;
item.addEventListener("click", () => {
addActionToList(def.type, "root");
menu.hidden = true;
btn.classList.remove("open");
});
item.addEventListener("dragstart", (evt) => {
onPaletteDragStart(evt, { actionType: def.type });
});
item.addEventListener("dragend", onDragEnd);
menu.appendChild(item);
});
@@ -785,12 +800,17 @@
const item = document.createElement("button");
item.type = "button";
item.className = "missionPaletteItem missionRef";
item.draggable = true;
item.innerHTML = `<span class="missionActionIcon kind-mission">◎</span><span>${escapeHtml(m.name)}</span>`;
item.addEventListener("click", () => {
addMissionRefToList(m.id, "root");
menu.hidden = true;
btn.classList.remove("open");
});
item.addEventListener("dragstart", (evt) => {
onPaletteDragStart(evt, { missionId: m.id });
});
item.addEventListener("dragend", onDragEnd);
menu.appendChild(item);
});
}
@@ -891,15 +911,13 @@
renderActionPalette();
missionActionListEl.innerHTML = "";
renderActionRows(draft.actions, "root", missionActionListEl);
bindDragPaletteItems();
}
function bindDragPaletteItems() {
document.querySelectorAll(".missionLoopDrop").forEach((drop) => {
drop.addEventListener("dragover", onLoopDragOver);
drop.addEventListener("dragleave", onLoopDragLeave);
drop.addEventListener("drop", onLoopDrop);
});
function onPaletteDragStart(evt, payload) {
store.drag = { mode: "palette", ...payload };
evt.dataTransfer.effectAllowed = "copy";
evt.dataTransfer.setData("text/plain", payload.actionType || payload.missionId || "palette");
evt.currentTarget.classList.add("dragging");
}
function onDragStart(evt, actionId, listPath) {
@@ -913,6 +931,7 @@
document.querySelectorAll(".missionActionRow.dragging, .missionActionRow.dropBefore, .missionActionRow.dropAfter").forEach((n) => {
n.classList.remove("dragging", "dropBefore", "dropAfter");
});
document.querySelectorAll(".missionPaletteItem.dragging").forEach((n) => n.classList.remove("dragging"));
document.querySelectorAll(".missionLoopDrop.dragOver").forEach((n) => n.classList.remove("dragOver"));
store.drag = null;
}
@@ -980,6 +999,7 @@
if (!mission) return;
store.editingId = missionId;
store.draft = JSON.parse(JSON.stringify(mission));
normalizeActionTree(store.draft.actions);
setDirty(false);
missionsListViewEl.hidden = true;
missionEditorViewEl.hidden = false;

View File

@@ -718,6 +718,7 @@ canvas {
color: var(--text);
}
.missionPaletteItem:hover { background: #eff6ff; }
.missionPaletteItem.dragging { opacity: 0.45; }
.missionPaletteItem.missionRef .missionActionIcon { background: rgba(16, 185, 129, 0.15); color: #059669; }
.missionEditorBody { padding: 16px 18px 20px; }