feat: 新增设备位置追踪与网页管理页面

- 更新服务端默认Lua版本与客户端程序版本至 v1.5.16
- 新增后端API接口用于提交和查询设备位置数据
- 为售票机和检票机添加后台GPS位置同步任务,定期上报设备坐标
- 服务端新增设备位置缓存,并自动清理超过5分钟未更新的过期数据
- 新增网页端设备位置地图页面,支持实时展示、点击查看详情与每分钟自动刷新
This commit is contained in:
2026-07-11 14:58:30 +08:00
parent 2eddb8b84c
commit efc061e471
6 changed files with 211 additions and 19 deletions
+36 -8
View File
@@ -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)
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+52
View File
@@ -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
View File
@@ -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,