This commit is contained in:
@@ -44,12 +44,14 @@ add_executable(lidar_manager_web
|
|||||||
src/storage/map_store.cpp
|
src/storage/map_store.cpp
|
||||||
src/storage/site_store.cpp
|
src/storage/site_store.cpp
|
||||||
src/storage/sound_store.cpp
|
src/storage/sound_store.cpp
|
||||||
|
src/storage/transition_store.cpp
|
||||||
src/storage/dashboard_store.cpp
|
src/storage/dashboard_store.cpp
|
||||||
src/storage/state_repository.cpp
|
src/storage/state_repository.cpp
|
||||||
src/validation/sensor_validator.cpp
|
src/validation/sensor_validator.cpp
|
||||||
src/server/static_file_server.cpp
|
src/server/static_file_server.cpp
|
||||||
src/server/api_server.cpp
|
src/server/api_server.cpp
|
||||||
src/mission/mission_queue.cpp
|
src/mission/mission_queue.cpp
|
||||||
|
src/mission/position_resolver.cpp
|
||||||
src/mission/mission_store.cpp
|
src/mission/mission_store.cpp
|
||||||
src/mission/mission_enqueue.cpp
|
src/mission/mission_enqueue.cpp
|
||||||
src/mission/modbus_trigger_service.cpp
|
src/mission/modbus_trigger_service.cpp
|
||||||
@@ -58,6 +60,7 @@ add_executable(lidar_manager_web
|
|||||||
src/server/api_mission_routes.cpp
|
src/server/api_mission_routes.cpp
|
||||||
src/server/api_robot_routes.cpp
|
src/server/api_robot_routes.cpp
|
||||||
src/server/api_media_routes.cpp
|
src/server/api_media_routes.cpp
|
||||||
|
src/server/api_transition_routes.cpp
|
||||||
src/server/api_dashboard_routes.cpp
|
src/server/api_dashboard_routes.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,14 @@ Mở trình duyệt: `http://localhost:8080/`
|
|||||||
| POST | `/api/sounds` | Tạo sound |
|
| POST | `/api/sounds` | Tạo sound |
|
||||||
| GET/PUT/DELETE | `/api/sounds/{id}` | CRUD sound |
|
| GET/PUT/DELETE | `/api/sounds/{id}` | CRUD sound |
|
||||||
| GET/POST | `/api/sounds/{id}/file` | Tải/upload file âm thanh |
|
| GET/POST | `/api/sounds/{id}/file` | Tải/upload file âm thanh |
|
||||||
|
| POST | `/api/sounds/{id}/play` | Phát sound trên robot (volume 0–100) |
|
||||||
|
| GET/POST | `/api/transitions` | Danh sách / tạo transition (`?site_id=`) |
|
||||||
|
| GET/PUT/DELETE | `/api/transitions/{id}` | CRUD transition |
|
||||||
| GET/PUT | `/api/dashboards` | Dashboard (server-side, thay localStorage) |
|
| GET/PUT | `/api/dashboards` | Dashboard (server-side, thay localStorage) |
|
||||||
| GET | `/api/recordings` | Stub — trả về `[]` (Phase sau) |
|
| GET | `/api/recordings` | Stub — trả về `[]` (Phase sau) |
|
||||||
|
|
||||||
|
**Transitions (MiR §4.4):** Setup → Transitions — cấu hình chuyển map (from/to, start/goal position, mission có `switch_map`). Khi mission chạy `move_to_position` hoặc `adjust_localization` tới position trên map khác `active_map_id`, runner tự chèn: di chuyển tới start position → chạy transition mission → chuyển map → di chuyển tới goal position → tiếp tục bước gốc. Position trong mission editor lấy từ zones `type: position` trên từng map.
|
||||||
|
|
||||||
### Đăng nhập (Signing in — MiR §2.1)
|
### Đăng nhập (Signing in — MiR §2.1)
|
||||||
|
|
||||||
Trang web **bắt buộc đăng nhập**. Hai tab: tên/mật khẩu hoặc **Mã PIN** (keypad 4 số). Tài khoản mặc định (trong `data/RBS.db`, seed lần đầu):
|
Trang web **bắt buộc đăng nhập**. Hai tab: tên/mật khẩu hoặc **Mã PIN** (keypad 4 số). Tài khoản mặc định (trong `data/RBS.db`, seed lần đầu):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "storage/map_store.hpp"
|
#include "storage/map_store.hpp"
|
||||||
#include "storage/site_store.hpp"
|
#include "storage/site_store.hpp"
|
||||||
#include "storage/sound_store.hpp"
|
#include "storage/sound_store.hpp"
|
||||||
|
#include "storage/transition_store.hpp"
|
||||||
#include "storage/state_repository.hpp"
|
#include "storage/state_repository.hpp"
|
||||||
|
|
||||||
#include <httplib.h>
|
#include <httplib.h>
|
||||||
@@ -61,13 +62,19 @@ int LidarManagerApp::run()
|
|||||||
StateRepository repo(data_path_, database);
|
StateRepository repo(data_path_, database);
|
||||||
repo.load();
|
repo.load();
|
||||||
|
|
||||||
MissionQueue mission_queue(database);
|
|
||||||
MissionStore mission_store(database);
|
|
||||||
RobotRuntime robot_runtime(database, mission_queue);
|
|
||||||
MapStore map_store(database);
|
MapStore map_store(database);
|
||||||
|
TransitionStore transition_store(database);
|
||||||
|
MissionStore mission_store(database);
|
||||||
|
MissionQueue mission_queue(database, map_store, transition_store, mission_store);
|
||||||
|
RobotRuntime robot_runtime(database, mission_queue);
|
||||||
SiteStore site_store(database);
|
SiteStore site_store(database);
|
||||||
site_store.ensureDefaultSiteId();
|
site_store.ensureDefaultSiteId();
|
||||||
SoundStore sound_store(database);
|
SoundStore sound_store(database);
|
||||||
|
{
|
||||||
|
std::string sound_err;
|
||||||
|
if (!sound_store.ensureSystemDefaults(sound_err))
|
||||||
|
std::fprintf(stderr, "Warning: sound defaults: %s\n", sound_err.c_str());
|
||||||
|
}
|
||||||
DashboardStore dashboard_store(database);
|
DashboardStore dashboard_store(database);
|
||||||
|
|
||||||
const auto enqueue_fn = [&mission_store, &mission_queue](const nlohmann::json& request, std::string& err) -> bool {
|
const auto enqueue_fn = [&mission_store, &mission_queue](const nlohmann::json& request, std::string& err) -> bool {
|
||||||
@@ -96,6 +103,7 @@ int LidarManagerApp::run()
|
|||||||
map_store,
|
map_store,
|
||||||
site_store,
|
site_store,
|
||||||
sound_store,
|
sound_store,
|
||||||
|
transition_store,
|
||||||
dashboard_store);
|
dashboard_store);
|
||||||
api.registerRoutes(svr);
|
api.registerRoutes(svr);
|
||||||
auth.registerRoutes(svr);
|
auth.registerRoutes(svr);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ nlohmann::json defaultPermissionsAllWrite()
|
|||||||
{"config", "write"},
|
{"config", "write"},
|
||||||
{"maps", "write"},
|
{"maps", "write"},
|
||||||
{"missions", "write"},
|
{"missions", "write"},
|
||||||
|
{"sounds", "write"},
|
||||||
{"integrations", "write"},
|
{"integrations", "write"},
|
||||||
{"users", "write"}};
|
{"users", "write"}};
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ nlohmann::json defaultPermissionsAdministrator()
|
|||||||
{"config", "none"},
|
{"config", "none"},
|
||||||
{"maps", "write"},
|
{"maps", "write"},
|
||||||
{"missions", "write"},
|
{"missions", "write"},
|
||||||
|
{"sounds", "write"},
|
||||||
{"integrations", "write"},
|
{"integrations", "write"},
|
||||||
{"users", "write"}};
|
{"users", "write"}};
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,7 @@ nlohmann::json defaultPermissionsUserGroup()
|
|||||||
{"config", "none"},
|
{"config", "none"},
|
||||||
{"maps", "none"},
|
{"maps", "none"},
|
||||||
{"missions", "read"},
|
{"missions", "read"},
|
||||||
|
{"sounds", "read"},
|
||||||
{"integrations", "read"},
|
{"integrations", "read"},
|
||||||
{"users", "none"}};
|
{"users", "none"}};
|
||||||
}
|
}
|
||||||
@@ -144,6 +147,22 @@ void AuthService::loadOrSeed()
|
|||||||
data_["version"] = 3;
|
data_["version"] = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data_.value("version", 1) < 4)
|
||||||
|
{
|
||||||
|
if (data_.contains("groups") && data_["groups"].is_array())
|
||||||
|
{
|
||||||
|
for (auto& g : data_["groups"])
|
||||||
|
{
|
||||||
|
if (!g.contains("permissions") || !g["permissions"].is_object())
|
||||||
|
continue;
|
||||||
|
auto& perms = g["permissions"];
|
||||||
|
if (!perms.contains("sounds"))
|
||||||
|
perms["sounds"] = perms.value("integrations", "none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data_["version"] = 4;
|
||||||
|
}
|
||||||
|
|
||||||
saveUnlocked();
|
saveUnlocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +230,7 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
|
|||||||
if (path.rfind("/api/dashboards", 0) == 0)
|
if (path.rfind("/api/dashboards", 0) == 0)
|
||||||
return "dashboard";
|
return "dashboard";
|
||||||
if (path.rfind("/api/sounds", 0) == 0)
|
if (path.rfind("/api/sounds", 0) == 0)
|
||||||
return "integrations";
|
return "sounds";
|
||||||
if (path == "/api/robot/active_map")
|
if (path == "/api/robot/active_map")
|
||||||
return "maps";
|
return "maps";
|
||||||
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/sites", 0) == 0 ||
|
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/sites", 0) == 0 ||
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
#include "mission/mission_queue.hpp"
|
#include "mission/mission_queue.hpp"
|
||||||
|
|
||||||
|
#include "mission/mission_store.hpp"
|
||||||
|
#include "mission/position_resolver.hpp"
|
||||||
#include "storage/database.hpp"
|
#include "storage/database.hpp"
|
||||||
|
#include "storage/map_store.hpp"
|
||||||
|
#include "storage/transition_store.hpp"
|
||||||
#include "util/id_util.hpp"
|
#include "util/id_util.hpp"
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
@@ -40,7 +44,8 @@ double paramNumber(const nlohmann::json& params, const std::string& key, double
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
MissionQueue::MissionQueue(Database& db) : db_(db)
|
MissionQueue::MissionQueue(Database& db, MapStore& maps, TransitionStore& transitions, MissionStore& missions)
|
||||||
|
: db_(db), maps_(maps), transitions_(transitions), missions_(missions), position_resolver_(maps)
|
||||||
{
|
{
|
||||||
load();
|
load();
|
||||||
ensureRunnerDefaults();
|
ensureRunnerDefaults();
|
||||||
@@ -450,10 +455,103 @@ void MissionQueue::runMissionActions(nlohmann::json& entry)
|
|||||||
cancel_ = false;
|
cancel_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MissionQueue::runAutoTransitionIfNeeded(const std::string& from_map_id,
|
||||||
|
const std::string& to_map_id,
|
||||||
|
const nlohmann::json& parameters,
|
||||||
|
nlohmann::json& log,
|
||||||
|
int loop_depth)
|
||||||
|
{
|
||||||
|
if (from_map_id.empty() || to_map_id.empty() || from_map_id == to_map_id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const auto transition = transitions_.findBetween(from_map_id, to_map_id);
|
||||||
|
if (!transition)
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "error"},
|
||||||
|
{"message", "No transition configured: " + from_map_id + " → " + to_map_id}});
|
||||||
|
throw std::runtime_error("no transition between maps");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string from_label = from_map_id;
|
||||||
|
std::string to_label = to_map_id;
|
||||||
|
const auto maps = maps_.list();
|
||||||
|
if (maps.is_array())
|
||||||
|
{
|
||||||
|
for (const auto& map : maps)
|
||||||
|
{
|
||||||
|
if (!map.is_object())
|
||||||
|
continue;
|
||||||
|
if (map.value("id", "") == from_map_id)
|
||||||
|
from_label = map.value("name", from_map_id);
|
||||||
|
if (map.value("id", "") == to_map_id)
|
||||||
|
to_label = map.value("name", to_map_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "info"},
|
||||||
|
{"message", "Auto transition: " + from_label + " → " + to_label}});
|
||||||
|
|
||||||
|
const std::string start_id = transition->value("start_position_id", "");
|
||||||
|
const std::string goal_id = transition->value("goal_position_id", "");
|
||||||
|
if (!start_id.empty())
|
||||||
|
{
|
||||||
|
const auto start_pos = position_resolver_.resolve(start_id, from_map_id);
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "info"},
|
||||||
|
{"message",
|
||||||
|
"Move to transition start → " + (start_pos ? start_pos->label : start_id)}});
|
||||||
|
sleepMs(1200);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string mission_id = transition->value("mission_id", "");
|
||||||
|
if (!mission_id.empty())
|
||||||
|
{
|
||||||
|
const auto mission = missions_.findMission(mission_id);
|
||||||
|
if (mission && mission->contains("actions") && (*mission)["actions"].is_array())
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "info"},
|
||||||
|
{"message", "Run transition mission: " + mission->value("name", mission_id)}});
|
||||||
|
executeActionsUnlocked((*mission)["actions"], parameters, log, loop_depth + 1, false);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "warn"},
|
||||||
|
{"message", "Transition mission not found: " + mission_id}});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
if (!setActiveMapOnRobotRuntimeDoc(db_, to_map_id, err))
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Switch map failed: " + err}});
|
||||||
|
throw std::runtime_error("switch_map failed after transition");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!goal_id.empty())
|
||||||
|
{
|
||||||
|
const auto goal_pos = position_resolver_.resolve(goal_id, to_map_id);
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "info"},
|
||||||
|
{"message", "Move to transition goal → " + (goal_pos ? goal_pos->label : goal_id)}});
|
||||||
|
sleepMs(1200);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
||||||
const nlohmann::json& parameters,
|
const nlohmann::json& parameters,
|
||||||
nlohmann::json& log,
|
nlohmann::json& log,
|
||||||
int loop_depth)
|
int loop_depth,
|
||||||
|
bool allow_auto_transition)
|
||||||
{
|
{
|
||||||
if (loop_depth > 8)
|
if (loop_depth > 8)
|
||||||
throw std::runtime_error("loop depth exceeded");
|
throw std::runtime_error("loop depth exceeded");
|
||||||
@@ -491,7 +589,7 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j
|
|||||||
if (action.contains("resolved_mission") && action["resolved_mission"].is_object())
|
if (action.contains("resolved_mission") && action["resolved_mission"].is_object())
|
||||||
{
|
{
|
||||||
const auto& nested_actions = action["resolved_mission"]["actions"];
|
const auto& nested_actions = action["resolved_mission"]["actions"];
|
||||||
const LoopControl nested = executeActionsUnlocked(nested_actions, parameters, log, loop_depth);
|
const LoopControl nested = executeActionsUnlocked(nested_actions, parameters, log, loop_depth, allow_auto_transition);
|
||||||
if (nested == LoopControl::Break)
|
if (nested == LoopControl::Break)
|
||||||
return LoopControl::Break;
|
return LoopControl::Break;
|
||||||
if (nested == LoopControl::Continue)
|
if (nested == LoopControl::Continue)
|
||||||
@@ -533,7 +631,7 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j
|
|||||||
{"level", "info"},
|
{"level", "info"},
|
||||||
{"message", "Loop " + std::to_string(i + 1) + "/" + std::to_string(iterations)}});
|
{"message", "Loop " + std::to_string(i + 1) + "/" + std::to_string(iterations)}});
|
||||||
}
|
}
|
||||||
const LoopControl ctrl = executeActionsUnlocked(children, parameters, log, loop_depth + 1);
|
const LoopControl ctrl = executeActionsUnlocked(children, parameters, log, loop_depth + 1, allow_auto_transition);
|
||||||
if (ctrl == LoopControl::Break)
|
if (ctrl == LoopControl::Break)
|
||||||
break;
|
break;
|
||||||
if (ctrl == LoopControl::Continue)
|
if (ctrl == LoopControl::Continue)
|
||||||
@@ -556,16 +654,58 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j
|
|||||||
|
|
||||||
if (type == "move_to_position" || type == "adjust_localization" || type == "pick_cart" || type == "drop_cart")
|
if (type == "move_to_position" || type == "adjust_localization" || type == "pick_cart" || type == "drop_cart")
|
||||||
{
|
{
|
||||||
const std::string pos = paramValue(action_id, params, "position", parameters);
|
std::string pos_ref = paramValue(action_id, params, "position_id", parameters);
|
||||||
|
if (pos_ref.empty())
|
||||||
|
pos_ref = paramValue(action_id, params, "position", parameters);
|
||||||
|
|
||||||
|
if (allow_auto_transition && (type == "move_to_position" || type == "adjust_localization") && !pos_ref.empty())
|
||||||
|
{
|
||||||
|
const std::string active_map = getActiveMapIdFromDb(db_);
|
||||||
|
const auto resolved = position_resolver_.resolve(pos_ref, active_map);
|
||||||
|
if (resolved)
|
||||||
|
{
|
||||||
|
if (!active_map.empty() && active_map != resolved->map_id)
|
||||||
|
{
|
||||||
|
runAutoTransitionIfNeeded(active_map, resolved->map_id, parameters, log, loop_depth);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
|
}
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "info"},
|
||||||
|
{"message", label + " → " + resolved->label}});
|
||||||
|
sleepMs(1200);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
{"level", "info"},
|
{"level", "info"},
|
||||||
{"message", label + " → " + (pos.empty() ? "?" : pos)}});
|
{"message", label + " → " + (pos_ref.empty() ? "?" : pos_ref)}});
|
||||||
sleepMs(1200);
|
sleepMs(1200);
|
||||||
if (cancel_)
|
if (cancel_)
|
||||||
throw MissionCancelled();
|
throw MissionCancelled();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type == "switch_map")
|
||||||
|
{
|
||||||
|
const std::string map_id = paramValue(action_id, params, "map_id", parameters);
|
||||||
|
std::string err;
|
||||||
|
if (!setActiveMapOnRobotRuntimeDoc(db_, map_id, err))
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Switch map failed: " + err}});
|
||||||
|
throw std::runtime_error("switch_map failed");
|
||||||
|
}
|
||||||
|
log.push_back(
|
||||||
|
{{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Switch map → " + map_id}});
|
||||||
|
sleepMs(300);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (type == "move_to_marker")
|
if (type == "move_to_marker")
|
||||||
{
|
{
|
||||||
const std::string marker = paramValue(action_id, params, "marker", parameters);
|
const std::string marker = paramValue(action_id, params, "marker", parameters);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "mission/position_resolver.hpp"
|
||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
@@ -11,11 +13,14 @@
|
|||||||
namespace lm {
|
namespace lm {
|
||||||
|
|
||||||
class Database;
|
class Database;
|
||||||
|
class MapStore;
|
||||||
|
class MissionStore;
|
||||||
|
class TransitionStore;
|
||||||
|
|
||||||
class MissionQueue
|
class MissionQueue
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit MissionQueue(Database& db);
|
MissionQueue(Database& db, MapStore& maps, TransitionStore& transitions, MissionStore& missions);
|
||||||
~MissionQueue();
|
~MissionQueue();
|
||||||
|
|
||||||
MissionQueue(const MissionQueue&) = delete;
|
MissionQueue(const MissionQueue&) = delete;
|
||||||
@@ -36,6 +41,10 @@ private:
|
|||||||
enum class LoopControl { None, Break, Continue };
|
enum class LoopControl { None, Break, Continue };
|
||||||
|
|
||||||
Database& db_;
|
Database& db_;
|
||||||
|
MapStore& maps_;
|
||||||
|
TransitionStore& transitions_;
|
||||||
|
MissionStore& missions_;
|
||||||
|
PositionResolver position_resolver_;
|
||||||
mutable std::recursive_mutex mu_;
|
mutable std::recursive_mutex mu_;
|
||||||
nlohmann::json queue_;
|
nlohmann::json queue_;
|
||||||
nlohmann::json runner_;
|
nlohmann::json runner_;
|
||||||
@@ -55,7 +64,13 @@ private:
|
|||||||
LoopControl executeActionsUnlocked(const nlohmann::json& actions,
|
LoopControl executeActionsUnlocked(const nlohmann::json& actions,
|
||||||
const nlohmann::json& parameters,
|
const nlohmann::json& parameters,
|
||||||
nlohmann::json& log,
|
nlohmann::json& log,
|
||||||
int loop_depth);
|
int loop_depth,
|
||||||
|
bool allow_auto_transition = true);
|
||||||
|
void runAutoTransitionIfNeeded(const std::string& from_map_id,
|
||||||
|
const std::string& to_map_id,
|
||||||
|
const nlohmann::json& parameters,
|
||||||
|
nlohmann::json& log,
|
||||||
|
int loop_depth);
|
||||||
void sleepMs(int ms);
|
void sleepMs(int ms);
|
||||||
void setRunnerState(const std::string& state, const std::string& message = "");
|
void setRunnerState(const std::string& state, const std::string& message = "");
|
||||||
void insertByPriorityUnlocked(nlohmann::json& entry);
|
void insertByPriorityUnlocked(nlohmann::json& entry);
|
||||||
|
|||||||
156
src/mission/position_resolver.cpp
Normal file
156
src/mission/position_resolver.cpp
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
#include "mission/position_resolver.hpp"
|
||||||
|
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "storage/map_store.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string lowerCopy(const std::string& s)
|
||||||
|
{
|
||||||
|
return StringUtil::toLower(StringUtil::trimCopy(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
PositionResolver::PositionResolver(MapStore& maps) : maps_(maps) {}
|
||||||
|
|
||||||
|
nlohmann::json PositionResolver::listPositions() const
|
||||||
|
{
|
||||||
|
nlohmann::json out = nlohmann::json::array();
|
||||||
|
const auto maps = maps_.list();
|
||||||
|
if (!maps.is_array())
|
||||||
|
return out;
|
||||||
|
|
||||||
|
for (const auto& map : maps)
|
||||||
|
{
|
||||||
|
if (!map.is_object())
|
||||||
|
continue;
|
||||||
|
const std::string map_id = map.value("id", "");
|
||||||
|
const std::string map_name = map.value("name", map_id);
|
||||||
|
const auto& zones = map.value("zones", nlohmann::json::array());
|
||||||
|
if (!zones.is_array())
|
||||||
|
continue;
|
||||||
|
for (const auto& z : zones)
|
||||||
|
{
|
||||||
|
if (!z.is_object() || z.value("type", "") != "position")
|
||||||
|
continue;
|
||||||
|
const std::string pid = z.value("id", "");
|
||||||
|
if (pid.empty())
|
||||||
|
continue;
|
||||||
|
const std::string pname = z.value("name", pid);
|
||||||
|
out.push_back({{"map_id", map_id},
|
||||||
|
{"map_name", map_name},
|
||||||
|
{"position_id", pid},
|
||||||
|
{"name", pname},
|
||||||
|
{"label", map_name + " / " + pname}});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<ResolvedPosition> PositionResolver::resolve(const std::string& ref,
|
||||||
|
const std::string& prefer_map_id) const
|
||||||
|
{
|
||||||
|
const std::string needle = StringUtil::trimCopy(ref);
|
||||||
|
if (needle.empty())
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
const auto maps = maps_.list();
|
||||||
|
if (!maps.is_array())
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
auto tryMatch = [&](const nlohmann::json& map, bool by_id) -> std::optional<ResolvedPosition> {
|
||||||
|
if (!map.is_object())
|
||||||
|
return std::nullopt;
|
||||||
|
const std::string map_id = map.value("id", "");
|
||||||
|
const std::string map_name = map.value("name", map_id);
|
||||||
|
const auto& zones = map.value("zones", nlohmann::json::array());
|
||||||
|
if (!zones.is_array())
|
||||||
|
return std::nullopt;
|
||||||
|
const std::string ref_lower = lowerCopy(needle);
|
||||||
|
for (const auto& z : zones)
|
||||||
|
{
|
||||||
|
if (!z.is_object() || z.value("type", "") != "position")
|
||||||
|
continue;
|
||||||
|
const std::string pid = z.value("id", "");
|
||||||
|
const std::string pname = z.value("name", "");
|
||||||
|
if (by_id)
|
||||||
|
{
|
||||||
|
if (pid != needle)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else if (lowerCopy(pname) != ref_lower)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ResolvedPosition out;
|
||||||
|
out.map_id = map_id;
|
||||||
|
out.position_id = pid;
|
||||||
|
out.name = pname;
|
||||||
|
out.label = map_name + " / " + (pname.empty() ? pid : pname);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!prefer_map_id.empty())
|
||||||
|
{
|
||||||
|
for (const auto& map : maps)
|
||||||
|
{
|
||||||
|
if (map.value("id", "") != prefer_map_id)
|
||||||
|
continue;
|
||||||
|
if (auto hit = tryMatch(map, true))
|
||||||
|
return hit;
|
||||||
|
if (auto hit = tryMatch(map, false))
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& map : maps)
|
||||||
|
{
|
||||||
|
if (auto hit = tryMatch(map, true))
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
for (const auto& map : maps)
|
||||||
|
{
|
||||||
|
if (auto hit = tryMatch(map, false))
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string getActiveMapIdFromDb(Database& db)
|
||||||
|
{
|
||||||
|
nlohmann::json rt;
|
||||||
|
if (!db.getDocument("robot_runtime", rt) || !rt.is_object())
|
||||||
|
return "";
|
||||||
|
if (!rt.contains("active_map_id") || rt["active_map_id"].is_null())
|
||||||
|
return "";
|
||||||
|
return rt.value("active_map_id", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool setActiveMapOnRobotRuntimeDoc(Database& db, const std::string& map_id, std::string& err)
|
||||||
|
{
|
||||||
|
if (map_id.empty())
|
||||||
|
{
|
||||||
|
err = "map_id is required";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
nlohmann::json rt;
|
||||||
|
if (!db.getDocument("robot_runtime", rt) || !rt.is_object())
|
||||||
|
rt = nlohmann::json::object();
|
||||||
|
rt["active_map_id"] = map_id;
|
||||||
|
rt["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
if (!db.setDocument("robot_runtime", rt))
|
||||||
|
{
|
||||||
|
err = "failed to update robot_runtime";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
35
src/mission/position_resolver.hpp
Normal file
35
src/mission/position_resolver.hpp
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class MapStore;
|
||||||
|
|
||||||
|
struct ResolvedPosition
|
||||||
|
{
|
||||||
|
std::string map_id;
|
||||||
|
std::string position_id;
|
||||||
|
std::string name;
|
||||||
|
std::string label;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PositionResolver
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit PositionResolver(MapStore& maps);
|
||||||
|
|
||||||
|
std::optional<ResolvedPosition> resolve(const std::string& ref, const std::string& prefer_map_id = "") const;
|
||||||
|
nlohmann::json listPositions() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
MapStore& maps_;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string getActiveMapIdFromDb(class Database& db);
|
||||||
|
bool setActiveMapOnRobotRuntimeDoc(class Database& db, const std::string& map_id, std::string& err);
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
@@ -262,6 +262,29 @@ void RobotRuntime::clearActiveMapIf(const std::string& map_id)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool RobotRuntime::queueSoundPlay(const std::string& sound_id, int volume, std::string& err)
|
||||||
|
{
|
||||||
|
if (sound_id.empty())
|
||||||
|
{
|
||||||
|
err = "sound_id is required";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int vol = volume;
|
||||||
|
if (vol < 0)
|
||||||
|
vol = 0;
|
||||||
|
if (vol > 100)
|
||||||
|
vol = 100;
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
state_["sound_play"] = {{"sound_id", sound_id},
|
||||||
|
{"volume", vol},
|
||||||
|
{"requested_at", IdUtil::nowIso8601()}};
|
||||||
|
state_["message"] = "Playing sound on robot";
|
||||||
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
saveUnlocked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void RobotRuntime::tick()
|
void RobotRuntime::tick()
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public:
|
|||||||
bool setJoystick(bool engaged, const std::string& speed, std::string& err);
|
bool setJoystick(bool engaged, const std::string& speed, std::string& err);
|
||||||
bool setActiveMap(const std::string& map_id, std::string& err);
|
bool setActiveMap(const std::string& map_id, std::string& err);
|
||||||
void clearActiveMapIf(const std::string& map_id);
|
void clearActiveMapIf(const std::string& map_id);
|
||||||
|
bool queueSoundPlay(const std::string& sound_id, int volume, std::string& err);
|
||||||
void tick();
|
void tick();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -323,6 +323,37 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
|||||||
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
svr.Post(R"(/api/sounds/([^/]+)/play$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
const auto sound = sound_store_.find(id);
|
||||||
|
if (!sound)
|
||||||
|
return HttpUtil::jsonError(res, 404, "sound not found");
|
||||||
|
if (sound->value("enabled", true) == false)
|
||||||
|
return HttpUtil::jsonError(res, 400, "sound is disabled");
|
||||||
|
|
||||||
|
int volume = sound->value("volume", 100);
|
||||||
|
if (!req.body.empty())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const auto body = nlohmann::json::parse(req.body);
|
||||||
|
if (body.contains("volume") && body["volume"].is_number_integer())
|
||||||
|
volume = body["volume"].get<int>();
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
if (!robot_runtime_.queueSoundPlay(id, volume, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = nlohmann::json({{"ok", true}, {"sound_id", id}, {"volume", volume}}).dump();
|
||||||
|
});
|
||||||
|
|
||||||
svr.Get("/api/recordings", [](const httplib::Request&, httplib::Response& res) {
|
svr.Get("/api/recordings", [](const httplib::Request&, httplib::Response& res) {
|
||||||
HttpUtil::addCors(res);
|
HttpUtil::addCors(res);
|
||||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ ApiServer::ApiServer(StateRepository& repo,
|
|||||||
MapStore& map_store,
|
MapStore& map_store,
|
||||||
SiteStore& site_store,
|
SiteStore& site_store,
|
||||||
SoundStore& sound_store,
|
SoundStore& sound_store,
|
||||||
|
TransitionStore& transition_store,
|
||||||
DashboardStore& dashboard_store)
|
DashboardStore& dashboard_store)
|
||||||
: repo_(repo),
|
: repo_(repo),
|
||||||
mission_queue_(mission_queue),
|
mission_queue_(mission_queue),
|
||||||
@@ -29,6 +30,7 @@ ApiServer::ApiServer(StateRepository& repo,
|
|||||||
map_store_(map_store),
|
map_store_(map_store),
|
||||||
site_store_(site_store),
|
site_store_(site_store),
|
||||||
sound_store_(sound_store),
|
sound_store_(sound_store),
|
||||||
|
transition_store_(transition_store),
|
||||||
dashboard_store_(dashboard_store)
|
dashboard_store_(dashboard_store)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -550,6 +552,7 @@ void ApiServer::registerRoutes(httplib::Server& svr)
|
|||||||
registerMirV2Routes(svr);
|
registerMirV2Routes(svr);
|
||||||
registerRobotRoutes(svr);
|
registerRobotRoutes(svr);
|
||||||
registerMediaRoutes(svr);
|
registerMediaRoutes(svr);
|
||||||
|
registerTransitionRoutes(svr);
|
||||||
registerDashboardRoutes(svr);
|
registerDashboardRoutes(svr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "storage/map_store.hpp"
|
#include "storage/map_store.hpp"
|
||||||
#include "storage/site_store.hpp"
|
#include "storage/site_store.hpp"
|
||||||
#include "storage/sound_store.hpp"
|
#include "storage/sound_store.hpp"
|
||||||
|
#include "storage/transition_store.hpp"
|
||||||
#include "storage/state_repository.hpp"
|
#include "storage/state_repository.hpp"
|
||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
@@ -27,6 +28,7 @@ public:
|
|||||||
MapStore& map_store,
|
MapStore& map_store,
|
||||||
SiteStore& site_store,
|
SiteStore& site_store,
|
||||||
SoundStore& sound_store,
|
SoundStore& sound_store,
|
||||||
|
TransitionStore& transition_store,
|
||||||
DashboardStore& dashboard_store);
|
DashboardStore& dashboard_store);
|
||||||
|
|
||||||
void registerRoutes(httplib::Server& svr);
|
void registerRoutes(httplib::Server& svr);
|
||||||
@@ -41,6 +43,7 @@ private:
|
|||||||
MapStore& map_store_;
|
MapStore& map_store_;
|
||||||
SiteStore& site_store_;
|
SiteStore& site_store_;
|
||||||
SoundStore& sound_store_;
|
SoundStore& sound_store_;
|
||||||
|
TransitionStore& transition_store_;
|
||||||
DashboardStore& dashboard_store_;
|
DashboardStore& dashboard_store_;
|
||||||
|
|
||||||
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
|
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
|
||||||
@@ -51,6 +54,7 @@ private:
|
|||||||
void registerIntegrationRoutes(httplib::Server& svr);
|
void registerIntegrationRoutes(httplib::Server& svr);
|
||||||
void registerRobotRoutes(httplib::Server& svr);
|
void registerRobotRoutes(httplib::Server& svr);
|
||||||
void registerMediaRoutes(httplib::Server& svr);
|
void registerMediaRoutes(httplib::Server& svr);
|
||||||
|
void registerTransitionRoutes(httplib::Server& svr);
|
||||||
void registerDashboardRoutes(httplib::Server& svr);
|
void registerDashboardRoutes(httplib::Server& svr);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
86
src/server/api_transition_routes.cpp
Normal file
86
src/server/api_transition_routes.cpp
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#include "server/api_server.hpp"
|
||||||
|
|
||||||
|
#include "auth/auth_service.hpp"
|
||||||
|
#include "util/http_util.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
void ApiServer::registerTransitionRoutes(httplib::Server& svr)
|
||||||
|
{
|
||||||
|
svr.Get("/api/transitions", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string site_id = req.has_param("site_id") ? req.get_param_value("site_id") : "";
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = nlohmann::json({{"transitions", transition_store_.list(site_id)}}).dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Get(R"(/api/transitions/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
const auto tr = transition_store_.find(id);
|
||||||
|
if (!tr)
|
||||||
|
return HttpUtil::jsonError(res, 404, "transition not found");
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = tr->dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/transitions", [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 (const AuthSession* session = AuthService::activeSession())
|
||||||
|
{
|
||||||
|
if (!body.contains("created_by") || !body["created_by"].is_string() ||
|
||||||
|
body["created_by"].get<std::string>().empty())
|
||||||
|
{
|
||||||
|
body["created_by"] = session->group_name.empty() ? session->username : session->group_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
const auto created = transition_store_.create(body, err);
|
||||||
|
if (!created)
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.status = 201;
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = created->dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put(R"(/api/transitions/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
if (!transition_store_.update(id, body, err))
|
||||||
|
return HttpUtil::jsonError(res, 404, err);
|
||||||
|
const auto updated = transition_store_.find(id);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Delete(R"(/api/transitions/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
std::string err;
|
||||||
|
if (!transition_store_.remove(id, err))
|
||||||
|
return HttpUtil::jsonError(res, 404, err);
|
||||||
|
res.status = 204;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
|
|
||||||
@@ -78,6 +78,8 @@ CREATE TABLE IF NOT EXISTS sounds (
|
|||||||
file_name TEXT,
|
file_name TEXT,
|
||||||
duration_ms INTEGER,
|
duration_ms INTEGER,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
volume INTEGER NOT NULL DEFAULT 100,
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
@@ -93,6 +95,19 @@ CREATE TABLE IF NOT EXISTS recordings (
|
|||||||
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE SET NULL
|
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS transitions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
site_id TEXT NOT NULL,
|
||||||
|
from_map_id TEXT NOT NULL,
|
||||||
|
to_map_id TEXT NOT NULL,
|
||||||
|
start_position_id TEXT NOT NULL,
|
||||||
|
goal_position_id TEXT NOT NULL,
|
||||||
|
mission_id TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dashboards (
|
CREATE TABLE IF NOT EXISTS dashboards (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -335,6 +350,51 @@ bool Database::applySchemaMigrations(std::string& err)
|
|||||||
setMeta("schema_version", "3");
|
setMeta("schema_version", "3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ver = getMeta("schema_version").value_or("1");
|
||||||
|
if (ver == "3")
|
||||||
|
{
|
||||||
|
if (!tableHasColumn(db_, "sounds", "volume"))
|
||||||
|
{
|
||||||
|
if (!execSql(db_, "ALTER TABLE sounds ADD COLUMN volume INTEGER NOT NULL DEFAULT 100", err))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!tableHasColumn(db_, "sounds", "is_system"))
|
||||||
|
{
|
||||||
|
if (!execSql(db_, "ALTER TABLE sounds ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0", err))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setMeta("schema_version", "4");
|
||||||
|
}
|
||||||
|
|
||||||
|
ver = getMeta("schema_version").value_or("1");
|
||||||
|
if (ver == "4")
|
||||||
|
{
|
||||||
|
if (!execSql(db_,
|
||||||
|
"CREATE TABLE IF NOT EXISTS transitions ("
|
||||||
|
"id TEXT PRIMARY KEY, "
|
||||||
|
"site_id TEXT NOT NULL, "
|
||||||
|
"from_map_id TEXT NOT NULL, "
|
||||||
|
"to_map_id TEXT NOT NULL, "
|
||||||
|
"start_position_id TEXT NOT NULL, "
|
||||||
|
"goal_position_id TEXT NOT NULL, "
|
||||||
|
"mission_id TEXT NOT NULL, "
|
||||||
|
"created_by TEXT NOT NULL DEFAULT '', "
|
||||||
|
"created_at TEXT NOT NULL, "
|
||||||
|
"updated_at TEXT NOT NULL"
|
||||||
|
")",
|
||||||
|
err))
|
||||||
|
return false;
|
||||||
|
setMeta("schema_version", "5");
|
||||||
|
}
|
||||||
|
|
||||||
|
ver = getMeta("schema_version").value_or("1");
|
||||||
|
if (ver == "5")
|
||||||
|
{
|
||||||
|
if (!execSql(db_, "ALTER TABLE transitions ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err))
|
||||||
|
return false;
|
||||||
|
setMeta("schema_version", "6");
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,21 @@
|
|||||||
#include "util/id_util.hpp"
|
#include "util/id_util.hpp"
|
||||||
#include "util/string_util.hpp"
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
#include <sqlite3.h>
|
#include <sqlite3.h>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
constexpr const char* kSoundSelect =
|
||||||
|
"SELECT id, name, description, file_name, duration_ms, enabled, volume, is_system, created_at, updated_at "
|
||||||
|
"FROM sounds";
|
||||||
|
|
||||||
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||||
{
|
{
|
||||||
auto textOrNull = [&](int col) -> nlohmann::json {
|
auto textOrNull = [&](int col) -> nlohmann::json {
|
||||||
@@ -26,8 +35,44 @@ nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
|||||||
? nlohmann::json(nullptr)
|
? nlohmann::json(nullptr)
|
||||||
: nlohmann::json(sqlite3_column_int(stmt, 4))},
|
: nlohmann::json(sqlite3_column_int(stmt, 4))},
|
||||||
{"enabled", sqlite3_column_int(stmt, 5) != 0},
|
{"enabled", sqlite3_column_int(stmt, 5) != 0},
|
||||||
{"created_at", textOrNull(6)},
|
{"volume", sqlite3_column_int(stmt, 6)},
|
||||||
{"updated_at", textOrNull(7)}};
|
{"is_system", sqlite3_column_int(stmt, 7) != 0},
|
||||||
|
{"created_at", textOrNull(8)},
|
||||||
|
{"updated_at", textOrNull(9)}};
|
||||||
|
}
|
||||||
|
|
||||||
|
int clampVolume(int v)
|
||||||
|
{
|
||||||
|
if (v < 0)
|
||||||
|
return 0;
|
||||||
|
if (v > 100)
|
||||||
|
return 100;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isReservedSystemName(const std::string& name)
|
||||||
|
{
|
||||||
|
const std::string lower = StringUtil::toLower(StringUtil::trimCopy(name));
|
||||||
|
return lower == "beep" || lower == "horn" || lower == "chime";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool findNameConflict(sqlite3* db, const std::string& name, const std::string& except_id)
|
||||||
|
{
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db,
|
||||||
|
except_id.empty()
|
||||||
|
? "SELECT id FROM sounds WHERE lower(name) = lower(?1) LIMIT 1"
|
||||||
|
: "SELECT id FROM sounds WHERE lower(name) = lower(?1) AND id != ?2 LIMIT 1",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
return false;
|
||||||
|
sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
if (!except_id.empty())
|
||||||
|
sqlite3_bind_text(stmt, 2, except_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
const bool conflict = sqlite3_step(stmt) == SQLITE_ROW;
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return conflict;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -38,13 +83,9 @@ nlohmann::json SoundStore::list() const
|
|||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
nlohmann::json sounds = nlohmann::json::array();
|
nlohmann::json sounds = nlohmann::json::array();
|
||||||
|
const std::string query = std::string(kSoundSelect) + " ORDER BY is_system DESC, name";
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_.handle(),
|
if (sqlite3_prepare_v2(db_.handle(), query.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
"SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at "
|
|
||||||
"FROM sounds ORDER BY name",
|
|
||||||
-1,
|
|
||||||
&stmt,
|
|
||||||
nullptr) != SQLITE_OK)
|
|
||||||
return sounds;
|
return sounds;
|
||||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
sounds.push_back(rowToJson(stmt));
|
sounds.push_back(rowToJson(stmt));
|
||||||
@@ -55,13 +96,9 @@ nlohmann::json SoundStore::list() const
|
|||||||
std::optional<nlohmann::json> SoundStore::find(const std::string& id) const
|
std::optional<nlohmann::json> SoundStore::find(const std::string& id) const
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
const std::string query = std::string(kSoundSelect) + " WHERE id = ?1";
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_.handle(),
|
if (sqlite3_prepare_v2(db_.handle(), query.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
"SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at "
|
|
||||||
"FROM sounds WHERE id = ?1",
|
|
||||||
-1,
|
|
||||||
&stmt,
|
|
||||||
nullptr) != SQLITE_OK)
|
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
std::optional<nlohmann::json> out;
|
std::optional<nlohmann::json> out;
|
||||||
@@ -84,17 +121,31 @@ std::optional<nlohmann::json> SoundStore::create(const nlohmann::json& payload,
|
|||||||
err = "name is required";
|
err = "name is required";
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
if (isReservedSystemName(name))
|
||||||
|
{
|
||||||
|
err = "name reserved for built-in system sounds";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (findNameConflict(db_.handle(), name, ""))
|
||||||
|
{
|
||||||
|
err = "sound name already exists";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
const std::string id = payload.value("id", IdUtil::newId());
|
const std::string id = payload.value("id", IdUtil::newId());
|
||||||
const std::string now = IdUtil::nowIso8601();
|
const std::string now = IdUtil::nowIso8601();
|
||||||
const std::string description = payload.value("description", "");
|
const std::string description = payload.value("description", "");
|
||||||
const bool enabled = payload.value("enabled", true);
|
const bool enabled = payload.value("enabled", true);
|
||||||
|
const int volume = clampVolume(payload.value("volume", 100));
|
||||||
|
const bool is_system = payload.value("is_system", false);
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_.handle(),
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
"INSERT INTO sounds(id, name, description, file_name, duration_ms, enabled, created_at, updated_at) "
|
"INSERT INTO sounds(id, name, description, file_name, duration_ms, enabled, volume, "
|
||||||
"VALUES(?1,?2,?3,NULL,NULL,?4,?5,?6)",
|
"is_system, created_at, updated_at) "
|
||||||
|
"VALUES(?1,?2,?3,NULL,NULL,?4,?5,?6,?7,?8)",
|
||||||
-1,
|
-1,
|
||||||
&stmt,
|
&stmt,
|
||||||
nullptr) != SQLITE_OK)
|
nullptr) != SQLITE_OK)
|
||||||
@@ -106,8 +157,10 @@ std::optional<nlohmann::json> SoundStore::create(const nlohmann::json& payload,
|
|||||||
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT);
|
sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
sqlite3_bind_int(stmt, 4, enabled ? 1 : 0);
|
sqlite3_bind_int(stmt, 4, enabled ? 1 : 0);
|
||||||
sqlite3_bind_text(stmt, 5, now.c_str(), -1, SQLITE_TRANSIENT);
|
sqlite3_bind_int(stmt, 5, volume);
|
||||||
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
|
sqlite3_bind_int(stmt, 6, is_system ? 1 : 0);
|
||||||
|
sqlite3_bind_text(stmt, 7, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
|
||||||
if (sqlite3_step(stmt) != SQLITE_DONE)
|
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||||
{
|
{
|
||||||
@@ -123,6 +176,8 @@ std::optional<nlohmann::json> SoundStore::create(const nlohmann::json& payload,
|
|||||||
{"file_name", nullptr},
|
{"file_name", nullptr},
|
||||||
{"duration_ms", nullptr},
|
{"duration_ms", nullptr},
|
||||||
{"enabled", enabled},
|
{"enabled", enabled},
|
||||||
|
{"volume", volume},
|
||||||
|
{"is_system", is_system},
|
||||||
{"created_at", now},
|
{"created_at", now},
|
||||||
{"updated_at", now}};
|
{"updated_at", now}};
|
||||||
}
|
}
|
||||||
@@ -136,18 +191,42 @@ bool SoundStore::update(const std::string& id, const nlohmann::json& payload, st
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bool is_system = existing->value("is_system", false);
|
||||||
nlohmann::json merged = *existing;
|
nlohmann::json merged = *existing;
|
||||||
for (const char* key : {"name", "description", "enabled", "duration_ms"})
|
for (const char* key : {"description", "enabled", "duration_ms", "volume"})
|
||||||
{
|
{
|
||||||
if (payload.contains(key))
|
if (payload.contains(key))
|
||||||
merged[key] = payload[key];
|
merged[key] = payload[key];
|
||||||
}
|
}
|
||||||
|
if (!is_system && payload.contains("name"))
|
||||||
|
{
|
||||||
|
const std::string new_name = StringUtil::trimCopy(payload["name"].get<std::string>());
|
||||||
|
if (new_name.empty())
|
||||||
|
{
|
||||||
|
err = "name is required";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isReservedSystemName(new_name))
|
||||||
|
{
|
||||||
|
err = "name reserved for built-in system sounds";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (findNameConflict(db_.handle(), new_name, id))
|
||||||
|
{
|
||||||
|
err = "sound name already exists";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
merged["name"] = new_name;
|
||||||
|
}
|
||||||
|
if (merged.contains("volume"))
|
||||||
|
merged["volume"] = clampVolume(merged["volume"].get<int>());
|
||||||
|
|
||||||
const std::string now = IdUtil::nowIso8601();
|
const std::string now = IdUtil::nowIso8601();
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_.handle(),
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
"UPDATE sounds SET name=?2, description=?3, enabled=?4, duration_ms=?5, updated_at=?6 WHERE id=?1",
|
"UPDATE sounds SET name=?2, description=?3, enabled=?4, duration_ms=?5, volume=?6, "
|
||||||
|
"updated_at=?7 WHERE id=?1",
|
||||||
-1,
|
-1,
|
||||||
&stmt,
|
&stmt,
|
||||||
nullptr) != SQLITE_OK)
|
nullptr) != SQLITE_OK)
|
||||||
@@ -163,7 +242,8 @@ bool SoundStore::update(const std::string& id, const nlohmann::json& payload, st
|
|||||||
sqlite3_bind_int(stmt, 5, merged["duration_ms"].get<int>());
|
sqlite3_bind_int(stmt, 5, merged["duration_ms"].get<int>());
|
||||||
else
|
else
|
||||||
sqlite3_bind_null(stmt, 5);
|
sqlite3_bind_null(stmt, 5);
|
||||||
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
|
sqlite3_bind_int(stmt, 6, clampVolume(merged.value("volume", 100)));
|
||||||
|
sqlite3_bind_text(stmt, 7, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
|
||||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||||
if (!ok)
|
if (!ok)
|
||||||
@@ -180,6 +260,11 @@ bool SoundStore::remove(const std::string& id, std::string& err)
|
|||||||
err = "sound not found";
|
err = "sound not found";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (existing->value("is_system", false))
|
||||||
|
{
|
||||||
|
err = "system sounds cannot be deleted";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
@@ -217,11 +302,17 @@ bool SoundStore::saveFile(const std::string& id,
|
|||||||
const std::string& bytes,
|
const std::string& bytes,
|
||||||
std::string& err)
|
std::string& err)
|
||||||
{
|
{
|
||||||
if (!find(id))
|
auto existing = find(id);
|
||||||
|
if (!existing)
|
||||||
{
|
{
|
||||||
err = "sound not found";
|
err = "sound not found";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (existing->value("is_system", false))
|
||||||
|
{
|
||||||
|
err = "system sounds cannot be replaced";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
std::filesystem::create_directories(db_.soundsDir(), ec);
|
std::filesystem::create_directories(db_.soundsDir(), ec);
|
||||||
@@ -254,4 +345,57 @@ bool SoundStore::saveFile(const std::string& id,
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool SoundStore::ensureSystemDefaults(std::string& err)
|
||||||
|
{
|
||||||
|
{
|
||||||
|
std::vector<std::string> dup_ids;
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"SELECT id FROM sounds WHERE is_system = 0 AND lower(name) IN ('beep', 'horn', 'chime')",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) == SQLITE_OK)
|
||||||
|
{
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
{
|
||||||
|
if (sqlite3_column_type(stmt, 0) != SQLITE_NULL)
|
||||||
|
dup_ids.push_back(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0)));
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
for (const auto& dup_id : dup_ids)
|
||||||
|
{
|
||||||
|
sqlite3_stmt* del = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM sounds WHERE id = ?1", -1, &del, nullptr) == SQLITE_OK)
|
||||||
|
{
|
||||||
|
sqlite3_bind_text(del, 1, dup_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_step(del);
|
||||||
|
sqlite3_finalize(del);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DefaultSound
|
||||||
|
{
|
||||||
|
const char* id;
|
||||||
|
const char* name;
|
||||||
|
};
|
||||||
|
static const DefaultSound kDefaults[] = {
|
||||||
|
{"sys_beep", "Beep"},
|
||||||
|
{"sys_horn", "Horn"},
|
||||||
|
{"sys_chime", "Chime"},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto& d : kDefaults)
|
||||||
|
{
|
||||||
|
if (find(d.id))
|
||||||
|
continue;
|
||||||
|
nlohmann::json payload = {{"id", d.id}, {"name", d.name}, {"is_system", true}, {"volume", 100}};
|
||||||
|
if (!create(payload, err))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace lm
|
} // namespace lm
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public:
|
|||||||
|
|
||||||
std::optional<std::filesystem::path> filePath(const std::string& id) const;
|
std::optional<std::filesystem::path> filePath(const std::string& id) const;
|
||||||
bool saveFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
|
bool saveFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
|
||||||
|
bool ensureSystemDefaults(std::string& err);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Database& db_;
|
Database& db_;
|
||||||
|
|||||||
258
src/storage/transition_store.cpp
Normal file
258
src/storage/transition_store.cpp
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
#include "storage/transition_store.hpp"
|
||||||
|
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||||
|
{
|
||||||
|
auto text = [&](int col) -> std::string {
|
||||||
|
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||||
|
return "";
|
||||||
|
const char* v = reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
|
||||||
|
return v ? std::string(v) : "";
|
||||||
|
};
|
||||||
|
return {{"id", text(0)},
|
||||||
|
{"site_id", text(1)},
|
||||||
|
{"from_map_id", text(2)},
|
||||||
|
{"to_map_id", text(3)},
|
||||||
|
{"start_position_id", text(4)},
|
||||||
|
{"goal_position_id", text(5)},
|
||||||
|
{"mission_id", text(6)},
|
||||||
|
{"created_by", text(7)},
|
||||||
|
{"created_at", text(8)},
|
||||||
|
{"updated_at", text(9)}};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TransitionStore::TransitionStore(Database& db) : db_(db) {}
|
||||||
|
|
||||||
|
nlohmann::json TransitionStore::list(const std::string& site_id) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
nlohmann::json out = nlohmann::json::array();
|
||||||
|
std::string sql =
|
||||||
|
"SELECT id, site_id, from_map_id, to_map_id, start_position_id, goal_position_id, mission_id, created_by, created_at, updated_at "
|
||||||
|
"FROM transitions";
|
||||||
|
if (!site_id.empty())
|
||||||
|
sql += " WHERE site_id = ?1";
|
||||||
|
sql += " ORDER BY site_id, from_map_id, to_map_id, created_at";
|
||||||
|
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
|
return out;
|
||||||
|
if (!site_id.empty())
|
||||||
|
sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
out.push_back(rowToJson(stmt));
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> TransitionStore::findBetween(const std::string& from_map_id,
|
||||||
|
const std::string& to_map_id) const
|
||||||
|
{
|
||||||
|
if (from_map_id.empty() || to_map_id.empty() || from_map_id == to_map_id)
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"SELECT id, site_id, from_map_id, to_map_id, start_position_id, goal_position_id, mission_id, "
|
||||||
|
"created_by, created_at, updated_at FROM transitions WHERE from_map_id = ?1 AND to_map_id = ?2 LIMIT 1",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
return std::nullopt;
|
||||||
|
sqlite3_bind_text(stmt, 1, from_map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, to_map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
std::optional<nlohmann::json> out;
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
out = rowToJson(stmt);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> TransitionStore::find(const std::string& id) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"SELECT id, site_id, from_map_id, to_map_id, start_position_id, goal_position_id, mission_id, "
|
||||||
|
"created_by, created_at, updated_at FROM transitions WHERE id = ?1",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
return std::nullopt;
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
std::optional<nlohmann::json> out;
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
out = rowToJson(stmt);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> TransitionStore::create(const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
if (!payload.is_object())
|
||||||
|
{
|
||||||
|
err = "payload must be an object";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const std::string site_id = StringUtil::trimCopy(payload.value("site_id", ""));
|
||||||
|
const std::string from_map_id = StringUtil::trimCopy(payload.value("from_map_id", ""));
|
||||||
|
const std::string to_map_id = StringUtil::trimCopy(payload.value("to_map_id", ""));
|
||||||
|
const std::string start_position_id = StringUtil::trimCopy(payload.value("start_position_id", ""));
|
||||||
|
const std::string goal_position_id = StringUtil::trimCopy(payload.value("goal_position_id", ""));
|
||||||
|
const std::string mission_id = StringUtil::trimCopy(payload.value("mission_id", ""));
|
||||||
|
const std::string created_by = StringUtil::trimCopy(payload.value("created_by", ""));
|
||||||
|
if (site_id.empty() || from_map_id.empty() || to_map_id.empty() || start_position_id.empty() || goal_position_id.empty() ||
|
||||||
|
mission_id.empty())
|
||||||
|
{
|
||||||
|
err = "missing required fields";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (from_map_id == to_map_id)
|
||||||
|
{
|
||||||
|
err = "from_map_id and to_map_id must differ";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string id = payload.value("id", IdUtil::newId());
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"INSERT INTO transitions(id, site_id, from_map_id, to_map_id, start_position_id, goal_position_id, "
|
||||||
|
"mission_id, created_by, created_at, updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 3, from_map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 4, to_map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 5, start_position_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 6, goal_position_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 7, mission_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 8, created_by.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 9, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 10, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return nlohmann::json{{"id", id},
|
||||||
|
{"site_id", site_id},
|
||||||
|
{"from_map_id", from_map_id},
|
||||||
|
{"to_map_id", to_map_id},
|
||||||
|
{"start_position_id", start_position_id},
|
||||||
|
{"goal_position_id", goal_position_id},
|
||||||
|
{"mission_id", mission_id},
|
||||||
|
{"created_by", created_by},
|
||||||
|
{"created_at", now},
|
||||||
|
{"updated_at", now}};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TransitionStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
auto existing = find(id);
|
||||||
|
if (!existing)
|
||||||
|
{
|
||||||
|
err = "transition not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json merged = *existing;
|
||||||
|
for (const char* key :
|
||||||
|
{"site_id", "from_map_id", "to_map_id", "start_position_id", "goal_position_id", "mission_id"})
|
||||||
|
{
|
||||||
|
if (payload.contains(key))
|
||||||
|
merged[key] = payload[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string site_id = StringUtil::trimCopy(merged.value("site_id", ""));
|
||||||
|
const std::string from_map_id = StringUtil::trimCopy(merged.value("from_map_id", ""));
|
||||||
|
const std::string to_map_id = StringUtil::trimCopy(merged.value("to_map_id", ""));
|
||||||
|
const std::string start_position_id = StringUtil::trimCopy(merged.value("start_position_id", ""));
|
||||||
|
const std::string goal_position_id = StringUtil::trimCopy(merged.value("goal_position_id", ""));
|
||||||
|
const std::string mission_id = StringUtil::trimCopy(merged.value("mission_id", ""));
|
||||||
|
if (site_id.empty() || from_map_id.empty() || to_map_id.empty() || start_position_id.empty() || goal_position_id.empty() ||
|
||||||
|
mission_id.empty())
|
||||||
|
{
|
||||||
|
err = "missing required fields";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (from_map_id == to_map_id)
|
||||||
|
{
|
||||||
|
err = "from_map_id and to_map_id must differ";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"UPDATE transitions SET site_id=?2, from_map_id=?3, to_map_id=?4, start_position_id=?5, "
|
||||||
|
"goal_position_id=?6, mission_id=?7, updated_at=?8 WHERE id=?1",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 3, from_map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 4, to_map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 5, start_position_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 6, goal_position_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 7, mission_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||||
|
if (!ok)
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TransitionStore::remove(const std::string& id, std::string& err)
|
||||||
|
{
|
||||||
|
if (!find(id))
|
||||||
|
{
|
||||||
|
err = "transition not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM transitions WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||||
|
if (!ok)
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
|
|
||||||
32
src/storage/transition_store.hpp
Normal file
32
src/storage/transition_store.hpp
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class Database;
|
||||||
|
|
||||||
|
class TransitionStore
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit TransitionStore(Database& db);
|
||||||
|
|
||||||
|
nlohmann::json list(const std::string& site_id = "") const;
|
||||||
|
std::optional<nlohmann::json> find(const std::string& id) const;
|
||||||
|
std::optional<nlohmann::json> findBetween(const std::string& from_map_id,
|
||||||
|
const std::string& to_map_id) const;
|
||||||
|
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
|
||||||
|
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
|
||||||
|
bool remove(const std::string& id, std::string& err);
|
||||||
|
|
||||||
|
private:
|
||||||
|
Database& db_;
|
||||||
|
mutable std::mutex mu_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ const pageMapsEl = el("pageMaps");
|
|||||||
const pageMissionsEl = el("pageMissions");
|
const pageMissionsEl = el("pageMissions");
|
||||||
const pageIntegrationsEl = el("pageIntegrations");
|
const pageIntegrationsEl = el("pageIntegrations");
|
||||||
const pageSoundsEl = el("pageSounds");
|
const pageSoundsEl = el("pageSounds");
|
||||||
|
const pageTransitionsEl = el("pageTransitions");
|
||||||
const pageMonitoringEl = el("pageMonitoring");
|
const pageMonitoringEl = el("pageMonitoring");
|
||||||
const pageHelpEl = el("pageHelp");
|
const pageHelpEl = el("pageHelp");
|
||||||
const contentEl = document.querySelector(".content");
|
const contentEl = document.querySelector(".content");
|
||||||
@@ -125,7 +126,7 @@ const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setActivePage(page) {
|
function setActivePage(page) {
|
||||||
const valid = ["dashboard", "config", "maps", "missions", "sounds", "integrations", "monitoring", "help"];
|
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "integrations", "monitoring", "help"];
|
||||||
let p = valid.includes(page) ? page : "missions";
|
let p = valid.includes(page) ? page : "missions";
|
||||||
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
||||||
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
||||||
@@ -137,6 +138,7 @@ function setActivePage(page) {
|
|||||||
if (pageMapsEl) pageMapsEl.hidden = p !== "maps";
|
if (pageMapsEl) pageMapsEl.hidden = p !== "maps";
|
||||||
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
||||||
if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds";
|
if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds";
|
||||||
|
if (pageTransitionsEl) pageTransitionsEl.hidden = p !== "transitions";
|
||||||
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
||||||
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
||||||
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
|
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
|
||||||
@@ -148,6 +150,7 @@ function setActivePage(page) {
|
|||||||
contentEl.classList.toggle("content--maps", p === "maps");
|
contentEl.classList.toggle("content--maps", p === "maps");
|
||||||
contentEl.classList.toggle("content--missions", p === "missions");
|
contentEl.classList.toggle("content--missions", p === "missions");
|
||||||
contentEl.classList.toggle("content--sounds", p === "sounds");
|
contentEl.classList.toggle("content--sounds", p === "sounds");
|
||||||
|
contentEl.classList.toggle("content--transitions", p === "transitions");
|
||||||
contentEl.classList.toggle("content--integrations", p === "integrations");
|
contentEl.classList.toggle("content--integrations", p === "integrations");
|
||||||
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
||||||
contentEl.classList.toggle("content--help", p === "help");
|
contentEl.classList.toggle("content--help", p === "help");
|
||||||
@@ -157,6 +160,8 @@ function setActivePage(page) {
|
|||||||
if (p === "maps" && window.MapsApp) window.MapsApp.onPageShow();
|
if (p === "maps" && window.MapsApp) window.MapsApp.onPageShow();
|
||||||
if (p === "sounds" && window.SoundsApp) window.SoundsApp.onPageShow();
|
if (p === "sounds" && window.SoundsApp) window.SoundsApp.onPageShow();
|
||||||
else if (window.SoundsApp?.onPageHide) window.SoundsApp.onPageHide();
|
else if (window.SoundsApp?.onPageHide) window.SoundsApp.onPageHide();
|
||||||
|
if (p === "transitions" && window.TransitionsApp) window.TransitionsApp.onPageShow();
|
||||||
|
else if (window.TransitionsApp?.onPageHide) window.TransitionsApp.onPageHide();
|
||||||
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
||||||
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
|
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
|
||||||
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
|
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
|
||||||
|
|||||||
@@ -149,7 +149,8 @@
|
|||||||
dashboard: "dashboard",
|
dashboard: "dashboard",
|
||||||
maps: "maps",
|
maps: "maps",
|
||||||
missions: "missions",
|
missions: "missions",
|
||||||
sounds: "integrations",
|
sounds: "sounds",
|
||||||
|
transitions: "maps",
|
||||||
integrations: "integrations",
|
integrations: "integrations",
|
||||||
};
|
};
|
||||||
const resource = map[page];
|
const resource = map[page];
|
||||||
@@ -168,6 +169,7 @@
|
|||||||
document.body.classList.toggle("auth-readonly-config", !canWrite("config"));
|
document.body.classList.toggle("auth-readonly-config", !canWrite("config"));
|
||||||
document.body.classList.toggle("auth-readonly-maps", !canWrite("maps"));
|
document.body.classList.toggle("auth-readonly-maps", !canWrite("maps"));
|
||||||
document.body.classList.toggle("auth-readonly-missions", !canWrite("missions"));
|
document.body.classList.toggle("auth-readonly-missions", !canWrite("missions"));
|
||||||
|
document.body.classList.toggle("auth-readonly-sounds", !canWrite("sounds") && !canWrite("integrations"));
|
||||||
document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations"));
|
document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
www/i18n.js
114
www/i18n.js
@@ -73,6 +73,7 @@
|
|||||||
"nav.missions": "Missions",
|
"nav.missions": "Missions",
|
||||||
"nav.maps": "Maps",
|
"nav.maps": "Maps",
|
||||||
"nav.sounds": "Sounds",
|
"nav.sounds": "Sounds",
|
||||||
|
"nav.transitions": "Transitions",
|
||||||
"nav.build-robot": "Build Robot",
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"nav.monitoring-log": "System log",
|
||||||
"nav.integrations": "Tích hợp",
|
"nav.integrations": "Tích hợp",
|
||||||
@@ -486,22 +487,73 @@
|
|||||||
"maps.menu.save": "Lưu map",
|
"maps.menu.save": "Lưu map",
|
||||||
|
|
||||||
"sounds.title": "Sounds",
|
"sounds.title": "Sounds",
|
||||||
"sounds.subtitle": "Setup → Sounds — upload và quản lý âm thanh robot cho sound zones.",
|
"sounds.subtitle": "Tạo và chỉnh sửa sounds.",
|
||||||
|
"sounds.helpTitle": "Trợ giúp Sounds",
|
||||||
|
"sounds.helpBody": "Upload âm thanh cho sound zones trên map. System sounds (Beep, Horn, Chime) không thể xóa. Dùng Listen hoặc Play on robot để kiểm tra volume.",
|
||||||
"sounds.create": "Tạo sound",
|
"sounds.create": "Tạo sound",
|
||||||
|
"sounds.clearFilters": "Xóa bộ lọc",
|
||||||
|
"sounds.filterLabel": "Lọc:",
|
||||||
|
"sounds.filterPlaceholder": "Lọc theo tên...",
|
||||||
|
"sounds.itemsFound": "{n} mục",
|
||||||
|
"sounds.pageOf": "Trang {page} / {total}",
|
||||||
|
"sounds.empty": "Chưa có sound.",
|
||||||
|
"sounds.emptyFilter": "Không có sound khớp bộ lọc.",
|
||||||
"sounds.createTitle": "Tạo sound",
|
"sounds.createTitle": "Tạo sound",
|
||||||
"sounds.editTitle": "Sửa sound",
|
"sounds.editTitle": "Sửa sound",
|
||||||
"sounds.empty": "Chưa có sound. Tạo mới để dùng trong sound zones.",
|
|
||||||
"sounds.name": "Tên",
|
"sounds.name": "Tên",
|
||||||
|
"sounds.note": "Ghi chú",
|
||||||
"sounds.description": "Mô tả",
|
"sounds.description": "Mô tả",
|
||||||
"sounds.enabled": "Bật",
|
"sounds.enabled": "Bật",
|
||||||
|
"sounds.volume": "Volume (0–100)",
|
||||||
|
"sounds.volumeHint": "100% ≈ 80 dB. Kiểm tra volume bằng Play on robot.",
|
||||||
|
"sounds.volumeShort": "{volume}%",
|
||||||
"sounds.file": "File âm thanh",
|
"sounds.file": "File âm thanh",
|
||||||
"sounds.noFile": "Chưa có file",
|
"sounds.noFile": "Chưa có file",
|
||||||
|
"sounds.systemNoFile": "Âm thanh hệ thống (phát trên robot)",
|
||||||
|
"sounds.systemBadge": "System sound",
|
||||||
|
"sounds.systemShort": "System",
|
||||||
"sounds.upload": "Upload file…",
|
"sounds.upload": "Upload file…",
|
||||||
|
"sounds.listen": "Listen",
|
||||||
|
"sounds.playOnRobot": "Play on robot",
|
||||||
"sounds.play": "Phát",
|
"sounds.play": "Phát",
|
||||||
"sounds.playFailed": "Không phát được file.",
|
"sounds.playFailed": "Không phát được file.",
|
||||||
"sounds.fileMeta": "{name} · {duration}",
|
"sounds.fileMeta": "{name} · {duration}",
|
||||||
|
"sounds.saveChanges": "Lưu thay đổi",
|
||||||
"sounds.nameRequired": "Nhập tên sound.",
|
"sounds.nameRequired": "Nhập tên sound.",
|
||||||
|
"sounds.nameDuplicate": "Đã có sound cùng tên. Hãy Edit bản ghi hiện có.",
|
||||||
|
"sounds.reservedName": "Beep, Horn, Chime là system sounds — dùng Edit trên dòng System.",
|
||||||
"sounds.deleteConfirm": "Xóa sound này?",
|
"sounds.deleteConfirm": "Xóa sound này?",
|
||||||
|
"sounds.deleteConfirmTitle": "Xóa sound?",
|
||||||
|
"sounds.deleteConfirmText": "Xóa \"{name}\"? Sound zones đang dùng có thể bị ảnh hưởng.",
|
||||||
|
|
||||||
|
"transitions.title": "Transitions",
|
||||||
|
"transitions.subtitle": "Tạo và chỉnh sửa transitions.",
|
||||||
|
"transitions.helpTitle": "Trợ giúp Transitions",
|
||||||
|
"transitions.helpBody": "Transition nối hai map trong cùng site. Start position trên map nguồn, Goal position trên map đích (cùng vị trí vật lý). Mission phải chứa các bước switch map.",
|
||||||
|
"transitions.create": "Tạo transition",
|
||||||
|
"transitions.clearFilters": "Xóa bộ lọc",
|
||||||
|
"transitions.filterLabel": "Lọc:",
|
||||||
|
"transitions.filterPlaceholder": "Lọc theo position hoặc mission...",
|
||||||
|
"transitions.itemsFound": "{n} mục",
|
||||||
|
"transitions.pageOf": "Trang {page} / {total}",
|
||||||
|
"transitions.colStart": "Start",
|
||||||
|
"transitions.colGoal": "Goal",
|
||||||
|
"transitions.colMission": "Mission",
|
||||||
|
"transitions.colCreatedBy": "Created by",
|
||||||
|
"transitions.colFunctions": "Functions",
|
||||||
|
"transitions.empty": "Chưa có transition.",
|
||||||
|
"transitions.emptyFilter": "Không có transition khớp bộ lọc.",
|
||||||
|
"transitions.createTitle": "Tạo transition",
|
||||||
|
"transitions.editTitle": "Sửa transition",
|
||||||
|
"transitions.site": "Site",
|
||||||
|
"transitions.fromMap": "From map",
|
||||||
|
"transitions.toMap": "To map",
|
||||||
|
"transitions.startPosition": "Start position",
|
||||||
|
"transitions.goalPosition": "Goal position",
|
||||||
|
"transitions.mission": "Mission",
|
||||||
|
"transitions.deleteTitle": "Xóa transition?",
|
||||||
|
"transitions.deleteConfirmText": "Xóa transition {from} → {to}?",
|
||||||
|
"transitions.error.missing": "Thiếu trường bắt buộc.",
|
||||||
|
|
||||||
"missions.title": "Missions",
|
"missions.title": "Missions",
|
||||||
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
||||||
@@ -571,6 +623,7 @@
|
|||||||
"missions.action.drop_cart": "Drop cart",
|
"missions.action.drop_cart": "Drop cart",
|
||||||
"missions.action.user_log": "User log",
|
"missions.action.user_log": "User log",
|
||||||
"missions.action.play_sound": "Play sound",
|
"missions.action.play_sound": "Play sound",
|
||||||
|
"missions.action.switch_map": "Switch map",
|
||||||
"missions.error.nameRequired": "Tên mission không được trống.",
|
"missions.error.nameRequired": "Tên mission không được trống.",
|
||||||
"missions.error.nameDuplicate": "Tên mission đã tồn tại.",
|
"missions.error.nameDuplicate": "Tên mission đã tồn tại.",
|
||||||
"missions.error.nameEmpty": "Tên không được trống.",
|
"missions.error.nameEmpty": "Tên không được trống.",
|
||||||
@@ -691,6 +744,7 @@
|
|||||||
"nav.missions": "Missions",
|
"nav.missions": "Missions",
|
||||||
"nav.maps": "Maps",
|
"nav.maps": "Maps",
|
||||||
"nav.sounds": "Sounds",
|
"nav.sounds": "Sounds",
|
||||||
|
"nav.transitions": "Transitions",
|
||||||
"nav.build-robot": "Build Robot",
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"nav.monitoring-log": "System log",
|
||||||
"nav.integrations": "Integrations",
|
"nav.integrations": "Integrations",
|
||||||
@@ -1104,22 +1158,73 @@
|
|||||||
"maps.menu.save": "Save map",
|
"maps.menu.save": "Save map",
|
||||||
|
|
||||||
"sounds.title": "Sounds",
|
"sounds.title": "Sounds",
|
||||||
"sounds.subtitle": "Setup → Sounds — upload and manage robot sounds for sound zones.",
|
"sounds.subtitle": "Create and edit sounds.",
|
||||||
|
"sounds.helpTitle": "Sounds help",
|
||||||
|
"sounds.helpBody": "Upload audio for sound zones on maps. System sounds (Beep, Horn, Chime) cannot be deleted. Use Listen or Play on robot to verify volume.",
|
||||||
"sounds.create": "Create sound",
|
"sounds.create": "Create sound",
|
||||||
|
"sounds.clearFilters": "Clear filters",
|
||||||
|
"sounds.filterLabel": "Filter:",
|
||||||
|
"sounds.filterPlaceholder": "Filter by name...",
|
||||||
|
"sounds.itemsFound": "{n} item(s) found",
|
||||||
|
"sounds.pageOf": "Page {page} of {total}",
|
||||||
|
"sounds.empty": "No sounds yet.",
|
||||||
|
"sounds.emptyFilter": "No sounds match the filter.",
|
||||||
"sounds.createTitle": "Create sound",
|
"sounds.createTitle": "Create sound",
|
||||||
"sounds.editTitle": "Edit sound",
|
"sounds.editTitle": "Edit sound",
|
||||||
"sounds.empty": "No sounds yet. Create one to use in sound zones.",
|
|
||||||
"sounds.name": "Name",
|
"sounds.name": "Name",
|
||||||
|
"sounds.note": "Note",
|
||||||
"sounds.description": "Description",
|
"sounds.description": "Description",
|
||||||
"sounds.enabled": "Enabled",
|
"sounds.enabled": "Enabled",
|
||||||
|
"sounds.volume": "Volume (0–100)",
|
||||||
|
"sounds.volumeHint": "100% is approximately 80 dB. Verify volume by playing on the robot.",
|
||||||
|
"sounds.volumeShort": "{volume}%",
|
||||||
"sounds.file": "Audio file",
|
"sounds.file": "Audio file",
|
||||||
"sounds.noFile": "No file uploaded",
|
"sounds.noFile": "No file uploaded",
|
||||||
|
"sounds.systemNoFile": "Built-in system sound (plays on robot)",
|
||||||
|
"sounds.systemBadge": "System sound",
|
||||||
|
"sounds.systemShort": "System",
|
||||||
"sounds.upload": "Upload file…",
|
"sounds.upload": "Upload file…",
|
||||||
|
"sounds.listen": "Listen",
|
||||||
|
"sounds.playOnRobot": "Play on robot",
|
||||||
"sounds.play": "Play",
|
"sounds.play": "Play",
|
||||||
"sounds.playFailed": "Could not play file.",
|
"sounds.playFailed": "Could not play file.",
|
||||||
"sounds.fileMeta": "{name} · {duration}",
|
"sounds.fileMeta": "{name} · {duration}",
|
||||||
|
"sounds.saveChanges": "Save changes",
|
||||||
"sounds.nameRequired": "Enter a sound name.",
|
"sounds.nameRequired": "Enter a sound name.",
|
||||||
|
"sounds.nameDuplicate": "A sound with this name already exists. Edit the existing entry instead.",
|
||||||
|
"sounds.reservedName": "Beep, Horn, and Chime are built-in system sounds — use Edit on the System row.",
|
||||||
"sounds.deleteConfirm": "Delete this sound?",
|
"sounds.deleteConfirm": "Delete this sound?",
|
||||||
|
"sounds.deleteConfirmTitle": "Delete sound?",
|
||||||
|
"sounds.deleteConfirmText": "Delete \"{name}\"? Sound zones using it may be affected.",
|
||||||
|
|
||||||
|
"transitions.title": "Transitions",
|
||||||
|
"transitions.subtitle": "Create and edit transitions.",
|
||||||
|
"transitions.helpTitle": "Transitions help",
|
||||||
|
"transitions.helpBody": "A transition links two maps in the same site. Start position is on the source map; Goal position is on the destination map (same physical location). The mission should include switch map actions.",
|
||||||
|
"transitions.create": "Create transition",
|
||||||
|
"transitions.clearFilters": "Clear filters",
|
||||||
|
"transitions.filterLabel": "Filter:",
|
||||||
|
"transitions.filterPlaceholder": "Filter by position or mission...",
|
||||||
|
"transitions.itemsFound": "{n} item(s) found",
|
||||||
|
"transitions.pageOf": "Page {page} of {total}",
|
||||||
|
"transitions.colStart": "Start",
|
||||||
|
"transitions.colGoal": "Goal",
|
||||||
|
"transitions.colMission": "Mission",
|
||||||
|
"transitions.colCreatedBy": "Created by",
|
||||||
|
"transitions.colFunctions": "Functions",
|
||||||
|
"transitions.empty": "No transitions yet.",
|
||||||
|
"transitions.emptyFilter": "No transitions match the filter.",
|
||||||
|
"transitions.createTitle": "Create transition",
|
||||||
|
"transitions.editTitle": "Edit transition",
|
||||||
|
"transitions.site": "Site",
|
||||||
|
"transitions.fromMap": "From map",
|
||||||
|
"transitions.toMap": "To map",
|
||||||
|
"transitions.startPosition": "Start position",
|
||||||
|
"transitions.goalPosition": "Goal position",
|
||||||
|
"transitions.mission": "Mission",
|
||||||
|
"transitions.deleteTitle": "Delete transition?",
|
||||||
|
"transitions.deleteConfirmText": "Delete transition {from} → {to}?",
|
||||||
|
"transitions.error.missing": "Missing required fields.",
|
||||||
|
|
||||||
"missions.title": "Missions",
|
"missions.title": "Missions",
|
||||||
"missions.subtitle": "Setup → Missions — robot task list.",
|
"missions.subtitle": "Setup → Missions — robot task list.",
|
||||||
@@ -1189,6 +1294,7 @@
|
|||||||
"missions.action.drop_cart": "Drop cart",
|
"missions.action.drop_cart": "Drop cart",
|
||||||
"missions.action.user_log": "User log",
|
"missions.action.user_log": "User log",
|
||||||
"missions.action.play_sound": "Play sound",
|
"missions.action.play_sound": "Play sound",
|
||||||
|
"missions.action.switch_map": "Switch map",
|
||||||
"missions.error.nameRequired": "Mission name cannot be empty.",
|
"missions.error.nameRequired": "Mission name cannot be empty.",
|
||||||
"missions.error.nameDuplicate": "Mission name already exists.",
|
"missions.error.nameDuplicate": "Mission name already exists.",
|
||||||
"missions.error.nameEmpty": "Name cannot be empty.",
|
"missions.error.nameEmpty": "Name cannot be empty.",
|
||||||
|
|||||||
192
www/index.html
192
www/index.html
@@ -864,53 +864,209 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page" id="pageSounds" data-page-content="sounds" hidden>
|
<div class="page" id="pageSounds" data-page-content="sounds" hidden>
|
||||||
<div class="soundsPage">
|
<div id="soundsListView" class="mapsMirPage">
|
||||||
<section class="card">
|
<header class="mapsMirHeader">
|
||||||
<div class="cardHeader">
|
<div class="mapsMirHeaderText">
|
||||||
<div>
|
<h1 class="mapsMirTitle" data-i18n="sounds.title">Sounds</h1>
|
||||||
<div class="cardTitle" data-i18n="sounds.title">Sounds</div>
|
<p class="mapsMirSubtitle">
|
||||||
<div class="cardSub" data-i18n="sounds.subtitle">Setup → Sounds — upload and manage robot sounds for sound zones.</div>
|
<span data-i18n="sounds.subtitle">Create and edit sounds.</span>
|
||||||
</div>
|
<button type="button" class="mapsMirHelpBtn" id="soundsHelpBtn" data-i18n-title="sounds.helpTitle" aria-label="Help">
|
||||||
<button id="soundCreateBtn" type="button" class="btn primary" data-i18n="sounds.create">Create sound</button>
|
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cardBody">
|
<div class="mapsMirHeaderActions">
|
||||||
<div id="soundListEmpty" class="mutedNote" hidden data-i18n="sounds.empty">No sounds yet. Create one to use in sound zones.</div>
|
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="soundCreateBtn">
|
||||||
<div id="soundList" class="missionList"></div>
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
<span data-i18n="sounds.create">Create sound</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundsClearFiltersBtn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||||
|
<span data-i18n="sounds.clearFilters">Clear filters</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</header>
|
||||||
|
|
||||||
|
<div class="mapsMirFilterBar">
|
||||||
|
<label class="mapsMirFilterLabel" for="soundsFilterInput" data-i18n="sounds.filterLabel">Filter:</label>
|
||||||
|
<input type="search" id="soundsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="sounds.filterPlaceholder" placeholder="Filter by name..." autocomplete="off" />
|
||||||
|
<span id="soundsFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||||
|
<div class="mapsMirPager">
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="soundsPageFirst" aria-label="First page">«</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="soundsPagePrev" aria-label="Previous page">‹</button>
|
||||||
|
<span id="soundsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="soundsPageNext" aria-label="Next page">›</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="soundsPageLast" aria-label="Last page">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="soundsMirListWrap">
|
||||||
|
<div id="soundList" class="soundsMirList" role="list"></div>
|
||||||
|
<div id="soundListEmpty" class="mapsMirEmpty" hidden data-i18n="sounds.empty">No sounds yet.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog id="soundEditDialog" class="mapsMirDialog">
|
<dialog id="soundEditDialog" class="mapsMirDialog">
|
||||||
<form id="soundEditForm" method="dialog">
|
<form id="soundEditForm" method="dialog">
|
||||||
<h2 class="mapsMirDialogTitle" id="soundEditTitle" data-i18n="sounds.createTitle">Create sound</h2>
|
<h2 class="mapsMirDialogTitle" id="soundEditTitle" data-i18n="sounds.createTitle">Create sound</h2>
|
||||||
|
<p id="soundEditSystemBadge" class="soundSystemBadge" hidden data-i18n="sounds.systemBadge">System sound</p>
|
||||||
<label class="mapsMirField">
|
<label class="mapsMirField">
|
||||||
<span class="mapsMirFieldLabel" data-i18n="sounds.name">Name</span>
|
<span class="mapsMirFieldLabel" data-i18n="sounds.name">Name</span>
|
||||||
<input type="text" id="soundEditName" autocomplete="off" required />
|
<input type="text" id="soundEditName" autocomplete="off" required />
|
||||||
</label>
|
</label>
|
||||||
<label class="mapsMirField">
|
<label class="mapsMirField">
|
||||||
<span class="mapsMirFieldLabel" data-i18n="sounds.description">Description</span>
|
<span class="mapsMirFieldLabel" data-i18n="sounds.note">Note</span>
|
||||||
<textarea id="soundEditDescription" rows="2"></textarea>
|
<textarea id="soundEditDescription" rows="2"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="sounds.volume">Volume (0–100)</span>
|
||||||
|
<div class="soundVolumeRow">
|
||||||
|
<input type="range" id="soundEditVolume" min="0" max="100" step="1" value="100" />
|
||||||
|
<output id="soundEditVolumeOut" for="soundEditVolume">100</output>
|
||||||
|
</div>
|
||||||
|
<span class="mapsMirFieldHint" data-i18n="sounds.volumeHint">100% is approximately 80 dB. Verify volume by playing on the robot.</span>
|
||||||
|
</label>
|
||||||
<label class="mapsMirField mapsMirField--checkbox">
|
<label class="mapsMirField mapsMirField--checkbox">
|
||||||
<input type="checkbox" id="soundEditEnabled" checked />
|
<input type="checkbox" id="soundEditEnabled" checked />
|
||||||
<span data-i18n="sounds.enabled">Enabled</span>
|
<span data-i18n="sounds.enabled">Enabled</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="mapsMirField">
|
<div class="mapsMirField" id="soundEditFileSection">
|
||||||
<span class="mapsMirFieldLabel" data-i18n="sounds.file">Audio file</span>
|
<span class="mapsMirFieldLabel" data-i18n="sounds.file">Audio file</span>
|
||||||
<p id="soundEditFileMeta" class="mutedNote">—</p>
|
<p id="soundEditFileMeta" class="mutedNote">—</p>
|
||||||
<div class="mapsMirDialogFooter mapsMirDialogFooter--inline">
|
<div class="mapsMirDialogFooter mapsMirDialogFooter--inline soundEditAudioActions">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline soundListenBtn" id="soundEditListenBtn" disabled title="Listen">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 2a4 4 0 0 0-4 4v3a4 4 0 0 0 8 0V6a4 4 0 0 0-4-4zm0 12a5 5 0 0 0 5-5H3a5 5 0 0 0 5 5z"/></svg>
|
||||||
|
<span data-i18n="sounds.listen">Listen</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditPlayRobotBtn" disabled data-i18n="sounds.playOnRobot">Play on robot</button>
|
||||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditUploadBtn" data-i18n="sounds.upload">Upload file…</button>
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditUploadBtn" data-i18n="sounds.upload">Upload file…</button>
|
||||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditPlayBtn" disabled data-i18n="sounds.play">Play</button>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="soundEditUploadInput" accept="audio/*,.wav,.mp3,.ogg" hidden />
|
<input type="file" id="soundEditUploadInput" accept="audio/*,.wav,.mp3,.ogg" hidden />
|
||||||
</div>
|
</div>
|
||||||
<div class="mapsMirDialogFooter">
|
<div class="mapsMirDialogFooter">
|
||||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditCancelBtn" data-i18n="common.cancel">Cancel</button>
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundEditCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||||
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="soundEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
|
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="soundEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
|
||||||
<button type="submit" class="mapsMirBtn mapsMirBtn--primary" data-i18n="common.save">Save</button>
|
<button type="submit" class="mapsMirBtn mapsMirBtn--green" data-i18n="sounds.saveChanges">Save changes</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="soundDeleteConfirmDialog" class="mapsMirDialog">
|
||||||
|
<div class="mapsMirDialogPanel">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="sounds.deleteConfirmTitle">Delete sound?</h2>
|
||||||
|
<p id="soundDeleteConfirmText" class="mapsMirDialogText"></p>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="soundDeleteCancelBtn" data-i18n="common.no">No</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="soundDeleteYesBtn" data-i18n="common.yes">Yes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page" id="pageTransitions" data-page-content="transitions" hidden>
|
||||||
|
<div id="transitionsListView" class="mapsMirPage">
|
||||||
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<h1 class="mapsMirTitle" data-i18n="transitions.title">Transitions</h1>
|
||||||
|
<p class="mapsMirSubtitle">
|
||||||
|
<span data-i18n="transitions.subtitle">Create and edit transitions.</span>
|
||||||
|
<button type="button" class="mapsMirHelpBtn" id="transitionsHelpBtn" data-i18n-title="transitions.helpTitle" aria-label="Help">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirHeaderActions">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="transitionCreateBtn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
<span data-i18n="transitions.create">Create transition</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="transitionsClearFiltersBtn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||||
|
<span data-i18n="transitions.clearFilters">Clear filters</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mapsMirFilterBar">
|
||||||
|
<label class="mapsMirFilterLabel" for="transitionsFilterInput" data-i18n="transitions.filterLabel">Filter:</label>
|
||||||
|
<input type="search" id="transitionsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="transitions.filterPlaceholder" placeholder="Filter by position or mission..." autocomplete="off" />
|
||||||
|
<span id="transitionsFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||||
|
<div class="mapsMirPager">
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="transitionsPageFirst" aria-label="First page">«</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="transitionsPagePrev" aria-label="Previous page">‹</button>
|
||||||
|
<span id="transitionsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="transitionsPageNext" aria-label="Next page">›</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="transitionsPageLast" aria-label="Last page">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mapsMirTableWrap">
|
||||||
|
<table class="mapsMirTable mapsMirTable--transitions" id="transitionsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="transMirThIcon" aria-hidden="true"></th>
|
||||||
|
<th data-i18n="transitions.colStart">Start</th>
|
||||||
|
<th data-i18n="transitions.colGoal">Goal</th>
|
||||||
|
<th data-i18n="transitions.colMission">Mission</th>
|
||||||
|
<th data-i18n="transitions.colCreatedBy">Created by</th>
|
||||||
|
<th class="mapsMirThFunctions" data-i18n="transitions.colFunctions">Functions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="transitionList"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="transitionListEmpty" class="mapsMirEmpty" hidden data-i18n="transitions.empty">No transitions yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="transitionEditDialog" class="mapsMirDialog">
|
||||||
|
<form id="transitionEditForm" method="dialog">
|
||||||
|
<h2 class="mapsMirDialogTitle" id="transitionEditTitle" data-i18n="transitions.createTitle">Create transition</h2>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="transitions.site">Site</span>
|
||||||
|
<select id="transitionEditSite" required></select>
|
||||||
|
</label>
|
||||||
|
<div class="mapsMirFieldRow">
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="transitions.fromMap">From map</span>
|
||||||
|
<select id="transitionEditFromMap" required></select>
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="transitions.toMap">To map</span>
|
||||||
|
<select id="transitionEditToMap" required></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirFieldRow">
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="transitions.startPosition">Start position</span>
|
||||||
|
<select id="transitionEditStartPos" required></select>
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="transitions.goalPosition">Goal position</span>
|
||||||
|
<select id="transitionEditGoalPos" required></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="transitions.mission">Mission</span>
|
||||||
|
<select id="transitionEditMission" required></select>
|
||||||
|
</label>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="transitionEditCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="transitionEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
|
||||||
|
<button type="submit" class="mapsMirBtn mapsMirBtn--green" data-i18n="common.save">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="transitionDeleteConfirmDialog" class="mapsMirDialog">
|
||||||
|
<div class="mapsMirDialogPanel">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="transitions.deleteTitle">Delete transition?</h2>
|
||||||
|
<p id="transitionDeleteConfirmText" class="mapsMirDialogText"></p>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="transitionDeleteCancelBtn" data-i18n="common.no">No</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="transitionDeleteYesBtn" data-i18n="common.yes">Yes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page" id="pageMaps" data-page-content="maps" hidden>
|
<div class="page" id="pageMaps" data-page-content="maps" hidden>
|
||||||
@@ -2069,6 +2225,7 @@ GET /api/v2.0.0/status</pre>
|
|||||||
<script src="/i18n.js"></script>
|
<script src="/i18n.js"></script>
|
||||||
<script src="/auth.js"></script>
|
<script src="/auth.js"></script>
|
||||||
<script src="/nav.js"></script>
|
<script src="/nav.js"></script>
|
||||||
|
<script src="/sounds.js"></script>
|
||||||
<script src="/missions.js"></script>
|
<script src="/missions.js"></script>
|
||||||
<script src="/map-geo.js"></script>
|
<script src="/map-geo.js"></script>
|
||||||
<script src="/map-occupancy-canvas.js"></script>
|
<script src="/map-occupancy-canvas.js"></script>
|
||||||
@@ -2080,6 +2237,7 @@ GET /api/v2.0.0/status</pre>
|
|||||||
<script src="/map-yaml.js"></script>
|
<script src="/map-yaml.js"></script>
|
||||||
<script src="/maps.js"></script>
|
<script src="/maps.js"></script>
|
||||||
<script src="/sounds.js"></script>
|
<script src="/sounds.js"></script>
|
||||||
|
<script src="/transitions.js"></script>
|
||||||
<script src="/map-editor.js"></script>
|
<script src="/map-editor.js"></script>
|
||||||
<script src="/topbar.js"></script>
|
<script src="/topbar.js"></script>
|
||||||
<script src="/dashboard.js"></script>
|
<script src="/dashboard.js"></script>
|
||||||
|
|||||||
156
www/missions.js
156
www/missions.js
@@ -6,6 +6,7 @@
|
|||||||
{ type: "move_to_position", label: "Go to position" },
|
{ type: "move_to_position", label: "Go to position" },
|
||||||
{ type: "move_to_marker", label: "Go to marker" },
|
{ type: "move_to_marker", label: "Go to marker" },
|
||||||
{ type: "adjust_localization", label: "Adjust localization" },
|
{ type: "adjust_localization", label: "Adjust localization" },
|
||||||
|
{ type: "switch_map", label: "Switch map" },
|
||||||
{ type: "wait", label: "Wait" },
|
{ type: "wait", label: "Wait" },
|
||||||
{ type: "set_speed", label: "Set speed" },
|
{ type: "set_speed", label: "Set speed" },
|
||||||
],
|
],
|
||||||
@@ -38,6 +39,60 @@
|
|||||||
const SAMPLE_IO_MODULES = ["GPIO module 1", "PLC I/O 1"];
|
const SAMPLE_IO_MODULES = ["GPIO module 1", "PLC I/O 1"];
|
||||||
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
|
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
|
||||||
|
|
||||||
|
let missionPositionCatalog = [];
|
||||||
|
|
||||||
|
function buildPositionCatalogFromMaps(maps) {
|
||||||
|
const out = [];
|
||||||
|
(maps || []).forEach((map) => {
|
||||||
|
if (!map?.id) return;
|
||||||
|
const zones = Array.isArray(map.zones) ? map.zones : [];
|
||||||
|
zones
|
||||||
|
.filter((z) => z && z.type === "position" && typeof z.id === "string" && z.id)
|
||||||
|
.forEach((z) => {
|
||||||
|
const mapName = map.name || map.id;
|
||||||
|
const pname = z.name || z.id;
|
||||||
|
out.push({
|
||||||
|
position_id: z.id,
|
||||||
|
map_id: map.id,
|
||||||
|
name: pname,
|
||||||
|
label: `${mapName} / ${pname}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionOptions() {
|
||||||
|
return missionPositionCatalog.map((p) => ({ value: p.position_id, label: p.label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionIds() {
|
||||||
|
return missionPositionCatalog.map((p) => p.position_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionLabel(ref) {
|
||||||
|
if (!ref) return "—";
|
||||||
|
const hit = missionPositionCatalog.find((p) => p.position_id === ref || p.name === ref);
|
||||||
|
return hit?.label || ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionSelectOptions() {
|
||||||
|
const opts = positionOptions();
|
||||||
|
if (opts.length) return opts;
|
||||||
|
return SAMPLE_POSITIONS.map((n) => ({ value: n, label: n }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPositionFieldDefs() {
|
||||||
|
const ids = positionIds();
|
||||||
|
["move_to_position", "adjust_localization", "if", "pick_cart", "drop_cart"].forEach((type) => {
|
||||||
|
const defs = VARIABLE_FIELD_DEFS[type];
|
||||||
|
if (!defs) return;
|
||||||
|
defs.forEach((def) => {
|
||||||
|
if (def.key === "position") def.options = ids.length ? ids : SAMPLE_POSITIONS;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const el = (id) => document.getElementById(id);
|
const el = (id) => document.getElementById(id);
|
||||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
|
||||||
@@ -75,6 +130,7 @@
|
|||||||
{ key: "cart", label: "Cart", options: SAMPLE_CARTS },
|
{ key: "cart", label: "Cart", options: SAMPLE_CARTS },
|
||||||
],
|
],
|
||||||
drop_cart: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
|
drop_cart: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
|
||||||
|
switch_map: [{ key: "map_id", label: "Map", options: [] }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const store = {
|
const store = {
|
||||||
@@ -102,17 +158,19 @@
|
|||||||
function defaultParams(type) {
|
function defaultParams(type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "move_to_position":
|
case "move_to_position":
|
||||||
return { position: SAMPLE_POSITIONS[0], check_free: true };
|
return { position: positionIds()[0] || SAMPLE_POSITIONS[0], check_free: true };
|
||||||
case "move_to_marker":
|
case "move_to_marker":
|
||||||
return { marker: SAMPLE_MARKERS[0] };
|
return { marker: SAMPLE_MARKERS[0] };
|
||||||
case "adjust_localization":
|
case "adjust_localization":
|
||||||
return { position: SAMPLE_POSITIONS[0] };
|
return { position: positionIds()[0] || SAMPLE_POSITIONS[0] };
|
||||||
case "wait":
|
case "wait":
|
||||||
return { seconds: 1 };
|
return { seconds: 1 };
|
||||||
case "set_speed":
|
case "set_speed":
|
||||||
return { speed: "normal" };
|
return { speed: "normal" };
|
||||||
|
case "switch_map":
|
||||||
|
return { map_id: "", entry_position_id: "" };
|
||||||
case "if":
|
case "if":
|
||||||
return { condition: "position_free", position: SAMPLE_POSITIONS[0] };
|
return { condition: "position_free", position: positionIds()[0] || SAMPLE_POSITIONS[0] };
|
||||||
case "loop":
|
case "loop":
|
||||||
return { count: 1, mode: "count" };
|
return { count: 1, mode: "count" };
|
||||||
case "set_digital_output":
|
case "set_digital_output":
|
||||||
@@ -122,13 +180,13 @@
|
|||||||
case "set_plc_register":
|
case "set_plc_register":
|
||||||
return { register: 1, action: "set", value: 0 };
|
return { register: 1, action: "set", value: 0 };
|
||||||
case "pick_cart":
|
case "pick_cart":
|
||||||
return { position: SAMPLE_POSITIONS[0], cart: SAMPLE_CARTS[0] };
|
return { position: positionIds()[0] || SAMPLE_POSITIONS[0], cart: SAMPLE_CARTS[0] };
|
||||||
case "drop_cart":
|
case "drop_cart":
|
||||||
return { position: SAMPLE_POSITIONS[0], collision_check: true };
|
return { position: positionIds()[0] || SAMPLE_POSITIONS[0], collision_check: true };
|
||||||
case "user_log":
|
case "user_log":
|
||||||
return { message: "Mission step" };
|
return { message: "Mission step" };
|
||||||
case "play_sound":
|
case "play_sound":
|
||||||
return { sound: "beep" };
|
return { sound: "sys_beep", volume: 100 };
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -277,7 +335,7 @@
|
|||||||
const fmtVar = (key, val) => (p[`${key}_var`] ? `${val} (biến)` : val);
|
const fmtVar = (key, val) => (p[`${key}_var`] ? `${val} (biến)` : val);
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "move_to_position":
|
case "move_to_position":
|
||||||
return `Position: ${fmtVar("position", p.position)}${p.check_free ? " • kiểm tra trống" : ""}`;
|
return `Position: ${fmtVar("position", positionLabel(p.position))}${p.check_free ? " • kiểm tra trống" : ""}`;
|
||||||
case "move_to_marker":
|
case "move_to_marker":
|
||||||
return `Marker: ${fmtVar("marker", p.marker)}`;
|
return `Marker: ${fmtVar("marker", p.marker)}`;
|
||||||
case "wait":
|
case "wait":
|
||||||
@@ -287,7 +345,7 @@
|
|||||||
case "loop":
|
case "loop":
|
||||||
return p.mode === "endless" ? "Lặp vô hạn" : `Lặp ${p.count} lần • ${action.children?.length || 0} bước`;
|
return p.mode === "endless" ? "Lặp vô hạn" : `Lặp ${p.count} lần • ${action.children?.length || 0} bước`;
|
||||||
case "if":
|
case "if":
|
||||||
return `If ${p.condition} @ ${p.position || "—"}`;
|
return `If ${p.condition} @ ${positionLabel(p.position)}`;
|
||||||
case "set_digital_output":
|
case "set_digital_output":
|
||||||
return `${p.module} pin ${p.pin} → ${p.value ? "ON" : "OFF"}`;
|
return `${p.module} pin ${p.pin} → ${p.value ? "ON" : "OFF"}`;
|
||||||
case "wait_digital_input":
|
case "wait_digital_input":
|
||||||
@@ -296,11 +354,18 @@
|
|||||||
return `Reg ${p.register}: ${p.action} ${p.value}`;
|
return `Reg ${p.register}: ${p.action} ${p.value}`;
|
||||||
case "pick_cart":
|
case "pick_cart":
|
||||||
case "drop_cart":
|
case "drop_cart":
|
||||||
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${fmtVar("position", p.position)}${action.type === "pick_cart" ? ` • ${fmtVar("cart", p.cart)}` : ""}`;
|
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${fmtVar("position", positionLabel(p.position))}${action.type === "pick_cart" ? ` • ${fmtVar("cart", p.cart)}` : ""}`;
|
||||||
case "user_log":
|
case "user_log":
|
||||||
return p.message || "—";
|
return p.message || "—";
|
||||||
case "play_sound":
|
case "switch_map":
|
||||||
return p.sound || "—";
|
return `${p.map_id || "—"}`;
|
||||||
|
case "play_sound": {
|
||||||
|
const soundName = (() => {
|
||||||
|
const s = (window.SoundsApp?.getSounds?.() || []).find((x) => x.id === p.sound);
|
||||||
|
return s?.name || p.sound || "—";
|
||||||
|
})();
|
||||||
|
return `${soundName} · ${p.volume != null ? p.volume : 100}%`;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return action.label;
|
return action.label;
|
||||||
}
|
}
|
||||||
@@ -679,9 +744,11 @@
|
|||||||
sel.dataset.varKey = v.key;
|
sel.dataset.varKey = v.key;
|
||||||
v.options.forEach((opt) => {
|
v.options.forEach((opt) => {
|
||||||
const o = document.createElement("option");
|
const o = document.createElement("option");
|
||||||
o.value = opt;
|
const value = opt?.value ?? opt;
|
||||||
o.textContent = opt;
|
const label = opt?.label ?? positionLabel(value);
|
||||||
if (opt === v.default) o.selected = true;
|
o.value = value;
|
||||||
|
o.textContent = label;
|
||||||
|
if (value === v.default) o.selected = true;
|
||||||
sel.appendChild(o);
|
sel.appendChild(o);
|
||||||
});
|
});
|
||||||
row.appendChild(lab);
|
row.appendChild(lab);
|
||||||
@@ -1170,6 +1237,19 @@
|
|||||||
return select;
|
return select;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectInputLabeled = (key, value, options) => {
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.dataset.param = key;
|
||||||
|
options.forEach((opt) => {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = opt.value;
|
||||||
|
o.textContent = opt.label;
|
||||||
|
if (opt.value === value) o.selected = true;
|
||||||
|
select.appendChild(o);
|
||||||
|
});
|
||||||
|
return select;
|
||||||
|
};
|
||||||
|
|
||||||
const addVariableToggle = (paramKey, fieldLabel) => {
|
const addVariableToggle = (paramKey, fieldLabel) => {
|
||||||
const chk = document.createElement("label");
|
const chk = document.createElement("label");
|
||||||
chk.innerHTML = `<input type="checkbox" data-param="${paramKey}_var" ${p[`${paramKey}_var`] ? "checked" : ""} /> Biến — hỏi khi thêm vào queue`;
|
chk.innerHTML = `<input type="checkbox" data-param="${paramKey}_var" ${p[`${paramKey}_var`] ? "checked" : ""} /> Biến — hỏi khi thêm vào queue`;
|
||||||
@@ -1179,7 +1259,7 @@
|
|||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "move_to_position":
|
case "move_to_position":
|
||||||
case "adjust_localization":
|
case "adjust_localization":
|
||||||
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
|
||||||
addVariableToggle("position", "Position");
|
addVariableToggle("position", "Position");
|
||||||
if (action.type === "move_to_position") {
|
if (action.type === "move_to_position") {
|
||||||
const chk = document.createElement("label");
|
const chk = document.createElement("label");
|
||||||
@@ -1203,7 +1283,7 @@
|
|||||||
break;
|
break;
|
||||||
case "if":
|
case "if":
|
||||||
addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"]));
|
addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"]));
|
||||||
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
|
||||||
addVariableToggle("position", "Position");
|
addVariableToggle("position", "Position");
|
||||||
break;
|
break;
|
||||||
case "set_digital_output":
|
case "set_digital_output":
|
||||||
@@ -1231,13 +1311,13 @@
|
|||||||
addField("Giá trị", textInput("value", p.value, "number"));
|
addField("Giá trị", textInput("value", p.value, "number"));
|
||||||
break;
|
break;
|
||||||
case "pick_cart":
|
case "pick_cart":
|
||||||
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
|
||||||
addVariableToggle("position", "Position");
|
addVariableToggle("position", "Position");
|
||||||
addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS));
|
addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS));
|
||||||
addVariableToggle("cart", "Cart");
|
addVariableToggle("cart", "Cart");
|
||||||
break;
|
break;
|
||||||
case "drop_cart":
|
case "drop_cart":
|
||||||
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
|
||||||
addVariableToggle("position", "Position");
|
addVariableToggle("position", "Position");
|
||||||
{
|
{
|
||||||
const chk = document.createElement("label");
|
const chk = document.createElement("label");
|
||||||
@@ -1248,9 +1328,25 @@
|
|||||||
case "user_log":
|
case "user_log":
|
||||||
addField("Message", textInput("message", p.message));
|
addField("Message", textInput("message", p.message));
|
||||||
break;
|
break;
|
||||||
case "play_sound":
|
case "play_sound": {
|
||||||
addField("Sound", selectInput("sound", p.sound, ["beep", "horn", "chime"]));
|
const catalog = (window.SoundsApp?.getSounds?.() || []).filter((s) => s.enabled !== false);
|
||||||
|
const options = catalog.length
|
||||||
|
? catalog.map((s) => ({ value: s.id, label: s.name }))
|
||||||
|
: [
|
||||||
|
{ value: "sys_beep", label: "Beep" },
|
||||||
|
{ value: "sys_horn", label: "Horn" },
|
||||||
|
{ value: "sys_chime", label: "Chime" },
|
||||||
|
];
|
||||||
|
addField("Sound", selectInputLabeled("sound", p.sound || options[0].value, options));
|
||||||
|
addField("Volume (0–100)", textInput("volume", p.volume != null ? p.volume : 100, "number"));
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
case "switch_map": {
|
||||||
|
const options = (VARIABLE_FIELD_DEFS.switch_map?.[0]?.options || []).map((id) => ({ value: id, label: id }));
|
||||||
|
addField("Map", selectInputLabeled("map_id", p.map_id || options[0]?.value || "", options));
|
||||||
|
addField("Entry position id", textInput("entry_position_id", p.entry_position_id || ""));
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
grid.innerHTML = `<p class="mutedNote">Action này không có tham số cấu hình.</p>`;
|
grid.innerHTML = `<p class="mutedNote">Action này không có tham số cấu hình.</p>`;
|
||||||
}
|
}
|
||||||
@@ -1361,6 +1457,26 @@
|
|||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
loadStore();
|
loadStore();
|
||||||
|
if (window.SoundsApp?.refreshSounds) {
|
||||||
|
try {
|
||||||
|
await window.SoundsApp.refreshSounds();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const maps = await fetch("/api/maps", { credentials: "include" });
|
||||||
|
if (maps.ok) {
|
||||||
|
const data = await maps.json();
|
||||||
|
const list = Array.isArray(data.maps) ? data.maps : [];
|
||||||
|
missionPositionCatalog = buildPositionCatalogFromMaps(list);
|
||||||
|
syncPositionFieldDefs();
|
||||||
|
const ids = list.map((m) => m.id).filter(Boolean);
|
||||||
|
VARIABLE_FIELD_DEFS.switch_map[0].options = ids;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
await loadStoreFromBackend();
|
await loadStoreFromBackend();
|
||||||
bindEvents();
|
bindEvents();
|
||||||
renderMissionList();
|
renderMissionList();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
{ section: "missions", page: "missions" },
|
{ section: "missions", page: "missions" },
|
||||||
{ section: "maps", page: "maps" },
|
{ section: "maps", page: "maps" },
|
||||||
{ section: "sounds", page: "sounds" },
|
{ section: "sounds", page: "sounds" },
|
||||||
|
{ section: "transitions", page: "transitions" },
|
||||||
{ section: "build-robot", page: "config" },
|
{ section: "build-robot", page: "config" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
maps: { module: "setup", section: "maps" },
|
maps: { module: "setup", section: "maps" },
|
||||||
missions: { module: "setup", section: "missions" },
|
missions: { module: "setup", section: "missions" },
|
||||||
sounds: { module: "setup", section: "sounds" },
|
sounds: { module: "setup", section: "sounds" },
|
||||||
|
transitions: { module: "setup", section: "transitions" },
|
||||||
integrations: { module: "system", section: "integrations" },
|
integrations: { module: "system", section: "integrations" },
|
||||||
monitoring: { module: "monitoring", section: "monitoring-log" },
|
monitoring: { module: "monitoring", section: "monitoring-log" },
|
||||||
help: { module: "help", section: "help-api" },
|
help: { module: "help", section: "help-api" },
|
||||||
|
|||||||
330
www/sounds.js
330
www/sounds.js
@@ -1,31 +1,49 @@
|
|||||||
(() => {
|
(() => {
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
listen: `<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 2a4 4 0 0 0-4 4v3a4 4 0 0 0 8 0V6a4 4 0 0 0-4-4zm0 12a5 5 0 0 0 5-5H3a5 5 0 0 0 5 5z"/></svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
const el = (id) => document.getElementById(id);
|
const el = (id) => document.getElementById(id);
|
||||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
|
||||||
const listEl = el("soundList");
|
const listEl = el("soundList");
|
||||||
const emptyEl = el("soundListEmpty");
|
const emptyEl = el("soundListEmpty");
|
||||||
const createBtnEl = el("soundCreateBtn");
|
const createBtnEl = el("soundCreateBtn");
|
||||||
|
const filterInputEl = el("soundsFilterInput");
|
||||||
|
const filterCountEl = el("soundsFilterCount");
|
||||||
|
const pageLabelEl = el("soundsPageLabel");
|
||||||
const dialogEl = el("soundEditDialog");
|
const dialogEl = el("soundEditDialog");
|
||||||
const formEl = el("soundEditForm");
|
const formEl = el("soundEditForm");
|
||||||
const titleEl = el("soundEditTitle");
|
const titleEl = el("soundEditTitle");
|
||||||
|
const systemBadgeEl = el("soundEditSystemBadge");
|
||||||
const nameEl = el("soundEditName");
|
const nameEl = el("soundEditName");
|
||||||
const descEl = el("soundEditDescription");
|
const descEl = el("soundEditDescription");
|
||||||
|
const volumeEl = el("soundEditVolume");
|
||||||
|
const volumeOutEl = el("soundEditVolumeOut");
|
||||||
const enabledEl = el("soundEditEnabled");
|
const enabledEl = el("soundEditEnabled");
|
||||||
const fileMetaEl = el("soundEditFileMeta");
|
const fileMetaEl = el("soundEditFileMeta");
|
||||||
|
const fileSectionEl = el("soundEditFileSection");
|
||||||
const uploadInputEl = el("soundEditUploadInput");
|
const uploadInputEl = el("soundEditUploadInput");
|
||||||
const uploadBtnEl = el("soundEditUploadBtn");
|
const uploadBtnEl = el("soundEditUploadBtn");
|
||||||
const playBtnEl = el("soundEditPlayBtn");
|
const listenBtnEl = el("soundEditListenBtn");
|
||||||
|
const playRobotBtnEl = el("soundEditPlayRobotBtn");
|
||||||
const deleteBtnEl = el("soundEditDeleteBtn");
|
const deleteBtnEl = el("soundEditDeleteBtn");
|
||||||
|
const deleteConfirmDialogEl = el("soundDeleteConfirmDialog");
|
||||||
|
const deleteConfirmTextEl = el("soundDeleteConfirmText");
|
||||||
|
|
||||||
const store = {
|
const store = {
|
||||||
sounds: [],
|
sounds: [],
|
||||||
editingId: null,
|
editingId: null,
|
||||||
previewAudio: null,
|
previewAudio: null,
|
||||||
|
filter: "",
|
||||||
|
page: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
function canWrite() {
|
function canWrite() {
|
||||||
if (!window.AuthApp?.canWrite) return true;
|
if (!window.AuthApp?.canWrite) return true;
|
||||||
return window.AuthApp.canWrite("integrations");
|
return window.AuthApp.canWrite("sounds") || window.AuthApp.canWrite("integrations");
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
@@ -66,38 +84,124 @@
|
|||||||
return `${sec}s`;
|
return `${sec}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentEditingSound() {
|
||||||
|
return store.editingId ? store.sounds.find((s) => s.id === store.editingId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canListen(sound) {
|
||||||
|
return !!(sound?.file_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedSounds() {
|
||||||
|
return [...store.sounds].sort((a, b) => {
|
||||||
|
if (!!a.is_system !== !!b.is_system) return a.is_system ? -1 : 1;
|
||||||
|
return (a.name || "").localeCompare(b.name || "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredSounds() {
|
||||||
|
const q = store.filter.trim().toLowerCase();
|
||||||
|
let items = sortedSounds();
|
||||||
|
if (q) {
|
||||||
|
items = items.filter((sound) => {
|
||||||
|
const name = (sound.name || "").toLowerCase();
|
||||||
|
const file = (sound.file_name || "").toLowerCase();
|
||||||
|
const desc = (sound.description || "").toLowerCase();
|
||||||
|
return name.includes(q) || file.includes(q) || desc.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageCount(total) {
|
||||||
|
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagedItems(items) {
|
||||||
|
const totalPages = pageCount(items.length);
|
||||||
|
if (store.page > totalPages) store.page = totalPages;
|
||||||
|
if (store.page < 1) store.page = 1;
|
||||||
|
const start = (store.page - 1) * PAGE_SIZE;
|
||||||
|
return items.slice(start, start + PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagerUi(totalItems) {
|
||||||
|
const totalPages = pageCount(totalItems);
|
||||||
|
if (filterCountEl) filterCountEl.textContent = t("sounds.itemsFound", { n: totalItems });
|
||||||
|
if (pageLabelEl) pageLabelEl.textContent = t("sounds.pageOf", { page: store.page, total: totalPages });
|
||||||
|
const atStart = store.page <= 1;
|
||||||
|
const atEnd = store.page >= totalPages;
|
||||||
|
el("soundsPageFirst")?.toggleAttribute("disabled", atStart);
|
||||||
|
el("soundsPagePrev")?.toggleAttribute("disabled", atStart);
|
||||||
|
el("soundsPageNext")?.toggleAttribute("disabled", atEnd);
|
||||||
|
el("soundsPageLast")?.toggleAttribute("disabled", atEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function soundMetaLine(sound) {
|
||||||
|
const hasFile = !!sound.file_name;
|
||||||
|
const parts = [
|
||||||
|
sound.enabled === false ? t("common.disabled") : t("common.enabled"),
|
||||||
|
t("sounds.volumeShort", { volume: sound.volume != null ? sound.volume : 100 }),
|
||||||
|
hasFile ? sound.file_name : t("sounds.noFile"),
|
||||||
|
];
|
||||||
|
if (sound.duration_ms != null) parts.push(formatDuration(sound.duration_ms));
|
||||||
|
if (sound.is_system) parts.push(t("sounds.systemShort"));
|
||||||
|
return parts.map((p) => escapeHtml(p)).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
listEl.innerHTML = "";
|
const items = filteredSounds();
|
||||||
if (emptyEl) emptyEl.hidden = store.sounds.length > 0;
|
const pageItems = pagedItems(items);
|
||||||
|
updatePagerUi(items.length);
|
||||||
|
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
const showEmpty = items.length === 0;
|
||||||
|
if (listEl) listEl.hidden = showEmpty;
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = !showEmpty;
|
||||||
|
emptyEl.textContent = store.filter.trim() ? t("sounds.emptyFilter") : t("sounds.empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
pageItems.forEach((sound) => {
|
||||||
|
const row = document.createElement("article");
|
||||||
|
row.className = "soundsMirRow";
|
||||||
|
row.setAttribute("role", "listitem");
|
||||||
|
row.dataset.id = sound.id;
|
||||||
|
|
||||||
|
const listenDisabled = !canListen(sound);
|
||||||
|
const editDisabled = !canWrite();
|
||||||
|
|
||||||
store.sounds.forEach((sound) => {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "missionListItem soundListItem";
|
|
||||||
const hasFile = !!sound.file_name;
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div>
|
<div class="soundsMirRowMain">
|
||||||
<div class="missionListItemTitle">${escapeHtml(sound.name || sound.id)}</div>
|
<button type="button" class="soundsMirRowTitle soundEditBtn" data-id="${escapeHtml(sound.id)}" ${editDisabled ? "disabled" : ""}>
|
||||||
<div class="missionListItemMeta">
|
${escapeHtml(sound.name || sound.id)}
|
||||||
${sound.enabled === false ? t("common.disabled") : t("common.enabled")}
|
</button>
|
||||||
· ${hasFile ? escapeHtml(sound.file_name) : t("sounds.noFile")}
|
<div class="soundsMirRowMeta">${soundMetaLine(sound)}</div>
|
||||||
${sound.duration_ms != null ? ` · ${formatDuration(sound.duration_ms)}` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="missionListItemActions">
|
<div class="soundsMirRowActions">
|
||||||
<button type="button" class="btn subtle soundPlayBtn" data-id="${escapeHtml(sound.id)}" ${hasFile ? "" : "disabled"}>${t("sounds.play")}</button>
|
<button type="button" class="mapsMirIconBtn soundListenBtn" data-id="${escapeHtml(sound.id)}" ${listenDisabled ? "disabled" : ""} title="${escapeHtml(t("sounds.listen"))}">
|
||||||
<button type="button" class="btn subtle soundEditBtn" data-id="${escapeHtml(sound.id)}">${t("common.edit")}</button>
|
${ICONS.listen}
|
||||||
</div>
|
</button>
|
||||||
`;
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline soundEditBtn" data-id="${escapeHtml(sound.id)}" ${editDisabled ? "disabled" : ""}>
|
||||||
|
${escapeHtml(t("common.edit"))}
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
row.querySelectorAll(".soundEditBtn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
if (!canWrite()) return;
|
||||||
|
openDialog(btn.dataset.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.querySelector(".soundListenBtn")?.addEventListener("click", () => listenSound(sound.id));
|
||||||
|
row.addEventListener("dblclick", () => {
|
||||||
|
if (canWrite()) openDialog(sound.id);
|
||||||
|
});
|
||||||
listEl.appendChild(row);
|
listEl.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
listEl.querySelectorAll(".soundEditBtn").forEach((btn) => {
|
if (createBtnEl) createBtnEl.hidden = !canWrite();
|
||||||
btn.addEventListener("click", () => openDialog(btn.dataset.id));
|
|
||||||
});
|
|
||||||
listEl.querySelectorAll(".soundPlayBtn").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", () => playSound(btn.dataset.id));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPreview() {
|
function stopPreview() {
|
||||||
@@ -107,13 +211,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function playSound(id) {
|
function listenSound(id) {
|
||||||
|
const sound = store.sounds.find((s) => s.id === id);
|
||||||
|
if (!canListen(sound)) return;
|
||||||
stopPreview();
|
stopPreview();
|
||||||
const audio = new Audio(`/api/sounds/${encodeURIComponent(id)}/file`);
|
const audio = new Audio(`/api/sounds/${encodeURIComponent(id)}/file`);
|
||||||
store.previewAudio = audio;
|
store.previewAudio = audio;
|
||||||
audio.play().catch(() => alert(t("sounds.playFailed")));
|
audio.play().catch(() => alert(t("sounds.playFailed")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readVolume() {
|
||||||
|
return Number(volumeEl?.value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVolumeOut() {
|
||||||
|
if (volumeOutEl) volumeOutEl.textContent = String(readVolume());
|
||||||
|
}
|
||||||
|
|
||||||
function updateFileMeta(sound) {
|
function updateFileMeta(sound) {
|
||||||
if (!fileMetaEl) return;
|
if (!fileMetaEl) return;
|
||||||
if (sound?.file_name) {
|
if (sound?.file_name) {
|
||||||
@@ -121,45 +235,84 @@
|
|||||||
name: sound.file_name,
|
name: sound.file_name,
|
||||||
duration: formatDuration(sound.duration_ms),
|
duration: formatDuration(sound.duration_ms),
|
||||||
});
|
});
|
||||||
|
} else if (sound?.is_system) {
|
||||||
|
fileMetaEl.textContent = t("sounds.systemNoFile");
|
||||||
} else {
|
} else {
|
||||||
fileMetaEl.textContent = t("sounds.noFile");
|
fileMetaEl.textContent = t("sounds.noFile");
|
||||||
}
|
}
|
||||||
if (playBtnEl) playBtnEl.disabled = !sound?.file_name;
|
const listenOk = canListen(sound);
|
||||||
|
if (listenBtnEl) listenBtnEl.disabled = !listenOk;
|
||||||
|
if (playRobotBtnEl) playRobotBtnEl.disabled = !sound?.id || sound.enabled === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDialogPermissions(sound) {
|
||||||
|
const ro = !canWrite();
|
||||||
|
const isSystem = !!sound?.is_system;
|
||||||
|
if (nameEl) nameEl.readOnly = ro || isSystem;
|
||||||
|
if (descEl) descEl.readOnly = ro;
|
||||||
|
if (volumeEl) volumeEl.disabled = ro;
|
||||||
|
if (enabledEl) enabledEl.disabled = ro;
|
||||||
|
if (uploadBtnEl) uploadBtnEl.hidden = isSystem || ro;
|
||||||
|
if (fileSectionEl) fileSectionEl.hidden = false;
|
||||||
|
if (deleteBtnEl) deleteBtnEl.hidden = !sound || isSystem || ro;
|
||||||
|
if (systemBadgeEl) systemBadgeEl.hidden = !isSystem;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDialog(id = null) {
|
function openDialog(id = null) {
|
||||||
store.editingId = id;
|
store.editingId = id;
|
||||||
const existing = id ? store.sounds.find((s) => s.id === id) : null;
|
const existing = id ? store.sounds.find((s) => s.id === id) : null;
|
||||||
if (titleEl) {
|
if (titleEl) titleEl.textContent = existing ? t("sounds.editTitle") : t("sounds.createTitle");
|
||||||
titleEl.textContent = existing ? t("sounds.editTitle") : t("sounds.createTitle");
|
|
||||||
}
|
|
||||||
if (nameEl) nameEl.value = existing?.name || "";
|
if (nameEl) nameEl.value = existing?.name || "";
|
||||||
if (descEl) descEl.value = existing?.description || "";
|
if (descEl) descEl.value = existing?.description || "";
|
||||||
if (enabledEl) enabledEl.checked = existing?.enabled !== false;
|
if (enabledEl) enabledEl.checked = existing?.enabled !== false;
|
||||||
|
if (volumeEl) volumeEl.value = existing?.volume != null ? existing.volume : 100;
|
||||||
|
syncVolumeOut();
|
||||||
updateFileMeta(existing);
|
updateFileMeta(existing);
|
||||||
if (deleteBtnEl) deleteBtnEl.hidden = !existing || !canWrite();
|
applyDialogPermissions(existing);
|
||||||
if (uploadBtnEl) uploadBtnEl.disabled = !canWrite();
|
|
||||||
if (nameEl) nameEl.readOnly = !canWrite();
|
|
||||||
if (descEl) descEl.readOnly = !canWrite();
|
|
||||||
if (enabledEl) enabledEl.disabled = !canWrite();
|
|
||||||
dialogEl?.showModal();
|
dialogEl?.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPayload() {
|
||||||
|
return {
|
||||||
|
name: nameEl?.value.trim() || "",
|
||||||
|
description: descEl?.value.trim() || "",
|
||||||
|
enabled: enabledEl?.checked !== false,
|
||||||
|
volume: readVolume(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReservedName(name) {
|
||||||
|
return ["beep", "horn", "chime"].includes(String(name || "").trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameExists(name, exceptId = null) {
|
||||||
|
const lower = String(name || "").trim().toLowerCase();
|
||||||
|
return store.sounds.some(
|
||||||
|
(s) => s.id !== exceptId && String(s.name || "").trim().toLowerCase() === lower,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function saveDialog() {
|
async function saveDialog() {
|
||||||
if (!canWrite()) return;
|
if (!canWrite()) return;
|
||||||
const name = nameEl?.value.trim() || "";
|
const soundId = store.editingId;
|
||||||
if (!name) {
|
const payload = readPayload();
|
||||||
|
if (!payload.name) {
|
||||||
alert(t("sounds.nameRequired"));
|
alert(t("sounds.nameRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = {
|
if (!soundId) {
|
||||||
name,
|
if (isReservedName(payload.name)) {
|
||||||
description: descEl?.value.trim() || "",
|
alert(t("sounds.reservedName"));
|
||||||
enabled: enabledEl?.checked !== false,
|
return;
|
||||||
};
|
}
|
||||||
|
if (nameExists(payload.name)) {
|
||||||
|
alert(t("sounds.nameDuplicate"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (store.editingId) {
|
if (soundId) {
|
||||||
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}`, {
|
await apiJson(`/api/sounds/${encodeURIComponent(soundId)}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -174,8 +327,9 @@
|
|||||||
}
|
}
|
||||||
await refreshSounds();
|
await refreshSounds();
|
||||||
renderList();
|
renderList();
|
||||||
const updated = store.sounds.find((s) => s.id === store.editingId);
|
const updated = currentEditingSound();
|
||||||
updateFileMeta(updated);
|
updateFileMeta(updated);
|
||||||
|
applyDialogPermissions(updated);
|
||||||
if (!uploadInputEl?.files?.length) {
|
if (!uploadInputEl?.files?.length) {
|
||||||
dialogEl?.close();
|
dialogEl?.close();
|
||||||
}
|
}
|
||||||
@@ -186,6 +340,8 @@
|
|||||||
|
|
||||||
async function uploadFile() {
|
async function uploadFile() {
|
||||||
if (!canWrite() || !store.editingId) return;
|
if (!canWrite() || !store.editingId) return;
|
||||||
|
const sound = currentEditingSound();
|
||||||
|
if (sound?.is_system) return;
|
||||||
const file = uploadInputEl?.files?.[0];
|
const file = uploadInputEl?.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -198,17 +354,26 @@
|
|||||||
uploadInputEl.value = "";
|
uploadInputEl.value = "";
|
||||||
await refreshSounds();
|
await refreshSounds();
|
||||||
renderList();
|
renderList();
|
||||||
updateFileMeta(store.sounds.find((s) => s.id === store.editingId));
|
updateFileMeta(currentEditingSound());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message);
|
alert(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSound() {
|
function openDeleteConfirm() {
|
||||||
|
const sound = currentEditingSound();
|
||||||
|
if (!sound || sound.is_system || !canWrite()) return;
|
||||||
|
if (deleteConfirmTextEl) {
|
||||||
|
deleteConfirmTextEl.textContent = t("sounds.deleteConfirmText", { name: sound.name || sound.id });
|
||||||
|
}
|
||||||
|
deleteConfirmDialogEl?.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
if (!canWrite() || !store.editingId) return;
|
if (!canWrite() || !store.editingId) return;
|
||||||
if (!confirm(t("sounds.deleteConfirm"))) return;
|
|
||||||
try {
|
try {
|
||||||
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}`, { method: "DELETE" });
|
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}`, { method: "DELETE" });
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
dialogEl?.close();
|
dialogEl?.close();
|
||||||
store.editingId = null;
|
store.editingId = null;
|
||||||
await refreshSounds();
|
await refreshSounds();
|
||||||
@@ -218,6 +383,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function playOnRobot() {
|
||||||
|
if (!store.editingId) return;
|
||||||
|
try {
|
||||||
|
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}/play`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ volume: readVolume() }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
store.filter = "";
|
||||||
|
store.page = 1;
|
||||||
|
if (filterInputEl) filterInputEl.value = "";
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
createBtnEl?.addEventListener("click", () => {
|
createBtnEl?.addEventListener("click", () => {
|
||||||
if (!canWrite()) return;
|
if (!canWrite()) return;
|
||||||
@@ -229,26 +414,65 @@
|
|||||||
});
|
});
|
||||||
el("soundEditCancelBtn")?.addEventListener("click", () => {
|
el("soundEditCancelBtn")?.addEventListener("click", () => {
|
||||||
stopPreview();
|
stopPreview();
|
||||||
|
store.editingId = null;
|
||||||
dialogEl?.close();
|
dialogEl?.close();
|
||||||
});
|
});
|
||||||
dialogEl?.addEventListener("cancel", (evt) => {
|
dialogEl?.addEventListener("cancel", (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
stopPreview();
|
stopPreview();
|
||||||
|
store.editingId = null;
|
||||||
dialogEl?.close();
|
dialogEl?.close();
|
||||||
});
|
});
|
||||||
|
volumeEl?.addEventListener("input", syncVolumeOut);
|
||||||
uploadBtnEl?.addEventListener("click", () => uploadInputEl?.click());
|
uploadBtnEl?.addEventListener("click", () => uploadInputEl?.click());
|
||||||
uploadInputEl?.addEventListener("change", () => {
|
uploadInputEl?.addEventListener("change", () => {
|
||||||
saveDialog().then(() => uploadFile());
|
saveDialog().then(() => uploadFile());
|
||||||
});
|
});
|
||||||
playBtnEl?.addEventListener("click", () => {
|
listenBtnEl?.addEventListener("click", () => {
|
||||||
if (store.editingId) playSound(store.editingId);
|
if (store.editingId) listenSound(store.editingId);
|
||||||
});
|
});
|
||||||
deleteBtnEl?.addEventListener("click", () => deleteSound());
|
playRobotBtnEl?.addEventListener("click", () => playOnRobot());
|
||||||
|
deleteBtnEl?.addEventListener("click", () => openDeleteConfirm());
|
||||||
|
el("soundDeleteCancelBtn")?.addEventListener("click", () => deleteConfirmDialogEl?.close());
|
||||||
|
el("soundDeleteYesBtn")?.addEventListener("click", () => confirmDelete());
|
||||||
|
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
filterInputEl?.addEventListener("input", () => {
|
||||||
|
store.filter = filterInputEl.value;
|
||||||
|
store.page = 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("soundsClearFiltersBtn")?.addEventListener("click", clearFilters);
|
||||||
|
el("soundsPageFirst")?.addEventListener("click", () => {
|
||||||
|
store.page = 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("soundsPagePrev")?.addEventListener("click", () => {
|
||||||
|
store.page = Math.max(1, store.page - 1);
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("soundsPageNext")?.addEventListener("click", () => {
|
||||||
|
store.page += 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("soundsPageLast")?.addEventListener("click", () => {
|
||||||
|
store.page = pageCount(filteredSounds().length);
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("soundsHelpBtn")?.addEventListener("click", () => {
|
||||||
|
alert(t("sounds.helpBody"));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("lm:locale-change", () => renderList());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onPageShow() {
|
async function onPageShow() {
|
||||||
stopPreview();
|
stopPreview();
|
||||||
if (createBtnEl) createBtnEl.hidden = !canWrite();
|
if (createBtnEl) createBtnEl.hidden = !canWrite();
|
||||||
|
document.body.classList.toggle("auth-readonly-sounds", !canWrite());
|
||||||
try {
|
try {
|
||||||
await refreshSounds();
|
await refreshSounds();
|
||||||
renderList();
|
renderList();
|
||||||
@@ -257,12 +481,14 @@
|
|||||||
emptyEl.hidden = false;
|
emptyEl.hidden = false;
|
||||||
emptyEl.textContent = e.message;
|
emptyEl.textContent = e.message;
|
||||||
}
|
}
|
||||||
|
if (listEl) listEl.hidden = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPageHide() {
|
function onPageHide() {
|
||||||
stopPreview();
|
stopPreview();
|
||||||
dialogEl?.close();
|
dialogEl?.close();
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSounds() {
|
function getSounds() {
|
||||||
|
|||||||
232
www/style.css
232
www/style.css
@@ -1051,6 +1051,22 @@ canvas {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.content.content--transitions {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
align-content: stretch;
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.content.content--transitions > #pageTransitions {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
.content.content--dashboard {
|
.content.content--dashboard {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -1070,8 +1086,169 @@ canvas {
|
|||||||
grid-template-columns: var(--leftPaneW, 460px) 10px 1fr;
|
grid-template-columns: var(--leftPaneW, 460px) 10px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content.content--sounds {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
align-content: stretch;
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.content.content--sounds > #pageSounds {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.missionsPage { min-width: 0; width: 100%; }
|
.missionsPage { min-width: 0; width: 100%; }
|
||||||
.soundsPage { min-width: 0; width: 100%; }
|
|
||||||
|
.soundVolumeRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundVolumeRow input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundVolumeRow output {
|
||||||
|
min-width: 2.5em;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapsMirFieldHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundSystemBadge {
|
||||||
|
margin: -8px 0 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundListenBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundEditAudioActions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.auth-readonly-sounds #soundCreateBtn { display: none !important; }
|
||||||
|
|
||||||
|
body.auth-readonly-sounds .soundsMirRow .soundEditBtn { pointer-events: none; opacity: 0.55; }
|
||||||
|
|
||||||
|
#pageSounds {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#soundsListView {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirListWrap {
|
||||||
|
width: 100%;
|
||||||
|
align-self: stretch;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
min-height: 56px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirRow:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirRow:hover {
|
||||||
|
background: #f9fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirRowMain {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirRowTitle {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #222;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirRowTitle:hover:not(:disabled) {
|
||||||
|
color: var(--mir-blue, #337ab7);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirRowTitle:disabled {
|
||||||
|
cursor: default;
|
||||||
|
color: #222;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirRowMeta {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #777;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirRowActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsMirRowActions .mapsMirBtn--outline {
|
||||||
|
min-width: 72px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.missionList { display: grid; gap: 10px; }
|
.missionList { display: grid; gap: 10px; }
|
||||||
.missionListItem {
|
.missionListItem {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2951,6 +3128,19 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pageTransitions {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#transitionsListView {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#mapsListView[hidden],
|
#mapsListView[hidden],
|
||||||
#mapsCreateView[hidden],
|
#mapsCreateView[hidden],
|
||||||
#mapEditorView[hidden] {
|
#mapEditorView[hidden] {
|
||||||
@@ -3268,6 +3458,13 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
|
|||||||
|
|
||||||
.mapsMirBtn--green:hover { background: var(--mir-green-hover, #4cae4c); }
|
.mapsMirBtn--green:hover { background: var(--mir-green-hover, #4cae4c); }
|
||||||
|
|
||||||
|
.mapsMirBtn--primary {
|
||||||
|
background: var(--mir-green, #5cb85c);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapsMirBtn--primary:hover { background: var(--mir-green-hover, #4cae4c); }
|
||||||
|
|
||||||
.mapsMirBtn--outline {
|
.mapsMirBtn--outline {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #555;
|
color: #555;
|
||||||
@@ -3387,6 +3584,28 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
|
|||||||
.mapsMirTable thead th:nth-child(2) { width: 22%; }
|
.mapsMirTable thead th:nth-child(2) { width: 22%; }
|
||||||
.mapsMirThFunctions { width: 148px; text-align: right !important; }
|
.mapsMirThFunctions { width: 148px; text-align: right !important; }
|
||||||
|
|
||||||
|
.mapsMirTable--transitions thead th.transMirThIcon { width: 52px; }
|
||||||
|
.mapsMirTable--transitions thead th:nth-child(2) { width: 20%; }
|
||||||
|
.mapsMirTable--transitions thead th:nth-child(3) { width: 20%; }
|
||||||
|
.mapsMirTable--transitions thead th:nth-child(4) { width: 22%; }
|
||||||
|
.mapsMirTable--transitions thead th:nth-child(5) { width: 16%; }
|
||||||
|
|
||||||
|
.transMirIcon {
|
||||||
|
color: var(--mir-green, #5cb85c);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transMirCellIcon {
|
||||||
|
width: 52px;
|
||||||
|
text-align: center;
|
||||||
|
padding-left: 12px !important;
|
||||||
|
padding-right: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transMirNameLink {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.mapsMirSiteRow td {
|
.mapsMirSiteRow td {
|
||||||
padding: 12px 16px 8px;
|
padding: 12px 16px 8px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -4912,14 +5131,11 @@ body.auth-readonly-maps-page #mapsImportSiteBtn { display: none !important; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Setup → Sounds: MiR-style square corners */
|
/* Setup → Sounds: MiR-style square corners */
|
||||||
#pageSounds .card,
|
#pageSounds .soundsMirRow,
|
||||||
#pageSounds .missionListItem,
|
#pageSounds .soundsMirListWrap,
|
||||||
#pageSounds .btn,
|
|
||||||
#pageSounds button,
|
|
||||||
#pageSounds input,
|
|
||||||
#pageSounds textarea,
|
|
||||||
#pageSounds #soundEditDialog.mapsMirDialog,
|
|
||||||
#pageSounds .mapsMirBtn,
|
#pageSounds .mapsMirBtn,
|
||||||
|
#pageSounds .mapsMirIconBtn,
|
||||||
|
#pageSounds #soundEditDialog.mapsMirDialog,
|
||||||
#pageSounds .mapsMirField input,
|
#pageSounds .mapsMirField input,
|
||||||
#pageSounds .mapsMirField textarea {
|
#pageSounds .mapsMirField textarea {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|||||||
456
www/transitions.js
Normal file
456
www/transitions.js
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
(() => {
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
transition: `<svg class="transMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="5" cy="11" r="3.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17" cy="11" r="3.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8.5 11h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
|
||||||
|
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
|
||||||
|
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
|
||||||
|
const listEl = el("transitionList");
|
||||||
|
const emptyEl = el("transitionListEmpty");
|
||||||
|
const tableEl = el("transitionsTable");
|
||||||
|
const createBtnEl = el("transitionCreateBtn");
|
||||||
|
const filterInputEl = el("transitionsFilterInput");
|
||||||
|
const filterCountEl = el("transitionsFilterCount");
|
||||||
|
const pageLabelEl = el("transitionsPageLabel");
|
||||||
|
const dialogEl = el("transitionEditDialog");
|
||||||
|
const formEl = el("transitionEditForm");
|
||||||
|
const titleEl = el("transitionEditTitle");
|
||||||
|
const deleteBtnEl = el("transitionEditDeleteBtn");
|
||||||
|
const deleteConfirmDialogEl = el("transitionDeleteConfirmDialog");
|
||||||
|
const deleteConfirmTextEl = el("transitionDeleteConfirmText");
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
site: el("transitionEditSite"),
|
||||||
|
fromMap: el("transitionEditFromMap"),
|
||||||
|
toMap: el("transitionEditToMap"),
|
||||||
|
startPos: el("transitionEditStartPos"),
|
||||||
|
goalPos: el("transitionEditGoalPos"),
|
||||||
|
mission: el("transitionEditMission"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
transitions: [],
|
||||||
|
sites: [],
|
||||||
|
maps: [],
|
||||||
|
missions: [],
|
||||||
|
editingId: null,
|
||||||
|
pendingDeleteId: null,
|
||||||
|
filter: "",
|
||||||
|
page: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function canWrite() {
|
||||||
|
if (!window.AuthApp?.canWrite) return true;
|
||||||
|
return window.AuthApp.canWrite("maps");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiJson(url, opts = {}) {
|
||||||
|
const res = await fetch(url, { credentials: "include", ...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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
const [sites, maps, missions, transitions] = await Promise.all([
|
||||||
|
apiJson("/api/sites"),
|
||||||
|
apiJson("/api/maps"),
|
||||||
|
apiJson("/api/missions"),
|
||||||
|
apiJson("/api/transitions"),
|
||||||
|
]);
|
||||||
|
store.sites = Array.isArray(sites.sites) ? sites.sites : [];
|
||||||
|
store.maps = Array.isArray(maps.maps) ? maps.maps : [];
|
||||||
|
store.missions = Array.isArray(missions.missions) ? missions.missions : [];
|
||||||
|
store.transitions = Array.isArray(transitions.transitions) ? transitions.transitions : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteName(id) {
|
||||||
|
const s = store.sites.find((x) => x.id === id);
|
||||||
|
return s?.name || id || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapName(id) {
|
||||||
|
const m = store.maps.find((x) => x.id === id);
|
||||||
|
return m?.name || id || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function missionName(id) {
|
||||||
|
const m = store.missions.find((x) => x.id === id);
|
||||||
|
return m?.name || id || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionName(mapId, positionId) {
|
||||||
|
const m = store.maps.find((x) => x.id === mapId);
|
||||||
|
const zones = Array.isArray(m?.zones) ? m.zones : [];
|
||||||
|
const hit = zones.find((z) => z && z.type === "position" && z.id === positionId);
|
||||||
|
return hit?.name || positionId || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionOptionsForMap(mapId) {
|
||||||
|
const m = store.maps.find((x) => x.id === mapId);
|
||||||
|
const zones = Array.isArray(m?.zones) ? m.zones : [];
|
||||||
|
const positions = zones.filter((z) => z && z.type === "position" && typeof z.id === "string");
|
||||||
|
return positions.map((p) => ({ value: p.id, label: p.name || p.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(selectEl, options, value) {
|
||||||
|
if (!selectEl) return;
|
||||||
|
selectEl.innerHTML = "";
|
||||||
|
options.forEach((opt) => {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = opt.value;
|
||||||
|
o.textContent = opt.label;
|
||||||
|
if (opt.value === value) o.selected = true;
|
||||||
|
selectEl.appendChild(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredTransitions() {
|
||||||
|
const q = store.filter.trim().toLowerCase();
|
||||||
|
let items = [...store.transitions].sort((a, b) => {
|
||||||
|
const sa = siteName(a.site_id).localeCompare(siteName(b.site_id));
|
||||||
|
if (sa !== 0) return sa;
|
||||||
|
const startA = positionName(a.from_map_id, a.start_position_id);
|
||||||
|
const startB = positionName(b.from_map_id, b.start_position_id);
|
||||||
|
return startA.localeCompare(startB);
|
||||||
|
});
|
||||||
|
if (q) {
|
||||||
|
items = items.filter((tr) => {
|
||||||
|
const start = positionName(tr.from_map_id, tr.start_position_id).toLowerCase();
|
||||||
|
const goal = positionName(tr.to_map_id, tr.goal_position_id).toLowerCase();
|
||||||
|
const mission = missionName(tr.mission_id).toLowerCase();
|
||||||
|
const fromMap = mapName(tr.from_map_id).toLowerCase();
|
||||||
|
const toMap = mapName(tr.to_map_id).toLowerCase();
|
||||||
|
const site = siteName(tr.site_id).toLowerCase();
|
||||||
|
return (
|
||||||
|
start.includes(q) ||
|
||||||
|
goal.includes(q) ||
|
||||||
|
mission.includes(q) ||
|
||||||
|
fromMap.includes(q) ||
|
||||||
|
toMap.includes(q) ||
|
||||||
|
site.includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageCount(total) {
|
||||||
|
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagedItems(items) {
|
||||||
|
const totalPages = pageCount(items.length);
|
||||||
|
if (store.page > totalPages) store.page = totalPages;
|
||||||
|
if (store.page < 1) store.page = 1;
|
||||||
|
const start = (store.page - 1) * PAGE_SIZE;
|
||||||
|
return items.slice(start, start + PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagerUi(totalItems) {
|
||||||
|
const totalPages = pageCount(totalItems);
|
||||||
|
if (filterCountEl) filterCountEl.textContent = t("transitions.itemsFound", { n: totalItems });
|
||||||
|
if (pageLabelEl) pageLabelEl.textContent = t("transitions.pageOf", { page: store.page, total: totalPages });
|
||||||
|
const atStart = store.page <= 1;
|
||||||
|
const atEnd = store.page >= totalPages;
|
||||||
|
el("transitionsPageFirst")?.toggleAttribute("disabled", atStart);
|
||||||
|
el("transitionsPagePrev")?.toggleAttribute("disabled", atStart);
|
||||||
|
el("transitionsPageNext")?.toggleAttribute("disabled", atEnd);
|
||||||
|
el("transitionsPageLast")?.toggleAttribute("disabled", atEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
if (!listEl) return;
|
||||||
|
const items = filteredTransitions();
|
||||||
|
const pageItems = pagedItems(items);
|
||||||
|
updatePagerUi(items.length);
|
||||||
|
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
const showEmpty = items.length === 0;
|
||||||
|
if (tableEl) tableEl.hidden = showEmpty;
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = !showEmpty;
|
||||||
|
emptyEl.textContent = store.filter.trim() ? t("transitions.emptyFilter") : t("transitions.empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastSiteId = null;
|
||||||
|
pageItems.forEach((tr) => {
|
||||||
|
const siteId = tr.site_id || "";
|
||||||
|
if (siteId !== lastSiteId) {
|
||||||
|
lastSiteId = siteId;
|
||||||
|
const siteTr = document.createElement("tr");
|
||||||
|
siteTr.className = "mapsMirSiteRow";
|
||||||
|
siteTr.innerHTML = `<td colspan="6">${escapeHtml(siteName(siteId))}</td>`;
|
||||||
|
listEl.appendChild(siteTr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLabel = positionName(tr.from_map_id, tr.start_position_id);
|
||||||
|
const goalLabel = positionName(tr.to_map_id, tr.goal_position_id);
|
||||||
|
const missionLabel = missionName(tr.mission_id);
|
||||||
|
const createdBy = tr.created_by || "—";
|
||||||
|
|
||||||
|
const trEl = document.createElement("tr");
|
||||||
|
trEl.className = "mapsMirRow transMirRow";
|
||||||
|
trEl.dataset.id = tr.id;
|
||||||
|
|
||||||
|
const actions = canWrite()
|
||||||
|
? `<div class="mapsMirRowActions">
|
||||||
|
<button type="button" class="mapsMirIconBtn" data-edit="${escapeHtml(tr.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
|
||||||
|
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(tr.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>
|
||||||
|
</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
trEl.innerHTML = `
|
||||||
|
<td class="transMirCellIcon">${ICONS.transition}</td>
|
||||||
|
<td class="transMirCellStart">
|
||||||
|
<button type="button" class="mapsMirNameLink transMirNameLink" data-edit="${escapeHtml(tr.id)}">${escapeHtml(startLabel)}</button>
|
||||||
|
</td>
|
||||||
|
<td class="transMirCellGoal">${escapeHtml(goalLabel)}</td>
|
||||||
|
<td class="transMirCellMission">${escapeHtml(missionLabel)}</td>
|
||||||
|
<td class="mapsMirCellCreatedBy">${escapeHtml(createdBy)}</td>
|
||||||
|
<td class="mapsMirCellActions">${actions}</td>`;
|
||||||
|
|
||||||
|
trEl.querySelectorAll("[data-edit]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => openDialog(btn.dataset.edit));
|
||||||
|
});
|
||||||
|
trEl.querySelector("[data-delete]")?.addEventListener("click", () => openDeleteConfirm(tr.id));
|
||||||
|
trEl.addEventListener("dblclick", () => {
|
||||||
|
if (canWrite()) openDialog(tr.id);
|
||||||
|
});
|
||||||
|
listEl.appendChild(trEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createBtnEl) createBtnEl.hidden = !canWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentEditingTransition() {
|
||||||
|
return store.editingId ? store.transitions.find((x) => x.id === store.editingId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPayload() {
|
||||||
|
return {
|
||||||
|
site_id: fields.site?.value || "",
|
||||||
|
from_map_id: fields.fromMap?.value || "",
|
||||||
|
to_map_id: fields.toMap?.value || "",
|
||||||
|
start_position_id: fields.startPos?.value || "",
|
||||||
|
goal_position_id: fields.goalPos?.value || "",
|
||||||
|
mission_id: fields.mission?.value || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDependentSelects() {
|
||||||
|
const fromMapId = fields.fromMap?.value || "";
|
||||||
|
const toMapId = fields.toMap?.value || "";
|
||||||
|
fillSelect(fields.startPos, positionOptionsForMap(fromMapId), fields.startPos?.value);
|
||||||
|
fillSelect(fields.goalPos, positionOptionsForMap(toMapId), fields.goalPos?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshMapSelectsForSite(existing) {
|
||||||
|
const siteOpts = store.sites.map((s) => ({ value: s.id, label: s.name || s.id }));
|
||||||
|
const defaultSite = existing?.site_id || store.maps[0]?.site_id || siteOpts[0]?.value || "";
|
||||||
|
fillSelect(fields.site, siteOpts, fields.site?.value || defaultSite);
|
||||||
|
|
||||||
|
const mapsForSite = store.maps.filter((m) => (m.site_id || "") === (fields.site?.value || ""));
|
||||||
|
const mapOpts = mapsForSite.map((m) => ({ value: m.id, label: m.name || m.id }));
|
||||||
|
fillSelect(fields.fromMap, mapOpts, existing?.from_map_id || mapOpts[0]?.value);
|
||||||
|
fillSelect(fields.toMap, mapOpts, existing?.to_map_id || mapOpts[0]?.value);
|
||||||
|
updateDependentSelects();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog(id = null) {
|
||||||
|
store.editingId = id;
|
||||||
|
const existing = id ? store.transitions.find((x) => x.id === id) : null;
|
||||||
|
if (titleEl) titleEl.textContent = existing ? t("transitions.editTitle") : t("transitions.createTitle");
|
||||||
|
|
||||||
|
refreshMapSelectsForSite(existing);
|
||||||
|
if (fields.startPos && existing?.start_position_id) fields.startPos.value = existing.start_position_id;
|
||||||
|
if (fields.goalPos && existing?.goal_position_id) fields.goalPos.value = existing.goal_position_id;
|
||||||
|
|
||||||
|
const missionOpts = store.missions.map((m) => ({ value: m.id, label: m.name || m.id }));
|
||||||
|
fillSelect(fields.mission, missionOpts, existing?.mission_id || missionOpts[0]?.value);
|
||||||
|
|
||||||
|
const ro = !canWrite();
|
||||||
|
Object.values(fields).forEach((node) => {
|
||||||
|
if (!node) return;
|
||||||
|
node.disabled = ro;
|
||||||
|
});
|
||||||
|
if (deleteBtnEl) deleteBtnEl.hidden = !existing || ro;
|
||||||
|
dialogEl?.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDialog() {
|
||||||
|
if (!canWrite()) return;
|
||||||
|
const payload = readPayload();
|
||||||
|
if (
|
||||||
|
!payload.site_id ||
|
||||||
|
!payload.from_map_id ||
|
||||||
|
!payload.to_map_id ||
|
||||||
|
!payload.start_position_id ||
|
||||||
|
!payload.goal_position_id ||
|
||||||
|
!payload.mission_id
|
||||||
|
) {
|
||||||
|
alert(t("transitions.error.missing"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (store.editingId) {
|
||||||
|
await apiJson(`/api/transitions/${encodeURIComponent(store.editingId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiJson("/api/transitions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await refreshAll();
|
||||||
|
renderList();
|
||||||
|
dialogEl?.close();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteConfirm(id) {
|
||||||
|
const tr = store.transitions.find((x) => x.id === id);
|
||||||
|
if (!tr || !canWrite()) return;
|
||||||
|
store.pendingDeleteId = id;
|
||||||
|
if (deleteConfirmTextEl) {
|
||||||
|
deleteConfirmTextEl.textContent = t("transitions.deleteConfirmText", {
|
||||||
|
from: positionName(tr.from_map_id, tr.start_position_id),
|
||||||
|
to: positionName(tr.to_map_id, tr.goal_position_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
deleteConfirmDialogEl?.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteConfirmFromDialog() {
|
||||||
|
const tr = currentEditingTransition();
|
||||||
|
if (!tr) return;
|
||||||
|
openDeleteConfirm(tr.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const id = store.pendingDeleteId || store.editingId;
|
||||||
|
if (!id || !canWrite()) return;
|
||||||
|
try {
|
||||||
|
await apiJson(`/api/transitions/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
dialogEl?.close();
|
||||||
|
store.editingId = null;
|
||||||
|
store.pendingDeleteId = null;
|
||||||
|
await refreshAll();
|
||||||
|
renderList();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
store.filter = "";
|
||||||
|
store.page = 1;
|
||||||
|
if (filterInputEl) filterInputEl.value = "";
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
createBtnEl?.addEventListener("click", () => openDialog(null));
|
||||||
|
formEl?.addEventListener("submit", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
saveDialog();
|
||||||
|
});
|
||||||
|
el("transitionEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
|
||||||
|
dialogEl?.addEventListener("cancel", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
dialogEl?.close();
|
||||||
|
});
|
||||||
|
fields.site?.addEventListener("change", () => {
|
||||||
|
const existing = currentEditingTransition();
|
||||||
|
refreshMapSelectsForSite(existing);
|
||||||
|
});
|
||||||
|
fields.fromMap?.addEventListener("change", updateDependentSelects);
|
||||||
|
fields.toMap?.addEventListener("change", updateDependentSelects);
|
||||||
|
deleteBtnEl?.addEventListener("click", openDeleteConfirmFromDialog);
|
||||||
|
el("transitionDeleteCancelBtn")?.addEventListener("click", () => {
|
||||||
|
store.pendingDeleteId = null;
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
});
|
||||||
|
el("transitionDeleteYesBtn")?.addEventListener("click", confirmDelete);
|
||||||
|
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
store.pendingDeleteId = null;
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
filterInputEl?.addEventListener("input", () => {
|
||||||
|
store.filter = filterInputEl.value;
|
||||||
|
store.page = 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("transitionsClearFiltersBtn")?.addEventListener("click", clearFilters);
|
||||||
|
el("transitionsPageFirst")?.addEventListener("click", () => {
|
||||||
|
store.page = 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("transitionsPagePrev")?.addEventListener("click", () => {
|
||||||
|
store.page = Math.max(1, store.page - 1);
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("transitionsPageNext")?.addEventListener("click", () => {
|
||||||
|
store.page += 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("transitionsPageLast")?.addEventListener("click", () => {
|
||||||
|
store.page = pageCount(filteredTransitions().length);
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("transitionsHelpBtn")?.addEventListener("click", () => {
|
||||||
|
alert(t("transitions.helpBody"));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("lm:locale-change", () => renderList());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPageShow() {
|
||||||
|
try {
|
||||||
|
await refreshAll();
|
||||||
|
renderList();
|
||||||
|
} catch (e) {
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
emptyEl.textContent = e.message;
|
||||||
|
}
|
||||||
|
if (tableEl) tableEl.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageHide() {
|
||||||
|
dialogEl?.close();
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
window.TransitionsApp = { onPageShow, onPageHide };
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user