feat: 新增设备位置追踪与网页管理页面
- 更新服务端默认Lua版本与客户端程序版本至 v1.5.16 - 新增后端API接口用于提交和查询设备位置数据 - 为售票机和检票机添加后台GPS位置同步任务,定期上报设备坐标 - 服务端新增设备位置缓存,并自动清理超过5分钟未更新的过期数据 - 新增网页端设备位置地图页面,支持实时展示、点击查看详情与每分钟自动刷新
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
local DEFAULT_SERVER_BASE = "http://ticket.fse-media.group"
|
local DEFAULT_SERVER_BASE = "http://ticket.fse-media.group"
|
||||||
local DEFAULT_SERVER_PATH = "/api/tickets/check"
|
local DEFAULT_SERVER_PATH = "/api/tickets/check"
|
||||||
local GATE_OPEN_SECONDS = 2
|
local GATE_OPEN_SECONDS = 2
|
||||||
local VERSION = "v1.5.15"
|
local VERSION = "v1.5.16"
|
||||||
local VERSION_CHECK_INTERVAL = 5
|
local VERSION_CHECK_INTERVAL = 5
|
||||||
|
|
||||||
local CONFIG_PATH = "gate_config.json"
|
local CONFIG_PATH = "gate_config.json"
|
||||||
@@ -1498,21 +1498,49 @@ local function processInspectionEvent(eventName, ev)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
|
local function mainLoop()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
while true do
|
while true do
|
||||||
local ev = pack(os.pullEvent())
|
local ev = pack(os.pullEvent())
|
||||||
if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then
|
if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then
|
||||||
processInspectionEvent(ev[1], ev)
|
processInspectionEvent(ev[1], ev)
|
||||||
os.sleep(0.35)
|
os.sleep(0.35)
|
||||||
drawReadyScreen()
|
drawReadyScreen()
|
||||||
elseif ev[1] == "timer" and ev[2] == versionTimer then
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function backgroundSyncTask()
|
||||||
|
while true do
|
||||||
|
sleep(VERSION_CHECK_INTERVAL)
|
||||||
pcall(function()
|
pcall(function()
|
||||||
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
|
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
|
||||||
end)
|
end)
|
||||||
pcall(checkAndApplyUpdate)
|
pcall(checkAndApplyUpdate)
|
||||||
versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function backgroundLocationSyncTask()
|
||||||
|
while true do
|
||||||
|
if gps and type(gps.locate) == "function" then
|
||||||
|
local x, y, z = gps.locate(2)
|
||||||
|
if x and y and z then
|
||||||
|
local label = os.getComputerLabel()
|
||||||
|
if not label or label == "" then label = "#" .. tostring(os.getComputerID()) end
|
||||||
|
local payload = {
|
||||||
|
device_id = label,
|
||||||
|
type = "Gate",
|
||||||
|
gate_mode = modeBySide and "bidirectional" or mode,
|
||||||
|
station_code = stationCodeForSide(nil) or "",
|
||||||
|
x = x,
|
||||||
|
y = y,
|
||||||
|
z = z
|
||||||
|
}
|
||||||
|
local serverBase = guessBaseFromStatusURL(serverURL)
|
||||||
|
pcall(postCheck, serverBase .. '/api/machine-location', payload)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sleep(60)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
parallel.waitForAny(mainLoop, backgroundSyncTask, backgroundLocationSyncTask)
|
||||||
|
|||||||
@@ -163,6 +163,29 @@ router.get('/check-update', (req, res) => {
|
|||||||
res.json({ update_counter: globalUpdateCounter });
|
res.json({ update_counter: globalUpdateCounter });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/machine-location', (req, res) => {
|
||||||
|
const data = req.body || {};
|
||||||
|
if (!data.device_id) return res.status(400).json({ ok: false, error: 'device_id required' });
|
||||||
|
if (!data.station && data.station_code) {
|
||||||
|
data.station = resolveStationName(data.station_code);
|
||||||
|
}
|
||||||
|
DataService.updateMachineLocation(data.device_id, data);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/machine-locations', (req, res) => {
|
||||||
|
const locs = DataService.getMachineLocations() || {};
|
||||||
|
// clean up old locations (> 5 minutes)
|
||||||
|
const now = Date.now();
|
||||||
|
const active = {};
|
||||||
|
for (const [id, data] of Object.entries(locs)) {
|
||||||
|
if (now - data.last_update_ts < 5 * 60 * 1000) {
|
||||||
|
active[id] = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ ok: true, data: active });
|
||||||
|
});
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
router.get('/config', (req, res) => {
|
router.get('/config', (req, res) => {
|
||||||
const cfg = DataService.getConfig();
|
const cfg = DataService.getConfig();
|
||||||
|
|||||||
+12
-3
@@ -22,8 +22,8 @@ const pool = mysql.createPool({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_LUA_VERSIONS = {
|
const DEFAULT_LUA_VERSIONS = {
|
||||||
ticketmachine: 'v1.5.8',
|
ticketmachine: 'v1.5.16',
|
||||||
gate: 'v1.5.8'
|
gate: 'v1.5.16'
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeLuaVersions(input) {
|
function normalizeLuaVersions(input) {
|
||||||
@@ -64,7 +64,8 @@ const cache = {
|
|||||||
icCardIndex: {},
|
icCardIndex: {},
|
||||||
icCardEvents: [],
|
icCardEvents: [],
|
||||||
statsTicket: [],
|
statsTicket: [],
|
||||||
statsGate: []
|
statsGate: [],
|
||||||
|
machineLocations: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const DataService = {
|
const DataService = {
|
||||||
@@ -522,6 +523,14 @@ const DataService = {
|
|||||||
}),
|
}),
|
||||||
saveExport: () => {
|
saveExport: () => {
|
||||||
try { fs.writeFileSync(paths.export, JSON.stringify(DataService.buildExportPayload(), null, 2)); } catch(e){}
|
try { fs.writeFileSync(paths.export, JSON.stringify(DataService.buildExportPayload(), null, 2)); } catch(e){}
|
||||||
|
},
|
||||||
|
|
||||||
|
getMachineLocations: () => cache.machineLocations,
|
||||||
|
updateMachineLocation: (deviceId, data) => {
|
||||||
|
cache.machineLocations[deviceId] = {
|
||||||
|
...data,
|
||||||
|
last_update_ts: Date.now()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -1,6 +1,6 @@
|
|||||||
local CURRENT_STATION_CODE = 'Ticket-Machine'
|
local CURRENT_STATION_CODE = 'Ticket-Machine'
|
||||||
local API_BASE = 'http://ticket.fse-media.group/api'
|
local API_BASE = 'http://ticket.fse-media.group/api'
|
||||||
local VERSION = 'v1.5.15'
|
local VERSION = 'v1.5.16'
|
||||||
local UPDATE_CHECK_INTERVAL = 5
|
local UPDATE_CHECK_INTERVAL = 5
|
||||||
|
|
||||||
-- ###########################
|
-- ###########################
|
||||||
@@ -775,7 +775,26 @@ local function backgroundTicketUploadTask()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function backgroundLocationSyncTask()
|
||||||
|
while true do
|
||||||
|
if gps and type(gps.locate) == "function" then
|
||||||
|
local x, y, z = gps.locate(2)
|
||||||
|
if x and y and z then
|
||||||
|
local payload = {
|
||||||
|
device_id = currentDeviceId(),
|
||||||
|
type = "TicketMachine",
|
||||||
|
station = (CFG.current_station and (CFG.current_station.en_name or CFG.current_station.name)) or state.stationName,
|
||||||
|
station_code = state.stationCode or CURRENT_STATION_CODE,
|
||||||
|
x = x,
|
||||||
|
y = y,
|
||||||
|
z = z
|
||||||
|
}
|
||||||
|
postJSONResult(API_BASE .. '/machine-location', payload)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sleep(60)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- ###########################
|
-- ###########################
|
||||||
-- Data logic & Config
|
-- Data logic & Config
|
||||||
@@ -2528,4 +2547,4 @@ local function mainPageLoop()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)
|
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask, backgroundLocationSyncTask)
|
||||||
|
|||||||
@@ -64,6 +64,9 @@
|
|||||||
<div class="nav-item" :class="{active: currentView === 'faremap'}" @click="currentView = 'faremap'">
|
<div class="nav-item" :class="{active: currentView === 'faremap'}" @click="currentView = 'faremap'">
|
||||||
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
|
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item" :class="{active: currentView === 'machinemap'}" @click="currentView = 'machinemap'">
|
||||||
|
<span class="nav-icon"><i class="fas fa-map-marker-alt"></i></span> 机器位置
|
||||||
|
</div>
|
||||||
<div class="nav-item" :class="{active: currentView === 'tickets'}" @click="currentView = 'tickets'">
|
<div class="nav-item" :class="{active: currentView === 'tickets'}" @click="currentView = 'tickets'">
|
||||||
<span class="nav-icon"><i class="fas fa-ticket-alt"></i></span> 车票记录
|
<span class="nav-icon"><i class="fas fa-ticket-alt"></i></span> 车票记录
|
||||||
</div>
|
</div>
|
||||||
@@ -641,6 +644,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 机器位置 -->
|
||||||
|
<div v-if="currentView === 'machinemap'">
|
||||||
|
<div class="card">
|
||||||
|
<h3>设备分布图</h3>
|
||||||
|
<p class="text-muted">机器位置与状态 (每 60 秒自动更新)</p>
|
||||||
|
<div class="map-container" style="position: relative; width: 100%; height: 600px; background: #1a1a1a; border-radius: 8px; overflow: hidden; border: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
<!-- We can use a simple coordinate mapping here. -->
|
||||||
|
<!-- First, calculate bounds to normalize coordinates -->
|
||||||
|
<div v-if="Object.keys(machineLocations).length === 0" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #666;">
|
||||||
|
暂无机器坐标数据
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<!-- Map markers -->
|
||||||
|
<div v-for="(loc, id) in machineLocations" :key="id"
|
||||||
|
class="map-marker"
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
left: getMapX(loc.x) + '%',
|
||||||
|
top: getMapY(loc.z) + '%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
zIndex: selectedMachineId === id ? 10 : 1
|
||||||
|
}"
|
||||||
|
@click="selectedMachineId = id"
|
||||||
|
>
|
||||||
|
<div :class="['marker-icon', loc.type === 'Gate' ? 'gate' : 'ticket']"
|
||||||
|
:style="{
|
||||||
|
width: '20px', height: '20px', borderRadius: '50%',
|
||||||
|
background: loc.type === 'Gate' ? '#ff9800' : '#4ade80',
|
||||||
|
border: '2px solid #fff',
|
||||||
|
boxShadow: '0 0 10px rgba(0,0,0,0.5)'
|
||||||
|
}">
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMachineId === id" class="marker-popup"
|
||||||
|
style="position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); background: #333; padding: 10px; border-radius: 6px; width: max-content; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 20; color: white; text-align: center;">
|
||||||
|
<div style="font-weight: bold; margin-bottom: 4px;">{{ loc.device_id || id }}</div>
|
||||||
|
<div style="font-size: 0.85em; color: #aaa;">类型: {{ loc.type === 'Gate' ? '检票机' : '售票机' }}</div>
|
||||||
|
<div v-if="loc.type === 'Gate'" style="font-size: 0.85em; color: #aaa;">
|
||||||
|
模式: {{ loc.gate_mode === 'entry' ? '进站' : (loc.gate_mode === 'exit' ? '出站' : '双向') }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.85em; color: #aaa;">站点: {{ loc.station || loc.station_code || '未知' }}</div>
|
||||||
|
<div style="font-size: 0.8em; color: #888; margin-top: 4px;">坐标: {{ loc.x }}, {{ loc.y }}, {{ loc.z }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 车票记录 -->
|
<!-- 车票记录 -->
|
||||||
<div v-if="currentView === 'tickets'">
|
<div v-if="currentView === 'tickets'">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
|
|||||||
+62
-1
@@ -24,6 +24,7 @@ createApp({
|
|||||||
dashboard: '仪表盘',
|
dashboard: '仪表盘',
|
||||||
management: '线路与票价管理',
|
management: '线路与票价管理',
|
||||||
faremap: '票价地图',
|
faremap: '票价地图',
|
||||||
|
machinemap: '机器位置',
|
||||||
tickets: '车票记录',
|
tickets: '车票记录',
|
||||||
vouchers: '凭证管理',
|
vouchers: '凭证管理',
|
||||||
iccards: 'IC 卡管理',
|
iccards: 'IC 卡管理',
|
||||||
@@ -78,12 +79,14 @@ createApp({
|
|||||||
let logDataLoaded = false;
|
let logDataLoaded = false;
|
||||||
let assetsLoaded = false;
|
let assetsLoaded = false;
|
||||||
let fareMapLoaded = false;
|
let fareMapLoaded = false;
|
||||||
|
let machineLocationsLoaded = false;
|
||||||
const loadingState = reactive({
|
const loadingState = reactive({
|
||||||
core: false,
|
core: false,
|
||||||
tickets: false,
|
tickets: false,
|
||||||
orders: false,
|
orders: false,
|
||||||
logs: false,
|
logs: false,
|
||||||
iccards: false
|
iccards: false,
|
||||||
|
machinemap: false
|
||||||
});
|
});
|
||||||
const lastSyncAt = ref(0);
|
const lastSyncAt = ref(0);
|
||||||
|
|
||||||
@@ -1116,6 +1119,52 @@ createApp({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const machineLocations = ref({});
|
||||||
|
const selectedMachineId = ref('');
|
||||||
|
const getMapX = (x) => {
|
||||||
|
if (typeof x !== 'number') return 50;
|
||||||
|
// Assuming typical MC coords are between -10000 and +10000.
|
||||||
|
// We can auto-scale based on actual data bounds instead.
|
||||||
|
let min = 0, max = 0;
|
||||||
|
const vals = Object.values(machineLocations.value).map(l => l.x).filter(v => typeof v === 'number');
|
||||||
|
if (vals.length > 0) {
|
||||||
|
min = Math.min(...vals) - 50;
|
||||||
|
max = Math.max(...vals) + 50;
|
||||||
|
} else {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
if (max === min) return 50;
|
||||||
|
return ((x - min) / (max - min)) * 90 + 5; // 5% to 95% margin
|
||||||
|
};
|
||||||
|
const getMapY = (z) => {
|
||||||
|
if (typeof z !== 'number') return 50;
|
||||||
|
let min = 0, max = 0;
|
||||||
|
const vals = Object.values(machineLocations.value).map(l => l.z).filter(v => typeof v === 'number');
|
||||||
|
if (vals.length > 0) {
|
||||||
|
min = Math.min(...vals) - 50;
|
||||||
|
max = Math.max(...vals) + 50;
|
||||||
|
} else {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
if (max === min) return 50;
|
||||||
|
return ((z - min) / (max - min)) * 90 + 5;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMachineLocations = async () => {
|
||||||
|
loadingState.machinemap = true;
|
||||||
|
try {
|
||||||
|
const res = await requestJson('/api/machine-locations');
|
||||||
|
if (res && res.data) {
|
||||||
|
machineLocations.value = res.data;
|
||||||
|
}
|
||||||
|
machineLocationsLoaded = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loadingState.machinemap = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const ensureViewData = async (view = currentView.value, { force = false } = {}) => {
|
const ensureViewData = async (view = currentView.value, { force = false } = {}) => {
|
||||||
await fetchCoreData({ force });
|
await fetchCoreData({ force });
|
||||||
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
|
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
|
||||||
@@ -1129,6 +1178,7 @@ createApp({
|
|||||||
}
|
}
|
||||||
if (view === 'faremap' && (force || !fareMapLoaded)) await loadFareMap({ force });
|
if (view === 'faremap' && (force || !fareMapLoaded)) await loadFareMap({ force });
|
||||||
if (view === 'assets' && (!assetsLoaded || force)) await fetchAssetsManifest();
|
if (view === 'assets' && (!assetsLoaded || force)) await fetchAssetsManifest();
|
||||||
|
if (view === 'machinemap') await fetchMachineLocations();
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -1564,6 +1614,8 @@ createApp({
|
|||||||
syncSelectedIcCard().catch(console.error);
|
syncSelectedIcCard().catch(console.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let machineMapTimer = null;
|
||||||
|
|
||||||
watch(currentView, (v) => {
|
watch(currentView, (v) => {
|
||||||
sidebarOpen.value = false;
|
sidebarOpen.value = false;
|
||||||
if (v === 'iccards') {
|
if (v === 'iccards') {
|
||||||
@@ -1571,6 +1623,14 @@ createApp({
|
|||||||
} else {
|
} else {
|
||||||
stopIcCardSync();
|
stopIcCardSync();
|
||||||
}
|
}
|
||||||
|
if (v === 'machinemap') {
|
||||||
|
if (!machineMapTimer) machineMapTimer = setInterval(fetchMachineLocations, 60000);
|
||||||
|
} else {
|
||||||
|
if (machineMapTimer) {
|
||||||
|
clearInterval(machineMapTimer);
|
||||||
|
machineMapTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
ensureViewData(v).catch(console.error);
|
ensureViewData(v).catch(console.error);
|
||||||
const sp = new URLSearchParams(location.search);
|
const sp = new URLSearchParams(location.search);
|
||||||
if (v === 'dashboard') sp.delete('view');
|
if (v === 'dashboard') sp.delete('view');
|
||||||
@@ -1720,6 +1780,7 @@ createApp({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
machineLocations, selectedMachineId, getMapX, getMapY,
|
||||||
currentView, viewTitle, sidebarOpen,
|
currentView, viewTitle, sidebarOpen,
|
||||||
loadingState, isViewBusy, lastSyncText, currentViewSummary,
|
loadingState, isViewBusy, lastSyncText, currentViewSummary,
|
||||||
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
|
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
|
||||||
|
|||||||
Reference in New Issue
Block a user