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_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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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