feat(web,backend,lua,installer): 新增Lua脚本版本管理功能及相关优化

- 升级售票机、检票机内置Lua脚本版本至v1.5.8
- 新增后端配置的lua_versions字段,统一管理售票机、检票机的Lua脚本版本
- 前端新增版本管理配置页面,支持版本号配置和一键补丁升级
- 为售票机、检票机添加远程版本检测功能,屏幕显示版本匹配状态标记
- 简化installer配置交互流程,优化站点代码输入方式
- 重构后端配置规范化处理逻辑,统一配置初始化与存储流程
- 优化售票机外设检测、支付检测逻辑,修复部分已知问题
This commit is contained in:
2026-06-28 16:30:17 +08:00
parent 81debd3b55
commit 07e4200c17
9 changed files with 480 additions and 136 deletions
+84 -14
View File
@@ -1,7 +1,8 @@
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.7" local VERSION = "v1.5.8"
local VERSION_CHECK_INTERVAL = 60
local CONFIG_PATH = "gate_config.json" local CONFIG_PATH = "gate_config.json"
@@ -26,6 +27,15 @@ local function trim(s)
return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", "")) return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end 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 function splitCsv(s)
local out = {} local out = {}
s = trim(s) s = trim(s)
@@ -80,6 +90,8 @@ local inspection = peripheral.find("ticket_inspection_machine")
local serverConnected = nil local serverConnected = nil
local serverLastChangeTs = 0 local serverLastChangeTs = 0
local expectedGateVersion = nil
local versionMismatch = nil
local function setServerConnected(ok) local function setServerConnected(ok)
if serverConnected == ok then return end if serverConnected == ok then return end
@@ -126,10 +138,20 @@ local function drawVersionIndicator(w)
local s = tostring(VERSION or "") local s = tostring(VERSION or "")
if #s == 0 then return end if #s == 0 then return end
if w < #s 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.setBackgroundColor(colors.black)
termDev.setTextColor(colors.gray) termDev.setTextColor(colors.gray)
termDev.setCursorPos(1, 1) termDev.setCursorPos(1, 1)
termDev.write(s) termDev.write(s)
if w >= (#s + 1) then
termDev.setTextColor(markerColor)
termDev.write("*")
end
termDev.setTextColor(colors.white) termDev.setTextColor(colors.white)
end end
@@ -337,6 +359,23 @@ local function refreshStationNameMap(serverBase)
return true return true
end 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 function inferStationCodeFromName(name)
local key = normKey(name or "") local key = normKey(name or "")
if #key == 0 then return "" end if #key == 0 then return "" end
@@ -655,6 +694,10 @@ pcall(function()
refreshStationNameMap(guessBaseFromStatusURL(serverURL)) refreshStationNameMap(guessBaseFromStatusURL(serverURL))
end) end)
pcall(function()
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
end)
if not inspection then if not inspection then
if modeBySide == nil then if modeBySide == nil then
draw("Missing peripheral:", "ticket_inspection_machine", colors.red) draw("Missing peripheral:", "ticket_inspection_machine", colors.red)
@@ -667,18 +710,6 @@ if next(stationSet) == nil then
error("No station codes configured") error("No station codes configured")
end 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 = {} local stationCodesPayload = {}
for k, _ in pairs(stationSet) do table.insert(stationCodesPayload, k) end for k, _ in pairs(stationSet) do table.insert(stationCodesPayload, k) end
table.sort(stationCodesPayload) table.sort(stationCodesPayload)
@@ -785,6 +816,37 @@ local function actionForSide(side)
return modeBySide[side] or mode return modeBySide[side] or mode
end 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 function collectInspectionDevices(side, modeBySideRef)
local sideKnown = trimSide(side) ~= nil local sideKnown = trimSide(side) ~= nil
local inspectionDevs = {} local inspectionDevs = {}
@@ -1278,11 +1340,19 @@ local function processInspectionEvent(eventName, ev)
end end
end end
local versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
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)
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
end end
+4 -23
View File
@@ -35,13 +35,6 @@ local function prompt(label)
return trim(read() or "") return trim(read() or "")
end 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) local function httpGet(url)
if not http then return false, "HTTP API disabled" end if not http then return false, "HTTP API disabled" end
local okReq, err = pcall(function() local okReq, err = pcall(function()
@@ -96,27 +89,15 @@ term.setCursorPos(1, 1)
print("Ticket Gate Installer") print("Ticket Gate Installer")
print("") print("")
local stationsRaw = prompt("Station codes (comma or slash): ") local stationCode = trim(prompt("Station code: "))
local stationCodes = splitCsv(stationsRaw) if #stationCode == 0 then
if #stationCodes == 0 then print("No station code provided.")
print("No station codes provided.")
return return
end end
local modeRaw = prompt("Gate mode (entry/exit): ") local modeRaw = prompt("Gate mode (entry/exit): ")
local mode = (trim(modeRaw):lower() == "exit") and "exit" or "entry" local mode = (trim(modeRaw):lower() == "exit") and "exit" or "entry"
local stationCode = prompt("Gate station code (default first code): ") local cfg = { mode = mode, station_codes = { stationCode }, station_code = stationCode }
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 okCfg, cfgJson = pcall(textutils.serializeJSON, cfg) local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg)
if not okCfg then if not okCfg then
print("Config serialize failed.") print("Config serialize failed.")
+7 -26
View File
@@ -34,13 +34,6 @@ local function prompt(label)
return trim(read() or "") return trim(read() or "")
end 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) local function httpGet(url)
if not http then return false, "HTTP API disabled" end if not http then return false, "HTTP API disabled" end
local okReq, err = pcall(function() local okReq, err = pcall(function()
@@ -99,10 +92,9 @@ term.setCursorPos(1, 1)
print("Bidirectional Ticket Gate Installer") print("Bidirectional Ticket Gate Installer")
print("") print("")
local stationsRaw = prompt("Station codes (comma or slash): ") local stationCode = trim(prompt("Station code: "))
local stationCodes = splitCsv(stationsRaw) if #stationCode == 0 then
if #stationCodes == 0 then print("No station code provided.")
print("No station codes provided.")
return return
end end
@@ -112,30 +104,19 @@ local frontRaw = prompt("Front mode (entry/exit): ")
local backRaw = prompt("Back mode (entry/exit): ") local backRaw = prompt("Back mode (entry/exit): ")
local frontMode = normalizeMode(frontRaw) local frontMode = normalizeMode(frontRaw)
local backMode = normalizeMode(backRaw) 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 = { local cfg = {
station_codes = stationCodes, station_codes = { stationCode },
station_code = stationCodes[1], station_code = stationCode,
side_modes = { side_modes = {
front = frontMode, front = frontMode,
back = backMode, back = backMode,
}, },
side_station_codes = { side_station_codes = {
front = frontStationCode, front = stationCode,
back = backStationCode, 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) local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg)
if not okCfg then if not okCfg then
print("Config serialize failed.") print("Config serialize failed.")
+12 -4
View File
@@ -148,14 +148,16 @@ const resolveCurrentStationCode = (body, resolveStation) => {
// Config // Config
router.get('/config', (req, res) => { router.get('/config', (req, res) => {
const cfg = DataService.getConfig();
res.json({ res.json({
api_base: DataService.getConfig().api_base, api_base: cfg.api_base,
current_station: DataService.getConfig().current_station, current_station: cfg.current_station,
stations: DataService.getStations(), stations: DataService.getStations(),
lines: DataService.getLines(), lines: DataService.getLines(),
fares: DataService.getFares(), fares: DataService.getFares(),
transfers: DataService.getConfig().transfers || [], transfers: cfg.transfers || [],
promotion: DataService.getConfig().promotion || { name: '', discount: 1 } 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' }); 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 }; 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); await DataService.saveConfig(cfg);
appendReqLog(req, { category: 'admin', type: 'update_config_generic', detail: incoming }); appendReqLog(req, { category: 'admin', type: 'update_config_generic', detail: incoming });
io.emit('config:updated', cfg); io.emit('config:updated', cfg);
+2 -1
View File
@@ -221,7 +221,8 @@ router.get('/fares/query', (req, res) => {
router.get('/config', (req, res) => { router.get('/config', (req, res) => {
const cfg = DataService.getConfig(); const cfg = DataService.getConfig();
res.json({ 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
View File
@@ -21,14 +21,39 @@ const pool = mysql.createPool({
queueLimit: 0 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 // In-memory cache for synchronous read access
const cache = { const cache = {
config: { config: normalizeConfig({}),
api_base: 'http://127.0.0.1:23333/api',
current_station: { name: 'Station1', code: '01-01' },
transfers: [],
promotion: { name: '', discount: 1 }
},
stations: [], stations: [],
lines: [], lines: [],
fares: [], fares: [],
@@ -66,7 +91,7 @@ const DataService = {
// Load Cache // Load Cache
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']); 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)]); 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'); const [stations] = await conn.query('SELECT data FROM stations');
@@ -114,8 +139,8 @@ const DataService = {
// Config // Config
getConfig: () => cache.config, getConfig: () => cache.config,
saveConfig: async (cfg) => { saveConfig: async (cfg) => {
cache.config = cfg; cache.config = normalizeConfig(cfg);
await pool.query('INSERT INTO kv_store (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?', ['config', JSON.stringify(cfg), JSON.stringify(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 // Stations
+286 -56
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.7' local VERSION = 'v1.5.8'
-- ########################### -- ###########################
-- Core HTTP & JSON Utilities -- Core HTTP & JSON Utilities
@@ -13,6 +13,17 @@ end
local serverConnected = nil local serverConnected = nil
local serverLastChangeTs = 0 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) local function setServerConnected(ok)
if serverConnected == ok then return end if serverConnected == ok then return end
@@ -314,22 +325,93 @@ end
-- ########################### -- ###########################
-- Peripheral discovery -- Peripheral discovery
-- ########################### -- ###########################
local monitor = peripheral.find('monitor') local SIDE_PRIORITY = { top = 1, bottom = 2, left = 3, right = 4, front = 5, back = 6 }
local ticketVendingMachine = peripheral.find('ticket_vending_machine') local REDSTONE_SIDES = { 'right', 'left', 'top', 'bottom', 'front', 'back' }
local speaker = peripheral.find('speaker') 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 local MOD_DEBUG = true
pcall(math.randomseed, (os.epoch and os.epoch('utc')) or os.time()) pcall(math.randomseed, (os.epoch and os.epoch('utc')) or os.time())
local function safe(term) local function comparePeripheralName(a, b)
if monitor then return peripheral.wrap(peripheral.getName(monitor)) end local pa = SIDE_PRIORITY[tostring(a or '')] or 99
return term 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 end
local termDev = safe(term) local function peripheralTypeMatches(name, typeName)
if monitor then pcall(monitor.setTextScale, 0.5) end 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 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) local function saveCardIssueSnapshot(cardData)
pcall(function() pcall(function()
ensureDir('logs/last_card_issue.json') ensureDir('logs/last_card_issue.json')
@@ -356,6 +438,14 @@ local function peripheralCallSucceeded(r1)
return r1 ~= nil and r1 ~= false return r1 ~= nil and r1 ~= false
end end
local function getTicketVendingMachine()
refreshDevices()
if type(ticketVendingMachine) == 'table' then
return ticketVendingMachine
end
return nil
end
local function callPeripheralMethods(dev, methodNames, variants) local function callPeripheralMethods(dev, methodNames, variants)
if type(dev) ~= 'table' then return false, 'peripheral_unavailable' end if type(dev) ~= 'table' then return false, 'peripheral_unavailable' end
for _, methodName in ipairs(methodNames) do for _, methodName in ipairs(methodNames) do
@@ -373,7 +463,7 @@ local function callPeripheralMethods(dev, methodNames, variants)
end end
local function issueBlankICCard(holderName, initialBalance) local function issueBlankICCard(holderName, initialBalance)
local dev = ticketVendingMachine local dev = getTicketVendingMachine()
if type(dev) ~= 'table' then return false, '', 'peripheral_unavailable' end if type(dev) ~= 'table' then return false, '', 'peripheral_unavailable' end
local safeHolderName = firstString(holderName, 'CARD USER') local safeHolderName = firstString(holderName, 'CARD USER')
local safeInitialBalance = math.max(0, math.floor(tonumber(initialBalance) or 0)) local safeInitialBalance = math.max(0, math.floor(tonumber(initialBalance) or 0))
@@ -395,8 +485,9 @@ local function issueBlankICCard(holderName, initialBalance)
return false, '', 'unsupported_method' return false, '', 'unsupported_method'
end end
local function writeICCard(cardData) local function writeICCard(cardData, opts)
local dev = ticketVendingMachine local dev = getTicketVendingMachine()
local options = opts or {}
local payload = {} local payload = {}
for k, v in pairs(cardData or {}) do payload[k] = v end for k, v in pairs(cardData or {}) do payload[k] = v end
payload.media = payload.media or 'ic_card' payload.media = payload.media or 'ic_card'
@@ -409,7 +500,9 @@ local function writeICCard(cardData)
saveCardIssueSnapshot(payload) saveCardIssueSnapshot(payload)
local okWrite, methodName, r1, r2, r3 = callPeripheralMethods(dev, 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 }, { payload },
{ tostring(payload.holder_name or ''), tonumber(payload.initial_balance) or 0 }, { 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) return postJSON(API_BASE .. '/cards/open', payload)
end 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 -- Audio Utilities & Playback
-- ########################### -- ###########################
@@ -489,6 +651,15 @@ local function backgroundSyncTask()
end end
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() local function backgroundTicketUploadTask()
loadPendingUploadsOnce() loadPendingUploadsOnce()
local backoff = 2 local backoff = 2
@@ -516,6 +687,17 @@ local stationByCode = {}
local adjacency_regular, adjacency_express = {}, {} local adjacency_regular, adjacency_express = {}, {}
local transferGroupByCode = {} 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) local function normalizeCode(s)
s = tostring(s or '') s = tostring(s or '')
s = s:gsub('[\239\187\191]', ''):gsub('%s+', '') 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 if f then f.write(textutils.serializeJSON(cfg)); f.close() end
CFG = cfg CFG = cfg
rebuildMaps() rebuildMaps()
updateVersionStateFromConfig()
os.queueEvent('config_updated') os.queueEvent('config_updated')
return true return true
end end
@@ -665,6 +848,7 @@ end
CFG = loadConfig() or CFG CFG = loadConfig() or CFG
rebuildMaps() rebuildMaps()
updateVersionStateFromConfig()
-- ########################### -- ###########################
@@ -889,10 +1073,20 @@ end
local function drawVersionIndicator() local function drawVersionIndicator()
if w < 1 then return end 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.setBackgroundColor(colors.black)
termDev.setTextColor(colors.gray) termDev.setTextColor(colors.gray)
termDev.setCursorPos(1, 1) termDev.setCursorPos(1, 1)
termDev.write(tostring(VERSION)) termDev.write(tostring(VERSION))
if w >= (#tostring(VERSION) + 1) then
termDev.setTextColor(markerColor)
termDev.write('*')
end
termDev.setTextColor(colors.white) termDev.setTextColor(colors.white)
end end
@@ -1457,6 +1651,33 @@ local function computeCost(src, dst, trainType)
return nil return nil
end 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() local function drawOrder()
if state.productMode == 'card' then if state.productMode == 'card' then
local cardSub = (state.cardMode == 'redeem') and 'Redeem IC card order' or 'Open new stored-value card' 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 if total <= 0 then
centerText(statusY + 1, 'Ready to confirm', colors.lightGray) centerText(statusY + 1, 'Ready to confirm', colors.lightGray)
else else
centerText(statusY + 1, 'Insert payment on RIGHT side', colors.lightGray) centerText(statusY + 1, paymentHintText(), colors.lightGray)
end end
end end
@@ -1603,6 +1824,8 @@ local function showOrderAndAudio()
local reuseBlankCardId = firstString(state.pendingBlankCardId) local reuseBlankCardId = firstString(state.pendingBlankCardId)
if #reuseBlankCardId > 0 then if #reuseBlankCardId > 0 then
payload.card_id = reuseBlankCardId payload.card_id = reuseBlankCardId
else
payload.card_id = generateCardId()
end end
local okIssueBlank, blankCardId, issueMethod = false, '', 'reuse_pending' local okIssueBlank, blankCardId, issueMethod = false, '', 'reuse_pending'
if #reuseBlankCardId == 0 then if #reuseBlankCardId == 0 then
@@ -1616,13 +1839,19 @@ local function showOrderAndAudio()
local okReq, code, parsed, err = submitCardOpen(payload) local okReq, code, parsed, err = submitCardOpen(payload)
if okReq then if okReq then
local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {} 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) local finalCard = buildFinalCardData(payload, respData)
state.cardBalance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance local okWrite, writtenCard, writeMethod = writeICCard(finalCard, { writeOnly = true })
state.card_server_data = respData if okWrite then
state.pendingBlankCardId = nil state.card_id = firstString(writtenCard.card_id, finalCard.card_id)
confirmed = true state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
statusMsg, statusCol = 'Card ready', colors.green state.card_server_data = respData
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end 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 else
local errorMsg = 'Card API Err' local errorMsg = 'Card API Err'
if code == 409 then if code == 409 then
@@ -1640,20 +1869,7 @@ local function showOrderAndAudio()
local okReq, code, parsed, err = submitCardOpen(payload) local okReq, code, parsed, err = submitCardOpen(payload)
if okReq then if okReq then
local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {} local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {}
local finalCard = { local finalCard = buildFinalCardData(payload, respData)
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 okWrite, writtenCard, writeMethod = writeICCard(finalCard) local okWrite, writtenCard, writeMethod = writeICCard(finalCard)
if okWrite then if okWrite then
state.card_id = firstString(writtenCard.card_id, finalCard.card_id) state.card_id = firstString(writtenCard.card_id, finalCard.card_id)
@@ -1736,12 +1952,12 @@ local function showOrderAndAudio()
confirmAction() confirmAction()
end end
local prev = redstone.getInput('right') local prevInputs = snapshotPaymentInputs()
while state.page == 'order' do while state.page == 'order' do
local ev, p1, p2, p3 = os.pullEvent() local ev, p1, p2, p3 = os.pullEvent()
if ev == 'redstone' then if ev == 'redstone' then
local now = redstone.getInput('right') local pulsed, _, nextInputs = detectPaymentPulse(prevInputs)
if now and not prev then if pulsed then
playNote('hat', 20, 1, 0.01) playNote('hat', 20, 1, 0.01)
state.paid = (state.paid or 0) + 1; render() state.paid = (state.paid or 0) + 1; render()
if state.paid >= (state.cost or 0) then if state.paid >= (state.cost or 0) then
@@ -1752,7 +1968,8 @@ local function showOrderAndAudio()
sleep(0.5) -- Wait for UI/Audio slightly sleep(0.5) -- Wait for UI/Audio slightly
confirmAction() confirmAction()
end end
end; prev = now end
prevInputs = nextInputs
elseif ev == 'mouse_click' or ev == 'monitor_touch' then elseif ev == 'mouse_click' or ev == 'monitor_touch' then
-- For mouse_click: p1=button, p2=x, p3=y -- For mouse_click: p1=button, p2=x, p3=y
-- For monitor_touch: p1=side, p2=x, p3=y -- For monitor_touch: p1=side, p2=x, p3=y
@@ -1794,9 +2011,10 @@ local function generateTicketId()
end end
local function ensureTicketIdFormat(id) local function ensureTicketIdFormat(id)
if id == nil then return generateTicketId() end if id == nil then return '' end
local s = tostring(id) local s = tostring(id)
s = s:gsub('%s+', '') s = s:gsub('%s+', '')
if #s == 0 then return '' end
local prefix, num = s:match('^([A-Za-z][A-Za-z])%-?([0-9]+)$') local prefix, num = s:match('^([A-Za-z][A-Za-z])%-?([0-9]+)$')
if prefix and num then if prefix and num then
prefix = prefix:upper() prefix = prefix:upper()
@@ -1890,22 +2108,35 @@ local function showDone()
start_name = startObj and unicodeEscape(startObj.name) or nil, start_name = startObj and unicodeEscape(startObj.name) or nil,
terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil, terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil,
start_name_en = fromNameEn, 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 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) local localGeneratedTicketId = generateTicketId()
if not (okCall and okIssue and ticketId) then local okIssueTicket, issuedTicketId, issueMethod = issueTicketFromPeripheral(
okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg) fromNameEnArg,
end toNameEnArg,
if okCall and okIssue and ticketId then apiType,
state.ticket_id = ensureTicketIdFormat(ticketId) rides,
issueSource = 'ticket_vending_machine' cost,
else startStationArg,
state.ticket_id = generateTicketId() terminalStationArg,
end fromNameCnUArg,
toNameCnUArg,
localGeneratedTicketId
)
if okIssueTicket then
state.ticket_id = issuedTicketId
issueSource = 'ticket_vending_machine'
else 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 end
pcall(function() 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.backspace then code = code:sub(1, -2)
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode() elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode()
elseif ev == 'config_updated' then elseif ev == 'config_updated' then
-- Config updated in background, continue to redraw
end end
end end
end end
@@ -2101,4 +2331,4 @@ local function mainPageLoop()
end end
end end
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask) parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)
+22 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<!-- 充满未知和不稳定的票务系统! --> <!-- 充满未知和不稳定的票务系统! -->
@@ -841,6 +841,27 @@
<button @click="saveConfig"><i class="fas fa-save"></i> 保存</button> <button @click="saveConfig"><i class="fas fa-save"></i> 保存</button>
</div> </div>
</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>
<div class="card"> <div class="card">
<h4>数据管理</h4> <h4>数据管理</h4>
+29 -2
View File
@@ -44,7 +44,11 @@ createApp({
const fares = ref([]); const fares = ref([]);
const tickets = ref([]); const tickets = ref([]);
const stats = reactive({ sold_tickets: 0, revenue: 0 }); 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 logs = ref([]);
const logCategory = ref(''); const logCategory = ref('');
const logTypeFilter = 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 // Methods
const formatTime = (ts) => { const formatTime = (ts) => {
if (ts == null || ts === '') return '---'; if (ts == null || ts === '') return '---';
@@ -1344,10 +1362,18 @@ createApp({
const saveConfig = async () => { const saveConfig = async () => {
await runMutation(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 }); await requestJson('/api/config', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(config) }, { expectOk: true });
}, { successMessage: '保存成功' }); }, { 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 = () => { const exportData = () => {
window.open('/api/export', '_blank'); window.open('/api/export', '_blank');
}; };
@@ -1390,6 +1416,7 @@ createApp({
}); });
socket.on('config:updated', (data) => { socket.on('config:updated', (data) => {
Object.assign(config, data); Object.assign(config, data);
if (!config.lua_versions) config.lua_versions = { ticketmachine: 'v1.5.8', gate: 'v1.5.8' };
coreLoaded = true; coreLoaded = true;
fareMapLoaded = false; fareMapLoaded = false;
if (currentView.value === 'faremap') { if (currentView.value === 'faremap') {
@@ -1579,7 +1606,7 @@ createApp({
saveCurrentFare, deleteCurrentFare, closeFareModal, saveCurrentFare, deleteCurrentFare, closeFareModal,
saveConfig, exportData, exportFareMap, saveConfig, bumpLuaVersion, exportData, exportFareMap,
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData, formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset, fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,
isTransferStation, getTransferTitleSuffix, getTransferLineBadges isTransferStation, getTransferTitleSuffix, getTransferLineBadges