Compare commits

...

2 Commits

Author SHA1 Message Date
a2e87aeb29 Add function Language
Some checks failed
Test / test (push) Has been cancelled
2026-06-16 16:44:04 +07:00
1156e1ab29 add top bar 2026-06-16 11:17:28 +07:00
23 changed files with 3315 additions and 454 deletions

View File

@@ -47,7 +47,9 @@ add_executable(lidar_manager_web
src/mission/mission_enqueue.cpp
src/mission/modbus_trigger_service.cpp
src/mission/mission_scheduler.cpp
src/robot/robot_runtime.cpp
src/server/api_mission_routes.cpp
src/server/api_robot_routes.cpp
)
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads)

View File

@@ -711,6 +711,173 @@
"source": "ui",
"started_at": "2026-06-15T03:25:12Z",
"status": "cancelled"
},
{
"created_at": "2026-06-16T09:41:27Z",
"finished_at": "2026-06-16T09:41:41Z",
"id": "29d42c51d3a96bec",
"log": [
{
"level": "info",
"message": "Loop endless (simulated, max 10000)",
"ts": "2026-06-16T09:41:28Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:28Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:28Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:29Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:29Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:30Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:31Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:32Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:32Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:33Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:34Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:35Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:35Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:36Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:36Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:37Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:38Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:39Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:39Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:40Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:41Z"
},
{
"level": "warn",
"message": "Mission hủy bởi operator",
"ts": "2026-06-16T09:41:41Z"
}
],
"mission": {
"actions": [
{
"children": [
{
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
"kind": "action",
"label": "Set PLC register",
"params": {
"action": "set",
"register": 1,
"value": 0
},
"type": "set_plc_register"
},
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
"kind": "action",
"label": "Loop",
"params": {
"count": 0,
"mode": "endless"
},
"type": "loop"
}
],
"description": "",
"group": "Missions",
"id": "5ae9dbcb0722dffb",
"name": "Test run",
"updated_at": "2026-06-15T03:08:55.138Z"
},
"mission_group": "Missions",
"mission_id": "5ae9dbcb0722dffb",
"mission_name": "Test run",
"parameters": {},
"priority": 0,
"robot_id": "default",
"source": "ui",
"started_at": "2026-06-16T09:41:28Z",
"status": "cancelled"
}
],
"runner": {
@@ -719,6 +886,6 @@
"message": "Đã hủy: Test run",
"paused": false,
"state": "idle",
"updated_at": "2026-06-15T03:26:42Z"
"updated_at": "2026-06-16T09:41:41Z"
}
}

13
data/robot_runtime.json Normal file
View File

@@ -0,0 +1,13 @@
{
"battery_charging": false,
"battery_percent": 54,
"cmd_angular": 0.0,
"cmd_linear": 0.0,
"error": null,
"health": "ok",
"joystick_engaged": false,
"joystick_speed": "fast",
"message": "Robot paused",
"motion": "paused",
"updated_at": "2026-06-16T03:40:34Z"
}

View File

@@ -99,6 +99,29 @@ assert_json_true "auth me" "$TMP/me.json" 'doc.get("user",{}).get("group_name")
assert_code "GET /" 200 "$TMP/index.html" -X GET "$BASE/"
assert_code "GET /auth.js" 200 "$TMP/auth.js" -X GET "$BASE/auth.js"
assert_code "GET /missions.js" 200 "$TMP/missions.js" -X GET "$BASE/missions.js"
assert_code "GET /topbar.js" 200 "$TMP/topbar.js" -X GET "$BASE/topbar.js"
assert_code "GET /api/robot/status" 200 "$TMP/robot_status.json" -X GET "$BASE/api/robot/status"
assert_json_true "robot status motion" "$TMP/robot_status.json" 'doc.get("motion") in ("paused", "running")'
assert_json_true "robot status battery" "$TMP/robot_status.json" 'doc.get("battery_percent", 0) >= 0'
assert_code "POST /api/robot/start" 200 "$TMP/robot_start.json" \
-X POST "$BASE/api/robot/start" -H 'Content-Type: application/json' -d '{}'
assert_json_true "robot started" "$TMP/robot_start.json" 'doc.get("motion") == "running"'
assert_code "POST /api/robot/pause" 200 "$TMP/robot_pause.json" \
-X POST "$BASE/api/robot/pause" -H 'Content-Type: application/json' -d '{}'
assert_json_true "robot paused" "$TMP/robot_pause.json" 'doc.get("motion") == "paused"'
assert_code "POST /api/robot/errors/reset" 200 "$TMP/robot_reset.json" \
-X POST "$BASE/api/robot/errors/reset" -H 'Content-Type: application/json' -d '{}'
assert_json_true "robot health ok" "$TMP/robot_reset.json" 'doc.get("health") == "ok"'
assert_code "PUT /api/auth/profile" 200 "$TMP/profile.json" \
-X PUT "$BASE/api/auth/profile" \
-H 'Content-Type: application/json' \
-d '{"display_name":"Admin Test"}'
assert_json_true "profile display_name" "$TMP/profile.json" 'doc.get("user",{}).get("display_name") == "Admin Test"'
assert_code "GET /api/state" 200 "$TMP/state.json" -X GET "$BASE/api/state"

View File

@@ -6,6 +6,7 @@
#include "mission/mission_scheduler.hpp"
#include "mission/mission_store.hpp"
#include "mission/modbus_trigger_service.hpp"
#include "robot/robot_runtime.hpp"
#include "server/api_server.hpp"
#include "server/static_file_server.hpp"
#include "storage/state_repository.hpp"
@@ -31,6 +32,7 @@ int LidarManagerApp::run()
const std::filesystem::path missions_store_path = data_path_.parent_path() / "missions.json";
MissionQueue mission_queue(mission_queue_path);
MissionStore mission_store(missions_store_path);
RobotRuntime robot_runtime(data_path_.parent_path() / "robot_runtime.json", mission_queue);
const auto enqueue_fn = [&mission_store, &mission_queue](const nlohmann::json& request, std::string& err) -> bool {
nlohmann::json payload;
@@ -49,7 +51,7 @@ int LidarManagerApp::run()
return auth.preRoute(req, res);
});
ApiServer api(repo, mission_queue, mission_store, modbus, scheduler);
ApiServer api(repo, mission_queue, mission_store, modbus, scheduler, robot_runtime);
api.registerRoutes(svr);
auth.registerRoutes(svr);
StaticFileServer::mount(svr, www_root_);

View File

@@ -157,7 +157,8 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
return std::nullopt;
if (path.rfind("/api/users", 0) == 0 || path.rfind("/api/user_groups", 0) == 0)
return "users";
if (path.rfind("/api/missions", 0) == 0 || path.rfind("/api/mission_queue", 0) == 0)
if (path.rfind("/api/missions", 0) == 0 || path.rfind("/api/mission_queue", 0) == 0 ||
path.rfind("/api/robot", 0) == 0)
return "missions";
if (path.rfind("/api/triggers", 0) == 0 || path.rfind("/api/schedules", 0) == 0 ||
path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 ||
@@ -410,6 +411,47 @@ bool AuthService::changePassword(const std::string& token,
return false;
}
std::optional<nlohmann::json> AuthService::changeProfile(const std::string& token,
const nlohmann::json& payload,
std::string& err)
{
if (!payload.is_object())
{
err = "invalid payload";
return std::nullopt;
}
std::lock_guard<std::mutex> lock(mu_);
const auto it = sessions_.find(token);
if (it == sessions_.end())
{
err = "not authenticated";
return std::nullopt;
}
for (auto& user : data_["users"])
{
if (user.value("id", "") != it->second.user_id)
continue;
if (payload.contains("display_name") && payload["display_name"].is_string())
{
const std::string name = StringUtil::trimCopy(payload["display_name"].get<std::string>());
if (name.empty())
{
err = "display_name cannot be empty";
return std::nullopt;
}
user["display_name"] = name;
}
saveUnlocked();
const auto* group = findGroupByIdUnlocked(user.value("group_id", ""));
return userPublicView(user, group ? *group : nlohmann::json::object());
}
err = "user not found";
return std::nullopt;
}
nlohmann::json AuthService::listGroups() const
{
std::lock_guard<std::mutex> lock(mu_);
@@ -704,6 +746,31 @@ void AuthService::registerRoutes(httplib::Server& svr)
HttpUtil::addCors(res);
});
svr.Put("/api/auth/profile", [this](const httplib::Request& req, httplib::Response& res) {
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
HttpUtil::jsonError(res, 400, "invalid json");
return;
}
const std::string token = extractToken(req);
std::string err;
const auto profile = changeProfile(token, body, err);
if (!profile)
{
HttpUtil::jsonError(res, 400, err);
return;
}
nlohmann::json out = {{"user", *profile}};
res.set_content(out.dump(), "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Get("/api/user_groups", [this](const httplib::Request&, httplib::Response& res) {
nlohmann::json out = {{"groups", listGroups()}};
res.set_content(out.dump(), "application/json; charset=utf-8");

View File

@@ -40,6 +40,9 @@ public:
const std::string& current_password,
const std::string& new_password,
std::string& err);
std::optional<nlohmann::json> changeProfile(const std::string& token,
const nlohmann::json& payload,
std::string& err);
nlohmann::json listGroups() const;
nlohmann::json listUsers() const;

265
src/robot/robot_runtime.cpp Normal file
View File

@@ -0,0 +1,265 @@
#include "robot/robot_runtime.hpp"
#include "mission/mission_queue.hpp"
#include "util/file_util.hpp"
#include "util/id_util.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
namespace lm {
namespace {
constexpr const char* kDefaultMessage = "Waiting for new missions...";
} // namespace
RobotRuntime::RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue)
: runtime_path_(std::move(runtime_path)), mission_queue_(mission_queue)
{
load();
ensureDefaultsUnlocked();
}
void RobotRuntime::load()
{
std::lock_guard<std::mutex> lock(mu_);
state_ = nlohmann::json::object();
if (!std::filesystem::exists(runtime_path_))
return;
try
{
const auto parsed = nlohmann::json::parse(FileUtil::readBinary(runtime_path_));
if (parsed.is_object())
state_ = parsed;
}
catch (...)
{
state_ = nlohmann::json::object();
}
}
void RobotRuntime::saveUnlocked() const
{
FileUtil::writeBinaryAtomic(runtime_path_, state_.dump(2));
}
void RobotRuntime::ensureDefaultsUnlocked()
{
if (!state_.is_object())
state_ = nlohmann::json::object();
if (!state_.contains("motion"))
state_["motion"] = "paused";
if (!state_.contains("health"))
state_["health"] = "ok";
if (!state_.contains("message"))
state_["message"] = kDefaultMessage;
if (!state_.contains("error"))
state_["error"] = nullptr;
if (!state_.contains("battery_percent"))
state_["battery_percent"] = 54;
if (!state_.contains("battery_charging"))
state_["battery_charging"] = false;
if (!state_.contains("joystick_engaged"))
state_["joystick_engaged"] = false;
if (!state_.contains("joystick_speed"))
state_["joystick_speed"] = "fast";
if (!state_.contains("cmd_linear"))
state_["cmd_linear"] = 0.0;
if (!state_.contains("cmd_angular"))
state_["cmd_angular"] = 0.0;
if (!state_.contains("updated_at"))
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
}
nlohmann::json RobotRuntime::buildStatusUnlocked() const
{
const auto runner = mission_queue_.runnerStatus();
const auto queue = mission_queue_.list();
int pending = 0;
if (queue.is_array())
{
for (const auto& item : queue)
{
if (item.value("status", "") == "pending")
++pending;
}
}
const std::string motion = state_.value("motion", "paused");
const std::string health = state_.value("health", "ok");
const std::string runner_state = runner.value("state", "idle");
std::string message = state_.value("message", kDefaultMessage);
if (health == "ok")
{
if (!runner.value("message", "").empty())
message = runner.value("message", "");
else if (runner_state == "idle" && motion == "paused" && pending == 0)
message = kDefaultMessage;
}
nlohmann::json body = {{"motion", motion},
{"health", health},
{"message", message},
{"error", state_.contains("error") ? state_["error"] : nullptr},
{"battery_percent", state_.value("battery_percent", 54)},
{"battery_charging", state_.value("battery_charging", false)},
{"joystick_engaged", state_.value("joystick_engaged", false)},
{"joystick_speed", state_.value("joystick_speed", "fast")},
{"cmd_linear", state_.value("cmd_linear", 0.0)},
{"cmd_angular", state_.value("cmd_angular", 0.0)},
{"runner", runner},
{"queue_pending", pending},
{"updated_at", state_.value("updated_at", "")}};
return body;
}
nlohmann::json RobotRuntime::status() const
{
std::lock_guard<std::mutex> lock(mu_);
return buildStatusUnlocked();
}
bool RobotRuntime::start(std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (state_.value("health", "ok") == "error")
{
err = "cannot start while robot is in error state — reset error first";
return false;
}
state_["motion"] = "running";
state_["message"] = "Robot running";
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
if (runner_state == "paused")
return mission_queue_.resume(err);
return true;
}
bool RobotRuntime::pause(std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
state_["motion"] = "paused";
state_["message"] = "Robot paused";
state_["joystick_engaged"] = false;
state_["cmd_linear"] = 0.0;
state_["cmd_angular"] = 0.0;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
if (runner_state == "running" || runner_state == "paused")
return mission_queue_.pause(err);
return true;
}
bool RobotRuntime::resetError(std::string& err)
{
(void)err;
std::lock_guard<std::mutex> lock(mu_);
state_["health"] = "ok";
state_["error"] = nullptr;
state_["message"] = kDefaultMessage;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
bool RobotRuntime::setCmdVel(double linear, double angular, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (state_.value("health", "ok") == "error")
{
err = "robot is in error state";
return false;
}
if (!state_.value("joystick_engaged", false))
{
err = "joystick is not engaged";
return false;
}
if (state_.value("motion", "paused") != "running")
{
err = "robot motion is paused — press START first";
return false;
}
const std::string speed = state_.value("joystick_speed", "fast");
double scale = 1.0;
if (speed == "medium")
scale = 0.55;
else if (speed == "slow")
scale = 0.25;
linear = std::clamp(linear, -1.0, 1.0) * scale;
angular = std::clamp(angular, -1.0, 1.0) * scale;
state_["cmd_linear"] = linear;
state_["cmd_angular"] = angular;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
bool RobotRuntime::setJoystick(bool engaged, const std::string& speed, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (engaged && state_.value("health", "ok") == "error")
{
err = "cannot engage joystick while robot is in error state";
return false;
}
if (engaged && state_.value("motion", "paused") != "running")
{
err = "start the robot before engaging the joystick";
return false;
}
state_["joystick_engaged"] = engaged;
if (!speed.empty())
{
if (speed != "fast" && speed != "medium" && speed != "slow")
{
err = "speed must be fast, medium, or slow";
return false;
}
state_["joystick_speed"] = speed;
}
if (!engaged)
{
state_["cmd_linear"] = 0.0;
state_["cmd_angular"] = 0.0;
}
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
void RobotRuntime::tick()
{
std::lock_guard<std::mutex> lock(mu_);
const bool running = state_.value("motion", "paused") == "running";
const bool joy = state_.value("joystick_engaged", false);
double battery = state_.value("battery_percent", 54.0);
if (running && (joy || std::abs(state_.value("cmd_linear", 0.0)) > 0.01))
battery = std::max(0.0, battery - 0.02);
else if (state_.value("battery_charging", false))
battery = std::min(100.0, battery + 0.05);
else if (!running)
battery = std::min(100.0, battery + 0.005);
state_["battery_percent"] = static_cast<int>(std::lround(battery));
saveUnlocked();
}
} // namespace lm

View File

@@ -0,0 +1,38 @@
#pragma once
#include <nlohmann/json.hpp>
#include <filesystem>
#include <mutex>
#include <string>
namespace lm {
class MissionQueue;
class RobotRuntime
{
public:
explicit RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue);
nlohmann::json status() const;
bool start(std::string& err);
bool pause(std::string& err);
bool resetError(std::string& err);
bool setCmdVel(double linear, double angular, std::string& err);
bool setJoystick(bool engaged, const std::string& speed, std::string& err);
void tick();
private:
std::filesystem::path runtime_path_;
MissionQueue& mission_queue_;
mutable std::mutex mu_;
nlohmann::json state_;
void load();
void saveUnlocked() const;
void ensureDefaultsUnlocked();
nlohmann::json buildStatusUnlocked() const;
};
} // namespace lm

View File

@@ -0,0 +1,85 @@
#include "server/api_server.hpp"
#include "util/http_util.hpp"
namespace lm {
void ApiServer::registerRobotRoutes(httplib::Server& svr)
{
svr.Get("/api/robot/status", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
robot_runtime_.tick();
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/start", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.start(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/pause", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.pause(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/errors/reset", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.resetError(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/cmd_vel", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
const double linear = body.value("linear", body.value("vx", 0.0));
const double angular = body.value("angular", body.value("omega", 0.0));
std::string err;
if (!robot_runtime_.setCmdVel(linear, angular, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/joystick", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
if (!body.contains("engaged") || !body["engaged"].is_boolean())
return HttpUtil::jsonError(res, 400, "engaged (boolean) is required");
const std::string speed = body.value("speed", "");
std::string err;
if (!robot_runtime_.setJoystick(body["engaged"].get<bool>(), speed, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
}
} // namespace lm

View File

@@ -14,12 +14,14 @@ ApiServer::ApiServer(StateRepository& repo,
MissionQueue& mission_queue,
MissionStore& mission_store,
ModbusTriggerService& modbus,
MissionScheduler& scheduler)
MissionScheduler& scheduler,
RobotRuntime& robot_runtime)
: repo_(repo),
mission_queue_(mission_queue),
mission_store_(mission_store),
modbus_(modbus),
scheduler_(scheduler)
scheduler_(scheduler),
robot_runtime_(robot_runtime)
{
}
@@ -538,6 +540,7 @@ void ApiServer::registerRoutes(httplib::Server& svr)
registerMissionRoutes(svr);
registerIntegrationRoutes(svr);
registerMirV2Routes(svr);
registerRobotRoutes(svr);
}
} // namespace lm

View File

@@ -6,6 +6,7 @@
#include "mission/mission_scheduler.hpp"
#include "mission/mission_store.hpp"
#include "mission/modbus_trigger_service.hpp"
#include "robot/robot_runtime.hpp"
#include "storage/state_repository.hpp"
namespace lm {
@@ -17,7 +18,8 @@ public:
MissionQueue& mission_queue,
MissionStore& mission_store,
ModbusTriggerService& modbus,
MissionScheduler& scheduler);
MissionScheduler& scheduler,
RobotRuntime& robot_runtime);
void registerRoutes(httplib::Server& svr);
@@ -27,6 +29,7 @@ private:
MissionStore& mission_store_;
ModbusTriggerService& modbus_;
MissionScheduler& scheduler_;
RobotRuntime& robot_runtime_;
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
std::optional<nlohmann::json> enqueueMission(const nlohmann::json& request, std::string& err);
@@ -34,6 +37,7 @@ private:
void registerMissionRoutes(httplib::Server& svr);
void registerMirV2Routes(httplib::Server& svr);
void registerIntegrationRoutes(httplib::Server& svr);
void registerRobotRoutes(httplib::Server& svr);
};
} // namespace lm

View File

@@ -1,14 +1,16 @@
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const statusEl = el("status");
const listEl = el("lidarList");
const lidarFormHintEl = el("lidarFormHint");
const pageTitleEl = document.querySelector(".pageTitle");
const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]"));
const pageOverviewEl = el("pageOverview");
const pageConfigEl = el("pageConfig");
const pageMissionsEl = el("pageMissions");
const pageIntegrationsEl = el("pageIntegrations");
const pageMonitoringEl = el("pageMonitoring");
const pageHelpEl = el("pageHelp");
const contentEl = document.querySelector(".content");
const contentRightEl = el("contentRight");
const overviewBackendEl = el("overviewBackend");
@@ -121,30 +123,19 @@ const state = {
};
function setActivePage(page) {
const valid = ["dashboard", "config", "missions", "integrations"];
const valid = ["dashboard", "config", "missions", "integrations", "monitoring", "help"];
let p = valid.includes(page) ? page : "config";
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
p = fallback || "dashboard";
}
if (page === "overview") p = "dashboard";
navItemEls.forEach((a) => {
const on = (a.dataset.page || "") === p;
a.classList.toggle("active", on);
if (on) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
});
const titles = {
dashboard: "Dashboard",
config: "Cấu Hình",
missions: "Missions",
integrations: "Tích hợp",
};
if (pageTitleEl) pageTitleEl.textContent = titles[p] || "Cấu Hình";
if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard";
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
if (configSplitterEl) configSplitterEl.hidden = p !== "config";
if (contentRightEl) contentRightEl.hidden = p !== "config";
if (contentEl) {
@@ -152,14 +143,16 @@ function setActivePage(page) {
contentEl.classList.toggle("content--config", p === "config");
contentEl.classList.toggle("content--missions", p === "missions");
contentEl.classList.toggle("content--integrations", p === "integrations");
contentEl.classList.toggle("content--monitoring", p === "monitoring");
contentEl.classList.toggle("content--help", p === "help");
}
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide();
window.NavApp?.syncFromPage?.(p);
try {
localStorage.setItem("activePage", p);
} catch {
@@ -168,25 +161,12 @@ function setActivePage(page) {
}
function initNavigation() {
navItemEls.forEach((a) => {
a.addEventListener("click", (evt) => {
evt.preventDefault();
setActivePage(a.dataset.page || "config");
});
});
// Restore last page, default to config (màn hình chính).
let initial = "config";
try {
const saved = localStorage.getItem("activePage");
if (saved === "dashboard" || saved === "overview" || saved === "config" || saved === "missions" || saved === "integrations") {
initial = saved === "overview" ? "dashboard" : saved;
}
} catch {
/* ignore */
}
setActivePage(initial);
if (window.NavApp?.init) window.NavApp.init();
else setActivePage("config");
}
window.LmApp = { setActivePage };
function setLeftPaneWidth(px) {
const v = Math.round(clamp(Number(px), 320, 720));
document.documentElement.style.setProperty("--leftPaneW", `${v}px`);
@@ -638,7 +618,7 @@ function findDuplicateImuFrame(frameId, excludeId = null) {
function clearCanvasSelection() {
state.selectedId = null;
state.selectedImuId = null;
selectedText.textContent = "none";
selectedText.textContent = t("common.none");
setSelectedRelText();
}
@@ -1706,7 +1686,10 @@ function updateLayoutActiveHint() {
if (!layoutActiveHintEl) return;
const name = state.activeLayoutName || "—";
const dirty = state.layoutDirty ? " • chưa lưu" : "";
layoutActiveHintEl.textContent = `Đang chỉnh: ${name}${dirty}`;
layoutActiveHintEl.textContent = t("config.layout.editingHint", {
name,
dirty: dirty ? t("config.layout.unsavedDirty") : "",
});
}
function renderLayoutSelect() {
@@ -1792,7 +1775,7 @@ async function deleteActiveLayoutFromUI() {
return;
}
const name = state.activeLayoutName || state.activeLayoutId;
if (!window.confirm(`Xóa layout «${name}»? Hành động không hoàn tác.`)) return;
if (!window.confirm(t("config.layout.deleteConfirm", { name }))) return;
await api(`/api/layouts/${state.activeLayoutId}`, { method: "DELETE" });
state.viewInitialized = false;
await loadAll();
@@ -2147,7 +2130,7 @@ function setSelectedRelText() {
function renderList() {
if (!state.lidars.length) {
listEl.innerHTML = `<div class="item"><div class="itemName">Chưa có LiDAR</div><div class="itemMeta">Hãy thêm LiDAR ở form phía trên.</div></div>`;
listEl.innerHTML = `<div class="item"><div class="itemName">${t("config.lidar.empty")}</div><div class="itemMeta">${t("config.lidar.emptyHint")}</div></div>`;
return;
}
@@ -2253,7 +2236,7 @@ function updateImuItemPoseUI(id) {
function renderImuList() {
if (!imuListEl) return;
if (!state.imus.length) {
imuListEl.innerHTML = `<div class="item"><div class="itemName">Chưa có IMU</div><div class="itemMeta">Thêm IMU ở form phía trên.</div></div>`;
imuListEl.innerHTML = `<div class="item"><div class="itemName">${t("config.imu.empty")}</div><div class="itemMeta">${t("config.imu.emptyHint")}</div></div>`;
return;
}
@@ -3126,7 +3109,7 @@ async function loadAll() {
state.selectedImuId = null;
}
if (!state.selectedId && !state.selectedImuId) {
selectedText.textContent = "none";
selectedText.textContent = t("common.none");
}
setSelectedRelText();
renderList();
@@ -3139,7 +3122,10 @@ async function loadAll() {
overviewActiveModelEl.textContent = state.layout?.robot?.model || "diff";
}
if (overviewActiveSensorsEl) {
overviewActiveSensorsEl.textContent = `${state.lidars.length} LiDAR • ${state.imus.length} IMU`;
overviewActiveSensorsEl.textContent = t("dashboard.system.sensorCount", {
lidars: state.lidars.length,
imus: state.imus.length,
});
}
if (!state.viewInitialized) {
fitViewToWorld();
@@ -3155,7 +3141,7 @@ async function loadAll() {
}
}
el("refreshBtn").addEventListener("click", async () => {
el("refreshBtn")?.addEventListener("click", async () => {
try {
state.viewInitialized = false;
await loadAll();
@@ -3398,7 +3384,7 @@ window.addEventListener("keyup", (evt) => {
if (evt.key === "Shift") canvasWrap.classList.remove("shift-pan");
});
saveLayoutBtn.addEventListener("click", async () => {
saveLayoutBtn?.addEventListener("click", async () => {
try {
await saveCurrentLayout();
setStatus(`Đã lưu layout «${state.activeLayoutName || ""}»`);
@@ -3413,16 +3399,16 @@ saveLayoutBtn.addEventListener("click", async () => {
await api("/api/health");
await loadMotorCatalog();
await loadAll();
selectedText.textContent = "none";
selectedText.textContent = t("common.none");
selectedRelText.textContent = "—";
setStatus("Sẵn sàng");
setStatus(t("app.status.ready"));
} catch (e) {
const msg = String(e.message || e);
if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`;
if (overviewBackendEl) overviewBackendEl.textContent = t("common.error", { msg });
if (msg.includes("stack") || msg.includes("Maximum call")) {
setStatus(`Lỗi JavaScript: ${msg}`);
setStatus(`${t("app.status.jsError")}: ${msg}`);
} else {
setStatus(`Không kết nối được backend: ${msg}`);
setStatus(`${t("app.status.backendError")}: ${msg}`);
}
}
};
@@ -3430,3 +3416,15 @@ saveLayoutBtn.addEventListener("click", async () => {
else window.AuthApp?.whenReady(() => { boot(); });
})();
window.addEventListener("lm:locale-change", () => {
if (typeof renderList === "function") renderList();
if (typeof renderImuList === "function") renderImuList();
if (typeof renderLayoutSelect === "function") renderLayoutSelect();
if (typeof renderLayoutSelect === "function") renderLayoutSelect();
if (typeof updateLayoutActiveHint === "function") updateLayoutActiveHint();
if (typeof renderMotorWheels === "function") renderMotorWheels();
if (typeof renderBicycleMotorWheels === "function") renderBicycleMotorWheels();
if (typeof updateOverview === "function") updateOverview();
window.I18n?.applyDOM?.();
});

View File

@@ -12,10 +12,8 @@
const loginPinErrorEl = el("loginPinError");
const loginTabPasswordEl = el("loginTabPassword");
const loginTabPinEl = el("loginTabPin");
const userMenuBtnEl = el("userMenuBtn");
const userMenuPanelEl = el("userMenuPanel");
const userMenuNameEl = el("userMenuName");
const userMenuGroupEl = el("userMenuGroup");
const userMenuBtnEl = el("mirUserBtn");
const userMenuPanelEl = el("mirUserPanel");
const changePasswordDialogEl = el("changePasswordDialog");
const changePasswordFormEl = el("changePasswordForm");
const changePasswordErrorEl = el("changePasswordError");
@@ -26,6 +24,8 @@
let pinDigits = [];
let pinSubmitting = false;
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
async function apiJson(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
@@ -84,9 +84,9 @@
} catch (e) {
const msg = String(e.message || "");
if (msg.includes("invalid pin") || msg.includes("401")) {
showError("Mã PIN không hợp lệ. Liên hệ quản trị viên.", "pin");
showError(t("login.error.invalidPin"), "pin");
} else {
showError(msg || "Mã PIN không hợp lệ", "pin");
showError(msg || t("login.error.invalidPinShort"), "pin");
}
resetPin();
setLoginLoading(false);
@@ -112,7 +112,7 @@
function setLoginLoading(loading) {
loginScreenEl?.classList.toggle("is-loading", loading);
document.querySelectorAll(".loginSubmitLabel").forEach((label) => {
label.textContent = loading ? "Đang đăng nhập…" : "Đăng nhập";
label.textContent = loading ? t("login.submitting") : t("login.submit");
});
}
@@ -155,12 +155,9 @@
}
function applyNavPermissions() {
document.querySelectorAll(".navItem[data-page]").forEach((a) => {
const page = a.dataset.page || "";
const allowed = canAccessPage(page);
a.hidden = !allowed;
a.style.display = allowed ? "" : "none";
});
if (window.NavApp?.applyPermissions) {
window.NavApp.applyPermissions();
}
document.body.classList.toggle("auth-readonly-config", !canWrite("config"));
document.body.classList.toggle("auth-readonly-missions", !canWrite("missions"));
document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations"));
@@ -168,17 +165,21 @@
function updateUserMenu() {
if (!currentUser) return;
if (userMenuNameEl) userMenuNameEl.textContent = currentUser.display_name || currentUser.username || "—";
if (userMenuGroupEl) userMenuGroupEl.textContent = currentUser.group_name || "—";
if (window.TopbarApp?.updateUserMenu) {
window.TopbarApp.updateUserMenu(currentUser);
return;
}
if (userMenuBtnEl) {
const label = currentUser.display_name || currentUser.username || "User";
userMenuBtnEl.textContent = label;
userMenuBtnEl.title = `${label} (${currentUser.group_name || ""})`;
const label = (currentUser.group_name || "USER").toUpperCase();
userMenuBtnEl.title = `${currentUser.display_name || currentUser.username} (${currentUser.group_name || ""})`;
const labelEl = el("mirUserLabel");
if (labelEl) labelEl.textContent = label;
}
}
function unlockApp() {
setLoginLoading(false);
document.body.classList.remove("auth-logged-out");
if (loginScreenEl) {
loginScreenEl.setAttribute("hidden", "");
loginScreenEl.style.display = "none";
@@ -188,14 +189,16 @@
shellEl.style.display = "";
}
applyNavPermissions();
updateUserMenu();
ready = true;
window.dispatchEvent(new CustomEvent("lm:auth-ready", { detail: { user: currentUser } }));
updateUserMenu();
}
function lockApp() {
ready = false;
currentUser = null;
document.body.classList.add("auth-logged-out");
window.TopbarApp?.hideJoystickOverlay?.();
if (shellEl) shellEl.classList.add("auth-locked");
if (loginScreenEl) {
loginScreenEl.removeAttribute("hidden");
@@ -239,6 +242,12 @@
}
async function logout() {
try {
await window.TopbarApp?.disengageJoystick?.();
} catch {
/* ignore */
}
window.TopbarApp?.hideJoystickOverlay?.();
try {
await apiJson("/api/auth/logout", { method: "POST", body: "{}" });
} catch {
@@ -251,6 +260,17 @@
window.dispatchEvent(new Event("lm:auth-logout"));
}
async function saveProfile() {
const display_name = el("mirProfileDisplayName")?.value?.trim() || "";
if (!display_name) throw new Error(t("auth.profile.displayNameRequired"));
const data = await apiJson("/api/auth/profile", {
method: "PUT",
body: JSON.stringify({ display_name }),
});
currentUser = data.user;
updateUserMenu();
}
function bindEvents() {
loginTabPasswordEl?.addEventListener("click", (evt) => {
evt.preventDefault();
@@ -266,7 +286,7 @@
const username = el("loginUsername")?.value?.trim() || "";
const password = el("loginPasswordInput")?.value || "";
if (!username || !password) {
showError("Nhập tên đăng nhập và mật khẩu", "password");
showError(t("login.error.missingCredentials"), "password");
return;
}
setLoginLoading(true);
@@ -276,11 +296,11 @@
} catch (e) {
const msg = String(e.message || "");
if (msg.includes("credentials") || msg.includes("401")) {
showError("Sai tên đăng nhập hoặc mật khẩu. Thử Admin / admin", "password");
showError(t("login.error.badCredentials"), "password");
} else if (msg.includes("fetch") || msg.includes("Failed")) {
showError("Không kết nối được server. Kiểm tra http://localhost:8080", "password");
showError(t("login.error.serverUnreachable"), "password");
} else {
showError(msg || "Đăng nhập thất bại", "password");
showError(msg || t("login.error.failed"), "password");
}
setLoginLoading(false);
}
@@ -308,36 +328,35 @@
}
});
userMenuBtnEl?.addEventListener("click", (evt) => {
evt.stopPropagation();
const open = userMenuPanelEl?.hasAttribute("hidden");
if (open) userMenuPanelEl?.removeAttribute("hidden");
else userMenuPanelEl?.setAttribute("hidden", "");
});
document.addEventListener("click", () => {
userMenuPanelEl?.setAttribute("hidden", "");
});
el("userMenuSignOutBtn")?.addEventListener("click", (evt) => {
el("mirUserSignOutBtn")?.addEventListener("click", (evt) => {
evt.preventDefault();
logout();
});
el("userMenuChangePasswordBtn")?.addEventListener("click", (evt) => {
el("mirUserChangePasswordBtn")?.addEventListener("click", (evt) => {
evt.preventDefault();
userMenuPanelEl?.setAttribute("hidden", "");
changePasswordErrorEl && (changePasswordErrorEl.textContent = "");
changePasswordDialogEl?.showModal();
});
el("mirProfileSaveBtn")?.addEventListener("click", async (evt) => {
evt.preventDefault();
try {
await saveProfile();
userMenuPanelEl?.setAttribute("hidden", "");
} catch (e) {
alert(e.message || t("auth.profile.saveFailed"));
}
});
changePasswordFormEl?.addEventListener("submit", async (evt) => {
evt.preventDefault();
const current = el("changePasswordCurrent")?.value || "";
const next = el("changePasswordNew")?.value || "";
const confirm = el("changePasswordConfirm")?.value || "";
if (next !== confirm) {
if (changePasswordErrorEl) changePasswordErrorEl.textContent = "Mật khẩu mới không khớp";
if (changePasswordErrorEl) changePasswordErrorEl.textContent = t("auth.changePassword.mismatch");
return;
}
try {
@@ -348,7 +367,7 @@
changePasswordDialogEl?.close();
changePasswordFormEl.reset();
} catch (e) {
if (changePasswordErrorEl) changePasswordErrorEl.textContent = e.message || "Đổi mật khẩu thất bại";
if (changePasswordErrorEl) changePasswordErrorEl.textContent = e.message || t("auth.changePassword.failed");
}
});
}
@@ -366,7 +385,18 @@
};
bindEvents();
window.addEventListener("lm:locale-change", () => {
const loading = loginScreenEl?.classList.contains("is-loading");
setLoginLoading(loading);
});
setLoginMode("password");
shellEl?.classList.add("auth-locked");
if (window.location.search) {
try {
history.replaceState({}, "", window.location.pathname);
} catch {
/* ignore */
}
}
tryRestoreSession();
})();

View File

@@ -1,14 +1,12 @@
(() => {
const STORAGE_KEY = "phenikaax_dashboard_v1";
const WIDGET_LABELS = {
mission_button: "Mission button",
mission_group: "Mission group",
mission_queue: "Mission queue",
pause_continue: "Pause / Continue",
};
function widgetTypeLabel(type) {
return t(`dashboard.widget.${type}`) || type;
}
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const gridEl = el("dashboardGrid");
const emptyEl = el("dashboardEmpty");
const addDialogEl = el("dashboardAddWidgetDialog");
@@ -22,7 +20,7 @@
const store = {
widgets: [],
editMode: false,
pollTimer: null,
pollActive: false,
queueUnsub: null,
};
@@ -60,7 +58,7 @@
store.widgets = [
{ id: newId(), type: "mission_button", mission_id: firstId, title: "" },
{ id: newId(), type: "mission_group", group: "Missions", title: "" },
{ id: newId(), type: "mission_queue", title: "Mission queue" },
{ id: newId(), type: "mission_queue", title: "" },
{ id: newId(), type: "pause_continue", title: "" },
];
persistStore();
@@ -76,7 +74,7 @@
function widgetTitle(widget) {
if (widget.title) return widget.title;
return WIDGET_LABELS[widget.type] || widget.type;
return widgetTypeLabel(widget.type);
}
function missionOptions(selected) {
@@ -102,33 +100,33 @@
if (type === "mission_button") {
container.innerHTML = `
<div class="row rowWide">
<label>Mission</label>
<label>${t("dashboard.widget.field.mission")}</label>
<select data-field="mission_id">${missionOptions(widget.mission_id || "")}</select>
</div>
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="VD: Go to charging" />
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="${t(\"dashboard.widget.titlePlaceholder\")}" />
</div>`;
} else if (type === "mission_group") {
container.innerHTML = `
<div class="row rowWide">
<label>Nhóm mission</label>
<label>${t("dashboard.widget.field.group")}</label>
<select data-field="group">${groupOptions(widget.group || "Missions")}</select>
</div>
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
</div>`;
} else if (type === "mission_queue") {
container.innerHTML = `
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "Mission queue")}" />
</div>`;
} else if (type === "pause_continue") {
container.innerHTML = `
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
</div>
<p class="mutedNote">Tạm dừng / tiếp tục / hủy mission đang chạy trên robot.</p>`;
@@ -145,13 +143,13 @@
function renderMissionButtonWidget(widget, bodyEl) {
const m = missions()?.getMissionById?.(widget.mission_id);
const label = m?.name || "Chọn mission";
const label = m?.name || t("dashboard.widget.selectMission");
bodyEl.innerHTML = `
<button type="button" class="dashboardMissionBtn" data-run-mission="${escapeHtml(widget.mission_id || "")}">
<span class="dashboardMissionBtnIcon">▶</span>
<span>${escapeHtml(label)}</span>
</button>
${!m ? `<p class="mutedNote dashboardWidgetHint">Cấu hình widget và chọn mission.</p>` : ""}`;
${!m ? `<p class="mutedNote dashboardWidgetHint">${t("dashboard.widget.configHint")}</p>` : ""}`;
bodyEl.querySelector("[data-run-mission]")?.addEventListener("click", () => {
if (!widget.mission_id) return;
missions()?.queueMission?.(widget.mission_id);
@@ -162,7 +160,7 @@
const group = widget.group || "Missions";
const list = (missions()?.getMissions?.() || []).filter((m) => m.group === group);
if (!list.length) {
bodyEl.innerHTML = `<p class="mutedNote">Không có mission trong nhóm «${escapeHtml(group)}».</p>`;
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.emptyGroup", { group })}</p>`;
return;
}
bodyEl.innerHTML = `<div class="dashboardMissionGroupList"></div>`;
@@ -181,8 +179,8 @@
bodyEl.innerHTML = `
<div class="dashboardQueueRunner mutedNote" data-role="runner">—</div>
<div class="dashboardQueueList" data-role="list"></div>
<p class="mutedNote dashboardQueueEmpty" data-role="empty">Queue trống</p>
<button type="button" class="btn subtle btnBlock dashboardQueueClear">Xóa queue chờ</button>`;
<p class="mutedNote dashboardQueueEmpty" data-role="empty">${t("dashboard.widget.queueEmpty")}</p>
<button type="button" class="btn subtle btnBlock dashboardQueueClear">${t("dashboard.widget.clearQueue")}</button>`;
bodyEl.querySelector(".dashboardQueueClear")?.addEventListener("click", () => missions()?.clearQueue?.());
refreshQueueWidget(bodyEl);
}
@@ -208,13 +206,13 @@
bodyEl.innerHTML = `
<div class="dashboardRunnerControls">
<button type="button" class="dashboardPauseBtn ${paused ? "is-paused" : ""}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
${paused ? "Continue" : "Pause"}
${paused ? t("dashboard.widget.continue") : t("dashboard.widget.pause")}
</button>
<button type="button" class="dashboardCancelBtn" data-cancel-mission ${running ? "" : "disabled"}>
Hủy mission
${t("dashboard.widget.cancelMission")}
</button>
</div>
<p class="mutedNote dashboardWidgetHint">${running ? (paused ? "Mission đang tạm dừng" : "Mission đang chạy") : "Không có mission đang chạy"}</p>`;
<p class="mutedNote dashboardWidgetHint">${running ? (paused ? t("dashboard.widget.runner.paused") : t("dashboard.widget.runner.running")) : t("dashboard.widget.runner.idle")}</p>`;
bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => {
const action = evt.currentTarget.dataset.pauseAction;
try {
@@ -242,8 +240,8 @@
<div class="dashboardWidgetHeader">
<div class="dashboardWidgetTitle">${escapeHtml(widgetTitle(widget))}</div>
<div class="dashboardWidgetChrome" hidden>
<button type="button" class="iconBtn" data-widget-config title="Cấu hình">⚙</button>
<button type="button" class="iconBtn danger" data-widget-delete title="Xóa">×</button>
<button type="button" class="iconBtn" data-widget-config title="${t(\"common.configure\")}"></button>
<button type="button" class="iconBtn danger" data-widget-delete title="${t(\"common.delete\")}">×</button>
</div>
</div>
<div class="dashboardWidgetBody"></div>`;
@@ -301,13 +299,13 @@
const widget = store.widgets.find((w) => w.id === widgetId);
if (!widget) return;
editWidgetIdEl.value = widget.id;
editWidgetTypeEl.value = WIDGET_LABELS[widget.type] || widget.type;
editWidgetTypeEl.value = widgetTypeLabel(widget.type);
fillTypeFields(editFieldsEl, widget.type, widget);
editDialogEl.showModal();
}
function deleteWidget(widgetId) {
if (!confirm("Xóa widget này?")) return;
if (!confirm(t("dashboard.widget.deleteConfirm"))) return;
store.widgets = store.widgets.filter((w) => w.id !== widgetId);
persistStore();
renderDashboard();
@@ -318,7 +316,7 @@
el("dashboardAddWidgetBtn")?.addEventListener("click", openAddDialog);
el("dashboardEditBtn")?.addEventListener("click", () => {
store.editMode = !store.editMode;
el("dashboardEditBtn").textContent = store.editMode ? "Xong" : "Sửa layout";
el("dashboardEditBtn").textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout");
renderDashboard();
});
@@ -353,13 +351,14 @@
stopDashboardPoll();
missions()?.refreshQueue?.();
store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets());
store.pollTimer = setInterval(() => missions()?.refreshQueue?.(), 2000);
missions()?.startQueuePoll?.();
store.pollActive = true;
}
function stopDashboardPoll() {
if (store.pollTimer) {
clearInterval(store.pollTimer);
store.pollTimer = null;
if (store.pollActive) {
missions()?.stopQueuePoll?.();
store.pollActive = false;
}
if (store.queueUnsub) {
store.queueUnsub();
@@ -390,6 +389,12 @@
function boot() {
init();
}
window.addEventListener("lm:locale-change", () => {
renderDashboard();
const editBtn = el("dashboardEditBtn");
if (editBtn) editBtn.textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout");
});
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopDashboardPoll);

771
www/i18n.js Normal file
View File

@@ -0,0 +1,771 @@
/**
* Central i18n for LiDAR Manager — vi / en.
* Static DOM: data-i18n, data-i18n-placeholder, data-i18n-title, data-i18n-aria
* Dynamic JS: I18n.t("key") or I18n.t("key", { name: "..." })
*/
(() => {
const MESSAGES = {
vi: {
"app.title": "LiDAR Manager",
"app.robotName": "RobotApp",
"app.status.ready": "Sẵn sàng",
"app.status.reloaded": "Đã tải lại",
"app.status.backendError": "Không kết nối được backend",
"app.status.jsError": "Lỗi JavaScript",
"common.cancel": "Hủy",
"common.close": "Đóng",
"common.save": "Lưu",
"common.add": "Thêm",
"common.delete": "Xóa",
"common.apply": "Áp dụng",
"common.reload": "Tải lại",
"common.select": "Chọn",
"common.edit": "Sửa",
"common.enabled": "Bật",
"common.disabled": "Tắt",
"common.configure": "Cấu hình",
"common.error": "Lỗi: {msg}",
"common.none": "none",
"common.optional": "Tùy chọn",
"login.prompt": "Chọn cách đăng nhập:",
"login.tab.password": "Tên đăng nhập và mật khẩu",
"login.tab.pin": "Mã PIN",
"login.password.title": "Đăng nhập bằng tên và mật khẩu",
"login.password.help1": "Nhập tên đăng nhập và mật khẩu để truy cập robot.",
"login.password.help2": "Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.",
"login.password.help3": "Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.",
"login.field.username": "Tên đăng nhập:",
"login.field.password": "Mật khẩu:",
"login.placeholder.username": "Admin",
"login.submit": "Đăng nhập",
"login.submitting": "Đang đăng nhập…",
"login.pin.title": "Đăng nhập bằng mã PIN",
"login.pin.help1": "Người dùng được kích hoạt PIN có thể đăng nhập tại đây.",
"login.pin.help2": "Nếu chưa có mã PIN 4 chữ số, vui lòng liên hệ quản trị viên robot.",
"login.pin.helpNote": "Không có mã PIN cấu hình sẵn — quản trị viên phải gán PIN trước.",
"login.pin.aria.group": "Mã PIN 4 chữ số",
"login.pin.aria.keypad": "Bàn phím số",
"login.pin.aria.backspace": "Xóa",
"login.error.invalidPin": "Mã PIN không hợp lệ. Liên hệ quản trị viên.",
"login.error.invalidPinShort": "Mã PIN không hợp lệ",
"login.error.missingCredentials": "Nhập tên đăng nhập và mật khẩu",
"login.error.badCredentials": "Sai tên đăng nhập hoặc mật khẩu. Thử Admin / admin",
"login.error.serverUnreachable": "Không kết nối được server. Kiểm tra http://localhost:8080",
"login.error.failed": "Đăng nhập thất bại",
"nav.aria.main": "Điều hướng chính",
"nav.aria.submenu": "Menu phụ",
"nav.collapse": "Thu gọn menu",
"nav.expand": "Mở menu",
"nav.dashboards": "Dashboards",
"nav.setup": "Setup",
"nav.monitoring": "Monitoring",
"nav.system": "System",
"nav.help": "Help",
"nav.logout": "Log out",
"nav.dashboard": "Dashboard",
"nav.missions": "Missions",
"nav.maps": "Maps & layout",
"nav.monitoring-log": "System log",
"nav.integrations": "Tích hợp",
"nav.help-api": "API documentation",
"topbar.robotTitle": "Robot",
"topbar.controlAria": "Start / Pause robot",
"topbar.allOk": "ỔN ĐỊNH",
"topbar.error": "LỖI",
"topbar.paused": "TẠM DỪNG",
"topbar.running": "ĐANG CHẠY",
"topbar.waiting": "Đang chờ mission mới…",
"topbar.noMissionsQueue": "Không có mission trong queue…",
"topbar.reset": "RESET",
"topbar.changeUserData": "Lưu thông tin",
"topbar.changePassword": "Đổi mật khẩu",
"topbar.logout": "Đăng xuất",
"topbar.displayName": "Tên hiển thị",
"topbar.joystickTitle": "Điều khiển tay (Joystick)",
"topbar.joystickSpeed": "Tốc độ",
"topbar.joystickOff": "Tắt joystick",
"topbar.joystickAria": "Joystick",
"topbar.batteryTitle": "Pin",
"topbar.localeVi": "TIẾNG VIỆT",
"topbar.localeEn": "ENGLISH",
"topbar.localeOption.vi": "🇻🇳 Tiếng Việt",
"topbar.localeOption.en": "🇺🇸 English",
"topbar.userDefault": "USER",
"topbar.noControlPermission": "Không có quyền điều khiển",
"topbar.queueCount": "{n} mission trong queue",
"topbar.code": "Mã",
"topbar.module": "Module",
"topbar.joystickSpeed.slow": "Chậm",
"topbar.joystickSpeed.medium": "Trung bình",
"topbar.joystickSpeed.fast": "Nhanh",
"topbar.startHint": "Bấm để START robot",
"topbar.pauseHint": "Bấm để PAUSE robot",
"auth.profile.displayNameRequired": "Tên hiển thị không được trống",
"auth.profile.saveFailed": "Lưu thông tin thất bại",
"auth.changePassword.title": "Đổi mật khẩu",
"auth.changePassword.current": "Mật khẩu hiện tại",
"auth.changePassword.new": "Mật khẩu mới",
"auth.changePassword.confirm": "Xác nhận mật khẩu mới",
"auth.changePassword.mismatch": "Mật khẩu mới không khớp",
"auth.changePassword.failed": "Đổi mật khẩu thất bại",
"dashboard.title": "Dashboard",
"dashboard.subtitle": "Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.",
"dashboard.addWidget": "Thêm widget",
"dashboard.editLayout": "Sửa layout",
"dashboard.editDone": "Xong",
"dashboard.empty": "Chưa có widget. Bấm «Thêm widget» để bắt đầu.",
"dashboard.system.title": "Hệ thống",
"dashboard.system.subtitle": "Trạng thái backend và layout đang active.",
"dashboard.system.backend": "Backend",
"dashboard.system.layout": "Layout",
"dashboard.system.model": "Model robot",
"dashboard.system.sensors": "LiDAR / IMU",
"dashboard.system.sensorCount": "{lidars} LiDAR • {imus} IMU",
"dashboard.dialog.add.title": "Thêm widget",
"dashboard.dialog.add.type": "Loại widget",
"dashboard.dialog.edit.title": "Cấu hình widget",
"dashboard.dialog.edit.type": "Loại",
"dashboard.dialog.edit.delete": "Xóa widget",
"dashboard.widget.mission_button": "Nút mission",
"dashboard.widget.mission_group": "Nhóm mission",
"dashboard.widget.mission_queue": "Mission queue",
"dashboard.widget.pause_continue": "Tạm dừng / Tiếp tục",
"dashboard.widget.field.mission": "Mission",
"dashboard.widget.field.group": "Nhóm mission",
"dashboard.widget.field.title": "Tiêu đề widget (tùy chọn)",
"dashboard.widget.titlePlaceholder": "VD: Go to charging",
"dashboard.widget.pauseHint": "Tạm dừng / tiếp tục / hủy mission đang chạy trên robot.",
"dashboard.widget.selectMission": "Chọn mission…",
"dashboard.widget.configHint": "Cấu hình widget và chọn mission.",
"dashboard.widget.emptyGroup": "Không có mission trong nhóm «{group}».",
"dashboard.widget.queueEmpty": "Queue trống",
"dashboard.widget.clearQueue": "Xóa queue chờ",
"dashboard.widget.continue": "Tiếp tục",
"dashboard.widget.pause": "Tạm dừng",
"dashboard.widget.cancelMission": "Hủy mission",
"dashboard.widget.runner.paused": "Mission đang tạm dừng",
"dashboard.widget.runner.running": "Mission đang chạy",
"dashboard.widget.runner.idle": "Không có mission đang chạy",
"dashboard.widget.unsupported": "Widget không hỗ trợ.",
"dashboard.widget.deleteConfirm": "Xóa widget này?",
"config.layout.title": "Quản lý layout",
"config.layout.subtitle": "Nhiều cấu hình robot — mỗi layout có LiDAR và model riêng.",
"config.layout.save": "Lưu layout",
"config.layout.current": "Layout hiện tại",
"config.layout.newName": "Tên layout mới",
"config.layout.newNamePlaceholder": "VD: AGV kho A",
"config.layout.cloneCurrent": "Sao chép từ layout đang mở",
"config.layout.create": "Tạo layout",
"config.layout.editingHint": "Đang chỉnh: {name}{dirty}",
"config.layout.unsavedDirty": " • chưa lưu",
"config.layout.unsavedSwitchConfirm": "Layout hiện tại có thay đổi chưa lưu. Tiếp tục?",
"config.layout.deleteConfirm": "Xóa layout «{name}»? Hành động không hoàn tác.",
"config.lidar.title": "LiDARs",
"config.lidar.subtitle": "Đăng ký tên, IP, port và chỉnh pose theo robot frame.",
"config.lidar.field.name": "Tên",
"config.lidar.field.ip": "IP",
"config.lidar.field.port": "Port",
"config.lidar.placeholder.name": "Lidar trước",
"config.lidar.placeholder.ip": "192.168.0.10",
"config.lidar.empty": "Chưa có LiDAR",
"config.lidar.emptyHint": "Hãy thêm LiDAR ở form phía trên.",
"config.lidar.deleteConfirm": "Xóa LiDAR này?",
"config.imu.title": "IMU",
"config.imu.subtitle": "Cảm biến quán tính — frame, topic và pose trên robot.",
"config.imu.field.name": "Tên",
"config.imu.field.frame": "Frame ID",
"config.imu.field.topic": "Topic",
"config.imu.field.source": "Nguồn",
"config.imu.source.external": "Ngoài (ROS topic)",
"config.imu.source.lidarBuiltin": "Tích hợp LiDAR",
"config.imu.source.onboard": "Onboard robot",
"config.imu.field.rate": "Tần số (Hz)",
"config.imu.enabled": "Bật IMU",
"config.imu.add": "Thêm IMU",
"config.imu.placeholder.name": "IMU chính",
"config.imu.placeholder.frame": "imu_link",
"config.imu.placeholder.topic": "imu/data",
"config.imu.empty": "Chưa có IMU",
"config.imu.emptyHint": "Thêm IMU ở form phía trên.",
"config.imu.deleteConfirm": "Xóa IMU này?",
"config.robot.title": "Model robot",
"config.robot.subtitle": "Kinematic differential — bánh, động cơ và giới hạn vận tốc.",
"config.robot.model.diff": "Differential (2 bánh)",
"config.robot.model.bicycle": "Bicycle",
"config.canvas.title": "Bố trí trên robot",
"config.canvas.viewHint": "Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn",
"config.canvas.robotCenter": "Robot center:",
"config.canvas.selected": "Selected:",
"config.canvas.pose": "Pose:",
"config.pose.notSet": "chưa đặt pose",
"config.selected.lidar": "LiDAR: {name}",
"config.selected.imu": "IMU: {name}",
"config.motor.wheelRight": "Bánh phải",
"config.motor.wheelLeft": "Bánh trái",
"config.motor.wheelSteer": "Bánh trước (steer)",
"config.motor.wheelDrive": "Bánh sau (drive)",
"config.motor.vendor": "Hãng",
"config.motor.model": "Model",
"config.motor.joint": "Joint (ROS)",
"config.motor.ratio": "Tỷ số hộp số",
"config.motor.invert": "Đảo chiều quay",
"config.motor.invertSteer": "Đảo chiều",
"config.motor.custom": "Tùy chỉnh",
"config.motor.customMotor": "Motor tùy chỉnh",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
"missions.create": "Tạo mission",
"missions.empty": "Chưa có mission. Bấm Tạo mission để bắt đầu.",
"missions.queue.title": "Mission queue",
"missions.queue.subtitle": "Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.",
"missions.queue.cancel": "Hủy chạy",
"missions.queue.clear": "Xóa queue",
"missions.queue.empty": "Queue trống. Bấm ▤ trên mission để thêm.",
"missions.editor.kicker": "Mission editor",
"missions.editor.unsaved": "Chưa lưu",
"missions.editor.saveAs": "Save as",
"missions.editor.save": "Save",
"missions.editor.flowHint": "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.",
"missions.editor.emptyActions": "Chọn action từ menu phía trên để bắt đầu.",
"missions.editor.backAria": "Quay lại danh sách",
"missions.editor.settingsAria": "Cài đặt mission",
"missions.editor.addActionAria": "Thêm action",
"missions.queue.status.pending": "Chờ",
"missions.queue.status.running": "Đang chạy",
"missions.queue.status.done": "Xong",
"missions.queue.status.error": "Lỗi",
"missions.queue.status.cancelled": "Đã hủy",
"missions.queue.ready": "Sẵn sàng",
"missions.queue.idleMessage": "Robot sẵn sàng — queue trống hoặc chờ mission mới.",
"missions.queue.moveUp": "Lên",
"missions.queue.moveDown": "Xuống",
"missions.queue.addAria": "Thêm vào mission queue",
"missions.deleteConfirm": "Xóa mission «{name}»?",
"missions.queue.clearConfirm": "Xóa các mission đang chờ trong queue?",
"missions.queue.cancelConfirm": "Hủy mission đang chạy? (thoát loop nếu đang lặp)",
"missions.dialog.create.title": "Tạo mission",
"missions.dialog.create.name": "Tên mission",
"missions.dialog.create.group": "Nhóm mission",
"missions.dialog.create.groupNew": "Hoặc nhóm mới",
"missions.dialog.create.desc": "Mô tả",
"missions.dialog.create.namePlaceholder": "VD: Go to charging station",
"missions.dialog.settings.title": "Cài đặt mission",
"missions.dialog.settings.name": "Tên",
"missions.dialog.settings.group": "Nhóm",
"missions.dialog.settings.desc": "Mô tả",
"missions.dialog.saveAs.title": "Save mission as",
"missions.dialog.saveAs.name": "Tên mission mới",
"missions.dialog.saveAs.submit": "Lưu bản sao",
"missions.dialog.actionConfig.title": "Cấu hình action",
"missions.dialog.queue.title": "Thêm vào mission queue",
"missions.group.Move": "Move",
"missions.group.Logic": "Logic",
"missions.group.IO": "I/O",
"missions.group.Cart": "Cart",
"missions.group.Misc": "Misc",
"missions.action.move_to_position": "Go to position",
"missions.action.move_to_marker": "Go to marker",
"missions.action.adjust_localization": "Adjust localization",
"missions.action.wait": "Wait",
"missions.action.set_speed": "Set speed",
"missions.action.if": "If",
"missions.action.loop": "Loop",
"missions.action.break": "Break",
"missions.action.continue": "Continue",
"missions.action.pause": "Pause",
"missions.action.set_digital_output": "Set digital output",
"missions.action.wait_digital_input": "Wait for digital input",
"missions.action.set_plc_register": "Set PLC register",
"missions.action.pick_cart": "Pick cart",
"missions.action.drop_cart": "Drop cart",
"missions.action.user_log": "User log",
"missions.action.play_sound": "Play sound",
"missions.error.nameRequired": "Tên mission không được trống.",
"missions.error.nameDuplicate": "Tên mission đã tồn tại.",
"missions.error.nameEmpty": "Tên không được trống.",
"missions.saveSuccess": "Đã lưu mission.",
"missions.editor.discardConfirm": "Bỏ thay đổi chưa lưu?",
"missions.queue.status.executing": "Đang chạy",
"missions.action.waitOnLevel": "Chờ mức ON",
"integrations.modbus.title": "Modbus trigger",
"integrations.modbus.subtitle": "System → Triggers — coil 10012000 gắn mission_id. Thiết bị remote bật coil (Modbus TCP :5502) → mission vào queue.",
"integrations.modbus.add": "Thêm trigger",
"integrations.modbus.empty": "Chưa có trigger Modbus.",
"integrations.modbus.coilsLabel": "Coil đã gán (bấm để mô phỏng rising edge)",
"integrations.rest.title": "REST API — MiR v2.0.0",
"integrations.rest.subtitle": "Hệ thống bên ngoài POST mission vào queue qua REST.",
"integrations.rest.baseUrl": "Base URL",
"integrations.rest.quickTest": "Thử nhanh",
"integrations.rest.postQueue": "POST queue",
"integrations.fleet.title": "MiRFleet — Lên lịch mission",
"integrations.fleet.subtitle": "Ưu tiên, gán robot, chạy ASAP hoặc theo thời gian.",
"integrations.fleet.addSchedule": "Thêm lịch",
"integrations.fleet.empty": "Chưa có lịch fleet.",
"integrations.noMissions": "— Chưa có mission —",
"integrations.defaultRobot": "Robot chính",
"integrations.fireTrigger": "Kích hoạt",
"integrations.coilsEmpty": "Chưa gán coil. Thêm trigger bên trên (10012000).",
"integrations.coilState": "coil hiện tại: {state}",
"integrations.confirm.deleteTrigger": "Xóa trigger Modbus này?",
"integrations.confirm.deleteSchedule": "Xóa lịch fleet này?",
"integrations.dialog.trigger.title": "Modbus trigger",
"integrations.dialog.trigger.name": "Tên trigger",
"integrations.dialog.trigger.coil": "Coil ID",
"integrations.dialog.trigger.mission": "Mission",
"integrations.dialog.schedule.title": "Lịch MiRFleet",
"integrations.dialog.schedule.name": "Tên lịch",
"integrations.dialog.schedule.robot": "Robot",
"integrations.dialog.schedule.priority": "Ưu tiên",
"integrations.dialog.schedule.mode": "Chế độ",
"integrations.dialog.schedule.asap": "ASAP",
"integrations.dialog.schedule.scheduled": "Lên lịch",
"integrations.dialog.schedule.startTime": "Thời gian bắt đầu",
"integrations.schedule.runNow": "Chạy ngay",
"monitoring.log.title": "System log",
"monitoring.log.subtitle": "Monitoring → System log — nhật ký hệ thống (đang phát triển).",
"monitoring.log.placeholder": "Tính năng monitoring sẽ hiển thị log robot, cảnh báo và lịch sử mission tại đây.",
"help.api.title": "API documentation",
"help.api.subtitle": "Help → API — tham chiếu REST MiR v2.0.0 cho tích hợp bên ngoài.",
"help.api.body1": "Xem chi tiết endpoint tại System → Tích hợp hoặc tài liệu /api/v2.0.0/.",
"help.api.body2": "Reference Guide MiR rev 1.9: docs/Reference guide.pdf",
},
en: {
"app.title": "LiDAR Manager",
"app.robotName": "RobotApp",
"app.status.ready": "Ready",
"app.status.reloaded": "Reloaded",
"app.status.backendError": "Cannot connect to backend",
"app.status.jsError": "JavaScript error",
"common.cancel": "Cancel",
"common.close": "Close",
"common.save": "Save",
"common.add": "Add",
"common.delete": "Delete",
"common.apply": "Apply",
"common.reload": "Reload",
"common.select": "Select",
"common.edit": "Edit",
"common.enabled": "On",
"common.disabled": "Off",
"common.configure": "Configure",
"common.error": "Error: {msg}",
"common.none": "none",
"common.optional": "Optional",
"login.prompt": "Choose sign-in method:",
"login.tab.password": "Username and password",
"login.tab.pin": "PIN code",
"login.password.title": "Sign in with username and password",
"login.password.help1": "Enter your username and password to access the robot.",
"login.password.help2": "Accounts are provided by an administrator or in the robot manual.",
"login.password.help3": "If you do not have an account, contact the robot administrator.",
"login.field.username": "Username:",
"login.field.password": "Password:",
"login.placeholder.username": "Admin",
"login.submit": "Sign in",
"login.submitting": "Signing in…",
"login.pin.title": "Sign in with PIN",
"login.pin.help1": "Users with PIN enabled can sign in here.",
"login.pin.help2": "If you do not have a 4-digit PIN, contact the robot administrator.",
"login.pin.helpNote": "No PIN is preconfigured — an administrator must assign one first.",
"login.pin.aria.group": "4-digit PIN",
"login.pin.aria.keypad": "Numeric keypad",
"login.pin.aria.backspace": "Delete",
"login.error.invalidPin": "Invalid PIN. Contact the administrator.",
"login.error.invalidPinShort": "Invalid PIN",
"login.error.missingCredentials": "Enter username and password",
"login.error.badCredentials": "Invalid username or password. Try Admin / admin",
"login.error.serverUnreachable": "Cannot reach server. Check http://localhost:8080",
"login.error.failed": "Sign-in failed",
"nav.aria.main": "Main navigation",
"nav.aria.submenu": "Submenu",
"nav.collapse": "Collapse menu",
"nav.expand": "Expand menu",
"nav.dashboards": "Dashboards",
"nav.setup": "Setup",
"nav.monitoring": "Monitoring",
"nav.system": "System",
"nav.help": "Help",
"nav.logout": "Log out",
"nav.dashboard": "Dashboard",
"nav.missions": "Missions",
"nav.maps": "Maps & layout",
"nav.monitoring-log": "System log",
"nav.integrations": "Integrations",
"nav.help-api": "API documentation",
"topbar.robotTitle": "Robot",
"topbar.controlAria": "Start / Pause robot",
"topbar.allOk": "ALL OK",
"topbar.error": "ERROR",
"topbar.paused": "PAUSED",
"topbar.running": "RUNNING",
"topbar.waiting": "Waiting for new missions…",
"topbar.noMissionsQueue": "No missions in queue…",
"topbar.reset": "RESET",
"topbar.changeUserData": "Change user data",
"topbar.changePassword": "Change password",
"topbar.logout": "Log out",
"topbar.displayName": "Display name",
"topbar.joystickTitle": "Manual control (Joystick)",
"topbar.joystickSpeed": "Speed",
"topbar.joystickOff": "Disengage joystick",
"topbar.joystickAria": "Joystick",
"topbar.batteryTitle": "Battery",
"topbar.localeVi": "TIẾNG VIỆT",
"topbar.localeEn": "ENGLISH",
"topbar.localeOption.vi": "🇻🇳 Tiếng Việt",
"topbar.localeOption.en": "🇺🇸 English",
"topbar.userDefault": "USER",
"topbar.noControlPermission": "No control permission",
"topbar.queueCount": "{n} mission(s) in queue",
"topbar.code": "Code",
"topbar.module": "Module",
"topbar.joystickSpeed.slow": "Slow",
"topbar.joystickSpeed.medium": "Medium",
"topbar.joystickSpeed.fast": "Fast",
"topbar.startHint": "Click to START the robot",
"topbar.pauseHint": "Click to PAUSE the robot",
"auth.profile.displayNameRequired": "Display name cannot be empty",
"auth.profile.saveFailed": "Failed to save profile",
"auth.changePassword.title": "Change password",
"auth.changePassword.current": "Current password",
"auth.changePassword.new": "New password",
"auth.changePassword.confirm": "Confirm new password",
"auth.changePassword.mismatch": "New passwords do not match",
"auth.changePassword.failed": "Failed to change password",
"dashboard.title": "Dashboard",
"dashboard.subtitle": "Mission widgets — run, queue and pause like MiR Fleet.",
"dashboard.addWidget": "Add widget",
"dashboard.editLayout": "Edit layout",
"dashboard.editDone": "Done",
"dashboard.empty": "No widgets yet. Click «Add widget» to start.",
"dashboard.system.title": "System",
"dashboard.system.subtitle": "Backend status and active layout.",
"dashboard.system.backend": "Backend",
"dashboard.system.layout": "Layout",
"dashboard.system.model": "Robot model",
"dashboard.system.sensors": "LiDAR / IMU",
"dashboard.system.sensorCount": "{lidars} LiDAR • {imus} IMU",
"dashboard.dialog.add.title": "Add widget",
"dashboard.dialog.add.type": "Widget type",
"dashboard.dialog.edit.title": "Configure widget",
"dashboard.dialog.edit.type": "Type",
"dashboard.dialog.edit.delete": "Delete widget",
"dashboard.widget.mission_button": "Mission button",
"dashboard.widget.mission_group": "Mission group",
"dashboard.widget.mission_queue": "Mission queue",
"dashboard.widget.pause_continue": "Pause / Continue",
"dashboard.widget.field.mission": "Mission",
"dashboard.widget.field.group": "Mission group",
"dashboard.widget.field.title": "Widget title (optional)",
"dashboard.widget.titlePlaceholder": "e.g. Go to charging",
"dashboard.widget.pauseHint": "Pause, continue or cancel the running mission on the robot.",
"dashboard.widget.selectMission": "Select mission…",
"dashboard.widget.configHint": "Configure the widget and select a mission.",
"dashboard.widget.emptyGroup": "No missions in group «{group}».",
"dashboard.widget.queueEmpty": "Queue empty",
"dashboard.widget.clearQueue": "Clear pending queue",
"dashboard.widget.continue": "Continue",
"dashboard.widget.pause": "Pause",
"dashboard.widget.cancelMission": "Cancel mission",
"dashboard.widget.runner.paused": "Mission paused",
"dashboard.widget.runner.running": "Mission running",
"dashboard.widget.runner.idle": "No mission running",
"dashboard.widget.unsupported": "Unsupported widget.",
"dashboard.widget.deleteConfirm": "Delete this widget?",
"config.layout.title": "Layout manager",
"config.layout.subtitle": "Multiple robot configurations — each layout has its own LiDAR and model.",
"config.layout.save": "Save layout",
"config.layout.current": "Current layout",
"config.layout.newName": "New layout name",
"config.layout.newNamePlaceholder": "e.g. Warehouse AGV A",
"config.layout.cloneCurrent": "Clone from open layout",
"config.layout.create": "Create layout",
"config.layout.editingHint": "Editing: {name}{dirty}",
"config.layout.unsavedDirty": " • unsaved",
"config.layout.unsavedSwitchConfirm": "Current layout has unsaved changes. Continue?",
"config.layout.deleteConfirm": "Delete layout «{name}»? This cannot be undone.",
"config.lidar.title": "LiDARs",
"config.lidar.subtitle": "Register name, IP, port and adjust pose in robot frame.",
"config.lidar.field.name": "Name",
"config.lidar.field.ip": "IP",
"config.lidar.field.port": "Port",
"config.lidar.placeholder.name": "Front lidar",
"config.lidar.placeholder.ip": "192.168.0.10",
"config.lidar.empty": "No LiDAR yet",
"config.lidar.emptyHint": "Add a LiDAR using the form above.",
"config.lidar.deleteConfirm": "Delete this LiDAR?",
"config.imu.title": "IMU",
"config.imu.subtitle": "Inertial sensor — frame, topic and pose on robot.",
"config.imu.field.name": "Name",
"config.imu.field.frame": "Frame ID",
"config.imu.field.topic": "Topic",
"config.imu.field.source": "Source",
"config.imu.source.external": "External (ROS topic)",
"config.imu.source.lidarBuiltin": "LiDAR integrated",
"config.imu.source.onboard": "Onboard robot",
"config.imu.field.rate": "Rate (Hz)",
"config.imu.enabled": "Enable IMU",
"config.imu.add": "Add IMU",
"config.imu.placeholder.name": "Main IMU",
"config.imu.placeholder.frame": "imu_link",
"config.imu.placeholder.topic": "imu/data",
"config.imu.empty": "No IMU yet",
"config.imu.emptyHint": "Add an IMU using the form above.",
"config.imu.deleteConfirm": "Delete this IMU?",
"config.robot.title": "Robot model",
"config.robot.subtitle": "Differential kinematics — wheels, motors and velocity limits.",
"config.robot.model.diff": "Differential (2 wheels)",
"config.robot.model.bicycle": "Bicycle",
"config.canvas.title": "Layout on robot",
"config.canvas.viewHint": "Mouse wheel: zoom • Shift + drag: pan view",
"config.canvas.robotCenter": "Robot center:",
"config.canvas.selected": "Selected:",
"config.canvas.pose": "Pose:",
"config.pose.notSet": "pose not set",
"config.selected.lidar": "LiDAR: {name}",
"config.selected.imu": "IMU: {name}",
"config.motor.wheelRight": "Right wheel",
"config.motor.wheelLeft": "Left wheel",
"config.motor.wheelSteer": "Front wheel (steer)",
"config.motor.wheelDrive": "Rear wheel (drive)",
"config.motor.vendor": "Vendor",
"config.motor.model": "Model",
"config.motor.joint": "Joint (ROS)",
"config.motor.ratio": "Gear ratio",
"config.motor.invert": "Invert rotation",
"config.motor.invertSteer": "Invert",
"config.motor.custom": "Custom",
"config.motor.customMotor": "Custom motor",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — robot task list.",
"missions.create": "Create mission",
"missions.empty": "No missions yet. Click Create mission to start.",
"missions.queue.title": "Mission queue",
"missions.queue.subtitle": "Add missions via the queue icon — robot runs top to bottom.",
"missions.queue.cancel": "Cancel run",
"missions.queue.clear": "Clear queue",
"missions.queue.empty": "Queue empty. Click ▤ on a mission to add.",
"missions.editor.kicker": "Mission editor",
"missions.editor.unsaved": "Unsaved",
"missions.editor.saveAs": "Save as",
"missions.editor.save": "Save",
"missions.editor.flowHint": "Execute top to bottom. Drag ↔ to reorder. For Loop: drag actions inside.",
"missions.editor.emptyActions": "Pick an action from the menu above to start.",
"missions.editor.backAria": "Back to list",
"missions.editor.settingsAria": "Mission settings",
"missions.editor.addActionAria": "Add action",
"missions.queue.status.pending": "Pending",
"missions.queue.status.running": "Running",
"missions.queue.status.done": "Done",
"missions.queue.status.error": "Error",
"missions.queue.status.cancelled": "Cancelled",
"missions.queue.ready": "Ready",
"missions.queue.idleMessage": "Robot ready — queue empty or waiting for new mission.",
"missions.queue.moveUp": "Up",
"missions.queue.moveDown": "Down",
"missions.queue.addAria": "Add to mission queue",
"missions.deleteConfirm": "Delete mission «{name}»?",
"missions.queue.clearConfirm": "Clear pending missions in queue?",
"missions.queue.cancelConfirm": "Cancel running mission? (exits loop if looping)",
"missions.dialog.create.title": "Create mission",
"missions.dialog.create.name": "Mission name",
"missions.dialog.create.group": "Mission group",
"missions.dialog.create.groupNew": "Or new group",
"missions.dialog.create.desc": "Description",
"missions.dialog.create.namePlaceholder": "e.g. Go to charging station",
"missions.dialog.settings.title": "Mission settings",
"missions.dialog.settings.name": "Name",
"missions.dialog.settings.group": "Group",
"missions.dialog.settings.desc": "Description",
"missions.dialog.saveAs.title": "Save mission as",
"missions.dialog.saveAs.name": "New mission name",
"missions.dialog.saveAs.submit": "Save copy",
"missions.dialog.actionConfig.title": "Configure action",
"missions.dialog.queue.title": "Add to mission queue",
"missions.group.Move": "Move",
"missions.group.Logic": "Logic",
"missions.group.IO": "I/O",
"missions.group.Cart": "Cart",
"missions.group.Misc": "Misc",
"missions.action.move_to_position": "Go to position",
"missions.action.move_to_marker": "Go to marker",
"missions.action.adjust_localization": "Adjust localization",
"missions.action.wait": "Wait",
"missions.action.set_speed": "Set speed",
"missions.action.if": "If",
"missions.action.loop": "Loop",
"missions.action.break": "Break",
"missions.action.continue": "Continue",
"missions.action.pause": "Pause",
"missions.action.set_digital_output": "Set digital output",
"missions.action.wait_digital_input": "Wait for digital input",
"missions.action.set_plc_register": "Set PLC register",
"missions.action.pick_cart": "Pick cart",
"missions.action.drop_cart": "Drop cart",
"missions.action.user_log": "User log",
"missions.action.play_sound": "Play sound",
"missions.error.nameRequired": "Mission name cannot be empty.",
"missions.error.nameDuplicate": "Mission name already exists.",
"missions.error.nameEmpty": "Name cannot be empty.",
"missions.saveSuccess": "Mission saved.",
"missions.editor.discardConfirm": "Discard unsaved changes?",
"missions.queue.status.executing": "Running",
"missions.action.waitOnLevel": "Wait for ON level",
"integrations.modbus.title": "Modbus trigger",
"integrations.modbus.subtitle": "System → Triggers — coils 10012000 map to mission_id. Remote device sets coil (Modbus TCP :5502) → mission queued.",
"integrations.modbus.add": "Add trigger",
"integrations.modbus.empty": "No Modbus triggers yet.",
"integrations.modbus.coilsLabel": "Assigned coils (click to simulate rising edge)",
"integrations.rest.title": "REST API — MiR v2.0.0",
"integrations.rest.subtitle": "External systems POST missions to the queue via REST.",
"integrations.rest.baseUrl": "Base URL",
"integrations.rest.quickTest": "Quick test",
"integrations.rest.postQueue": "POST queue",
"integrations.fleet.title": "MiRFleet — Schedule missions",
"integrations.fleet.subtitle": "Priority, robot assignment, run ASAP or scheduled.",
"integrations.fleet.addSchedule": "Add schedule",
"integrations.fleet.empty": "No fleet schedules yet.",
"integrations.noMissions": "— No missions —",
"integrations.defaultRobot": "Main robot",
"integrations.fireTrigger": "Fire",
"integrations.coilsEmpty": "No coils assigned. Add a trigger above (10012000).",
"integrations.coilState": "coil state: {state}",
"integrations.confirm.deleteTrigger": "Delete this Modbus trigger?",
"integrations.confirm.deleteSchedule": "Delete this fleet schedule?",
"integrations.dialog.trigger.title": "Modbus trigger",
"integrations.dialog.trigger.name": "Trigger name",
"integrations.dialog.trigger.coil": "Coil ID",
"integrations.dialog.trigger.mission": "Mission",
"integrations.dialog.schedule.title": "MiRFleet schedule",
"integrations.dialog.schedule.name": "Schedule name",
"integrations.dialog.schedule.robot": "Robot",
"integrations.dialog.schedule.priority": "Priority",
"integrations.dialog.schedule.mode": "Mode",
"integrations.dialog.schedule.asap": "ASAP",
"integrations.dialog.schedule.scheduled": "Scheduled",
"integrations.dialog.schedule.startTime": "Start time",
"integrations.schedule.runNow": "Run now",
"monitoring.log.title": "System log",
"monitoring.log.subtitle": "Monitoring → System log — system log (coming soon).",
"monitoring.log.placeholder": "Monitoring will show robot logs, alerts and mission history here.",
"help.api.title": "API documentation",
"help.api.subtitle": "Help → API — MiR v2.0.0 REST reference for external integration.",
"help.api.body1": "See endpoint details under System → Integrations or /api/v2.0.0/ docs.",
"help.api.body2": "Reference Guide MiR rev 1.9: docs/Reference guide.pdf",
},
};
const LOCALE_META = {
vi: { flag: "🇻🇳", labelKey: "topbar.localeVi" },
en: { flag: "🇺🇸", labelKey: "topbar.localeEn" },
};
let locale = "vi";
function interpolate(str, vars) {
if (!vars) return str;
return String(str).replace(/\{(\w+)\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : `{${k}}`));
}
function t(key, vars) {
const raw = MESSAGES[locale]?.[key] ?? MESSAGES.en[key] ?? key;
return interpolate(raw, vars);
}
function applyDOM() {
document.querySelectorAll("[data-i18n]").forEach((node) => {
const key = node.dataset.i18n;
if (key) node.textContent = t(key);
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((node) => {
const key = node.dataset.i18nPlaceholder;
if (key) node.placeholder = t(key);
});
document.querySelectorAll("[data-i18n-title]").forEach((node) => {
const key = node.dataset.i18nTitle;
if (key) node.title = t(key);
});
document.querySelectorAll("[data-i18n-aria]").forEach((node) => {
const key = node.dataset.i18nAria;
if (key) node.setAttribute("aria-label", t(key));
});
document.querySelectorAll("option[data-i18n]").forEach((node) => {
const key = node.dataset.i18n;
if (key) node.textContent = t(key);
});
const titleKey = document.documentElement.dataset.i18nTitle;
if (titleKey) document.title = t(titleKey);
}
function syncTopbarLocaleUI() {
const meta = LOCALE_META[locale];
if (!meta) return;
const flagEl = document.getElementById("mirLocaleFlag");
const labelEl = document.getElementById("mirLocaleLabel");
if (flagEl) flagEl.textContent = meta.flag;
if (labelEl) labelEl.textContent = t(meta.labelKey);
}
function setLocale(next, opts = {}) {
locale = MESSAGES[next] ? next : "vi";
try {
localStorage.setItem("lm_locale", locale);
} catch {
/* ignore */
}
document.documentElement.lang = locale;
applyDOM();
syncTopbarLocaleUI();
if (!opts.silent) {
window.dispatchEvent(new CustomEvent("lm:locale-change", { detail: { locale } }));
}
}
function loadLocale() {
try {
const saved = localStorage.getItem("lm_locale");
if (saved && MESSAGES[saved]) locale = saved;
} catch {
/* ignore */
}
setLocale(locale, { silent: true });
window.dispatchEvent(new CustomEvent("lm:locale-change", { detail: { locale } }));
}
window.I18n = {
t,
getLocale: () => locale,
setLocale,
applyDOM,
loadLocale,
LOCALE_META,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", loadLocale);
} else {
loadLocale();
}
})();

View File

@@ -6,7 +6,7 @@
<title>LiDAR Manager</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<body class="auth-logged-out">
<div id="loginScreen" class="loginScreen">
<div class="loginFrame">
<header class="loginHeader">
@@ -14,10 +14,10 @@
<div class="loginHeaderRight">
<span class="loginHeaderPrompt">Chọn cách đăng nhập:</span>
<div class="loginTabs" role="tablist">
<button id="loginTabPassword" type="button" class="loginTab active" role="tab" aria-selected="true">
<button id="loginTabPassword" type="button" class="loginTab active" role="tab" aria-selected="true" data-i18n="login.tab.password">
Tên đăng nhập và mật khẩu
</button>
<button id="loginTabPin" type="button" class="loginTab" role="tab" aria-selected="false">
<button id="loginTabPin" type="button" class="loginTab" role="tab" aria-selected="false" data-i18n="login.tab.pin">
Mã PIN
</button>
</div>
@@ -27,26 +27,26 @@
<div class="loginCard">
<div id="loginPanelPassword" class="loginPanel">
<div id="loginHelpPassword" class="loginHelp">
<h2 class="loginHelpTitle">Đăng nhập bằng tên và mật khẩu</h2>
<p>Nhập tên đăng nhập và mật khẩu để truy cập robot.</p>
<p>Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.</p>
<p>Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.</p>
<h2 class="loginHelpTitle" data-i18n="login.password.title">Đăng nhập bằng tên và mật khẩu</h2>
<p data-i18n="login.password.help1">Nhập tên đăng nhập và mật khẩu để truy cập robot.</p>
<p data-i18n="login.password.help2">Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.</p>
<p data-i18n="login.password.help3">Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.</p>
</div>
<div class="loginForms">
<form id="loginForm" class="loginForm" action="#" method="post">
<form id="loginForm" class="loginForm" action="#" method="post" novalidate>
<label class="loginField">
<span class="loginFieldLabel">Tên đăng nhập:</span>
<input id="loginUsername" name="username" type="text" autocomplete="username" placeholder="Admin" required />
<span class="loginFieldLabel" data-i18n="login.field.username">Tên đăng nhập:</span>
<input id="loginUsername" name="username" type="text" autocomplete="username" placeholder="Admin" data-i18n-placeholder="login.placeholder.username" required />
</label>
<label class="loginField">
<span class="loginFieldLabel">Mật khẩu:</span>
<span class="loginFieldLabel" data-i18n="login.field.password">Mật khẩu:</span>
<input id="loginPasswordInput" name="password" type="password" autocomplete="current-password" placeholder="" required />
</label>
<button type="submit" class="loginSubmit" data-mode="password">
<svg class="loginSubmitIcon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M12.65 10A5.99 5.99 0 0 0 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6a5.99 5.99 0 0 0 5.65-4H17v2h3v-2h1v-3h-3V9h-1.35zM7 14a4 4 0 1 1 0-8 4 4 0 0 1 0 8z"/>
</svg>
<span class="loginSubmitLabel">Đăng nhập</span>
<span class="loginSubmitLabel" data-i18n="login.submit">Đăng nhập</span>
</button>
</form>
<p id="loginError" class="loginError" hidden></p>
@@ -56,12 +56,12 @@
<div id="loginPanelPin" class="loginPanel loginPanel--pin" hidden>
<div class="loginPinLeft">
<div class="loginHelp">
<h2 class="loginHelpTitle">Đăng nhập bằng mã PIN</h2>
<p>Người dùng được kích hoạt PIN có thể đăng nhập tại đây.</p>
<p>Nếu chưa có mã PIN 4 chữ số, vui lòng liên hệ quản trị viên robot.</p>
<p class="loginHelpNote">Không có mã PIN cấu hình sẵn — quản trị viên phải gán PIN trước.</p>
<h2 class="loginHelpTitle" data-i18n="login.pin.title">Đăng nhập bằng mã PIN</h2>
<p data-i18n="login.pin.help1">Người dùng được kích hoạt PIN có thể đăng nhập tại đây.</p>
<p data-i18n="login.pin.help2">Nếu chưa có mã PIN 4 chữ số, vui lòng liên hệ quản trị viên robot.</p>
<p class="loginHelpNote" data-i18n="login.pin.helpNote">Không có mã PIN cấu hình sẵn — quản trị viên phải gán PIN trước.</p>
</div>
<div class="loginPinBoxes" id="loginPinBoxes" role="group" aria-label="Mã PIN 4 chữ số">
<div class="loginPinBoxes" id="loginPinBoxes" role="group" aria-label="Mã PIN 4 chữ số" data-i18n-aria="login.pin.aria.group">
<div class="loginPinCell" data-idx="0"></div>
<div class="loginPinCell" data-idx="1"></div>
<div class="loginPinCell" data-idx="2"></div>
@@ -70,7 +70,7 @@
<input id="loginPin" type="hidden" value="" autocomplete="off" />
<p id="loginPinError" class="loginError loginPinError" hidden></p>
</div>
<div class="loginKeypad" id="loginKeypad" aria-label="Bàn phím số">
<div class="loginKeypad" id="loginKeypad" aria-label="Bàn phím số" data-i18n-aria="login.pin.aria.keypad">
<button type="button" class="loginKey" data-key="1">1</button>
<button type="button" class="loginKey" data-key="2">2</button>
<button type="button" class="loginKey" data-key="3">3</button>
@@ -81,7 +81,7 @@
<button type="button" class="loginKey" data-key="8">8</button>
<button type="button" class="loginKey" data-key="9">9</button>
<button type="button" class="loginKey loginKey--wide" data-key="0">0</button>
<button type="button" class="loginKey loginKey--back" data-key="back" aria-label="Xóa"></button>
<button type="button" class="loginKey loginKey--back" data-key="back" aria-label="Xóa" data-i18n-aria="login.pin.aria.backspace"></button>
</div>
</div>
</div>
@@ -89,67 +89,160 @@
</div>
<div class="shell auth-locked">
<aside class="sidebar">
<div class="brand">
<div class="brandIcon">R</div>
<div class="brandText">
<div class="brandTitle">PhenikaaX</div>
<div class="brandSub">RobotApp</div>
</div>
</div>
<aside class="mirNavShell" id="mirNavShell">
<nav class="mirNavRail" id="mirNavRail" aria-label="Main navigation" data-i18n-aria="nav.aria.main">
<button type="button" class="mirNavBackBtn" id="mirNavBackBtn" aria-label="Collapse menu" title="Collapse menu" data-i18n-aria="nav.collapse" data-i18n-title="nav.collapse">
<span aria-hidden="true">«</span>
</button>
<div class="navTitle">WORKSPACE</div>
<nav class="nav">
<a class="navItem active" href="#" data-page="dashboard" aria-current="page">
<span class="navDot"></span>
Dashboard
</a>
<a class="navItem" href="#" data-page="config">
<span class="navDot"></span>
Cấu hình
</a>
<div class="mirNavRailItems">
<button type="button" class="mirNavRailItem" data-module="dashboards">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M4 4h7v9H4V4zm9 0h7v5h-7V4zM4 15h7v5H4v-5zm9 4h7v-5h-7v5z" fill="currentColor"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.dashboards">Dashboards</span>
</button>
<button type="button" class="mirNavRailItem is-active" data-module="setup" aria-current="true">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.setup">Setup</span>
</button>
<button type="button" class="mirNavRailItem" data-module="monitoring">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M3 3v18h18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M7 14l4-4 3 3 5-6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.monitoring">Monitoring</span>
</button>
<button type="button" class="mirNavRailItem" data-module="system">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.system">System</span>
</button>
<button type="button" class="mirNavRailItem" data-module="help">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/>
<path d="M9.5 9a2.5 2.5 0 1 1 4.2 1.8c-.8.6-1.2 1.2-1.2 2.2M12 17h.01" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.help">Help</span>
</button>
</div>
<div class="mirNavRailFooter">
<button type="button" class="mirNavRailItem mirNavRailItem--logout" id="mirNavLogout">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.logout">Log out</span>
</button>
</div>
</nav>
<div class="navTitle">CÀI ĐẶT</div>
<nav class="nav">
<a class="navItem" href="#" data-page="missions">
<span class="navDot"></span>
Missions
</a>
<a class="navItem" href="#" data-page="integrations">
<span class="navDot"></span>
Tích hợp
</a>
</nav>
<div class="sidebarFooter">
<div class="statusBadge">
<span class="statusLed"></span>
<span id="status" class="statusText"></span>
<aside class="mirNavFlyout" id="mirNavFlyout">
<div class="mirNavFlyoutHeader">
<h2 class="mirNavFlyoutTitle" id="mirNavFlyoutTitle">Setup</h2>
</div>
</div>
<nav class="mirNavFlyoutList" id="mirNavFlyoutList" aria-label="Submenu" data-i18n-aria="nav.aria.submenu"></nav>
<div class="mirNavFlyoutFooter">
<span class="mirNavStatusLed" aria-hidden="true"></span>
<span id="status" class="mirNavStatusText"></span>
</div>
</aside>
</aside>
<div class="body">
<header class="topbar">
<div class="topbarTitle">
<div class="kicker">PhenikaaX Robotics</div>
<div class="pageTitle">Cấu Hình</div>
</div>
<div class="topbarActions">
<div class="userMenuWrap">
<button id="userMenuBtn" type="button" class="btn subtle userMenuBtn" aria-haspopup="true"></button>
<div id="userMenuPanel" class="userMenuPanel" hidden>
<div class="userMenuHeader">
<div id="userMenuName" class="userMenuName"></div>
<div id="userMenuGroup" class="userMenuGroup mutedNote"></div>
</div>
<button id="userMenuChangePasswordBtn" type="button" class="userMenuItem">Đổi mật khẩu</button>
<button id="userMenuSignOutBtn" type="button" class="userMenuItem userMenuItemDanger">Đăng xuất</button>
<header class="mirTopbar" id="mirTopbar">
<div class="mirTopbarInner">
<div class="mirTopbarLeft">
<div class="mirRobotId" id="mirRobotId" title="Robot" data-i18n-title="topbar.robotTitle">RobotApp</div>
<button type="button" class="mirPauseBtn" id="mirSegControl" aria-label="Start / Pause robot" title="Start / Pause robot" data-i18n-aria="topbar.controlAria" data-i18n-title="topbar.controlAria">
<svg class="mirPauseBtnIcon mirPauseBtnIcon--pause" id="mirControlIconPause" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<rect x="6" y="5" width="4.5" height="14" rx="1" fill="#f39c12"/>
<rect x="13.5" y="5" width="4.5" height="14" rx="1" fill="#f39c12"/>
</svg>
<svg class="mirPauseBtnIcon mirPauseBtnIcon--play" id="mirControlIconPlay" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" hidden>
<path d="M9 6.5v11l9-5.5-9-5.5z" fill="#7bed9f"/>
</svg>
</button>
<div class="mirMissionStrip" id="mirMissionStrip">
<span class="mirMissionMsg" id="mirMissionMsg"></span>
<span class="mirStatePill" id="mirControlPill">PAUSED</span>
</div>
</div>
<div class="mirTopbarRight">
<button type="button" class="mirSegment mirSegment--status" id="mirSegStatus" aria-haspopup="true" aria-expanded="false">
<svg class="mirSvgIcon mirStatusSvg is-ok" id="mirStatusIcon" viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M6 10.2l2.4 2.4L14 7.2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="mirSegmentLabel" id="mirStatusLabel" data-i18n="topbar.allOk">ALL OK</span>
<span class="mirCaret" aria-hidden="true"></span>
</button>
<div class="mirPanel mirPanel--status" id="mirStatusPanel" hidden>
<div class="mirPanelBody" id="mirStatusPanelBody"></div>
<div class="mirPanelFooter" id="mirStatusPanelFooter" hidden>
<button type="button" class="mirBtn mirBtn--reset" id="mirErrorResetBtn" data-i18n="topbar.reset">RESET</button>
</div>
</div>
<button type="button" class="mirSegment mirSegment--locale" id="mirSegLocale" aria-haspopup="true" aria-expanded="false">
<span class="mirFlag" id="mirLocaleFlag" aria-hidden="true">🇻🇳</span>
<span class="mirSegmentLabel" id="mirLocaleLabel">TIẾNG VIỆT</span>
<span class="mirCaret" aria-hidden="true"></span>
</button>
<div class="mirPanel mirPanel--locale" id="mirLocalePanel" hidden>
<button type="button" class="mirLocaleOption" data-locale="vi" data-i18n="topbar.localeOption.vi">🇻🇳 Tiếng Việt</button>
<button type="button" class="mirLocaleOption" data-locale="en" data-i18n="topbar.localeOption.en">🇺🇸 English</button>
</div>
<button type="button" class="mirSegment mirSegment--user" id="mirUserBtn" aria-haspopup="true" aria-expanded="false">
<svg class="mirSvgIcon mirUserSvg" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="8" r="4" fill="currentColor"/>
<path d="M5 20c0-4 3.5-6 7-6s7 2 7 6" fill="currentColor"/>
</svg>
<span class="mirSegmentLabel" id="mirUserLabel">USER</span>
<span class="mirCaret" aria-hidden="true"></span>
</button>
<div class="mirPanel mirPanel--user" id="mirUserPanel" hidden>
<div class="mirUserPanelHeader">
<div class="mirUserPanelAvatar" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="#64748b"><circle cx="12" cy="8" r="4"/><path d="M5 20c0-4 3.5-6 7-6s7 2 7 6"/></svg>
</div>
<div>
<div class="mirUserPanelRole" id="mirUserPanelRole"></div>
<div class="mirUserPanelName mutedNote" id="mirUserPanelName"></div>
</div>
</div>
<label class="mirProfileField">
<span data-i18n="topbar.displayName">Tên hiển thị</span>
<input id="mirProfileDisplayName" type="text" autocomplete="name" />
</label>
<button type="button" class="mirBtn mirBtn--primary" id="mirProfileSaveBtn" data-i18n="topbar.changeUserData">Đổi thông tin</button>
<button type="button" class="mirBtn mirBtn--primary subtle" id="mirUserChangePasswordBtn" data-i18n="topbar.changePassword">Đổi mật khẩu</button>
<button type="button" class="mirBtn mirBtn--danger" id="mirUserSignOutBtn" data-i18n="topbar.logout">Đăng xuất</button>
</div>
<button type="button" class="mirSegment mirSegment--joystick" id="mirSegJoystick" title="Engage joystick" data-i18n-title="topbar.joystickAria" aria-label="Joystick">
<svg class="mirSvgIcon mirJoystickSvg" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<rect x="7" y="10" width="10" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
<line x1="12" y1="10" x2="12" y2="4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
<circle cx="12" cy="3" r="2.2" fill="currentColor"/>
</svg>
</button>
<div class="mirSegment mirSegment--battery" id="mirSegBattery" title="Battery" data-i18n-title="topbar.batteryTitle">
<span class="mirBatteryIcon" id="mirBatteryIcon" aria-hidden="true">
<span class="mirBatteryLevel" id="mirBatteryLevel"></span>
</span>
<span class="mirSegmentLabel mirBatteryPct" id="mirBatteryLabel">—%</span>
</div>
</div>
<button id="refreshBtn" type="button" class="btn subtle">Tải lại</button>
<button id="saveLayoutBtn" class="btn primary" type="button">Lưu layout</button>
</div>
</header>
@@ -159,42 +252,42 @@
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Dashboard</div>
<div class="cardSub">Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.</div>
<div class="cardTitle" data-i18n="dashboard.title">Dashboard</div>
<div class="cardSub" data-i18n="dashboard.subtitle">Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.</div>
</div>
<div class="dashboardToolbar">
<button id="dashboardAddWidgetBtn" type="button" class="btn subtle">Thêm widget</button>
<button id="dashboardEditBtn" type="button" class="btn subtle">Sửa layout</button>
<button id="dashboardAddWidgetBtn" type="button" class="btn subtle" data-i18n="dashboard.addWidget">Thêm widget</button>
<button id="dashboardEditBtn" type="button" class="btn subtle" data-i18n="dashboard.editLayout">Sửa layout</button>
</div>
</div>
<div class="cardBody">
<div id="dashboardGrid" class="dashboardGrid"></div>
<p id="dashboardEmpty" class="mutedNote dashboardEmpty" hidden>Chưa có widget. Bấm «Thêm widget» để bắt đầu.</p>
<p id="dashboardEmpty" class="mutedNote dashboardEmpty" hidden data-i18n="dashboard.empty">Chưa có widget. Bấm «Thêm widget» để bắt đầu.</p>
</div>
</section>
<section class="card dashboardInfoCard">
<div class="cardHeader">
<div>
<div class="cardTitle">Hệ thống</div>
<div class="cardSub">Trạng thái backend và layout đang active.</div>
<div class="cardTitle" data-i18n="dashboard.system.title">Hệ thống</div>
<div class="cardSub" data-i18n="dashboard.system.subtitle">Trạng thái backend và layout đang active.</div>
</div>
</div>
<div class="cardBody dashboardInfoGrid">
<div class="row rowWide">
<label>Backend</label>
<label data-i18n="dashboard.system.backend">Backend</label>
<div id="overviewBackend" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label>Layout</label>
<label data-i18n="dashboard.system.layout">Layout</label>
<div id="overviewActiveLayout" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label>Model robot</label>
<label data-i18n="dashboard.system.model">Model robot</label>
<div id="overviewActiveModel" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label>LiDAR / IMU</label>
<label data-i18n="dashboard.system.sensors">LiDAR / IMU</label>
<div id="overviewActiveSensors" class="mutedNote"></div>
</div>
</div>
@@ -207,28 +300,32 @@
<section class="card" id="layoutManagerCard">
<div class="cardHeader">
<div>
<div class="cardTitle">Quản lý layout</div>
<div class="cardSub">Nhiều cấu hình robot — mỗi layout có LiDAR và model riêng.</div>
<div class="cardTitle" data-i18n="config.layout.title">Quản lý layout</div>
<div class="cardSub" data-i18n="config.layout.subtitle">Nhiều cấu hình robot — mỗi layout có LiDAR và model riêng.</div>
</div>
<div class="configPageActions">
<button id="refreshBtn" type="button" class="btn subtle" data-i18n="common.reload">Tải lại</button>
<button id="saveLayoutBtn" type="button" class="btn primary" data-i18n="config.layout.save">Lưu layout</button>
</div>
</div>
<div class="cardBody">
<div class="row rowWide">
<label>Layout hiện tại</label>
<label data-i18n="config.layout.current">Layout hiện tại</label>
<select id="layoutSelect"></select>
</div>
<div class="row rowWide">
<label>Tên layout mới</label>
<input id="layoutNewName" type="text" placeholder="VD: AGV kho A" />
<label data-i18n="config.layout.newName">Tên layout mới</label>
<input id="layoutNewName" type="text" placeholder="VD: AGV kho A" data-i18n-placeholder="config.layout.newNamePlaceholder" />
</div>
<div class="checkRow">
<label>
<input id="layoutCloneCurrent" type="checkbox" />
Sao chép từ layout đang mở
<span data-i18n="config.layout.cloneCurrent">Sao chép từ layout đang mở</span>
</label>
</div>
<div class="layoutManagerActions">
<button id="layoutCreateBtn" type="button" class="btn subtle">Tạo layout</button>
<button id="layoutDeleteBtn" type="button" class="btn subtle danger">Xóa</button>
<button id="layoutCreateBtn" type="button" class="btn subtle" data-i18n="config.layout.create">Tạo layout</button>
<button id="layoutDeleteBtn" type="button" class="btn subtle danger" data-i18n="common.delete">Xóa</button>
</div>
<p id="layoutActiveHint" class="mutedNote"></p>
</div>
@@ -244,8 +341,8 @@
aria-controls="lidarListCardBody"
>
<div>
<div class="cardTitle">LiDARs</div>
<div class="cardSub">Đăng ký tên, IP, port và chỉnh pose theo robot frame.</div>
<div class="cardTitle" data-i18n="config.lidar.title">LiDARs</div>
<div class="cardSub" data-i18n="config.lidar.subtitle">Đăng ký tên, IP, port và chỉnh pose theo robot frame.</div>
</div>
<span class="cardChevron" aria-hidden="true"></span>
</div>
@@ -253,19 +350,19 @@
<div class="cardBody" id="lidarListCardBody">
<form id="lidarForm" class="form">
<div class="row">
<label>Tên</label>
<input id="name" placeholder="Lidar trước" required />
<label data-i18n="config.lidar.field.name">Tên</label>
<input id="name" placeholder="Lidar trước" data-i18n-placeholder="config.lidar.placeholder.name" required />
</div>
<div class="row">
<label>IP</label>
<input id="ip" placeholder="192.168.0.10" required />
<label data-i18n="config.lidar.field.ip">IP</label>
<input id="ip" placeholder="192.168.0.10" data-i18n-placeholder="config.lidar.placeholder.ip" required />
</div>
<div class="row">
<label>Port</label>
<label data-i18n="config.lidar.field.port">Port</label>
<input id="port" type="number" min="1" max="65535" value="2112" required />
</div>
<div class="actions">
<button id="addLidarBtn" class="btn primary" type="button">Thêm</button>
<button id="addLidarBtn" class="btn primary" type="button" data-i18n="common.add">Thêm</button>
</div>
<p id="lidarFormHint" class="formHint" hidden></p>
</form>
@@ -284,8 +381,8 @@
aria-controls="imuListCardBody"
>
<div>
<div class="cardTitle">IMU</div>
<div class="cardSub">Cảm biến quán tính — frame, topic và pose trên robot.</div>
<div class="cardTitle" data-i18n="config.imu.title">IMU</div>
<div class="cardSub" data-i18n="config.imu.subtitle">Cảm biến quán tính — frame, topic và pose trên robot.</div>
</div>
<span class="cardChevron" aria-hidden="true"></span>
</div>
@@ -293,37 +390,37 @@
<div class="cardBody" id="imuListCardBody">
<form id="imuForm" class="form">
<div class="row">
<label>Tên</label>
<input id="imuName" placeholder="IMU chính" required />
<label data-i18n="config.imu.field.name">Tên</label>
<input id="imuName" placeholder="IMU chính" data-i18n-placeholder="config.imu.placeholder.name" required />
</div>
<div class="row">
<label>Frame ID</label>
<input id="imuFrameId" placeholder="imu_link" required />
<label data-i18n="config.imu.field.frame">Frame ID</label>
<input id="imuFrameId" placeholder="imu_link" data-i18n-placeholder="config.imu.placeholder.frame" required />
</div>
<div class="row">
<label>Topic</label>
<input id="imuTopic" placeholder="imu/data" value="imu/data" required />
<label data-i18n="config.imu.field.topic">Topic</label>
<input id="imuTopic" placeholder="imu/data" data-i18n-placeholder="config.imu.placeholder.topic" value="imu/data" required />
</div>
<div class="row rowWide">
<label>Nguồn</label>
<label data-i18n="config.imu.field.source">Nguồn</label>
<select id="imuSource">
<option value="external">Ngoài (ROS topic)</option>
<option value="lidar_builtin">Tích hợp LiDAR</option>
<option value="onboard">Onboard robot</option>
<option value="external" data-i18n="config.imu.source.external">Ngoài (ROS topic)</option>
<option value="lidar_builtin" data-i18n="config.imu.source.lidarBuiltin">Tích hợp LiDAR</option>
<option value="onboard" data-i18n="config.imu.source.onboard">Onboard robot</option>
</select>
</div>
<div class="row">
<label>Tần số (Hz)</label>
<label data-i18n="config.imu.field.rate">Tần số (Hz)</label>
<input id="imuRateHz" type="number" min="1" max="1000" step="1" value="100" />
</div>
<div class="checkRow">
<label>
<input id="imuEnabled" type="checkbox" checked />
Bật IMU
<span data-i18n="config.imu.enabled">Bật IMU</span>
</label>
</div>
<div class="actions">
<button id="addImuBtn" class="btn primary" type="button">Thêm IMU</button>
<button id="addImuBtn" class="btn primary" type="button" data-i18n="config.imu.add">Thêm IMU</button>
</div>
<p id="imuFormHint" class="formHint" hidden></p>
</form>
@@ -342,7 +439,7 @@
aria-controls="robotModelCardBody"
>
<div>
<div class="cardTitle">Model robot</div>
<div class="cardTitle" data-i18n="config.robot.title">Model robot</div>
<div class="cardSub">Kinematic differential — bánh, động cơ và giới hạn vận tốc.</div>
</div>
<span class="cardChevron" aria-hidden="true"></span>
@@ -622,13 +719,13 @@
<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 class="cardTitle" data-i18n="missions.title">Missions</div>
<div class="cardSub" data-i18n="missions.subtitle">Setup → Missions — danh sách nhiệm vụ robot.</div>
</div>
<button id="missionCreateOpenBtn" type="button" class="btn primary">Create mission</button>
<button id="missionCreateOpenBtn" type="button" class="btn primary" data-i18n="missions.create">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="missionListEmpty" class="mutedNote" hidden data-i18n="missions.empty">Chưa có mission. Bấm Create mission để bắt đầu.</div>
<div id="missionList" class="missionList"></div>
</div>
</section>
@@ -636,17 +733,17 @@
<section class="card" id="missionQueueCard">
<div class="cardHeader">
<div>
<div class="cardTitle">Mission queue</div>
<div class="cardSub">Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.</div>
<div class="cardTitle" data-i18n="missions.queue.title">Mission queue</div>
<div class="cardSub" data-i18n="missions.queue.subtitle">Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.</div>
</div>
<div class="missionQueueCardActions">
<button id="missionQueueCancelBtn" type="button" class="btn subtle danger" title="Hủy mission đang chạy">Hủy chạy</button>
<button id="missionQueueClearBtn" type="button" class="btn subtle danger">Xóa queue</button>
<button id="missionQueueCancelBtn" type="button" class="btn subtle danger" data-i18n="missions.queue.cancel">Hủy chạy</button>
<button id="missionQueueClearBtn" type="button" class="btn subtle danger" data-i18n="missions.queue.clear">Xóa queue</button>
</div>
</div>
<div class="cardBody">
<div id="missionQueueRunner" class="missionQueueRunner mutedNote"></div>
<div id="missionQueueEmpty" class="mutedNote">Queue trống. Bấm <span class="mono"></span> trên mission để thêm.</div>
<div id="missionQueueEmpty" class="mutedNote" data-i18n="missions.queue.empty">Queue trống. Bấm ▤ trên mission để thêm.</div>
<div id="missionQueueList" class="missionQueueList"></div>
</div>
</section>
@@ -656,31 +753,31 @@
<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>
<button id="missionEditorBackBtn" type="button" class="btn subtle missionBackBtn" data-i18n-aria="missions.editor.backAria"></button>
<div>
<div class="missionEditorKicker">Mission editor</div>
<div class="missionEditorKicker" data-i18n="missions.editor.kicker">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>
<button id="missionSettingsBtn" type="button" class="iconBtn" data-i18n-aria="missions.editor.settingsAria" data-i18n-title="missions.editor.settingsAria"></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>
<span id="missionEditorDirty" class="missionDirtyBadge" hidden data-i18n="missions.editor.unsaved">Chưa lưu</span>
<button id="missionSaveAsBtn" type="button" class="btn subtle" data-i18n="missions.editor.saveAs">Save as</button>
<button id="missionSaveBtn" type="button" class="btn primary" data-i18n="missions.editor.save">Save</button>
</div>
</div>
<div class="missionActionBar" id="missionActionBar" role="toolbar" aria-label="Thêm action">
<div class="missionActionBar" id="missionActionBar" role="toolbar" data-i18n-aria="missions.editor.addActionAria">
<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>
<p class="missionFlowHint" data-i18n="missions.editor.flowHint">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 id="missionActionListEmpty" class="missionActionListEmpty mutedNote" data-i18n="missions.editor.emptyActions">Chọn action từ menu phía trên để bắt đầu.</div>
</div>
</section>
</div>
@@ -691,16 +788,16 @@
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Modbus trigger</div>
<div class="cardSub">System → Triggers — coil 10012000 gắn mission_id. Thiết bị remote bật coil (Modbus TCP :5502) → mission vào queue.</div>
<div class="cardTitle" data-i18n="integrations.modbus.title">Modbus trigger</div>
<div class="cardSub" data-i18n="integrations.modbus.subtitle">System → Triggers — coil 10012000 gắn mission_id. Thiết bị remote bật coil (Modbus TCP :5502) → mission vào queue.</div>
</div>
<button id="integrationAddTriggerBtn" type="button" class="btn primary">Thêm trigger</button>
<button id="integrationAddTriggerBtn" type="button" class="btn primary" data-i18n="integrations.modbus.add">Thêm trigger</button>
</div>
<div class="cardBody">
<div id="integrationTriggerEmpty" class="mutedNote">Chưa có trigger Modbus.</div>
<div id="integrationTriggerEmpty" class="mutedNote" data-i18n="integrations.modbus.empty">Chưa có trigger Modbus.</div>
<div id="integrationTriggerList" class="missionList"></div>
<div class="integrationCoilSection">
<div class="integrationSectionLabel">Coil đã gán (bấm để mô phỏng rising edge)</div>
<div class="integrationSectionLabel" data-i18n="integrations.modbus.coilsLabel">Coil đã gán (bấm để mô phỏng rising edge)</div>
<div id="integrationCoilGrid" class="integrationCoilGrid"></div>
</div>
</div>
@@ -709,13 +806,13 @@
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">REST API — MiR v2.0.0</div>
<div class="cardSub">Hệ thống bên ngoài POST mission vào queue qua REST.</div>
<div class="cardTitle" data-i18n="integrations.rest.title">REST API — MiR v2.0.0</div>
<div class="cardSub" data-i18n="integrations.rest.subtitle">Hệ thống bên ngoài POST mission vào queue qua REST.</div>
</div>
</div>
<div class="cardBody integrationApiBody">
<div class="row rowWide">
<label>Base URL</label>
<label data-i18n="integrations.rest.baseUrl">Base URL</label>
<div id="integrationApiBaseUrl" class="mono integrationCode"></div>
</div>
<div class="integrationApiBlock">
@@ -731,10 +828,10 @@ GET /api/v2.0.0/missions
GET /api/v2.0.0/status</pre>
</div>
<div class="row rowWide integrationTestRow">
<label for="integrationRestMission">Thử nhanh</label>
<label for="integrationRestMission" data-i18n="integrations.rest.quickTest">Thử nhanh</label>
<div class="integrationTestActions">
<select id="integrationRestMission"></select>
<button id="integrationRestTestBtn" type="button" class="btn subtle">POST queue</button>
<button id="integrationRestTestBtn" type="button" class="btn subtle" data-i18n="integrations.rest.postQueue">POST queue</button>
</div>
</div>
</div>
@@ -743,29 +840,58 @@ GET /api/v2.0.0/status</pre>
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">MiRFleet — Lên lịch mission</div>
<div class="cardSub">Ưu tiên, gán robot, chạy ASAP hoặc theo thời gian.</div>
<div class="cardTitle" data-i18n="integrations.fleet.title">MiRFleet — Lên lịch mission</div>
<div class="cardSub" data-i18n="integrations.fleet.subtitle">Ưu tiên, gán robot, chạy ASAP hoặc theo thời gian.</div>
</div>
<div class="integrationHeaderActions">
<button id="integrationRefreshBtn" type="button" class="btn subtle">Tải lại</button>
<button id="integrationAddScheduleBtn" type="button" class="btn primary">Thêm lịch</button>
<button id="integrationRefreshBtn" type="button" class="btn subtle" data-i18n="common.reload">Tải lại</button>
<button id="integrationAddScheduleBtn" type="button" class="btn primary" data-i18n="integrations.fleet.addSchedule">Thêm lịch</button>
</div>
</div>
<div class="cardBody">
<div id="integrationScheduleEmpty" class="mutedNote">Chưa có lịch fleet.</div>
<div id="integrationScheduleEmpty" class="mutedNote" data-i18n="integrations.fleet.empty">Chưa có lịch fleet.</div>
<div id="integrationScheduleList" class="missionList"></div>
</div>
</section>
</div>
</div>
<div class="page" id="pageMonitoring" data-page-content="monitoring" hidden>
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle" data-i18n="monitoring.log.title">System log</div>
<div class="cardSub" data-i18n="monitoring.log.subtitle">Monitoring → System log — nhật ký hệ thống (đang phát triển).</div>
</div>
</div>
<div class="cardBody">
<p class="mutedNote" data-i18n="monitoring.log.placeholder">Tính năng monitoring sẽ hiển thị log robot, cảnh báo và lịch sử mission tại đây.</p>
</div>
</section>
</div>
<div class="page" id="pageHelp" data-page-content="help" hidden>
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle" data-i18n="help.api.title">API documentation</div>
<div class="cardSub" data-i18n="help.api.subtitle">Help → API — tham chiếu REST MiR v2.0.0 cho tích hợp bên ngoài.</div>
</div>
</div>
<div class="cardBody">
<p class="mutedNote" data-i18n="help.api.body1">Xem chi tiết endpoint tại System → Tích hợp hoặc tài liệu /api/v2.0.0/.</p>
<p class="mutedNote" data-i18n="help.api.body2">Reference Guide MiR rev 1.9: docs/Reference guide.pdf</p>
</div>
</section>
</div>
<div id="configSplitter" class="splitter" role="separator" aria-orientation="vertical" tabindex="0"></div>
<div class="contentRight" id="contentRight">
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Bố trí trên robot</div>
<div class="cardTitle" data-i18n="config.canvas.title">Bố trí trên robot</div>
</div>
</div>
@@ -774,11 +900,11 @@ GET /api/v2.0.0/status</pre>
<canvas id="canvas"></canvas>
</div>
<div class="metaBar">
<div class="viewHint">Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn</div>
<div class="viewHint" data-i18n="config.canvas.viewHint">Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn</div>
<div id="robotDiffSummary" class="robotDiffSummary"></div>
<div>Robot center: <span id="robotCenterText"></span></div>
<div>Selected: <span id="selectedText">none</span></div>
<div>Pose: <span id="selectedRelText"></span></div>
<div><span data-i18n="config.canvas.robotCenter">Robot center:</span> <span id="robotCenterText"></span></div>
<div><span data-i18n="config.canvas.selected">Selected:</span> <span id="selectedText" data-i18n="common.none">none</span></div>
<div><span data-i18n="config.canvas.pose">Pose:</span> <span id="selectedRelText"></span></div>
</div>
</div>
</section>
@@ -790,30 +916,30 @@ GET /api/v2.0.0/status</pre>
<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>
<h3 data-i18n="missions.dialog.create.title">Create mission</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionCreateDialog" aria-label="Đóng" data-i18n-aria="common.close">×</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" />
<label for="missionCreateName" data-i18n="missions.dialog.create.name">Tên mission</label>
<input id="missionCreateName" type="text" required placeholder="VD: Go to charging station" data-i18n-placeholder="missions.dialog.create.namePlaceholder" />
</div>
<div class="row rowWide">
<label for="missionCreateGroup">Nhóm mission</label>
<label for="missionCreateGroup" data-i18n="missions.dialog.create.group">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" />
<label for="missionCreateGroupNew" data-i18n="missions.dialog.create.groupNew">Hoặc nhóm mới</label>
<input id="missionCreateGroupNew" type="text" placeholder="Tùy chọn" data-i18n-placeholder="common.optional" />
</div>
<div class="row rowWide">
<label for="missionCreateDesc">Mô tả</label>
<textarea id="missionCreateDesc" rows="2" placeholder="Tùy chọn"></textarea>
<label for="missionCreateDesc" data-i18n="missions.dialog.create.desc">Mô tả</label>
<textarea id="missionCreateDesc" rows="2" placeholder="Tùy chọn" data-i18n-placeholder="common.optional"></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>
<button type="button" class="btn subtle" data-close-dialog="missionCreateDialog" data-i18n="common.cancel">Hủy</button>
<button type="submit" class="btn primary" data-i18n="missions.create">Create mission</button>
</div>
</form>
</dialog>
@@ -821,26 +947,26 @@ GET /api/v2.0.0/status</pre>
<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>
<h3 data-i18n="missions.dialog.settings.title">Cài đặt mission</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSettingsDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="missionSettingsName">Tên</label>
<label for="missionSettingsName" data-i18n="missions.dialog.settings.name">Tên</label>
<input id="missionSettingsName" type="text" required />
</div>
<div class="row rowWide">
<label for="missionSettingsGroup">Nhóm</label>
<label for="missionSettingsGroup" data-i18n="missions.dialog.settings.group">Nhóm</label>
<select id="missionSettingsGroup"></select>
</div>
<div class="row rowWide">
<label for="missionSettingsDesc">Mô tả</label>
<label for="missionSettingsDesc" data-i18n="missions.dialog.settings.desc">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>
<button type="submit" class="btn primary" data-i18n="common.apply">Áp dụng</button>
</div>
</form>
</dialog>
@@ -848,18 +974,18 @@ GET /api/v2.0.0/status</pre>
<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>
<h3 data-i18n="missions.dialog.saveAs.title">Save mission as</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSaveAsDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="missionSaveAsName">Tên mission mới</label>
<label for="missionSaveAsName" data-i18n="missions.dialog.saveAs.name">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>
<button type="submit" class="btn primary" data-i18n="missions.dialog.saveAs.submit">Lưu bản sao</button>
</div>
</form>
</dialog>
@@ -867,13 +993,13 @@ GET /api/v2.0.0/status</pre>
<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>
<h3 id="missionActionConfigTitle" data-i18n="missions.dialog.actionConfig.title">Cấu hình action</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionActionConfigDialog" aria-label="Đóng" data-i18n-aria="common.close">×</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>
<button type="submit" class="btn primary" data-i18n="common.apply">Áp dụng</button>
</div>
</form>
</dialog>
@@ -882,7 +1008,7 @@ GET /api/v2.0.0/status</pre>
<form id="missionQueueForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Thêm vào mission queue</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionQueueDialog" aria-label="Đóng">×</button>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionQueueDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<p id="missionQueueDialogMission" class="mutedNote"></p>
@@ -902,7 +1028,7 @@ GET /api/v2.0.0/status</pre>
<form id="dashboardAddWidgetForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Thêm widget</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardAddWidgetDialog" aria-label="Đóng">×</button>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardAddWidgetDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
@@ -927,7 +1053,7 @@ GET /api/v2.0.0/status</pre>
<form id="dashboardEditWidgetForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Cấu hình widget</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardEditWidgetDialog" aria-label="Đóng">×</button>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardEditWidgetDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<input type="hidden" id="dashboardEditWidgetId" />
@@ -949,7 +1075,7 @@ GET /api/v2.0.0/status</pre>
<form id="integrationAddTriggerForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Modbus trigger</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddTriggerDialog" aria-label="Đóng">×</button>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddTriggerDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
@@ -976,7 +1102,7 @@ GET /api/v2.0.0/status</pre>
<form id="integrationAddScheduleForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Lịch MiRFleet</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddScheduleDialog" aria-label="Đóng">×</button>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddScheduleDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
@@ -1017,33 +1143,61 @@ GET /api/v2.0.0/status</pre>
<dialog id="changePasswordDialog" class="missionDialog">
<form id="changePasswordForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Đổi mật khẩu</h3>
<button type="button" class="iconBtn missionDialogClose" onclick="document.getElementById('changePasswordDialog').close()" aria-label="Đóng">×</button>
<h3 data-i18n="auth.changePassword.title">Đổi mật khẩu</h3>
<button type="button" class="iconBtn missionDialogClose" onclick="document.getElementById('changePasswordDialog').close()" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="changePasswordCurrent">Mật khẩu hiện tại</label>
<label for="changePasswordCurrent" data-i18n="auth.changePassword.current">Mật khẩu hiện tại</label>
<input id="changePasswordCurrent" type="password" autocomplete="current-password" required />
</div>
<div class="row rowWide">
<label for="changePasswordNew">Mật khẩu mới</label>
<label for="changePasswordNew" data-i18n="auth.changePassword.new">Mật khẩu mới</label>
<input id="changePasswordNew" type="password" autocomplete="new-password" required minlength="4" />
</div>
<div class="row rowWide">
<label for="changePasswordConfirm">Xác nhận mật khẩu mới</label>
<label for="changePasswordConfirm" data-i18n="auth.changePassword.confirm">Xác nhận mật khẩu mới</label>
<input id="changePasswordConfirm" type="password" autocomplete="new-password" required minlength="4" />
</div>
<p id="changePasswordError" class="loginError"></p>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" onclick="document.getElementById('changePasswordDialog').close()">Hủy</button>
<button type="button" class="btn subtle" onclick="document.getElementById('changePasswordDialog').close()" data-i18n="common.cancel">Hủy</button>
<button type="submit" class="btn primary">Lưu</button>
</div>
</form>
</dialog>
<div id="joystickOverlay" class="joystickOverlay" hidden>
<div class="joystickOverlayCard">
<div class="joystickOverlayHeader">
<strong data-i18n="topbar.joystickTitle">Điều khiển tay (Joystick)</strong>
<span class="mutedNote" id="joystickSpeedLabel">fast</span>
</div>
<div class="joystickPadWrap">
<div class="joystickPad" id="joystickPad">
<div class="joystickStick" id="joystickStick"></div>
</div>
</div>
<div class="joystickOverlayActions">
<label class="joystickSpeedSelect">
<span data-i18n="topbar.joystickSpeed">Tốc độ</span>
<select id="joystickSpeedSelect">
<option value="slow" data-i18n="topbar.joystickSpeed.slow">Slow</option>
<option value="medium" data-i18n="topbar.joystickSpeed.medium">Medium</option>
<option value="fast" data-i18n="topbar.joystickSpeed.fast" selected>Fast</option>
</select>
</label>
<button type="button" class="mirBtn mirBtn--danger" id="joystickDisengageBtn" data-i18n="topbar.joystickOff">Tắt joystick</button>
</div>
</div>
</div>
<script src="/i18n.js"></script>
<script src="/auth.js"></script>
<script src="/nav.js"></script>
<script src="/missions.js"></script>
<script src="/topbar.js"></script>
<script src="/dashboard.js"></script>
<script src="/integrations.js"></script>
<script src="/app.js"></script>

View File

@@ -3,6 +3,7 @@
const COIL_MAX = 2000;
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const triggerListEl = el("integrationTriggerList");
const triggerEmptyEl = el("integrationTriggerEmpty");
const coilGridEl = el("integrationCoilGrid");
@@ -72,7 +73,7 @@
const data = await apiJson("/api/fleet/robots");
store.robots = Array.isArray(data) ? data : [];
} catch {
store.robots = [{ id: "default", name: "Robot chính" }];
store.robots = [{ id: "default", name: t("integrations.defaultRobot") }];
}
}
@@ -96,7 +97,7 @@
if (!store.missions.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "— Chưa có mission —";
opt.textContent = t("integrations.noMissions");
selectEl.appendChild(opt);
return;
}
@@ -122,7 +123,7 @@
if (!store.robots.length) {
const opt = document.createElement("option");
opt.value = "default";
opt.textContent = "Robot chính";
opt.textContent = t("integrations.defaultRobot");
opt.selected = selected === "default";
selectEl.appendChild(opt);
}
@@ -133,24 +134,24 @@
triggerListEl.innerHTML = "";
if (triggerEmptyEl) triggerEmptyEl.hidden = store.triggers.length > 0;
store.triggers.forEach((t) => {
store.triggers.forEach((trigger) => {
const row = document.createElement("div");
row.className = "missionListItem integrationRow";
const coil = t.coil_id;
const coil = trigger.coil_id;
const on = store.coils[String(coil)] === true;
row.innerHTML = `
<div>
<div class="missionListItemTitle">${escapeHtml(t.name)}</div>
<div class="missionListItemTitle">${escapeHtml(trigger.name)}</div>
<div class="missionListItemMeta">
Coil <span class="mono">${coil}</span>
${escapeHtml(missionName(t.mission_id))}
· ${t.enabled === false ? "Tắt" : "Bật"}
· coil hiện tại: <span class="mono">${on ? "ON" : "OFF"}</span>
${escapeHtml(missionName(trigger.mission_id))}
· ${trigger.enabled === false ? t("common.disabled") : t("common.enabled")}
· ${t("integrations.coilState", { state: on ? "ON" : "OFF" })}
</div>
</div>
<div class="missionListItemActions">
<button type="button" class="btn subtle" data-fire-coil="${coil}">Kích hoạt</button>
<button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(t.id)}">Xóa</button>
<button type="button" class="btn subtle" data-fire-coil="${coil}">${t("integrations.fireTrigger")}</button>
<button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(trigger.id)}">${t("common.delete")}</button>
</div>`;
triggerListEl.appendChild(row);
});
@@ -160,10 +161,10 @@
if (!coilGridEl) return;
const assigned = new Map(store.triggers.map((t) => [t.coil_id, t]));
const chips = [];
assigned.forEach((t, coilId) => {
assigned.forEach((trigger, coilId) => {
const on = store.coils[String(coilId)] === true;
chips.push(
`<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(t.name)}">
`<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(trigger.name)}">
${coilId}
</button>`
);
@@ -171,11 +172,11 @@
coilGridEl.innerHTML =
chips.length > 0
? chips.join("")
: `<span class="mutedNote">Chưa gán coil. Thêm trigger bên trên (10012000).</span>`;
: `<span class="mutedNote">${t("integrations.coilsEmpty")}</span>`;
}
function formatScheduleTime(s) {
if (!s.start_at) return s.start_mode === "scheduled" ? "—" : "Ngay (asap)";
if (!s.start_at) return s.start_mode === "scheduled" ? "—" : t("integrations.dialog.schedule.asap");
try {
return new Date(s.start_at).toLocaleString("vi-VN");
} catch {
@@ -204,7 +205,7 @@
</div>
</div>
<div class="missionListItemActions">
<button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">Chạy ngay</button>
<button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">${t("integrations.schedule.runNow")}</button>
<button type="button" class="btn subtle danger" data-delete-schedule="${escapeHtml(s.id)}">Xóa</button>
</div>`;
scheduleListEl.appendChild(row);
@@ -316,7 +317,7 @@
}
async function deleteTrigger(id) {
if (!confirm("Xóa trigger Modbus này?")) return;
if (!confirm(t("integrations.confirm.deleteTrigger"))) return;
try {
await apiJson(`/api/triggers/${id}`, { method: "DELETE" });
await refreshAll();
@@ -326,7 +327,7 @@
}
async function deleteSchedule(id) {
if (!confirm("Xóa lịch fleet này?")) return;
if (!confirm(t("integrations.confirm.deleteSchedule"))) return;
try {
await apiJson(`/api/fleet/schedules/${id}`, { method: "DELETE" });
await refreshAll();
@@ -446,6 +447,12 @@
function boot() {
init();
}
window.addEventListener("lm:locale-change", () => {
renderTriggers();
renderCoilGrid();
renderSchedules();
});
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
})();

View File

@@ -39,6 +39,7 @@
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const missionListEl = el("missionList");
const missionListEmptyEl = el("missionListEmpty");
@@ -87,10 +88,12 @@
configListPath: "root",
queue: [],
runner: { state: "idle", message: "" },
queuePollTimer: null,
pendingQueueMissionId: null,
};
let queuePollRefs = 0;
let queuePollTimer = null;
function newId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
return `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -134,9 +137,9 @@
function actionMeta(type) {
for (const items of Object.values(ACTION_GROUPS)) {
const hit = items.find((a) => a.type === type);
if (hit) return hit;
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
}
return { type, label: type };
return { type, label: t(`missions.action.${type}`) || type };
}
function createAction(type, overrides = {}) {
@@ -334,7 +337,7 @@
if (action.id === actionId) return { action, list, index: i, path, parent };
if (Array.isArray(action.children)) {
const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
if (hit) return hit;
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
}
}
return null;
@@ -474,13 +477,14 @@
function queueStatusLabel(status) {
const map = {
pending: "Chờ",
executing: "Đang chạy",
completed: "Xong",
failed: "Lỗi",
cancelled: "Đã hủy",
pending: "missions.queue.status.pending",
executing: "missions.queue.status.executing",
completed: "missions.queue.status.done",
failed: "missions.queue.status.error",
cancelled: "missions.queue.status.cancelled",
};
return map[status] || status;
const key = map[status];
return key ? t(key) : status;
}
async function refreshQueue() {
@@ -493,7 +497,7 @@
notifyQueueUpdate();
} catch (e) {
if (String(e.message || "").includes("not authenticated")) return;
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`;
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `${t("common.error", { msg: e.message })}`;
}
}
@@ -534,8 +538,8 @@
? `${store.runner.message}${action}`
: st === "idle"
? compact
? "Sẵn sàng"
: "Robot sẵn sàng — queue trống hoặc chờ mission mới."
? t("missions.queue.ready")
: t("missions.queue.idleMessage")
: "—";
}
@@ -552,12 +556,12 @@
${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""}
</div>
<div class="missionQueueWidgetActions">
${entry.status === "pending" ? `<button type="button" class="iconBtn danger" data-queue-remove="${entry.id}" title="Xóa">×</button>` : `<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>`}
${entry.status === "pending" ? `<button type="button" class="iconBtn danger" data-queue-remove="${entry.id}" title="" data-i18n-title="common.delete">×</button>` : `<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>`}
</div>`
: `
<div class="missionQueueOrder">
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="Lên" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="Xuống" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button>
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="${t("missions.queue.moveUp")}" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="${t("missions.queue.moveDown")}" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button>
</div>
<div>
<div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div>
@@ -566,7 +570,7 @@
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;">
<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>
${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">Xóa</button>` : ""}
${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">${t("common.delete")}</button>` : ""}
</div>`;
row.querySelector("[data-queue-up]")?.addEventListener("click", () => moveQueueItem(entry.id, -1));
@@ -617,7 +621,7 @@
}
async function clearQueue() {
if (!confirm("Xóa các mission đang chờ trong queue?")) return;
if (!confirm(t("missions.queue.clearConfirm"))) return;
try {
await missionApi("/api/mission_queue", { method: "DELETE" });
await refreshQueue();
@@ -648,7 +652,7 @@
}
async function cancelRunner() {
if (!confirm("Hủy mission đang chạy? (thoát loop và dừng ngay)")) return;
if (!confirm(t("missions.queue.cancelConfirm"))) return;
await missionApi("/api/mission_queue/cancel", { method: "POST", body: "{}" });
await refreshQueue();
}
@@ -714,15 +718,27 @@
function startQueuePoll() {
if (window.AuthApp && !window.AuthApp.isReady()) return;
stopQueuePoll();
refreshQueue();
store.queuePollTimer = setInterval(refreshQueue, 1500);
queuePollRefs += 1;
if (queuePollRefs === 1) {
refreshQueue();
queuePollTimer = setInterval(refreshQueue, 1500);
}
}
function stopQueuePoll() {
if (store.queuePollTimer) {
clearInterval(store.queuePollTimer);
store.queuePollTimer = null;
if (queuePollRefs <= 0) return;
queuePollRefs -= 1;
if (queuePollRefs === 0 && queuePollTimer) {
clearInterval(queuePollTimer);
queuePollTimer = null;
}
}
function stopQueuePollForce() {
queuePollRefs = 0;
if (queuePollTimer) {
clearInterval(queuePollTimer);
queuePollTimer = null;
}
}
@@ -742,8 +758,8 @@
</div>
<div class="missionListItemActions">
<button type="button" class="iconBtn missionQueueBtn" data-queue="${mission.id}" title="Thêm vào mission queue" aria-label="Thêm vào queue">▤</button>
<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>
<button type="button" class="btn subtle" data-edit="${mission.id}">${t("common.edit")}</button>
<button type="button" class="btn subtle danger" data-delete="${mission.id}">${t("common.delete")}</button>
</div>`;
row.addEventListener("click", (evt) => {
if (evt.target.closest("button")) return;
@@ -759,7 +775,7 @@
});
row.querySelector("[data-delete]").addEventListener("click", (evt) => {
evt.stopPropagation();
if (!confirm(`Xóa mission «${mission.name}»?`)) return;
if (!confirm(t("missions.deleteConfirm", { name: mission.name }))) return;
store.missions = store.missions.filter((m) => m.id !== mission.id);
persistStore();
renderMissionList();
@@ -768,6 +784,12 @@
});
}
function groupLabel(name) {
const key = `missions.group.${name}`;
const v = t(key);
return v !== key ? v : name;
}
function renderActionPalette() {
if (!missionGroupTabsEl) return;
missionGroupTabsEl.innerHTML = "";
@@ -875,7 +897,7 @@
</div>
<div class="missionActionBtns">
<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>
<button type="button" class="iconBtn danger" data-remove="${action.id}" title="" data-i18n-title="common.delete">×</button>
</div>
</div>`;
@@ -1030,7 +1052,7 @@
}
function closeEditor() {
if (store.dirty && !confirm("Bỏ thay đổi chưa lưu?")) return;
if (store.dirty && !confirm(t("missions.editor.discardConfirm"))) return;
store.editingId = null;
store.draft = null;
setDirty(false);
@@ -1043,7 +1065,7 @@
const draft = getDraft();
if (!draft) return false;
if (!draft.name.trim()) {
alert("Tên mission không được trống.");
alert(t("missions.error.nameRequired"));
return false;
}
draft.updated_at = new Date().toISOString();
@@ -1062,7 +1084,7 @@
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.");
alert(t("missions.error.nameDuplicate"));
return false;
}
const copy = JSON.parse(JSON.stringify(draft));
@@ -1199,7 +1221,7 @@
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`;
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
addField("Kỳ vọng", chk);
}
break;
@@ -1272,7 +1294,7 @@
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.");
alert(t("missions.error.nameDuplicate"));
return;
}
const mission = createMission(name, group, el("missionCreateDesc").value);
@@ -1285,7 +1307,7 @@
el("missionEditorBackBtn")?.addEventListener("click", closeEditor);
el("missionSaveBtn")?.addEventListener("click", () => {
if (saveDraft()) alert("Đã lưu mission.");
if (saveDraft()) alert(t("missions.saveSuccess"));
});
el("missionSaveAsBtn")?.addEventListener("click", openSaveAsDialog);
el("missionSettingsBtn")?.addEventListener("click", openSettingsDialog);
@@ -1298,7 +1320,7 @@
draft.group = el("missionSettingsGroup").value;
draft.description = el("missionSettingsDesc").value.trim();
if (!draft.name) {
alert("Tên không được trống.");
alert(t("missions.error.nameEmpty"));
return;
}
setDirty(true);
@@ -1379,7 +1401,17 @@
function boot() {
init();
}
function onLocaleChange() {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else {
renderMissionList();
renderQueuePanel();
}
renderActionPalette();
}
window.addEventListener("lm:locale-change", onLocaleChange);
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopQueuePoll);
window.addEventListener("lm:auth-logout", stopQueuePollForce);
})();

268
www/nav.js Normal file
View File

@@ -0,0 +1,268 @@
/**
* MiR-style 3-column navigation: primary rail + flyout submenu + content.
*/
(function () {
const STORAGE_MODULE = "mirNavModule";
const STORAGE_SECTION = "mirNavSection";
const STORAGE_FLYOUT = "mirNavFlyoutOpen";
const MODULES = {
dashboards: {
items: [{ section: "dashboard", page: "dashboard" }],
},
setup: {
items: [
{ section: "missions", page: "missions" },
{ section: "maps", page: "config" },
],
},
monitoring: {
items: [{ section: "monitoring-log", page: "monitoring" }],
},
system: {
items: [{ section: "integrations", page: "integrations" }],
},
help: {
items: [{ section: "help-api", page: "help" }],
},
};
const PAGE_NAV = {
dashboard: { module: "dashboards", section: "dashboard" },
config: { module: "setup", section: "maps" },
missions: { module: "setup", section: "missions" },
integrations: { module: "system", section: "integrations" },
monitoring: { module: "monitoring", section: "monitoring-log" },
help: { module: "help", section: "help-api" },
};
let activeModule = "setup";
let activeSection = "maps";
let flyoutOpen = true;
const shellEl = () => document.getElementById("mirNavShell");
const flyoutListEl = () => document.getElementById("mirNavFlyoutList");
const flyoutTitleEl = () => document.getElementById("mirNavFlyoutTitle");
const backBtnEl = () => document.getElementById("mirNavBackBtn");
function t(key) {
return window.I18n?.t(`nav.${key}`) ?? key;
}
function canAccessPage(page) {
if (window.AuthApp?.canAccessPage) return window.AuthApp.canAccessPage(page);
return true;
}
function visibleItems(moduleId) {
const mod = MODULES[moduleId];
if (!mod) return [];
return mod.items.filter((item) => canAccessPage(item.page));
}
function moduleHasAccess(moduleId) {
return visibleItems(moduleId).length > 0;
}
function saveState() {
try {
localStorage.setItem(STORAGE_MODULE, activeModule);
localStorage.setItem(STORAGE_SECTION, activeSection);
localStorage.setItem(STORAGE_FLYOUT, flyoutOpen ? "1" : "0");
} catch {
/* ignore */
}
}
function renderFlyout() {
const list = flyoutListEl();
const title = flyoutTitleEl();
if (!list || !title) return;
const items = visibleItems(activeModule);
title.textContent = t(activeModule);
list.replaceChildren();
items.forEach((item) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "mirNavFlyoutItem";
btn.dataset.section = item.section;
btn.dataset.page = item.page;
btn.textContent = t(item.section);
if (item.section === activeSection) {
btn.classList.add("is-active");
btn.setAttribute("aria-current", "page");
}
btn.addEventListener("click", () => selectSection(item.section, item.page));
list.appendChild(btn);
});
}
function updateRailUI() {
document.querySelectorAll(".mirNavRailItem[data-module]").forEach((btn) => {
const mod = btn.dataset.module || "";
const allowed = moduleHasAccess(mod);
btn.hidden = !allowed;
btn.style.display = allowed ? "" : "none";
const on = mod === activeModule && flyoutOpen;
btn.classList.toggle("is-active", on);
if (on) btn.setAttribute("aria-current", "true");
else btn.removeAttribute("aria-current");
});
const shell = shellEl();
if (shell) shell.classList.toggle("mirNavShell--flyout-collapsed", !flyoutOpen);
const back = backBtnEl();
if (back) {
const label = flyoutOpen ? t("collapse") : t("expand");
back.title = label;
back.setAttribute("aria-label", label);
}
renderFlyout();
}
function selectModule(moduleId, opts = {}) {
if (!MODULES[moduleId] || !moduleHasAccess(moduleId)) return;
if (moduleId === activeModule && flyoutOpen && !opts.forceSection) {
flyoutOpen = false;
saveState();
updateRailUI();
return;
}
activeModule = moduleId;
flyoutOpen = true;
const items = visibleItems(moduleId);
const keepSection = items.some((i) => i.section === activeSection);
if (!keepSection || opts.forceSection) {
const preferred = items.find((i) => i.section === opts.section) || items[0];
if (preferred) {
activeSection = preferred.section;
if (!opts.skipPage) navigateToPage(preferred.page);
}
} else if (!opts.skipPage) {
const current = items.find((i) => i.section === activeSection);
if (current) navigateToPage(current.page);
}
saveState();
updateRailUI();
}
function selectSection(section, page) {
activeSection = section;
saveState();
updateRailUI();
navigateToPage(page);
}
function navigateToPage(page) {
if (window.LmApp?.setActivePage) window.LmApp.setActivePage(page);
}
function syncFromPage(page) {
const nav = PAGE_NAV[page];
if (!nav) return;
activeModule = nav.module;
activeSection = nav.section;
saveState();
updateRailUI();
}
function toggleFlyout() {
flyoutOpen = !flyoutOpen;
saveState();
updateRailUI();
}
function applyPermissions() {
const modules = Object.keys(MODULES);
if (!moduleHasAccess(activeModule)) {
const fallback = modules.find((m) => moduleHasAccess(m));
if (fallback) selectModule(fallback, { forceSection: true, skipPage: false });
} else {
const items = visibleItems(activeModule);
if (!items.some((i) => i.section === activeSection)) {
activeSection = items[0]?.section || activeSection;
}
}
updateRailUI();
}
function restoreInitialPage() {
let page = "config";
try {
const saved = localStorage.getItem("activePage");
if (saved && PAGE_NAV[saved]) page = saved;
} catch {
/* ignore */
}
try {
const savedMod = localStorage.getItem(STORAGE_MODULE);
const savedSec = localStorage.getItem(STORAGE_SECTION);
const savedFlyout = localStorage.getItem(STORAGE_FLYOUT);
if (savedMod && MODULES[savedMod]) activeModule = savedMod;
if (savedSec) activeSection = savedSec;
if (savedFlyout === "0") flyoutOpen = false;
} catch {
/* ignore */
}
const nav = PAGE_NAV[page];
if (nav && moduleHasAccess(nav.module)) {
activeModule = nav.module;
activeSection = nav.section;
} else {
const modItems = visibleItems(activeModule);
const match = modItems.find((i) => i.page === page) || modItems[0];
if (match) {
activeSection = match.section;
page = match.page;
}
}
updateRailUI();
navigateToPage(page);
}
function refreshLabels() {
window.I18n?.applyDOM?.();
updateRailUI();
}
function bindEvents() {
document.querySelectorAll(".mirNavRailItem[data-module]").forEach((btn) => {
btn.addEventListener("click", () => selectModule(btn.dataset.module || "setup"));
});
backBtnEl()?.addEventListener("click", toggleFlyout);
document.getElementById("mirNavLogout")?.addEventListener("click", () => {
window.AuthApp?.logout?.();
});
window.addEventListener("lm:locale-change", () => refreshLabels());
}
function init() {
refreshLabels();
bindEvents();
applyPermissions();
restoreInitialPage();
}
window.NavApp = {
init,
syncFromPage,
applyPermissions,
selectModule,
selectSection,
toggleFlyout,
};
})();

View File

@@ -24,115 +24,573 @@ body {
.shell {
display: grid;
grid-template-columns: 260px 1fr;
grid-template-columns: auto 1fr;
min-height: 100vh;
}
.sidebar {
/* —— MiR 3-column navigation (rail + flyout + content) —— */
.mirNavShell {
display: flex;
height: 100vh;
position: sticky;
top: 0;
height: 100vh;
background: linear-gradient(180deg, #0b1220, #0b1220);
color: #e8eefc;
padding: 16px 14px;
border-right: 1px solid rgba(255, 255, 255, 0.08);
z-index: 35;
flex-shrink: 0;
}
.brand {
display: grid;
grid-template-columns: 40px 1fr;
gap: 10px;
align-items: center;
padding: 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
.mirNavShell--flyout-collapsed .mirNavFlyout {
width: 0;
min-width: 0;
padding: 0;
border: none;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.brandIcon {
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
background: rgba(37, 99, 235, 0.22);
border: 1px solid rgba(37, 99, 235, 0.35);
font-weight: 800;
}
.brandTitle { font-weight: 800; font-size: 13px; letter-spacing: 0.2px; }
.brandSub { color: rgba(232,238,252,0.75); font-size: 12px; margin-top: 2px; }
.navTitle {
margin-top: 16px;
padding: 0 10px;
font-size: 11px;
color: rgba(232,238,252,0.65);
letter-spacing: 0.12em;
.mirNavRail {
width: 76px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
background: linear-gradient(180deg, #1a3d66 0%, #122d4f 55%, #0f2744 100%);
color: rgba(255, 255, 255, 0.92);
border-right: 1px solid rgba(0, 0, 0, 0.28);
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.06);
}
.nav { margin-top: 8px; display: grid; gap: 6px; }
.navItem {
.mirNavBackBtn {
flex-shrink: 0;
margin: 8px 8px 4px;
height: 32px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.mirNavBackBtn:hover { background: rgba(255, 255, 255, 0.14); }
.mirNavRailItems {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
overflow-y: auto;
}
.mirNavRailFooter {
padding: 8px 6px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.mirNavRailItem {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
min-height: 68px;
padding: 8px 4px;
border: none;
border-radius: 10px;
background: transparent;
color: rgba(255, 255, 255, 0.78);
cursor: pointer;
text-align: center;
}
.mirNavRailItem:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.mirNavRailItem.is-active {
background: rgba(255, 255, 255, 0.14);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.mirNavRailIcon { display: block; opacity: 0.95; }
.mirNavRailLabel {
font-size: 10px;
line-height: 1.15;
letter-spacing: 0.01em;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.mirNavRailItem--logout { color: rgba(255, 200, 200, 0.9); }
.mirNavRailItem--logout:hover { color: #fff; background: rgba(239, 68, 68, 0.22); }
.mirNavFlyout {
width: 248px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #5a94cf 0%, #4a7fbe 45%, #3d6fa8 100%);
color: #fff;
border-right: 1px solid rgba(0, 0, 0, 0.18);
box-shadow: 4px 0 18px rgba(15, 23, 42, 0.12);
transition: width 0.18s ease, opacity 0.18s ease;
overflow: hidden;
}
.mirNavFlyoutHeader {
padding: 14px 16px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.mirNavFlyoutTitle {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
}
.mirNavFlyoutList {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 8px;
overflow-y: auto;
}
.mirNavFlyoutItem {
display: block;
width: 100%;
text-align: left;
padding: 11px 14px;
border: none;
border-radius: 8px;
background: transparent;
color: rgba(255, 255, 255, 0.88);
font-size: 14px;
cursor: pointer;
}
.mirNavFlyoutItem:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.mirNavFlyoutItem.is-active {
background: rgba(255, 255, 255, 0.22);
color: #fff;
font-weight: 600;
box-shadow: inset 3px 0 0 #fff;
}
.mirNavFlyoutFooter {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 10px;
border-radius: 12px;
color: rgba(232,238,252,0.85);
text-decoration: none;
border: 1px solid transparent;
gap: 8px;
padding: 10px 14px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
}
.navItem:hover { background: rgba(255,255,255,0.05); }
.navItem.active {
background: rgba(37, 99, 235, 0.22);
border-color: rgba(37, 99, 235, 0.30);
color: #ffffff;
}
.navDot {
width: 10px;
height: 10px;
.mirNavStatusLed {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(255,255,255,0.25);
background: rgba(16, 185, 129, 0.9);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
flex-shrink: 0;
}
.navItem.active .navDot { background: rgba(37, 99, 235, 1); }
.sidebarFooter {
position: absolute;
left: 14px;
right: 14px;
bottom: 14px;
.mirNavStatusText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusBadge {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.statusLed {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.85);
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12);
}
.statusText { color: rgba(232,238,252,0.85); font-size: 12px; }
.body {
display: grid;
grid-template-rows: 72px 1fr;
grid-template-rows: auto 1fr;
min-width: 0;
background: var(--bg);
box-shadow: -2px 0 12px rgba(15, 23, 42, 0.06);
}
.topbar {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 14px 18px;
/* —— MiR-style top bar —— */
.mirTopbar {
background: linear-gradient(180deg, #4d7fbe 0%, #2f5f9e 48%, #254f87 100%);
color: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.22);
position: relative;
z-index: 40;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.mirTopbarInner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 12px;
min-height: 52px;
padding: 0 10px 0 14px;
overflow-x: auto;
overscroll-behavior: contain;
}
.mirTopbarLeft {
display: flex;
align-items: center;
gap: 10px;
flex: 1 1 auto;
min-width: 0;
}
.mirTopbarRight {
display: flex;
align-items: stretch;
flex-shrink: 0;
margin-left: auto;
}
.mirRobotId {
font-size: 22px;
font-weight: 800;
letter-spacing: 0.02em;
color: #fff;
padding-right: 6px;
flex-shrink: 0;
}
.mirPauseBtn {
appearance: none;
border: 1px solid rgba(0, 0, 0, 0.28);
background: rgba(16, 42, 82, 0.72);
border-radius: 6px;
width: 42px;
height: 42px;
display: grid;
place-items: center;
cursor: pointer;
flex-shrink: 0;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.mirPauseBtn:hover:not(:disabled) {
background: rgba(16, 42, 82, 0.9);
}
.mirPauseBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mirPauseBtnIcon { display: block; }
.mirPauseBtnIcon[hidden] { display: none !important; }
.mirMissionStrip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex: 1 1 auto;
min-width: 0;
max-width: 520px;
padding: 7px 8px 7px 16px;
border-radius: 999px;
background: rgba(14, 36, 72, 0.72);
border: 1px solid rgba(0, 0, 0, 0.22);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.mirMissionMsg {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.mirStatePill {
flex-shrink: 0;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
padding: 5px 12px;
border-radius: 999px;
background: #e67e22;
color: #fff;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.15);
}
.mirStatePill.is-running { background: #3dba6a; }
.mirStatePill.is-paused { background: #e67e22; }
.mirStatePill.is-error { background: #c0392b; }
.mirSegment {
appearance: none;
border: 0;
background: transparent;
color: inherit;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 14px;
border-left: 1px solid rgba(255, 255, 255, 0.18);
cursor: pointer;
font: inherit;
white-space: nowrap;
flex-shrink: 0;
min-height: 52px;
}
.mirTopbarRight > .mirPanel {
top: 100%;
}
.mirSegment:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.mirSegment:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.07);
}
.mirSegment--battery {
cursor: default;
border-left: 1px solid rgba(255, 255, 255, 0.18);
}
.mirSegment--battery:hover {
background: transparent;
}
.mirSegment--joystick.is-active {
background: rgba(255, 255, 255, 0.14);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.mirSegment--status.is-error {
background: rgba(192, 57, 43, 0.35);
}
.mirSvgIcon {
display: block;
flex-shrink: 0;
color: #fff;
}
.mirStatusSvg.is-ok { color: #6ee7a0; }
.mirStatusSvg.is-error { color: #ff8a8a; }
.mirFlag {
font-size: 16px;
line-height: 1;
}
.mirSegmentLabel {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.mirBatteryPct {
font-size: 12px;
letter-spacing: 0.02em;
text-transform: none;
font-weight: 700;
}
.mirCaret {
font-size: 9px;
opacity: 0.85;
margin-left: 2px;
line-height: 1;
}
.mirPanel {
position: absolute;
top: calc(100% - 2px);
min-width: 280px;
background: #ececec;
color: #1f2937;
border: 1px solid #c5c5c5;
border-radius: 2px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
z-index: 50;
}
.mirPanel[hidden] {
display: none !important;
}
.mirPanel--status { right: 280px; left: auto; }
.mirPanel--locale { right: 140px; left: auto; }
.mirPanel--user { right: 8px; left: auto; }
.mirPanelBody { padding: 14px 16px; font-size: 13px; }
.mirStatusOkTitle, .mirStatusErrorTitle {
font-weight: 800;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.mirStatusErrorTitle { color: #c0392b; }
.mirStatusDesc { color: #4b5563; line-height: 1.45; }
.mirStatusRow { font-size: 12px; margin-bottom: 4px; }
.mirStatusMeta { margin-top: 8px; font-size: 12px; color: #6b7280; }
.mirPanelFooter {
padding: 0 16px 14px;
display: flex;
justify-content: flex-end;
}
.mirPanel--locale {
flex-direction: column;
padding: 6px;
min-width: 200px;
}
.mirPanel--locale:not([hidden]) {
display: flex;
}
.mirLocaleOption {
appearance: none;
border: 0;
background: transparent;
text-align: left;
padding: 10px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.mirLocaleOption:hover { background: rgba(0, 0, 0, 0.06); }
.mirUserPanelHeader {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 16px 8px;
}
.mirUserPanelAvatar {
width: 42px;
height: 42px;
border-radius: 999px;
background: #d1d5db;
display: grid;
place-items: center;
font-size: 20px;
}
.mirUserPanelRole {
font-weight: 800;
letter-spacing: 0.05em;
}
.mirProfileField {
display: grid;
gap: 6px;
padding: 0 16px 10px;
font-size: 12px;
color: #4b5563;
}
.mirProfileField input {
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
}
.mirPanel--user .mirBtn {
margin: 0 16px 8px;
width: calc(100% - 32px);
}
.mirBtn {
appearance: none;
border: 0;
border-radius: 4px;
padding: 10px 14px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
cursor: pointer;
}
.mirBtn--primary { background: #2980b9; color: #fff; }
.mirBtn--primary.subtle { background: #5dade2; }
.mirBtn--danger { background: #c0392b; color: #fff; }
.mirBtn--reset { background: #7f8c8d; color: #fff; min-width: 96px; }
.mirBatteryIcon {
width: 28px;
height: 13px;
border: 2px solid rgba(255, 255, 255, 0.95);
border-radius: 2px;
position: relative;
padding: 1px;
box-sizing: border-box;
flex-shrink: 0;
}
.mirBatteryIcon::after {
content: "";
position: absolute;
right: -5px;
top: 3px;
width: 3px;
height: 6px;
background: rgba(255, 255, 255, 0.9);
border-radius: 0 2px 2px 0;
}
.mirBatteryLevel {
display: block;
height: 100%;
width: 54%;
background: #fff;
border-radius: 1px;
}
.mirSegment--battery.is-low .mirBatteryLevel { background: #ffb4b4; }
.mirSegment--battery.is-mid .mirBatteryLevel { background: #ffe08a; }
.configPageActions {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.joystickOverlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
z-index: 80;
display: grid;
place-items: center;
padding: 20px;
}
.joystickOverlay[hidden],
body.auth-logged-out .joystickOverlay {
display: none !important;
pointer-events: none !important;
}
.joystickOverlayCard {
background: #fff;
border-radius: 16px;
padding: 18px;
width: min(360px, 100%);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
}
.joystickOverlayHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.joystickPadWrap { display: grid; place-items: center; padding: 8px 0 14px; }
.joystickPad {
width: 200px;
height: 200px;
border-radius: 999px;
background: radial-gradient(circle at 50% 50%, #f8fafc 0%, #e2e8f0 100%);
border: 2px solid #cbd5e1;
position: relative;
touch-action: none;
}
.joystickStick {
position: absolute;
left: 50%;
top: 50%;
width: 56px;
height: 56px;
margin: -28px 0 0 -28px;
border-radius: 999px;
background: linear-gradient(180deg, #3b82f6, #1d4ed8);
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.35);
}
.joystickOverlayActions {
display: flex;
gap: 10px;
align-items: end;
justify-content: space-between;
}
.joystickSpeedSelect {
display: grid;
gap: 4px;
font-size: 12px;
color: var(--muted);
}
.joystickSpeedSelect select {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
}
.mirTopbar--no-missions .mirTopbarLeft {
visibility: hidden;
width: 0;
overflow: hidden;
flex: 0;
padding: 0;
}
.mirTopbar--no-missions .mirTopbarRight .mirSegment--joystick,
.mirTopbar--no-missions .mirTopbarRight .mirSegment--battery {
display: none;
}
.kicker { font-size: 12px; color: var(--muted); }
.pageTitle { font-size: 16px; font-weight: 800; letter-spacing: 0.2px; margin-top: 2px; }
.topbarActions { display: flex; gap: 10px; align-items: center; }
.content {
padding: 18px;
@@ -986,9 +1444,18 @@ canvas {
.dashboardInfoCard .dashboardInfoGrid { display: grid; gap: 8px; }
.dashboardEmpty { text-align: center; padding: 12px 0 0; }
@media (max-width: 1100px) {
.mirMissionStrip { max-width: 280px; }
.mirPanel--status { right: 8px; left: 8px; }
.mirPanel--locale { right: 8px; left: 8px; }
.mirPanel--user { right: 8px; left: auto; }
}
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; }
.sidebar { position: relative; height: auto; }
.shell { grid-template-columns: auto 1fr; }
.mirNavShell { height: auto; position: relative; }
.mirNavShell--flyout-collapsed .mirNavFlyout { display: none; }
.mirNavFlyout { width: 200px; }
.body { grid-template-rows: auto 1fr; }
.content { grid-template-columns: 1fr; height: auto; }
.splitter { display: none; }

457
www/topbar.js Normal file
View File

@@ -0,0 +1,457 @@
(() => {
const el = (id) => document.getElementById(id);
const LOCALE_META = {
vi: { flag: "🇻🇳", labelKey: "topbar.localeVi" },
en: { flag: "🇺🇸", labelKey: "topbar.localeEn" },
};
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const getLocale = () => window.I18n?.getLocale?.() ?? "vi";
let robotStatus = null;
let pollTimer = null;
let eventsBound = false;
let openPanel = null;
let joystickActive = false;
let joystickPointerId = null;
let joystickRaf = null;
let lastCmd = { linear: 0, angular: 0 };
function applyLocale(next) {
if (window.I18n) window.I18n.setLocale(next);
if (robotStatus) renderAll(robotStatus);
}
function loadLocale() {
/* locale owned by I18n */
}
function canSeeMissions() {
return window.AuthApp?.canAccessPage?.("missions");
}
function canControl() {
return window.AuthApp?.canWrite?.("missions");
}
async function apiJson(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
function closePanels() {
document.querySelectorAll(".mirPanel").forEach((p) => {
p.hidden = true;
});
document.querySelectorAll(".mirSegment[aria-haspopup='true']").forEach((btn) => {
btn.setAttribute("aria-expanded", "false");
});
openPanel = null;
}
function togglePanel(btn, panel) {
if (!btn || !panel) return;
const isOpen = btn.getAttribute("aria-expanded") === "true";
closePanels();
if (!isOpen) {
panel.hidden = false;
btn.setAttribute("aria-expanded", "true");
openPanel = panel;
}
}
function missionStripMessage(status) {
const pending = Number(status.queue_pending) || 0;
const runnerState = status.runner?.state || "idle";
const msg = status.message || "";
if (runnerState === "running" || runnerState === "paused") {
if (msg && msg !== t("topbar.waiting")) return msg;
const name = status.runner?.current_action;
if (name) return String(name);
}
if (pending === 0 && runnerState === "idle") return t("topbar.noMissionsQueue");
if (msg && msg !== t("topbar.waiting")) return msg;
return t("topbar.waiting");
}
function renderControl(status) {
const motion = status.motion || "paused";
const running = motion === "running";
const runnerState = status.runner?.state || "idle";
const isError = status.health === "error" || runnerState === "error";
const pauseIcon = el("mirControlIconPause");
const playIcon = el("mirControlIconPlay");
const pillEl = el("mirControlPill");
const msgEl = el("mirMissionMsg");
const btnEl = el("mirSegControl");
const stripEl = el("mirMissionStrip");
if (pauseIcon && playIcon) {
pauseIcon.hidden = !running;
playIcon.hidden = running;
}
if (pillEl) {
pillEl.textContent = isError ? t("topbar.error") : running ? t("topbar.running") : t("topbar.paused");
pillEl.classList.toggle("is-running", running && !isError);
pillEl.classList.toggle("is-paused", !running && !isError);
pillEl.classList.toggle("is-error", isError);
}
if (msgEl) msgEl.textContent = missionStripMessage(status);
if (stripEl) stripEl.classList.toggle("is-error", isError);
if (btnEl) {
btnEl.disabled = !canControl() || status.health === "error";
btnEl.title = running ? t("topbar.pauseHint") : t("topbar.startHint");
btnEl.classList.toggle("is-readonly", !canControl());
}
}
function renderStatus(status) {
const health = status.health || "ok";
const runnerState = status.runner?.state || "idle";
const isError = health === "error" || runnerState === "error";
const labelEl = el("mirStatusLabel");
const iconEl = el("mirStatusIcon");
const bodyEl = el("mirStatusPanelBody");
const footerEl = el("mirStatusPanelFooter");
const segEl = el("mirSegStatus");
if (labelEl) labelEl.textContent = isError ? t("topbar.error") : t("topbar.allOk");
if (iconEl) {
iconEl.classList.toggle("is-ok", !isError);
iconEl.classList.toggle("is-error", isError);
}
if (segEl) segEl.classList.toggle("is-error", isError);
if (!bodyEl) return;
const err = status.error && typeof status.error === "object" ? status.error : null;
const runnerErr = runnerState === "error" ? status.runner?.message : "";
const message = status.message || t("topbar.waiting");
if (isError && (err || runnerErr)) {
bodyEl.innerHTML = `
<div class="mirStatusErrorTitle">${t("topbar.error")}</div>
${err?.code != null ? `<div class="mirStatusRow"><span>${t("topbar.code")}:</span> <strong>${err.code}</strong></div>` : ""}
${err?.module ? `<div class="mirStatusRow"><span>${t("topbar.module")}:</span> ${err.module}</div>` : ""}
<div class="mirStatusDesc">${err?.description || runnerErr || message}</div>`;
if (footerEl) footerEl.hidden = !canControl();
} else {
bodyEl.innerHTML = `
<div class="mirStatusOkTitle">${t("topbar.allOk")}</div>
<div class="mirStatusDesc">${message}</div>
${status.queue_pending > 0 ? `<div class="mirStatusMeta">${t("topbar.queueCount", { n: status.queue_pending })}</div>` : ""}`;
if (footerEl) footerEl.hidden = true;
}
}
function renderBattery(status) {
const pct = Math.max(0, Math.min(100, Number(status.battery_percent) || 0));
const labelEl = el("mirBatteryLabel");
const levelEl = el("mirBatteryLevel");
const segEl = el("mirSegBattery");
if (labelEl) labelEl.textContent = `${pct}%`;
if (levelEl) levelEl.style.width = `${pct}%`;
if (segEl) {
segEl.classList.toggle("is-low", pct < 20);
segEl.classList.toggle("is-mid", pct >= 20 && pct < 50);
segEl.classList.toggle("is-charging", !!status.battery_charging);
}
}
function hideJoystickOverlay() {
const overlay = el("joystickOverlay");
if (overlay) overlay.hidden = true;
joystickActive = false;
const stick = el("joystickStick");
if (stick) stick.style.transform = "translate(0, 0)";
lastCmd = { linear: 0, angular: 0 };
}
function renderJoystick(status) {
if (!window.AuthApp?.isReady?.()) {
hideJoystickOverlay();
return;
}
const seg = el("mirSegJoystick");
const engaged = !!status.joystick_engaged;
if (seg) seg.classList.toggle("is-active", engaged);
const overlay = el("joystickOverlay");
if (overlay) overlay.hidden = !engaged;
joystickActive = engaged;
const speedSel = el("joystickSpeedSelect");
if (speedSel && status.joystick_speed) speedSel.value = status.joystick_speed;
if (el("joystickSpeedLabel")) {
const speed = status.joystick_speed || "fast";
el("joystickSpeedLabel").textContent = t(`topbar.joystickSpeed.${speed}`);
}
}
function renderAll(status) {
if (!window.AuthApp?.isReady?.()) {
hideJoystickOverlay();
return;
}
robotStatus = status;
if (!canSeeMissions()) {
el("mirTopbar")?.classList.add("mirTopbar--no-missions");
return;
}
el("mirTopbar")?.classList.remove("mirTopbar--no-missions");
renderControl(status);
renderStatus(status);
renderBattery(status);
renderJoystick(status);
}
async function fetchStatus() {
if (!window.AuthApp?.isReady() || !canSeeMissions()) return;
try {
const data = await apiJson("/api/robot/status");
renderAll(data);
window.dispatchEvent(new CustomEvent("lm:robot-status", { detail: data }));
} catch (e) {
if (String(e.message || "").includes("not authenticated")) return;
}
}
function startPoll() {
stopPoll();
fetchStatus();
pollTimer = setInterval(fetchStatus, 1500);
window.MissionsApp?.startQueuePoll?.();
}
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
window.MissionsApp?.stopQueuePoll?.();
}
async function toggleRobotMotion() {
if (!robotStatus || !canControl()) return;
const running = robotStatus.motion === "running";
const path = running ? "/api/robot/pause" : "/api/robot/start";
const data = await apiJson(path, { method: "POST", body: "{}" });
renderAll(data);
}
async function resetError() {
const data = await apiJson("/api/robot/errors/reset", { method: "POST", body: "{}" });
renderAll(data);
closePanels();
}
async function engageJoystick(engaged, speed) {
const payload = { engaged };
if (speed) payload.speed = speed;
const data = await apiJson("/api/robot/joystick", {
method: "POST",
body: JSON.stringify(payload),
});
renderAll(data);
}
function sendCmdVel(linear, angular) {
if (!joystickActive) return;
if (Math.abs(linear - lastCmd.linear) < 0.02 && Math.abs(angular - lastCmd.angular) < 0.02) return;
lastCmd = { linear, angular };
apiJson("/api/robot/cmd_vel", {
method: "POST",
body: JSON.stringify({ linear, angular }),
}).catch(() => {});
}
function bindJoystickPad() {
const pad = el("joystickPad");
const stick = el("joystickStick");
if (!pad || !stick) return;
const center = () => {
const r = pad.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, radius: r.width / 2 - 24 };
};
const moveStick = (clientX, clientY) => {
const c = center();
let dx = clientX - c.x;
let dy = clientY - c.y;
const dist = Math.hypot(dx, dy);
if (dist > c.radius) {
dx = (dx / dist) * c.radius;
dy = (dy / dist) * c.radius;
}
stick.style.transform = `translate(${dx}px, ${dy}px)`;
const linear = -dy / c.radius;
const angular = dx / c.radius;
if (joystickRaf) cancelAnimationFrame(joystickRaf);
joystickRaf = requestAnimationFrame(() => sendCmdVel(linear, angular));
};
const resetStick = () => {
stick.style.transform = "translate(0, 0)";
sendCmdVel(0, 0);
lastCmd = { linear: 0, angular: 0 };
};
const onDown = (evt) => {
if (!joystickActive) return;
joystickPointerId = evt.pointerId;
pad.setPointerCapture(evt.pointerId);
moveStick(evt.clientX, evt.clientY);
};
const onMove = (evt) => {
if (evt.pointerId !== joystickPointerId) return;
moveStick(evt.clientX, evt.clientY);
};
const onUp = (evt) => {
if (evt.pointerId !== joystickPointerId) return;
joystickPointerId = null;
resetStick();
};
pad.addEventListener("pointerdown", onDown);
pad.addEventListener("pointermove", onMove);
pad.addEventListener("pointerup", onUp);
pad.addEventListener("pointercancel", onUp);
}
function bindEvents() {
if (eventsBound) return;
eventsBound = true;
el("mirSegControl")?.addEventListener("click", () => {
toggleRobotMotion().catch((e) => alert(e.message));
});
el("mirSegStatus")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirSegStatus"), el("mirStatusPanel"));
});
el("mirSegLocale")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirSegLocale"), el("mirLocalePanel"));
});
el("mirUserBtn")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirUserBtn"), el("mirUserPanel"));
});
el("mirErrorResetBtn")?.addEventListener("click", () => {
resetError().catch((e) => alert(e.message));
});
document.querySelectorAll(".mirLocaleOption").forEach((btn) => {
btn.addEventListener("click", (evt) => {
evt.stopPropagation();
applyLocale(btn.dataset.locale || "vi");
closePanels();
});
});
el("mirSegJoystick")?.addEventListener("click", async () => {
if (!canControl()) {
alert(t("topbar.noControlPermission"));
return;
}
try {
if (robotStatus?.joystick_engaged) await engageJoystick(false);
else await engageJoystick(true, el("joystickSpeedSelect")?.value || "fast");
} catch (e) {
alert(e.message);
}
});
el("joystickDisengageBtn")?.addEventListener("click", () => {
engageJoystick(false).catch((e) => alert(e.message));
});
el("joystickSpeedSelect")?.addEventListener("change", (evt) => {
if (robotStatus?.joystick_engaged) {
engageJoystick(true, evt.target.value).catch(() => {});
}
});
document.addEventListener("click", (evt) => {
if (evt.target.closest(".mirSegment") || evt.target.closest(".mirPanel")) return;
closePanels();
});
document.addEventListener("keydown", (evt) => {
if (evt.key === "Escape") {
closePanels();
if (robotStatus?.joystick_engaged) engageJoystick(false).catch(() => {});
}
});
bindJoystickPad();
window.addEventListener("lm:locale-change", () => {
if (robotStatus) renderAll(robotStatus);
});
}
function start() {
loadLocale();
bindEvents();
if (!window.AuthApp?.isReady()) return;
startPoll();
}
function stop() {
stopPoll();
closePanels();
hideJoystickOverlay();
if (window.AuthApp?.isReady?.()) {
engageJoystick(false).catch(() => {});
}
}
async function disengageJoystick() {
hideJoystickOverlay();
if (!window.AuthApp?.isReady?.()) return;
try {
await engageJoystick(false);
} catch {
/* session may already be gone */
}
}
window.TopbarApp = {
t,
getLocale,
applyLocale,
refresh: fetchStatus,
getRobotStatus: () => robotStatus,
hideJoystickOverlay,
disengageJoystick,
updateUserMenu(user) {
const role = (user?.group_name || "USER").toUpperCase();
if (el("mirUserLabel")) el("mirUserLabel").textContent = role;
if (el("mirUserPanelRole")) el("mirUserPanelRole").textContent = role;
if (el("mirUserPanelName")) el("mirUserPanelName").textContent = user?.display_name || user?.username || "—";
if (el("mirProfileDisplayName")) el("mirProfileDisplayName").value = user?.display_name || user?.username || "";
},
};
if (window.AuthApp?.isReady()) start();
else window.addEventListener("lm:auth-ready", () => start(), { once: true });
window.addEventListener("lm:auth-logout", () => stop());
hideJoystickOverlay();
})();