Compare commits
18 Commits
8a8ebd0df4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ef9926dc58 | |||
| 07e4200c17 | |||
| 81debd3b55 | |||
| 0a70ffe931 | |||
| a4d97fbd5a | |||
| d6aa03d3a7 | |||
| 042720d812 | |||
| 7fe1acd9d7 | |||
| b614ff663c | |||
| e78557f335 | |||
| 2ddcd18e1e | |||
| b1cb84f736 | |||
| 7fea8807b8 | |||
| 108435e90d | |||
| ea5c0a0d5a | |||
| db1562b830 | |||
| d35ae5e75b | |||
| 585e498235 |
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
scene: git_message
|
||||||
|
---
|
||||||
|
|
||||||
|
在此处编写规则,自定义 AI 生成提交信息的风格。
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-1492
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
|
||||||
|
local URL_MACHINE_HTTP = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/ticketmachine.lua"
|
||||||
|
local URL_UPDATE_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_machine.lua"
|
||||||
|
|
||||||
|
local function writeFile(path, content, binary)
|
||||||
|
local mode = binary and "wb" or "w"
|
||||||
|
local f = fs.open(path, mode)
|
||||||
|
if not f then return false end
|
||||||
|
f.write(content)
|
||||||
|
f.close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function atomicWrite(path, content, binary)
|
||||||
|
local tmp = path .. ".new"
|
||||||
|
if fs.exists(tmp) then fs.delete(tmp) end
|
||||||
|
if not writeFile(tmp, content, binary) then return false end
|
||||||
|
if fs.exists(path) then fs.delete(path) end
|
||||||
|
fs.move(tmp, path)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function httpGet(url)
|
||||||
|
if not http then return false, "HTTP API disabled" end
|
||||||
|
local okReq, err = pcall(function()
|
||||||
|
http.request({ url = url, method = "GET" })
|
||||||
|
end)
|
||||||
|
if not okReq then return false, tostring(err) end
|
||||||
|
|
||||||
|
while true do
|
||||||
|
local ev, p1, p2, p3 = os.pullEvent()
|
||||||
|
if ev == "http_success" and p1 == url then
|
||||||
|
local res = p2
|
||||||
|
if type(res) == "table" and type(res.readAll) == "function" then
|
||||||
|
local data = res.readAll()
|
||||||
|
res.close()
|
||||||
|
return true, data
|
||||||
|
end
|
||||||
|
return false, "invalid http response"
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1, 1)
|
||||||
|
print("Ticket Machine Installer")
|
||||||
|
print("")
|
||||||
|
print("Downloading ticket machine program...")
|
||||||
|
|
||||||
|
local ok, code = httpGet(URL_MACHINE_HTTP)
|
||||||
|
|
||||||
|
if not ok or type(code) ~= "string" or #code == 0 then
|
||||||
|
print("Download failed: " .. tostring(code or ""))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local okUpdate, updateCode = httpGet(URL_UPDATE_MACHINE)
|
||||||
|
if not okUpdate or type(updateCode) ~= "string" or #updateCode == 0 then
|
||||||
|
print("Download failed: " .. tostring(updateCode or ""))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not atomicWrite("startup", code, false) then
|
||||||
|
print("Write failed: startup")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
atomicWrite("startup.lua", code, false)
|
||||||
|
if fs.exists("ticketmachine.lua") then atomicWrite("ticketmachine.lua", code, false) end
|
||||||
|
if not atomicWrite("update_machine.lua", updateCode, false) then
|
||||||
|
print("Write failed: update_machine.lua")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("Done.")
|
||||||
|
print("Reboot the computer to start the ticket machine.")
|
||||||
+9
-26
@@ -1,6 +1,7 @@
|
|||||||
local URL_ERROR = "http://cloud.fse-media.group/d/API/TicketMachine/error.dfpwm?sign=DvQ39wQDN6Cej4KPhAkM3JyXPM466koan6w9CckPxWU=:0"
|
local URL_ERROR = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/error.dfpwm"
|
||||||
local URL_PASS = "http://cloud.fse-media.group/d/API/TicketMachine/pass.dfpwm?sign=ZJfGDYnaxQU4JrGSh4NDzKZtjq3eMjYh9KHmMSAMKZA=:0"
|
local URL_PASS = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/pass.dfpwm"
|
||||||
local URL_GATE = "http://cloud.fse-media.group/d/API/TicketMachine/gate.lua?sign=OWy1wKkKhUhpnxXKeX6fRLePSg1XcaQgWOLvQbMuHRQ=:0"
|
local URL_GATE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/gate.lua"
|
||||||
|
local URL_UPDATE_GATE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_gate.lua"
|
||||||
|
|
||||||
local CONFIG_PATH = "gate_config.json"
|
local CONFIG_PATH = "gate_config.json"
|
||||||
|
|
||||||
@@ -34,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()
|
||||||
@@ -95,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.")
|
||||||
@@ -137,6 +119,7 @@ end
|
|||||||
|
|
||||||
writeFile("startup.lua", gateCode, false)
|
writeFile("startup.lua", gateCode, false)
|
||||||
writeFile("startup", gateCode, false)
|
writeFile("startup", gateCode, false)
|
||||||
|
if not download(URL_UPDATE_GATE, "update_gate.lua", false) then return end
|
||||||
|
|
||||||
print("")
|
print("")
|
||||||
print("Done.")
|
print("Done.")
|
||||||
|
|||||||
+12
-29
@@ -1,6 +1,7 @@
|
|||||||
local URL_ERROR = "http://cloud.fse-media.group/d/API/TicketMachine/error.dfpwm?sign=DvQ39wQDN6Cej4KPhAkM3JyXPM466koan6w9CckPxWU=:0"
|
local URL_ERROR = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/error.dfpwm"
|
||||||
local URL_PASS = "http://cloud.fse-media.group/d/API/TicketMachine/pass.dfpwm?sign=ZJfGDYnaxQU4JrGSh4NDzKZtjq3eMjYh9KHmMSAMKZA=:0"
|
local URL_PASS = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/pass.dfpwm"
|
||||||
local URL_GATE = "http://cloud.fse-media.group/d/API/TicketMachine/gate.lua?sign=OWy1wKkKhUhpnxXKeX6fRLePSg1XcaQgWOLvQbMuHRQ=:0"
|
local URL_GATE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/gate.lua"
|
||||||
|
local URL_UPDATE_GATE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_gate.lua"
|
||||||
|
|
||||||
local CONFIG_PATH = "gate_config.json"
|
local CONFIG_PATH = "gate_config.json"
|
||||||
|
|
||||||
@@ -33,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()
|
||||||
@@ -98,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
|
||||||
|
|
||||||
@@ -111,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.")
|
||||||
@@ -156,6 +138,7 @@ end
|
|||||||
|
|
||||||
writeFile("startup.lua", gateCode, false)
|
writeFile("startup.lua", gateCode, false)
|
||||||
writeFile("startup", gateCode, false)
|
writeFile("startup", gateCode, false)
|
||||||
|
if not download(URL_UPDATE_GATE, "update_gate.lua", false) then return end
|
||||||
|
|
||||||
print("")
|
print("")
|
||||||
print("Done.")
|
print("Done.")
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
local URL_REFILL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/refillmachine.lua"
|
||||||
|
local URL_UPDATE_REFILL = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_refill.lua"
|
||||||
|
|
||||||
|
local function writeFile(path, content, binary)
|
||||||
|
local mode = binary and "wb" or "w"
|
||||||
|
local f = fs.open(path, mode)
|
||||||
|
if not f then return false end
|
||||||
|
f.write(content)
|
||||||
|
f.close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function atomicWrite(path, content, binary)
|
||||||
|
local tmp = path .. ".new"
|
||||||
|
if fs.exists(tmp) then fs.delete(tmp) end
|
||||||
|
if not writeFile(tmp, content, binary) then return false end
|
||||||
|
if fs.exists(path) then fs.delete(path) end
|
||||||
|
fs.move(tmp, path)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function httpGet(url)
|
||||||
|
if not http then return false, "HTTP API disabled" end
|
||||||
|
local okReq, err = pcall(function()
|
||||||
|
http.request({ url = url, method = "GET" })
|
||||||
|
end)
|
||||||
|
if not okReq then return false, tostring(err) end
|
||||||
|
|
||||||
|
while true do
|
||||||
|
local ev, p1, p2, p3 = os.pullEvent()
|
||||||
|
if ev == "http_success" and p1 == url then
|
||||||
|
local res = p2
|
||||||
|
if type(res) == "table" and type(res.readAll) == "function" then
|
||||||
|
local data = res.readAll()
|
||||||
|
res.close()
|
||||||
|
return true, data
|
||||||
|
end
|
||||||
|
return false, "invalid http response"
|
||||||
|
end
|
||||||
|
if ev == "http_failure" and p1 == url then
|
||||||
|
local failErr = p2
|
||||||
|
local res = p3
|
||||||
|
if type(p2) == "table" and type(p2.readAll) == "function" then
|
||||||
|
res = p2
|
||||||
|
failErr = 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(failErr or "http_failure")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1, 1)
|
||||||
|
print("Refill Machine Installer")
|
||||||
|
print("")
|
||||||
|
print("Downloading refill machine program...")
|
||||||
|
|
||||||
|
local ok, code = httpGet(URL_REFILL_MACHINE)
|
||||||
|
if not ok or type(code) ~= "string" or #code == 0 then
|
||||||
|
print("Download failed: " .. tostring(code or ""))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local okUpdate, updateCode = httpGet(URL_UPDATE_REFILL)
|
||||||
|
if not okUpdate or type(updateCode) ~= "string" or #updateCode == 0 then
|
||||||
|
print("Download failed: " .. tostring(updateCode or ""))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not atomicWrite("refillmachine.lua", code, false) then
|
||||||
|
print("Write failed: refillmachine.lua")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not atomicWrite("startup.lua", code, false) then
|
||||||
|
print("Write failed: startup.lua")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not atomicWrite("startup", code, false) then
|
||||||
|
print("Write failed: startup")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not atomicWrite("update_refill.lua", updateCode, false) then
|
||||||
|
print("Write failed: update_refill.lua")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("Done.")
|
||||||
|
print("refillmachine.lua has been installed as startup.")
|
||||||
|
print("Reboot the computer to start the refill machine.")
|
||||||
BIN
Binary file not shown.
+1
-1
@@ -550,7 +550,7 @@ local function refillLoop()
|
|||||||
|
|
||||||
local errMessage = okCall and tostring(newBalance or "refill_failed") or "refill_call_failed"
|
local errMessage = okCall and tostring(newBalance or "refill_failed") or "refill_call_failed"
|
||||||
drawErrorPage(errMessage)
|
drawErrorPage(errMessage)
|
||||||
sleep(2)
|
sleep(8)
|
||||||
state.page = "home"
|
state.page = "home"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
+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);
|
||||||
|
|||||||
+12
-3
@@ -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 || {}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,8 +346,16 @@ router.post('/orders/:code/consume', async (req, res) => {
|
|||||||
router.get('/ic-cards/query', async (req, res) => {
|
router.get('/ic-cards/query', async (req, res) => {
|
||||||
const q = String(req.query.q || '').trim();
|
const q = String(req.query.q || '').trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
appendReqLog(req, { category: 'public', type: 'ic_card_query_invalid', level: 'warn', detail: { q } });
|
const cards = (DataService.getIcCards() || [])
|
||||||
return res.status(400).json({ ok: false, error: 'query required' });
|
.slice()
|
||||||
|
.sort((a, b) => Number(b?.created_ts || 0) - Number(a?.created_ts || 0))
|
||||||
|
.map((card) => ({
|
||||||
|
...presentIcCard(card),
|
||||||
|
status_label: mapIcCardStatus(card.status),
|
||||||
|
card_type_label: mapIcCardType(card.card_type)
|
||||||
|
}));
|
||||||
|
appendReqLog(req, { category: 'public', type: 'ic_card_query_all', detail: { total: cards.length } });
|
||||||
|
return res.json({ ok: true, cards });
|
||||||
}
|
}
|
||||||
const normCardId = normalizeIcCardId(q);
|
const normCardId = normalizeIcCardId(q);
|
||||||
const normOrderCode = String(q || '').trim().toUpperCase();
|
const normOrderCode = String(q || '').trim().toUpperCase();
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+293
-62
@@ -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,81 @@ 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 generateNumericCode(prefix, digits)
|
||||||
|
local width = math.max(1, math.floor(tonumber(digits) or 1))
|
||||||
|
local maxValue = (10 ^ width) - 1
|
||||||
|
local num = string.format('%0' .. tostring(width) .. 'd', math.random(0, maxValue))
|
||||||
|
return tostring(prefix or 'ID'):upper() .. '-' .. num
|
||||||
|
end
|
||||||
|
|
||||||
|
local function generateCardId()
|
||||||
|
return generateNumericCode('IC', 6)
|
||||||
|
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 +657,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 +693,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 +832,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 +854,7 @@ end
|
|||||||
|
|
||||||
CFG = loadConfig() or CFG
|
CFG = loadConfig() or CFG
|
||||||
rebuildMaps()
|
rebuildMaps()
|
||||||
|
updateVersionStateFromConfig()
|
||||||
|
|
||||||
|
|
||||||
-- ###########################
|
-- ###########################
|
||||||
@@ -889,10 +1079,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 +1657,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 +1763,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 +1830,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 +1845,19 @@ local function showOrderAndAudio()
|
|||||||
local okReq, code, parsed, err = submitCardOpen(payload)
|
local okReq, code, parsed, err = submitCardOpen(payload)
|
||||||
if okReq then
|
if okReq then
|
||||||
local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {}
|
local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {}
|
||||||
state.card_id = firstString(respData.card_id, respData.id, payload.card_id)
|
local finalCard = buildFinalCardData(payload, respData)
|
||||||
state.cardBalance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance
|
local okWrite, writtenCard, writeMethod = writeICCard(finalCard, { writeOnly = true })
|
||||||
state.card_server_data = respData
|
if okWrite then
|
||||||
state.pendingBlankCardId = nil
|
state.card_id = firstString(writtenCard.card_id, finalCard.card_id)
|
||||||
confirmed = true
|
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
|
||||||
statusMsg, statusCol = 'Card ready', colors.green
|
state.card_server_data = respData
|
||||||
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
|
state.pendingBlankCardId = nil
|
||||||
|
confirmed = true
|
||||||
|
statusMsg, statusCol = 'Card ready', colors.green
|
||||||
|
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
|
||||||
|
else
|
||||||
|
statusMsg, statusCol = 'Write failed: ' .. tostring(writeMethod), colors.red
|
||||||
|
end
|
||||||
else
|
else
|
||||||
local errorMsg = 'Card API Err'
|
local errorMsg = 'Card API Err'
|
||||||
if code == 409 then
|
if code == 409 then
|
||||||
@@ -1640,20 +1875,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 +1958,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 +1974,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
|
||||||
@@ -1785,18 +2008,14 @@ local function showPrePrintCheck()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function generateTicketId()
|
local function generateTicketId()
|
||||||
local function randLetter()
|
return generateNumericCode('TK', 8)
|
||||||
return string.char(string.byte('A') + math.random(0, 25))
|
|
||||||
end
|
|
||||||
local prefix = randLetter() .. randLetter()
|
|
||||||
local num = string.format('%08d', math.random(0, 99999999))
|
|
||||||
return prefix .. '-' .. num
|
|
||||||
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 +2109,35 @@ local function showDone()
|
|||||||
start_name = startObj and unicodeEscape(startObj.name) or nil,
|
start_name = startObj and unicodeEscape(startObj.name) or nil,
|
||||||
terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil,
|
terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil,
|
||||||
start_name_en = fromNameEn,
|
start_name_en = fromNameEn,
|
||||||
terminal_name_en = toNameEn
|
terminal_name_en = toNameEn,
|
||||||
|
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
|
||||||
}
|
}
|
||||||
if ticketVendingMachine and ticketVendingMachine.issueTicket then
|
|
||||||
local apiType = (state.trainType == 'Express') and 'limited_express' or 'local'
|
local apiType = (state.trainType == 'Express') and 'limited_express' or 'local'
|
||||||
local okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
|
local localGeneratedTicketId = generateTicketId()
|
||||||
if not (okCall and okIssue and ticketId) then
|
local okIssueTicket, issuedTicketId, issueMethod = issueTicketFromPeripheral(
|
||||||
okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
|
fromNameEnArg,
|
||||||
end
|
toNameEnArg,
|
||||||
if okCall and okIssue and ticketId then
|
apiType,
|
||||||
state.ticket_id = ensureTicketIdFormat(ticketId)
|
rides,
|
||||||
issueSource = 'ticket_vending_machine'
|
cost,
|
||||||
else
|
startStationArg,
|
||||||
state.ticket_id = generateTicketId()
|
terminalStationArg,
|
||||||
end
|
fromNameCnUArg,
|
||||||
|
toNameCnUArg,
|
||||||
|
localGeneratedTicketId
|
||||||
|
)
|
||||||
|
if okIssueTicket then
|
||||||
|
state.ticket_id = issuedTicketId
|
||||||
|
issueSource = 'ticket_vending_machine'
|
||||||
else
|
else
|
||||||
state.ticket_id = generateTicketId()
|
local issueError = tostring(issueMethod or 'ticket_issue_failed')
|
||||||
|
print('Ticket issue failed: ' .. issueError)
|
||||||
|
_G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError
|
||||||
|
showAlert('Ticket issue failed')
|
||||||
|
resetTicketFlow()
|
||||||
|
state.page = 'home'
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
pcall(function()
|
pcall(function()
|
||||||
@@ -2074,7 +2306,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 +2332,4 @@ local function mainPageLoop()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask)
|
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
local URL_GATE = "http://cloud.fse-media.group/d/API/TicketMachine/gate.lua?sign=OWy1wKkKhUhpnxXKeX6fRLePSg1XcaQgWOLvQbMuHRQ=:0"
|
local URL_GATE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/gate.lua"
|
||||||
|
|
||||||
local function writeFile(path, content, binary)
|
local function writeFile(path, content, binary)
|
||||||
local mode = binary and "wb" or "w"
|
local mode = binary and "wb" or "w"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
local URL_MACHINE = "http://cloud.fse-media.group/d/API/TicketMachine/ticketmachine.lua?sign=UIiheDcpyzwKdovDZM3G-8IRZqApFgU2Kpnhe9k5ETQ=:0"
|
local URL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/ticketmachine.lua"
|
||||||
|
|
||||||
local function writeFile(path, content, binary)
|
local function writeFile(path, content, binary)
|
||||||
local mode = binary and "wb" or "w"
|
local mode = binary and "wb" or "w"
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
|
||||||
|
local URL_REFILL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/refillmachine.lua"
|
||||||
|
|
||||||
|
local function writeFile(path, content, binary)
|
||||||
|
local mode = binary and "wb" or "w"
|
||||||
|
local f = fs.open(path, mode)
|
||||||
|
if not f then return false end
|
||||||
|
f.write(content)
|
||||||
|
f.close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function atomicWrite(path, content, binary)
|
||||||
|
local tmp = path .. ".new"
|
||||||
|
if fs.exists(tmp) then fs.delete(tmp) end
|
||||||
|
if not writeFile(tmp, content, binary) then return false end
|
||||||
|
if fs.exists(path) then fs.delete(path) end
|
||||||
|
fs.move(tmp, path)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function httpGet(url)
|
||||||
|
if not http then return false, "HTTP API disabled" end
|
||||||
|
local okReq, err = pcall(function()
|
||||||
|
http.request({ url = url, method = "GET" })
|
||||||
|
end)
|
||||||
|
if not okReq then return false, tostring(err) end
|
||||||
|
|
||||||
|
while true do
|
||||||
|
local ev, p1, p2, p3 = os.pullEvent()
|
||||||
|
if ev == "http_success" and p1 == url then
|
||||||
|
local res = p2
|
||||||
|
if type(res) == "table" and type(res.readAll) == "function" then
|
||||||
|
local data = res.readAll()
|
||||||
|
res.close()
|
||||||
|
return true, data
|
||||||
|
end
|
||||||
|
return false, "invalid http response"
|
||||||
|
end
|
||||||
|
if ev == "http_failure" and p1 == url then
|
||||||
|
local failErr = p2
|
||||||
|
local res = p3
|
||||||
|
if type(p2) == "table" and type(p2.readAll) == "function" then
|
||||||
|
res = p2
|
||||||
|
failErr = 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(failErr or "http_failure")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1, 1)
|
||||||
|
print("Refill Machine Updater")
|
||||||
|
print("")
|
||||||
|
print("Downloading refill machine program...")
|
||||||
|
|
||||||
|
local ok, code = httpGet(URL_REFILL_MACHINE)
|
||||||
|
if not ok or type(code) ~= "string" or #code == 0 then
|
||||||
|
print("Download failed: " .. tostring(code or ""))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not atomicWrite("startup.lua", code, false) then
|
||||||
|
print("Write failed: startup.lua")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not atomicWrite("startup", code, false) then
|
||||||
|
print("Write failed: startup")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not atomicWrite("refillmachine.lua", code, false) then
|
||||||
|
print("Write failed: refillmachine.lua")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print("Done.")
|
||||||
|
print("refillmachine.lua has been updated and installed as startup.")
|
||||||
|
print("Reboot the computer to apply the update.")
|
||||||
+67
-58
@@ -1,65 +1,74 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>FMG</title>
|
<title>FMG</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="style.css?v=12">
|
<link rel="stylesheet" href="style.css?v=13">
|
||||||
<link rel="stylesheet" href="blog.css?v=2">
|
<link rel="stylesheet" href="blog.css?v=2">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="public-search">
|
<body class="public-search">
|
||||||
<div class="public-container">
|
<div class="public-container">
|
||||||
<header class="search-header" style="text-align: left;">
|
<header class="search-header" style="text-align: left;">
|
||||||
<div style="margin-bottom: 10px; text-align: left;">
|
<div style="margin-bottom: 10px; text-align: left;">
|
||||||
<a href="https://ticket.fse-media.group" id="homeLink" style="color: var(--primary); text-decoration: none; font-weight: 500;">
|
<a href="https://ticket.fse-media.group" id="homeLink"
|
||||||
<i class="fas fa-arrow-left"></i> 返回首页
|
style="color: var(--primary); text-decoration: none; font-weight: 500;">
|
||||||
</a>
|
<i class="fas fa-arrow-left"></i> 返回首页
|
||||||
</div>
|
</a>
|
||||||
<div style="display: flex; align-items: center; margin-bottom: 10px;">
|
</div>
|
||||||
<img src="logo.png" alt="Logo" style="height: 40px; margin-right: 12px;">
|
<div style="display: flex; align-items: center; margin-bottom: 10px;">
|
||||||
<h1 style="font-weight: 800; letter-spacing: -1px; margin: 0; text-align: left; font-size: 26px;">
|
<img src="logo.png" alt="Logo" style="height: 40px; margin-right: 12px;">
|
||||||
FMG
|
<h1 style="font-weight: 800; letter-spacing: -1px; margin: 0; text-align: left; font-size: 26px;">
|
||||||
</h1>
|
FMG
|
||||||
</div>
|
</h1>
|
||||||
</header>
|
</div>
|
||||||
<main>
|
</header>
|
||||||
<section class="tab-panel show">
|
<main>
|
||||||
<div class="portal-grid">
|
<section class="tab-panel show">
|
||||||
<a href="http://forum.fse-media.group" class="portal-card">
|
<div class="portal-grid">
|
||||||
<div class="portal-icon">
|
<a href="http://forum.fse-media.group" class="portal-card">
|
||||||
<i class="fas fa-comments"></i>
|
<div class="portal-icon">
|
||||||
</div>
|
<i class="fas fa-comments"></i>
|
||||||
<h3>论坛</h3>
|
</div>
|
||||||
<p>forum.fse-media.group</p>
|
<h3>论坛</h3>
|
||||||
</a>
|
<p>forum.fse-media.group</p>
|
||||||
<a href="http://b.igtm.ooooo.ink:11324" class="portal-card">
|
</a>
|
||||||
<div class="portal-icon">
|
<a href="http://b.igtm.ooooo.ink:11324" class="portal-card">
|
||||||
<i class="fas fa-poll-h"></i>
|
<div class="portal-icon">
|
||||||
</div>
|
<i class="fas fa-poll-h"></i>
|
||||||
<h3>问卷</h3>
|
</div>
|
||||||
<p>b.igtm.ooooo.ink</p>
|
<h3>问卷</h3>
|
||||||
</a>
|
<p>b.igtm.ooooo.ink</p>
|
||||||
</div>
|
</a>
|
||||||
<div class="card" style="margin-top: 20px;">
|
</div>
|
||||||
<div class="card-title" style="font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; color: var(--text);">
|
<div class="card" style="margin-top: 20px;">
|
||||||
<i class="fas fa-server text-primary"></i> 服务器状态</div>
|
<div class="card-title"
|
||||||
<div style="overflow-x: auto; width: 100%;">
|
style="font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; color: var(--text);">
|
||||||
<iframe id="mc-status-traintown-online-" frameborder="0" width="1000" height="500" style="max-width:100%; border-radius: 8px;" scrolling="no" src="https://motd.minebbs.com/iframe?ip=traintown.online&stype=auto&dark=true"></iframe>
|
<i class="fas fa-server text-primary"></i> 服务器状态
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div style="overflow-x: auto; width: 100%;">
|
||||||
</section>
|
<iframe id="mc-status-traintown-online-" frameborder="0" width="1000" height="500"
|
||||||
</main>
|
style="max-width:100%; border-radius: 8px;" scrolling="no"
|
||||||
<footer style="margin-top: 60px; padding: 20px 0; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; text-align: center;">
|
src="https://motd.minebbs.com/iframe?ip=traintown.online&stype=auto&dark=true"></iframe>
|
||||||
<p>© 2026 FSE Media Group. All rights reserved.</p>
|
</div>
|
||||||
</footer>
|
</div>
|
||||||
<footer class="site-footer">
|
</section>
|
||||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
|
</main>
|
||||||
<span class="version">v1.0.12</span>
|
<footer
|
||||||
</footer>
|
style="margin-top: 60px; padding: 20px 0; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; text-align: center;">
|
||||||
</div>
|
<p>© 2026 FSE Media Group. All rights reserved.</p>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
</footer>
|
||||||
<script src="blog.js?v=2"></script>
|
<footer class="site-footer">
|
||||||
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
|
||||||
|
<span class="version">v1.0.12</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
|
<script src="blog.js?v=2"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
+3
-2
@@ -6,7 +6,7 @@
|
|||||||
<title>FSE 铁路票务系统 - 首页</title>
|
<title>FSE 铁路票务系统 - 首页</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=12">
|
<link rel="stylesheet" href="/style.css?v=13">
|
||||||
</head>
|
</head>
|
||||||
<body class="public-search jr-public-page">
|
<body class="public-search jr-public-page">
|
||||||
<div class="jr-public-shell">
|
<div class="jr-public-shell">
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
<script src="/public-status.js?v=13"></script>
|
||||||
<script src="/ai-assistant.js?v=6"></script>
|
<script src="/ai-assistant.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -247,3 +247,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+56
-53
@@ -1,13 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FSE 閾佽矾绁ㄥ姟绯荤粺 - IC 鍗$鐞?/title>
|
<title>FSE 铁路票务系统 - IC 卡管理</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=12">
|
<link rel="stylesheet" href="/style.css?v=13">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="jr-admin-page jr-admin-ic-page jr-public-page">
|
<body class="jr-admin-page jr-admin-ic-page jr-public-page">
|
||||||
@@ -16,12 +16,12 @@
|
|||||||
<div class="jr-topbar-inner">
|
<div class="jr-topbar-inner">
|
||||||
<a href="/" class="jr-top-link" id="icTopLink">
|
<a href="/" class="jr-top-link" id="icTopLink">
|
||||||
<i class="fas fa-train"></i>
|
<i class="fas fa-train"></i>
|
||||||
<span>FSE 閾佽矾杩愯緭鍚庡彴绯荤粺</span>
|
<span>FSE 铁路运输后台系统</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
<div class="jr-top-status is-checking" data-server-status-root>
|
||||||
<span class="jr-top-status-label">鏈嶅姟鍣ㄧ姸鎬?/span>
|
<span class="jr-top-status-label">服务器状态</span>
|
||||||
<span class="jr-top-status-dot"></span>
|
<span class="jr-top-status-dot"></span>
|
||||||
<span class="jr-top-status-value" data-server-status-value>妫€娴嬩腑</span>
|
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -30,16 +30,16 @@
|
|||||||
<a href="/" class="jr-brand" id="icBrandLink">
|
<a href="/" class="jr-brand" id="icBrandLink">
|
||||||
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
||||||
<div class="jr-brand-copy">
|
<div class="jr-brand-copy">
|
||||||
<strong>FSE 閾佽矾杩愯緭</strong>
|
<strong>FarSight-T.N.E铁路运输</strong>
|
||||||
<span>IC 鍗$鐞嗗悗鍙?/span>
|
<span>IC 卡管理后台</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<nav class="jr-nav" aria-label="绔欑偣瀵艰埅">
|
<nav class="jr-nav" aria-label="站点导航">
|
||||||
<a href="https://ticket.fse-media.group/home.html" data-link="home">棣栭〉</a>
|
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
|
||||||
<a href="https://ticket.fse-media.group/order" data-link="order">绾夸笂棰勫畾</a>
|
<a href="https://ticket.fse-media.group/order" data-link="order">线上预定</a>
|
||||||
<a href="https://ticket.fse-media.group/search" data-link="search">杞︾エ鏌ヨ</a>
|
<a href="https://ticket.fse-media.group/search" data-link="search">车票查询</a>
|
||||||
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 鍗℃煡璇?/a>
|
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
|
||||||
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">绾夸笂璐崱</a>
|
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,22 +48,26 @@
|
|||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="jr-admin-sidebar-head">
|
<div class="jr-admin-sidebar-head">
|
||||||
<span class="jr-kicker">IC CARD CONSOLE</span>
|
<span class="jr-kicker">IC CARD CONSOLE</span>
|
||||||
<div class="brand">FSE 閾佽矾绁ㄥ姟绯荤粺鎺у埗鍙?/div>
|
<div class="brand">FSE 铁路票务系统控制台</div>
|
||||||
<p class="jr-admin-sidebar-copy">缁熶竴绠$悊 IC 鍗″彂琛屻€佸厖鍊笺€佹寔鍗′汉淇℃伅鍜屽巻鍙叉搷浣滆褰曘€?/p>
|
<p class="jr-admin-sidebar-copy">统一管理 IC 卡发卡、充值、持卡人信息以及历史操作记录。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/" class="nav-item" style="text-decoration:none;">
|
<a href="/" class="nav-item" style="text-decoration:none;">
|
||||||
<span class="nav-icon"><i class="fas fa-home"></i></span> 杩斿洖棣栭〉
|
<span class="nav-icon"><i class="fas fa-home"></i></span>
|
||||||
|
返回首页
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin" class="nav-item" style="text-decoration:none;">
|
<a href="/admin" class="nav-item" style="text-decoration:none;">
|
||||||
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> 涓绘帶鍒跺彴
|
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span>
|
||||||
|
主控制台
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/ic-card" class="nav-item active" style="text-decoration:none;">
|
<a href="/admin/ic-card" class="nav-item active" style="text-decoration:none;">
|
||||||
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC 鍗$鐞?</a>
|
<span class="nav-icon"><i class="fas fa-credit-card"></i></span>
|
||||||
|
IC 卡管理
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-footer jr-admin-sidebar-status">
|
<div class="sidebar-footer jr-admin-sidebar-status">
|
||||||
<div>IC Card Console</div>
|
<div>IC Card Console</div>
|
||||||
<div id="serverStatusText" style="margin-top:6px;">姝e湪妫€娴嬫湇鍔$姸鎬?..</div>
|
<div id="serverStatusText" style="margin-top:6px;">正在检测服务状态...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
@@ -72,42 +76,42 @@
|
|||||||
<div class="flex" style="gap: 12px;">
|
<div class="flex" style="gap: 12px;">
|
||||||
<div>
|
<div>
|
||||||
<span class="jr-kicker">JR STYLE ADMIN</span>
|
<span class="jr-kicker">JR STYLE ADMIN</span>
|
||||||
<h3 style="margin: 0;">IC 鍗$鐞?/h3>
|
<h3 style="margin: 0;">IC 卡管理</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<button id="refreshBtn"><i class="fas fa-sync-alt"></i> 鍒锋柊</button>
|
<button id="refreshBtn"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<section class="jr-page-intro jr-admin-intro">
|
<section class="jr-page-intro jr-admin-intro">
|
||||||
<span class="jr-kicker">IC MANAGEMENT</span>
|
<span class="jr-kicker">IC MANAGEMENT</span>
|
||||||
<h1>IC 鍗″彂琛屼笌鐘舵€佺鐞?/h1>
|
<h1>IC 卡发行与状态管理</h1>
|
||||||
<p>寤剁画鍏紑椤电殑鐧藉簳闂ㄦ埛鍐欐硶锛岃鍙戝崱銆佸偍鍊煎拰浜嬩欢璁板綍鍦ㄥ悓涓€鍧楃鐞嗗伐浣滃尯涓繚鎸佹竻鏅扮殑闃呰鑺傚銆?/p>
|
<p>延续公共页面的白底门户风格,让发卡、储值与事件记录在同一块工作区域中保持清晰易读。</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="jr-home-alert jr-admin-alert">
|
<section class="jr-home-alert jr-admin-alert">
|
||||||
<div class="jr-alert-title">
|
<div class="jr-alert-title">
|
||||||
<i class="fas fa-circle-info"></i>
|
<i class="fas fa-circle-info"></i>
|
||||||
<span>涓氬姟鑼冨洿</span>
|
<span>业务范围</span>
|
||||||
</div>
|
</div>
|
||||||
<p>褰撳墠椤甸潰鐢ㄤ簬澶勭悊 IC 鍗″垱寤恒€佷綑棰濈鐞嗐€佹寔鍗′汉璧勬枡鍜屼簨浠舵祦鏌ョ湅锛岄€傚悎浣滀负鍚庡彴鍗″姟绠$悊鐨勫崟鐙叆鍙c€?/p>
|
<p>当前页面用于处理 IC 卡创建、余额管理、持卡人资料和事件流查看,适合作为后台卡务管理的单独入口。</p>
|
||||||
</section>
|
</section>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">IC 鍗℃€绘暟</div>
|
<div class="stat-label">IC 卡总数</div>
|
||||||
<div class="stat-value" id="statTotal">0</div>
|
<div class="stat-value" id="statTotal">0</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">寰呴鍗?/div>
|
<div class="stat-label">待领卡</div>
|
||||||
<div class="stat-value" id="statPending">0</div>
|
<div class="stat-value" id="statPending">0</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">姝e父鍚敤</div>
|
<div class="stat-label">正常启用</div>
|
||||||
<div class="stat-value" id="statActive">0</div>
|
<div class="stat-value" id="statActive">0</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">鍌ㄥ€兼€婚</div>
|
<div class="stat-label">储值总额</div>
|
||||||
<div class="stat-value" id="statBalance">0</div>
|
<div class="stat-value" id="statBalance">0</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,28 +119,25 @@
|
|||||||
<div class="management-sidebar">
|
<div class="management-sidebar">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex between mb-4">
|
<div class="flex between mb-4">
|
||||||
<h4>蹇€熷缓鍗?/h4>
|
<h4>快速建卡</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="ic-form-grid">
|
<div class="ic-form-grid">
|
||||||
<input id="createHolder" placeholder="鎸佸崱浜哄鍚嶏紙浠呰嫳鏂囦笌绗﹀彿锛?>
|
<input id="createHolder" placeholder="持卡人姓名,仅支持英文与常用符号">
|
||||||
<input id="createBalance" type="number" min="0" step="1" value="50"
|
<input id="createBalance" type="number" min="0" step="1" value="50" placeholder="初始余额">
|
||||||
placeholder="鍒濆浣欓">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted" style="margin-top:12px;">鍚庡彴寤哄崱涔熺粺涓€涓?IC 鍌ㄥ€煎崱锛屾寔鍗′汉濮撳悕浠呮敮鎸佽嫳鏂囦笌甯哥敤绗﹀彿銆? </div>
|
<div class="text-muted" style="margin-top:12px;">后台建卡统一创建为 IC 储值卡,持卡人姓名仅支持英文与常用符号。</div>
|
||||||
<div class="toolbar" style="margin-top: 14px;">
|
<div class="toolbar" style="margin-top: 14px;">
|
||||||
<button id="createBtn" class="btn primary"><i class="fas fa-plus"></i> 鍒涘缓 IC
|
<button id="createBtn" class="btn primary"><i class="fas fa-plus"></i> 创建 IC 卡</button>
|
||||||
鍗?/button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card"
|
<div class="card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
|
||||||
style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
|
|
||||||
<div class="flex between mb-4">
|
<div class="flex between mb-4">
|
||||||
<h4>鍗$墖鍒楄〃</h4>
|
<h4>卡片列表</h4>
|
||||||
<span class="badge" id="listCountBadge">0</span>
|
<span class="badge" id="listCountBadge">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex mb-4" style="flex-wrap:wrap;">
|
<div class="flex mb-4" style="flex-wrap:wrap;">
|
||||||
<input id="searchInput" placeholder="鎼滅储鍗″彿 / 璁㈠崟鍙?/ 濮撳悕" style="flex:1;">
|
<input id="searchInput" placeholder="搜索卡号 / 订单号 / 姓名" style="flex:1;">
|
||||||
</div>
|
</div>
|
||||||
<div id="cardList" class="list-lines" style="flex:1; overflow-y:auto;"></div>
|
<div id="cardList" class="list-lines" style="flex:1; overflow-y:auto;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,33 +146,31 @@
|
|||||||
<div class="management-main">
|
<div class="management-main">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex between mb-4">
|
<div class="flex between mb-4">
|
||||||
<h4>鍗$墖璇︽儏</h4>
|
<h4>卡片详情</h4>
|
||||||
<div class="flex" style="gap:8px;">
|
<div class="flex" style="gap:8px;">
|
||||||
<button id="topupBtn" class="btn"><i class="fas fa-wallet"></i> 鍏呭€?/button>
|
<button id="topupBtn" class="btn"><i class="fas fa-wallet"></i> 充值</button>
|
||||||
<button id="saveBtn" class="btn primary"><i class="fas fa-save"></i>
|
<button id="saveBtn" class="btn primary"><i class="fas fa-save"></i> 保存</button>
|
||||||
淇濆瓨</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="detailPanel" class="empty-state">
|
<div id="detailPanel" class="empty-state">
|
||||||
<i class="fas fa-credit-card" style="font-size:48px; opacity:0.3;"></i>
|
<i class="fas fa-credit-card" style="font-size:48px; opacity:0.3;"></i>
|
||||||
<p>浠庡乏渚ч€夋嫨涓€寮?IC 鍗′互鏌ョ湅璇︽儏銆?/p>
|
<p>从左侧选择一张 IC 卡以查看详情。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
<div class="card" style="margin-bottom:0;">
|
||||||
<div class="flex between mb-4">
|
<div class="flex between mb-4">
|
||||||
<h4>鎿嶄綔璁板綍</h4>
|
<h4>操作记录</h4>
|
||||||
</div>
|
</div>
|
||||||
<div id="eventList" class="timeline">
|
<div id="eventList" class="timeline">
|
||||||
<div class="loading">閫夋嫨鍗$墖鍚庢樉绀轰簨浠舵祦銆?/div>
|
<div class="loading">选择卡片后显示事件流。</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<a href="https://beian.miit.gov.cn/" target="_blank"
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
|
||||||
rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
|
|
||||||
<span class="version">v1.0.12</span>
|
<span class="version">v1.0.12</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +179,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
<script src="/public-status.js?v=13"></script>
|
||||||
<script src="/ic-card-admin.js?v=2"></script>
|
<script src="/ic-card-admin.js?v=2"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -194,8 +193,11 @@
|
|||||||
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
|
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('icTopLink').href = links.home;
|
const topLink = document.getElementById('icTopLink');
|
||||||
document.getElementById('icBrandLink').href = links.home;
|
const brandLink = document.getElementById('icBrandLink');
|
||||||
|
|
||||||
|
if (topLink) topLink.href = links.home;
|
||||||
|
if (brandLink) brandLink.href = links.home;
|
||||||
|
|
||||||
document.querySelectorAll('[data-link]').forEach((el) => {
|
document.querySelectorAll('[data-link]').forEach((el) => {
|
||||||
const key = el.getAttribute('data-link');
|
const key = el.getAttribute('data-link');
|
||||||
@@ -206,3 +208,4 @@
|
|||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>IC 卡详情</title>
|
<title>IC 卡详情</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=12">
|
<link rel="stylesheet" href="/style.css?v=13">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="public-search jr-public-page">
|
<body class="public-search jr-public-page">
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
|
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
|
||||||
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
||||||
<div class="jr-brand-copy">
|
<div class="jr-brand-copy">
|
||||||
<strong>FSE Railway</strong>
|
<strong>FarSight-T.N.E铁路运输</strong>
|
||||||
<span>IC Card Detail</span>
|
<span>IC卡 详情</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<nav class="jr-nav" aria-label="站点导航">
|
<nav class="jr-nav" aria-label="站点导航">
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/ic-card-detail.js?v=2"></script>
|
<script src="/ic-card-detail.js?v=2"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
<script src="/public-status.js?v=13"></script>
|
||||||
<script>document.addEventListener('DOMContentLoaded', () => {
|
<script>document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -110,3 +110,4 @@
|
|||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
+58
-39
@@ -1,13 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>IC 鍗$嚎涓婅喘鍗?/title>
|
<title>IC 卡线上购卡</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=12">
|
<link rel="stylesheet" href="/style.css?v=13">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="public-search jr-public-page">
|
<body class="public-search jr-public-page">
|
||||||
@@ -16,12 +16,12 @@
|
|||||||
<div class="jr-topbar-inner">
|
<div class="jr-topbar-inner">
|
||||||
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
|
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
|
||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
<span>杩斿洖棣栭〉</span>
|
<span>返回首页</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
<div class="jr-top-status is-checking" data-server-status-root>
|
||||||
<span class="jr-top-status-label">鏈嶅姟鍣ㄧ姸鎬?/span>
|
<span class="jr-top-status-label">服务器状态</span>
|
||||||
<span class="jr-top-status-dot"></span>
|
<span class="jr-top-status-dot"></span>
|
||||||
<span class="jr-top-status-value" data-server-status-value>妫€娴嬩腑</span>
|
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -30,102 +30,121 @@
|
|||||||
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
|
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
|
||||||
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
||||||
<div class="jr-brand-copy">
|
<div class="jr-brand-copy">
|
||||||
<strong>FSE Railway</strong>
|
<strong>FarSight-T.N.E铁路运输</strong>
|
||||||
<span>IC Card Online Order</span>
|
<span>IC卡 线上预定</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<nav class="jr-nav" aria-label="绔欑偣瀵艰埅">
|
<nav class="jr-nav" aria-label="站点导航">
|
||||||
<a href="https://ticket.fse-media.group/home.html" data-link="home">棣栭〉</a>
|
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
|
||||||
<a href="https://ticket.fse-media.group/order" data-link="order">绾夸笂棰勫畾</a>
|
<a href="https://ticket.fse-media.group/order" data-link="order">线上预定</a>
|
||||||
<a href="https://ticket.fse-media.group/search" data-link="search">杞︾エ鏌ヨ</a>
|
<a href="https://ticket.fse-media.group/search" data-link="search">车票查询</a>
|
||||||
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 鍗℃煡璇?/a>
|
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
|
||||||
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order"
|
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order" class="is-active">线上购卡</a>
|
||||||
class="is-active">绾夸笂璐崱</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main class="jr-public-main">
|
<main class="jr-public-main">
|
||||||
<section class="jr-page-intro">
|
<section class="jr-page-intro">
|
||||||
<span class="jr-kicker">IC CARD ORDER</span>
|
<span class="jr-kicker">IC CARD ORDER</span>
|
||||||
<h1>鍦ㄧ嚎璐拱 IC 鍗″苟鐢熸垚棰嗗崱鍑瘉</h1>
|
<h1>在线购买 IC 卡并生成领卡凭证</h1>
|
||||||
<p>鎻愪氦鎸佸崱浜哄鍚嶅苟閫夋嫨棣栨鍏呭€奸噾棰濆悗锛岀郴缁熶細鍗虫椂鐢熸垚鍗″彿鍜?5 浣嶅嚟璇佺爜锛屾梾瀹㈠彲鍑嚟璇佺爜鍒扮珯鍐呭姙鐞嗛鍗°€?/p>
|
<p>提交持卡人姓名并选择首次充值金额后,系统会即时生成卡号和 5 位凭证码,旅客可凭凭证码到站内办理领卡。</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="jr-home-alert">
|
<section class="jr-home-alert">
|
||||||
<div class="jr-alert-title">
|
<div class="jr-alert-title">
|
||||||
<i class="fas fa-circle-info"></i>
|
<i class="fas fa-circle-info"></i>
|
||||||
<span>璐崱鎻愰啋</span>
|
<span>购卡提醒</span>
|
||||||
</div>
|
</div>
|
||||||
<p>绾夸笂璐崱鍒涘缓鍚庨粯璁ょ姸鎬佷负鈥滃緟棰嗗崱鈥濓紱鎸佸崱浜哄鍚嶄粎鏀寔鑻辨枃涓庡父鐢ㄧ鍙枫€傚闇€琛ユ煡鍑瘉鎴栧崱鐗囩姸鎬侊紝鍙墠寰€ IC 鍗℃煡璇㈤〉闈㈣緭鍏ュ崱鍙锋垨鍑瘉鐮佹绱€?/p>
|
<p>线上购卡创建后默认状态为“待领卡”;持卡人姓名仅支持英文与常用符号。如需补查凭证或卡片状态,可前往 IC 卡查询页输入卡号或凭证码检索。</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="jr-grid-two">
|
<section class="jr-grid-two">
|
||||||
<article class="jr-panel-card">
|
<article class="jr-panel-card">
|
||||||
<div class="jr-panel-headline">
|
<div class="jr-panel-headline">
|
||||||
<h2>棣栨鍏呭€?/h2>
|
<h2>首次充值</h2>
|
||||||
<span class="jr-panel-note">First Top-up</span>
|
<span class="jr-panel-note">First Top-up</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="rechargeOptionList" class="jr-card-plan-grid">
|
<div id="rechargeOptionList" class="jr-card-plan-grid">
|
||||||
<div class="jr-center-empty">
|
<div class="jr-center-empty">
|
||||||
<p>姝e湪鍔犺浇鍏呭€奸厤缃?..</p>
|
<p>正在加载充值配置...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="customRechargeBox" class="jr-card-plan-custom-box">
|
<div id="customRechargeBox" class="jr-card-plan-custom-box">
|
||||||
<input id="customInitialBalance" class="jr-search-input" type="number" min="1" step="1"
|
<input id="customInitialBalance" class="jr-search-input" type="number" min="1" step="1"
|
||||||
placeholder="鑷畾涔夐娆″厖鍊奸噾棰濓紙閫夋嫨鈥滆嚜瀹氫箟鈥濆悗鍚敤锛? disabled>
|
placeholder="自定义首次充值金额,选择“自定义”后启用" disabled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="jr-panel-headline" style="margin-top:24px;">
|
<div class="jr-panel-headline" style="margin-top:24px;">
|
||||||
<h3>鎸佸崱浜轰俊鎭?/h3>
|
<h3>持卡人信息</h3>
|
||||||
<span class="jr-panel-note">Order Form</span>
|
<span class="jr-panel-note">Order Form</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ic-form-grid">
|
<div class="ic-form-grid">
|
||||||
<input id="holderName" class="jr-search-input" type="text" maxlength="24"
|
<input id="holderName" class="jr-search-input" type="text" maxlength="24"
|
||||||
placeholder="鎸佸崱浜哄鍚嶏紙浠呰嫳鏂囦笌绗﹀彿锛?>
|
placeholder="持卡人姓名,仅支持英文与常用符号">
|
||||||
</div>
|
</div>
|
||||||
<p id="holderNameHint" class="text-muted" style="margin-top:12px;">浠呮敮鎸佽嫳鏂囦笌甯哥敤绗﹀彿锛屼緥濡?`Alex
|
<p id="holderNameHint" class="text-muted" style="margin-top:12px;">仅支持英文与常用符号,例如 `Alex Smith`、`A.Brown`、`Chris-O'Neil`。</p>
|
||||||
Smith`銆乣A.Brown`銆乣Chris-O'Neil`銆?/p>
|
|
||||||
<div class="jr-action-row">
|
<div class="jr-action-row">
|
||||||
<button id="submitOrderBtn" class="btn primary jr-search-button"><i
|
<button id="submitOrderBtn" class="btn primary jr-search-button">
|
||||||
class="fas fa-credit-card"></i> 鎻愪氦璐崱</button>
|
<i class="fas fa-credit-card"></i>
|
||||||
|
提交购卡
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<div>
|
<div>
|
||||||
<article class="jr-panel-card" style="margin-bottom:20px;">
|
<article class="jr-panel-card" style="margin-bottom:20px;">
|
||||||
<div class="jr-panel-headline">
|
<div class="jr-panel-headline">
|
||||||
<h2>璐圭敤棰勪及</h2>
|
<h2>费用预估</h2>
|
||||||
<span class="jr-panel-note">Estimate</span>
|
<span class="jr-panel-note">Estimate</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="estimateBox" class="ic-inline-meta">
|
<div id="estimateBox" class="ic-inline-meta">
|
||||||
<div class="jr-center-empty">
|
<div class="jr-center-empty">
|
||||||
<p>璇烽€夋嫨棣栨鍏呭€奸噾棰濆悗鏌ョ湅璐圭敤鏋勬垚銆?/p>
|
<p>请选择首次充值金额后查看费用构成。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="jr-panel-card">
|
<article class="jr-panel-card">
|
||||||
<div class="jr-panel-headline">
|
<div class="jr-panel-headline">
|
||||||
<h2>璐崱缁撴灉</h2>
|
<h2>购卡结果</h2>
|
||||||
<span class="jr-panel-note">Card Result</span>
|
<span class="jr-panel-note">Card Result</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="orderResultBox" class="jr-center-empty">
|
<div id="orderResultBox" class="jr-center-empty">
|
||||||
<p>鎻愪氦鍚庡皢鍦ㄦ鏄剧ず鍗″彿銆佸嚟璇佺爜鍜岄鍗℃彁绀恒€?/p>
|
<p>提交后将在此显示卡号、凭证码和领卡提示。</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<footer class="site-footer jr-footer-space">
|
<footer class="site-footer jr-footer-space">
|
||||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
|
||||||
<span class="version">v1.0.12</span>
|
<span class="version">v1.0.12</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/ic-card-order.js?v=2"></script>
|
<script src="/ic-card-order.js?v=2"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
<script src="/public-status.js?v=13"></script>
|
||||||
<script src="/ai-assistant.js?v=6"></script>
|
<script src="/ai-assistant.js?v=6"></script>
|
||||||
<script>document.addEventListener('DOMContentLoaded', () => {
|
<script>
|
||||||
const isDomain = location.hostname.includes('fse-media.group'); const links = {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html', order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html', search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html', 'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html', 'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
|
const isDomain = location.hostname.includes('fse-media.group');
|
||||||
}; const homeLink = document.getElementById('homeLink'); const brandLink = document.getElementById('brandLink'); if (homeLink) homeLink.href = links.home; if (brandLink) brandLink.href = links.home; document.querySelectorAll('[data-link]').forEach((el) => { const key = el.getAttribute('data-link'); if (links[key]) el.href = links[key]; });
|
const links = {
|
||||||
});</script>
|
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html',
|
||||||
|
order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html',
|
||||||
|
search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html',
|
||||||
|
'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html',
|
||||||
|
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
|
||||||
|
};
|
||||||
|
|
||||||
|
const homeLink = document.getElementById('homeLink');
|
||||||
|
const brandLink = document.getElementById('brandLink');
|
||||||
|
|
||||||
|
if (homeLink) homeLink.href = links.home;
|
||||||
|
if (brandLink) brandLink.href = links.home;
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-link]').forEach((el) => {
|
||||||
|
const key = el.getAttribute('data-link');
|
||||||
|
if (links[key]) el.href = links[key];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
+137
-43
@@ -1,13 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>IC 鍗℃煡璇?/title>
|
<title>IC 卡查询</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=12">
|
<link rel="stylesheet" href="/style.css?v=14">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="public-search jr-public-page">
|
<body class="public-search jr-public-page">
|
||||||
@@ -16,12 +16,12 @@
|
|||||||
<div class="jr-topbar-inner">
|
<div class="jr-topbar-inner">
|
||||||
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
|
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
|
||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
<span>杩斿洖棣栭〉</span>
|
<span>返回首页</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
<div class="jr-top-status is-checking" data-server-status-root>
|
||||||
<span class="jr-top-status-label">鏈嶅姟鍣ㄧ姸鎬?/span>
|
<span class="jr-top-status-label">服务器状态</span>
|
||||||
<span class="jr-top-status-dot"></span>
|
<span class="jr-top-status-dot"></span>
|
||||||
<span class="jr-top-status-value" data-server-status-value>妫€娴嬩腑</span>
|
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -30,74 +30,168 @@
|
|||||||
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
|
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
|
||||||
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
||||||
<div class="jr-brand-copy">
|
<div class="jr-brand-copy">
|
||||||
<strong>FSE閾佽矾绁ㄥ姟绯荤粺</strong>
|
<strong>FarSight-T.N.E铁路运输</strong>
|
||||||
<span>IC 鍗℃煡璇㈡湇鍔?/span>
|
<span>IC卡 查询</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<nav class="jr-nav" aria-label="绔欑偣瀵艰埅">
|
<nav class="jr-nav" aria-label="站点导航">
|
||||||
<a href="https://ticket.fse-media.group/home.html" data-link="home">棣栭〉</a>
|
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
|
||||||
<a href="https://ticket.fse-media.group/order" data-link="order">绾夸笂棰勫畾</a>
|
<a href="https://ticket.fse-media.group/order" data-link="order">线上预定</a>
|
||||||
<a href="https://ticket.fse-media.group/search" data-link="search">杞︾エ鏌ヨ</a>
|
<a href="https://ticket.fse-media.group/search" data-link="search">车票查询</a>
|
||||||
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search" class="is-active">IC
|
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search" class="is-active">IC 卡查询</a>
|
||||||
鍗℃煡璇?/a>
|
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
|
||||||
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">绾夸笂璐崱</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main class="jr-public-main">
|
<main class="jr-public-main">
|
||||||
<section class="jr-page-intro">
|
<section class="jr-page-intro">
|
||||||
<span class="jr-kicker">IC CARD SEARCH</span>
|
<span class="jr-kicker">IC CARD SEARCH</span>
|
||||||
<h1>鎸夊崱鍙锋垨鍑瘉鐮佹煡璇?IC 鍗$姸鎬?/h1>
|
<h1>按卡号或凭证码查询 IC 卡状态</h1>
|
||||||
<p>鍙煡璇?IC 鍗$殑褰撳墠鐘舵€併€佷綑棰濆拰鏈€杩戞搷浣滆褰曘€傝緭鍏ョ嚎涓婅喘鍗$敓鎴愮殑鍑瘉鐮佷篃鍙弽鏌ュ搴斿崱鐗囥€?/p>
|
<p>支持检索 IC 卡当前状态、余额和最近操作记录;输入线上购卡生成的凭证码,也能反查对应卡片。</p>
|
||||||
|
</section>
|
||||||
|
<section class="jr-query-overview jr-grid-three" aria-label="IC 卡查询摘要">
|
||||||
|
<article class="jr-query-stat">
|
||||||
|
<span class="jr-query-stat-label">检索方式</span>
|
||||||
|
<strong>卡号 / 凭证码</strong>
|
||||||
|
<p>支持凭证码反查对应卡片,也支持直接输入卡号查看当前状态与余额。</p>
|
||||||
|
</article>
|
||||||
|
<article class="jr-query-stat">
|
||||||
|
<span class="jr-query-stat-label">结果浏览</span>
|
||||||
|
<strong>列表与详情并排</strong>
|
||||||
|
<p>左侧浏览卡片列表,右侧查看卡片详情、状态提示和最近操作记录。</p>
|
||||||
|
</article>
|
||||||
|
<article class="jr-query-stat">
|
||||||
|
<span class="jr-query-stat-label">移动端</span>
|
||||||
|
<strong>触达区更大</strong>
|
||||||
|
<p>手机端自动切换为单列阅读,卡片点击区域与按钮尺寸都更适合触屏操作。</p>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<section class="jr-panel-card" style="margin-bottom:24px;">
|
<section class="jr-panel-card" style="margin-bottom:24px;">
|
||||||
<div class="jr-panel-headline">
|
<div class="jr-panel-headline">
|
||||||
<h2>妫€绱㈡潯浠?/h2>
|
<h2>检索条件</h2>
|
||||||
<span class="jr-panel-note">Card ID / Voucher Code</span>
|
<span class="jr-panel-note">Card ID / Voucher Code</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-search-form">
|
<div class="jr-search-form">
|
||||||
<input id="queryInput" class="jr-search-input" type="text"
|
<input id="queryInput" class="jr-search-input" type="text"
|
||||||
placeholder="杈撳叆鍗″彿鎴栧嚟璇佺爜锛屽 IC-348215 / M1SKP" />
|
placeholder="输入卡号或凭证码,例如 IC-348215 / M1SKP" />
|
||||||
<button id="queryBtn" class="btn primary jr-search-button"><i class="fas fa-search"></i> 鏌ヨ IC
|
<button id="queryBtn" class="btn primary jr-search-button">
|
||||||
鍗?/button>
|
<i class="fas fa-search"></i>
|
||||||
|
查询 IC 卡
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="jr-search-helper">留空可浏览全部 IC 卡;输入卡号或凭证码后,可直接定位到对应卡片详情。</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="jr-grid-two">
|
<section class="jr-search-results">
|
||||||
<article class="jr-panel-card">
|
<article class="jr-panel-card">
|
||||||
<div class="jr-panel-headline">
|
<div class="jr-panel-headline">
|
||||||
<h3>鍗$墖姒傝</h3>
|
<h3>结果列表</h3>
|
||||||
<span class="jr-panel-note">Card Overview</span>
|
<span class="jr-panel-note">Card Results</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="summaryBox" class="jr-center-empty">
|
<div id="summaryBox" class="jr-scroll-box">
|
||||||
<p>璇疯緭鍏ュ崱鍙锋垨鍑瘉鐮佸紑濮嬫煡璇€?/p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="jr-panel-card">
|
|
||||||
<div class="jr-panel-headline">
|
|
||||||
<h3>浜嬩欢璁板綍</h3>
|
|
||||||
<span class="jr-panel-note">Recent Events</span>
|
|
||||||
</div>
|
|
||||||
<div id="eventBox" class="jr-history-list">
|
|
||||||
<div class="jr-center-empty" style="min-height:180px;">
|
<div class="jr-center-empty" style="min-height:180px;">
|
||||||
<p>鏌ヨ鎴愬姛鍚庢樉绀哄缓鍗°€佽喘鍗°€佸厖鍊肩瓑鎿嶄綔璁板綍銆?/p>
|
<p>请输入卡号或凭证码开始查询。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
<section class="jr-detail-stack">
|
||||||
|
<article class="jr-panel-card">
|
||||||
|
<div class="jr-panel-headline">
|
||||||
|
<h3>卡片详情</h3>
|
||||||
|
<span class="jr-panel-note">Card Overview</span>
|
||||||
|
</div>
|
||||||
|
<div id="detailBox">
|
||||||
|
<div class="jr-center-empty" style="min-height:180px;">
|
||||||
|
<p>从左侧选择一张 IC 卡以查看详情。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="jr-panel-card">
|
||||||
|
<div class="jr-panel-headline">
|
||||||
|
<h3>事件记录</h3>
|
||||||
|
<span class="jr-panel-note">Recent Events</span>
|
||||||
|
</div>
|
||||||
|
<div id="eventBox" class="jr-history-list">
|
||||||
|
<div class="jr-center-empty" style="min-height:180px;">
|
||||||
|
<p>查询成功后会在这里显示建卡、购卡、充值等操作记录。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<div class="jr-grid-two">
|
||||||
|
<article class="jr-panel-card jr-guide-card">
|
||||||
|
<div class="jr-panel-headline">
|
||||||
|
<h3>状态说明</h3>
|
||||||
|
<span class="jr-panel-note">Card Status</span>
|
||||||
|
</div>
|
||||||
|
<div class="jr-guide-list">
|
||||||
|
<div class="jr-guide-item">
|
||||||
|
<strong>正常</strong>
|
||||||
|
<span>卡片已启用,可在检票设备直接刷卡进出站。</span>
|
||||||
|
</div>
|
||||||
|
<div class="jr-guide-item">
|
||||||
|
<strong>待领卡</strong>
|
||||||
|
<span>请持购卡凭证码前往站内售票机完成领卡后再使用。</span>
|
||||||
|
</div>
|
||||||
|
<div class="jr-guide-item">
|
||||||
|
<strong>不可用</strong>
|
||||||
|
<span>卡片已停用、挂失或退款,建议联系站务进行处理。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="jr-panel-card jr-guide-card">
|
||||||
|
<div class="jr-panel-headline">
|
||||||
|
<h3>查询提示</h3>
|
||||||
|
<span class="jr-panel-note">Search Guide</span>
|
||||||
|
</div>
|
||||||
|
<div class="jr-guide-list">
|
||||||
|
<div class="jr-guide-item">
|
||||||
|
<strong>留空查询</strong>
|
||||||
|
<span>不输入关键字时,会按建卡时间倒序展示全部 IC 卡记录。</span>
|
||||||
|
</div>
|
||||||
|
<div class="jr-guide-item">
|
||||||
|
<strong>凭证反查</strong>
|
||||||
|
<span>购卡后若未领卡,可直接使用凭证码快速定位对应卡片。</span>
|
||||||
|
</div>
|
||||||
|
<div class="jr-guide-item">
|
||||||
|
<strong>手机查看</strong>
|
||||||
|
<span>移动端会把结果列表、详情和事件记录按顺序折叠为单列阅读。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<footer class="site-footer jr-footer-space">
|
<footer class="site-footer jr-footer-space">
|
||||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
|
||||||
<span class="version">v1.0.12</span>
|
<span class="version">v1.0.12</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/ic-card-search.js?v=2"></script>
|
<script src="/ic-card-search.js?v=3"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
<script src="/public-status.js?v=13"></script>
|
||||||
<script>document.addEventListener('DOMContentLoaded', () => {
|
<script>
|
||||||
const isDomain = location.hostname.includes('fse-media.group'); const links = {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html', order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html', search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html', 'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html', 'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
|
const isDomain = location.hostname.includes('fse-media.group');
|
||||||
}; const homeLink = document.getElementById('homeLink'); const brandLink = document.getElementById('brandLink'); if (homeLink) homeLink.href = links.home; if (brandLink) brandLink.href = links.home; document.querySelectorAll('[data-link]').forEach((el) => { const key = el.getAttribute('data-link'); if (links[key]) el.href = links[key]; });
|
const links = {
|
||||||
});</script>
|
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html',
|
||||||
|
order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html',
|
||||||
|
search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html',
|
||||||
|
'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html',
|
||||||
|
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
|
||||||
|
};
|
||||||
|
|
||||||
|
const homeLink = document.getElementById('homeLink');
|
||||||
|
const brandLink = document.getElementById('brandLink');
|
||||||
|
|
||||||
|
if (homeLink) homeLink.href = links.home;
|
||||||
|
if (brandLink) brandLink.href = links.home;
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-link]').forEach((el) => {
|
||||||
|
const key = el.getAttribute('data-link');
|
||||||
|
if (links[key]) el.href = links[key];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+146
-22
@@ -3,7 +3,12 @@
|
|||||||
const inputEl = $('#queryInput');
|
const inputEl = $('#queryInput');
|
||||||
const queryBtn = $('#queryBtn');
|
const queryBtn = $('#queryBtn');
|
||||||
const summaryBoxEl = $('#summaryBox');
|
const summaryBoxEl = $('#summaryBox');
|
||||||
|
const detailBoxEl = $('#detailBox');
|
||||||
const eventBoxEl = $('#eventBox');
|
const eventBoxEl = $('#eventBox');
|
||||||
|
const state = {
|
||||||
|
cards: [],
|
||||||
|
selectedQuery: ''
|
||||||
|
};
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
async request(url) {
|
async request(url) {
|
||||||
@@ -19,6 +24,15 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusClass = (status) => {
|
||||||
|
const s = String(status || '').trim().toLowerCase();
|
||||||
|
if (s === 'active') return 'jr-status-valid';
|
||||||
|
if (s === 'pending_pickup') return 'jr-status-used';
|
||||||
|
return 'jr-status-expired';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLookupKey = (card) => String(card?.card_id || '').trim();
|
||||||
|
|
||||||
const escapeHtml = (value) => String(value == null ? '' : value)
|
const escapeHtml = (value) => String(value == null ? '' : value)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
@@ -44,33 +58,40 @@
|
|||||||
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
|
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSummary = (card) => {
|
const buildCardPreview = (card) => {
|
||||||
const shownCardId = card.display_card_id || card.card_id || '---';
|
const shownCardId = card.display_card_id || card.card_id || '---';
|
||||||
summaryBoxEl.className = '';
|
const detailHref = window.location.hostname.includes('fse-media.group')
|
||||||
summaryBoxEl.innerHTML = `
|
? `https://ticket.fse-media.group/ic/${encodeURIComponent(card.card_id || shownCardId)}`
|
||||||
|
: `/ic/${encodeURIComponent(card.card_id || shownCardId)}`;
|
||||||
|
return `
|
||||||
<div class="jr-ticket-preview">
|
<div class="jr-ticket-preview">
|
||||||
<div class="jr-ticket-row-head">
|
<div class="jr-ticket-row-head">
|
||||||
<span class="jr-ticket-id mono">${escapeHtml(shownCardId)}</span>
|
<span class="jr-ticket-id mono">${escapeHtml(shownCardId)}</span>
|
||||||
<span class="jr-status-pill ${card.status === 'active' ? 'jr-status-valid' : (card.status === 'pending_pickup' ? 'jr-status-used' : 'jr-status-expired')}">${escapeHtml(card.status_label || card.status || '未知')}</span>
|
<span class="jr-status-pill ${getStatusClass(card.status)}">${escapeHtml(card.status_label || card.status || '未知')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-meta-grid">
|
<div class="jr-meta-grid">
|
||||||
<div class="jr-meta-item"><span>持卡人</span><strong>${escapeHtml(card.holder_name || '未登记')}</strong></div>
|
<div class="jr-meta-item"><span>持卡人</span><strong>${escapeHtml(card.holder_name || '未登记')}</strong></div>
|
||||||
<div class="jr-meta-item"><span>卡片类型</span><strong>IC 储值卡</strong></div>
|
<div class="jr-meta-item"><span>卡片类型</span><strong>${escapeHtml(card.card_type_label || 'IC 储值卡')}</strong></div>
|
||||||
<div class="jr-meta-item"><span>余额</span><strong>${escapeHtml(card.balance ?? 0)}</strong></div>
|
<div class="jr-meta-item"><span>余额</span><strong>${escapeHtml(card.balance ?? 0)}</strong></div>
|
||||||
<div class="jr-meta-item"><span>首次充值</span><strong>${escapeHtml(card.purchase_amount ?? card.balance ?? 0)}</strong></div>
|
<div class="jr-meta-item"><span>首次充值</span><strong>${escapeHtml(card.purchase_amount ?? card.balance ?? 0)}</strong></div>
|
||||||
<div class="jr-meta-item"><span>凭证码</span><strong>${escapeHtml(card.voucher_code || card.code || card.order_code || '---')}</strong></div>
|
<div class="jr-meta-item"><span>凭证码</span><strong>${escapeHtml(card.voucher_code || card.code || card.order_code || '---')}</strong></div>
|
||||||
<div class="jr-meta-item"><span>购卡时间</span><strong>${escapeHtml(formatTime(card.created_ts))}</strong></div>
|
<div class="jr-meta-item"><span>购卡时间</span><strong>${escapeHtml(formatTime(card.created_ts))}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="jr-action-row">
|
||||||
|
<a href="${detailHref}" class="btn jr-secondary-btn" target="_blank" rel="noopener noreferrer">
|
||||||
|
<i class="fas fa-id-card"></i>
|
||||||
|
打开卡片页
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderEvents = (events) => {
|
const buildEventsHtml = (events) => {
|
||||||
if (!events.length) {
|
if (!events.length) {
|
||||||
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>暂无相关事件记录。</p></div>';
|
return '<div class="jr-center-empty" style="min-height:180px;"><p>暂无相关事件记录。</p></div>';
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
eventBoxEl.innerHTML = events.map((event) => `
|
return events.map((event) => `
|
||||||
<div class="jr-history-item">
|
<div class="jr-history-item">
|
||||||
<div class="jr-history-row">
|
<div class="jr-history-row">
|
||||||
<span class="jr-history-title">${escapeHtml(eventTitle(event))}</span>
|
<span class="jr-history-title">${escapeHtml(eventTitle(event))}</span>
|
||||||
@@ -81,37 +102,140 @@
|
|||||||
`).join('');
|
`).join('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderError = (message) => {
|
const renderDetailPrompt = (message) => {
|
||||||
|
detailBoxEl.innerHTML = `<div class="jr-center-empty" style="min-height:180px;"><p>${escapeHtml(message)}</p></div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEventPrompt = (message) => {
|
||||||
|
eventBoxEl.innerHTML = `<div class="jr-center-empty" style="min-height:180px;"><p>${escapeHtml(message)}</p></div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSelectedCard = (card, events) => {
|
||||||
|
if (!card) {
|
||||||
|
renderDetailPrompt('请选择左侧卡片查看详情。');
|
||||||
|
renderEventPrompt('请选择左侧卡片查看详情与事件记录。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detailBoxEl.innerHTML = buildCardPreview(card);
|
||||||
|
eventBoxEl.innerHTML = buildEventsHtml(events);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCardList = () => {
|
||||||
|
if (!state.cards.length) {
|
||||||
|
summaryBoxEl.className = 'jr-center-empty';
|
||||||
|
summaryBoxEl.innerHTML = '<p>暂无可显示的 IC 卡记录。</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryBoxEl.className = 'jr-scroll-box';
|
||||||
|
summaryBoxEl.innerHTML = state.cards.map((card) => {
|
||||||
|
const lookupKey = getLookupKey(card);
|
||||||
|
const shownCardId = card.display_card_id || card.card_id || '---';
|
||||||
|
const voucherCode = card.voucher_code || card.code || card.order_code || '---';
|
||||||
|
const isSelected = lookupKey && state.selectedQuery === lookupKey;
|
||||||
|
return `
|
||||||
|
<div class="jr-ticket-row${isSelected ? ' is-active' : ''}" data-card-query="${escapeHtml(lookupKey)}">
|
||||||
|
<div class="jr-ticket-row-head">
|
||||||
|
<span class="jr-ticket-id mono">${escapeHtml(shownCardId)}</span>
|
||||||
|
<span class="jr-status-pill ${getStatusClass(card.status)}">${escapeHtml(card.status_label || card.status || '未知')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="jr-ticket-route">${escapeHtml(card.holder_name || '未登记持卡人')}</div>
|
||||||
|
<div class="jr-list-meta">
|
||||||
|
余额 ${escapeHtml(card.balance ?? 0)} · 凭证码 ${escapeHtml(voucherCode)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
summaryBoxEl.querySelectorAll('[data-card-query]').forEach((item) => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const q = item.getAttribute('data-card-query');
|
||||||
|
if (q) {
|
||||||
|
loadCardDetail(q).catch((error) => {
|
||||||
|
renderQueryError(error.message || String(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCardDetail = async (q, options = {}) => {
|
||||||
|
const { updateUrl = true } = options;
|
||||||
|
renderDetailPrompt('正在加载卡片详情...');
|
||||||
|
renderEventPrompt('正在加载事件记录...');
|
||||||
|
const data = await api.query(q);
|
||||||
|
const card = data.card || null;
|
||||||
|
const events = data.events || [];
|
||||||
|
const lookupKey = getLookupKey(card) || q;
|
||||||
|
if (card) {
|
||||||
|
const existingIdx = state.cards.findIndex((item) => getLookupKey(item) === lookupKey);
|
||||||
|
if (existingIdx >= 0) state.cards[existingIdx] = card;
|
||||||
|
else state.cards = [card];
|
||||||
|
}
|
||||||
|
state.selectedQuery = lookupKey;
|
||||||
|
renderCardList();
|
||||||
|
renderSelectedCard(card, events);
|
||||||
|
if (updateUrl) {
|
||||||
|
const newUrl = `${window.location.origin}${window.location.pathname}?q=${encodeURIComponent(q)}`;
|
||||||
|
window.history.replaceState({ path: newUrl }, '', newUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAllCards = async () => {
|
||||||
summaryBoxEl.className = 'jr-center-empty';
|
summaryBoxEl.className = 'jr-center-empty';
|
||||||
summaryBoxEl.innerHTML = `<p>${escapeHtml(message)}</p>`;
|
summaryBoxEl.innerHTML = '<p>正在加载全部 IC 卡...</p>';
|
||||||
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>暂无可显示的事件记录。</p></div>';
|
renderDetailPrompt('正在准备卡片详情...');
|
||||||
|
renderEventPrompt('正在准备事件记录...');
|
||||||
|
const data = await api.query('');
|
||||||
|
state.cards = Array.isArray(data.cards) ? data.cards : [];
|
||||||
|
state.selectedQuery = '';
|
||||||
|
renderCardList();
|
||||||
|
|
||||||
|
if (!state.cards.length) {
|
||||||
|
renderDetailPrompt('当前暂无 IC 卡记录。');
|
||||||
|
renderEventPrompt('当前暂无 IC 卡记录。');
|
||||||
|
const newUrl = `${window.location.origin}${window.location.pathname}`;
|
||||||
|
window.history.replaceState({ path: newUrl }, '', newUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadCardDetail(getLookupKey(state.cards[0]), { updateUrl: false });
|
||||||
|
const newUrl = `${window.location.origin}${window.location.pathname}`;
|
||||||
|
window.history.replaceState({ path: newUrl }, '', newUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
const doQuery = async () => {
|
const doQuery = async () => {
|
||||||
const q = inputEl.value.trim();
|
const q = inputEl.value.trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
renderError('请输入卡号或凭证码');
|
await loadAllCards();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
summaryBoxEl.className = 'jr-center-empty';
|
summaryBoxEl.className = 'jr-center-empty';
|
||||||
summaryBoxEl.innerHTML = '<p>正在查询 IC 卡...</p>';
|
summaryBoxEl.innerHTML = '<p>正在查询 IC 卡...</p>';
|
||||||
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>正在加载事件记录...</p></div>';
|
renderDetailPrompt('正在查询卡片详情...');
|
||||||
const data = await api.query(q);
|
renderEventPrompt('正在查询事件记录...');
|
||||||
renderSummary(data.card || {});
|
state.cards = [];
|
||||||
renderEvents(data.events || []);
|
await loadCardDetail(q);
|
||||||
const newUrl = `${window.location.origin}${window.location.pathname}?q=${encodeURIComponent(q)}`;
|
|
||||||
window.history.replaceState({ path: newUrl }, '', newUrl);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
queryBtn.addEventListener('click', () => doQuery().catch((error) => renderError(error.message || String(error))));
|
const renderQueryError = (message) => {
|
||||||
|
summaryBoxEl.className = 'jr-center-empty';
|
||||||
|
summaryBoxEl.innerHTML = `<p>${escapeHtml(message)}</p>`;
|
||||||
|
renderDetailPrompt(message);
|
||||||
|
renderEventPrompt(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
queryBtn.addEventListener('click', () => doQuery().catch((error) => renderQueryError(error.message || String(error))));
|
||||||
inputEl.addEventListener('keydown', (event) => {
|
inputEl.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Enter') doQuery().catch((error) => renderError(error.message || String(error)));
|
if (event.key === 'Enter') doQuery().catch((error) => renderQueryError(error.message || String(error)));
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const q = params.get('q');
|
const q = params.get('q');
|
||||||
if (q) {
|
if (q) {
|
||||||
inputEl.value = q;
|
inputEl.value = q;
|
||||||
doQuery().catch((error) => renderError(error.message || String(error)));
|
doQuery().catch((error) => renderQueryError(error.message || String(error)));
|
||||||
|
} else {
|
||||||
|
loadAllCards().catch((error) => renderQueryError(error.message || String(error)));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
+122
-29
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<!-- 充满未知和不稳定的票务系统! -->
|
<!-- 充满未知和不稳定的票务系统! -->
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FSE铁路票务系统控制台</title>
|
<title>FSE铁路票务系统控制台</title>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="style.css?v=12">
|
<link rel="stylesheet" href="style.css?v=14">
|
||||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -22,11 +22,6 @@
|
|||||||
<i class="fas fa-train"></i>
|
<i class="fas fa-train"></i>
|
||||||
<span>FSE 铁路运输后台系统</span>
|
<span>FSE 铁路运输后台系统</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
|
||||||
<span class="jr-top-status-label">服务器状态</span>
|
|
||||||
<span class="jr-top-status-dot"></span>
|
|
||||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -88,15 +83,6 @@
|
|||||||
<span class="nav-icon"><i class="fas fa-list"></i></span> 日志
|
<span class="nav-icon"><i class="fas fa-list"></i></span> 日志
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!--连接状态显示-->
|
|
||||||
<div class="jr-admin-sidebar-status">
|
|
||||||
<div class="jr-admin-sidebar-status-label">Server: {{ connected ? 'Online' : 'Offline' }}</div>
|
|
||||||
<div class="flex" style="align-items: center; gap: 6px; margin-bottom: 15px;">
|
|
||||||
<i class="fas fa-circle"
|
|
||||||
:style="{ color: connected ? '#10b981' : '#ef4444', fontSize: '0.6rem' }"></i>
|
|
||||||
<span>Status: {{ connected ? 'Connected' : 'Disconnected' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false"></div>
|
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false"></div>
|
||||||
|
|
||||||
@@ -112,10 +98,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-admin-header-side">
|
<div class="jr-admin-header-side">
|
||||||
<span class="jr-admin-header-pill" :class="connected ? 'is-online' : 'is-offline'">
|
<div class="jr-admin-sync-meta">
|
||||||
<i class="fas fa-circle"></i>
|
<span class="jr-admin-sync-label">当前模块</span>
|
||||||
{{ connected ? '服务器在线' : '服务器离线' }}
|
<strong>{{ viewTitle }}</strong>
|
||||||
</span>
|
</div>
|
||||||
|
<div class="jr-admin-sync-meta">
|
||||||
|
<span class="jr-admin-sync-label">最近同步</span>
|
||||||
|
<strong>{{ lastSyncText }}</strong>
|
||||||
|
</div>
|
||||||
|
<button class="btn primary" @click="refreshData" :disabled="isViewBusy">
|
||||||
|
<i class="fas" :class="isViewBusy ? 'fa-spinner fa-spin' : 'fa-rotate-right'"></i>
|
||||||
|
{{ isViewBusy ? '同步中' : '刷新视图' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@@ -131,6 +125,32 @@
|
|||||||
</div>
|
</div>
|
||||||
<p>当前模块:{{ viewTitle }},已加载 {{ lines.length }} 条线路、{{ stations.length }} 个站点。需要做批量调整时,可先在左侧切换模块再进入对应卡片操作区。</p>
|
<p>当前模块:{{ viewTitle }},已加载 {{ lines.length }} 条线路、{{ stations.length }} 个站点。需要做批量调整时,可先在左侧切换模块再进入对应卡片操作区。</p>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="jr-admin-overview-grid">
|
||||||
|
<article class="jr-admin-overview-card">
|
||||||
|
<span class="jr-admin-overview-label">当前模块</span>
|
||||||
|
<strong class="jr-admin-overview-value">{{ viewTitle }}</strong>
|
||||||
|
<p class="jr-admin-overview-note">{{ currentViewSummary }}</p>
|
||||||
|
</article>
|
||||||
|
<article class="jr-admin-overview-card">
|
||||||
|
<span class="jr-admin-overview-label">线路与站点</span>
|
||||||
|
<strong class="jr-admin-overview-value">{{ lines.length }} / {{ stations.length }}</strong>
|
||||||
|
<p class="jr-admin-overview-note">后台操作统一建立在线路、站点与票价的核心数据之上。</p>
|
||||||
|
</article>
|
||||||
|
<article class="jr-admin-overview-card">
|
||||||
|
<span class="jr-admin-overview-label">同步状态</span>
|
||||||
|
<strong class="jr-admin-overview-value">{{ isViewBusy ? '正在更新' : '数据已就绪' }}</strong>
|
||||||
|
<p class="jr-admin-overview-note">切换模块时只拉取当前视图需要的数据,减少等待与无效刷新。</p>
|
||||||
|
</article>
|
||||||
|
<article class="jr-admin-overview-card is-actions">
|
||||||
|
<span class="jr-admin-overview-label">快捷操作</span>
|
||||||
|
<div class="jr-admin-overview-actions">
|
||||||
|
<button class="btn" @click="currentView = 'management'"><i class="fas fa-network-wired"></i> 线路管理</button>
|
||||||
|
<button class="btn" @click="currentView = 'iccards'"><i class="fas fa-credit-card"></i> IC 卡务</button>
|
||||||
|
<button class="btn" @click="currentView = 'logs'"><i class="fas fa-list"></i> 查看日志</button>
|
||||||
|
<button class="btn primary" @click="refreshData" :disabled="isViewBusy"><i class="fas fa-rotate-right"></i> 立即同步</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
<!-- 仪表盘-->
|
<!-- 仪表盘-->
|
||||||
<div v-if="currentView === 'dashboard'">
|
<div v-if="currentView === 'dashboard'">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -255,8 +275,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 可视化线路编辑-->
|
<!-- 可视化线路编辑-->
|
||||||
<div class="visual-line-container">
|
<div class="visual-line-container"
|
||||||
<svg width="100%" height="200"
|
ref="visualLineViewport"
|
||||||
|
:class="{ 'is-panning': lineViewportPan.active }"
|
||||||
|
@mousedown="startLineViewportPan"
|
||||||
|
@mousemove="moveLineViewportPan">
|
||||||
|
<svg :width="lineEditorSvgWidth" height="200"
|
||||||
v-if="selectedLine.stations && selectedLine.stations.length > 0">
|
v-if="selectedLine.stations && selectedLine.stations.length > 0">
|
||||||
<!--站点连接线-->
|
<!--站点连接线-->
|
||||||
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100"
|
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100"
|
||||||
@@ -479,6 +503,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentView === 'iccards'">
|
<div v-if="currentView === 'iccards'">
|
||||||
|
<section class="jr-admin-section-toolbar">
|
||||||
|
<div class="jr-admin-section-toolbar-copy">
|
||||||
|
<span class="jr-admin-overview-label">IC CARD DESK</span>
|
||||||
|
<strong>{{ currentViewSummary }}</strong>
|
||||||
|
<p>把检索、充值、状态维护和事件核对集中在同一工作流里,减少在列表和详情之间来回跳转的成本。</p>
|
||||||
|
</div>
|
||||||
|
<div class="jr-admin-overview-actions">
|
||||||
|
<button class="btn" @click="fetchIcCards(false)" :disabled="isViewBusy"><i class="fas fa-list"></i> 刷新列表</button>
|
||||||
|
<button class="btn primary" @click="loadIcCard(icSelectedId)" :disabled="!icSelectedId || isViewBusy"><i class="fas fa-id-card"></i> 刷新详情</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">IC 卡总数</div>
|
<div class="stat-label">IC 卡总数</div>
|
||||||
@@ -500,9 +535,24 @@
|
|||||||
|
|
||||||
<div class="management-container ic-admin-layout">
|
<div class="management-container ic-admin-layout">
|
||||||
<div class="management-sidebar">
|
<div class="management-sidebar">
|
||||||
<div class="card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
|
<div class="card jr-admin-note-card">
|
||||||
<div class="flex between mb-4">
|
<div class="flex between mb-4">
|
||||||
<h4>卡片列表</h4>
|
<h4>操作说明</h4>
|
||||||
|
<span class="badge">只读入口</span>
|
||||||
|
</div>
|
||||||
|
<p class="jr-admin-card-note">本模块不提供后台快速建卡,卡片发放流程保持在线上购卡或既有开卡流程中完成,后台仅负责检索、维护、充值与记录核对。</p>
|
||||||
|
<div class="jr-admin-note-list">
|
||||||
|
<div>1. 先检索卡号、订单号或持卡人。</div>
|
||||||
|
<div>2. 在详情面板修改状态并保存。</div>
|
||||||
|
<div>3. 需要补款时直接使用充值入口。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card jr-admin-list-card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
|
||||||
|
<div class="flex between mb-4">
|
||||||
|
<div>
|
||||||
|
<h4>卡片列表</h4>
|
||||||
|
<div class="jr-admin-list-meta">支持按卡号、订单号、凭证码和持卡人姓名检索。</div>
|
||||||
|
</div>
|
||||||
<span class="badge">{{ icCards.length }}</span>
|
<span class="badge">{{ icCards.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex mb-4" style="flex-wrap:wrap;">
|
<div class="flex mb-4" style="flex-wrap:wrap;">
|
||||||
@@ -536,8 +586,13 @@
|
|||||||
<div class="management-main">
|
<div class="management-main">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex between mb-4">
|
<div class="flex between mb-4">
|
||||||
<h4>卡片详情</h4>
|
<div>
|
||||||
|
<h4>卡片详情</h4>
|
||||||
|
<div class="jr-admin-list-meta">在同一面板直接处理状态维护、充值和记录核对。</div>
|
||||||
|
</div>
|
||||||
<div class="flex" style="gap:8px;">
|
<div class="flex" style="gap:8px;">
|
||||||
|
<button class="btn" @click="loadIcCard(icSelectedId)" :disabled="!icSelectedCard || isViewBusy"><i class="fas fa-rotate-right"></i> 刷新</button>
|
||||||
|
<button class="btn" @click="topupIcCard" :disabled="!icSelectedCard || isViewBusy"><i class="fas fa-wallet"></i> 充值</button>
|
||||||
<button class="btn danger" @click="deleteIcCard" :disabled="!icSelectedCard"><i class="fas fa-trash"></i> 删除卡</button>
|
<button class="btn danger" @click="deleteIcCard" :disabled="!icSelectedCard"><i class="fas fa-trash"></i> 删除卡</button>
|
||||||
<button class="btn primary" @click="saveIcCard" :disabled="!icSelectedCard"><i class="fas fa-save"></i> 保存</button>
|
<button class="btn primary" @click="saveIcCard" :disabled="!icSelectedCard"><i class="fas fa-save"></i> 保存</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -554,6 +609,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="icStatusInfo(icSelectedCard.status).className">{{ icStatusInfo(icSelectedCard.status).text }}</span>
|
<span class="badge" :class="icStatusInfo(icSelectedCard.status).className">{{ icStatusInfo(icSelectedCard.status).text }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="jr-admin-summary-grid">
|
||||||
|
<div class="jr-admin-summary-item">
|
||||||
|
<span>当前余额</span>
|
||||||
|
<strong>{{ formatMoney(icSelectedCard.balance) }}</strong>
|
||||||
|
<small>支持直接发起充值。</small>
|
||||||
|
</div>
|
||||||
|
<div class="jr-admin-summary-item">
|
||||||
|
<span>事件记录</span>
|
||||||
|
<strong>{{ icSelectedEvents.length }}</strong>
|
||||||
|
<small>用于追踪开卡与状态变更。</small>
|
||||||
|
</div>
|
||||||
|
<div class="jr-admin-summary-item">
|
||||||
|
<span>订单来源</span>
|
||||||
|
<strong>{{ cardOrderCode(icSelectedCard) }}</strong>
|
||||||
|
<small>自动识别线上订单或现场办卡。</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="ic-detail-grid">
|
<div class="ic-detail-grid">
|
||||||
<label class="ic-field">
|
<label class="ic-field">
|
||||||
<span>持卡人</span>
|
<span>持卡人</span>
|
||||||
@@ -769,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>
|
||||||
@@ -841,9 +934,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
<script src="index.js?v=6"></script>
|
||||||
<script src="index.js?v=2"></script>
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const isDomain = location.hostname.includes('fse-media.group');
|
const isDomain = location.hostname.includes('fse-media.group');
|
||||||
@@ -873,3 +965,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+262
-68
@@ -34,8 +34,9 @@ createApp({
|
|||||||
return map[currentView.value] || '票价图';
|
return map[currentView.value] || '票价图';
|
||||||
});
|
});
|
||||||
|
|
||||||
const connected = ref(false);
|
// Prefer polling first so admin remains connected even when the proxy
|
||||||
const socket = io({ transports: ['websocket', 'polling'], upgrade: false });
|
// does not support WebSocket upgrades reliably.
|
||||||
|
const socket = io({ transports: ['polling', 'websocket'] });
|
||||||
|
|
||||||
// Data State
|
// Data State
|
||||||
const stations = ref([]);
|
const stations = ref([]);
|
||||||
@@ -43,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('');
|
||||||
@@ -63,6 +68,24 @@ createApp({
|
|||||||
const icCreateForm = reactive({ holder_name: '', balance: 50 });
|
const icCreateForm = reactive({ holder_name: '', balance: 50 });
|
||||||
const icDetailForm = reactive({ holder_name: '', status: 'active' });
|
const icDetailForm = reactive({ holder_name: '', status: 'active' });
|
||||||
let icCardSyncTimer = null;
|
let icCardSyncTimer = null;
|
||||||
|
let icCardSyncBusy = false;
|
||||||
|
let icListRequestSeq = 0;
|
||||||
|
let icDetailRequestSeq = 0;
|
||||||
|
let appMouseupHandler = null;
|
||||||
|
let coreLoaded = false;
|
||||||
|
let ticketDataLoaded = false;
|
||||||
|
let orderDataLoaded = false;
|
||||||
|
let logDataLoaded = false;
|
||||||
|
let assetsLoaded = false;
|
||||||
|
let fareMapLoaded = false;
|
||||||
|
const loadingState = reactive({
|
||||||
|
core: false,
|
||||||
|
tickets: false,
|
||||||
|
orders: false,
|
||||||
|
logs: false,
|
||||||
|
iccards: false
|
||||||
|
});
|
||||||
|
const lastSyncAt = ref(0);
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
const showAddLine = ref(false);
|
const showAddLine = ref(false);
|
||||||
@@ -82,6 +105,15 @@ createApp({
|
|||||||
const showFareModal = ref(false);
|
const showFareModal = ref(false);
|
||||||
const currentFare = reactive({ exists: false, cost_regular: 0, cost_express: 0 });
|
const currentFare = reactive({ exists: false, cost_regular: 0, cost_express: 0 });
|
||||||
const draggingStationIndex = ref(null);
|
const draggingStationIndex = ref(null);
|
||||||
|
const visualLineViewport = ref(null);
|
||||||
|
const lineViewportPan = reactive({
|
||||||
|
active: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
scrollLeft: 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
moved: false
|
||||||
|
});
|
||||||
const showStationModal = ref(false);
|
const showStationModal = ref(false);
|
||||||
const stationForm = reactive({ code: '', name: '', en_name: '', transfer_enabled: false, transfer_to: [] });
|
const stationForm = reactive({ code: '', name: '', en_name: '', transfer_enabled: false, transfer_to: [] });
|
||||||
const stationFormOriginalCode = ref('');
|
const stationFormOriginalCode = ref('');
|
||||||
@@ -103,6 +135,9 @@ createApp({
|
|||||||
confirm: (message) => Promise.resolve(window.confirm(message)),
|
confirm: (message) => Promise.resolve(window.confirm(message)),
|
||||||
prompt: (message, defaultValue) => Promise.resolve(window.prompt(message, defaultValue))
|
prompt: (message, defaultValue) => Promise.resolve(window.prompt(message, defaultValue))
|
||||||
};
|
};
|
||||||
|
const markSynced = () => {
|
||||||
|
lastSyncAt.value = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
const buildAssetUrl = (name) => {
|
const buildAssetUrl = (name) => {
|
||||||
if (!name) return '';
|
if (!name) return '';
|
||||||
@@ -193,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 '---';
|
||||||
@@ -417,6 +466,7 @@ createApp({
|
|||||||
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
|
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
|
||||||
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
|
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
|
||||||
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
|
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
|
||||||
|
assetsLoaded = true;
|
||||||
|
|
||||||
assetsFarePreview.headers = [];
|
assetsFarePreview.headers = [];
|
||||||
assetsFarePreview.rows = [];
|
assetsFarePreview.rows = [];
|
||||||
@@ -454,6 +504,7 @@ createApp({
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
markSynced();
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadAssetFile = async (url, file) => {
|
const uploadAssetFile = async (url, file) => {
|
||||||
@@ -631,36 +682,90 @@ createApp({
|
|||||||
draggingStationIndex.value = null;
|
draggingStationIndex.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startLineViewportPan = (event) => {
|
||||||
|
const viewport = visualLineViewport.value;
|
||||||
|
if (!viewport) return;
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
if (event.target && event.target.closest('.station-node')) return;
|
||||||
|
lineViewportPan.active = true;
|
||||||
|
lineViewportPan.moved = false;
|
||||||
|
lineViewportPan.startX = event.clientX;
|
||||||
|
lineViewportPan.startY = event.clientY;
|
||||||
|
lineViewportPan.scrollLeft = viewport.scrollLeft;
|
||||||
|
lineViewportPan.scrollTop = viewport.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveLineViewportPan = (event) => {
|
||||||
|
if (!lineViewportPan.active) return;
|
||||||
|
const viewport = visualLineViewport.value;
|
||||||
|
if (!viewport) return;
|
||||||
|
const deltaX = event.clientX - lineViewportPan.startX;
|
||||||
|
const deltaY = event.clientY - lineViewportPan.startY;
|
||||||
|
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
|
||||||
|
lineViewportPan.moved = true;
|
||||||
|
}
|
||||||
|
viewport.scrollLeft = lineViewportPan.scrollLeft - deltaX;
|
||||||
|
viewport.scrollTop = lineViewportPan.scrollTop - deltaY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const endLineViewportPan = () => {
|
||||||
|
lineViewportPan.active = false;
|
||||||
|
};
|
||||||
|
|
||||||
// --- Order Management ---
|
// --- Order Management ---
|
||||||
const fetchOrders = async () => {
|
const fetchOrders = async () => {
|
||||||
|
if (loadingState.orders) return;
|
||||||
|
loadingState.orders = true;
|
||||||
try {
|
try {
|
||||||
const res = await requestJson('/api/orders');
|
const res = await requestJson('/api/orders');
|
||||||
if (res && res.ok) orders.value = res.orders;
|
if (res && res.ok) {
|
||||||
} catch (e) { console.error(e); }
|
orders.value = res.orders || [];
|
||||||
|
orderDataLoaded = true;
|
||||||
|
markSynced();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loadingState.orders = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchIcCards = async (keepSelection = true) => {
|
const fetchIcCards = async (keepSelection = true) => {
|
||||||
|
if (loadingState.iccards) return;
|
||||||
|
loadingState.iccards = true;
|
||||||
|
const requestSeq = ++icListRequestSeq;
|
||||||
const sp = new URLSearchParams();
|
const sp = new URLSearchParams();
|
||||||
if (icCardSearch.value.trim()) sp.set('q', icCardSearch.value.trim());
|
if (icCardSearch.value.trim()) sp.set('q', icCardSearch.value.trim());
|
||||||
const res = await requestJson(`/api/ic-cards?${sp.toString()}`);
|
try {
|
||||||
icCards.value = res?.cards || [];
|
const res = await requestJson(`/api/ic-cards?${sp.toString()}`);
|
||||||
if (keepSelection && icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
if (requestSeq !== icListRequestSeq) return;
|
||||||
await loadIcCard(icSelectedId.value);
|
icCards.value = res?.cards || [];
|
||||||
} else if (icSelectedId.value && !icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
if (keepSelection && icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
||||||
icSelectedId.value = '';
|
await loadIcCard(icSelectedId.value);
|
||||||
icSelectedCard.value = null;
|
} else if (icSelectedId.value && !icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
||||||
icSelectedEvents.value = [];
|
icSelectedId.value = '';
|
||||||
|
icSelectedCard.value = null;
|
||||||
|
icSelectedEvents.value = [];
|
||||||
|
}
|
||||||
|
markSynced();
|
||||||
|
} finally {
|
||||||
|
if (requestSeq === icListRequestSeq) {
|
||||||
|
loadingState.iccards = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadIcCard = async (id) => {
|
const loadIcCard = async (id) => {
|
||||||
|
const requestSeq = ++icDetailRequestSeq;
|
||||||
const res = await requestJson(`/api/ic-cards/${encodeURIComponent(id)}`);
|
const res = await requestJson(`/api/ic-cards/${encodeURIComponent(id)}`);
|
||||||
|
if (requestSeq !== icDetailRequestSeq) return;
|
||||||
const card = res?.card || null;
|
const card = res?.card || null;
|
||||||
icSelectedId.value = id;
|
icSelectedId.value = id;
|
||||||
icSelectedCard.value = card;
|
icSelectedCard.value = card;
|
||||||
icSelectedEvents.value = res?.events || [];
|
icSelectedEvents.value = res?.events || [];
|
||||||
icDetailForm.holder_name = card?.holder_name || '';
|
icDetailForm.holder_name = card?.holder_name || '';
|
||||||
icDetailForm.status = card?.status || 'active';
|
icDetailForm.status = card?.status || 'active';
|
||||||
|
markSynced();
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncSelectedIcCard = async () => {
|
const syncSelectedIcCard = async () => {
|
||||||
@@ -683,9 +788,15 @@ createApp({
|
|||||||
stopIcCardSync();
|
stopIcCardSync();
|
||||||
if (currentView.value !== 'iccards') return;
|
if (currentView.value !== 'iccards') return;
|
||||||
icCardSyncTimer = setInterval(() => {
|
icCardSyncTimer = setInterval(() => {
|
||||||
fetchIcCards(false).catch(console.error);
|
if (document.hidden || icCardSyncBusy) return;
|
||||||
syncSelectedIcCard().catch(console.error);
|
icCardSyncBusy = true;
|
||||||
}, 3000);
|
Promise.all([
|
||||||
|
fetchIcCards(false).catch(console.error),
|
||||||
|
icSelectedId.value ? syncSelectedIcCard().catch(console.error) : Promise.resolve()
|
||||||
|
]).finally(() => {
|
||||||
|
icCardSyncBusy = false;
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createIcCard = async () => {
|
const createIcCard = async () => {
|
||||||
@@ -775,10 +886,15 @@ createApp({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchLogs = async () => {
|
const fetchLogs = async () => {
|
||||||
|
if (logLoading.value) return;
|
||||||
logLoading.value = true;
|
logLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await requestJson(buildLogsUrl());
|
const res = await requestJson(buildLogsUrl());
|
||||||
if (res && res.ok) logs.value = res.logs || [];
|
if (res && res.ok) {
|
||||||
|
logs.value = res.logs || [];
|
||||||
|
logDataLoaded = true;
|
||||||
|
markSynced();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -823,66 +939,67 @@ createApp({
|
|||||||
return `¥${reg} / ¥${exp}`;
|
return `¥${reg} / ¥${exp}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchCoreData = async ({ force = false } = {}) => {
|
||||||
|
if (loadingState.core) return;
|
||||||
|
if (coreLoaded && !force) return;
|
||||||
|
loadingState.core = true;
|
||||||
try {
|
try {
|
||||||
const safeFetch = (url, defaultVal) => requestJson(url).catch(e => {
|
const safeFetch = (url, defaultVal) => requestJson(url).catch((e) => {
|
||||||
console.error(`Fetch failed for ${url}`, e);
|
console.error(`Fetch failed for ${url}`, e);
|
||||||
lastActionError.value = e?.message || String(e);
|
lastActionError.value = e?.message || String(e);
|
||||||
return defaultVal;
|
return defaultVal;
|
||||||
});
|
});
|
||||||
|
const [s, l, f, c, st] = await Promise.all([
|
||||||
const safeFetchList = (url, key) => requestJson(url).then(d => d?.[key] || []).catch(e => {
|
|
||||||
console.error(`Fetch list failed for ${url}`, e);
|
|
||||||
lastActionError.value = e?.message || String(e);
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const [s, l, f, c, t, lg, st, ord, cards] = await Promise.all([
|
|
||||||
safeFetch('/api/stations', []),
|
safeFetch('/api/stations', []),
|
||||||
safeFetch('/api/lines', []),
|
safeFetch('/api/lines', []),
|
||||||
safeFetch('/api/fares', []),
|
safeFetch('/api/fares', []),
|
||||||
safeFetch('/api/config', {}),
|
safeFetch('/api/config', {}),
|
||||||
safeFetchList('/api/tickets', 'tickets'),
|
safeFetch('/api/stats/ticket/total', {}).then((d) => d.total || {})
|
||||||
safeFetchList(buildLogsUrl(), 'logs'),
|
|
||||||
safeFetch('/api/stats/ticket/total', {}).then(d => d.total || {}),
|
|
||||||
safeFetchList('/api/orders', 'orders'),
|
|
||||||
safeFetchList('/api/ic-cards', 'cards')
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
stations.value = s;
|
stations.value = s;
|
||||||
lines.value = l;
|
lines.value = l;
|
||||||
fares.value = f;
|
fares.value = f;
|
||||||
Object.assign(config, c);
|
Object.assign(config, c);
|
||||||
tickets.value = t;
|
|
||||||
logs.value = lg;
|
|
||||||
Object.assign(stats, st);
|
Object.assign(stats, st);
|
||||||
orders.value = ord;
|
|
||||||
icCards.value = cards;
|
|
||||||
|
|
||||||
// Refresh selected line if it exists
|
|
||||||
if (selectedLine.value) {
|
if (selectedLine.value) {
|
||||||
const found = lines.value.find(l => l.id === selectedLine.value.id);
|
const found = lines.value.find((line) => line.id === selectedLine.value.id);
|
||||||
if (found) selectedLine.value = found;
|
selectedLine.value = found || null;
|
||||||
}
|
}
|
||||||
if (icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
coreLoaded = true;
|
||||||
await loadIcCard(icSelectedId.value);
|
markSynced();
|
||||||
}
|
|
||||||
|
|
||||||
loadFareMap();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch data", e);
|
console.error('Failed to fetch core data', e);
|
||||||
|
} finally {
|
||||||
|
loadingState.core = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadFareMap = async () => {
|
const fetchTicketData = async () => {
|
||||||
|
if (loadingState.tickets) return;
|
||||||
|
loadingState.tickets = true;
|
||||||
|
try {
|
||||||
|
const res = await requestJson('/api/tickets');
|
||||||
|
tickets.value = res?.tickets || [];
|
||||||
|
ticketDataLoaded = true;
|
||||||
|
markSynced();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch tickets', e);
|
||||||
|
} finally {
|
||||||
|
loadingState.tickets = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFareMap = async ({ force = false } = {}) => {
|
||||||
|
if (fareMapLoading.value) return;
|
||||||
|
if (fareMapLoaded && !force) return;
|
||||||
fareMapLoading.value = true;
|
fareMapLoading.value = true;
|
||||||
fareMapError.value = '';
|
fareMapError.value = '';
|
||||||
try {
|
try {
|
||||||
// Change to fetch the SVG text directly from the public API
|
|
||||||
// Add timestamp to prevent caching
|
|
||||||
const r = await fetch(`/api/public/fares/map/light?t=${Date.now()}`);
|
const r = await fetch(`/api/public/fares/map/light?t=${Date.now()}`);
|
||||||
const svg = await r.text();
|
const svg = await r.text();
|
||||||
fareMapSvg.value = svg;
|
fareMapSvg.value = svg;
|
||||||
|
fareMapLoaded = true;
|
||||||
|
markSynced();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load fare map", e);
|
console.error("Failed to load fare map", e);
|
||||||
fareMapError.value = '加载失败';
|
fareMapError.value = '加载失败';
|
||||||
@@ -891,6 +1008,25 @@ createApp({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureViewData = async (view = currentView.value, { force = false } = {}) => {
|
||||||
|
await fetchCoreData({ force });
|
||||||
|
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
|
||||||
|
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
|
||||||
|
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
|
||||||
|
if (view === 'iccards') {
|
||||||
|
await fetchIcCards(true);
|
||||||
|
if (icSelectedId.value) {
|
||||||
|
await syncSelectedIcCard().catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (view === 'faremap' && (force || !fareMapLoaded)) await loadFareMap({ force });
|
||||||
|
if (view === 'assets' && (!assetsLoaded || force)) await fetchAssetsManifest();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
await ensureViewData(currentView.value, { force: true });
|
||||||
|
};
|
||||||
|
|
||||||
const zoomFareMapIn = () => { fareMapScale.value = Math.min(3, Math.round((fareMapScale.value + 0.1) * 100) / 100); };
|
const zoomFareMapIn = () => { fareMapScale.value = Math.min(3, Math.round((fareMapScale.value + 0.1) * 100) / 100); };
|
||||||
const zoomFareMapOut = () => { fareMapScale.value = Math.max(0.3, Math.round((fareMapScale.value - 0.1) * 100) / 100); };
|
const zoomFareMapOut = () => { fareMapScale.value = Math.max(0.3, Math.round((fareMapScale.value - 0.1) * 100) / 100); };
|
||||||
const zoomFareMapReset = () => { fareMapScale.value = 1; };
|
const zoomFareMapReset = () => { fareMapScale.value = 1; };
|
||||||
@@ -1093,6 +1229,10 @@ createApp({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStationClick = async (code) => {
|
const handleStationClick = async (code) => {
|
||||||
|
if (lineViewportPan.moved) {
|
||||||
|
lineViewportPan.moved = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (stationEditMode.value) {
|
if (stationEditMode.value) {
|
||||||
openStationModal(code);
|
openStationModal(code);
|
||||||
return;
|
return;
|
||||||
@@ -1222,26 +1362,35 @@ 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');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Socket Listeners
|
// Socket Listeners
|
||||||
socket.on('connect', () => { connected.value = true; });
|
|
||||||
socket.on('disconnect', () => { connected.value = false; });
|
|
||||||
|
|
||||||
socket.on('stations:updated', (data) => {
|
socket.on('stations:updated', (data) => {
|
||||||
stations.value = data;
|
stations.value = data;
|
||||||
// Refresh map when stations change
|
// Refresh map when stations change
|
||||||
loadFareMap();
|
fareMapLoaded = false;
|
||||||
|
if (currentView.value === 'faremap') {
|
||||||
|
loadFareMap({ force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('lines:updated', (data) => {
|
socket.on('lines:updated', (data) => {
|
||||||
lines.value = data;
|
lines.value = data;
|
||||||
|
coreLoaded = true;
|
||||||
// Update selectedLine reference if it exists
|
// Update selectedLine reference if it exists
|
||||||
if (selectedLine.value) {
|
if (selectedLine.value) {
|
||||||
const updated = data.find(l => l.id === selectedLine.value.id);
|
const updated = data.find(l => l.id === selectedLine.value.id);
|
||||||
@@ -1251,14 +1400,29 @@ createApp({
|
|||||||
selectedLine.value = null; // Line was deleted
|
selectedLine.value = null; // Line was deleted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadFareMap();
|
fareMapLoaded = false;
|
||||||
|
if (currentView.value === 'faremap') {
|
||||||
|
loadFareMap({ force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('fares:updated', (data) => {
|
socket.on('fares:updated', (data) => {
|
||||||
fares.value = data;
|
fares.value = data;
|
||||||
loadFareMap();
|
coreLoaded = true;
|
||||||
|
fareMapLoaded = false;
|
||||||
|
if (currentView.value === 'faremap') {
|
||||||
|
loadFareMap({ force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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') {
|
||||||
|
loadFareMap({ force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
socket.on('config:updated', (data) => { Object.assign(config, data); loadFareMap(); });
|
|
||||||
|
|
||||||
socket.on('stats:ticket:updated', (item) => {
|
socket.on('stats:ticket:updated', (item) => {
|
||||||
stats.sold_tickets += item.sold_tickets;
|
stats.sold_tickets += item.sold_tickets;
|
||||||
@@ -1281,15 +1445,12 @@ createApp({
|
|||||||
|
|
||||||
watch(currentView, (v) => {
|
watch(currentView, (v) => {
|
||||||
sidebarOpen.value = false;
|
sidebarOpen.value = false;
|
||||||
if (v === 'assets') fetchAssetsManifest();
|
|
||||||
if (v === 'logs') fetchLogs();
|
|
||||||
if (v === 'iccards') {
|
if (v === 'iccards') {
|
||||||
fetchIcCards(true).catch(console.error);
|
|
||||||
syncSelectedIcCard().catch(() => {});
|
|
||||||
startIcCardSync();
|
startIcCardSync();
|
||||||
} else {
|
} else {
|
||||||
stopIcCardSync();
|
stopIcCardSync();
|
||||||
}
|
}
|
||||||
|
ensureViewData(v).catch(console.error);
|
||||||
const sp = new URLSearchParams(location.search);
|
const sp = new URLSearchParams(location.search);
|
||||||
if (v === 'dashboard') sp.delete('view');
|
if (v === 'dashboard') sp.delete('view');
|
||||||
else sp.set('view', v);
|
else sp.set('view', v);
|
||||||
@@ -1300,13 +1461,12 @@ createApp({
|
|||||||
|
|
||||||
// Initial Load
|
// Initial Load
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData();
|
ensureViewData(currentView.value, { force: true }).catch(console.error);
|
||||||
fetchAssetsManifest();
|
|
||||||
if (currentView.value === 'iccards') {
|
if (currentView.value === 'iccards') {
|
||||||
fetchIcCards(true).catch(console.error);
|
|
||||||
startIcCardSync();
|
startIcCardSync();
|
||||||
}
|
}
|
||||||
window.addEventListener('mouseup', async () => {
|
appMouseupHandler = async () => {
|
||||||
|
endLineViewportPan();
|
||||||
if (draggingStationIndex.value !== null) {
|
if (draggingStationIndex.value !== null) {
|
||||||
if (selectedLine.value) {
|
if (selectedLine.value) {
|
||||||
try {
|
try {
|
||||||
@@ -1323,16 +1483,48 @@ createApp({
|
|||||||
}
|
}
|
||||||
draggingStationIndex.value = null;
|
draggingStationIndex.value = null;
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
window.addEventListener('mouseup', appMouseupHandler);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopIcCardSync();
|
stopIcCardSync();
|
||||||
|
if (appMouseupHandler) {
|
||||||
|
window.removeEventListener('mouseup', appMouseupHandler);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const recentLogs = computed(() => logs.value);
|
const recentLogs = computed(() => logs.value);
|
||||||
const orderList = computed(() => orders.value);
|
const orderList = computed(() => orders.value);
|
||||||
|
const lineEditorSvgWidth = computed(() => {
|
||||||
|
const count = Array.isArray(selectedLine.value?.stations) ? selectedLine.value.stations.length : 0;
|
||||||
|
return Math.max(960, 100 + Math.max(0, count - 1) * 120 + 120);
|
||||||
|
});
|
||||||
|
const lastSyncText = computed(() => lastSyncAt.value ? formatTime(lastSyncAt.value) : '尚未同步');
|
||||||
|
const isViewBusy = computed(() => {
|
||||||
|
if (loadingState.core) return true;
|
||||||
|
if (currentView.value === 'tickets') return loadingState.tickets;
|
||||||
|
if (currentView.value === 'vouchers') return loadingState.orders;
|
||||||
|
if (currentView.value === 'logs') return logLoading.value;
|
||||||
|
if (currentView.value === 'iccards') return loadingState.iccards;
|
||||||
|
if (currentView.value === 'faremap') return fareMapLoading.value;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const currentViewSummary = computed(() => {
|
||||||
|
const map = {
|
||||||
|
dashboard: `已同步 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
|
||||||
|
management: `当前可编辑 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
|
||||||
|
faremap: fareMapLoading.value ? '票价图正在生成中' : '可导出当前铁路票价图',
|
||||||
|
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
|
||||||
|
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
|
||||||
|
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
|
||||||
|
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
|
||||||
|
settings: '可维护优惠活动与导出数据',
|
||||||
|
logs: `当前筛选结果 ${logs.value.length} 条日志`
|
||||||
|
};
|
||||||
|
return map[currentView.value] || '后台模块已就绪';
|
||||||
|
});
|
||||||
const icCardStats = computed(() => ({
|
const icCardStats = computed(() => ({
|
||||||
total: icCards.value.length,
|
total: icCards.value.length,
|
||||||
pending: icCards.value.filter((card) => card.status === 'pending_pickup').length,
|
pending: icCards.value.filter((card) => card.status === 'pending_pickup').length,
|
||||||
@@ -1388,7 +1580,8 @@ createApp({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentView, viewTitle, connected, sidebarOpen,
|
currentView, viewTitle, sidebarOpen,
|
||||||
|
loadingState, isViewBusy, lastSyncText, currentViewSummary,
|
||||||
stations, lines, fares, stats, config, recentLogs, ticketList,
|
stations, lines, fares, stats, config, recentLogs, ticketList,
|
||||||
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
|
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
|
||||||
orders, orderList, fetchOrders, deleteOrder,
|
orders, orderList, fetchOrders, deleteOrder,
|
||||||
@@ -1400,6 +1593,7 @@ createApp({
|
|||||||
|
|
||||||
// Management
|
// Management
|
||||||
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
|
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
|
||||||
|
visualLineViewport, lineViewportPan, lineEditorSvgWidth, startLineViewportPan, moveLineViewportPan,
|
||||||
selectLine, createLine, deleteLine, deleteStation, updateLineInfo, getFareText,
|
selectLine, createLine, deleteLine, deleteStation, updateLineInfo, getFareText,
|
||||||
isStationInLine, addStationToLine, removeStationFromLine,
|
isStationInLine, addStationToLine, removeStationFromLine,
|
||||||
handleStationClick, isStationSelected,
|
handleStationClick, isStationSelected,
|
||||||
@@ -1412,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
|
||||||
|
|||||||
+17
-23
@@ -4,22 +4,17 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>鎺у埗鍙扮櫥褰?/title>
|
<title>后台控制台登录</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="/style.css?v=12" />
|
<link rel="stylesheet" href="/style.css?v=13" />
|
||||||
</head>
|
</head>
|
||||||
<body class="jr-admin-login-page">
|
<body class="jr-admin-login-page">
|
||||||
<div class="jr-admin-login-shell">
|
<div class="jr-admin-login-shell">
|
||||||
<header class="jr-topbar">
|
<header class="jr-topbar">
|
||||||
<div class="jr-topbar-inner">
|
<div class="jr-topbar-inner">
|
||||||
<a href="/" class="jr-top-link">
|
<a href="/" class="jr-top-link">
|
||||||
<span>FSE閾佽矾绁ㄥ姟绯荤粺鎺у埗鍙?/span>
|
<span>FSE铁路票务系统控制台</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
|
||||||
<span class="jr-top-status-label">鏈嶅姟鍣ㄧ姸鎬?/span>
|
|
||||||
<span class="jr-top-status-dot"></span>
|
|
||||||
<span class="jr-top-status-value" data-server-status-value>妫€娴嬩腑</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -28,8 +23,8 @@
|
|||||||
<a href="/" class="jr-brand">
|
<a href="/" class="jr-brand">
|
||||||
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
||||||
<div class="jr-brand-copy">
|
<div class="jr-brand-copy">
|
||||||
<strong>FSE 閾佽矾杩愯緭</strong>
|
<strong>FarSight-T.N.E铁路运输</strong>
|
||||||
<span>鎺у埗鍙扮櫥褰?/span>
|
<span>控制台登录</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,38 +34,37 @@
|
|||||||
<section class="jr-admin-login-panel">
|
<section class="jr-admin-login-panel">
|
||||||
<div class="jr-admin-login-copy">
|
<div class="jr-admin-login-copy">
|
||||||
<span class="jr-kicker">OPERATIONS ACCESS</span>
|
<span class="jr-kicker">OPERATIONS ACCESS</span>
|
||||||
<h1>鍚庡彴鎺у埗鍙?/h1>
|
<h1>后台控制台</h1>
|
||||||
<p>绾胯矾缁存姢銆佺エ鎹鐞嗐€佹棩蹇楁煡璇笌 IC 鍗$鐞?/p>
|
<p>线路维护、票务管理、日志查询与 IC 卡管理统一从这里进入。</p>
|
||||||
<ul class="jr-admin-login-points">
|
<ul class="jr-admin-login-points">
|
||||||
<li>缁熶竴绠$悊绾胯矾銆佺エ浠峰拰璧勬簮鍥炬枃浠?/li>
|
<li>统一管理线路、票价和资源图文件</li>
|
||||||
<li>鏌ョ湅鐢靛瓙绁ㄣ€佸嚟璇佷笌鎿嶄綔鏃ュ織</li>
|
<li>查看电子票、凭证与操作日志</li>
|
||||||
<li>缁存姢 IC 鍗″彂琛屻€佸厖鍊间笌鐘舵€佽褰?/li>
|
<li>维护 IC 卡发放、充值与状态记录</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="jr-admin-login-card">
|
<section class="jr-admin-login-card">
|
||||||
<div class="jr-page-intro jr-page-intro-compact">
|
<div class="jr-page-intro jr-page-intro-compact">
|
||||||
<span class="jr-kicker">SIGN IN</span>
|
<span class="jr-kicker">SIGN IN</span>
|
||||||
<h2>鎺у埗鍙扮櫥褰?/h2>
|
<h2>控制台登录</h2>
|
||||||
<p>璇疯緭鍏ョ鐞嗗憳璐﹀彿鍜屽瘑鐮併€?/p>
|
<p>请输入管理员账号和密码。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="login-row"><input id="loginUser" type="text" placeholder="鐢ㄦ埛鍚? /></div>
|
<div class="login-row"><input id="loginUser" type="text" placeholder="用户名" /></div>
|
||||||
<div class="login-row"><input id="loginPass" type="password" placeholder="瀵嗙爜" /></div>
|
<div class="login-row"><input id="loginPass" type="password" placeholder="密码" /></div>
|
||||||
<div class="login-actions">
|
<div class="login-actions">
|
||||||
<button id="loginBtn" class="btn primary">鐧诲綍</button>
|
<button id="loginBtn" class="btn primary">登录</button>
|
||||||
<span id="loginHint" class="hint"></span>
|
<span id="loginHint" class="hint"></span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
|
||||||
<span class="version">v1.0.12</span>
|
<span class="version">v1.0.12</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
|
||||||
<script src="login.js?v=2"></script>
|
<script src="login.js?v=2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+356
-11
@@ -28,6 +28,8 @@
|
|||||||
html, body {
|
html, body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -684,6 +686,7 @@ main {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.management-main {
|
.management-main {
|
||||||
@@ -700,6 +703,7 @@ main {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-item {
|
.line-item {
|
||||||
@@ -769,18 +773,26 @@ main {
|
|||||||
|
|
||||||
.visual-line-container {
|
.visual-line-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-x: auto;
|
overflow: auto;
|
||||||
overflow-y: hidden;
|
|
||||||
background-color: #00000022;
|
background-color: #00000022;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
|
min-height: 0;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visual-line-container svg {
|
.visual-line-container svg {
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-line-container.is-panning {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.station-node {
|
.station-node {
|
||||||
@@ -2578,6 +2590,39 @@ body.jr-public-page {
|
|||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jr-query-overview {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-query-stat {
|
||||||
|
padding: 18px 20px;
|
||||||
|
background: linear-gradient(180deg, #f7faf7 0, #ffffff 100%);
|
||||||
|
border: 1px solid #d7e0d3;
|
||||||
|
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-query-stat-label {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #6a786d;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-query-stat strong {
|
||||||
|
display: block;
|
||||||
|
color: #163024;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-query-stat p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: #647266;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
.jr-grid-two {
|
.jr-grid-two {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -2681,6 +2726,13 @@ body.jr-public-page {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jr-search-helper {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #66756a;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
.jr-search-input,
|
.jr-search-input,
|
||||||
body.jr-public-page .jr-search-input {
|
body.jr-public-page .jr-search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2740,16 +2792,22 @@ body.jr-public-page .jr-search-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.jr-ticket-row {
|
.jr-ticket-row {
|
||||||
padding: 18px 0;
|
padding: 18px 14px;
|
||||||
border-bottom: 1px solid #e4ece2;
|
border-bottom: 1px solid #e4ece2;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jr-ticket-row:hover {
|
.jr-ticket-row:hover {
|
||||||
background: #f7faf7;
|
background: #f7faf7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jr-ticket-row.is-active {
|
||||||
|
background: linear-gradient(180deg, #f4f8f4 0, #ffffff 100%);
|
||||||
|
border-left-color: #0b6b3a;
|
||||||
|
}
|
||||||
|
|
||||||
.jr-ticket-row:last-child {
|
.jr-ticket-row:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
@@ -2767,6 +2825,13 @@ body.jr-public-page .jr-search-button:hover {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jr-list-meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #728077;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.jr-ticket-id {
|
.jr-ticket-id {
|
||||||
color: #1b3022;
|
color: #1b3022;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -2908,6 +2973,12 @@ body.jr-public-page .jr-search-button:hover {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jr-detail-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.jr-popular-item {
|
.jr-popular-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3161,6 +3232,35 @@ body.jr-public-page .jr-secondary-btn:hover {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jr-guide-card {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-guide-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-guide-item {
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #f7faf7;
|
||||||
|
border: 1px solid #dfe8dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-guide-item strong {
|
||||||
|
display: block;
|
||||||
|
color: #173225;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-guide-item span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #647266;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
body.jr-ticket-board-page,
|
body.jr-ticket-board-page,
|
||||||
body.jr-ticket-board-page #app,
|
body.jr-ticket-board-page #app,
|
||||||
body.jr-ticket-board-page .jr-public-shell {
|
body.jr-ticket-board-page .jr-public-shell {
|
||||||
@@ -3381,6 +3481,12 @@ body.jr-ticket-board-page .jr-board-card:last-child {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ic-admin-layout .management-sidebar,
|
||||||
|
.ic-admin-layout .management-main,
|
||||||
|
.jr-admin-list-card {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.ic-form-grid {
|
.ic-form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -3566,6 +3672,37 @@ body.jr-ticket-board-page .jr-board-card:last-child {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jr-page-intro h1 {
|
||||||
|
font-size: clamp(1.75rem, 7vw, 2.35rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-panel-headline {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-query-stat,
|
||||||
|
.jr-ticket-preview,
|
||||||
|
.jr-history-item,
|
||||||
|
.jr-popular-item,
|
||||||
|
.jr-guide-item {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-ticket-row {
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-scroll-box {
|
||||||
|
min-height: 260px;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-center-empty {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.jr-order-info-grid {
|
.jr-order-info-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -3592,6 +3729,11 @@ body.jr-ticket-board-page .jr-board-card:last-child {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jr-action-row .btn,
|
||||||
|
.jr-action-row button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.jr-home-alert {
|
.jr-home-alert {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -3727,6 +3869,31 @@ body.jr-admin-login-page {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-sync-meta {
|
||||||
|
min-width: 118px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d8e2d4;
|
||||||
|
background: #f8fbf7;
|
||||||
|
color: #385446;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-sync-meta strong {
|
||||||
|
display: block;
|
||||||
|
color: #143423;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-sync-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #6a7d72;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jr-admin-header-pill {
|
.jr-admin-header-pill {
|
||||||
@@ -3762,6 +3929,149 @@ body.jr-admin-login-page {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jr-admin-overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-overview-card {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #d7e0d3;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 10px 24px rgba(18, 50, 33, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-overview-card.is-actions {
|
||||||
|
background: linear-gradient(180deg, #f8fbf7 0, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-overview-label {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #0b6b3a;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-overview-value {
|
||||||
|
display: block;
|
||||||
|
color: #143423;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-overview-note {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: #627368;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-overview-actions,
|
||||||
|
.jr-admin-card-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-overview-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-section-toolbar {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid #d7e0d3;
|
||||||
|
background: linear-gradient(135deg, rgba(11, 107, 58, 0.05) 0, rgba(11, 107, 58, 0.015) 28%, #ffffff 28%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-section-toolbar-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-section-toolbar-copy strong {
|
||||||
|
display: block;
|
||||||
|
color: #143423;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-section-toolbar-copy p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #627368;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-card-note,
|
||||||
|
.jr-admin-list-meta {
|
||||||
|
color: #6a7c70;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-note-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: #3c594a;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-list-card .jr-scroll-box {
|
||||||
|
padding-right: 4px;
|
||||||
|
min-height: 320px;
|
||||||
|
max-height: 560px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-summary-item {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #dbe5d8;
|
||||||
|
background: #f8fbf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-summary-item span,
|
||||||
|
.jr-admin-summary-item small {
|
||||||
|
display: block;
|
||||||
|
color: #687a70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-summary-item span {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-summary-item strong {
|
||||||
|
display: block;
|
||||||
|
color: #143423;
|
||||||
|
font-size: 1.18rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-summary-item small {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.jr-admin-page .card {
|
.jr-admin-page .card {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #d7e0d3;
|
border: 1px solid #d7e0d3;
|
||||||
@@ -3962,6 +4272,16 @@ body.jr-admin-login-page {
|
|||||||
.jr-admin-login-panel {
|
.jr-admin-login-panel {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jr-admin-overview-grid,
|
||||||
|
.jr-admin-summary-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-section-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -3980,6 +4300,38 @@ body.jr-admin-login-page {
|
|||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jr-admin-login-copy,
|
||||||
|
.jr-admin-login-card {
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-header-side,
|
||||||
|
.jr-admin-overview-actions,
|
||||||
|
.jr-admin-card-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-sync-meta,
|
||||||
|
.jr-admin-overview-grid,
|
||||||
|
.jr-admin-summary-grid {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-overview-grid,
|
||||||
|
.jr-admin-summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-overview-card,
|
||||||
|
.jr-admin-section-toolbar {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jr-admin-header-side .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Custom Dialog --- */
|
/* --- Custom Dialog --- */
|
||||||
|
|
||||||
@@ -4102,10 +4454,3 @@ body.jr-admin-login-page {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.jr-admin-login-copy,
|
|
||||||
.jr-admin-login-card {
|
|
||||||
padding: 22px;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+106
-88
@@ -4,10 +4,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FSE閾佽矾鐢靛瓙瀹㈢エ</title>
|
<title>FSE铁路电子客票</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=12">
|
<link rel="stylesheet" href="/style.css?v=13">
|
||||||
<style>
|
<style>
|
||||||
[v-cloak] {
|
[v-cloak] {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -49,12 +49,12 @@
|
|||||||
<div class="jr-topbar-inner">
|
<div class="jr-topbar-inner">
|
||||||
<a href="javascript:void(0)" @click="goHome" class="jr-top-link">
|
<a href="javascript:void(0)" @click="goHome" class="jr-top-link">
|
||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
<span>杩斿洖鏌ヨ</span>
|
<span>返回查询</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
<div class="jr-top-status is-checking" data-server-status-root>
|
||||||
<span class="jr-top-status-label">鏈嶅姟鍣ㄧ姸鎬?/span>
|
<span class="jr-top-status-label">服务器状态</span>
|
||||||
<span class="jr-top-status-dot"></span>
|
<span class="jr-top-status-dot"></span>
|
||||||
<span class="jr-top-status-value" data-server-status-value>妫€娴嬩腑</span>
|
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -63,28 +63,28 @@
|
|||||||
<a href="javascript:void(0)" @click="goHome" class="jr-brand">
|
<a href="javascript:void(0)" @click="goHome" class="jr-brand">
|
||||||
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
||||||
<div class="jr-brand-copy">
|
<div class="jr-brand-copy">
|
||||||
<strong>FSE閾佽矾绁ㄥ姟绯荤粺</strong>
|
<strong>FarSight-T.N.E铁路运输</strong>
|
||||||
<span>鐢靛瓙瀹㈢エ淇℃伅</span>
|
<span>电子客票信息</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<nav class="jr-nav" aria-label="绔欑偣瀵艰埅">
|
<nav class="jr-nav" aria-label="站点导航">
|
||||||
<a href="https://ticket.fse-media.group/home.html" data-link="home">棣栭〉</a>
|
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
|
||||||
<a href="https://ticket.fse-media.group/order" data-link="order">绾夸笂棰勫畾</a>
|
<a href="https://ticket.fse-media.group/order" data-link="order">线上预定</a>
|
||||||
<a href="https://ticket.fse-media.group/search" data-link="search" class="is-active">杞︾エ鏌ヨ</a>
|
<a href="https://ticket.fse-media.group/search" data-link="search" class="is-active">车票查询</a>
|
||||||
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 鍗℃煡璇?/a>
|
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
|
||||||
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">绾夸笂璐崱</a>
|
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main class="jr-public-main">
|
<main class="jr-public-main">
|
||||||
<section class="jr-page-intro">
|
<section class="jr-page-intro">
|
||||||
<span class="jr-kicker">ELECTRONIC TICKET</span>
|
<span class="jr-kicker">ELECTRONIC TICKET</span>
|
||||||
<h1>鏌ョ湅杞︾エ鐘舵€佷笌鏈€杩戞祦杞褰?/h1>
|
<h1>查看车票状态与最近流转记录</h1>
|
||||||
<p>鐢ㄤ簬鏌ョ湅鍗曞紶鐢靛瓙瀹㈢エ鐨勪箻杞︿俊鎭€佺姸鎬佷笌杩涘嚭绔欒褰曪紝渚夸簬鏃呭鍜屽伐浣滀汉鍛樺揩閫熺‘璁ょエ鎹姸鎬併€?/p>
|
<p>用于查看单张电子客票的乘车信息、状态与进出站记录</p>
|
||||||
</section>
|
</section>
|
||||||
<div v-if="loading" class="jr-panel-card">
|
<div v-if="loading" class="jr-panel-card">
|
||||||
<div class="jr-center-empty">
|
<div class="jr-center-empty">
|
||||||
<p>姝e湪璇诲彇绁ㄦ嵁鏁版嵁...</p>
|
<p>正在读取车票数据...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="!loading && hasTicket">
|
<template v-if="!loading && hasTicket">
|
||||||
@@ -92,8 +92,9 @@
|
|||||||
<article class="jr-board-card">
|
<article class="jr-board-card">
|
||||||
<div class="jr-panel-headline">
|
<div class="jr-panel-headline">
|
||||||
<h2 class="mono">{{ ticket.ticket_id }}</h2>
|
<h2 class="mono">{{ ticket.ticket_id }}</h2>
|
||||||
<span class="jr-status-pill" :class="statusInfo.class.replace('status-', 'jr-status-')">{{
|
<span class="jr-status-pill" :class="statusInfo.class.replace('status-', 'jr-status-')">
|
||||||
statusInfo.text }}</span>
|
{{ statusInfo.text }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-route-board">
|
<div class="jr-route-board">
|
||||||
<div class="jr-station-block">
|
<div class="jr-station-block">
|
||||||
@@ -114,46 +115,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="jr-meta-grid">
|
<div class="jr-meta-grid">
|
||||||
<div class="jr-meta-item">
|
<div class="jr-meta-item">
|
||||||
<span>杞﹀瀷</span>
|
<span>车型</span>
|
||||||
<strong>{{ formatTrainType(ticket.overview.train_type) }}</strong>
|
<strong>{{ formatTrainType(ticket.overview.train_type) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-meta-item">
|
<div class="jr-meta-item">
|
||||||
<span>绁ㄤ环</span>
|
<span>票价</span>
|
||||||
<strong>楼 {{ ticket.overview.amount || 0 }}</strong>
|
<strong>¥ {{ ticket.overview.amount || 0 }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-meta-item">
|
<div class="jr-meta-item">
|
||||||
<span>涔樻</span>
|
<span>乘次</span>
|
||||||
<strong>{{ (ticket.overview.trips_remaining == null ? 1 :
|
<strong>{{ (ticket.overview.trips_remaining == null ? 1 : ticket.overview.trips_remaining) }} / {{ (ticket.overview.trips_total == null ? 1 : ticket.overview.trips_total) }}</strong>
|
||||||
ticket.overview.trips_remaining) }} / {{ (ticket.overview.trips_total == null ? 1 :
|
|
||||||
ticket.overview.trips_total) }}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-meta-item">
|
<div class="jr-meta-item">
|
||||||
<span>鏇存柊鏃堕棿</span>
|
<span>更新时间</span>
|
||||||
<strong>{{ formatTime(ticket.overview.last_update_ts) }}</strong>
|
<strong>{{ formatTime(ticket.overview.last_update_ts) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<aside class="jr-board-card">
|
<aside class="jr-board-card">
|
||||||
<div class="jr-panel-headline">
|
<div class="jr-panel-headline">
|
||||||
<h3>娴佽浆璁板綍</h3>
|
<h3>流转记录</h3>
|
||||||
<span class="jr-panel-note">Recent Events</span>
|
<span class="jr-panel-note">Recent Events</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-history-list" v-if="ticket.events && ticket.events.length > 0">
|
<div class="jr-history-list" v-if="ticket.events && ticket.events.length > 0">
|
||||||
<div v-for="ev in ticket.events.slice().reverse().slice(0, 5)"
|
<div v-for="ev in ticket.events.slice().reverse().slice(0, 5)"
|
||||||
:key="ev.ts || ev.鏃堕棿鎴? class="jr-history-item">
|
:key="ev.ts || ev['时间戳'] || Math.random()"
|
||||||
|
class="jr-history-item">
|
||||||
<div class="jr-history-row">
|
<div class="jr-history-row">
|
||||||
<span class="jr-history-title">{{ formatEvent(ev) }}</span>
|
<span class="jr-history-title">{{ formatEvent(ev) }}</span>
|
||||||
<span class="jr-history-time">{{ formatTime(ev.鏃堕棿鎴?|| ev.ts) }}</span>
|
<span class="jr-history-time">{{ formatTime(ev['时间戳'] || ev.ts) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-history-desc">
|
<div class="jr-history-desc">
|
||||||
<div>{{ formatEventLocation(ev) }}</div>
|
<div>{{ formatEventLocation(ev) }}</div>
|
||||||
<div v-if="formatEventMeta(ev)" style="margin-top:4px;">{{ formatEventMeta(ev) }}
|
<div v-if="formatEventMeta(ev)" style="margin-top:4px;">{{ formatEventMeta(ev) }}</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="jr-center-empty">
|
<div v-else class="jr-center-empty">
|
||||||
<p>鏆傛棤娴佽浆璁板綍銆?/p>
|
<p>暂无流转记录。</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
@@ -161,23 +160,23 @@
|
|||||||
|
|
||||||
<div v-if="!loading && !hasTicket" class="jr-panel-card">
|
<div v-if="!loading && !hasTicket" class="jr-panel-card">
|
||||||
<div class="jr-center-empty">
|
<div class="jr-center-empty">
|
||||||
<h2 style="margin:0 0 10px;">鏃犳晥杞︾エ</h2>
|
<h2 style="margin:0 0 10px;">无效车票</h2>
|
||||||
<p>鏈壘鍒拌杞︾エ鐨勮缁嗕俊鎭€?/p>
|
<p>未找到该车票的详细信息。</p>
|
||||||
<div class="jr-action-row">
|
<div class="jr-action-row">
|
||||||
<button @click="goHome" class="btn primary jr-search-button">杩斿洖鏌ヨ</button>
|
<button @click="goHome" class="btn primary jr-search-button">返回查询</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="site-footer jr-footer-space">
|
<footer class="site-footer jr-footer-space">
|
||||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
|
||||||
<span class="version">v1.0.12</span>
|
<span class="version">v1.0.12</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
<script src="/public-status.js?v=13"></script>
|
||||||
<script src="/ai-assistant.js?v=6"></script>
|
<script src="/ai-assistant.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -218,60 +217,74 @@
|
|||||||
const statusInfo = computed(() => {
|
const statusInfo = computed(() => {
|
||||||
if (!hasTicket.value) return {};
|
if (!hasTicket.value) return {};
|
||||||
let raw = '';
|
let raw = '';
|
||||||
if (ticket.value && ticket.value.overview) {
|
if (ticket.value && ticket.value.overview && ticket.value.overview.status != null) {
|
||||||
if (ticket.value.overview.status != null) raw = ticket.value.overview.status;
|
raw = ticket.value.overview.status;
|
||||||
}
|
}
|
||||||
if (!raw && ticket.value) {
|
if (!raw && ticket.value && ticket.value.status != null) {
|
||||||
if (ticket.value.status != null) raw = ticket.value.status;
|
raw = ticket.value.status;
|
||||||
}
|
}
|
||||||
const status = String(raw).toLowerCase();
|
const status = String(raw).toLowerCase();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
status === '鏈夋晥' ||
|
status === '有效' ||
|
||||||
status === 'valid' ||
|
status === 'valid' ||
|
||||||
status === 'unused' ||
|
status === 'unused' ||
|
||||||
status === 'active' ||
|
status === 'active' ||
|
||||||
status.includes('鏈夋晥') ||
|
status.includes('有效') ||
|
||||||
status.includes('鏈娇鐢?) ||
|
status.includes('未使用') ||
|
||||||
status.includes('unused')
|
status.includes('unused')
|
||||||
) {
|
) {
|
||||||
return { text: '鏈夋晥', class: 'status-valid' };
|
return { text: '有效', class: 'status-valid' };
|
||||||
}
|
}
|
||||||
if (status === '宸蹭娇鐢? || status === 'used' || status.includes('宸蹭娇鐢?) || status.includes('used')) {
|
if (
|
||||||
return { text: '宸蹭娇鐢?, class: 'status-used' };
|
status === '已使用' ||
|
||||||
|
status === 'used' ||
|
||||||
|
status.includes('已使用') ||
|
||||||
|
status.includes('used')
|
||||||
|
) {
|
||||||
|
return { text: '已使用', class: 'status-used' };
|
||||||
}
|
}
|
||||||
return { text: '澶辨晥', class: 'status-expired' };
|
return { text: '失效', class: 'status-expired' };
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatTime = (timestamp) => {
|
const formatTime = (timestamp) => {
|
||||||
if (!timestamp) return '---';
|
if (!timestamp) return '---';
|
||||||
let ts = Number(timestamp);
|
let ts = Number(timestamp);
|
||||||
if (!Number.isFinite(ts)) return String(timestamp);
|
if (!Number.isFinite(ts)) return String(timestamp);
|
||||||
if (ts > 0 && ts < 1000000000000) ts = ts * 1000;
|
if (ts > 0 && ts < 1000000000000) ts *= 1000;
|
||||||
const date = new Date(ts);
|
const date = new Date(ts);
|
||||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit'});
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatEvent = (event) => {
|
const formatEvent = (event) => {
|
||||||
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
|
const type = String(event.type || event['类型'] || '').toLowerCase();
|
||||||
const action = String(event.action || event.鍔ㄤ綔 || '').toLowerCase();
|
const action = String(event.action || event['动作'] || '').toLowerCase();
|
||||||
|
|
||||||
if (type === '鐘舵€? || type === 'status') {
|
if (type === '状态' || type === 'status') {
|
||||||
const actionMap = { 'entry': '杩涚珯鎴愬姛', 'exit': '鍑虹珯鎴愬姛' };
|
const actionMap = { entry: '进站成功', exit: '出站成功' };
|
||||||
return actionMap[action] || '鐘舵€佸彉鏇?;
|
return actionMap[action] || '状态变更';
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeMap = { 'sale': '鍞エ鎴愬姛', '鍞エ': '鍞エ鎴愬姛', 'entry': '杩涚珯鎴愬姛', 'exit': '鍑虹珯鎴愬姛' };
|
const typeMap = {
|
||||||
return typeMap[type] || event.type || event.绫诲瀷 || '鐘舵€佸彉鏇?;
|
sale: '售票成功',
|
||||||
|
售票: '售票成功',
|
||||||
|
entry: '进站成功',
|
||||||
|
exit: '出站成功'
|
||||||
|
};
|
||||||
|
return typeMap[type] || event.type || event['类型'] || '状态变更';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatEventLocation = (event) => {
|
const formatEventLocation = (event) => {
|
||||||
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
|
const type = String(event.type || event['类型'] || '').toLowerCase();
|
||||||
const stationName = event.station_name || event.鍞エ绔?|| event.鍙戠敓绔?|| '';
|
const stationName = event.station_name || event['售票站'] || event['发生站'] || '';
|
||||||
const stationCode = event.station_code || event.绔欑偣缂栧彿 || '';
|
const stationCode = event.station_code || event['站点编号'] || '';
|
||||||
|
|
||||||
if (type === 'sale' || type === '鍞エ') {
|
if (type === 'sale' || type === '售票') {
|
||||||
return stationName || '绾夸笂鍞エ';
|
return stationName || '线上售票';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stationName && !stationCode) return '---';
|
if (!stationName && !stationCode) return '---';
|
||||||
@@ -279,25 +292,25 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatEventMeta = (event) => {
|
const formatEventMeta = (event) => {
|
||||||
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
|
const type = String(event.type || event['类型'] || '').toLowerCase();
|
||||||
if (type === 'sale' || type === '鍞エ') {
|
if (type === 'sale' || type === '售票') {
|
||||||
const amount = event.amount ?? event.鍞エ棰?
|
const amount = event.amount ?? event['售票额'];
|
||||||
if (amount != null && amount !== '') return `绁ㄤ环锛毬?${amount}`;
|
if (amount != null && amount !== '') return `票价:¥ ${amount}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stationEn = event.station_en || event.绔欑偣鑻辨枃 || '';
|
const stationEn = event.station_en || event['站点英文'] || '';
|
||||||
const deviceId = event.device_id || event.璁惧缂栧彿 || '';
|
const deviceId = event.device_id || event['设备编号'] || '';
|
||||||
if (stationEn && deviceId) return `${stationEn} (${deviceId})`;
|
if (stationEn && deviceId) return `${stationEn} (${deviceId})`;
|
||||||
if (deviceId) return `璁惧锛?{deviceId}`;
|
if (deviceId) return `设备:${deviceId}`;
|
||||||
return stationEn;
|
return stationEn;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTrainType = (type) => {
|
const formatTrainType = (type) => {
|
||||||
if (!type) return '鏅€?;
|
if (!type) return '普通';
|
||||||
const t = type.toLowerCase();
|
const t = String(type).toLowerCase();
|
||||||
if (t === 'local') return '鏅€?;
|
if (t === 'local') return '普通';
|
||||||
if (t === 'ltd.exp' || t === 'express' || t === 'exp' || t === 'express_train') return '鐗规€?;
|
if (t === 'ltd.exp' || t === 'express' || t === 'exp' || t === 'express_train') return '特急';
|
||||||
if (t.includes('鐗规€?)) return '鐗规€?;
|
if (t.includes('特急')) return '特急';
|
||||||
return String(type);
|
return String(type);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -311,22 +324,22 @@
|
|||||||
ticket.value = null;
|
ticket.value = null;
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const id = (data && (data.ticket_id || data.杞︾エ缂栧彿 || data.id)) || ticketid;
|
const id = (data && (data.ticket_id || data['车票编号'] || data.id)) || ticketid;
|
||||||
let overview = null;
|
let overview = null;
|
||||||
if (data) {
|
if (data) {
|
||||||
if (data.overview != null) overview = data.overview;
|
if (data.overview != null) overview = data.overview;
|
||||||
else if (data.姒傝 != null) overview = data.姒傝;
|
else if (data['概览'] != null) overview = data['概览'];
|
||||||
else if (data.summary != null) overview = data.summary;
|
else if (data.summary != null) overview = data.summary;
|
||||||
}
|
}
|
||||||
let events = [];
|
let events = [];
|
||||||
if (data) {
|
if (data) {
|
||||||
if (Array.isArray(data.events)) events = data.events;
|
if (Array.isArray(data.events)) events = data.events;
|
||||||
else if (data.浜嬩欢 != null) events = data.浜嬩欢;
|
else if (data['事件'] != null) events = data['事件'];
|
||||||
}
|
}
|
||||||
if (id && overview != null) {
|
if (id && overview != null) {
|
||||||
const out = {};
|
const out = {};
|
||||||
if (data && typeof data === 'object') {
|
if (data && typeof data === 'object') {
|
||||||
for (const k in data) out[k] = data[k];
|
for (const key in data) out[key] = data[key];
|
||||||
}
|
}
|
||||||
out.ticket_id = id;
|
out.ticket_id = id;
|
||||||
out.overview = overview;
|
out.overview = overview;
|
||||||
@@ -337,7 +350,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('鑾峰彇杞︾エ鏁版嵁澶辫触:', e);
|
console.error('获取车票数据失败:', e);
|
||||||
ticket.value = null;
|
ticket.value = null;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -357,15 +370,20 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading, ticket, hasTicket, statusInfo,
|
loading,
|
||||||
formatTime, formatEvent, formatEventLocation, formatEventMeta, formatTrainType, goHome
|
ticket,
|
||||||
|
hasTicket,
|
||||||
|
statusInfo,
|
||||||
|
formatTime,
|
||||||
|
formatEvent,
|
||||||
|
formatEventLocation,
|
||||||
|
formatEventMeta,
|
||||||
|
formatTrainType,
|
||||||
|
goHome
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>线上预定</title>
|
<title>线上预定</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=12">
|
<link rel="stylesheet" href="/style.css?v=13">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="public-search jr-order-page">
|
<body class="public-search jr-order-page">
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
|
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
|
||||||
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
||||||
<div class="jr-brand-copy">
|
<div class="jr-brand-copy">
|
||||||
<strong>FSE铁路票务系统</strong>
|
<strong>FarSight-T.N.E铁路运输</strong>
|
||||||
<span>线上预定</span>
|
<span>线上预定</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -232,8 +232,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/ticket-order.js?v=20"></script>
|
<script src="/ticket-order.js?v=21"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
<script src="/public-status.js?v=13"></script>
|
||||||
<script src="/ai-assistant.js?v=6"></script>
|
<script src="/ai-assistant.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -261,3 +261,4 @@
|
|||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -408,6 +408,58 @@
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStationPoint(code) {
|
||||||
|
const normalized = String(code || '').trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
const canonical = stationCanonicalByCode[normalized] || normalized;
|
||||||
|
const x = stationXByCanonical[canonical];
|
||||||
|
const y = stationYByCanonical[normalized];
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRouteOverlay() {
|
||||||
|
const svgEl = mapContainer.querySelector('svg');
|
||||||
|
if (!svgEl) return;
|
||||||
|
|
||||||
|
const existing = svgEl.querySelector('.route-overlay-group');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
if (!Array.isArray(currentRoute) || currentRoute.length < 2) return;
|
||||||
|
|
||||||
|
const points = currentRoute.map(getStationPoint).filter(Boolean);
|
||||||
|
if (points.length < 2) return;
|
||||||
|
|
||||||
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
|
const group = document.createElementNS(ns, 'g');
|
||||||
|
group.setAttribute('class', 'route-overlay-group');
|
||||||
|
|
||||||
|
const pathData = points.map((pt, idx) => `${idx === 0 ? 'M' : 'L'} ${pt.x} ${pt.y}`).join(' ');
|
||||||
|
|
||||||
|
const glow = document.createElementNS(ns, 'path');
|
||||||
|
glow.setAttribute('d', pathData);
|
||||||
|
glow.setAttribute('fill', 'none');
|
||||||
|
glow.setAttribute('stroke', 'rgba(250, 204, 21, 0.38)');
|
||||||
|
glow.setAttribute('stroke-width', '18');
|
||||||
|
glow.setAttribute('stroke-linecap', 'round');
|
||||||
|
glow.setAttribute('stroke-linejoin', 'round');
|
||||||
|
|
||||||
|
const main = document.createElementNS(ns, 'path');
|
||||||
|
main.setAttribute('d', pathData);
|
||||||
|
main.setAttribute('fill', 'none');
|
||||||
|
main.setAttribute('stroke', '#facc15');
|
||||||
|
main.setAttribute('stroke-width', '8');
|
||||||
|
main.setAttribute('stroke-linecap', 'round');
|
||||||
|
main.setAttribute('stroke-linejoin', 'round');
|
||||||
|
|
||||||
|
group.appendChild(glow);
|
||||||
|
group.appendChild(main);
|
||||||
|
|
||||||
|
const firstStation = svgEl.querySelector('.map-station');
|
||||||
|
if (firstStation) svgEl.insertBefore(group, firstStation);
|
||||||
|
else svgEl.appendChild(group);
|
||||||
|
}
|
||||||
|
|
||||||
function updateSelectionUI(skipPreview = false) {
|
function updateSelectionUI(skipPreview = false) {
|
||||||
if (!(selection[0] && selection[1])) {
|
if (!(selection[0] && selection[1])) {
|
||||||
currentRoute = [];
|
currentRoute = [];
|
||||||
@@ -447,6 +499,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderRouteOverlay();
|
||||||
|
|
||||||
// Auto preview if both selected
|
// Auto preview if both selected
|
||||||
if(!skipPreview && selection[0] && selection[1]) previewPrice();
|
if(!skipPreview && selection[0] && selection[1]) previewPrice();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<title>FSE铁路票务系统 - 线路规划</title>
|
<title>FSE铁路票务系统 - 线路规划</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=12">
|
<link rel="stylesheet" href="/style.css?v=13">
|
||||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<a href="https://ticket.fse-media.group" class="jr-brand" id="routeBrandLink">
|
<a href="https://ticket.fse-media.group" class="jr-brand" id="routeBrandLink">
|
||||||
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
||||||
<div class="jr-brand-copy">
|
<div class="jr-brand-copy">
|
||||||
<strong>FSE 铁路运输</strong>
|
<strong>FarSight-T.N.E铁路运输</strong>
|
||||||
<span>线路规划后台</span>
|
<span>线路规划后台</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -607,9 +607,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
<script src="/public-status.js?v=13"></script>
|
||||||
<script src="ticket-route.js?v=2"></script>
|
<script src="ticket-route.js?v=3"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const isDomain = location.hostname.includes('fse-media.group');
|
const isDomain = location.hostname.includes('fse-media.group');
|
||||||
@@ -641,3 +641,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -24,7 +24,8 @@ createApp({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const connected = ref(false);
|
const connected = ref(false);
|
||||||
const socket = io({ transports: ['websocket'], upgrade: false, timeout: 20000 });
|
// Keep the legacy route console usable behind proxies that only allow polling.
|
||||||
|
const socket = io({ transports: ['polling', 'websocket'], timeout: 20000 });
|
||||||
|
|
||||||
|
|
||||||
const stations = ref([]);
|
const stations = ref([]);
|
||||||
|
|||||||
+27
-8
@@ -1,4 +1,4 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>票务查询</title>
|
<title>票务查询</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=12" />
|
<link rel="stylesheet" href="/style.css?v=14" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="public-search jr-public-page">
|
<body class="public-search jr-public-page">
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
|
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
|
||||||
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
||||||
<div class="jr-brand-copy">
|
<div class="jr-brand-copy">
|
||||||
<strong>FSE铁路售票系统</strong>
|
<strong>FarSight-T.N.E铁路运输</strong>
|
||||||
<span>票务查询</span>
|
<span>票务查询</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -49,13 +49,31 @@
|
|||||||
<section class="jr-page-intro">
|
<section class="jr-page-intro">
|
||||||
<span class="jr-kicker">TICKET SEARCH</span>
|
<span class="jr-kicker">TICKET SEARCH</span>
|
||||||
<h1>按票号、站点或日期快速查询票据</h1>
|
<h1>按票号、站点或日期快速查询票据</h1>
|
||||||
<p>输入完整票号、起终点、日期或关键词,左侧浏览结果,右侧查看票据详情与最近流转记录。</p>
|
<p>输入完整票号、起终点、日期或关键词,左侧浏览结果,右侧查看票据详情与最近流转记录。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="jr-query-overview jr-grid-three" aria-label="车票查询摘要">
|
||||||
|
<article class="jr-query-stat">
|
||||||
|
<span class="jr-query-stat-label">检索方式</span>
|
||||||
|
<strong>票号 / 站点 / 日期</strong>
|
||||||
|
<p>支持完整票号与站点关键词联合查询,适合快速反查近期票据。</p>
|
||||||
|
</article>
|
||||||
|
<article class="jr-query-stat">
|
||||||
|
<span class="jr-query-stat-label">结果浏览</span>
|
||||||
|
<strong>列表与详情并排</strong>
|
||||||
|
<p>左侧先筛选票据,右侧立即查看电子票概览与最近流转记录。</p>
|
||||||
|
</article>
|
||||||
|
<article class="jr-query-stat">
|
||||||
|
<span class="jr-query-stat-label">移动端</span>
|
||||||
|
<strong>单列阅读更顺手</strong>
|
||||||
|
<p>手机端自动切为单列,查询、结果与详情会按操作顺序依次展开。</p>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="jr-panel-card" style="margin-bottom:24px;">
|
<section class="jr-panel-card" style="margin-bottom:24px;">
|
||||||
<div class="jr-panel-headline">
|
<div class="jr-panel-headline">
|
||||||
<h2>检索条件</h2>
|
<h2>检索条件</h2>
|
||||||
<span class="jr-panel-note">Ticket ID / Station / Date</span>
|
<span class="jr-panel-note">Ticket ID / Station / Date</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-search-form">
|
<div class="jr-search-form">
|
||||||
<input id="q" class="jr-search-input" type="text"
|
<input id="q" class="jr-search-input" type="text"
|
||||||
@@ -63,6 +81,7 @@
|
|||||||
<button id="searchBtn" class="btn primary jr-search-button"><i class="fas fa-search"></i>
|
<button id="searchBtn" class="btn primary jr-search-button"><i class="fas fa-search"></i>
|
||||||
立即搜索</button>
|
立即搜索</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="jr-search-helper">可直接输入完整票号,也可输入起点、终点或日期关键字进行模糊检索。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="jr-search-results">
|
<section class="jr-search-results">
|
||||||
@@ -78,7 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<section id="detail-section">
|
<section id="detail-section" class="jr-detail-stack">
|
||||||
<article class="jr-panel-card" style="margin-bottom:20px;">
|
<article class="jr-panel-card" style="margin-bottom:20px;">
|
||||||
<div class="jr-panel-headline">
|
<div class="jr-panel-headline">
|
||||||
<h3>车票详情</h3>
|
<h3>车票详情</h3>
|
||||||
@@ -117,8 +136,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/ticket-search.js?v=11"></script>
|
<script src="/ticket-search.js?v=12"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
<script src="/public-status.js?v=13"></script>
|
||||||
<script src="/ai-assistant.js?v=6"></script>
|
<script src="/ai-assistant.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
+33
-5
@@ -4,6 +4,10 @@
|
|||||||
const detailEl = $('#detail');
|
const detailEl = $('#detail');
|
||||||
const qEl = $('#q');
|
const qEl = $('#q');
|
||||||
const btn = $('#searchBtn');
|
const btn = $('#searchBtn');
|
||||||
|
const state = {
|
||||||
|
items: [],
|
||||||
|
selectedId: ''
|
||||||
|
};
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
searchTickets: async (q) => {
|
searchTickets: async (q) => {
|
||||||
@@ -52,6 +56,13 @@
|
|||||||
return type;
|
return type;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (value) => String(value == null ? '' : value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
const getTicketId = (obj) => (obj && (obj.ticket_id || obj["车票编号"] || obj.id)) || '';
|
const getTicketId = (obj) => (obj && (obj.ticket_id || obj["车票编号"] || obj.id)) || '';
|
||||||
|
|
||||||
const isValidStatus = (status) => {
|
const isValidStatus = (status) => {
|
||||||
@@ -111,6 +122,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
function renderList(items) {
|
function renderList(items) {
|
||||||
|
state.items = items;
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
listEl.innerHTML = '<div class="jr-center-empty"><p>未找到匹配结果。</p></div>';
|
listEl.innerHTML = '<div class="jr-center-empty"><p>未找到匹配结果。</p></div>';
|
||||||
@@ -119,22 +131,35 @@
|
|||||||
items.forEach(it => {
|
items.forEach(it => {
|
||||||
const id = it.ticket_id || it["车票编号"] || '';
|
const id = it.ticket_id || it["车票编号"] || '';
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'jr-ticket-row';
|
const statusText = formatStatusText(it.status || it["状态"] || '');
|
||||||
|
const isSelected = state.selectedId === id;
|
||||||
|
row.className = `jr-ticket-row${isSelected ? ' is-active' : ''}`;
|
||||||
|
|
||||||
const overview = it.overview || it["概览"] || null;
|
const overview = it.overview || it["概览"] || null;
|
||||||
const startName = overview ? (overview.start_name || overview["起点"]) : (it.start_name || it["起点"] || '---');
|
const startName = overview ? (overview.start_name || overview["起点"]) : (it.start_name || it["起点"] || '---');
|
||||||
const terminalName = overview ? (overview.terminal_name || overview["终点"]) : (it.terminal_name || it["终点"] || '---');
|
const terminalName = overview ? (overview.terminal_name || overview["终点"]) : (it.terminal_name || it["终点"] || '---');
|
||||||
|
const updateTime = formatTime(
|
||||||
|
(overview && (overview.last_update_ts || overview["上次更新时间"])) ||
|
||||||
|
it.last_update_ts ||
|
||||||
|
it["上次更新时间"] ||
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="jr-ticket-row-head">
|
<div class="jr-ticket-row-head">
|
||||||
<span class="jr-ticket-id mono">${id}</span>
|
<span class="jr-ticket-id mono">${escapeHtml(id)}</span>
|
||||||
<i class="fas fa-chevron-right text-muted"></i>
|
<span class="jr-status-pill ${isValidStatus(statusText) ? 'jr-status-valid' : (statusText === '已使用' ? 'jr-status-used' : 'jr-status-expired')}">${escapeHtml(statusText)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-ticket-route">
|
<div class="jr-ticket-route">
|
||||||
${startName} → ${terminalName}
|
${escapeHtml(startName)} → ${escapeHtml(terminalName)}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="jr-list-meta">最近更新 ${escapeHtml(updateTime)}</div>
|
||||||
`;
|
`;
|
||||||
row.onclick = () => loadDetail(id);
|
row.onclick = () => {
|
||||||
|
state.selectedId = id;
|
||||||
|
renderList(state.items);
|
||||||
|
loadDetail(id);
|
||||||
|
};
|
||||||
listEl.appendChild(row);
|
listEl.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -208,6 +233,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetail(id) {
|
async function loadDetail(id) {
|
||||||
|
state.selectedId = id;
|
||||||
|
renderList(state.items);
|
||||||
detailEl.innerHTML = '<div class="jr-center-empty"><p>正在加载详情...</p></div>';
|
detailEl.innerHTML = '<div class="jr-center-empty"><p>正在加载详情...</p></div>';
|
||||||
try {
|
try {
|
||||||
const d = await api.ticketDetail(id);
|
const d = await api.ticketDetail(id);
|
||||||
@@ -229,6 +256,7 @@
|
|||||||
listEl.innerHTML = '<div class="jr-center-empty"><p>正在搜索...</p></div>';
|
listEl.innerHTML = '<div class="jr-center-empty"><p>正在搜索...</p></div>';
|
||||||
try {
|
try {
|
||||||
const d = await api.searchTickets(q);
|
const d = await api.searchTickets(q);
|
||||||
|
state.selectedId = state.selectedId && d.some((item) => getTicketId(item) === state.selectedId) ? state.selectedId : '';
|
||||||
renderList(d);
|
renderList(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
listEl.innerHTML = '<div class="jr-center-empty"><p>搜索失败。</p></div>';
|
listEl.innerHTML = '<div class="jr-center-empty"><p>搜索失败。</p></div>';
|
||||||
|
|||||||
+3
-2
@@ -7,7 +7,7 @@
|
|||||||
<title>凭证详情</title>
|
<title>凭证详情</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=12" />
|
<link rel="stylesheet" href="/style.css?v=13" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="public-search jr-public-page">
|
<body class="public-search jr-public-page">
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=12"></script>
|
||||||
<script src="/token.js?v=2"></script>
|
<script src="/token.js?v=2"></script>
|
||||||
<script src="/public-status.js?v=13"></script>
|
<script src="/public-status.js?v=13"></script>
|
||||||
<script src="/ai-assistant.js?v=6"></script>
|
<script src="/ai-assistant.js?v=6"></script>
|
||||||
@@ -137,3 +137,4 @@
|
|||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user