feat(web,backend,lua,installer): 新增Lua脚本版本管理功能及相关优化
- 升级售票机、检票机内置Lua脚本版本至v1.5.8 - 新增后端配置的lua_versions字段,统一管理售票机、检票机的Lua脚本版本 - 前端新增版本管理配置页面,支持版本号配置和一键补丁升级 - 为售票机、检票机添加远程版本检测功能,屏幕显示版本匹配状态标记 - 简化installer配置交互流程,优化站点代码输入方式 - 重构后端配置规范化处理逻辑,统一配置初始化与存储流程 - 优化售票机外设检测、支付检测逻辑,修复部分已知问题
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
local DEFAULT_SERVER_BASE = "http://ticket.fse-media.group"
|
||||
local DEFAULT_SERVER_PATH = "/api/tickets/check"
|
||||
local GATE_OPEN_SECONDS = 2
|
||||
local VERSION = "v1.5.7"
|
||||
local VERSION = "v1.5.8"
|
||||
local VERSION_CHECK_INTERVAL = 60
|
||||
|
||||
local CONFIG_PATH = "gate_config.json"
|
||||
|
||||
@@ -26,6 +27,15 @@ local function trim(s)
|
||||
return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
local function normalizeVersionTag(v)
|
||||
local s = trim(v)
|
||||
if #s == 0 then return "" end
|
||||
if s:sub(1, 1):lower() ~= "v" then
|
||||
s = "v" .. s
|
||||
end
|
||||
return s:lower()
|
||||
end
|
||||
|
||||
local function splitCsv(s)
|
||||
local out = {}
|
||||
s = trim(s)
|
||||
@@ -80,6 +90,8 @@ local inspection = peripheral.find("ticket_inspection_machine")
|
||||
|
||||
local serverConnected = nil
|
||||
local serverLastChangeTs = 0
|
||||
local expectedGateVersion = nil
|
||||
local versionMismatch = nil
|
||||
|
||||
local function setServerConnected(ok)
|
||||
if serverConnected == ok then return end
|
||||
@@ -126,10 +138,20 @@ local function drawVersionIndicator(w)
|
||||
local s = tostring(VERSION or "")
|
||||
if #s == 0 then return end
|
||||
if w < #s then return end
|
||||
local markerColor = colors.yellow
|
||||
if versionMismatch == true then
|
||||
markerColor = colors.red
|
||||
elseif versionMismatch == false then
|
||||
markerColor = colors.lime
|
||||
end
|
||||
termDev.setBackgroundColor(colors.black)
|
||||
termDev.setTextColor(colors.gray)
|
||||
termDev.setCursorPos(1, 1)
|
||||
termDev.write(s)
|
||||
if w >= (#s + 1) then
|
||||
termDev.setTextColor(markerColor)
|
||||
termDev.write("*")
|
||||
end
|
||||
termDev.setTextColor(colors.white)
|
||||
end
|
||||
|
||||
@@ -337,6 +359,23 @@ local function refreshStationNameMap(serverBase)
|
||||
return true
|
||||
end
|
||||
|
||||
local function refreshRemoteLuaVersion(serverBase)
|
||||
serverBase = trim(serverBase or "")
|
||||
if #serverBase == 0 then return false end
|
||||
local url = serverBase:gsub("/+$", "") .. "/api/public/config"
|
||||
local ok, parsed = getJSON(url)
|
||||
if not ok or type(parsed) ~= "table" then return false end
|
||||
local remote = normalizeVersionTag(type(parsed.lua_versions) == "table" and parsed.lua_versions.gate or nil)
|
||||
if #remote == 0 then
|
||||
expectedGateVersion = nil
|
||||
versionMismatch = nil
|
||||
return true
|
||||
end
|
||||
expectedGateVersion = remote
|
||||
versionMismatch = (remote ~= normalizeVersionTag(VERSION))
|
||||
return true
|
||||
end
|
||||
|
||||
local function inferStationCodeFromName(name)
|
||||
local key = normKey(name or "")
|
||||
if #key == 0 then return "" end
|
||||
@@ -655,6 +694,10 @@ pcall(function()
|
||||
refreshStationNameMap(guessBaseFromStatusURL(serverURL))
|
||||
end)
|
||||
|
||||
pcall(function()
|
||||
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
|
||||
end)
|
||||
|
||||
if not inspection then
|
||||
if modeBySide == nil then
|
||||
draw("Missing peripheral:", "ticket_inspection_machine", colors.red)
|
||||
@@ -667,18 +710,6 @@ if next(stationSet) == nil then
|
||||
error("No station codes configured")
|
||||
end
|
||||
|
||||
local stationListText = table.concat(cfg.station_codes, ",")
|
||||
local function readyLine1()
|
||||
if not modeBySide then
|
||||
return "Ready (" .. mode:upper() .. ")"
|
||||
end
|
||||
local f = modeBySide.front and modeBySide.front:upper() or "-"
|
||||
local b = modeBySide.back and modeBySide.back:upper() or "-"
|
||||
return "Ready (BI) F:" .. f .. " B:" .. b
|
||||
end
|
||||
|
||||
draw(readyLine1(), "Station: " .. stationListText, colors.lime)
|
||||
|
||||
local stationCodesPayload = {}
|
||||
for k, _ in pairs(stationSet) do table.insert(stationCodesPayload, k) end
|
||||
table.sort(stationCodesPayload)
|
||||
@@ -785,6 +816,37 @@ local function actionForSide(side)
|
||||
return modeBySide[side] or mode
|
||||
end
|
||||
|
||||
local function shortModeLabel(v)
|
||||
v = trim(v):lower()
|
||||
if v == "exit" then return "OUT" end
|
||||
return "IN"
|
||||
end
|
||||
|
||||
local function readyLine1()
|
||||
if not modeBySide then
|
||||
return "READY " .. shortModeLabel(mode)
|
||||
end
|
||||
return "F " .. shortModeLabel(modeBySide.front or "") .. " B " .. shortModeLabel(modeBySide.back or "")
|
||||
end
|
||||
|
||||
local function readyLine2()
|
||||
if not modeBySide then
|
||||
return "ST " .. stationCodeForSide(nil)
|
||||
end
|
||||
local frontCode = stationCodeForSide("front")
|
||||
local backCode = stationCodeForSide("back")
|
||||
if frontCode == backCode then
|
||||
return "ST " .. frontCode
|
||||
end
|
||||
return "F " .. frontCode .. " B " .. backCode
|
||||
end
|
||||
|
||||
local function drawReadyScreen()
|
||||
draw(readyLine1(), readyLine2(), colors.lime)
|
||||
end
|
||||
|
||||
drawReadyScreen()
|
||||
|
||||
local function collectInspectionDevices(side, modeBySideRef)
|
||||
local sideKnown = trimSide(side) ~= nil
|
||||
local inspectionDevs = {}
|
||||
@@ -1278,11 +1340,19 @@ local function processInspectionEvent(eventName, ev)
|
||||
end
|
||||
end
|
||||
|
||||
local versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
|
||||
|
||||
while true do
|
||||
local ev = pack(os.pullEvent())
|
||||
if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then
|
||||
processInspectionEvent(ev[1], ev)
|
||||
os.sleep(0.35)
|
||||
draw(readyLine1(), "Station: " .. stationListText, colors.lime)
|
||||
drawReadyScreen()
|
||||
elseif ev[1] == "timer" and ev[2] == versionTimer then
|
||||
pcall(function()
|
||||
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
|
||||
end)
|
||||
drawReadyScreen()
|
||||
versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
|
||||
end
|
||||
end
|
||||
|
||||
+4
-23
@@ -35,13 +35,6 @@ local function prompt(label)
|
||||
return trim(read() or "")
|
||||
end
|
||||
|
||||
local function promptOptionalUrl(label)
|
||||
local raw = prompt(label)
|
||||
raw = trim(raw)
|
||||
if #raw == 0 then return nil end
|
||||
return raw
|
||||
end
|
||||
|
||||
local function httpGet(url)
|
||||
if not http then return false, "HTTP API disabled" end
|
||||
local okReq, err = pcall(function()
|
||||
@@ -96,27 +89,15 @@ term.setCursorPos(1, 1)
|
||||
print("Ticket Gate Installer")
|
||||
print("")
|
||||
|
||||
local stationsRaw = prompt("Station codes (comma or slash): ")
|
||||
local stationCodes = splitCsv(stationsRaw)
|
||||
if #stationCodes == 0 then
|
||||
print("No station codes provided.")
|
||||
local stationCode = trim(prompt("Station code: "))
|
||||
if #stationCode == 0 then
|
||||
print("No station code provided.")
|
||||
return
|
||||
end
|
||||
|
||||
local modeRaw = prompt("Gate mode (entry/exit): ")
|
||||
local mode = (trim(modeRaw):lower() == "exit") and "exit" or "entry"
|
||||
local stationCode = prompt("Gate station code (default first code): ")
|
||||
stationCode = trim(stationCode)
|
||||
if #stationCode == 0 then stationCode = stationCodes[1] end
|
||||
|
||||
print("")
|
||||
print("Optional server config for ticket / IC card checks.")
|
||||
local ticketServerUrl = promptOptionalUrl("Ticket check URL (blank=auto): ")
|
||||
local cardServerUrl = promptOptionalUrl("IC card check URL (blank=same server): ")
|
||||
|
||||
local cfg = { mode = mode, station_codes = stationCodes, station_code = stationCode }
|
||||
if ticketServerUrl then cfg.server_url = ticketServerUrl end
|
||||
if cardServerUrl then cfg.card_server_url = cardServerUrl end
|
||||
local cfg = { mode = mode, station_codes = { stationCode }, station_code = stationCode }
|
||||
local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg)
|
||||
if not okCfg then
|
||||
print("Config serialize failed.")
|
||||
|
||||
+7
-26
@@ -34,13 +34,6 @@ local function prompt(label)
|
||||
return trim(read() or "")
|
||||
end
|
||||
|
||||
local function promptOptionalUrl(label)
|
||||
local raw = prompt(label)
|
||||
raw = trim(raw)
|
||||
if #raw == 0 then return nil end
|
||||
return raw
|
||||
end
|
||||
|
||||
local function httpGet(url)
|
||||
if not http then return false, "HTTP API disabled" end
|
||||
local okReq, err = pcall(function()
|
||||
@@ -99,10 +92,9 @@ term.setCursorPos(1, 1)
|
||||
print("Bidirectional Ticket Gate Installer")
|
||||
print("")
|
||||
|
||||
local stationsRaw = prompt("Station codes (comma or slash): ")
|
||||
local stationCodes = splitCsv(stationsRaw)
|
||||
if #stationCodes == 0 then
|
||||
print("No station codes provided.")
|
||||
local stationCode = trim(prompt("Station code: "))
|
||||
if #stationCode == 0 then
|
||||
print("No station code provided.")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -112,30 +104,19 @@ local frontRaw = prompt("Front mode (entry/exit): ")
|
||||
local backRaw = prompt("Back mode (entry/exit): ")
|
||||
local frontMode = normalizeMode(frontRaw)
|
||||
local backMode = normalizeMode(backRaw)
|
||||
local frontStationCode = trim(prompt("Front station code (default first code): "))
|
||||
local backStationCode = trim(prompt("Back station code (default first code): "))
|
||||
if #frontStationCode == 0 then frontStationCode = stationCodes[1] end
|
||||
if #backStationCode == 0 then backStationCode = stationCodes[1] end
|
||||
|
||||
print("")
|
||||
print("Optional server config for ticket / IC card checks.")
|
||||
local ticketServerUrl = promptOptionalUrl("Ticket check URL (blank=auto): ")
|
||||
local cardServerUrl = promptOptionalUrl("IC card check URL (blank=same server): ")
|
||||
|
||||
local cfg = {
|
||||
station_codes = stationCodes,
|
||||
station_code = stationCodes[1],
|
||||
station_codes = { stationCode },
|
||||
station_code = stationCode,
|
||||
side_modes = {
|
||||
front = frontMode,
|
||||
back = backMode,
|
||||
},
|
||||
side_station_codes = {
|
||||
front = frontStationCode,
|
||||
back = backStationCode,
|
||||
front = stationCode,
|
||||
back = stationCode,
|
||||
}
|
||||
}
|
||||
if ticketServerUrl then cfg.server_url = ticketServerUrl end
|
||||
if cardServerUrl then cfg.card_server_url = cardServerUrl end
|
||||
local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg)
|
||||
if not okCfg then
|
||||
print("Config serialize failed.")
|
||||
|
||||
+12
-4
@@ -148,14 +148,16 @@ const resolveCurrentStationCode = (body, resolveStation) => {
|
||||
|
||||
// Config
|
||||
router.get('/config', (req, res) => {
|
||||
const cfg = DataService.getConfig();
|
||||
res.json({
|
||||
api_base: DataService.getConfig().api_base,
|
||||
current_station: DataService.getConfig().current_station,
|
||||
api_base: cfg.api_base,
|
||||
current_station: cfg.current_station,
|
||||
stations: DataService.getStations(),
|
||||
lines: DataService.getLines(),
|
||||
fares: DataService.getFares(),
|
||||
transfers: DataService.getConfig().transfers || [],
|
||||
promotion: DataService.getConfig().promotion || { name: '', discount: 1 }
|
||||
transfers: cfg.transfers || [],
|
||||
promotion: cfg.promotion || { name: '', discount: 1 },
|
||||
lua_versions: cfg.lua_versions || {}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,6 +221,12 @@ router.put('/config', async (req, res) => {
|
||||
if (!(d >= 0 && d <= 1)) return res.status(400).json({ ok: false, error: 'promotion.discount must be between 0 and 1' });
|
||||
cfg.promotion = { name: String(p.name || ''), discount: d };
|
||||
}
|
||||
if (incoming.lua_versions && typeof incoming.lua_versions === 'object') {
|
||||
cfg.lua_versions = {
|
||||
...(cfg.lua_versions || {}),
|
||||
...(incoming.lua_versions || {})
|
||||
};
|
||||
}
|
||||
await DataService.saveConfig(cfg);
|
||||
appendReqLog(req, { category: 'admin', type: 'update_config_generic', detail: incoming });
|
||||
io.emit('config:updated', cfg);
|
||||
|
||||
@@ -221,7 +221,8 @@ router.get('/fares/query', (req, res) => {
|
||||
router.get('/config', (req, res) => {
|
||||
const cfg = DataService.getConfig();
|
||||
res.json({
|
||||
promotion: { name: cfg.promotion?.name || '', discount: cfg.promotion?.discount ?? 1 }
|
||||
promotion: { name: cfg.promotion?.name || '', discount: cfg.promotion?.discount ?? 1 },
|
||||
lua_versions: cfg.lua_versions || {}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+34
-9
@@ -21,14 +21,39 @@ const pool = mysql.createPool({
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
const DEFAULT_LUA_VERSIONS = {
|
||||
ticketmachine: 'v1.5.8',
|
||||
gate: 'v1.5.8'
|
||||
};
|
||||
|
||||
function normalizeLuaVersions(input) {
|
||||
const src = (input && typeof input === 'object') ? input : {};
|
||||
return {
|
||||
ticketmachine: String(src.ticketmachine || DEFAULT_LUA_VERSIONS.ticketmachine),
|
||||
gate: String(src.gate || DEFAULT_LUA_VERSIONS.gate)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConfig(input) {
|
||||
const src = (input && typeof input === 'object') ? input : {};
|
||||
return {
|
||||
...src,
|
||||
api_base: String(src.api_base || 'http://127.0.0.1:23333/api'),
|
||||
current_station: (src.current_station && typeof src.current_station === 'object')
|
||||
? src.current_station
|
||||
: { name: 'Station1', code: '01-01' },
|
||||
transfers: Array.isArray(src.transfers) ? src.transfers : [],
|
||||
promotion: {
|
||||
name: String(src?.promotion?.name || ''),
|
||||
discount: Number(src?.promotion?.discount ?? 1)
|
||||
},
|
||||
lua_versions: normalizeLuaVersions(src.lua_versions)
|
||||
};
|
||||
}
|
||||
|
||||
// In-memory cache for synchronous read access
|
||||
const cache = {
|
||||
config: {
|
||||
api_base: 'http://127.0.0.1:23333/api',
|
||||
current_station: { name: 'Station1', code: '01-01' },
|
||||
transfers: [],
|
||||
promotion: { name: '', discount: 1 }
|
||||
},
|
||||
config: normalizeConfig({}),
|
||||
stations: [],
|
||||
lines: [],
|
||||
fares: [],
|
||||
@@ -66,7 +91,7 @@ const DataService = {
|
||||
|
||||
// Load Cache
|
||||
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
|
||||
if (configs.length > 0) cache.config = configs[0].v;
|
||||
if (configs.length > 0) cache.config = normalizeConfig(configs[0].v);
|
||||
else await conn.query('INSERT INTO kv_store (k, v) VALUES (?, ?)', ['config', JSON.stringify(cache.config)]);
|
||||
|
||||
const [stations] = await conn.query('SELECT data FROM stations');
|
||||
@@ -114,8 +139,8 @@ const DataService = {
|
||||
// Config
|
||||
getConfig: () => cache.config,
|
||||
saveConfig: async (cfg) => {
|
||||
cache.config = cfg;
|
||||
await pool.query('INSERT INTO kv_store (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?', ['config', JSON.stringify(cfg), JSON.stringify(cfg)]);
|
||||
cache.config = normalizeConfig(cfg);
|
||||
await pool.query('INSERT INTO kv_store (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?', ['config', JSON.stringify(cache.config), JSON.stringify(cache.config)]);
|
||||
},
|
||||
|
||||
// Stations
|
||||
|
||||
+279
-49
@@ -1,6 +1,6 @@
|
||||
local CURRENT_STATION_CODE = 'Ticket-Machine'
|
||||
local API_BASE = 'http://ticket.fse-media.group/api'
|
||||
local VERSION = 'v1.5.7'
|
||||
local VERSION = 'v1.5.8'
|
||||
|
||||
-- ###########################
|
||||
-- Core HTTP & JSON Utilities
|
||||
@@ -13,6 +13,17 @@ end
|
||||
|
||||
local serverConnected = nil
|
||||
local serverLastChangeTs = 0
|
||||
local expectedMachineVersion = nil
|
||||
local versionMismatch = nil
|
||||
|
||||
local function normalizeVersionTag(v)
|
||||
local s = tostring(v or ''):gsub('^%s+', ''):gsub('%s+$', '')
|
||||
if #s == 0 then return '' end
|
||||
if s:sub(1, 1):lower() ~= 'v' then
|
||||
s = 'v' .. s
|
||||
end
|
||||
return s:lower()
|
||||
end
|
||||
|
||||
local function setServerConnected(ok)
|
||||
if serverConnected == ok then return end
|
||||
@@ -314,22 +325,93 @@ end
|
||||
-- ###########################
|
||||
-- Peripheral discovery
|
||||
-- ###########################
|
||||
local monitor = peripheral.find('monitor')
|
||||
local ticketVendingMachine = peripheral.find('ticket_vending_machine')
|
||||
local speaker = peripheral.find('speaker')
|
||||
local SIDE_PRIORITY = { top = 1, bottom = 2, left = 3, right = 4, front = 5, back = 6 }
|
||||
local REDSTONE_SIDES = { 'right', 'left', 'top', 'bottom', 'front', 'back' }
|
||||
local monitor = nil
|
||||
local monitorName = nil
|
||||
local ticketVendingMachine = nil
|
||||
local ticketVendingMachineName = nil
|
||||
local speaker = nil
|
||||
local speakerName = nil
|
||||
local detectedPaymentSide = nil
|
||||
local MOD_DEBUG = true
|
||||
|
||||
pcall(math.randomseed, (os.epoch and os.epoch('utc')) or os.time())
|
||||
|
||||
local function safe(term)
|
||||
if monitor then return peripheral.wrap(peripheral.getName(monitor)) end
|
||||
return term
|
||||
local function comparePeripheralName(a, b)
|
||||
local pa = SIDE_PRIORITY[tostring(a or '')] or 99
|
||||
local pb = SIDE_PRIORITY[tostring(b or '')] or 99
|
||||
if pa ~= pb then return pa < pb end
|
||||
return tostring(a or '') < tostring(b or '')
|
||||
end
|
||||
|
||||
local termDev = safe(term)
|
||||
if monitor then pcall(monitor.setTextScale, 0.5) end
|
||||
local function peripheralTypeMatches(name, typeName)
|
||||
if not peripheral or type(peripheral.getType) ~= 'function' then return false end
|
||||
local got = peripheral.getType(name)
|
||||
if type(got) == 'string' then return got == typeName end
|
||||
if type(got) == 'table' then
|
||||
for _, item in ipairs(got) do
|
||||
if item == typeName then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function findPeripheralByType(typeName)
|
||||
if not peripheral then return nil, nil end
|
||||
if type(peripheral.getNames) == 'function' and type(peripheral.wrap) == 'function' then
|
||||
local names = peripheral.getNames() or {}
|
||||
table.sort(names, comparePeripheralName)
|
||||
for _, name in ipairs(names) do
|
||||
if peripheralTypeMatches(name, typeName) then
|
||||
local okWrap, dev = pcall(peripheral.wrap, name)
|
||||
if okWrap and type(dev) == 'table' then
|
||||
return dev, name
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(peripheral.find) == 'function' then
|
||||
local dev = peripheral.find(typeName)
|
||||
if type(dev) == 'table' then
|
||||
local name = nil
|
||||
if type(peripheral.getName) == 'function' then
|
||||
local okName, gotName = pcall(peripheral.getName, dev)
|
||||
if okName then name = gotName end
|
||||
end
|
||||
return dev, name
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
local termDev = term
|
||||
local w, h = termDev.getSize()
|
||||
|
||||
local function refreshDevices()
|
||||
local prevSignature = table.concat({
|
||||
tostring(monitorName or ''),
|
||||
tostring(ticketVendingMachineName or ''),
|
||||
tostring(speakerName or '')
|
||||
}, '|')
|
||||
monitor, monitorName = findPeripheralByType('monitor')
|
||||
ticketVendingMachine, ticketVendingMachineName = findPeripheralByType('ticket_vending_machine')
|
||||
speaker, speakerName = findPeripheralByType('speaker')
|
||||
termDev = monitor or term
|
||||
if monitor then pcall(monitor.setTextScale, 0.5) end
|
||||
w, h = termDev.getSize()
|
||||
local nextSignature = table.concat({
|
||||
tostring(monitorName or ''),
|
||||
tostring(ticketVendingMachineName or ''),
|
||||
tostring(speakerName or '')
|
||||
}, '|')
|
||||
if prevSignature ~= nextSignature then
|
||||
os.queueEvent('config_updated')
|
||||
end
|
||||
end
|
||||
|
||||
refreshDevices()
|
||||
|
||||
local function saveCardIssueSnapshot(cardData)
|
||||
pcall(function()
|
||||
ensureDir('logs/last_card_issue.json')
|
||||
@@ -356,6 +438,14 @@ local function peripheralCallSucceeded(r1)
|
||||
return r1 ~= nil and r1 ~= false
|
||||
end
|
||||
|
||||
local function getTicketVendingMachine()
|
||||
refreshDevices()
|
||||
if type(ticketVendingMachine) == 'table' then
|
||||
return ticketVendingMachine
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function callPeripheralMethods(dev, methodNames, variants)
|
||||
if type(dev) ~= 'table' then return false, 'peripheral_unavailable' end
|
||||
for _, methodName in ipairs(methodNames) do
|
||||
@@ -373,7 +463,7 @@ local function callPeripheralMethods(dev, methodNames, variants)
|
||||
end
|
||||
|
||||
local function issueBlankICCard(holderName, initialBalance)
|
||||
local dev = ticketVendingMachine
|
||||
local dev = getTicketVendingMachine()
|
||||
if type(dev) ~= 'table' then return false, '', 'peripheral_unavailable' end
|
||||
local safeHolderName = firstString(holderName, 'CARD USER')
|
||||
local safeInitialBalance = math.max(0, math.floor(tonumber(initialBalance) or 0))
|
||||
@@ -395,8 +485,9 @@ local function issueBlankICCard(holderName, initialBalance)
|
||||
return false, '', 'unsupported_method'
|
||||
end
|
||||
|
||||
local function writeICCard(cardData)
|
||||
local dev = ticketVendingMachine
|
||||
local function writeICCard(cardData, opts)
|
||||
local dev = getTicketVendingMachine()
|
||||
local options = opts or {}
|
||||
local payload = {}
|
||||
for k, v in pairs(cardData or {}) do payload[k] = v end
|
||||
payload.media = payload.media or 'ic_card'
|
||||
@@ -409,7 +500,9 @@ local function writeICCard(cardData)
|
||||
saveCardIssueSnapshot(payload)
|
||||
|
||||
local okWrite, methodName, r1, r2, r3 = callPeripheralMethods(dev,
|
||||
{ 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' },
|
||||
options.writeOnly
|
||||
and { 'writeCard', 'writeICCard', 'writeTicketData', 'issueTicketData' }
|
||||
or { 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' },
|
||||
{
|
||||
{ payload },
|
||||
{ tostring(payload.holder_name or ''), tonumber(payload.initial_balance) or 0 },
|
||||
@@ -430,6 +523,75 @@ local function submitCardOpen(payload)
|
||||
return postJSON(API_BASE .. '/cards/open', payload)
|
||||
end
|
||||
|
||||
local function buildFinalCardData(payload, respData)
|
||||
local data = (type(respData) == 'table') and respData or {}
|
||||
return {
|
||||
card_id = firstString(data.card_id, data.id, payload.card_id),
|
||||
holder_name = payload.holder_name,
|
||||
balance = firstNumber(data.balance, data.stored_value, payload.balance) or payload.balance,
|
||||
deposit = firstNumber(data.deposit, payload.deposit) or payload.deposit,
|
||||
topup = firstNumber(data.topup, data.first_topup, payload.topup) or payload.topup,
|
||||
station_code = payload.station_code,
|
||||
device = payload.device,
|
||||
voucher_code = payload.voucher_code,
|
||||
media = 'ic_card',
|
||||
product_type = 'stored_value',
|
||||
order_value = payload.order_value,
|
||||
initial_balance = firstNumber(data.first_topup, data.topup, payload.topup, payload.balance) or payload.topup or payload.balance or 0
|
||||
}
|
||||
end
|
||||
|
||||
local function generateCardId()
|
||||
local num = string.format('%06d', math.random(0, 999999))
|
||||
return 'IC-' .. num
|
||||
end
|
||||
|
||||
local function issueTicketFromPeripheral(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg, fallbackTicketId)
|
||||
local dev = getTicketVendingMachine()
|
||||
if type(dev) ~= 'table' then
|
||||
return false, '', 'peripheral_unavailable'
|
||||
end
|
||||
local fn = dev.issueTicket
|
||||
if type(fn) ~= 'function' then
|
||||
return false, '', 'unsupported_method'
|
||||
end
|
||||
|
||||
local function normalizeIssuedTicketId(id)
|
||||
if id == nil then return '' end
|
||||
local s = tostring(id):gsub('%s+', '')
|
||||
if #s == 0 then return '' end
|
||||
local prefix, num = s:match('^([A-Za-z][A-Za-z])%-?([0-9]+)$')
|
||||
if prefix and num then
|
||||
prefix = prefix:upper()
|
||||
if #num < 8 then
|
||||
num = string.rep('0', 8 - #num) .. num
|
||||
end
|
||||
return prefix .. '-' .. num
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
local function tryIssue(...)
|
||||
local okCall, r1, r2, r3 = pcall(fn, ...)
|
||||
if not (okCall and peripheralCallSucceeded(r1)) then
|
||||
return false, '', okCall and 'issue_failed' or 'issue_call_failed'
|
||||
end
|
||||
local issuedId = extractPeripheralId(r2, r3, r1, fallbackTicketId)
|
||||
local normalizedId = normalizeIssuedTicketId(issuedId)
|
||||
if #normalizedId == 0 then
|
||||
return false, '', 'invalid_ticket_id'
|
||||
end
|
||||
return true, normalizedId, 'issueTicket'
|
||||
end
|
||||
|
||||
local okIssue, ticketId, issueErr = tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
|
||||
if okIssue then
|
||||
return true, ticketId, 'issueTicket'
|
||||
end
|
||||
|
||||
return tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
|
||||
end
|
||||
|
||||
-- ###########################
|
||||
-- Audio Utilities & Playback
|
||||
-- ###########################
|
||||
@@ -489,6 +651,15 @@ local function backgroundSyncTask()
|
||||
end
|
||||
end
|
||||
|
||||
local function backgroundPeripheralTask()
|
||||
while true do
|
||||
local ev = os.pullEvent()
|
||||
if ev == 'peripheral' or ev == 'peripheral_detach' then
|
||||
pcall(refreshDevices)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function backgroundTicketUploadTask()
|
||||
loadPendingUploadsOnce()
|
||||
local backoff = 2
|
||||
@@ -516,6 +687,17 @@ local stationByCode = {}
|
||||
local adjacency_regular, adjacency_express = {}, {}
|
||||
local transferGroupByCode = {}
|
||||
|
||||
local function updateVersionStateFromConfig()
|
||||
local remote = normalizeVersionTag(type(CFG.lua_versions) == 'table' and CFG.lua_versions.ticketmachine or nil)
|
||||
if #remote == 0 then
|
||||
expectedMachineVersion = nil
|
||||
versionMismatch = nil
|
||||
return
|
||||
end
|
||||
expectedMachineVersion = remote
|
||||
versionMismatch = (remote ~= normalizeVersionTag(VERSION))
|
||||
end
|
||||
|
||||
local function normalizeCode(s)
|
||||
s = tostring(s or '')
|
||||
s = s:gsub('[\239\187\191]', ''):gsub('%s+', '')
|
||||
@@ -644,6 +826,7 @@ local function refreshConfigOnce()
|
||||
if f then f.write(textutils.serializeJSON(cfg)); f.close() end
|
||||
CFG = cfg
|
||||
rebuildMaps()
|
||||
updateVersionStateFromConfig()
|
||||
os.queueEvent('config_updated')
|
||||
return true
|
||||
end
|
||||
@@ -665,6 +848,7 @@ end
|
||||
|
||||
CFG = loadConfig() or CFG
|
||||
rebuildMaps()
|
||||
updateVersionStateFromConfig()
|
||||
|
||||
|
||||
-- ###########################
|
||||
@@ -889,10 +1073,20 @@ end
|
||||
|
||||
local function drawVersionIndicator()
|
||||
if w < 1 then return end
|
||||
local markerColor = colors.yellow
|
||||
if versionMismatch == true then
|
||||
markerColor = colors.red
|
||||
elseif versionMismatch == false then
|
||||
markerColor = colors.lime
|
||||
end
|
||||
termDev.setBackgroundColor(colors.black)
|
||||
termDev.setTextColor(colors.gray)
|
||||
termDev.setCursorPos(1, 1)
|
||||
termDev.write(tostring(VERSION))
|
||||
if w >= (#tostring(VERSION) + 1) then
|
||||
termDev.setTextColor(markerColor)
|
||||
termDev.write('*')
|
||||
end
|
||||
termDev.setTextColor(colors.white)
|
||||
end
|
||||
|
||||
@@ -1457,6 +1651,33 @@ local function computeCost(src, dst, trainType)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function paymentHintText()
|
||||
if detectedPaymentSide and #tostring(detectedPaymentSide) > 0 then
|
||||
return 'Payment side: ' .. tostring(detectedPaymentSide):upper()
|
||||
end
|
||||
return 'Insert payment on any side'
|
||||
end
|
||||
|
||||
local function snapshotPaymentInputs()
|
||||
local states = {}
|
||||
if not redstone or type(redstone.getInput) ~= 'function' then return states end
|
||||
for _, side in ipairs(REDSTONE_SIDES) do
|
||||
states[side] = redstone.getInput(side) and true or false
|
||||
end
|
||||
return states
|
||||
end
|
||||
|
||||
local function detectPaymentPulse(prevStates)
|
||||
local nowStates = snapshotPaymentInputs()
|
||||
for _, side in ipairs(REDSTONE_SIDES) do
|
||||
if nowStates[side] and not prevStates[side] then
|
||||
detectedPaymentSide = side
|
||||
return true, side, nowStates
|
||||
end
|
||||
end
|
||||
return false, nil, nowStates
|
||||
end
|
||||
|
||||
local function drawOrder()
|
||||
if state.productMode == 'card' then
|
||||
local cardSub = (state.cardMode == 'redeem') and 'Redeem IC card order' or 'Open new stored-value card'
|
||||
@@ -1536,7 +1757,7 @@ local function drawOrder()
|
||||
if total <= 0 then
|
||||
centerText(statusY + 1, 'Ready to confirm', colors.lightGray)
|
||||
else
|
||||
centerText(statusY + 1, 'Insert payment on RIGHT side', colors.lightGray)
|
||||
centerText(statusY + 1, paymentHintText(), colors.lightGray)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1603,6 +1824,8 @@ local function showOrderAndAudio()
|
||||
local reuseBlankCardId = firstString(state.pendingBlankCardId)
|
||||
if #reuseBlankCardId > 0 then
|
||||
payload.card_id = reuseBlankCardId
|
||||
else
|
||||
payload.card_id = generateCardId()
|
||||
end
|
||||
local okIssueBlank, blankCardId, issueMethod = false, '', 'reuse_pending'
|
||||
if #reuseBlankCardId == 0 then
|
||||
@@ -1616,13 +1839,19 @@ local function showOrderAndAudio()
|
||||
local okReq, code, parsed, err = submitCardOpen(payload)
|
||||
if okReq then
|
||||
local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {}
|
||||
state.card_id = firstString(respData.card_id, respData.id, payload.card_id)
|
||||
state.cardBalance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance
|
||||
local finalCard = buildFinalCardData(payload, respData)
|
||||
local okWrite, writtenCard, writeMethod = writeICCard(finalCard, { writeOnly = true })
|
||||
if okWrite then
|
||||
state.card_id = firstString(writtenCard.card_id, finalCard.card_id)
|
||||
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
|
||||
state.card_server_data = respData
|
||||
state.pendingBlankCardId = nil
|
||||
confirmed = true
|
||||
statusMsg, statusCol = 'Card ready', colors.green
|
||||
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
|
||||
else
|
||||
statusMsg, statusCol = 'Write failed: ' .. tostring(writeMethod), colors.red
|
||||
end
|
||||
else
|
||||
local errorMsg = 'Card API Err'
|
||||
if code == 409 then
|
||||
@@ -1640,20 +1869,7 @@ local function showOrderAndAudio()
|
||||
local okReq, code, parsed, err = submitCardOpen(payload)
|
||||
if okReq then
|
||||
local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {}
|
||||
local finalCard = {
|
||||
card_id = firstString(respData.card_id, respData.id, payload.card_id),
|
||||
holder_name = payload.holder_name,
|
||||
balance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance,
|
||||
deposit = firstNumber(respData.deposit, payload.deposit) or payload.deposit,
|
||||
topup = firstNumber(respData.topup, respData.first_topup, payload.topup) or payload.topup,
|
||||
station_code = payload.station_code,
|
||||
device = payload.device,
|
||||
voucher_code = payload.voucher_code,
|
||||
media = 'ic_card',
|
||||
product_type = 'stored_value',
|
||||
order_value = payload.order_value,
|
||||
initial_balance = payload.topup
|
||||
}
|
||||
local finalCard = buildFinalCardData(payload, respData)
|
||||
local okWrite, writtenCard, writeMethod = writeICCard(finalCard)
|
||||
if okWrite then
|
||||
state.card_id = firstString(writtenCard.card_id, finalCard.card_id)
|
||||
@@ -1736,12 +1952,12 @@ local function showOrderAndAudio()
|
||||
confirmAction()
|
||||
end
|
||||
|
||||
local prev = redstone.getInput('right')
|
||||
local prevInputs = snapshotPaymentInputs()
|
||||
while state.page == 'order' do
|
||||
local ev, p1, p2, p3 = os.pullEvent()
|
||||
if ev == 'redstone' then
|
||||
local now = redstone.getInput('right')
|
||||
if now and not prev then
|
||||
local pulsed, _, nextInputs = detectPaymentPulse(prevInputs)
|
||||
if pulsed then
|
||||
playNote('hat', 20, 1, 0.01)
|
||||
state.paid = (state.paid or 0) + 1; render()
|
||||
if state.paid >= (state.cost or 0) then
|
||||
@@ -1752,7 +1968,8 @@ local function showOrderAndAudio()
|
||||
sleep(0.5) -- Wait for UI/Audio slightly
|
||||
confirmAction()
|
||||
end
|
||||
end; prev = now
|
||||
end
|
||||
prevInputs = nextInputs
|
||||
elseif ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||
-- For mouse_click: p1=button, p2=x, p3=y
|
||||
-- For monitor_touch: p1=side, p2=x, p3=y
|
||||
@@ -1794,9 +2011,10 @@ local function generateTicketId()
|
||||
end
|
||||
|
||||
local function ensureTicketIdFormat(id)
|
||||
if id == nil then return generateTicketId() end
|
||||
if id == nil then return '' end
|
||||
local s = tostring(id)
|
||||
s = s:gsub('%s+', '')
|
||||
if #s == 0 then return '' end
|
||||
local prefix, num = s:match('^([A-Za-z][A-Za-z])%-?([0-9]+)$')
|
||||
if prefix and num then
|
||||
prefix = prefix:upper()
|
||||
@@ -1890,22 +2108,35 @@ local function showDone()
|
||||
start_name = startObj and unicodeEscape(startObj.name) or nil,
|
||||
terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil,
|
||||
start_name_en = fromNameEn,
|
||||
terminal_name_en = toNameEn
|
||||
terminal_name_en = toNameEn,
|
||||
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
|
||||
}
|
||||
if ticketVendingMachine and ticketVendingMachine.issueTicket then
|
||||
|
||||
local apiType = (state.trainType == 'Express') and 'limited_express' or 'local'
|
||||
local okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
|
||||
if not (okCall and okIssue and ticketId) then
|
||||
okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
|
||||
end
|
||||
if okCall and okIssue and ticketId then
|
||||
state.ticket_id = ensureTicketIdFormat(ticketId)
|
||||
local localGeneratedTicketId = generateTicketId()
|
||||
local okIssueTicket, issuedTicketId, issueMethod = issueTicketFromPeripheral(
|
||||
fromNameEnArg,
|
||||
toNameEnArg,
|
||||
apiType,
|
||||
rides,
|
||||
cost,
|
||||
startStationArg,
|
||||
terminalStationArg,
|
||||
fromNameCnUArg,
|
||||
toNameCnUArg,
|
||||
localGeneratedTicketId
|
||||
)
|
||||
if okIssueTicket then
|
||||
state.ticket_id = issuedTicketId
|
||||
issueSource = 'ticket_vending_machine'
|
||||
else
|
||||
state.ticket_id = generateTicketId()
|
||||
end
|
||||
else
|
||||
state.ticket_id = generateTicketId()
|
||||
local issueError = tostring(issueMethod or 'ticket_issue_failed')
|
||||
print('Ticket issue failed: ' .. issueError)
|
||||
_G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError
|
||||
showAlert('Ticket issue failed')
|
||||
resetTicketFlow()
|
||||
state.page = 'home'
|
||||
return
|
||||
end
|
||||
|
||||
pcall(function()
|
||||
@@ -2074,7 +2305,6 @@ local function showOnlineVoucher()
|
||||
elseif ev == 'key' and p1 == keys.backspace then code = code:sub(1, -2)
|
||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode()
|
||||
elseif ev == 'config_updated' then
|
||||
-- Config updated in background, continue to redraw
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2101,4 +2331,4 @@ local function mainPageLoop()
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask)
|
||||
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)
|
||||
|
||||
+22
-1
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<!-- 充满未知和不稳定的票务系统! -->
|
||||
|
||||
@@ -841,6 +841,27 @@
|
||||
<button @click="saveConfig"><i class="fas fa-save"></i> 保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:8px; font-weight:600;">Lua 脚本更新控制</label>
|
||||
<div class="flex" style="flex-direction: column; gap: 10px;">
|
||||
<div class="flex" style="flex-wrap: wrap; gap: 10px; align-items: center;">
|
||||
<span style="min-width: 70px;">售票机</span>
|
||||
<input v-model="config.lua_versions.ticketmachine" placeholder="例如 v1.5.8" style="max-width: 180px;">
|
||||
<button @click="bumpLuaVersion('ticketmachine')"><i class="fas fa-arrow-up"></i> 补丁 +1</button>
|
||||
</div>
|
||||
<div class="flex" style="flex-wrap: wrap; gap: 10px; align-items: center;">
|
||||
<span style="min-width: 70px;">检票机</span>
|
||||
<input v-model="config.lua_versions.gate" placeholder="例如 v1.5.8" style="max-width: 180px;">
|
||||
<button @click="bumpLuaVersion('gate')"><i class="fas fa-arrow-up"></i> 补丁 +1</button>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size: 0.9rem;">
|
||||
每次发布新的 Lua 脚本后,在这里手动提升一次版本号;设备检测到不一致时会在左上角版本号旁显示更新标记。
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button @click="saveConfig"><i class="fas fa-save"></i> 保存 Lua 版本</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h4>数据管理</h4>
|
||||
|
||||
+29
-2
@@ -44,7 +44,11 @@ createApp({
|
||||
const fares = ref([]);
|
||||
const tickets = ref([]);
|
||||
const stats = reactive({ sold_tickets: 0, revenue: 0 });
|
||||
const config = reactive({ api_base: '', promotion: { name: '', discount: 1 } });
|
||||
const config = reactive({
|
||||
api_base: '',
|
||||
promotion: { name: '', discount: 1 },
|
||||
lua_versions: { ticketmachine: 'v1.5.8', gate: 'v1.5.8' }
|
||||
});
|
||||
const logs = ref([]);
|
||||
const logCategory = ref('');
|
||||
const logTypeFilter = ref('');
|
||||
@@ -224,6 +228,20 @@ createApp({
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeLuaVersion = (value) => {
|
||||
let text = String(value || '').trim();
|
||||
if (!text) text = 'v1.0.0';
|
||||
if (!/^v/i.test(text)) text = `v${text}`;
|
||||
return text;
|
||||
};
|
||||
|
||||
const bumpPatchVersion = (value) => {
|
||||
const normalized = normalizeLuaVersion(value);
|
||||
const matched = /^v?(\d+)\.(\d+)\.(\d+)$/i.exec(normalized);
|
||||
if (!matched) return normalized;
|
||||
return `v${matched[1]}.${matched[2]}.${Number(matched[3]) + 1}`;
|
||||
};
|
||||
|
||||
// Methods
|
||||
const formatTime = (ts) => {
|
||||
if (ts == null || ts === '') return '---';
|
||||
@@ -1344,10 +1362,18 @@ createApp({
|
||||
|
||||
const saveConfig = async () => {
|
||||
await runMutation(async () => {
|
||||
config.lua_versions.ticketmachine = normalizeLuaVersion(config.lua_versions.ticketmachine);
|
||||
config.lua_versions.gate = normalizeLuaVersion(config.lua_versions.gate);
|
||||
await requestJson('/api/config', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(config) }, { expectOk: true });
|
||||
}, { successMessage: '保存成功' });
|
||||
};
|
||||
|
||||
const bumpLuaVersion = async (device) => {
|
||||
if (!config.lua_versions[device]) config.lua_versions[device] = 'v1.0.0';
|
||||
config.lua_versions[device] = bumpPatchVersion(config.lua_versions[device]);
|
||||
await saveConfig();
|
||||
};
|
||||
|
||||
const exportData = () => {
|
||||
window.open('/api/export', '_blank');
|
||||
};
|
||||
@@ -1390,6 +1416,7 @@ createApp({
|
||||
});
|
||||
socket.on('config:updated', (data) => {
|
||||
Object.assign(config, data);
|
||||
if (!config.lua_versions) config.lua_versions = { ticketmachine: 'v1.5.8', gate: 'v1.5.8' };
|
||||
coreLoaded = true;
|
||||
fareMapLoaded = false;
|
||||
if (currentView.value === 'faremap') {
|
||||
@@ -1579,7 +1606,7 @@ createApp({
|
||||
|
||||
saveCurrentFare, deleteCurrentFare, closeFareModal,
|
||||
|
||||
saveConfig, exportData, exportFareMap,
|
||||
saveConfig, bumpLuaVersion, exportData, exportFareMap,
|
||||
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
|
||||
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,
|
||||
isTransferStation, getTransferTitleSuffix, getTransferLineBadges
|
||||
|
||||
Reference in New Issue
Block a user