81debd3b55
原2秒的停留时间过短,用户无法及时看清错误提示内容
581 lines
16 KiB
Lua
581 lines
16 KiB
Lua
local VERSION = "v1.5.7"
|
|
local MACHINE_SIDE = "bottom"
|
|
local PAYMENT_SIDE = "right"
|
|
local PRESET_AMOUNTS = { 5, 10, 15, 20 }
|
|
local DEFAULT_API_BASE = "http://ticket.fse-media.group/api"
|
|
|
|
local termDev = term.current()
|
|
local w, h = termDev.getSize()
|
|
local Buttons = {}
|
|
local UI = {
|
|
bg = colors.black,
|
|
panel = colors.gray,
|
|
panelDark = colors.black,
|
|
text = colors.white,
|
|
muted = colors.lightGray,
|
|
accent = colors.cyan,
|
|
success = colors.lime,
|
|
warning = colors.yellow,
|
|
danger = colors.red,
|
|
info = colors.lightBlue,
|
|
action = colors.green,
|
|
neutral = colors.gray,
|
|
}
|
|
|
|
local state = {
|
|
page = "home",
|
|
amount = 0,
|
|
amountInput = "",
|
|
paid = 0,
|
|
finalBalance = nil,
|
|
cardInfo = nil,
|
|
}
|
|
|
|
local function trim(value)
|
|
return tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
|
end
|
|
|
|
local function readApiEndpointFile(path)
|
|
if not fs.exists(path) then return nil end
|
|
local f = fs.open(path, "r")
|
|
if not f then return nil end
|
|
local text = f.readAll()
|
|
f.close()
|
|
return trim(text)
|
|
end
|
|
|
|
local function resolveApiBase()
|
|
local base = readApiEndpointFile("API_ENDPOINT_REFILL.txt") or readApiEndpointFile("API_ENDPOINT.txt") or DEFAULT_API_BASE
|
|
base = trim(base)
|
|
if base:match("/api$") then
|
|
return base
|
|
end
|
|
return base:gsub("/+$", "") .. "/api"
|
|
end
|
|
|
|
local API_BASE = resolveApiBase()
|
|
|
|
local function urlEncodeComponent(value)
|
|
local s = tostring(value or "")
|
|
return (s:gsub("([^%w%-_%.~])", function(c)
|
|
return string.format("%%%02X", string.byte(c))
|
|
end))
|
|
end
|
|
|
|
local function postJSON(url, payload)
|
|
if not http then
|
|
return false, "HTTP API disabled"
|
|
end
|
|
local okBody, body = pcall(textutils.serializeJSON, payload or {})
|
|
if not okBody or type(body) ~= "string" then
|
|
return false, "serializeJSON failed"
|
|
end
|
|
local headers = { ["Content-Type"] = "application/json" }
|
|
|
|
if http.post then
|
|
local ok, res, err = pcall(http.post, url, body, headers)
|
|
if not ok or not res then
|
|
return false, tostring(err or res or "http.post failed")
|
|
end
|
|
local data = res.readAll and res.readAll() or ""
|
|
res.close()
|
|
local okJson, parsed = pcall(textutils.unserializeJSON, data or "")
|
|
return true, okJson and parsed or data
|
|
end
|
|
|
|
local okReq, reqErr = pcall(http.request, { url = url, method = "POST", headers = headers, body = body })
|
|
if not okReq or not reqErr then
|
|
return false, tostring(reqErr or "http.request failed")
|
|
end
|
|
while true do
|
|
local ev, p1, p2, p3 = os.pullEvent()
|
|
if ev == "http_success" and p1 == url then
|
|
local res = p2
|
|
local data = res.readAll and res.readAll() or ""
|
|
res.close()
|
|
local okJson, parsed = pcall(textutils.unserializeJSON, data or "")
|
|
return true, okJson and parsed or data
|
|
end
|
|
if ev == "http_failure" and p1 == url then
|
|
local err = p2
|
|
local res = p3
|
|
if type(p2) == "table" and type(p2.readAll) == "function" then
|
|
res = p2
|
|
err = p3
|
|
end
|
|
if type(res) == "table" and type(res.readAll) == "function" then
|
|
local data = res.readAll()
|
|
res.close()
|
|
return false, data
|
|
end
|
|
return false, tostring(err or "http_failure")
|
|
end
|
|
os.queueEvent(ev, p1, p2, p3)
|
|
sleep(0)
|
|
end
|
|
end
|
|
|
|
local function syncRefillResult(cardInfo, amount, balance)
|
|
local cardId = trim(cardInfo and cardInfo.cardId or "")
|
|
if #cardId == 0 then return false, "missing_card_id" end
|
|
local url = API_BASE .. "/ic-cards/" .. urlEncodeComponent(cardId) .. "/sync"
|
|
return postJSON(url, {
|
|
type = "topup",
|
|
device = "refill_machine",
|
|
amount = tonumber(amount) or 0,
|
|
balance = tonumber(balance) or 0,
|
|
result = "pass",
|
|
last_action = "topup"
|
|
})
|
|
end
|
|
|
|
local function refreshSize()
|
|
w, h = termDev.getSize()
|
|
end
|
|
|
|
local function clearScreen()
|
|
refreshSize()
|
|
termDev.setBackgroundColor(colors.black)
|
|
termDev.setTextColor(colors.white)
|
|
termDev.clear()
|
|
termDev.setCursorPos(1, 1)
|
|
end
|
|
|
|
local function centerText(y, text, color)
|
|
text = tostring(text or "")
|
|
termDev.setTextColor(color or colors.white)
|
|
termDev.setCursorPos(math.max(1, math.floor((w - #text) / 2) + 1), y)
|
|
termDev.write(text)
|
|
end
|
|
|
|
local function writeText(x, y, text, fg, bg)
|
|
if bg then termDev.setBackgroundColor(bg) end
|
|
if fg then termDev.setTextColor(fg) end
|
|
termDev.setCursorPos(x, y)
|
|
termDev.write(text)
|
|
end
|
|
|
|
local function fitText(text, maxLen)
|
|
text = tostring(text or "")
|
|
maxLen = math.max(1, tonumber(maxLen) or #text)
|
|
if #text <= maxLen then return text end
|
|
if maxLen <= 3 then return text:sub(1, maxLen) end
|
|
return text:sub(1, maxLen - 3) .. "..."
|
|
end
|
|
|
|
local function drawFooterHint(text)
|
|
centerText(h - 1, fitText(text, math.max(1, w - 2)), UI.muted)
|
|
end
|
|
|
|
local function addButton(x, y, label, bw, bh, bg, fg, onClick)
|
|
Buttons[#Buttons + 1] = {
|
|
x = x,
|
|
y = y,
|
|
w = bw,
|
|
h = bh,
|
|
onClick = onClick,
|
|
}
|
|
|
|
termDev.setBackgroundColor(bg)
|
|
termDev.setTextColor(fg)
|
|
for row = 0, bh - 1 do
|
|
termDev.setCursorPos(x, y + row)
|
|
termDev.write(string.rep(" ", bw))
|
|
end
|
|
|
|
local labelX = x + math.max(0, math.floor((bw - #label) / 2))
|
|
local labelY = y + math.floor((bh - 1) / 2)
|
|
termDev.setCursorPos(labelX, labelY)
|
|
termDev.write(label)
|
|
end
|
|
|
|
local function inRect(btn, px, py)
|
|
return px >= btn.x and px <= (btn.x + btn.w - 1) and py >= btn.y and py <= (btn.y + btn.h - 1)
|
|
end
|
|
|
|
local function handleTouch(x, y)
|
|
for _, btn in ipairs(Buttons) do
|
|
if inRect(btn, x, y) then
|
|
if btn.onClick then btn.onClick() end
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function getRefillMachine()
|
|
local sideType = peripheral.getType and peripheral.getType(MACHINE_SIDE) or nil
|
|
if sideType == "ic_refill_machine" then
|
|
local ok, dev = pcall(peripheral.wrap, MACHINE_SIDE)
|
|
if ok and type(dev) == "table" then
|
|
return dev
|
|
end
|
|
end
|
|
|
|
local dev = peripheral.find("ic_refill_machine")
|
|
if type(dev) == "table" then return dev end
|
|
return nil
|
|
end
|
|
|
|
local function readCardInfo()
|
|
local dev = getRefillMachine()
|
|
if not dev or type(dev.getCardInfo) ~= "function" then
|
|
return nil, "machine_unavailable"
|
|
end
|
|
|
|
local ok, info = pcall(dev.getCardInfo)
|
|
if not ok or type(info) ~= "table" then
|
|
return nil, "no_card"
|
|
end
|
|
|
|
local hasData = info.cardId ~= nil or info.ownerName ~= nil or info.balance ~= nil
|
|
if not hasData then
|
|
return nil, "no_card"
|
|
end
|
|
|
|
return {
|
|
cardId = tostring(info.cardId or ""),
|
|
ownerName = tostring(info.ownerName or "UNKNOWN"),
|
|
balance = tonumber(info.balance) or 0,
|
|
}
|
|
end
|
|
|
|
local function drawHeader(title, subTitle)
|
|
clearScreen()
|
|
centerText(2, fitText(title, math.max(1, w - 4)), UI.text)
|
|
if subTitle and #tostring(subTitle) > 0 then
|
|
centerText(3, fitText(subTitle, math.max(1, w - 4)), UI.info)
|
|
end
|
|
|
|
writeText(1, 1, VERSION, UI.muted, UI.bg)
|
|
|
|
local hasMachine = getRefillMachine() ~= nil
|
|
local status = hasMachine and "S* OK" or "S* ERR"
|
|
local statusColor = hasMachine and UI.success or UI.danger
|
|
local statusX = math.max(1, w - #status + 1)
|
|
writeText(statusX, 1, status, statusColor, UI.bg)
|
|
termDev.setBackgroundColor(UI.bg)
|
|
termDev.setTextColor(UI.text)
|
|
end
|
|
|
|
local function getAmountFromInput()
|
|
local value = tonumber(state.amountInput)
|
|
if not value then return 0 end
|
|
return math.max(0, math.floor(value))
|
|
end
|
|
|
|
local function setAmount(value)
|
|
value = math.max(0, math.floor(tonumber(value) or 0))
|
|
state.amount = value
|
|
state.amountInput = value > 0 and tostring(value) or ""
|
|
end
|
|
|
|
local function appendDigit(ch)
|
|
ch = tostring(ch or "")
|
|
if not ch:match("^%d$") then return end
|
|
if #state.amountInput >= 4 then return end
|
|
if state.amountInput == "0" then
|
|
state.amountInput = ch
|
|
else
|
|
state.amountInput = state.amountInput .. ch
|
|
end
|
|
state.amount = getAmountFromInput()
|
|
end
|
|
|
|
local function drawProgressBar(current, total, y)
|
|
local barW = math.max(10, w - 10)
|
|
local barX = math.floor((w - barW) / 2) + 1
|
|
local ratio = total <= 0 and 1 or math.min(1, current / total)
|
|
local fill = math.floor(barW * ratio)
|
|
|
|
termDev.setCursorPos(barX, y)
|
|
termDev.setTextColor(colors.green)
|
|
termDev.write(string.rep("=", fill))
|
|
termDev.setTextColor(colors.gray)
|
|
termDev.write(string.rep("-", math.max(0, barW - fill)))
|
|
end
|
|
|
|
local function resetFlowToHome()
|
|
state.amount = 0
|
|
state.amountInput = ""
|
|
state.paid = 0
|
|
state.finalBalance = nil
|
|
state.cardInfo = nil
|
|
state.page = "home"
|
|
end
|
|
|
|
local function waitForEventWithTimer(seconds)
|
|
local timer = os.startTimer(seconds)
|
|
while true do
|
|
local ev, p1, p2, p3 = os.pullEvent()
|
|
if ev == "timer" and p1 == timer then
|
|
return "timer"
|
|
end
|
|
if ev == "mouse_click" or ev == "monitor_touch" then
|
|
return ev, p1, p2, p3
|
|
end
|
|
if ev == "redstone" or ev == "term_resize" or ev == "peripheral" or ev == "peripheral_detach" then
|
|
return ev, p1, p2, p3
|
|
end
|
|
end
|
|
end
|
|
|
|
local function drawHomePage()
|
|
drawHeader("FSE Refill Machine", "IC Card Balance Refill")
|
|
Buttons = {}
|
|
|
|
local btnW = 18
|
|
local btnH = 5
|
|
local btnX = math.floor((w - btnW) / 2) + 1
|
|
local btnY = math.floor((h - btnH) / 2)
|
|
addButton(btnX, btnY, "START REFILL", btnW, btnH, UI.action, UI.text, function()
|
|
state.amount = 0
|
|
state.amountInput = ""
|
|
state.paid = 0
|
|
state.finalBalance = nil
|
|
state.cardInfo = nil
|
|
state.page = "amount"
|
|
end)
|
|
|
|
drawFooterHint("Touch the button to start")
|
|
end
|
|
|
|
local function drawAmountPage()
|
|
drawHeader("Select Refill Amount", "Preset amount or keyboard input")
|
|
Buttons = {}
|
|
|
|
local displayText = state.amountInput ~= "" and state.amountInput or "0"
|
|
local boxW = math.min(20, w - 8)
|
|
local boxX = math.floor((w - boxW) / 2) + 1
|
|
local presetY = 8
|
|
local presetW = 8
|
|
local gap = 2
|
|
local totalPresetW = (presetW * 4) + (gap * 3)
|
|
local presetX = math.floor((w - totalPresetW) / 2) + 1
|
|
local notesY = presetY + 4
|
|
local clearY = notesY + 3
|
|
local bottomY = h - 3
|
|
|
|
if clearY + 2 >= bottomY then
|
|
clearY = math.max(notesY + 2, bottomY - 4)
|
|
end
|
|
|
|
termDev.setBackgroundColor(colors.gray)
|
|
termDev.setTextColor(colors.yellow)
|
|
termDev.setCursorPos(boxX, 5)
|
|
termDev.write(string.rep(" ", boxW))
|
|
termDev.setCursorPos(boxX + boxW - #displayText - 2, 5)
|
|
termDev.write(displayText)
|
|
termDev.setBackgroundColor(colors.black)
|
|
centerText(4, "Amount", colors.white)
|
|
|
|
for index, amount in ipairs(PRESET_AMOUNTS) do
|
|
local x = presetX + (index - 1) * (presetW + gap)
|
|
local selected = getAmountFromInput() == amount
|
|
addButton(x, presetY, tostring(amount), presetW, 3, selected and colors.green or colors.gray, colors.white, function()
|
|
setAmount(amount)
|
|
end)
|
|
end
|
|
|
|
centerText(notesY, "Custom amount: type on keyboard", colors.lightGray)
|
|
centerText(notesY + 1, "Enter to continue, Backspace to delete", colors.lightGray)
|
|
|
|
addButton(math.floor((w - 10) / 2) + 1, clearY, "CLEAR", 10, 3, colors.red, colors.white, function()
|
|
setAmount(0)
|
|
end)
|
|
|
|
addButton(2, h - 3, "BACK", 8, 3, colors.red, colors.white, function()
|
|
state.page = "home"
|
|
end)
|
|
addButton(w - 11, h - 3, "NEXT", 10, 3, getAmountFromInput() > 0 and colors.green or colors.gray, colors.black, function()
|
|
local amount = getAmountFromInput()
|
|
if amount > 0 then
|
|
state.amount = amount
|
|
state.paid = 0
|
|
state.page = "payment"
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function drawPaymentPage()
|
|
drawHeader("Payment", "Insert payment from RIGHT side")
|
|
Buttons = {}
|
|
|
|
centerText(6, "Refill Amount: " .. tostring(state.amount), colors.yellow)
|
|
centerText(8, "Paid: " .. tostring(state.paid), colors.white)
|
|
centerText(9, "Remaining: " .. tostring(math.max(0, state.amount - state.paid)), colors.red)
|
|
drawProgressBar(state.paid, state.amount, 11)
|
|
centerText(13, "1 redstone pulse = 1 amount", colors.lightGray)
|
|
centerText(14, "Auto continue after full payment", colors.lightGray)
|
|
|
|
addButton(2, h - 3, "BACK", 8, 3, colors.red, colors.white, function()
|
|
state.page = "amount"
|
|
end)
|
|
end
|
|
|
|
local function drawWaitingCardPage(cardDetected)
|
|
drawHeader("Refill Processing", "Please keep the IC card inserted")
|
|
Buttons = {}
|
|
|
|
if cardDetected then
|
|
centerText(7, "Refilling, Please do not remove the IC card", colors.yellow)
|
|
if state.cardInfo then
|
|
centerText(10, "Card ID: " .. tostring(state.cardInfo.cardId ~= "" and state.cardInfo.cardId or "UNKNOWN"), colors.cyan)
|
|
centerText(12, "Current: " .. tostring(state.cardInfo.balance), colors.white)
|
|
centerText(13, "Add: " .. tostring(state.amount), colors.lightBlue)
|
|
centerText(14, "Target: " .. tostring(state.cardInfo.balance + state.amount), colors.green)
|
|
end
|
|
else
|
|
centerText(8, "Please Insert IC card", colors.red)
|
|
centerText(11, "Hold card and right click to insert", colors.lightGray)
|
|
centerText(12, "The program will continue automatically", colors.lightGray)
|
|
end
|
|
|
|
addButton(2, h - 3, "CANCEL", 10, 3, colors.red, colors.white, function()
|
|
resetFlowToHome()
|
|
end)
|
|
end
|
|
|
|
local function drawDonePage()
|
|
drawHeader("Refill Complete", "Refill completed")
|
|
centerText(math.floor(h / 2) - 1, "DONE", colors.lime)
|
|
if state.finalBalance ~= nil then
|
|
centerText(math.floor(h / 2) + 1, "New Balance: " .. tostring(state.finalBalance), colors.white)
|
|
end
|
|
end
|
|
|
|
local function drawErrorPage(message)
|
|
drawHeader("Refill Failed", "Please try again")
|
|
centerText(math.floor(h / 2), tostring(message or "Unknown error"), colors.red)
|
|
end
|
|
|
|
local function homeLoop()
|
|
while state.page == "home" do
|
|
drawHomePage()
|
|
local ev, p1, p2, p3 = os.pullEvent()
|
|
if ev == "mouse_click" or ev == "monitor_touch" then
|
|
handleTouch(p2, p3)
|
|
elseif ev == "term_resize" then
|
|
refreshSize()
|
|
end
|
|
end
|
|
end
|
|
|
|
local function amountLoop()
|
|
while state.page == "amount" do
|
|
drawAmountPage()
|
|
local ev, p1, p2, p3 = os.pullEvent()
|
|
if ev == "mouse_click" or ev == "monitor_touch" then
|
|
handleTouch(p2, p3)
|
|
elseif ev == "char" and tostring(p1):match("%d") then
|
|
appendDigit(p1)
|
|
elseif ev == "key" and p1 == keys.backspace then
|
|
state.amountInput = state.amountInput:sub(1, -2)
|
|
state.amount = getAmountFromInput()
|
|
elseif ev == "key" and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
|
if getAmountFromInput() > 0 then
|
|
state.amount = getAmountFromInput()
|
|
state.paid = 0
|
|
state.page = "payment"
|
|
end
|
|
elseif ev == "term_resize" then
|
|
refreshSize()
|
|
end
|
|
end
|
|
end
|
|
|
|
local function paymentLoop()
|
|
state.paid = 0
|
|
local lastSignal = redstone.getInput(PAYMENT_SIDE)
|
|
|
|
while state.page == "payment" do
|
|
drawPaymentPage()
|
|
if state.paid >= state.amount then
|
|
sleep(0.3)
|
|
state.page = "refill"
|
|
return
|
|
end
|
|
|
|
local ev, p1, p2, p3 = os.pullEvent()
|
|
if ev == "redstone" then
|
|
local now = redstone.getInput(PAYMENT_SIDE)
|
|
if now and not lastSignal then
|
|
state.paid = state.paid + 1
|
|
end
|
|
lastSignal = now
|
|
elseif ev == "mouse_click" or ev == "monitor_touch" then
|
|
handleTouch(p2, p3)
|
|
elseif ev == "term_resize" then
|
|
refreshSize()
|
|
end
|
|
end
|
|
end
|
|
|
|
local function refillLoop()
|
|
while state.page == "refill" do
|
|
local dev = getRefillMachine()
|
|
if not dev then
|
|
drawErrorPage("Refill machine not found on bottom")
|
|
sleep(2)
|
|
state.page = "home"
|
|
return
|
|
end
|
|
|
|
local info = readCardInfo()
|
|
if not info then
|
|
drawWaitingCardPage(false)
|
|
local ev, p1, p2, p3 = waitForEventWithTimer(0.2)
|
|
if ev == "mouse_click" or ev == "monitor_touch" then
|
|
handleTouch(p2, p3)
|
|
elseif ev == "term_resize" then
|
|
refreshSize()
|
|
end
|
|
else
|
|
state.cardInfo = info
|
|
drawWaitingCardPage(true)
|
|
sleep(0.2)
|
|
|
|
local currentBalance = tonumber(info.balance) or 0
|
|
local refillAmount = tonumber(state.amount) or 0
|
|
local expectedBalance = currentBalance + refillAmount
|
|
local okCall, okRefill, newBalance = pcall(dev.refill, refillAmount)
|
|
|
|
if okCall and okRefill then
|
|
state.finalBalance = tonumber(newBalance) or expectedBalance
|
|
state.cardInfo.balance = state.finalBalance
|
|
syncRefillResult(state.cardInfo, refillAmount, state.finalBalance)
|
|
state.page = "done"
|
|
return
|
|
end
|
|
|
|
local errMessage = okCall and tostring(newBalance or "refill_failed") or "refill_call_failed"
|
|
drawErrorPage(errMessage)
|
|
sleep(8)
|
|
state.page = "home"
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
local function doneLoop()
|
|
drawDonePage()
|
|
sleep(3)
|
|
resetFlowToHome()
|
|
end
|
|
|
|
while true do
|
|
if state.page == "home" then
|
|
homeLoop()
|
|
elseif state.page == "amount" then
|
|
amountLoop()
|
|
elseif state.page == "payment" then
|
|
paymentLoop()
|
|
elseif state.page == "refill" then
|
|
refillLoop()
|
|
elseif state.page == "done" then
|
|
doneLoop()
|
|
else
|
|
state.page = "home"
|
|
end
|
|
end
|