feat(feeder): unit card grid in app-config with equipment selection modal
- Unit cards in app-config use CSS grid (auto-fill 260px min) for compact card layout across the full page - Each card shows bound equipment tags and a "选择设备" button - Equipment selection modal allows multi-select with checkboxes, calls batch set-unit API to bind/unbind, then refreshes - App-config loads both units and equipments on first switch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2db9fec081
commit
3e026e1b99
|
|
@ -143,6 +143,31 @@ body {
|
||||||
|
|
||||||
.grid-app-config .panel.app-config-main { grid-column: 1; grid-row: 1; }
|
.grid-app-config .panel.app-config-main { grid-column: 1; grid-row: 1; }
|
||||||
|
|
||||||
|
.unit-config-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-config-list .unit-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-config-list .unit-card .unit-equipment-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-config-list .unit-card .unit-equipment-tags .badge {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* config view slot assignments */
|
/* config view slot assignments */
|
||||||
.grid-config .panel.top-left { grid-column: 1; grid-row: 1; }
|
.grid-config .panel.top-left { grid-column: 1; grid-row: 1; }
|
||||||
.grid-config .panel.top-right { grid-column: 2 / 4; grid-row: 1; }
|
.grid-config .panel.top-right { grid-column: 2 / 4; grid-row: 1; }
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,20 @@
|
||||||
|
|
||||||
<div data-partial="/ui/html/modals.html"></div>
|
<div data-partial="/ui/html/modals.html"></div>
|
||||||
<div data-partial="/ui/html/unit-modal.html"></div>
|
<div data-partial="/ui/html/unit-modal.html"></div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="unitEquipmentModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>选择设备</h3>
|
||||||
|
<button class="secondary" id="closeUnitEquipmentModal">X</button>
|
||||||
|
</div>
|
||||||
|
<div class="list" id="unitEquipmentList" style="max-height:400px;overflow:auto"></div>
|
||||||
|
<div class="form-actions" style="padding:10px">
|
||||||
|
<button type="button" class="secondary" id="cancelUnitEquipment">取消</button>
|
||||||
|
<button type="button" id="confirmUnitEquipment">确认绑定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div data-partial="/ui/html/api-doc-drawer.html"></div>
|
<div data-partial="/ui/html/api-doc-drawer.html"></div>
|
||||||
|
|
||||||
<script type="module" src="/ui/js/index.js?v=20260325f"></script>
|
<script type="module" src="/ui/js/index.js?v=20260325f"></script>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import {
|
||||||
} from "./points.js";
|
} from "./points.js";
|
||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
import { loadSources, saveSource } from "./sources.js";
|
import { loadSources, saveSource } from "./sources.js";
|
||||||
import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js";
|
import { bindUnitEquipmentModalEvents, closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js";
|
||||||
|
|
||||||
let _configLoaded = false;
|
let _configLoaded = false;
|
||||||
let _appConfigLoaded = false;
|
let _appConfigLoaded = false;
|
||||||
|
|
@ -83,7 +83,7 @@ function switchView(view) {
|
||||||
if (view === "app-config") {
|
if (view === "app-config") {
|
||||||
if (!_appConfigLoaded) {
|
if (!_appConfigLoaded) {
|
||||||
_appConfigLoaded = true;
|
_appConfigLoaded = true;
|
||||||
withStatus(loadUnits());
|
withStatus(Promise.all([loadUnits(), loadEquipments()]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +185,7 @@ function bindEvents() {
|
||||||
|
|
||||||
dom.refreshUnitBtn2.addEventListener("click", () => withStatus(loadUnits().then(loadEvents)));
|
dom.refreshUnitBtn2.addEventListener("click", () => withStatus(loadUnits().then(loadEvents)));
|
||||||
dom.newUnitBtn2.addEventListener("click", openCreateUnitModal);
|
dom.newUnitBtn2.addEventListener("click", openCreateUnitModal);
|
||||||
|
bindUnitEquipmentModalEvents();
|
||||||
|
|
||||||
document.addEventListener("equipments-updated", () => {
|
document.addEventListener("equipments-updated", () => {
|
||||||
renderUnits();
|
renderUnits();
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,11 @@ export const dom = {
|
||||||
refreshUnitBtn2: byId("refreshUnitBtn2"),
|
refreshUnitBtn2: byId("refreshUnitBtn2"),
|
||||||
newUnitBtn2: byId("newUnitBtn2"),
|
newUnitBtn2: byId("newUnitBtn2"),
|
||||||
unitConfigList: byId("unitConfigList"),
|
unitConfigList: byId("unitConfigList"),
|
||||||
|
unitEquipmentModal: byId("unitEquipmentModal"),
|
||||||
|
unitEquipmentList: byId("unitEquipmentList"),
|
||||||
|
closeUnitEquipmentModalBtn: byId("closeUnitEquipmentModal"),
|
||||||
|
cancelUnitEquipmentBtn: byId("cancelUnitEquipment"),
|
||||||
|
confirmUnitEquipmentBtn: byId("confirmUnitEquipment"),
|
||||||
closeUnitModalBtn: byId("closeUnitModal"),
|
closeUnitModalBtn: byId("closeUnitModal"),
|
||||||
closeEquipmentModalBtn: byId("closeEquipmentModal"),
|
closeEquipmentModalBtn: byId("closeEquipmentModal"),
|
||||||
refreshEventBtn: byId("refreshEventBtn"),
|
refreshEventBtn: byId("refreshEventBtn"),
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,26 @@
|
||||||
import { apiFetch } from "./api.js";
|
import { apiFetch, withStatus } from "./api.js";
|
||||||
import { dom } from "./dom.js";
|
import { dom } from "./dom.js";
|
||||||
import { loadEvents } from "./events.js";
|
import { loadEvents } from "./events.js";
|
||||||
import { renderEquipments } from "./equipment.js";
|
import { loadEquipments, renderEquipments } from "./equipment.js";
|
||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
function equipmentOf(item) {
|
||||||
|
return item && item.equipment ? item.equipment : item;
|
||||||
|
}
|
||||||
|
|
||||||
function equipmentCount(unitId) {
|
function equipmentCount(unitId) {
|
||||||
return state.equipments.filter((item) => {
|
return state.equipments.filter((item) => {
|
||||||
const equipment = item.equipment || item;
|
const equipment = equipmentOf(item);
|
||||||
return equipment.unit_id === unitId;
|
return equipment.unit_id === unitId;
|
||||||
}).length;
|
}).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function boundEquipments(unitId) {
|
||||||
|
return state.equipments
|
||||||
|
.map(equipmentOf)
|
||||||
|
.filter((e) => e.unit_id === unitId);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderUnitOptions(selected = "", target = dom.equipmentUnitId, includeEmpty = true) {
|
export function renderUnitOptions(selected = "", target = dom.equipmentUnitId, includeEmpty = true) {
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -97,11 +107,17 @@ function runtimeBadge(runtime) {
|
||||||
return `<span class="badge ${cls}">${label}</span>`;
|
return `<span class="badge ${cls}">${label}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUnitCard(unit, interactive) {
|
function buildUnitCard(unit, mode) {
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
const selected = interactive && state.selectedUnitId === unit.id;
|
const selected = mode === "interactive" && state.selectedUnitId === unit.id;
|
||||||
card.className = `list-item unit-card ${selected ? "selected" : ""}`;
|
card.className = `list-item unit-card ${selected ? "selected" : ""}`;
|
||||||
const runtime = state.runtimes.get(unit.id);
|
const runtime = state.runtimes.get(unit.id);
|
||||||
|
|
||||||
|
const bound = boundEquipments(unit.id);
|
||||||
|
const equipTags = bound.length
|
||||||
|
? bound.map((e) => `<span class="badge">${e.code}</span>`).join("")
|
||||||
|
: '<span class="muted">无设备</span>';
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<strong>${unit.code}</strong>
|
<strong>${unit.code}</strong>
|
||||||
|
|
@ -109,12 +125,13 @@ function buildUnitCard(unit, interactive) {
|
||||||
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
|
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>${unit.name}</div>
|
<div>${unit.name}</div>
|
||||||
<div class="muted">设备 ${equipmentCount(unit.id)} 台 | Acc ${runtime ? Math.floor(runtime.display_acc_sec / 1000) : 0}s</div>
|
<div class="muted">设备 ${bound.length} 台 | Acc ${runtime ? Math.floor(runtime.display_acc_sec / 1000) : 0}s</div>
|
||||||
<div class="muted">Run ${unit.run_time_sec}s / Stop ${unit.stop_time_sec}s / Acc ${unit.acc_time_sec}s / BL ${unit.bl_time_sec}s</div>
|
<div class="muted">Run ${unit.run_time_sec}s / Stop ${unit.stop_time_sec}s / Acc ${unit.acc_time_sec}s / BL ${unit.bl_time_sec}s</div>
|
||||||
|
${mode === "config" ? `<div class="unit-equipment-tags">${equipTags}</div>` : ""}
|
||||||
<div class="row unit-card-actions"></div>
|
<div class="row unit-card-actions"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (interactive) {
|
if (mode === "interactive") {
|
||||||
card.addEventListener("click", () => {
|
card.addEventListener("click", () => {
|
||||||
selectUnit(unit.id).catch((error) => {
|
selectUnit(unit.id).catch((error) => {
|
||||||
dom.statusText.textContent = error.message;
|
dom.statusText.textContent = error.message;
|
||||||
|
|
@ -144,6 +161,17 @@ function buildUnitCard(unit, interactive) {
|
||||||
|
|
||||||
actions.append(editBtn, deleteBtn);
|
actions.append(editBtn, deleteBtn);
|
||||||
|
|
||||||
|
if (mode === "config") {
|
||||||
|
const selectEquipBtn = document.createElement("button");
|
||||||
|
selectEquipBtn.className = "secondary";
|
||||||
|
selectEquipBtn.textContent = "选择设备";
|
||||||
|
selectEquipBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openUnitEquipmentModal(unit);
|
||||||
|
});
|
||||||
|
actions.append(selectEquipBtn);
|
||||||
|
}
|
||||||
|
|
||||||
const isAutoOn = runtime?.auto_enabled;
|
const isAutoOn = runtime?.auto_enabled;
|
||||||
const startBlocked = !isAutoOn && (runtime?.fault_locked || runtime?.manual_ack_required || runtime?.rem_local);
|
const startBlocked = !isAutoOn && (runtime?.fault_locked || runtime?.manual_ack_required || runtime?.rem_local);
|
||||||
const autoBtn = document.createElement("button");
|
const autoBtn = document.createElement("button");
|
||||||
|
|
@ -178,7 +206,7 @@ function buildUnitCard(unit, interactive) {
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderToContainer(container, interactive) {
|
function renderToContainer(container, mode) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
|
@ -188,13 +216,13 @@ function renderToContainer(container, interactive) {
|
||||||
}
|
}
|
||||||
|
|
||||||
state.units.forEach((unit) => {
|
state.units.forEach((unit) => {
|
||||||
container.appendChild(buildUnitCard(unit, interactive));
|
container.appendChild(buildUnitCard(unit, mode));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderUnits() {
|
export function renderUnits() {
|
||||||
renderToContainer(dom.unitList, true);
|
renderToContainer(dom.unitList, "interactive");
|
||||||
renderToContainer(dom.unitConfigList, false);
|
renderToContainer(dom.unitConfigList, "config");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadUnits() {
|
export async function loadUnits() {
|
||||||
|
|
@ -257,3 +285,71 @@ export async function deleteUnit(unitId) {
|
||||||
renderEquipments();
|
renderEquipments();
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Unit Equipment Selection Modal ──
|
||||||
|
|
||||||
|
let _unitEquipmentTargetId = null;
|
||||||
|
const _unitEquipmentSelected = new Set();
|
||||||
|
|
||||||
|
function openUnitEquipmentModal(unit) {
|
||||||
|
_unitEquipmentTargetId = unit.id;
|
||||||
|
_unitEquipmentSelected.clear();
|
||||||
|
|
||||||
|
const allEquipments = state.equipments.map(equipmentOf);
|
||||||
|
const bound = new Set(boundEquipments(unit.id).map((e) => e.id));
|
||||||
|
bound.forEach((id) => _unitEquipmentSelected.add(id));
|
||||||
|
|
||||||
|
dom.unitEquipmentList.innerHTML = "";
|
||||||
|
allEquipments.forEach((e) => {
|
||||||
|
const row = document.createElement("label");
|
||||||
|
row.className = "list-item";
|
||||||
|
row.style.cssText = "display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 10px";
|
||||||
|
const checked = bound.has(e.id) ? "checked" : "";
|
||||||
|
row.innerHTML = `<input type="checkbox" data-equip-id="${e.id}" ${checked} /> <span>${e.code} / ${e.name}</span>`;
|
||||||
|
row.querySelector("input").addEventListener("change", (ev) => {
|
||||||
|
if (ev.target.checked) _unitEquipmentSelected.add(e.id);
|
||||||
|
else _unitEquipmentSelected.delete(e.id);
|
||||||
|
});
|
||||||
|
dom.unitEquipmentList.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.unitEquipmentModal.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUnitEquipmentModal() {
|
||||||
|
dom.unitEquipmentModal.classList.add("hidden");
|
||||||
|
_unitEquipmentTargetId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmUnitEquipment() {
|
||||||
|
if (!_unitEquipmentTargetId) return;
|
||||||
|
|
||||||
|
const previouslyBound = new Set(boundEquipments(_unitEquipmentTargetId).map((e) => e.id));
|
||||||
|
|
||||||
|
const toBind = [..._unitEquipmentSelected].filter((id) => !previouslyBound.has(id));
|
||||||
|
const toUnbind = [...previouslyBound].filter((id) => !_unitEquipmentSelected.has(id));
|
||||||
|
|
||||||
|
if (toBind.length > 0) {
|
||||||
|
await apiFetch("/api/equipment/batch/set-unit", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ equipment_ids: toBind, unit_id: _unitEquipmentTargetId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toUnbind.length > 0) {
|
||||||
|
await apiFetch("/api/equipment/batch/set-unit", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ equipment_ids: toUnbind, unit_id: null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeUnitEquipmentModal();
|
||||||
|
await loadEquipments();
|
||||||
|
await loadUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindUnitEquipmentModalEvents() {
|
||||||
|
dom.closeUnitEquipmentModalBtn.addEventListener("click", closeUnitEquipmentModal);
|
||||||
|
dom.cancelUnitEquipmentBtn.addEventListener("click", closeUnitEquipmentModal);
|
||||||
|
dom.confirmUnitEquipmentBtn.addEventListener("click", () => withStatus(confirmUnitEquipment()));
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue