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_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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+279
-49
@@ -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 })
|
||||||
|
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.card_server_data = respData
|
||||||
state.pendingBlankCardId = nil
|
state.pendingBlankCardId = nil
|
||||||
confirmed = true
|
confirmed = true
|
||||||
statusMsg, statusCol = 'Card ready', colors.green
|
statusMsg, statusCol = 'Card ready', colors.green
|
||||||
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
|
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,
|
||||||
|
cost,
|
||||||
|
startStationArg,
|
||||||
|
terminalStationArg,
|
||||||
|
fromNameCnUArg,
|
||||||
|
toNameCnUArg,
|
||||||
|
localGeneratedTicketId
|
||||||
|
)
|
||||||
|
if okIssueTicket then
|
||||||
|
state.ticket_id = issuedTicketId
|
||||||
issueSource = 'ticket_vending_machine'
|
issueSource = 'ticket_vending_machine'
|
||||||
else
|
else
|
||||||
state.ticket_id = generateTicketId()
|
local issueError = tostring(issueMethod or 'ticket_issue_failed')
|
||||||
end
|
print('Ticket issue failed: ' .. issueError)
|
||||||
else
|
_G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError
|
||||||
state.ticket_id = generateTicketId()
|
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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user