feat(web): add unit and event management views
This commit is contained in:
parent
4e3d325437
commit
1f29eb3871
|
|
@ -7,5 +7,11 @@
|
||||||
<input id="equipmentKeyword" placeholder="搜索编码或名称" />
|
<input id="equipmentKeyword" placeholder="搜索编码或名称" />
|
||||||
<button type="button" class="secondary" id="refreshEquipmentBtn">刷新</button>
|
<button type="button" class="secondary" id="refreshEquipmentBtn">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toolbar equipment-batch-toolbar">
|
||||||
|
<div class="muted" id="selectedEquipmentSummary">已选 0 台设备</div>
|
||||||
|
<select id="equipmentBatchUnitId"></select>
|
||||||
|
<button type="button" class="secondary" id="clearEquipmentSelectionBtn">清空选择</button>
|
||||||
|
<button type="button" id="applyEquipmentUnitBtn">批量设单元</button>
|
||||||
|
</div>
|
||||||
<div class="list equipment-list" id="equipmentList"></div>
|
<div class="list equipment-list" id="equipmentList"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
<section class="panel bottom-middle">
|
<section class="panel bottom-middle">
|
||||||
|
<div class="stack-panel">
|
||||||
|
<div class="stack-section event-section">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>系统事件</h2>
|
||||||
|
<button type="button" class="secondary" id="refreshEventBtn">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="list event-list" id="eventList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack-section stack-section-bordered log-section">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>实时日志</h2>
|
<h2>实时日志</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="log" id="logView"></div>
|
<div class="log" id="logView"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,55 @@
|
||||||
|
<div class="modal hidden" id="unitModal">
|
||||||
|
<div class="modal-content modal-sm">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>控制单元配置</h3>
|
||||||
|
<button class="secondary" id="closeUnitModal">X</button>
|
||||||
|
</div>
|
||||||
|
<form id="unitForm" class="form">
|
||||||
|
<input type="hidden" id="unitId" />
|
||||||
|
<label>
|
||||||
|
编码
|
||||||
|
<input id="unitCode" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
名称
|
||||||
|
<input id="unitName" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
说明
|
||||||
|
<input id="unitDescription" />
|
||||||
|
</label>
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" id="unitEnabled" checked />
|
||||||
|
<span>启用</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
投煤运行时间(秒)
|
||||||
|
<input id="unitRunTimeSec" type="number" min="0" value="0" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
投煤停止时间(秒)
|
||||||
|
<input id="unitStopTimeSec" type="number" min="0" value="0" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
投煤累计阈值(秒)
|
||||||
|
<input id="unitAccTimeSec" type="number" min="0" value="0" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
布料机运行时间(秒)
|
||||||
|
<input id="unitBlTimeSec" type="number" min="0" value="0" />
|
||||||
|
</label>
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" id="unitManualAck" checked />
|
||||||
|
<span>故障恢复后需人工确认</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="secondary" id="unitReset">清空</button>
|
||||||
|
<button type="submit" id="unitSubmit">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal hidden" id="equipmentModal">
|
<div class="modal hidden" id="equipmentModal">
|
||||||
<div class="modal-content modal-sm">
|
<div class="modal-content modal-sm">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
|
|
@ -6,6 +58,10 @@
|
||||||
</div>
|
</div>
|
||||||
<form id="equipmentForm" class="form">
|
<form id="equipmentForm" class="form">
|
||||||
<input type="hidden" id="equipmentId" />
|
<input type="hidden" id="equipmentId" />
|
||||||
|
<label>
|
||||||
|
所属单元
|
||||||
|
<select id="equipmentUnitId"></select>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
编码
|
编码
|
||||||
<input id="equipmentCode" required />
|
<input id="equipmentCode" required />
|
||||||
|
|
@ -45,7 +101,7 @@
|
||||||
<div class="tree" id="nodeTree"></div>
|
<div class="tree" id="nodeTree"></div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<div class="muted" id="selectedCount">已选中 0 个节点</div>
|
<div class="muted" id="selectedCount">已选中 0 个节点</div>
|
||||||
<button id="createPoints">创建点位</button>
|
<button id="createPoints">创建设备点位</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,22 @@
|
||||||
<section class="panel bottom-left">
|
<section class="panel bottom-left">
|
||||||
|
<div class="stack-panel">
|
||||||
|
<div class="stack-section">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>控制单元</h2>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" class="secondary" id="refreshUnitBtn">刷新</button>
|
||||||
|
<button type="button" id="newUnitBtn">+ 新增</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list unit-list" id="unitList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack-section stack-section-bordered">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>数据源</h2>
|
<h2>数据源</h2>
|
||||||
<button id="openSourceForm">+ 新增</button>
|
<button type="button" id="openSourceForm">+ 新增</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="source-panels" id="sourceList"></div>
|
<div class="source-panels" id="sourceList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ import { withStatus } from "./api.js";
|
||||||
import { openChart, renderChart } from "./chart.js";
|
import { openChart, renderChart } from "./chart.js";
|
||||||
import { dom } from "./dom.js";
|
import { dom } from "./dom.js";
|
||||||
import { closeApiDocDrawer, openApiDocDrawer } from "./docs.js";
|
import { closeApiDocDrawer, openApiDocDrawer } from "./docs.js";
|
||||||
|
import { loadEvents } from "./events.js";
|
||||||
import {
|
import {
|
||||||
|
applyBatchEquipmentUnit,
|
||||||
clearEquipmentFilter,
|
clearEquipmentFilter,
|
||||||
clearPointBinding,
|
clearPointBinding,
|
||||||
|
clearSelectedEquipments,
|
||||||
closeEquipmentModal,
|
closeEquipmentModal,
|
||||||
loadEquipments,
|
loadEquipments,
|
||||||
openCreateEquipmentModal,
|
openCreateEquipmentModal,
|
||||||
|
|
@ -29,19 +32,28 @@ 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";
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
|
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
|
||||||
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
|
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
|
||||||
dom.equipmentForm.addEventListener("submit", (event) => withStatus(saveEquipment(event)));
|
dom.equipmentForm.addEventListener("submit", (event) => withStatus(saveEquipment(event)));
|
||||||
dom.pointBindingForm.addEventListener("submit", (event) => withStatus(savePointBinding(event)));
|
dom.pointBindingForm.addEventListener("submit", (event) => withStatus(savePointBinding(event)));
|
||||||
dom.batchBindingForm.addEventListener("submit", (event) => withStatus(saveBatchBinding(event)));
|
dom.batchBindingForm.addEventListener("submit", (event) => withStatus(saveBatchBinding(event)));
|
||||||
|
|
||||||
|
dom.unitResetBtn.addEventListener("click", resetUnitForm);
|
||||||
|
dom.refreshUnitBtn.addEventListener("click", () => withStatus(loadUnits().then(loadEvents)));
|
||||||
|
dom.newUnitBtn.addEventListener("click", openCreateUnitModal);
|
||||||
|
dom.closeUnitModalBtn.addEventListener("click", closeUnitModal);
|
||||||
|
|
||||||
dom.sourceResetBtn.addEventListener("click", () => dom.sourceForm.reset());
|
dom.sourceResetBtn.addEventListener("click", () => dom.sourceForm.reset());
|
||||||
dom.equipmentResetBtn.addEventListener("click", resetEquipmentForm);
|
dom.equipmentResetBtn.addEventListener("click", resetEquipmentForm);
|
||||||
dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments()));
|
dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments()));
|
||||||
dom.newEquipmentBtn.addEventListener("click", openCreateEquipmentModal);
|
dom.newEquipmentBtn.addEventListener("click", openCreateEquipmentModal);
|
||||||
dom.closeEquipmentModalBtn.addEventListener("click", closeEquipmentModal);
|
dom.closeEquipmentModalBtn.addEventListener("click", closeEquipmentModal);
|
||||||
dom.clearEquipmentFilterBtn.addEventListener("click", () => withStatus(clearEquipmentFilter()));
|
dom.clearEquipmentFilterBtn.addEventListener("click", () => withStatus(clearEquipmentFilter()));
|
||||||
|
dom.applyEquipmentUnitBtn.addEventListener("click", () => withStatus(applyBatchEquipmentUnit()));
|
||||||
|
dom.clearEquipmentSelectionBtn.addEventListener("click", clearSelectedEquipments);
|
||||||
|
|
||||||
dom.openPointModalBtn.addEventListener("click", openPointCreateModal);
|
dom.openPointModalBtn.addEventListener("click", openPointCreateModal);
|
||||||
dom.pointSourceSelect.addEventListener("change", () => {
|
dom.pointSourceSelect.addEventListener("change", () => {
|
||||||
|
|
@ -82,6 +94,7 @@ function bindEvents() {
|
||||||
|
|
||||||
dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
|
dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
|
||||||
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer);
|
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer);
|
||||||
|
dom.refreshEventBtn.addEventListener("click", () => withStatus(loadEvents()));
|
||||||
|
|
||||||
dom.refreshChartBtn.addEventListener("click", () => {
|
dom.refreshChartBtn.addEventListener("click", () => {
|
||||||
if (!state.chartPointId) {
|
if (!state.chartPointId) {
|
||||||
|
|
@ -111,6 +124,10 @@ function bindEvents() {
|
||||||
withStatus(loadEquipments());
|
withStatus(loadEquipments());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("equipments-updated", () => {
|
||||||
|
renderUnits();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
|
@ -122,8 +139,10 @@ async function bootstrap() {
|
||||||
startLogs();
|
startLogs();
|
||||||
startPointSocket();
|
startPointSocket();
|
||||||
|
|
||||||
|
await withStatus(loadUnits());
|
||||||
await withStatus(loadSources());
|
await withStatus(loadSources());
|
||||||
await withStatus(loadEquipments());
|
await withStatus(loadEquipments());
|
||||||
|
await withStatus(loadEvents());
|
||||||
await withStatus(loadPoints());
|
await withStatus(loadPoints());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ const byId = (id) => document.getElementById(id);
|
||||||
export const dom = {
|
export const dom = {
|
||||||
statusText: byId("statusText"),
|
statusText: byId("statusText"),
|
||||||
sourceList: byId("sourceList"),
|
sourceList: byId("sourceList"),
|
||||||
|
unitList: byId("unitList"),
|
||||||
|
eventList: byId("eventList"),
|
||||||
nodeTree: byId("nodeTree"),
|
nodeTree: byId("nodeTree"),
|
||||||
pointList: byId("pointList"),
|
pointList: byId("pointList"),
|
||||||
pointsPageInfo: byId("pointsPageInfo"),
|
pointsPageInfo: byId("pointsPageInfo"),
|
||||||
|
|
@ -18,11 +20,24 @@ export const dom = {
|
||||||
chartTitle: byId("chartTitle"),
|
chartTitle: byId("chartTitle"),
|
||||||
chartSummary: byId("chartSummary"),
|
chartSummary: byId("chartSummary"),
|
||||||
pointModal: byId("pointModal"),
|
pointModal: byId("pointModal"),
|
||||||
|
unitModal: byId("unitModal"),
|
||||||
sourceModal: byId("sourceModal"),
|
sourceModal: byId("sourceModal"),
|
||||||
equipmentModal: byId("equipmentModal"),
|
equipmentModal: byId("equipmentModal"),
|
||||||
pointBindingModal: byId("pointBindingModal"),
|
pointBindingModal: byId("pointBindingModal"),
|
||||||
batchBindingModal: byId("batchBindingModal"),
|
batchBindingModal: byId("batchBindingModal"),
|
||||||
apiDocDrawer: byId("apiDocDrawer"),
|
apiDocDrawer: byId("apiDocDrawer"),
|
||||||
|
unitForm: byId("unitForm"),
|
||||||
|
unitId: byId("unitId"),
|
||||||
|
unitCode: byId("unitCode"),
|
||||||
|
unitName: byId("unitName"),
|
||||||
|
unitDescription: byId("unitDescription"),
|
||||||
|
unitEnabled: byId("unitEnabled"),
|
||||||
|
unitRunTimeSec: byId("unitRunTimeSec"),
|
||||||
|
unitStopTimeSec: byId("unitStopTimeSec"),
|
||||||
|
unitAccTimeSec: byId("unitAccTimeSec"),
|
||||||
|
unitBlTimeSec: byId("unitBlTimeSec"),
|
||||||
|
unitManualAck: byId("unitManualAck"),
|
||||||
|
unitResetBtn: byId("unitReset"),
|
||||||
sourceForm: byId("sourceForm"),
|
sourceForm: byId("sourceForm"),
|
||||||
sourceId: byId("sourceId"),
|
sourceId: byId("sourceId"),
|
||||||
sourceName: byId("sourceName"),
|
sourceName: byId("sourceName"),
|
||||||
|
|
@ -31,14 +46,23 @@ export const dom = {
|
||||||
sourceResetBtn: byId("sourceReset"),
|
sourceResetBtn: byId("sourceReset"),
|
||||||
equipmentForm: byId("equipmentForm"),
|
equipmentForm: byId("equipmentForm"),
|
||||||
equipmentId: byId("equipmentId"),
|
equipmentId: byId("equipmentId"),
|
||||||
|
equipmentUnitId: byId("equipmentUnitId"),
|
||||||
equipmentCode: byId("equipmentCode"),
|
equipmentCode: byId("equipmentCode"),
|
||||||
equipmentName: byId("equipmentName"),
|
equipmentName: byId("equipmentName"),
|
||||||
equipmentKind: byId("equipmentKind"),
|
equipmentKind: byId("equipmentKind"),
|
||||||
equipmentDescription: byId("equipmentDescription"),
|
equipmentDescription: byId("equipmentDescription"),
|
||||||
equipmentResetBtn: byId("equipmentReset"),
|
equipmentResetBtn: byId("equipmentReset"),
|
||||||
equipmentKeyword: byId("equipmentKeyword"),
|
equipmentKeyword: byId("equipmentKeyword"),
|
||||||
|
equipmentBatchUnitId: byId("equipmentBatchUnitId"),
|
||||||
|
selectedEquipmentSummary: byId("selectedEquipmentSummary"),
|
||||||
equipmentList: byId("equipmentList"),
|
equipmentList: byId("equipmentList"),
|
||||||
|
refreshUnitBtn: byId("refreshUnitBtn"),
|
||||||
|
newUnitBtn: byId("newUnitBtn"),
|
||||||
|
closeUnitModalBtn: byId("closeUnitModal"),
|
||||||
closeEquipmentModalBtn: byId("closeEquipmentModal"),
|
closeEquipmentModalBtn: byId("closeEquipmentModal"),
|
||||||
|
refreshEventBtn: byId("refreshEventBtn"),
|
||||||
|
applyEquipmentUnitBtn: byId("applyEquipmentUnitBtn"),
|
||||||
|
clearEquipmentSelectionBtn: byId("clearEquipmentSelectionBtn"),
|
||||||
pointBindingForm: byId("pointBindingForm"),
|
pointBindingForm: byId("pointBindingForm"),
|
||||||
bindingPointId: byId("bindingPointId"),
|
bindingPointId: byId("bindingPointId"),
|
||||||
bindingPointName: byId("bindingPointName"),
|
bindingPointName: byId("bindingPointName"),
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,61 @@ function equipmentOf(item) {
|
||||||
return item && item.equipment ? item.equipment : item;
|
return item && item.equipment ? item.equipment : item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentUnitLabel(unitId) {
|
||||||
|
if (!unitId) {
|
||||||
|
return "未绑定单元";
|
||||||
|
}
|
||||||
|
const unit = state.unitMap.get(unitId);
|
||||||
|
return unit ? `${unit.code} / ${unit.name}` : "未知单元";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredEquipments() {
|
||||||
|
if (!state.selectedUnitId) {
|
||||||
|
return state.equipments;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.equipments.filter((item) => {
|
||||||
|
const equipment = equipmentOf(item);
|
||||||
|
return equipment.unit_id === state.selectedUnitId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEquipmentUnitOptions(selected = "", target = dom.equipmentUnitId) {
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = ['<option value="">未绑定单元</option>'];
|
||||||
|
state.units.forEach((unit) => {
|
||||||
|
const isSelected = unit.id === selected ? "selected" : "";
|
||||||
|
options.push(`<option value="${unit.id}" ${isSelected}>${unit.code} / ${unit.name}</option>`);
|
||||||
|
});
|
||||||
|
target.innerHTML = options.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBatchUnitOptions(selected = "") {
|
||||||
|
if (!dom.equipmentBatchUnitId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = ['<option value="">批量绑定到单元...</option>'];
|
||||||
|
state.units.forEach((unit) => {
|
||||||
|
const isSelected = unit.id === selected ? "selected" : "";
|
||||||
|
options.push(`<option value="${unit.id}" ${isSelected}>${unit.code} / ${unit.name}</option>`);
|
||||||
|
});
|
||||||
|
dom.equipmentBatchUnitId.innerHTML = options.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedEquipmentSummary() {
|
||||||
|
if (!dom.selectedEquipmentSummary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dom.selectedEquipmentSummary.textContent = `已选 ${state.selectedEquipmentIds.size} 台设备`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) {
|
export function renderBindingEquipmentOptions(selected = "", target = dom.bindingEquipmentId) {
|
||||||
const options = ['<option value="">Unbound</option>'];
|
const options = ['<option value="">Unbound</option>'];
|
||||||
state.equipments.forEach((item) => {
|
filteredEquipments().forEach((item) => {
|
||||||
const equipment = equipmentOf(item);
|
const equipment = equipmentOf(item);
|
||||||
const isSelected = equipment.id === selected ? "selected" : "";
|
const isSelected = equipment.id === selected ? "selected" : "";
|
||||||
options.push(
|
options.push(
|
||||||
|
|
@ -28,6 +80,7 @@ export function renderBatchBindingDefaults() {
|
||||||
export function resetEquipmentForm() {
|
export function resetEquipmentForm() {
|
||||||
dom.equipmentForm.reset();
|
dom.equipmentForm.reset();
|
||||||
dom.equipmentId.value = "";
|
dom.equipmentId.value = "";
|
||||||
|
renderEquipmentUnitOptions("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEquipmentModal() {
|
function openEquipmentModal() {
|
||||||
|
|
@ -45,6 +98,7 @@ export function openCreateEquipmentModal() {
|
||||||
|
|
||||||
function openEditEquipmentModal(equipment) {
|
function openEditEquipmentModal(equipment) {
|
||||||
dom.equipmentId.value = equipment.id || "";
|
dom.equipmentId.value = equipment.id || "";
|
||||||
|
dom.equipmentUnitId.value = equipment.unit_id || "";
|
||||||
dom.equipmentCode.value = equipment.code || "";
|
dom.equipmentCode.value = equipment.code || "";
|
||||||
dom.equipmentName.value = equipment.name || "";
|
dom.equipmentName.value = equipment.name || "";
|
||||||
dom.equipmentKind.value = equipment.kind || "";
|
dom.equipmentKind.value = equipment.kind || "";
|
||||||
|
|
@ -61,6 +115,21 @@ async function selectEquipment(equipmentId) {
|
||||||
await loadPoints();
|
await loadPoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleEquipmentSelection(equipmentId, checked) {
|
||||||
|
if (checked) {
|
||||||
|
state.selectedEquipmentIds.add(equipmentId);
|
||||||
|
} else {
|
||||||
|
state.selectedEquipmentIds.delete(equipmentId);
|
||||||
|
}
|
||||||
|
updateSelectedEquipmentSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSelectedEquipments() {
|
||||||
|
state.selectedEquipmentIds.clear();
|
||||||
|
renderEquipments();
|
||||||
|
updateSelectedEquipmentSummary();
|
||||||
|
}
|
||||||
|
|
||||||
export function clearEquipmentFilter() {
|
export function clearEquipmentFilter() {
|
||||||
state.selectedEquipmentId = null;
|
state.selectedEquipmentId = null;
|
||||||
state.pointsPage = 1;
|
state.pointsPage = 1;
|
||||||
|
|
@ -71,29 +140,37 @@ export function clearEquipmentFilter() {
|
||||||
|
|
||||||
export function renderEquipments() {
|
export function renderEquipments() {
|
||||||
dom.equipmentList.innerHTML = "";
|
dom.equipmentList.innerHTML = "";
|
||||||
|
updateSelectedEquipmentSummary();
|
||||||
|
|
||||||
const activeEquipment = state.selectedEquipmentId
|
const activeEquipment = state.selectedEquipmentId
|
||||||
? state.equipmentMap.get(state.selectedEquipmentId) || null
|
? state.equipmentMap.get(state.selectedEquipmentId) || null
|
||||||
: null;
|
: null;
|
||||||
dom.clearEquipmentFilterBtn.textContent = activeEquipment
|
dom.clearEquipmentFilterBtn.textContent = activeEquipment
|
||||||
? `设备筛选: ${activeEquipment.name}`
|
? `设备筛选 ${activeEquipment.name}`
|
||||||
: "设备筛选: 全部";
|
: "设备筛选 全部";
|
||||||
|
|
||||||
if (!state.equipments.length) {
|
const items = filteredEquipments();
|
||||||
|
if (!items.length) {
|
||||||
dom.equipmentList.innerHTML = '<div class="list-item"><div class="muted">No equipment</div></div>';
|
dom.equipmentList.innerHTML = '<div class="list-item"><div class="muted">No equipment</div></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.equipments.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const equipment = equipmentOf(item);
|
const equipment = equipmentOf(item);
|
||||||
const box = document.createElement("div");
|
const box = document.createElement("div");
|
||||||
box.className = `list-item equipment-card ${state.selectedEquipmentId === equipment.id ? "selected" : ""}`;
|
box.className = `list-item equipment-card ${state.selectedEquipmentId === equipment.id ? "selected" : ""}`;
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
|
<label class="equipment-select-row">
|
||||||
|
<input type="checkbox" data-equipment-select="true" ${state.selectedEquipmentIds.has(equipment.id) ? "checked" : ""} />
|
||||||
|
<span class="muted">批量选择</span>
|
||||||
|
</label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<strong>${equipment.code}</strong>
|
<strong>${equipment.code}</strong>
|
||||||
<span class="badge">${item.point_count ?? 0} pts</span>
|
<span class="badge">${item.point_count ?? 0} pts</span>
|
||||||
</div>
|
</div>
|
||||||
<div>${equipment.name}</div>
|
<div>${equipment.name}</div>
|
||||||
<div class="muted">${equipment.kind || "No type"}</div>
|
<div class="muted">${equipment.kind || "No type"}</div>
|
||||||
|
<div class="muted">单元: ${currentUnitLabel(equipment.unit_id)}</div>
|
||||||
<div class="row equipment-card-actions"></div>
|
<div class="row equipment-card-actions"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -103,6 +180,14 @@ export function renderEquipments() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const checkbox = box.querySelector('input[data-equipment-select="true"]');
|
||||||
|
checkbox.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
checkbox.addEventListener("change", (event) => {
|
||||||
|
toggleEquipmentSelection(equipment.id, event.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
const actionRow = box.querySelector(".equipment-card-actions");
|
const actionRow = box.querySelector(".equipment-card-actions");
|
||||||
|
|
||||||
const editBtn = document.createElement("button");
|
const editBtn = document.createElement("button");
|
||||||
|
|
@ -141,6 +226,15 @@ export async function loadEquipments() {
|
||||||
return [equipment.id, equipment];
|
return [equipment.id, equipment];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
state.selectedEquipmentIds.forEach((id) => {
|
||||||
|
if (!state.equipmentMap.has(id)) {
|
||||||
|
state.selectedEquipmentIds.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderEquipmentUnitOptions(dom.equipmentUnitId?.value || "");
|
||||||
|
renderBatchUnitOptions(dom.equipmentBatchUnitId?.value || "");
|
||||||
renderBindingEquipmentOptions();
|
renderBindingEquipmentOptions();
|
||||||
renderBatchBindingDefaults();
|
renderBatchBindingDefaults();
|
||||||
if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) {
|
if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) {
|
||||||
|
|
@ -148,12 +242,15 @@ export async function loadEquipments() {
|
||||||
}
|
}
|
||||||
renderEquipments();
|
renderEquipments();
|
||||||
updatePointFilterSummary();
|
updatePointFilterSummary();
|
||||||
|
document.dispatchEvent(new Event("equipments-updated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveEquipment(event) {
|
export async function saveEquipment(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
const unitId = dom.equipmentUnitId.value || null;
|
||||||
const payload = {
|
const payload = {
|
||||||
|
unit_id: unitId,
|
||||||
code: dom.equipmentCode.value.trim(),
|
code: dom.equipmentCode.value.trim(),
|
||||||
name: dom.equipmentName.value.trim(),
|
name: dom.equipmentName.value.trim(),
|
||||||
kind: dom.equipmentKind.value.trim() || null,
|
kind: dom.equipmentKind.value.trim() || null,
|
||||||
|
|
@ -176,6 +273,25 @@ export async function saveEquipment(event) {
|
||||||
await loadPoints();
|
await loadPoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function applyBatchEquipmentUnit() {
|
||||||
|
if (!state.selectedEquipmentIds.size) {
|
||||||
|
throw new Error("请先选择设备");
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = dom.equipmentBatchUnitId.value;
|
||||||
|
await apiFetch("/api/equipment/batch/set-unit", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
equipment_ids: Array.from(state.selectedEquipmentIds),
|
||||||
|
unit_id: value || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSelectedEquipments();
|
||||||
|
renderBatchUnitOptions("");
|
||||||
|
await loadEquipments();
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteEquipment(equipmentId) {
|
export async function deleteEquipment(equipmentId) {
|
||||||
if (!window.confirm("Delete this equipment?")) {
|
if (!window.confirm("Delete this equipment?")) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -185,6 +301,7 @@ export async function deleteEquipment(equipmentId) {
|
||||||
if (state.selectedEquipmentId === equipmentId) {
|
if (state.selectedEquipmentId === equipmentId) {
|
||||||
state.selectedEquipmentId = null;
|
state.selectedEquipmentId = null;
|
||||||
}
|
}
|
||||||
|
state.selectedEquipmentIds.delete(equipmentId);
|
||||||
resetEquipmentForm();
|
resetEquipmentForm();
|
||||||
closeEquipmentModal();
|
closeEquipmentModal();
|
||||||
clearSelectedPoints();
|
clearSelectedPoints();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
function formatTime(value) {
|
||||||
|
if (!value) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEvents() {
|
||||||
|
dom.eventList.innerHTML = "";
|
||||||
|
|
||||||
|
if (!state.events.length) {
|
||||||
|
dom.eventList.innerHTML = '<div class="list-item"><div class="muted">暂无事件</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.events.forEach((item) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "list-item event-card";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
<strong>${item.event_type}</strong>
|
||||||
|
<span class="badge">${(item.level || "info").toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div>${item.message}</div>
|
||||||
|
<div class="muted">${formatTime(item.created_at)}</div>
|
||||||
|
`;
|
||||||
|
dom.eventList.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEvents() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: "1",
|
||||||
|
page_size: "20",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.selectedUnitId) {
|
||||||
|
params.set("unit_id", state.selectedUnitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiFetch(`/api/event?${params.toString()}`);
|
||||||
|
state.events = response.data || [];
|
||||||
|
renderEvents();
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
export const state = {
|
export const state = {
|
||||||
|
units: [],
|
||||||
|
unitMap: new Map(),
|
||||||
|
selectedUnitId: null,
|
||||||
sources: [],
|
sources: [],
|
||||||
|
events: [],
|
||||||
equipments: [],
|
equipments: [],
|
||||||
equipmentMap: new Map(),
|
equipmentMap: new Map(),
|
||||||
selectedEquipmentId: null,
|
selectedEquipmentId: null,
|
||||||
|
selectedEquipmentIds: new Set(),
|
||||||
selectedSourceId: null,
|
selectedSourceId: null,
|
||||||
selectedNodeIds: new Set(),
|
selectedNodeIds: new Set(),
|
||||||
selectedPointIds: new Set(),
|
selectedPointIds: new Set(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { apiFetch } from "./api.js";
|
||||||
|
import { dom } from "./dom.js";
|
||||||
|
import { loadEvents } from "./events.js";
|
||||||
|
import { renderEquipments } from "./equipment.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
function equipmentCount(unitId) {
|
||||||
|
return state.equipments.filter((item) => {
|
||||||
|
const equipment = item.equipment || item;
|
||||||
|
return equipment.unit_id === unitId;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderUnitOptions(selected = "", target = dom.equipmentUnitId, includeEmpty = true) {
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [];
|
||||||
|
if (includeEmpty) {
|
||||||
|
options.push('<option value="">未绑定单元</option>');
|
||||||
|
}
|
||||||
|
|
||||||
|
state.units.forEach((unit) => {
|
||||||
|
const isSelected = unit.id === selected ? "selected" : "";
|
||||||
|
options.push(`<option value="${unit.id}" ${isSelected}>${unit.code} / ${unit.name}</option>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
target.innerHTML = options.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetUnitForm() {
|
||||||
|
dom.unitForm.reset();
|
||||||
|
dom.unitId.value = "";
|
||||||
|
dom.unitEnabled.checked = true;
|
||||||
|
dom.unitManualAck.checked = true;
|
||||||
|
dom.unitRunTimeSec.value = "0";
|
||||||
|
dom.unitStopTimeSec.value = "0";
|
||||||
|
dom.unitAccTimeSec.value = "0";
|
||||||
|
dom.unitBlTimeSec.value = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUnitModal() {
|
||||||
|
dom.unitModal.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeUnitModal() {
|
||||||
|
dom.unitModal.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCreateUnitModal() {
|
||||||
|
resetUnitForm();
|
||||||
|
openUnitModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditUnitModal(unit) {
|
||||||
|
dom.unitId.value = unit.id || "";
|
||||||
|
dom.unitCode.value = unit.code || "";
|
||||||
|
dom.unitName.value = unit.name || "";
|
||||||
|
dom.unitDescription.value = unit.description || "";
|
||||||
|
dom.unitEnabled.checked = !!unit.enabled;
|
||||||
|
dom.unitRunTimeSec.value = String(unit.run_time_sec ?? 0);
|
||||||
|
dom.unitStopTimeSec.value = String(unit.stop_time_sec ?? 0);
|
||||||
|
dom.unitAccTimeSec.value = String(unit.acc_time_sec ?? 0);
|
||||||
|
dom.unitBlTimeSec.value = String(unit.bl_time_sec ?? 0);
|
||||||
|
dom.unitManualAck.checked = !!unit.require_manual_ack_after_fault;
|
||||||
|
openUnitModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectUnit(unitId) {
|
||||||
|
state.selectedUnitId = state.selectedUnitId === unitId ? null : unitId;
|
||||||
|
renderUnits();
|
||||||
|
renderEquipments();
|
||||||
|
await loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderUnits() {
|
||||||
|
dom.unitList.innerHTML = "";
|
||||||
|
|
||||||
|
if (!state.units.length) {
|
||||||
|
dom.unitList.innerHTML = '<div class="list-item"><div class="muted">暂无控制单元</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.units.forEach((unit) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
const selected = state.selectedUnitId === unit.id;
|
||||||
|
card.className = `list-item unit-card ${selected ? "selected" : ""}`;
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
<strong>${unit.code}</strong>
|
||||||
|
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "ENABLED" : "DISABLED"}</span>
|
||||||
|
</div>
|
||||||
|
<div>${unit.name}</div>
|
||||||
|
<div class="muted">设备 ${equipmentCount(unit.id)} 台</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="row unit-card-actions"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
card.addEventListener("click", () => {
|
||||||
|
selectUnit(unit.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = card.querySelector(".unit-card-actions");
|
||||||
|
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "secondary";
|
||||||
|
editBtn.textContent = "Edit";
|
||||||
|
editBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openEditUnitModal(unit);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.className = "danger";
|
||||||
|
deleteBtn.textContent = "Delete";
|
||||||
|
deleteBtn.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
deleteUnit(unit.id).catch((error) => {
|
||||||
|
dom.statusText.textContent = error.message;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(editBtn, deleteBtn);
|
||||||
|
dom.unitList.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUnits() {
|
||||||
|
const response = await apiFetch("/api/unit?page=1&page_size=-1");
|
||||||
|
state.units = response.data || [];
|
||||||
|
state.unitMap = new Map(state.units.map((unit) => [unit.id, unit]));
|
||||||
|
|
||||||
|
if (state.selectedUnitId && !state.unitMap.has(state.selectedUnitId)) {
|
||||||
|
state.selectedUnitId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderUnits();
|
||||||
|
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
|
||||||
|
renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUnit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
code: dom.unitCode.value.trim(),
|
||||||
|
name: dom.unitName.value.trim(),
|
||||||
|
description: dom.unitDescription.value.trim() || null,
|
||||||
|
enabled: dom.unitEnabled.checked,
|
||||||
|
run_time_sec: Number(dom.unitRunTimeSec.value || 0),
|
||||||
|
stop_time_sec: Number(dom.unitStopTimeSec.value || 0),
|
||||||
|
acc_time_sec: Number(dom.unitAccTimeSec.value || 0),
|
||||||
|
bl_time_sec: Number(dom.unitBlTimeSec.value || 0),
|
||||||
|
require_manual_ack_after_fault: dom.unitManualAck.checked,
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = dom.unitId.value;
|
||||||
|
await apiFetch(id ? `/api/unit/${id}` : "/api/unit", {
|
||||||
|
method: id ? "PUT" : "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
closeUnitModal();
|
||||||
|
await loadUnits();
|
||||||
|
renderEquipments();
|
||||||
|
await loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUnit(unitId) {
|
||||||
|
if (!window.confirm("Delete this unit?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch(`/api/unit/${unitId}`, { method: "DELETE" });
|
||||||
|
if (state.selectedUnitId === unitId) {
|
||||||
|
state.selectedUnitId = null;
|
||||||
|
}
|
||||||
|
closeUnitModal();
|
||||||
|
await loadUnits();
|
||||||
|
renderEquipments();
|
||||||
|
await loadEvents();
|
||||||
|
}
|
||||||
|
|
@ -101,6 +101,24 @@ body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stack-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-section-bordered {
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Panel Header ───────────────────────────────── */
|
/* ── Panel Header ───────────────────────────────── */
|
||||||
|
|
||||||
.panel-head {
|
.panel-head {
|
||||||
|
|
@ -408,6 +426,25 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.equipment-batch-toolbar {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-batch-toolbar .muted {
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-batch-toolbar select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Form ─────────────────────────────────────────── */
|
/* ── Form ─────────────────────────────────────────── */
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
|
|
@ -623,6 +660,43 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unit-list,
|
||||||
|
.event-list {
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-card-actions {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-section {
|
||||||
|
flex-basis: 42%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-section {
|
||||||
|
flex-basis: 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-select-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-select-row input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.drawer-backdrop {
|
.drawer-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue