Compare commits
2 Commits
9aee5f4100
...
a2e87aeb29
| Author | SHA1 | Date | |
|---|---|---|---|
| a2e87aeb29 | |||
| 1156e1ab29 |
@@ -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)
|
||||
|
||||
@@ -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
13
data/robot_runtime.json
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
265
src/robot/robot_runtime.cpp
Normal 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
|
||||
38
src/robot/robot_runtime.hpp
Normal file
38
src/robot/robot_runtime.hpp
Normal 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
|
||||
85
src/server/api_robot_routes.cpp
Normal file
85
src/server/api_robot_routes.cpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
94
www/app.js
94
www/app.js
@@ -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?.();
|
||||
});
|
||||
|
||||
|
||||
106
www/auth.js
106
www/auth.js
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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
771
www/i18n.js
Normal 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 1001–2000 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 (1001–2000).",
|
||||
"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 1001–2000 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 (1001–2000).",
|
||||
"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();
|
||||
}
|
||||
})();
|
||||
528
www/index.html
528
www/index.html
@@ -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 1001–2000 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 1001–2000 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>
|
||||
|
||||
@@ -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 (1001–2000).</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 });
|
||||
})();
|
||||
|
||||
106
www/missions.js
106
www/missions.js
@@ -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
268
www/nav.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
641
www/style.css
641
www/style.css
@@ -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
457
www/topbar.js
Normal 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user