Compare commits
2 Commits
8c111f2406
...
10f4c36c23
| Author | SHA1 | Date | |
|---|---|---|---|
| 10f4c36c23 | |||
| 853acefac1 |
@@ -29,10 +29,25 @@ endif()
|
|||||||
|
|
||||||
add_executable(lidar_manager_web
|
add_executable(lidar_manager_web
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
|
src/app/lidar_manager_app.cpp
|
||||||
|
src/util/file_util.cpp
|
||||||
|
src/util/string_util.cpp
|
||||||
|
src/util/id_util.cpp
|
||||||
|
src/util/http_util.cpp
|
||||||
|
src/domain/layout_schema.cpp
|
||||||
|
src/domain/layout_profile.cpp
|
||||||
|
src/storage/state_repository.cpp
|
||||||
|
src/validation/sensor_validator.cpp
|
||||||
|
src/server/static_file_server.cpp
|
||||||
|
src/server/api_server.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads)
|
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads)
|
||||||
|
|
||||||
|
target_include_directories(lidar_manager_web PRIVATE
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||||
|
)
|
||||||
|
|
||||||
target_include_directories(lidar_manager_web SYSTEM PRIVATE
|
target_include_directories(lidar_manager_web SYSTEM PRIVATE
|
||||||
"${cpp_httplib_SOURCE_DIR}"
|
"${cpp_httplib_SOURCE_DIR}"
|
||||||
"${nlohmann_json_SOURCE_DIR}/single_include"
|
"${nlohmann_json_SOURCE_DIR}/single_include"
|
||||||
|
|||||||
14
src/app/app_state.hpp
Normal file
14
src/app/app_state.hpp
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
struct AppState
|
||||||
|
{
|
||||||
|
std::filesystem::path data_path;
|
||||||
|
nlohmann::json state;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
40
src/app/lidar_manager_app.cpp
Normal file
40
src/app/lidar_manager_app.cpp
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#include "app/lidar_manager_app.hpp"
|
||||||
|
|
||||||
|
#include "server/api_server.hpp"
|
||||||
|
#include "server/static_file_server.hpp"
|
||||||
|
#include "storage/state_repository.hpp"
|
||||||
|
|
||||||
|
#include <httplib.h>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
LidarManagerApp::LidarManagerApp(int port,
|
||||||
|
std::filesystem::path www_root,
|
||||||
|
std::filesystem::path data_path)
|
||||||
|
: port_(port), www_root_(std::move(www_root)), data_path_(std::move(data_path))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
int LidarManagerApp::run()
|
||||||
|
{
|
||||||
|
StateRepository repo(data_path_);
|
||||||
|
repo.load();
|
||||||
|
|
||||||
|
httplib::Server svr;
|
||||||
|
ApiServer api(repo);
|
||||||
|
api.registerRoutes(svr);
|
||||||
|
StaticFileServer::mount(svr, www_root_);
|
||||||
|
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"lidar_manager_web listening on http://0.0.0.0:%d (www=%s, state=%s, models=%s)\n",
|
||||||
|
port_,
|
||||||
|
www_root_.string().c_str(),
|
||||||
|
data_path_.string().c_str(),
|
||||||
|
(data_path_.parent_path() / "models").string().c_str());
|
||||||
|
|
||||||
|
svr.listen("0.0.0.0", port_);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
20
src/app/lidar_manager_app.hpp
Normal file
20
src/app/lidar_manager_app.hpp
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class LidarManagerApp
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
LidarManagerApp(int port, std::filesystem::path www_root, std::filesystem::path data_path);
|
||||||
|
|
||||||
|
int run();
|
||||||
|
|
||||||
|
private:
|
||||||
|
int port_;
|
||||||
|
std::filesystem::path www_root_;
|
||||||
|
std::filesystem::path data_path_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
105
src/domain/layout_profile.cpp
Normal file
105
src/domain/layout_profile.cpp
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#include "domain/layout_profile.hpp"
|
||||||
|
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
nlohmann::json LayoutProfile::make(const std::string& name,
|
||||||
|
const nlohmann::json& layout,
|
||||||
|
const nlohmann::json& lidars,
|
||||||
|
const nlohmann::json& imus)
|
||||||
|
{
|
||||||
|
const std::string ts = IdUtil::nowIso8601();
|
||||||
|
return nlohmann::json{{"id", IdUtil::newId()},
|
||||||
|
{"name", name},
|
||||||
|
{"created_at", ts},
|
||||||
|
{"updated_at", ts},
|
||||||
|
{"layout", layout},
|
||||||
|
{"lidars", lidars},
|
||||||
|
{"imus", imus}};
|
||||||
|
}
|
||||||
|
|
||||||
|
void LayoutProfile::touch(nlohmann::json& profile)
|
||||||
|
{
|
||||||
|
profile["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string LayoutProfile::modelFromLayout(const nlohmann::json& layout)
|
||||||
|
{
|
||||||
|
if (layout.is_object() && layout.contains("robot") && layout["robot"].is_object() &&
|
||||||
|
layout["robot"].contains("model") && layout["robot"]["model"].is_string())
|
||||||
|
return layout["robot"]["model"].get<std::string>();
|
||||||
|
return "diff";
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json LayoutProfile::catalogEntryFromProfile(const nlohmann::json& profile)
|
||||||
|
{
|
||||||
|
const nlohmann::json& layout = profile.contains("layout") ? profile["layout"] : nlohmann::json::object();
|
||||||
|
const size_t lidar_count =
|
||||||
|
profile.contains("lidars") && profile["lidars"].is_array() ? profile["lidars"].size() : 0;
|
||||||
|
const size_t imu_count =
|
||||||
|
profile.contains("imus") && profile["imus"].is_array() ? profile["imus"].size() : 0;
|
||||||
|
return nlohmann::json{{"id", profile["id"]},
|
||||||
|
{"name", profile["name"]},
|
||||||
|
{"model", modelFromLayout(layout)},
|
||||||
|
{"created_at", profile.value("created_at", "")},
|
||||||
|
{"updated_at", profile.value("updated_at", "")},
|
||||||
|
{"lidar_count", lidar_count},
|
||||||
|
{"imu_count", imu_count}};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<size_t> LayoutProfile::findIndex(const nlohmann::json& state, const std::string& id)
|
||||||
|
{
|
||||||
|
if (!state.contains("layouts") || !state["layouts"].is_array())
|
||||||
|
return std::nullopt;
|
||||||
|
const auto& layouts = state["layouts"];
|
||||||
|
for (size_t i = 0; i < layouts.size(); i++)
|
||||||
|
{
|
||||||
|
const auto& p = layouts[i];
|
||||||
|
if (p.is_object() && p.contains("id") && p["id"].is_string() && p["id"].get<std::string>() == id)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<size_t> LayoutProfile::findActiveIndex(nlohmann::json& state)
|
||||||
|
{
|
||||||
|
if (!state.contains("active_layout_id") || !state["active_layout_id"].is_string())
|
||||||
|
return std::nullopt;
|
||||||
|
return findIndex(state, state["active_layout_id"].get<std::string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool LayoutProfile::nameExists(const nlohmann::json& state,
|
||||||
|
const std::string& name,
|
||||||
|
const std::string* exclude_id)
|
||||||
|
{
|
||||||
|
if (!state.contains("layouts") || !state["layouts"].is_array())
|
||||||
|
return false;
|
||||||
|
const std::string n = StringUtil::trimCopy(name);
|
||||||
|
for (const auto& p : state["layouts"])
|
||||||
|
{
|
||||||
|
if (!p.is_object() || !p.contains("name"))
|
||||||
|
continue;
|
||||||
|
if (exclude_id && p.contains("id") && p["id"].get<std::string>() == *exclude_id)
|
||||||
|
continue;
|
||||||
|
if (StringUtil::trimCopy(p["name"].get<std::string>()) == n)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json LayoutProfile::buildCatalog(const nlohmann::json& state)
|
||||||
|
{
|
||||||
|
nlohmann::json catalog = nlohmann::json::array();
|
||||||
|
if (!state.contains("layouts") || !state["layouts"].is_array())
|
||||||
|
return catalog;
|
||||||
|
for (const auto& p : state["layouts"])
|
||||||
|
{
|
||||||
|
if (!p.is_object() || !p.contains("id") || !p.contains("name"))
|
||||||
|
continue;
|
||||||
|
catalog.push_back(p);
|
||||||
|
}
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
} // namespace lm
|
||||||
32
src/domain/layout_profile.hpp
Normal file
32
src/domain/layout_profile.hpp
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class LayoutProfile
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static nlohmann::json make(const std::string& name,
|
||||||
|
const nlohmann::json& layout,
|
||||||
|
const nlohmann::json& lidars,
|
||||||
|
const nlohmann::json& imus = nlohmann::json::array());
|
||||||
|
|
||||||
|
static void touch(nlohmann::json& profile);
|
||||||
|
static std::string modelFromLayout(const nlohmann::json& layout);
|
||||||
|
static nlohmann::json catalogEntryFromProfile(const nlohmann::json& profile);
|
||||||
|
|
||||||
|
static std::optional<size_t> findIndex(const nlohmann::json& state, const std::string& id);
|
||||||
|
static std::optional<size_t> findActiveIndex(nlohmann::json& state);
|
||||||
|
|
||||||
|
static bool nameExists(const nlohmann::json& state,
|
||||||
|
const std::string& name,
|
||||||
|
const std::string* exclude_id = nullptr);
|
||||||
|
|
||||||
|
static nlohmann::json buildCatalog(const nlohmann::json& state);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
310
src/domain/layout_schema.cpp
Normal file
310
src/domain/layout_schema.cpp
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
#include "domain/layout_schema.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
nlohmann::json LayoutSchema::defaultBicycleWheels()
|
||||||
|
{
|
||||||
|
return nlohmann::json::array(
|
||||||
|
{nlohmann::json{{"id", "rear"},
|
||||||
|
{"role", "drive"},
|
||||||
|
{"x_m", 0},
|
||||||
|
{"y_m", 0},
|
||||||
|
{"joint_name", "rear_wheel_joint"},
|
||||||
|
{"motor",
|
||||||
|
{{"vendor", "moons"},
|
||||||
|
{"model", "m2dc10a"},
|
||||||
|
{"gear_ratio", 20},
|
||||||
|
{"invert", false}}}},
|
||||||
|
nlohmann::json{{"id", "front"},
|
||||||
|
{"role", "steer"},
|
||||||
|
{"x_m", 1.2},
|
||||||
|
{"y_m", 0},
|
||||||
|
{"joint_name", "front_steer_joint"},
|
||||||
|
{"motor",
|
||||||
|
{{"vendor", "moons"},
|
||||||
|
{"model", "m2dc10a"},
|
||||||
|
{"gear_ratio", 20},
|
||||||
|
{"invert", false}}}}});
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json LayoutSchema::defaultDiffWheels()
|
||||||
|
{
|
||||||
|
return nlohmann::json::array(
|
||||||
|
{nlohmann::json{{"id", "left"},
|
||||||
|
{"side", "left"},
|
||||||
|
{"joint_name", "wheel_left_joint"},
|
||||||
|
{"y_m", 0.5},
|
||||||
|
{"motor",
|
||||||
|
{{"vendor", "moons"},
|
||||||
|
{"model", "m2dc10a"},
|
||||||
|
{"gear_ratio", 20},
|
||||||
|
{"invert", false}}}},
|
||||||
|
nlohmann::json{{"id", "right"},
|
||||||
|
{"side", "right"},
|
||||||
|
{"joint_name", "wheel_right_joint"},
|
||||||
|
{"y_m", -0.5},
|
||||||
|
{"motor",
|
||||||
|
{{"vendor", "moons"},
|
||||||
|
{"model", "m2dc10a"},
|
||||||
|
{"gear_ratio", 20},
|
||||||
|
{"invert", false}}}}});
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json LayoutSchema::defaultLayoutObject()
|
||||||
|
{
|
||||||
|
return nlohmann::json{
|
||||||
|
{"robot",
|
||||||
|
{{"x", 400},
|
||||||
|
{"y", 300},
|
||||||
|
{"yaw_deg", 0},
|
||||||
|
{"frame_id", "base_footprint"},
|
||||||
|
{"model", "diff"},
|
||||||
|
{"diff",
|
||||||
|
{{"wheel_separation_m", 1.0},
|
||||||
|
{"wheel_radius_m", 0.3},
|
||||||
|
{"wheel_separation_multiplier", 1.0},
|
||||||
|
{"wheel_radius_multiplier", 1.0},
|
||||||
|
{"display", {{"scale_m_per_px", 0.005}, {"b_px", 200}, {"d_px", 120}}},
|
||||||
|
{"limits",
|
||||||
|
{{"cmd_vel_timeout_s", 0.25},
|
||||||
|
{"linear",
|
||||||
|
{{"max_velocity", 1.0},
|
||||||
|
{"min_velocity", -0.5},
|
||||||
|
{"max_acceleration", 0.8},
|
||||||
|
{"min_acceleration", -0.4}}},
|
||||||
|
{"angular",
|
||||||
|
{{"max_velocity", 1.7}, {"max_acceleration", 1.5}}}},
|
||||||
|
{"wheels", defaultDiffWheels()}}}},
|
||||||
|
{"footprint",
|
||||||
|
nlohmann::json::array({nlohmann::json{{"x", 120}, {"y", 80}},
|
||||||
|
nlohmann::json{{"x", 120}, {"y", -80}},
|
||||||
|
nlohmann::json{{"x", -90}, {"y", -80}},
|
||||||
|
nlohmann::json{{"x", -90}, {"y", 80}}})}}},
|
||||||
|
{"map", {{"width", 800}, {"height", 600}}},
|
||||||
|
{"lidarPositions", nlohmann::json::object()},
|
||||||
|
{"lidarPoses", nlohmann::json::object()},
|
||||||
|
{"lidarPosesFrame", "robot"},
|
||||||
|
{"imuPoses", nlohmann::json::object()},
|
||||||
|
{"imuPosesFrame", "robot"}};
|
||||||
|
}
|
||||||
|
|
||||||
|
void LayoutSchema::ensure(nlohmann::json& layout)
|
||||||
|
{
|
||||||
|
if (!layout.is_object())
|
||||||
|
layout = nlohmann::json::object();
|
||||||
|
if (!layout.contains("robot") || !layout["robot"].is_object())
|
||||||
|
layout["robot"] = nlohmann::json::object();
|
||||||
|
if (!layout.contains("map") || !layout["map"].is_object())
|
||||||
|
layout["map"] = nlohmann::json::object();
|
||||||
|
if (!layout.contains("lidarPositions") || !layout["lidarPositions"].is_object())
|
||||||
|
layout["lidarPositions"] = nlohmann::json::object();
|
||||||
|
if (!layout.contains("lidarPoses") || !layout["lidarPoses"].is_object())
|
||||||
|
layout["lidarPoses"] = nlohmann::json::object();
|
||||||
|
if (!layout.contains("lidarPosesFrame"))
|
||||||
|
layout["lidarPosesFrame"] = "robot";
|
||||||
|
if (!layout.contains("imuPoses") || !layout["imuPoses"].is_object())
|
||||||
|
layout["imuPoses"] = nlohmann::json::object();
|
||||||
|
if (!layout.contains("imuPosesFrame"))
|
||||||
|
layout["imuPosesFrame"] = "robot";
|
||||||
|
|
||||||
|
auto& robot = layout["robot"];
|
||||||
|
if (!robot.contains("x"))
|
||||||
|
robot["x"] = 400;
|
||||||
|
if (!robot.contains("y"))
|
||||||
|
robot["y"] = 300;
|
||||||
|
if (!robot.contains("yaw_deg"))
|
||||||
|
robot["yaw_deg"] = 0;
|
||||||
|
if (!robot.contains("frame_id"))
|
||||||
|
robot["frame_id"] = "base_footprint";
|
||||||
|
if (!robot.contains("model"))
|
||||||
|
robot["model"] = "diff";
|
||||||
|
if (!robot.contains("diff") || !robot["diff"].is_object())
|
||||||
|
robot["diff"] = nlohmann::json::object();
|
||||||
|
auto& diff = robot["diff"];
|
||||||
|
|
||||||
|
const double default_scale = 0.005;
|
||||||
|
if (!diff.contains("display") || !diff["display"].is_object())
|
||||||
|
diff["display"] = nlohmann::json::object();
|
||||||
|
auto& display = diff["display"];
|
||||||
|
if (!display.contains("scale_m_per_px"))
|
||||||
|
display["scale_m_per_px"] = default_scale;
|
||||||
|
const double scale = display["scale_m_per_px"].get<double>();
|
||||||
|
|
||||||
|
if (!diff.contains("wheel_separation_m"))
|
||||||
|
{
|
||||||
|
if (diff.contains("b"))
|
||||||
|
diff["wheel_separation_m"] = diff["b"].get<double>() * scale;
|
||||||
|
else
|
||||||
|
diff["wheel_separation_m"] = 1.0;
|
||||||
|
}
|
||||||
|
if (!diff.contains("wheel_radius_m"))
|
||||||
|
{
|
||||||
|
if (diff.contains("d"))
|
||||||
|
diff["wheel_radius_m"] = diff["d"].get<double>() * scale * 0.5;
|
||||||
|
else
|
||||||
|
diff["wheel_radius_m"] = 0.3;
|
||||||
|
}
|
||||||
|
if (!diff.contains("wheel_separation_multiplier"))
|
||||||
|
diff["wheel_separation_multiplier"] = 1.0;
|
||||||
|
if (!diff.contains("wheel_radius_multiplier"))
|
||||||
|
diff["wheel_radius_multiplier"] = 1.0;
|
||||||
|
|
||||||
|
const double b_mult = diff["wheel_separation_multiplier"].get<double>();
|
||||||
|
const double r_mult = diff["wheel_radius_multiplier"].get<double>();
|
||||||
|
const double sep_m = diff["wheel_separation_m"].get<double>();
|
||||||
|
const double rad_m = diff["wheel_radius_m"].get<double>();
|
||||||
|
display["b_px"] = sep_m * b_mult / scale;
|
||||||
|
display["d_px"] = 2.0 * rad_m * r_mult / scale;
|
||||||
|
diff["b"] = display["b_px"];
|
||||||
|
diff["d"] = display["d_px"];
|
||||||
|
|
||||||
|
if (!diff.contains("limits") || !diff["limits"].is_object())
|
||||||
|
diff["limits"] = nlohmann::json::object();
|
||||||
|
auto& limits = diff["limits"];
|
||||||
|
if (!limits.contains("cmd_vel_timeout_s"))
|
||||||
|
limits["cmd_vel_timeout_s"] = 0.25;
|
||||||
|
if (!limits.contains("linear") || !limits["linear"].is_object())
|
||||||
|
limits["linear"] = nlohmann::json::object();
|
||||||
|
auto& linear = limits["linear"];
|
||||||
|
if (!linear.contains("max_velocity"))
|
||||||
|
linear["max_velocity"] = 1.0;
|
||||||
|
if (!linear.contains("min_velocity"))
|
||||||
|
linear["min_velocity"] = -0.5;
|
||||||
|
if (!linear.contains("max_acceleration"))
|
||||||
|
linear["max_acceleration"] = 0.8;
|
||||||
|
if (!linear.contains("min_acceleration"))
|
||||||
|
linear["min_acceleration"] = -0.4;
|
||||||
|
if (!limits.contains("angular") || !limits["angular"].is_object())
|
||||||
|
limits["angular"] = nlohmann::json::object();
|
||||||
|
auto& angular = limits["angular"];
|
||||||
|
if (!angular.contains("max_velocity"))
|
||||||
|
angular["max_velocity"] = 1.7;
|
||||||
|
if (!angular.contains("max_acceleration"))
|
||||||
|
angular["max_acceleration"] = 1.5;
|
||||||
|
|
||||||
|
const double half_sep = sep_m / 2.0;
|
||||||
|
if (!diff.contains("wheels") || !diff["wheels"].is_array() || diff["wheels"].empty())
|
||||||
|
{
|
||||||
|
diff["wheels"] = nlohmann::json::array(
|
||||||
|
{nlohmann::json{{"id", "left"},
|
||||||
|
{"side", "left"},
|
||||||
|
{"joint_name", "wheel_left_joint"},
|
||||||
|
{"y_m", half_sep},
|
||||||
|
{"motor",
|
||||||
|
nlohmann::json{{"vendor", "moons"},
|
||||||
|
{"model", "m2dc10a"},
|
||||||
|
{"gear_ratio", 20},
|
||||||
|
{"invert", false}}}},
|
||||||
|
nlohmann::json{{"id", "right"},
|
||||||
|
{"side", "right"},
|
||||||
|
{"joint_name", "wheel_right_joint"},
|
||||||
|
{"y_m", -half_sep},
|
||||||
|
{"motor",
|
||||||
|
nlohmann::json{{"vendor", "moons"},
|
||||||
|
{"model", "m2dc10a"},
|
||||||
|
{"gear_ratio", 20},
|
||||||
|
{"invert", false}}}}});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (auto& w : diff["wheels"])
|
||||||
|
{
|
||||||
|
if (!w.is_object())
|
||||||
|
continue;
|
||||||
|
if (!w.contains("id"))
|
||||||
|
w["id"] = "left";
|
||||||
|
if (!w.contains("side"))
|
||||||
|
w["side"] = (w["id"].get<std::string>() == "right") ? "right" : "left";
|
||||||
|
if (!w.contains("joint_name"))
|
||||||
|
{
|
||||||
|
w["joint_name"] = (w["side"].get<std::string>() == "right") ? "wheel_right_joint"
|
||||||
|
: "wheel_left_joint";
|
||||||
|
}
|
||||||
|
if (!w.contains("y_m"))
|
||||||
|
{
|
||||||
|
w["y_m"] = (w["side"].get<std::string>() == "right") ? -half_sep : half_sep;
|
||||||
|
}
|
||||||
|
if (!w.contains("motor") || !w["motor"].is_object())
|
||||||
|
w["motor"] = nlohmann::json::object();
|
||||||
|
auto& motor = w["motor"];
|
||||||
|
if (!motor.contains("vendor"))
|
||||||
|
motor["vendor"] = "custom";
|
||||||
|
if (!motor.contains("model"))
|
||||||
|
motor["model"] = "custom";
|
||||||
|
if (!motor.contains("gear_ratio"))
|
||||||
|
motor["gear_ratio"] = 20;
|
||||||
|
if (!motor.contains("invert"))
|
||||||
|
motor["invert"] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!robot.contains("bicycle") || !robot["bicycle"].is_object())
|
||||||
|
robot["bicycle"] = nlohmann::json::object();
|
||||||
|
auto& bicycle = robot["bicycle"];
|
||||||
|
if (!bicycle.contains("display") || !bicycle["display"].is_object())
|
||||||
|
bicycle["display"] = nlohmann::json::object();
|
||||||
|
auto& bdisplay = bicycle["display"];
|
||||||
|
if (!bdisplay.contains("scale_m_per_px"))
|
||||||
|
bdisplay["scale_m_per_px"] = default_scale;
|
||||||
|
const double bscale = bdisplay["scale_m_per_px"].get<double>();
|
||||||
|
if (!bicycle.contains("wheelbase_m"))
|
||||||
|
bicycle["wheelbase_m"] = 1.2;
|
||||||
|
if (!bicycle.contains("wheel_radius_m"))
|
||||||
|
bicycle["wheel_radius_m"] = 0.15;
|
||||||
|
const double L_m = bicycle["wheelbase_m"].get<double>();
|
||||||
|
const double b_rad = bicycle["wheel_radius_m"].get<double>();
|
||||||
|
bdisplay["L_px"] = L_m / bscale;
|
||||||
|
bdisplay["r_px"] = 2.0 * b_rad / bscale;
|
||||||
|
if (!bicycle.contains("steer") || !bicycle["steer"].is_object())
|
||||||
|
bicycle["steer"] = nlohmann::json::object();
|
||||||
|
auto& steer = bicycle["steer"];
|
||||||
|
if (!steer.contains("max_angle_deg"))
|
||||||
|
steer["max_angle_deg"] = 35;
|
||||||
|
if (!steer.contains("preview_deg"))
|
||||||
|
steer["preview_deg"] = 15;
|
||||||
|
if (!steer.contains("joint_name"))
|
||||||
|
steer["joint_name"] = "front_steer_joint";
|
||||||
|
if (!bicycle.contains("drive") || !bicycle["drive"].is_object())
|
||||||
|
bicycle["drive"] = nlohmann::json::object();
|
||||||
|
if (!bicycle["drive"].contains("joint_name"))
|
||||||
|
bicycle["drive"]["joint_name"] = "rear_wheel_joint";
|
||||||
|
if (!bicycle.contains("limits") || !bicycle["limits"].is_object())
|
||||||
|
bicycle["limits"] = nlohmann::json::object();
|
||||||
|
auto& blimits = bicycle["limits"];
|
||||||
|
if (!blimits.contains("cmd_vel_timeout_s"))
|
||||||
|
blimits["cmd_vel_timeout_s"] = 0.25;
|
||||||
|
if (!blimits.contains("linear") || !blimits["linear"].is_object())
|
||||||
|
blimits["linear"] = nlohmann::json::object();
|
||||||
|
auto& blinear = blimits["linear"];
|
||||||
|
if (!blinear.contains("max_velocity"))
|
||||||
|
blinear["max_velocity"] = 1.0;
|
||||||
|
if (!blinear.contains("max_acceleration"))
|
||||||
|
blinear["max_acceleration"] = 0.8;
|
||||||
|
if (!bicycle.contains("wheels") || !bicycle["wheels"].is_array() || bicycle["wheels"].empty())
|
||||||
|
bicycle["wheels"] = defaultBicycleWheels();
|
||||||
|
|
||||||
|
if (!robot.contains("footprint") || !robot["footprint"].is_array())
|
||||||
|
{
|
||||||
|
robot["footprint"] =
|
||||||
|
nlohmann::json::array({nlohmann::json{{"x", 120}, {"y", 80}},
|
||||||
|
nlohmann::json{{"x", 120}, {"y", -80}},
|
||||||
|
nlohmann::json{{"x", -90}, {"y", -80}},
|
||||||
|
nlohmann::json{{"x", -90}, {"y", 80}}});
|
||||||
|
}
|
||||||
|
if (!robot.contains("footprint_shape"))
|
||||||
|
robot["footprint_shape"] = "custom";
|
||||||
|
if (!robot.contains("footprint_params") || !robot["footprint_params"].is_object())
|
||||||
|
{
|
||||||
|
robot["footprint_params"] = nlohmann::json{{"length_m", 1.4},
|
||||||
|
{"width_m", 1.1},
|
||||||
|
{"radius_m", 0.55},
|
||||||
|
{"sides", 6},
|
||||||
|
{"segments", 32}};
|
||||||
|
}
|
||||||
|
auto& map = layout["map"];
|
||||||
|
if (!map.contains("width"))
|
||||||
|
map["width"] = 800;
|
||||||
|
if (!map.contains("height"))
|
||||||
|
map["height"] = 600;
|
||||||
|
}
|
||||||
|
} // namespace lm
|
||||||
18
src/domain/layout_schema.hpp
Normal file
18
src/domain/layout_schema.hpp
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class LayoutSchema
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static nlohmann::json defaultLayoutObject();
|
||||||
|
static void ensure(nlohmann::json& layout);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static nlohmann::json defaultBicycleWheels();
|
||||||
|
static nlohmann::json defaultDiffWheels();
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
1470
src/main.cpp
1470
src/main.cpp
File diff suppressed because it is too large
Load Diff
441
src/server/api_server.cpp
Normal file
441
src/server/api_server.cpp
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
#include "server/api_server.hpp"
|
||||||
|
|
||||||
|
#include "domain/layout_profile.hpp"
|
||||||
|
#include "domain/layout_schema.hpp"
|
||||||
|
#include "util/http_util.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
#include "validation/sensor_validator.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
ApiServer::ApiServer(StateRepository& repo) : repo_(repo) {}
|
||||||
|
|
||||||
|
void ApiServer::registerRoutes(httplib::Server& svr)
|
||||||
|
{
|
||||||
|
svr.Options(R"(/api/(.*))", [](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
res.status = 204;
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Get("/api/health", [](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = nlohmann::json({{"ok", true}}).dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Get("/api/state", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
repo_.ensureSchema();
|
||||||
|
std::string active_name;
|
||||||
|
const auto idx = LayoutProfile::findActiveIndex(repo_.app().state);
|
||||||
|
if (idx)
|
||||||
|
active_name = repo_.app().state["layouts"][*idx]["name"].get<std::string>();
|
||||||
|
const nlohmann::json response = {{"version", repo_.app().state.value("version", 3)},
|
||||||
|
{"active_layout_id", repo_.app().state["active_layout_id"]},
|
||||||
|
{"active_layout_name", active_name},
|
||||||
|
{"layouts", LayoutProfile::buildCatalog(repo_.app().state)},
|
||||||
|
{"layout", repo_.app().state["layout"]},
|
||||||
|
{"lidars", repo_.app().state["lidars"]},
|
||||||
|
{"imus", repo_.app().state.contains("imus") ? repo_.app().state["imus"] : nlohmann::json::array()}};
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = response.dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Get("/api/layouts", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
repo_.ensureSchema();
|
||||||
|
const nlohmann::json response = {{"active_layout_id", repo_.app().state["active_layout_id"]},
|
||||||
|
{"layouts", LayoutProfile::buildCatalog(repo_.app().state)}};
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = response.dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/layouts", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
repo_.ensureSchema();
|
||||||
|
nlohmann::json payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
if (!payload.is_object() || !payload.contains("name") || !payload["name"].is_string())
|
||||||
|
return HttpUtil::jsonError(res, 400, "name is required");
|
||||||
|
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
|
||||||
|
if (name.empty())
|
||||||
|
return HttpUtil::jsonError(res, 400, "name is required");
|
||||||
|
if (LayoutProfile::nameExists(repo_.app().state, name))
|
||||||
|
return HttpUtil::jsonError(res, 409, "layout name already exists");
|
||||||
|
|
||||||
|
const bool clone = payload.contains("clone") && payload["clone"].is_boolean() && payload["clone"].get<bool>();
|
||||||
|
nlohmann::json layout = LayoutSchema::defaultLayoutObject();
|
||||||
|
nlohmann::json lidars = nlohmann::json::array();
|
||||||
|
nlohmann::json imus = nlohmann::json::array();
|
||||||
|
if (clone)
|
||||||
|
{
|
||||||
|
layout = repo_.app().state["layout"];
|
||||||
|
lidars = repo_.app().state["lidars"];
|
||||||
|
imus = repo_.app().state.contains("imus") && repo_.app().state["imus"].is_array() ? repo_.app().state["imus"] : nlohmann::json::array();
|
||||||
|
}
|
||||||
|
nlohmann::json profile = LayoutProfile::make(name, layout, lidars, imus);
|
||||||
|
LayoutSchema::ensure(profile["layout"]);
|
||||||
|
if (!repo_.saveProfile(profile))
|
||||||
|
return HttpUtil::jsonError(res, 500, "failed to save layout file");
|
||||||
|
repo_.app().state["layouts"].push_back(LayoutProfile::catalogEntryFromProfile(profile));
|
||||||
|
repo_.app().state["active_layout_id"] = profile["id"].get<std::string>();
|
||||||
|
repo_.reloadActiveCache();
|
||||||
|
repo_.save();
|
||||||
|
|
||||||
|
res.status = 201;
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = profile.dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post(R"(/api/layouts/([0-9a-fA-F]+)/activate)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1].str();
|
||||||
|
repo_.ensureSchema();
|
||||||
|
if (!LayoutProfile::findIndex(repo_.app().state, id))
|
||||||
|
return HttpUtil::jsonError(res, 404, "layout not found");
|
||||||
|
repo_.app().state["active_layout_id"] = id;
|
||||||
|
repo_.reloadActiveCache();
|
||||||
|
repo_.save();
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = nlohmann::json({{"ok", true}, {"active_layout_id", id}}).dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Delete(R"(/api/layouts/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1].str();
|
||||||
|
repo_.ensureSchema();
|
||||||
|
if (!repo_.app().state.contains("layouts") || !repo_.app().state["layouts"].is_array())
|
||||||
|
return HttpUtil::jsonError(res, 404, "layout not found");
|
||||||
|
if (repo_.app().state["layouts"].size() <= 1)
|
||||||
|
return HttpUtil::jsonError(res, 400, "cannot delete the last layout");
|
||||||
|
const auto idx = LayoutProfile::findIndex(repo_.app().state, id);
|
||||||
|
if (!idx)
|
||||||
|
return HttpUtil::jsonError(res, 404, "layout not found");
|
||||||
|
|
||||||
|
const bool was_active =
|
||||||
|
repo_.app().state.contains("active_layout_id") && repo_.app().state["active_layout_id"].get<std::string>() == id;
|
||||||
|
repo_.deleteProfile(id);
|
||||||
|
repo_.app().state["layouts"].erase(repo_.app().state["layouts"].begin() + static_cast<nlohmann::json::difference_type>(*idx));
|
||||||
|
if (was_active)
|
||||||
|
repo_.app().state["active_layout_id"] = repo_.app().state["layouts"][0]["id"].get<std::string>();
|
||||||
|
repo_.reloadActiveCache();
|
||||||
|
repo_.save();
|
||||||
|
res.status = 204;
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Get("/api/lidars", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = repo_.app().state["lidars"].dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/lidars", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
nlohmann::json payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
if (!SensorValidator::validateLidarPayload(payload, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
|
||||||
|
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
|
||||||
|
const std::string ip = StringUtil::trimCopy(payload["ip"].get<std::string>());
|
||||||
|
const int port = payload["port"].get<int>();
|
||||||
|
if (SensorValidator::lidarTripletExists(repo_.app().state, name, ip, port))
|
||||||
|
return HttpUtil::jsonError(res, 409, "lidar with same name, ip and port already exists");
|
||||||
|
|
||||||
|
nlohmann::json lidar = {
|
||||||
|
{"id", IdUtil::newId()},
|
||||||
|
{"name", name},
|
||||||
|
{"ip", ip},
|
||||||
|
{"port", port},
|
||||||
|
};
|
||||||
|
repo_.app().state["lidars"].push_back(lidar);
|
||||||
|
if (!repo_.saveAppState())
|
||||||
|
return HttpUtil::jsonError(res, 500, "failed to save layout");
|
||||||
|
|
||||||
|
res.status = 201;
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = lidar.dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put(R"(/api/lidars/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1].str();
|
||||||
|
|
||||||
|
nlohmann::json payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
if (!SensorValidator::validateLidarPayload(payload, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
|
||||||
|
auto idx = SensorValidator::findLidarIndex(repo_.app().state, id);
|
||||||
|
if (!idx)
|
||||||
|
return HttpUtil::jsonError(res, 404, "lidar not found");
|
||||||
|
|
||||||
|
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
|
||||||
|
const std::string ip = StringUtil::trimCopy(payload["ip"].get<std::string>());
|
||||||
|
const int port = payload["port"].get<int>();
|
||||||
|
if (SensorValidator::lidarTripletExists(repo_.app().state, name, ip, port, &id))
|
||||||
|
return HttpUtil::jsonError(res, 409, "lidar with same name, ip and port already exists");
|
||||||
|
|
||||||
|
auto& lidar = repo_.app().state["lidars"][*idx];
|
||||||
|
lidar["name"] = name;
|
||||||
|
lidar["ip"] = ip;
|
||||||
|
lidar["port"] = port;
|
||||||
|
if (!repo_.saveAppState())
|
||||||
|
return HttpUtil::jsonError(res, 500, "failed to save layout");
|
||||||
|
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = lidar.dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Delete(R"(/api/lidars/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1].str();
|
||||||
|
auto idx = SensorValidator::findLidarIndex(repo_.app().state, id);
|
||||||
|
if (!idx)
|
||||||
|
return HttpUtil::jsonError(res, 404, "lidar not found");
|
||||||
|
|
||||||
|
repo_.app().state["lidars"].erase(repo_.app().state["lidars"].begin() + static_cast<nlohmann::json::difference_type>(*idx));
|
||||||
|
// Also remove pose entry if present
|
||||||
|
if (repo_.app().state.contains("layout") && repo_.app().state["layout"].is_object())
|
||||||
|
{
|
||||||
|
if (repo_.app().state["layout"].contains("lidarPoses") && repo_.app().state["layout"]["lidarPoses"].is_object())
|
||||||
|
repo_.app().state["layout"]["lidarPoses"].erase(id);
|
||||||
|
if (repo_.app().state["layout"].contains("lidarPositions") && repo_.app().state["layout"]["lidarPositions"].is_object())
|
||||||
|
repo_.app().state["layout"]["lidarPositions"].erase(id);
|
||||||
|
}
|
||||||
|
if (!repo_.saveAppState())
|
||||||
|
return HttpUtil::jsonError(res, 500, "failed to save layout");
|
||||||
|
res.status = 204;
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Get("/api/layout", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = repo_.app().state["layout"].dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put("/api/layout", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
nlohmann::json payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
if (!payload.is_object())
|
||||||
|
return HttpUtil::jsonError(res, 400, "layout must be an object");
|
||||||
|
|
||||||
|
repo_.app().state["layout"] = payload;
|
||||||
|
if (!repo_.saveAppState())
|
||||||
|
return HttpUtil::jsonError(res, 500, "failed to save layout");
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = repo_.app().state["layout"].dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put(R"(/api/layouts/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1].str();
|
||||||
|
nlohmann::json payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
if (!payload.is_object())
|
||||||
|
return HttpUtil::jsonError(res, 400, "payload must be an object");
|
||||||
|
|
||||||
|
repo_.ensureSchema();
|
||||||
|
const auto idx = LayoutProfile::findIndex(repo_.app().state, id);
|
||||||
|
if (!idx)
|
||||||
|
return HttpUtil::jsonError(res, 404, "layout not found");
|
||||||
|
|
||||||
|
auto loaded = repo_.loadProfileById(id);
|
||||||
|
if (!loaded)
|
||||||
|
return HttpUtil::jsonError(res, 404, "layout file not found");
|
||||||
|
nlohmann::json profile = *loaded;
|
||||||
|
|
||||||
|
if (payload.contains("name") && payload["name"].is_string())
|
||||||
|
{
|
||||||
|
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
|
||||||
|
if (name.empty())
|
||||||
|
return HttpUtil::jsonError(res, 400, "name is required");
|
||||||
|
if (LayoutProfile::nameExists(repo_.app().state, name, &id))
|
||||||
|
return HttpUtil::jsonError(res, 409, "layout name already exists");
|
||||||
|
profile["name"] = name;
|
||||||
|
}
|
||||||
|
if (payload.contains("layout") && payload["layout"].is_object())
|
||||||
|
profile["layout"] = payload["layout"];
|
||||||
|
if (payload.contains("lidars") && payload["lidars"].is_array())
|
||||||
|
profile["lidars"] = payload["lidars"];
|
||||||
|
if (payload.contains("imus") && payload["imus"].is_array())
|
||||||
|
profile["imus"] = payload["imus"];
|
||||||
|
if (!profile.contains("imus") || !profile["imus"].is_array())
|
||||||
|
profile["imus"] = nlohmann::json::array();
|
||||||
|
LayoutSchema::ensure(profile["layout"]);
|
||||||
|
LayoutProfile::touch(profile);
|
||||||
|
if (!repo_.saveProfile(profile))
|
||||||
|
return HttpUtil::jsonError(res, 500, "failed to save layout file");
|
||||||
|
|
||||||
|
repo_.app().state["layouts"][*idx] = LayoutProfile::catalogEntryFromProfile(profile);
|
||||||
|
const bool is_active =
|
||||||
|
repo_.app().state.contains("active_layout_id") && repo_.app().state["active_layout_id"].get<std::string>() == id;
|
||||||
|
if (is_active)
|
||||||
|
{
|
||||||
|
repo_.app().state["layout"] = profile["layout"];
|
||||||
|
repo_.app().state["lidars"] = profile["lidars"];
|
||||||
|
repo_.app().state["imus"] = profile["imus"];
|
||||||
|
}
|
||||||
|
repo_.save();
|
||||||
|
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = profile.dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Get("/api/imus", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
repo_.ensureSchema();
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = (repo_.app().state.contains("imus") ? repo_.app().state["imus"] : nlohmann::json::array()).dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/imus", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
nlohmann::json payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
if (!SensorValidator::validateImuPayload(payload, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
|
||||||
|
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
|
||||||
|
const std::string frame_id = StringUtil::trimCopy(payload["frame_id"].get<std::string>());
|
||||||
|
const std::string topic = StringUtil::trimCopy(payload["topic"].get<std::string>());
|
||||||
|
if (SensorValidator::imuFrameExists(repo_.app().state, frame_id))
|
||||||
|
return HttpUtil::jsonError(res, 409, "imu with same frame_id already exists");
|
||||||
|
|
||||||
|
if (!repo_.app().state.contains("imus") || !repo_.app().state["imus"].is_array())
|
||||||
|
repo_.app().state["imus"] = nlohmann::json::array();
|
||||||
|
|
||||||
|
const std::string source =
|
||||||
|
payload.contains("source") && payload["source"].is_string() ? payload["source"].get<std::string>()
|
||||||
|
: "external";
|
||||||
|
const bool enabled = !payload.contains("enabled") || payload["enabled"].get<bool>();
|
||||||
|
const double rate_hz =
|
||||||
|
payload.contains("rate_hz") && payload["rate_hz"].is_number() ? payload["rate_hz"].get<double>() : 100.0;
|
||||||
|
|
||||||
|
nlohmann::json imu = {{"id", IdUtil::newId()},
|
||||||
|
{"name", name},
|
||||||
|
{"frame_id", frame_id},
|
||||||
|
{"topic", topic},
|
||||||
|
{"source", source},
|
||||||
|
{"enabled", enabled},
|
||||||
|
{"rate_hz", rate_hz}};
|
||||||
|
repo_.app().state["imus"].push_back(imu);
|
||||||
|
if (!repo_.saveAppState())
|
||||||
|
return HttpUtil::jsonError(res, 500, "failed to save layout");
|
||||||
|
|
||||||
|
res.status = 201;
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = imu.dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put(R"(/api/imus/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1].str();
|
||||||
|
|
||||||
|
nlohmann::json payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
if (!SensorValidator::validateImuPayload(payload, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
|
||||||
|
auto idx = SensorValidator::findImuIndex(repo_.app().state, id);
|
||||||
|
if (!idx)
|
||||||
|
return HttpUtil::jsonError(res, 404, "imu not found");
|
||||||
|
|
||||||
|
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
|
||||||
|
const std::string frame_id = StringUtil::trimCopy(payload["frame_id"].get<std::string>());
|
||||||
|
const std::string topic = StringUtil::trimCopy(payload["topic"].get<std::string>());
|
||||||
|
if (SensorValidator::imuFrameExists(repo_.app().state, frame_id, &id))
|
||||||
|
return HttpUtil::jsonError(res, 409, "imu with same frame_id already exists");
|
||||||
|
|
||||||
|
auto& imu = repo_.app().state["imus"][*idx];
|
||||||
|
imu["name"] = name;
|
||||||
|
imu["frame_id"] = frame_id;
|
||||||
|
imu["topic"] = topic;
|
||||||
|
if (payload.contains("source") && payload["source"].is_string())
|
||||||
|
imu["source"] = payload["source"];
|
||||||
|
if (payload.contains("enabled") && payload["enabled"].is_boolean())
|
||||||
|
imu["enabled"] = payload["enabled"];
|
||||||
|
if (payload.contains("rate_hz") && payload["rate_hz"].is_number())
|
||||||
|
imu["rate_hz"] = payload["rate_hz"];
|
||||||
|
if (!repo_.saveAppState())
|
||||||
|
return HttpUtil::jsonError(res, 500, "failed to save layout");
|
||||||
|
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = imu.dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Delete(R"(/api/imus/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1].str();
|
||||||
|
auto idx = SensorValidator::findImuIndex(repo_.app().state, id);
|
||||||
|
if (!idx)
|
||||||
|
return HttpUtil::jsonError(res, 404, "imu not found");
|
||||||
|
|
||||||
|
repo_.app().state["imus"].erase(repo_.app().state["imus"].begin() + static_cast<nlohmann::json::difference_type>(*idx));
|
||||||
|
if (repo_.app().state.contains("layout") && repo_.app().state["layout"].is_object())
|
||||||
|
{
|
||||||
|
if (repo_.app().state["layout"].contains("imuPoses") && repo_.app().state["layout"]["imuPoses"].is_object())
|
||||||
|
repo_.app().state["layout"]["imuPoses"].erase(id);
|
||||||
|
}
|
||||||
|
if (!repo_.saveAppState())
|
||||||
|
return HttpUtil::jsonError(res, 500, "failed to save layout");
|
||||||
|
res.status = 204;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
20
src/server/api_server.hpp
Normal file
20
src/server/api_server.hpp
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <httplib.h>
|
||||||
|
|
||||||
|
#include "storage/state_repository.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class ApiServer
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit ApiServer(StateRepository& repo);
|
||||||
|
|
||||||
|
void registerRoutes(httplib::Server& svr);
|
||||||
|
|
||||||
|
private:
|
||||||
|
StateRepository& repo_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
49
src/server/static_file_server.cpp
Normal file
49
src/server/static_file_server.cpp
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#include "server/static_file_server.hpp"
|
||||||
|
|
||||||
|
#include "util/file_util.hpp"
|
||||||
|
#include "util/http_util.hpp"
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
void StaticFileServer::mount(httplib::Server& svr, const fs::path& www_root)
|
||||||
|
{
|
||||||
|
svr.Get(R"(/(.*))", [www_root](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
std::string rel = req.matches.size() >= 2 ? req.matches[1].str() : "";
|
||||||
|
if (rel.empty())
|
||||||
|
rel = "index.html";
|
||||||
|
|
||||||
|
if (rel.find("..") != std::string::npos)
|
||||||
|
{
|
||||||
|
res.status = 400;
|
||||||
|
res.set_content("Bad path", "text/plain; charset=utf-8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::path file_path = www_root / rel;
|
||||||
|
if (fs::is_directory(file_path))
|
||||||
|
file_path /= "index.html";
|
||||||
|
|
||||||
|
std::error_code ec;
|
||||||
|
if (!fs::exists(file_path, ec) || ec)
|
||||||
|
{
|
||||||
|
res.status = 404;
|
||||||
|
res.set_content("Not Found", "text/plain; charset=utf-8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto body = FileUtil::readBinary(file_path);
|
||||||
|
if (body.empty())
|
||||||
|
{
|
||||||
|
res.status = 500;
|
||||||
|
res.set_content("Failed to read file", "text/plain; charset=utf-8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set_header("Cache-Control", "no-store");
|
||||||
|
res.set_content(body, HttpUtil::contentTypeForPath(file_path));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
15
src/server/static_file_server.hpp
Normal file
15
src/server/static_file_server.hpp
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <httplib.h>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class StaticFileServer
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static void mount(httplib::Server& svr, const std::filesystem::path& www_root);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
322
src/storage/state_repository.cpp
Normal file
322
src/storage/state_repository.cpp
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
#include "storage/state_repository.hpp"
|
||||||
|
|
||||||
|
#include "domain/layout_profile.hpp"
|
||||||
|
#include "domain/layout_schema.hpp"
|
||||||
|
#include "util/file_util.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
std::filesystem::path StateRepository::modelsDir() const
|
||||||
|
{
|
||||||
|
return app_.data_path.parent_path() / "models";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path StateRepository::profileFilePath(const std::string& id) const
|
||||||
|
{
|
||||||
|
return modelsDir() / (id + ".json");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> StateRepository::loadProfileFromDisk(const std::string& id) const
|
||||||
|
{
|
||||||
|
const auto raw = FileUtil::readBinary(profileFilePath(id));
|
||||||
|
if (raw.empty())
|
||||||
|
return std::nullopt;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return nlohmann::json::parse(raw);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StateRepository::saveProfileToDisk(const nlohmann::json& profile) const
|
||||||
|
{
|
||||||
|
if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string())
|
||||||
|
return false;
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::create_directories(modelsDir(), ec);
|
||||||
|
auto body = profile.dump(2);
|
||||||
|
body.push_back('\n');
|
||||||
|
return FileUtil::writeBinaryAtomic(profileFilePath(profile["id"].get<std::string>()), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StateRepository::deleteProfileFile(const std::string& id) const
|
||||||
|
{
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::remove(profileFilePath(id), ec);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateRepository::loadActiveCache()
|
||||||
|
{
|
||||||
|
nlohmann::json& state = app_.state;
|
||||||
|
const auto idx = LayoutProfile::findActiveIndex(state);
|
||||||
|
if (!idx)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const std::string id = state["layouts"][*idx]["id"].get<std::string>();
|
||||||
|
nlohmann::json profile;
|
||||||
|
if (auto loaded = loadProfileFromDisk(id))
|
||||||
|
profile = *loaded;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
profile = LayoutProfile::make(state["layouts"][*idx]["name"].get<std::string>(),
|
||||||
|
LayoutSchema::defaultLayoutObject(),
|
||||||
|
nlohmann::json::array());
|
||||||
|
profile["id"] = id;
|
||||||
|
if (state["layouts"][*idx].contains("created_at"))
|
||||||
|
profile["created_at"] = state["layouts"][*idx]["created_at"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile.contains("layout") || !profile["layout"].is_object())
|
||||||
|
profile["layout"] = LayoutSchema::defaultLayoutObject();
|
||||||
|
if (!profile.contains("lidars") || !profile["lidars"].is_array())
|
||||||
|
profile["lidars"] = nlohmann::json::array();
|
||||||
|
if (!profile.contains("imus") || !profile["imus"].is_array())
|
||||||
|
profile["imus"] = nlohmann::json::array();
|
||||||
|
|
||||||
|
LayoutSchema::ensure(profile["layout"]);
|
||||||
|
state["layout"] = profile["layout"];
|
||||||
|
state["lidars"] = profile["lidars"];
|
||||||
|
state["imus"] = profile["imus"];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StateRepository::persistActiveProfile()
|
||||||
|
{
|
||||||
|
nlohmann::json& state = app_.state;
|
||||||
|
const auto idx = LayoutProfile::findActiveIndex(state);
|
||||||
|
if (!idx)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
auto& entry = state["layouts"][*idx];
|
||||||
|
nlohmann::json profile;
|
||||||
|
profile["id"] = entry["id"];
|
||||||
|
profile["name"] = entry.contains("name") ? entry["name"] : nlohmann::json("Layout");
|
||||||
|
profile["created_at"] = entry.value("created_at", IdUtil::nowIso8601());
|
||||||
|
profile["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
profile["layout"] = state["layout"];
|
||||||
|
profile["lidars"] = state["lidars"];
|
||||||
|
profile["imus"] = state.contains("imus") && state["imus"].is_array() ? state["imus"] : nlohmann::json::array();
|
||||||
|
LayoutSchema::ensure(profile["layout"]);
|
||||||
|
if (!saveProfileToDisk(profile))
|
||||||
|
return false;
|
||||||
|
entry = LayoutProfile::catalogEntryFromProfile(profile);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json StateRepository::globalStateForDisk(const nlohmann::json& state) const
|
||||||
|
{
|
||||||
|
nlohmann::json out = nlohmann::json::object();
|
||||||
|
out["version"] = 3;
|
||||||
|
if (state.contains("active_layout_id"))
|
||||||
|
out["active_layout_id"] = state["active_layout_id"];
|
||||||
|
out["layouts"] = nlohmann::json::array();
|
||||||
|
if (state.contains("layouts") && state["layouts"].is_array())
|
||||||
|
{
|
||||||
|
for (const auto& entry : state["layouts"])
|
||||||
|
{
|
||||||
|
if (!entry.is_object() || !entry.contains("id") || !entry.contains("name"))
|
||||||
|
continue;
|
||||||
|
if (entry.contains("layout"))
|
||||||
|
out["layouts"].push_back(LayoutProfile::catalogEntryFromProfile(entry));
|
||||||
|
else
|
||||||
|
out["layouts"].push_back(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateRepository::stripInlineProfiles(nlohmann::json& state) const
|
||||||
|
{
|
||||||
|
if (!state.contains("layouts") || !state["layouts"].is_array())
|
||||||
|
return;
|
||||||
|
nlohmann::json catalog = nlohmann::json::array();
|
||||||
|
for (const auto& entry : state["layouts"])
|
||||||
|
{
|
||||||
|
if (!entry.is_object() || !entry.contains("id"))
|
||||||
|
continue;
|
||||||
|
if (entry.contains("layout"))
|
||||||
|
catalog.push_back(LayoutProfile::catalogEntryFromProfile(entry));
|
||||||
|
else
|
||||||
|
catalog.push_back(entry);
|
||||||
|
}
|
||||||
|
state["layouts"] = catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateRepository::migrateStorage()
|
||||||
|
{
|
||||||
|
nlohmann::json& s = app_.state;
|
||||||
|
if (!s.is_object())
|
||||||
|
s = nlohmann::json::object();
|
||||||
|
|
||||||
|
const int version = s.contains("version") && s["version"].is_number_integer() ? s["version"].get<int>() : 1;
|
||||||
|
|
||||||
|
if (!s.contains("layouts") || !s["layouts"].is_array() || s["layouts"].empty())
|
||||||
|
{
|
||||||
|
nlohmann::json layout = s.contains("layout") && s["layout"].is_object() ? s["layout"] : LayoutSchema::defaultLayoutObject();
|
||||||
|
nlohmann::json lidars = s.contains("lidars") && s["lidars"].is_array() ? s["lidars"] : nlohmann::json::array();
|
||||||
|
nlohmann::json profile = LayoutProfile::make("Mặc định", layout, lidars);
|
||||||
|
LayoutSchema::ensure(profile["layout"]);
|
||||||
|
saveProfileToDisk(profile);
|
||||||
|
s["layouts"] = nlohmann::json::array({LayoutProfile::catalogEntryFromProfile(profile)});
|
||||||
|
s["active_layout_id"] = profile["id"].get<std::string>();
|
||||||
|
}
|
||||||
|
else if (version < 3)
|
||||||
|
{
|
||||||
|
if (!s.contains("active_layout_id") || !s["active_layout_id"].is_string() ||
|
||||||
|
!LayoutProfile::findIndex(s, s["active_layout_id"].get<std::string>()))
|
||||||
|
{
|
||||||
|
s["active_layout_id"] = s["layouts"][0]["id"].get<std::string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json catalog = nlohmann::json::array();
|
||||||
|
for (auto& entry : s["layouts"])
|
||||||
|
{
|
||||||
|
if (!entry.is_object() || !entry.contains("id"))
|
||||||
|
continue;
|
||||||
|
nlohmann::json profile;
|
||||||
|
if (entry.contains("layout"))
|
||||||
|
{
|
||||||
|
profile = entry;
|
||||||
|
if (!profile.contains("name"))
|
||||||
|
profile["name"] = "Layout";
|
||||||
|
if (!profile.contains("lidars") || !profile["lidars"].is_array())
|
||||||
|
profile["lidars"] = nlohmann::json::array();
|
||||||
|
if (!profile.contains("created_at"))
|
||||||
|
profile["created_at"] = IdUtil::nowIso8601();
|
||||||
|
LayoutProfile::touch(profile);
|
||||||
|
LayoutSchema::ensure(profile["layout"]);
|
||||||
|
saveProfileToDisk(profile);
|
||||||
|
catalog.push_back(LayoutProfile::catalogEntryFromProfile(profile));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const std::string id = entry["id"].get<std::string>();
|
||||||
|
if (auto loaded = loadProfileFromDisk(id))
|
||||||
|
{
|
||||||
|
catalog.push_back(LayoutProfile::catalogEntryFromProfile(*loaded));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
profile = LayoutProfile::make(entry["name"].get<std::string>(), LayoutSchema::defaultLayoutObject(), nlohmann::json::array());
|
||||||
|
profile["id"] = id;
|
||||||
|
profile["created_at"] = entry.value("created_at", IdUtil::nowIso8601());
|
||||||
|
LayoutProfile::touch(profile);
|
||||||
|
saveProfileToDisk(profile);
|
||||||
|
catalog.push_back(LayoutProfile::catalogEntryFromProfile(profile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s["layouts"] = catalog;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stripInlineProfiles(s);
|
||||||
|
if (!s.contains("active_layout_id") || !s["active_layout_id"].is_string() ||
|
||||||
|
!LayoutProfile::findIndex(s, s["active_layout_id"].get<std::string>()))
|
||||||
|
{
|
||||||
|
s["active_layout_id"] = s["layouts"][0]["id"].get<std::string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s["version"] = 3;
|
||||||
|
s.erase("layout");
|
||||||
|
s.erase("lidars");
|
||||||
|
loadActiveCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateRepository::bootstrapDefaultState()
|
||||||
|
{
|
||||||
|
const nlohmann::json layout = LayoutSchema::defaultLayoutObject();
|
||||||
|
nlohmann::json profile = LayoutProfile::make("Mặc định", layout, nlohmann::json::array());
|
||||||
|
LayoutSchema::ensure(profile["layout"]);
|
||||||
|
saveProfileToDisk(profile);
|
||||||
|
app_.state = nlohmann::json{{"version", 3},
|
||||||
|
{"active_layout_id", profile["id"]},
|
||||||
|
{"layouts", nlohmann::json::array({LayoutProfile::catalogEntryFromProfile(profile)})}};
|
||||||
|
app_.state["layout"] = profile["layout"];
|
||||||
|
app_.state["lidars"] = profile["lidars"];
|
||||||
|
app_.state["imus"] = profile.contains("imus") ? profile["imus"] : nlohmann::json::array();
|
||||||
|
}
|
||||||
|
|
||||||
|
StateRepository::StateRepository(std::filesystem::path data_path)
|
||||||
|
{
|
||||||
|
app_.data_path = std::move(data_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StateRepository::load()
|
||||||
|
{
|
||||||
|
const auto raw = FileUtil::readBinary(app_.data_path);
|
||||||
|
if (raw.empty())
|
||||||
|
{
|
||||||
|
bootstrapDefaultState();
|
||||||
|
save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
app_.state = nlohmann::json::parse(raw);
|
||||||
|
ensureSchema();
|
||||||
|
save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
bootstrapDefaultState();
|
||||||
|
save();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateRepository::ensureSchema()
|
||||||
|
{
|
||||||
|
migrateStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StateRepository::saveProfile(const nlohmann::json& profile)
|
||||||
|
{
|
||||||
|
return saveProfileToDisk(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
void StateRepository::reloadActiveCache()
|
||||||
|
{
|
||||||
|
loadActiveCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StateRepository::deleteProfile(const std::string& id)
|
||||||
|
{
|
||||||
|
return deleteProfileFile(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> StateRepository::loadProfileById(const std::string& id) const
|
||||||
|
{
|
||||||
|
return loadProfileFromDisk(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StateRepository::saveAppState()
|
||||||
|
{
|
||||||
|
if (!persistActiveProfile())
|
||||||
|
return false;
|
||||||
|
return save();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool StateRepository::save() const
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const nlohmann::json disk = globalStateForDisk(app_.state);
|
||||||
|
auto raw = disk.dump(2);
|
||||||
|
raw.push_back('\n');
|
||||||
|
return FileUtil::writeBinaryAtomic(app_.data_path, raw);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
49
src/storage/state_repository.hpp
Normal file
49
src/storage/state_repository.hpp
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app/app_state.hpp"
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class StateRepository
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit StateRepository(std::filesystem::path data_path);
|
||||||
|
|
||||||
|
AppState& app() { return app_; }
|
||||||
|
const AppState& app() const { return app_; }
|
||||||
|
|
||||||
|
bool load();
|
||||||
|
bool save() const;
|
||||||
|
bool saveAppState();
|
||||||
|
void ensureSchema();
|
||||||
|
|
||||||
|
bool saveProfile(const nlohmann::json& profile);
|
||||||
|
void reloadActiveCache();
|
||||||
|
bool deleteProfile(const std::string& id);
|
||||||
|
std::optional<nlohmann::json> loadProfileById(const std::string& id) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
AppState app_;
|
||||||
|
|
||||||
|
std::filesystem::path modelsDir() const;
|
||||||
|
std::filesystem::path profileFilePath(const std::string& id) const;
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> loadProfileFromDisk(const std::string& id) const;
|
||||||
|
bool saveProfileToDisk(const nlohmann::json& profile) const;
|
||||||
|
bool deleteProfileFile(const std::string& id) const;
|
||||||
|
|
||||||
|
void loadActiveCache();
|
||||||
|
bool persistActiveProfile();
|
||||||
|
nlohmann::json globalStateForDisk(const nlohmann::json& state) const;
|
||||||
|
void stripInlineProfiles(nlohmann::json& state) const;
|
||||||
|
void migrateStorage();
|
||||||
|
void bootstrapDefaultState();
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
47
src/util/file_util.cpp
Normal file
47
src/util/file_util.cpp
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#include "util/file_util.hpp"
|
||||||
|
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
std::string FileUtil::readBinary(const fs::path& path)
|
||||||
|
{
|
||||||
|
std::ifstream in(path, std::ios::binary);
|
||||||
|
if (!in)
|
||||||
|
return {};
|
||||||
|
std::ostringstream ss;
|
||||||
|
ss << in.rdbuf();
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileUtil::writeBinaryAtomic(const fs::path& path, const std::string& contents)
|
||||||
|
{
|
||||||
|
fs::create_directories(path.parent_path());
|
||||||
|
auto tmp = path;
|
||||||
|
tmp += ".tmp";
|
||||||
|
|
||||||
|
{
|
||||||
|
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!out)
|
||||||
|
return false;
|
||||||
|
out.write(contents.data(), static_cast<std::streamsize>(contents.size()));
|
||||||
|
out.flush();
|
||||||
|
if (!out)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::error_code ec;
|
||||||
|
fs::rename(tmp, path, ec);
|
||||||
|
if (!ec)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
fs::remove(path, ec);
|
||||||
|
ec.clear();
|
||||||
|
fs::rename(tmp, path, ec);
|
||||||
|
return !ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
15
src/util/file_util.hpp
Normal file
15
src/util/file_util.hpp
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class FileUtil
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static std::string readBinary(const std::filesystem::path& path);
|
||||||
|
static bool writeBinaryAtomic(const std::filesystem::path& path, const std::string& contents);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
41
src/util/http_util.cpp
Normal file
41
src/util/http_util.cpp
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#include "util/http_util.hpp"
|
||||||
|
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
std::string HttpUtil::contentTypeForPath(const std::filesystem::path& p)
|
||||||
|
{
|
||||||
|
const auto ext = StringUtil::toLower(p.extension().string());
|
||||||
|
if (ext == ".html")
|
||||||
|
return "text/html; charset=utf-8";
|
||||||
|
if (ext == ".css")
|
||||||
|
return "text/css; charset=utf-8";
|
||||||
|
if (ext == ".js")
|
||||||
|
return "application/javascript; charset=utf-8";
|
||||||
|
if (ext == ".json")
|
||||||
|
return "application/json; charset=utf-8";
|
||||||
|
if (ext == ".png")
|
||||||
|
return "image/png";
|
||||||
|
if (ext == ".svg")
|
||||||
|
return "image/svg+xml";
|
||||||
|
if (ext == ".ico")
|
||||||
|
return "image/x-icon";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
void HttpUtil::jsonError(httplib::Response& res, int status, const std::string& msg)
|
||||||
|
{
|
||||||
|
res.status = status;
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = nlohmann::json({{"error", msg}}).dump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void HttpUtil::addCors(httplib::Response& res)
|
||||||
|
{
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||||
|
res.set_header("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
19
src/util/http_util.hpp
Normal file
19
src/util/http_util.hpp
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <httplib.h>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class HttpUtil
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static std::string contentTypeForPath(const std::filesystem::path& p);
|
||||||
|
static void jsonError(httplib::Response& res, int status, const std::string& msg);
|
||||||
|
static void addCors(httplib::Response& res);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
35
src/util/id_util.cpp
Normal file
35
src/util/id_util.cpp
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#include "util/id_util.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <ctime>
|
||||||
|
#include <random>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
std::string IdUtil::newId()
|
||||||
|
{
|
||||||
|
static thread_local std::mt19937_64 rng{std::random_device{}()};
|
||||||
|
static constexpr char kHex[] = "0123456789abcdef";
|
||||||
|
std::string out;
|
||||||
|
out.resize(16);
|
||||||
|
for (int i = 0; i < 16; i++)
|
||||||
|
out[i] = kHex[static_cast<size_t>(rng() & 0xF)];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string IdUtil::nowIso8601()
|
||||||
|
{
|
||||||
|
using clock = std::chrono::system_clock;
|
||||||
|
const std::time_t t = clock::to_time_t(clock::now());
|
||||||
|
std::tm tm{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
gmtime_s(&tm, &t);
|
||||||
|
#else
|
||||||
|
gmtime_r(&t, &tm);
|
||||||
|
#endif
|
||||||
|
char buf[32];
|
||||||
|
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
14
src/util/id_util.hpp
Normal file
14
src/util/id_util.hpp
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class IdUtil
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static std::string newId();
|
||||||
|
static std::string nowIso8601();
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
25
src/util/string_util.cpp
Normal file
25
src/util/string_util.cpp
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
std::string StringUtil::toLower(std::string s)
|
||||||
|
{
|
||||||
|
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string StringUtil::trimCopy(const std::string& s)
|
||||||
|
{
|
||||||
|
size_t a = 0;
|
||||||
|
while (a < s.size() && std::isspace(static_cast<unsigned char>(s[a])))
|
||||||
|
a++;
|
||||||
|
size_t b = s.size();
|
||||||
|
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1])))
|
||||||
|
b--;
|
||||||
|
return s.substr(a, b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
14
src/util/string_util.hpp
Normal file
14
src/util/string_util.hpp
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class StringUtil
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static std::string toLower(std::string s);
|
||||||
|
static std::string trimCopy(const std::string& s);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
151
src/validation/sensor_validator.cpp
Normal file
151
src/validation/sensor_validator.cpp
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
#include "validation/sensor_validator.hpp"
|
||||||
|
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
std::optional<size_t> SensorValidator::findLidarIndex(const nlohmann::json& state, const std::string& id)
|
||||||
|
{
|
||||||
|
if (!state.contains("lidars") || !state["lidars"].is_array())
|
||||||
|
return std::nullopt;
|
||||||
|
const auto& lidars = state["lidars"];
|
||||||
|
for (size_t i = 0; i < lidars.size(); i++)
|
||||||
|
{
|
||||||
|
const auto& l = lidars[i];
|
||||||
|
if (l.is_object() && l.contains("id") && l["id"].is_string() && l["id"].get<std::string>() == id)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<size_t> SensorValidator::findImuIndex(const nlohmann::json& state, const std::string& id)
|
||||||
|
{
|
||||||
|
if (!state.contains("imus") || !state["imus"].is_array())
|
||||||
|
return std::nullopt;
|
||||||
|
const auto& imus = state["imus"];
|
||||||
|
for (size_t i = 0; i < imus.size(); i++)
|
||||||
|
{
|
||||||
|
const auto& im = imus[i];
|
||||||
|
if (im.is_object() && im.contains("id") && im["id"].is_string() && im["id"].get<std::string>() == id)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SensorValidator::validateLidarPayload(const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
if (!payload.is_object())
|
||||||
|
{
|
||||||
|
err = "payload must be a JSON object";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!payload.contains("name") || !payload["name"].is_string() || payload["name"].get<std::string>().empty())
|
||||||
|
{
|
||||||
|
err = "name is required";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!payload.contains("ip") || !payload["ip"].is_string() || payload["ip"].get<std::string>().empty())
|
||||||
|
{
|
||||||
|
err = "ip is required";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!payload.contains("port") || !payload["port"].is_number_integer())
|
||||||
|
{
|
||||||
|
err = "port must be an integer";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const int port = payload["port"].get<int>();
|
||||||
|
if (port < 1 || port > 65535)
|
||||||
|
{
|
||||||
|
err = "port must be in range 1..65535";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SensorValidator::lidarTripletExists(const nlohmann::json& state,
|
||||||
|
const std::string& name,
|
||||||
|
const std::string& ip,
|
||||||
|
int port,
|
||||||
|
const std::string* exclude_id)
|
||||||
|
{
|
||||||
|
if (!state.contains("lidars") || !state["lidars"].is_array())
|
||||||
|
return false;
|
||||||
|
const std::string n = StringUtil::trimCopy(name);
|
||||||
|
const std::string i = StringUtil::trimCopy(ip);
|
||||||
|
for (const auto& l : state["lidars"])
|
||||||
|
{
|
||||||
|
if (!l.is_object())
|
||||||
|
continue;
|
||||||
|
if (exclude_id && l.contains("id") && l["id"].get<std::string>() == *exclude_id)
|
||||||
|
continue;
|
||||||
|
if (!l.contains("name") || !l.contains("ip") || !l.contains("port"))
|
||||||
|
continue;
|
||||||
|
if (StringUtil::trimCopy(l["name"].get<std::string>()) == n && StringUtil::trimCopy(l["ip"].get<std::string>()) == i &&
|
||||||
|
l["port"].get<int>() == port)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SensorValidator::validateImuPayload(const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
if (!payload.is_object())
|
||||||
|
{
|
||||||
|
err = "payload must be a JSON object";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!payload.contains("name") || !payload["name"].is_string() || payload["name"].get<std::string>().empty())
|
||||||
|
{
|
||||||
|
err = "name is required";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!payload.contains("frame_id") || !payload["frame_id"].is_string() ||
|
||||||
|
payload["frame_id"].get<std::string>().empty())
|
||||||
|
{
|
||||||
|
err = "frame_id is required";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!payload.contains("topic") || !payload["topic"].is_string() || payload["topic"].get<std::string>().empty())
|
||||||
|
{
|
||||||
|
err = "topic is required";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (payload.contains("source") && payload["source"].is_string())
|
||||||
|
{
|
||||||
|
const std::string src = payload["source"].get<std::string>();
|
||||||
|
if (src != "external" && src != "lidar_builtin" && src != "onboard")
|
||||||
|
{
|
||||||
|
err = "source must be external, lidar_builtin, or onboard";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.contains("rate_hz") && !payload["rate_hz"].is_number())
|
||||||
|
{
|
||||||
|
err = "rate_hz must be a number";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SensorValidator::imuFrameExists(const nlohmann::json& state,
|
||||||
|
const std::string& frame_id,
|
||||||
|
const std::string* exclude_id)
|
||||||
|
{
|
||||||
|
if (!state.contains("imus") || !state["imus"].is_array())
|
||||||
|
return false;
|
||||||
|
const std::string f = StringUtil::trimCopy(frame_id);
|
||||||
|
for (const auto& im : state["imus"])
|
||||||
|
{
|
||||||
|
if (!im.is_object())
|
||||||
|
continue;
|
||||||
|
if (exclude_id && im.contains("id") && im["id"].get<std::string>() == *exclude_id)
|
||||||
|
continue;
|
||||||
|
if (!im.contains("frame_id"))
|
||||||
|
continue;
|
||||||
|
if (StringUtil::trimCopy(im["frame_id"].get<std::string>()) == f)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} // namespace lm
|
||||||
30
src/validation/sensor_validator.hpp
Normal file
30
src/validation/sensor_validator.hpp
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class SensorValidator
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static bool validateLidarPayload(const nlohmann::json& payload, std::string& err);
|
||||||
|
static bool validateImuPayload(const nlohmann::json& payload, std::string& err);
|
||||||
|
|
||||||
|
static bool lidarTripletExists(const nlohmann::json& state,
|
||||||
|
const std::string& name,
|
||||||
|
const std::string& ip,
|
||||||
|
int port,
|
||||||
|
const std::string* exclude_id = nullptr);
|
||||||
|
|
||||||
|
static bool imuFrameExists(const nlohmann::json& state,
|
||||||
|
const std::string& frame_id,
|
||||||
|
const std::string* exclude_id = nullptr);
|
||||||
|
|
||||||
|
static std::optional<size_t> findLidarIndex(const nlohmann::json& state, const std::string& id);
|
||||||
|
static std::optional<size_t> findImuIndex(const nlohmann::json& state, const std::string& id);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
21
www/app.js
21
www/app.js
@@ -7,6 +7,9 @@ const pageTitleEl = document.querySelector(".pageTitle");
|
|||||||
const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]"));
|
const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]"));
|
||||||
const pageOverviewEl = el("pageOverview");
|
const pageOverviewEl = el("pageOverview");
|
||||||
const pageConfigEl = el("pageConfig");
|
const pageConfigEl = el("pageConfig");
|
||||||
|
const pageMissionsEl = el("pageMissions");
|
||||||
|
const contentEl = document.querySelector(".content");
|
||||||
|
const contentRightEl = el("contentRight");
|
||||||
const overviewBackendEl = el("overviewBackend");
|
const overviewBackendEl = el("overviewBackend");
|
||||||
const overviewActiveLayoutEl = el("overviewActiveLayout");
|
const overviewActiveLayoutEl = el("overviewActiveLayout");
|
||||||
const overviewActiveModelEl = el("overviewActiveModel");
|
const overviewActiveModelEl = el("overviewActiveModel");
|
||||||
@@ -117,16 +120,28 @@ const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setActivePage(page) {
|
function setActivePage(page) {
|
||||||
const p = page === "overview" ? "overview" : "config";
|
const valid = ["overview", "config", "missions"];
|
||||||
|
const p = valid.includes(page) ? page : "config";
|
||||||
navItemEls.forEach((a) => {
|
navItemEls.forEach((a) => {
|
||||||
const on = (a.dataset.page || "") === p;
|
const on = (a.dataset.page || "") === p;
|
||||||
a.classList.toggle("active", on);
|
a.classList.toggle("active", on);
|
||||||
if (on) a.setAttribute("aria-current", "page");
|
if (on) a.setAttribute("aria-current", "page");
|
||||||
else a.removeAttribute("aria-current");
|
else a.removeAttribute("aria-current");
|
||||||
});
|
});
|
||||||
if (pageTitleEl) pageTitleEl.textContent = p === "overview" ? "Tổng quan" : "Cấu Hình";
|
const titles = { overview: "Tổng quan", config: "Cấu Hình", missions: "Missions" };
|
||||||
|
if (pageTitleEl) pageTitleEl.textContent = titles[p] || "Cấu Hình";
|
||||||
if (pageOverviewEl) pageOverviewEl.hidden = p !== "overview";
|
if (pageOverviewEl) pageOverviewEl.hidden = p !== "overview";
|
||||||
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
|
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
|
||||||
|
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
||||||
|
if (configSplitterEl) configSplitterEl.hidden = p !== "config";
|
||||||
|
if (contentRightEl) contentRightEl.hidden = p !== "config";
|
||||||
|
if (contentEl) {
|
||||||
|
contentEl.classList.toggle("content--overview", p === "overview");
|
||||||
|
contentEl.classList.toggle("content--config", p === "config");
|
||||||
|
contentEl.classList.toggle("content--missions", p === "missions");
|
||||||
|
}
|
||||||
|
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
|
||||||
|
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("activePage", p);
|
localStorage.setItem("activePage", p);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -145,7 +160,7 @@ function initNavigation() {
|
|||||||
let initial = "config";
|
let initial = "config";
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem("activePage");
|
const saved = localStorage.getItem("activePage");
|
||||||
if (saved === "overview" || saved === "config") initial = saved;
|
if (saved === "overview" || saved === "config" || saved === "missions") initial = saved;
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
153
www/index.html
153
www/index.html
@@ -29,6 +29,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="navTitle">CÀI ĐẶT</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="navItem" href="#" data-page="missions">
|
||||||
|
<span class="navDot"></span>
|
||||||
|
Missions
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="sidebarFooter">
|
<div class="sidebarFooter">
|
||||||
<div class="statusBadge">
|
<div class="statusBadge">
|
||||||
<span class="statusLed"></span>
|
<span class="statusLed"></span>
|
||||||
@@ -509,9 +517,60 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="page" id="pageMissions" data-page-content="missions" hidden>
|
||||||
|
<div id="missionsListView" class="missionsPage">
|
||||||
|
<section class="card">
|
||||||
|
<div class="cardHeader">
|
||||||
|
<div>
|
||||||
|
<div class="cardTitle">Missions</div>
|
||||||
|
<div class="cardSub">Setup → Missions — danh sách nhiệm vụ robot.</div>
|
||||||
|
</div>
|
||||||
|
<button id="missionCreateOpenBtn" type="button" class="btn primary">Create mission</button>
|
||||||
|
</div>
|
||||||
|
<div class="cardBody">
|
||||||
|
<div id="missionListEmpty" class="mutedNote" hidden>Chưa có mission. Bấm Create mission để bắt đầu.</div>
|
||||||
|
<div id="missionList" class="missionList"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="missionEditorView" class="missionsPage" hidden>
|
||||||
|
<section class="card missionEditorCard">
|
||||||
|
<div class="missionEditorTop">
|
||||||
|
<div class="missionEditorTitleWrap">
|
||||||
|
<button id="missionEditorBackBtn" type="button" class="btn subtle missionBackBtn" aria-label="Quay lại danh sách">←</button>
|
||||||
|
<div>
|
||||||
|
<div class="missionEditorKicker">Mission editor</div>
|
||||||
|
<div class="missionEditorTitleRow">
|
||||||
|
<h2 id="missionEditorTitle" class="missionEditorTitle">—</h2>
|
||||||
|
<button id="missionSettingsBtn" type="button" class="iconBtn" title="Cài đặt mission" aria-label="Cài đặt mission">⚙</button>
|
||||||
|
</div>
|
||||||
|
<div id="missionEditorMeta" class="missionEditorMeta">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="missionEditorTopActions">
|
||||||
|
<span id="missionEditorDirty" class="missionDirtyBadge" hidden>Chưa lưu</span>
|
||||||
|
<button id="missionSaveAsBtn" type="button" class="btn subtle">Save as</button>
|
||||||
|
<button id="missionSaveBtn" type="button" class="btn primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="missionActionBar" id="missionActionBar" role="toolbar" aria-label="Thêm action">
|
||||||
|
<div class="missionGroupTabs" id="missionGroupTabs"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="missionEditorBody">
|
||||||
|
<p class="missionFlowHint">Thực thi từ trên xuống dưới. Kéo biểu tượng ↔ để đổi thứ tự. Với Loop: kéo action vào vùng bên trong.</p>
|
||||||
|
<div id="missionActionList" class="missionActionList"></div>
|
||||||
|
<div id="missionActionListEmpty" class="missionActionListEmpty mutedNote">Chọn action từ menu phía trên để bắt đầu.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="configSplitter" class="splitter" role="separator" aria-orientation="vertical" tabindex="0"></div>
|
<div id="configSplitter" class="splitter" role="separator" aria-orientation="vertical" tabindex="0"></div>
|
||||||
|
|
||||||
<div class="contentRight">
|
<div class="contentRight" id="contentRight">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="cardHeader">
|
<div class="cardHeader">
|
||||||
<div>
|
<div>
|
||||||
@@ -537,6 +596,98 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<dialog id="missionCreateDialog" class="missionDialog">
|
||||||
|
<form id="missionCreateForm" method="dialog" class="missionDialogForm">
|
||||||
|
<div class="missionDialogHeader">
|
||||||
|
<h3>Create mission</h3>
|
||||||
|
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionCreateDialog" aria-label="Đóng">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="missionDialogBody">
|
||||||
|
<div class="row rowWide">
|
||||||
|
<label for="missionCreateName">Tên mission</label>
|
||||||
|
<input id="missionCreateName" type="text" required placeholder="VD: Go to charging station" />
|
||||||
|
</div>
|
||||||
|
<div class="row rowWide">
|
||||||
|
<label for="missionCreateGroup">Nhóm mission</label>
|
||||||
|
<select id="missionCreateGroup"></select>
|
||||||
|
</div>
|
||||||
|
<div class="row rowWide">
|
||||||
|
<label for="missionCreateGroupNew">Hoặc nhóm mới</label>
|
||||||
|
<input id="missionCreateGroupNew" type="text" placeholder="Tùy chọn" />
|
||||||
|
</div>
|
||||||
|
<div class="row rowWide">
|
||||||
|
<label for="missionCreateDesc">Mô tả</label>
|
||||||
|
<textarea id="missionCreateDesc" rows="2" placeholder="Tùy chọn"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="missionDialogFooter">
|
||||||
|
<button type="button" class="btn subtle" data-close-dialog="missionCreateDialog">Hủy</button>
|
||||||
|
<button type="submit" class="btn primary">Create mission</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="missionSettingsDialog" class="missionDialog">
|
||||||
|
<form id="missionSettingsForm" method="dialog" class="missionDialogForm">
|
||||||
|
<div class="missionDialogHeader">
|
||||||
|
<h3>Cài đặt mission</h3>
|
||||||
|
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSettingsDialog" aria-label="Đóng">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="missionDialogBody">
|
||||||
|
<div class="row rowWide">
|
||||||
|
<label for="missionSettingsName">Tên</label>
|
||||||
|
<input id="missionSettingsName" type="text" required />
|
||||||
|
</div>
|
||||||
|
<div class="row rowWide">
|
||||||
|
<label for="missionSettingsGroup">Nhóm</label>
|
||||||
|
<select id="missionSettingsGroup"></select>
|
||||||
|
</div>
|
||||||
|
<div class="row rowWide">
|
||||||
|
<label for="missionSettingsDesc">Mô tả</label>
|
||||||
|
<textarea id="missionSettingsDesc" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="missionDialogFooter">
|
||||||
|
<button type="button" class="btn subtle" data-close-dialog="missionSettingsDialog">Hủy</button>
|
||||||
|
<button type="submit" class="btn primary">Áp dụng</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="missionSaveAsDialog" class="missionDialog">
|
||||||
|
<form id="missionSaveAsForm" method="dialog" class="missionDialogForm">
|
||||||
|
<div class="missionDialogHeader">
|
||||||
|
<h3>Save mission as</h3>
|
||||||
|
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSaveAsDialog" aria-label="Đóng">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="missionDialogBody">
|
||||||
|
<div class="row rowWide">
|
||||||
|
<label for="missionSaveAsName">Tên mission mới</label>
|
||||||
|
<input id="missionSaveAsName" type="text" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="missionDialogFooter">
|
||||||
|
<button type="button" class="btn subtle" data-close-dialog="missionSaveAsDialog">Hủy</button>
|
||||||
|
<button type="submit" class="btn primary">Lưu bản sao</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="missionActionConfigDialog" class="missionDialog missionDialogWide">
|
||||||
|
<form id="missionActionConfigForm" method="dialog" class="missionDialogForm">
|
||||||
|
<div class="missionDialogHeader">
|
||||||
|
<h3 id="missionActionConfigTitle">Cấu hình action</h3>
|
||||||
|
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionActionConfigDialog" aria-label="Đóng">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="missionDialogBody" id="missionActionConfigBody"></div>
|
||||||
|
<div class="missionDialogFooter">
|
||||||
|
<button type="button" class="btn subtle" data-close-dialog="missionActionConfigDialog">Hủy</button>
|
||||||
|
<button type="submit" class="btn primary">Áp dụng</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/missions.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
911
www/missions.js
Normal file
911
www/missions.js
Normal file
@@ -0,0 +1,911 @@
|
|||||||
|
(() => {
|
||||||
|
const STORAGE_KEY = "phenikaax_missions_v1";
|
||||||
|
|
||||||
|
const ACTION_GROUPS = {
|
||||||
|
Move: [
|
||||||
|
{ type: "move_to_position", label: "Go to position" },
|
||||||
|
{ type: "move_to_marker", label: "Go to marker" },
|
||||||
|
{ type: "adjust_localization", label: "Adjust localization" },
|
||||||
|
{ type: "wait", label: "Wait" },
|
||||||
|
{ type: "set_speed", label: "Set speed" },
|
||||||
|
],
|
||||||
|
Logic: [
|
||||||
|
{ type: "if", label: "If" },
|
||||||
|
{ type: "loop", label: "Loop", isLoop: true },
|
||||||
|
{ type: "break", label: "Break" },
|
||||||
|
{ type: "continue", label: "Continue" },
|
||||||
|
{ type: "pause", label: "Pause" },
|
||||||
|
],
|
||||||
|
"I/O": [
|
||||||
|
{ type: "set_digital_output", label: "Set digital output" },
|
||||||
|
{ type: "wait_digital_input", label: "Wait for digital input" },
|
||||||
|
{ type: "set_plc_register", label: "Set PLC register" },
|
||||||
|
],
|
||||||
|
Cart: [
|
||||||
|
{ type: "pick_cart", label: "Pick cart" },
|
||||||
|
{ type: "drop_cart", label: "Drop cart" },
|
||||||
|
],
|
||||||
|
Misc: [
|
||||||
|
{ type: "user_log", label: "User log" },
|
||||||
|
{ type: "play_sound", label: "Play sound" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_GROUPS = ["Missions", "Move", "Logic", "I/O", "Cart", "Misc"];
|
||||||
|
|
||||||
|
const SAMPLE_POSITIONS = ["Charging station", "Warehouse", "Production line 1", "Dock A"];
|
||||||
|
const SAMPLE_MARKERS = ["Marker 1", "Marker 2", "Home"];
|
||||||
|
const SAMPLE_IO_MODULES = ["GPIO module 1", "PLC I/O 1"];
|
||||||
|
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
|
||||||
|
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const missionListEl = el("missionList");
|
||||||
|
const missionListEmptyEl = el("missionListEmpty");
|
||||||
|
const missionsListViewEl = el("missionsListView");
|
||||||
|
const missionEditorViewEl = el("missionEditorView");
|
||||||
|
const missionActionBarEl = el("missionActionBar");
|
||||||
|
const missionGroupTabsEl = el("missionGroupTabs");
|
||||||
|
const missionActionListEl = el("missionActionList");
|
||||||
|
const missionEditorTitleEl = el("missionEditorTitle");
|
||||||
|
const missionEditorMetaEl = el("missionEditorMeta");
|
||||||
|
const missionEditorDirtyEl = el("missionEditorDirty");
|
||||||
|
const missionCreateDialogEl = el("missionCreateDialog");
|
||||||
|
const missionSettingsDialogEl = el("missionSettingsDialog");
|
||||||
|
const missionSaveAsDialogEl = el("missionSaveAsDialog");
|
||||||
|
const missionActionConfigDialogEl = el("missionActionConfigDialog");
|
||||||
|
const missionActionConfigBodyEl = el("missionActionConfigBody");
|
||||||
|
const missionActionConfigTitleEl = el("missionActionConfigTitle");
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
missions: [],
|
||||||
|
groups: [...DEFAULT_GROUPS],
|
||||||
|
editingId: null,
|
||||||
|
draft: null,
|
||||||
|
dirty: false,
|
||||||
|
drag: null,
|
||||||
|
configActionId: null,
|
||||||
|
configListPath: "root",
|
||||||
|
};
|
||||||
|
|
||||||
|
function newId() {
|
||||||
|
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
|
||||||
|
return `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultParams(type) {
|
||||||
|
switch (type) {
|
||||||
|
case "move_to_position":
|
||||||
|
return { position: SAMPLE_POSITIONS[0], check_free: true };
|
||||||
|
case "move_to_marker":
|
||||||
|
return { marker: SAMPLE_MARKERS[0] };
|
||||||
|
case "adjust_localization":
|
||||||
|
return { position: SAMPLE_POSITIONS[0] };
|
||||||
|
case "wait":
|
||||||
|
return { seconds: 1 };
|
||||||
|
case "set_speed":
|
||||||
|
return { speed: "normal" };
|
||||||
|
case "if":
|
||||||
|
return { condition: "position_free", position: SAMPLE_POSITIONS[0] };
|
||||||
|
case "loop":
|
||||||
|
return { count: 1, mode: "count" };
|
||||||
|
case "set_digital_output":
|
||||||
|
return { module: SAMPLE_IO_MODULES[0], pin: 1, value: true };
|
||||||
|
case "wait_digital_input":
|
||||||
|
return { module: SAMPLE_IO_MODULES[0], pin: 1, expected: true, timeout_s: 30 };
|
||||||
|
case "set_plc_register":
|
||||||
|
return { register: 1, action: "set", value: 0 };
|
||||||
|
case "pick_cart":
|
||||||
|
return { position: SAMPLE_POSITIONS[0], cart: SAMPLE_CARTS[0] };
|
||||||
|
case "drop_cart":
|
||||||
|
return { position: SAMPLE_POSITIONS[0], collision_check: true };
|
||||||
|
case "user_log":
|
||||||
|
return { message: "Mission step" };
|
||||||
|
case "play_sound":
|
||||||
|
return { sound: "beep" };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionMeta(type) {
|
||||||
|
for (const items of Object.values(ACTION_GROUPS)) {
|
||||||
|
const hit = items.find((a) => a.type === type);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return { type, label: type };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAction(type, overrides = {}) {
|
||||||
|
const meta = actionMeta(type);
|
||||||
|
return {
|
||||||
|
id: newId(),
|
||||||
|
kind: "action",
|
||||||
|
type,
|
||||||
|
label: meta.label,
|
||||||
|
params: defaultParams(type),
|
||||||
|
children: meta.isLoop ? [] : undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMissionRef(mission) {
|
||||||
|
return {
|
||||||
|
id: newId(),
|
||||||
|
kind: "mission",
|
||||||
|
refId: mission.id,
|
||||||
|
label: mission.name,
|
||||||
|
params: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMission(name, group, description) {
|
||||||
|
return {
|
||||||
|
id: newId(),
|
||||||
|
name: name.trim(),
|
||||||
|
group: group.trim() || "Missions",
|
||||||
|
description: (description || "").trim(),
|
||||||
|
actions: [],
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStore() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (Array.isArray(data.missions)) store.missions = data.missions;
|
||||||
|
if (Array.isArray(data.groups)) store.groups = data.groups;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
ensureDefaultGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistStore() {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ missions: store.missions, groups: store.groups })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDefaultGroups() {
|
||||||
|
DEFAULT_GROUPS.forEach((g) => {
|
||||||
|
if (!store.groups.includes(g)) store.groups.push(g);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function allGroups() {
|
||||||
|
const fromMissions = store.missions.map((m) => m.group).filter(Boolean);
|
||||||
|
return [...new Set([...store.groups, ...fromMissions])].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillGroupSelect(selectEl, selected) {
|
||||||
|
if (!selectEl) return;
|
||||||
|
selectEl.innerHTML = "";
|
||||||
|
allGroups().forEach((g) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = g;
|
||||||
|
opt.textContent = g;
|
||||||
|
if (g === selected) opt.selected = true;
|
||||||
|
selectEl.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMission(id) {
|
||||||
|
return store.missions.find((m) => m.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDraft() {
|
||||||
|
return store.draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDirty(flag) {
|
||||||
|
store.dirty = !!flag;
|
||||||
|
if (missionEditorDirtyEl) missionEditorDirtyEl.hidden = !store.dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionSummary(action) {
|
||||||
|
if (action.kind === "mission") return `Mission con: ${action.label}`;
|
||||||
|
const p = action.params || {};
|
||||||
|
switch (action.type) {
|
||||||
|
case "move_to_position":
|
||||||
|
return `Position: ${p.position}${p.check_free ? " • kiểm tra trống" : ""}`;
|
||||||
|
case "move_to_marker":
|
||||||
|
return `Marker: ${p.marker}`;
|
||||||
|
case "wait":
|
||||||
|
return `${p.seconds}s`;
|
||||||
|
case "set_speed":
|
||||||
|
return `Speed: ${p.speed}`;
|
||||||
|
case "loop":
|
||||||
|
return p.mode === "endless" ? "Lặp vô hạn" : `Lặp ${p.count} lần • ${action.children?.length || 0} bước`;
|
||||||
|
case "if":
|
||||||
|
return `If ${p.condition} @ ${p.position || "—"}`;
|
||||||
|
case "set_digital_output":
|
||||||
|
return `${p.module} pin ${p.pin} → ${p.value ? "ON" : "OFF"}`;
|
||||||
|
case "wait_digital_input":
|
||||||
|
return `${p.module} pin ${p.pin} = ${p.expected ? "ON" : "OFF"}`;
|
||||||
|
case "set_plc_register":
|
||||||
|
return `Reg ${p.register}: ${p.action} ${p.value}`;
|
||||||
|
case "pick_cart":
|
||||||
|
case "drop_cart":
|
||||||
|
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${p.position}`;
|
||||||
|
case "user_log":
|
||||||
|
return p.message || "—";
|
||||||
|
case "play_sound":
|
||||||
|
return p.sound || "—";
|
||||||
|
default:
|
||||||
|
return action.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActionList(path) {
|
||||||
|
const draft = getDraft();
|
||||||
|
if (!draft) return null;
|
||||||
|
if (path === "root") return draft.actions;
|
||||||
|
const parts = path.split(".");
|
||||||
|
let list = draft.actions;
|
||||||
|
for (const part of parts) {
|
||||||
|
const node = list.find((a) => a.id === part);
|
||||||
|
if (!node || !Array.isArray(node.children)) return null;
|
||||||
|
list = node.children;
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActionWithParent(actionId, list = getDraft()?.actions, path = "root", parent = null) {
|
||||||
|
if (!list) return null;
|
||||||
|
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) {
|
||||||
|
const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeActionFromTree(actionId) {
|
||||||
|
const hit = findActionWithParent(actionId);
|
||||||
|
if (!hit) return false;
|
||||||
|
hit.list.splice(hit.index, 1);
|
||||||
|
setDirty(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveAction(actionId, targetPath, targetIndex) {
|
||||||
|
const hit = findActionWithParent(actionId);
|
||||||
|
if (!hit) return false;
|
||||||
|
const targetList = findActionList(targetPath);
|
||||||
|
if (!targetList) return false;
|
||||||
|
|
||||||
|
if (hit.path === targetPath) {
|
||||||
|
const [item] = hit.list.splice(hit.index, 1);
|
||||||
|
const idx = hit.index < targetIndex ? targetIndex - 1 : targetIndex;
|
||||||
|
targetList.splice(idx, 0, item);
|
||||||
|
setDirty(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movingIntoLoop = targetPath !== "root";
|
||||||
|
if (movingIntoLoop) {
|
||||||
|
const loopId = targetPath.split(".").pop();
|
||||||
|
if (loopId === actionId) return false;
|
||||||
|
let cursor = findActionWithParent(loopId);
|
||||||
|
while (cursor) {
|
||||||
|
if (cursor.action.id === actionId) return false;
|
||||||
|
cursor = cursor.parent ? findActionWithParent(cursor.parent.id) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = hit.list.splice(hit.index, 1);
|
||||||
|
targetList.splice(targetIndex, 0, item);
|
||||||
|
setDirty(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addActionToList(type, listPath = "root") {
|
||||||
|
const list = findActionList(listPath);
|
||||||
|
if (!list) return;
|
||||||
|
list.push(createAction(type));
|
||||||
|
setDirty(true);
|
||||||
|
renderMissionEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMissionRefToList(missionId, listPath = "root") {
|
||||||
|
const mission = findMission(missionId);
|
||||||
|
const list = findActionList(listPath);
|
||||||
|
if (!mission || !list || mission.id === store.editingId) return;
|
||||||
|
list.push(createMissionRef(mission));
|
||||||
|
setDirty(true);
|
||||||
|
renderMissionEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMissionList() {
|
||||||
|
if (!missionListEl) return;
|
||||||
|
missionListEl.innerHTML = "";
|
||||||
|
const items = [...store.missions].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
missionListEmptyEl.hidden = items.length > 0;
|
||||||
|
|
||||||
|
items.forEach((mission) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "missionListItem";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="missionListItemTitle">${escapeHtml(mission.name)}</div>
|
||||||
|
<div class="missionListItemMeta">${escapeHtml(mission.group)} • ${mission.actions.length} action(s)${mission.description ? ` • ${escapeHtml(mission.description)}` : ""}</div>
|
||||||
|
</div>
|
||||||
|
<div class="missionListItemActions">
|
||||||
|
<button type="button" class="btn subtle" data-edit="${mission.id}">Sửa</button>
|
||||||
|
<button type="button" class="btn subtle danger" data-delete="${mission.id}">Xóa</button>
|
||||||
|
</div>`;
|
||||||
|
row.addEventListener("click", (evt) => {
|
||||||
|
if (evt.target.closest("button")) return;
|
||||||
|
openEditor(mission.id);
|
||||||
|
});
|
||||||
|
row.querySelector("[data-edit]").addEventListener("click", (evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
openEditor(mission.id);
|
||||||
|
});
|
||||||
|
row.querySelector("[data-delete]").addEventListener("click", (evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
if (!confirm(`Xóa mission «${mission.name}»?`)) return;
|
||||||
|
store.missions = store.missions.filter((m) => m.id !== mission.id);
|
||||||
|
persistStore();
|
||||||
|
renderMissionList();
|
||||||
|
});
|
||||||
|
missionListEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActionPalette() {
|
||||||
|
if (!missionGroupTabsEl) return;
|
||||||
|
missionGroupTabsEl.innerHTML = "";
|
||||||
|
|
||||||
|
Object.entries(ACTION_GROUPS).forEach(([groupName, actions]) => {
|
||||||
|
const tab = document.createElement("div");
|
||||||
|
tab.className = "missionGroupTab";
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "missionGroupTabBtn";
|
||||||
|
btn.textContent = groupName;
|
||||||
|
btn.dataset.group = groupName;
|
||||||
|
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.className = "missionGroupMenu";
|
||||||
|
menu.hidden = true;
|
||||||
|
|
||||||
|
actions.forEach((def) => {
|
||||||
|
const item = document.createElement("button");
|
||||||
|
item.type = "button";
|
||||||
|
item.className = "missionPaletteItem";
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
menu.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const embeddable = store.missions.filter((m) => m.id !== store.editingId);
|
||||||
|
if (embeddable.length) {
|
||||||
|
const sep = document.createElement("div");
|
||||||
|
sep.className = "mutedNote";
|
||||||
|
sep.style.padding = "6px 10px";
|
||||||
|
sep.textContent = "Missions có sẵn";
|
||||||
|
menu.appendChild(sep);
|
||||||
|
embeddable.forEach((m) => {
|
||||||
|
const item = document.createElement("button");
|
||||||
|
item.type = "button";
|
||||||
|
item.className = "missionPaletteItem missionRef";
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
menu.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener("click", (evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
const open = !menu.hidden;
|
||||||
|
closeAllPaletteMenus();
|
||||||
|
menu.hidden = open;
|
||||||
|
btn.classList.toggle("open", !open);
|
||||||
|
});
|
||||||
|
|
||||||
|
tab.appendChild(btn);
|
||||||
|
tab.appendChild(menu);
|
||||||
|
missionGroupTabsEl.appendChild(tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllPaletteMenus() {
|
||||||
|
document.querySelectorAll(".missionGroupMenu").forEach((m) => {
|
||||||
|
m.hidden = true;
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".missionGroupTabBtn.open").forEach((b) => b.classList.remove("open"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActionRows(actions, listPath, container) {
|
||||||
|
actions.forEach((action, index) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "missionActionRow";
|
||||||
|
row.dataset.actionId = action.id;
|
||||||
|
row.dataset.listPath = listPath;
|
||||||
|
row.dataset.index = String(index);
|
||||||
|
|
||||||
|
const iconClass =
|
||||||
|
action.kind === "mission" ? "kind-mission" : action.type === "loop" ? "kind-loop" : "";
|
||||||
|
const iconChar = action.kind === "mission" ? "◎" : action.type === "loop" ? "↻" : "▶";
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="missionDragHandle" draggable="true" title="Kéo để sắp xếp" aria-label="Kéo để sắp xếp">↕</div>
|
||||||
|
<div class="missionActionMain">
|
||||||
|
<div class="missionActionLabelRow">
|
||||||
|
<span class="missionActionIcon ${iconClass}">${iconChar}</span>
|
||||||
|
<span class="missionActionLabel">${escapeHtml(action.label)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="missionActionSummary">${escapeHtml(actionSummary(action))}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="iconBtn" data-config="${action.id}" title="Cấu hình">⚙</button>
|
||||||
|
<button type="button" class="iconBtn danger" data-remove="${action.id}" title="Xóa">×</button>`;
|
||||||
|
|
||||||
|
if (action.type === "loop" && Array.isArray(action.children)) {
|
||||||
|
const loop = document.createElement("div");
|
||||||
|
loop.className = "missionLoopBlock";
|
||||||
|
loop.innerHTML = `<div class="missionLoopLabel">Loop body — kéo action vào đây</div>`;
|
||||||
|
const drop = document.createElement("div");
|
||||||
|
drop.className = "missionLoopDrop";
|
||||||
|
drop.dataset.loopPath = `${listPath}.${action.id}`;
|
||||||
|
if (!action.children.length) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "missionLoopEmpty";
|
||||||
|
empty.textContent = "Kéo action hoặc mission vào loop";
|
||||||
|
drop.appendChild(empty);
|
||||||
|
} else {
|
||||||
|
renderActionRows(action.children, `${listPath}.${action.id}`, drop);
|
||||||
|
}
|
||||||
|
loop.appendChild(drop);
|
||||||
|
row.querySelector(".missionActionMain").appendChild(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.querySelector("[data-config]").addEventListener("click", () => openActionConfig(action.id));
|
||||||
|
row.querySelector("[data-remove]").addEventListener("click", () => {
|
||||||
|
removeActionFromTree(action.id);
|
||||||
|
renderMissionEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = row.querySelector(".missionDragHandle");
|
||||||
|
handle.addEventListener("dragstart", (evt) => onDragStart(evt, action.id, listPath));
|
||||||
|
handle.addEventListener("dragend", onDragEnd);
|
||||||
|
row.addEventListener("dragover", onRowDragOver);
|
||||||
|
row.addEventListener("dragleave", onRowDragLeave);
|
||||||
|
row.addEventListener("drop", onRowDrop);
|
||||||
|
|
||||||
|
const loopDrop = row.querySelector(".missionLoopDrop");
|
||||||
|
if (loopDrop) {
|
||||||
|
loopDrop.addEventListener("dragover", onLoopDragOver);
|
||||||
|
loopDrop.addEventListener("dragleave", onLoopDragLeave);
|
||||||
|
loopDrop.addEventListener("drop", onLoopDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMissionEditor() {
|
||||||
|
const draft = getDraft();
|
||||||
|
if (!draft) return;
|
||||||
|
missionEditorTitleEl.textContent = draft.name;
|
||||||
|
missionEditorMetaEl.textContent = `${draft.group}${draft.description ? ` • ${draft.description}` : ""}`;
|
||||||
|
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 onDragStart(evt, actionId, listPath) {
|
||||||
|
store.drag = { actionId, listPath, mode: "reorder" };
|
||||||
|
evt.dataTransfer.effectAllowed = "move";
|
||||||
|
evt.dataTransfer.setData("text/plain", actionId);
|
||||||
|
evt.target.closest(".missionActionRow")?.classList.add("dragging");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd(evt) {
|
||||||
|
document.querySelectorAll(".missionActionRow.dragging, .missionActionRow.dropBefore, .missionActionRow.dropAfter").forEach((n) => {
|
||||||
|
n.classList.remove("dragging", "dropBefore", "dropAfter");
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".missionLoopDrop.dragOver").forEach((n) => n.classList.remove("dragOver"));
|
||||||
|
store.drag = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowDragOver(evt) {
|
||||||
|
if (!store.drag || store.drag.mode !== "reorder") return;
|
||||||
|
evt.preventDefault();
|
||||||
|
const row = evt.currentTarget;
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const before = evt.clientY < rect.top + rect.height / 2;
|
||||||
|
row.classList.toggle("dropBefore", before);
|
||||||
|
row.classList.toggle("dropAfter", !before);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowDragLeave(evt) {
|
||||||
|
evt.currentTarget.classList.remove("dropBefore", "dropAfter");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowDrop(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
if (!store.drag || store.drag.mode !== "reorder") return;
|
||||||
|
const row = evt.currentTarget;
|
||||||
|
const targetPath = row.dataset.listPath;
|
||||||
|
let targetIndex = Number(row.dataset.index);
|
||||||
|
if (row.classList.contains("dropAfter")) targetIndex += 1;
|
||||||
|
row.classList.remove("dropBefore", "dropAfter");
|
||||||
|
moveAction(store.drag.actionId, targetPath, targetIndex);
|
||||||
|
renderMissionEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoopDragOver(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
evt.currentTarget.classList.add("dragOver");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoopDragLeave(evt) {
|
||||||
|
evt.currentTarget.classList.remove("dragOver");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoopDrop(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
evt.currentTarget.classList.remove("dragOver");
|
||||||
|
if (!store.drag) return;
|
||||||
|
const loopPath = evt.currentTarget.dataset.loopPath;
|
||||||
|
const list = findActionList(loopPath);
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (store.drag.mode === "reorder") {
|
||||||
|
moveAction(store.drag.actionId, loopPath, list.length);
|
||||||
|
} else if (store.drag.mode === "palette") {
|
||||||
|
if (store.drag.missionId) addMissionRefToList(store.drag.missionId, loopPath);
|
||||||
|
else if (store.drag.actionType) {
|
||||||
|
list.push(createAction(store.drag.actionType));
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderMissionEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor(missionId) {
|
||||||
|
const mission = findMission(missionId);
|
||||||
|
if (!mission) return;
|
||||||
|
store.editingId = missionId;
|
||||||
|
store.draft = JSON.parse(JSON.stringify(mission));
|
||||||
|
setDirty(false);
|
||||||
|
missionsListViewEl.hidden = true;
|
||||||
|
missionEditorViewEl.hidden = false;
|
||||||
|
renderMissionEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
if (store.dirty && !confirm("Bỏ thay đổi chưa lưu?")) return;
|
||||||
|
store.editingId = null;
|
||||||
|
store.draft = null;
|
||||||
|
setDirty(false);
|
||||||
|
missionEditorViewEl.hidden = true;
|
||||||
|
missionsListViewEl.hidden = false;
|
||||||
|
renderMissionList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft() {
|
||||||
|
const draft = getDraft();
|
||||||
|
if (!draft) return false;
|
||||||
|
if (!draft.name.trim()) {
|
||||||
|
alert("Tên mission không được trống.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
draft.updated_at = new Date().toISOString();
|
||||||
|
const idx = store.missions.findIndex((m) => m.id === draft.id);
|
||||||
|
if (idx >= 0) store.missions[idx] = JSON.parse(JSON.stringify(draft));
|
||||||
|
else store.missions.push(JSON.parse(JSON.stringify(draft)));
|
||||||
|
persistStore();
|
||||||
|
setDirty(false);
|
||||||
|
renderMissionList();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraftAs(newName) {
|
||||||
|
const draft = getDraft();
|
||||||
|
if (!draft) return false;
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return false;
|
||||||
|
if (store.missions.some((m) => m.name === name && m.id !== draft.id)) {
|
||||||
|
alert("Tên mission đã tồn tại.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const copy = JSON.parse(JSON.stringify(draft));
|
||||||
|
copy.id = newId();
|
||||||
|
copy.name = name;
|
||||||
|
copy.updated_at = new Date().toISOString();
|
||||||
|
store.missions.push(copy);
|
||||||
|
persistStore();
|
||||||
|
store.editingId = copy.id;
|
||||||
|
store.draft = copy;
|
||||||
|
setDirty(false);
|
||||||
|
renderMissionEditor();
|
||||||
|
renderMissionList();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
fillGroupSelect(el("missionCreateGroup"), "Missions");
|
||||||
|
el("missionCreateName").value = "";
|
||||||
|
el("missionCreateGroupNew").value = "";
|
||||||
|
el("missionCreateDesc").value = "";
|
||||||
|
missionCreateDialogEl.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettingsDialog() {
|
||||||
|
const draft = getDraft();
|
||||||
|
if (!draft) return;
|
||||||
|
fillGroupSelect(el("missionSettingsGroup"), draft.group);
|
||||||
|
el("missionSettingsName").value = draft.name;
|
||||||
|
el("missionSettingsDesc").value = draft.description || "";
|
||||||
|
missionSettingsDialogEl.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSaveAsDialog() {
|
||||||
|
const draft = getDraft();
|
||||||
|
if (!draft) return;
|
||||||
|
el("missionSaveAsName").value = `${draft.name} (copy)`;
|
||||||
|
missionSaveAsDialogEl.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openActionConfig(actionId) {
|
||||||
|
const hit = findActionWithParent(actionId);
|
||||||
|
if (!hit || hit.action.kind === "mission") return;
|
||||||
|
store.configActionId = actionId;
|
||||||
|
missionActionConfigTitleEl.textContent = `Cấu hình: ${hit.action.label}`;
|
||||||
|
missionActionConfigBodyEl.innerHTML = buildConfigForm(hit.action);
|
||||||
|
missionActionConfigDialogEl.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigForm(action) {
|
||||||
|
const p = action.params || {};
|
||||||
|
const grid = document.createElement("div");
|
||||||
|
grid.className = "missionConfigGrid";
|
||||||
|
|
||||||
|
const addField = (label, node) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row rowWide";
|
||||||
|
const lab = document.createElement("label");
|
||||||
|
lab.textContent = label;
|
||||||
|
row.appendChild(lab);
|
||||||
|
row.appendChild(node);
|
||||||
|
grid.appendChild(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const textInput = (key, value, type = "text") => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = type;
|
||||||
|
input.dataset.param = key;
|
||||||
|
input.value = value ?? "";
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectInput = (key, value, options) => {
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.dataset.param = key;
|
||||||
|
options.forEach((opt) => {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = opt;
|
||||||
|
o.textContent = opt;
|
||||||
|
if (opt === value) o.selected = true;
|
||||||
|
select.appendChild(o);
|
||||||
|
});
|
||||||
|
return select;
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case "move_to_position":
|
||||||
|
case "adjust_localization":
|
||||||
|
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
||||||
|
if (action.type === "move_to_position") {
|
||||||
|
const chk = document.createElement("label");
|
||||||
|
chk.innerHTML = `<input type="checkbox" data-param="check_free" ${p.check_free ? "checked" : ""} /> Kiểm tra vị trí trống`;
|
||||||
|
addField("Tuỳ chọn", chk);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "move_to_marker":
|
||||||
|
addField("Marker", selectInput("marker", p.marker, SAMPLE_MARKERS));
|
||||||
|
break;
|
||||||
|
case "wait":
|
||||||
|
addField("Giây", textInput("seconds", p.seconds, "number"));
|
||||||
|
break;
|
||||||
|
case "set_speed":
|
||||||
|
addField("Tốc độ", selectInput("speed", p.speed, ["slow", "normal", "fast"]));
|
||||||
|
break;
|
||||||
|
case "loop":
|
||||||
|
addField("Chế độ", selectInput("mode", p.mode, ["count", "endless"]));
|
||||||
|
addField("Số lần lặp", textInput("count", p.count, "number"));
|
||||||
|
break;
|
||||||
|
case "if":
|
||||||
|
addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"]));
|
||||||
|
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
||||||
|
break;
|
||||||
|
case "set_digital_output":
|
||||||
|
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
|
||||||
|
addField("Pin", textInput("pin", p.pin, "number"));
|
||||||
|
{
|
||||||
|
const chk = document.createElement("label");
|
||||||
|
chk.innerHTML = `<input type="checkbox" data-param="value" ${p.value ? "checked" : ""} /> Bật (ON)`;
|
||||||
|
addField("Trạng thái", chk);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "wait_digital_input":
|
||||||
|
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
|
||||||
|
addField("Pin", textInput("pin", p.pin, "number"));
|
||||||
|
addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number"));
|
||||||
|
{
|
||||||
|
const chk = document.createElement("label");
|
||||||
|
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> Chờ mức ON`;
|
||||||
|
addField("Kỳ vọng", chk);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "set_plc_register":
|
||||||
|
addField("Register", textInput("register", p.register, "number"));
|
||||||
|
addField("Hành động", selectInput("action", p.action, ["set", "add", "subtract"]));
|
||||||
|
addField("Giá trị", textInput("value", p.value, "number"));
|
||||||
|
break;
|
||||||
|
case "pick_cart":
|
||||||
|
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
||||||
|
addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS));
|
||||||
|
break;
|
||||||
|
case "drop_cart":
|
||||||
|
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
||||||
|
{
|
||||||
|
const chk = document.createElement("label");
|
||||||
|
chk.innerHTML = `<input type="checkbox" data-param="collision_check" ${p.collision_check ? "checked" : ""} /> Kiểm tra va chạm`;
|
||||||
|
addField("An toàn", chk);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "user_log":
|
||||||
|
addField("Message", textInput("message", p.message));
|
||||||
|
break;
|
||||||
|
case "play_sound":
|
||||||
|
addField("Sound", selectInput("sound", p.sound, ["beep", "horn", "chime"]));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
grid.innerHTML = `<p class="mutedNote">Action này không có tham số cấu hình.</p>`;
|
||||||
|
}
|
||||||
|
return grid.outerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyActionConfig() {
|
||||||
|
const hit = findActionWithParent(store.configActionId);
|
||||||
|
if (!hit) return;
|
||||||
|
const params = { ...hit.action.params };
|
||||||
|
missionActionConfigBodyEl.querySelectorAll("[data-param]").forEach((node) => {
|
||||||
|
const key = node.dataset.param;
|
||||||
|
if (node.type === "checkbox") params[key] = node.checked;
|
||||||
|
else if (node.type === "number") params[key] = Number(node.value);
|
||||||
|
else params[key] = node.value;
|
||||||
|
});
|
||||||
|
hit.action.params = params;
|
||||||
|
setDirty(true);
|
||||||
|
renderMissionEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
el("missionCreateOpenBtn")?.addEventListener("click", openCreateDialog);
|
||||||
|
|
||||||
|
el("missionCreateForm")?.addEventListener("submit", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const name = el("missionCreateName").value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
let group = el("missionCreateGroup").value;
|
||||||
|
const groupNew = el("missionCreateGroupNew").value.trim();
|
||||||
|
if (groupNew) {
|
||||||
|
group = groupNew;
|
||||||
|
if (!store.groups.includes(group)) store.groups.push(group);
|
||||||
|
}
|
||||||
|
if (store.missions.some((m) => m.name === name)) {
|
||||||
|
alert("Tên mission đã tồn tại.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mission = createMission(name, group, el("missionCreateDesc").value);
|
||||||
|
store.missions.push(mission);
|
||||||
|
persistStore();
|
||||||
|
missionCreateDialogEl.close();
|
||||||
|
renderMissionList();
|
||||||
|
openEditor(mission.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
el("missionEditorBackBtn")?.addEventListener("click", closeEditor);
|
||||||
|
el("missionSaveBtn")?.addEventListener("click", () => {
|
||||||
|
if (saveDraft()) alert("Đã lưu mission.");
|
||||||
|
});
|
||||||
|
el("missionSaveAsBtn")?.addEventListener("click", openSaveAsDialog);
|
||||||
|
el("missionSettingsBtn")?.addEventListener("click", openSettingsDialog);
|
||||||
|
|
||||||
|
el("missionSettingsForm")?.addEventListener("submit", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const draft = getDraft();
|
||||||
|
if (!draft) return;
|
||||||
|
draft.name = el("missionSettingsName").value.trim();
|
||||||
|
draft.group = el("missionSettingsGroup").value;
|
||||||
|
draft.description = el("missionSettingsDesc").value.trim();
|
||||||
|
if (!draft.name) {
|
||||||
|
alert("Tên không được trống.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDirty(true);
|
||||||
|
missionSettingsDialogEl.close();
|
||||||
|
renderMissionEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
el("missionSaveAsForm")?.addEventListener("submit", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (saveDraftAs(el("missionSaveAsName").value.trim())) {
|
||||||
|
missionSaveAsDialogEl.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
el("missionActionConfigForm")?.addEventListener("submit", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
applyActionConfig();
|
||||||
|
missionActionConfigDialogEl.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-close-dialog]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const id = btn.getAttribute("data-close-dialog");
|
||||||
|
el(id)?.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (evt) => {
|
||||||
|
if (!evt.target.closest(".missionGroupTab")) closeAllPaletteMenus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
loadStore();
|
||||||
|
bindEvents();
|
||||||
|
renderMissionList();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MissionsApp = {
|
||||||
|
init,
|
||||||
|
onPageShow() {
|
||||||
|
if (!missionEditorViewEl?.hidden) renderMissionEditor();
|
||||||
|
else renderMissionList();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
265
www/style.css
265
www/style.css
@@ -137,7 +137,6 @@ body {
|
|||||||
.content {
|
.content {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--leftPaneW, 460px) 10px 1fr;
|
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -538,6 +537,270 @@ canvas {
|
|||||||
.viewHint { color: var(--muted); font-size: 12px; width: 100%; }
|
.viewHint { color: var(--muted); font-size: 12px; width: 100%; }
|
||||||
.canvasWrap canvas.edit-footprint { cursor: crosshair; }
|
.canvasWrap canvas.edit-footprint { cursor: crosshair; }
|
||||||
|
|
||||||
|
.content.content--missions {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
.content.content--overview {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
.content.content--config {
|
||||||
|
grid-template-columns: var(--leftPaneW, 460px) 10px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missionsPage { min-width: 0; width: 100%; }
|
||||||
|
.missionList { display: grid; gap: 10px; }
|
||||||
|
.missionListItem {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.missionListItem:hover {
|
||||||
|
border-color: rgba(37, 99, 235, 0.35);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.missionListItemTitle { font-weight: 700; font-size: 14px; }
|
||||||
|
.missionListItemMeta { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||||
|
.missionListItemActions { display: flex; gap: 8px; }
|
||||||
|
.missionListItemActions .btn { padding: 6px 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
.missionEditorCard { overflow: hidden; }
|
||||||
|
.missionEditorTop {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: linear-gradient(180deg, #fff, #f8fafc);
|
||||||
|
}
|
||||||
|
.missionEditorTitleWrap { display: flex; gap: 12px; align-items: flex-start; min-width: 0; }
|
||||||
|
.missionBackBtn { padding: 8px 12px; flex-shrink: 0; }
|
||||||
|
.missionEditorKicker { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
||||||
|
.missionEditorTitleRow { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
|
||||||
|
.missionEditorTitle { margin: 0; font-size: 18px; font-weight: 800; }
|
||||||
|
.missionEditorMeta { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
||||||
|
.missionEditorTopActions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||||
|
.missionDirtyBadge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #b45309;
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missionActionBar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
.missionGroupTab {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.missionGroupTabBtn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.missionGroupTabBtn:hover,
|
||||||
|
.missionGroupTabBtn.open {
|
||||||
|
background: rgba(37, 99, 235, 0.35);
|
||||||
|
border-color: rgba(37, 99, 235, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.missionGroupMenu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 220px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
z-index: 20;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow2);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.missionGroupMenu[hidden] { display: none; }
|
||||||
|
.missionPaletteItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.missionPaletteItem:hover { background: #eff6ff; }
|
||||||
|
.missionPaletteItem.missionRef .missionActionIcon { background: rgba(16, 185, 129, 0.15); color: #059669; }
|
||||||
|
|
||||||
|
.missionEditorBody { padding: 16px 18px 20px; }
|
||||||
|
.missionFlowHint { margin: 0 0 12px; font-size: 12px; color: var(--muted); }
|
||||||
|
.missionActionList { display: grid; gap: 8px; min-height: 48px; }
|
||||||
|
.missionActionListEmpty { padding: 24px; text-align: center; border: 1px dashed var(--border); border-radius: 12px; }
|
||||||
|
.missionActionListEmpty[hidden],
|
||||||
|
.missionActionList:not(:empty) + .missionActionListEmpty { display: none; }
|
||||||
|
|
||||||
|
.missionActionRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px 1fr auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
.missionActionRow.dragging { opacity: 0.45; }
|
||||||
|
.missionActionRow.dropBefore { box-shadow: inset 0 3px 0 var(--accent); }
|
||||||
|
.missionActionRow.dropAfter { box-shadow: inset 0 -3px 0 var(--accent); }
|
||||||
|
.missionDragHandle {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #f8fafc;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: grab;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.missionDragHandle:active { cursor: grabbing; }
|
||||||
|
.missionActionMain { min-width: 0; }
|
||||||
|
.missionActionLabelRow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.missionActionLabel { font-weight: 700; font-size: 13px; }
|
||||||
|
.missionActionSummary { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||||
|
.missionActionIcon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
background: rgba(37, 99, 235, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.missionActionIcon.kind-mission { background: rgba(16, 185, 129, 0.15); color: #059669; }
|
||||||
|
.missionActionIcon.kind-loop { background: rgba(139, 92, 246, 0.15); color: #7c3aed; }
|
||||||
|
.iconBtn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.iconBtn:hover { border-color: rgba(37, 99, 235, 0.35); color: var(--accent); background: #eff6ff; }
|
||||||
|
.iconBtn.danger:hover { border-color: rgba(239, 68, 68, 0.35); color: var(--danger); background: #fef2f2; }
|
||||||
|
|
||||||
|
.missionLoopBlock {
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px dashed rgba(124, 58, 237, 0.35);
|
||||||
|
background: rgba(124, 58, 237, 0.04);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.missionLoopLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #7c3aed;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.missionLoopDrop {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.missionLoopDrop.dragOver {
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
outline: 2px dashed rgba(37, 99, 235, 0.35);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.missionLoopEmpty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missionDialog {
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0;
|
||||||
|
width: min(480px, calc(100vw - 32px));
|
||||||
|
box-shadow: var(--shadow2);
|
||||||
|
}
|
||||||
|
.missionDialogWide { width: min(560px, calc(100vw - 32px)); }
|
||||||
|
.missionDialog::backdrop { background: rgba(15, 23, 42, 0.45); }
|
||||||
|
.missionDialogForm { display: grid; }
|
||||||
|
.missionDialogHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.missionDialogHeader h3 { margin: 0; font-size: 16px; }
|
||||||
|
.missionDialogBody { padding: 16px 18px; display: grid; gap: 12px; }
|
||||||
|
.missionDialogBody textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.missionDialogFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.missionConfigGrid { display: grid; gap: 12px; }
|
||||||
|
.missionConfigGrid .rowWide { grid-template-columns: 1fr; gap: 6px; }
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.shell { grid-template-columns: 1fr; }
|
.shell { grid-template-columns: 1fr; }
|
||||||
.sidebar { position: relative; height: auto; }
|
.sidebar { position: relative; height: auto; }
|
||||||
|
|||||||
Reference in New Issue
Block a user