Compare commits

...

8 Commits

Author SHA1 Message Date
Henry_Du ef9926dc58 refactor(id生成): 提取公共数字编码生成函数简化逻辑
将原有的分散在generateCardId和generateTicketId中的ID生成逻辑统一调用通用函数,同时调整工单ID的前缀为固定TK。
2026-06-28 16:54:07 +08:00
Henry_Du 07e4200c17 feat(web,backend,lua,installer): 新增Lua脚本版本管理功能及相关优化
- 升级售票机、检票机内置Lua脚本版本至v1.5.8
- 新增后端配置的lua_versions字段,统一管理售票机、检票机的Lua脚本版本
- 前端新增版本管理配置页面,支持版本号配置和一键补丁升级
- 为售票机、检票机添加远程版本检测功能,屏幕显示版本匹配状态标记
- 简化installer配置交互流程,优化站点代码输入方式
- 重构后端配置规范化处理逻辑,统一配置初始化与存储流程
- 优化售票机外设检测、支付检测逻辑,修复部分已知问题
2026-06-28 16:30:17 +08:00
Henry_Du 81debd3b55 fix(补钞机): 延长错误页面的停留时长至8秒
原2秒的停留时间过短,用户无法及时看清错误提示内容
2026-06-28 13:30:30 +08:00
Henry_Du 0a70ffe931 feat(installers): 切换资源URL到main分支并添加更新脚本安装
将所有安装脚本的资源下载链接从固定commit路径切换为main分支原始路径,同时为各安装器新增下载并写入对应更新脚本的逻辑,支持后续程序更新。
2026-06-28 13:20:49 +08:00
Henry_Du a4d97fbd5a feat: 统一远程资源地址并新增售票机/补票机安装更新脚本
将所有现有文件的远程下载链接替换为Gitea仓库固定提交d6aa03d3的直接地址,移除原有签名验证参数;新增售票机、补票机的安装与更新脚本文件。
2026-06-28 13:00:56 +08:00
Henry_Du d6aa03d3a7 feat(web): 优化票务与IC卡查询页面的功能与UI
- 更新静态资源版本以清理浏览器缓存
- 新增查询概览模块与搜索辅助提示文字
- 添加XSS内容转义防护,优化列表项选中样式
- 重构IC卡查询页面布局,拆分详情与事件记录区域
- 优化移动端响应式展示效果
2026-06-28 11:20:57 +08:00
Henry_Du 042720d812 feat(web, server): 更新品牌文案,新增IC卡批量查询并重构搜索页面
统一替换全站所有HTML页面的品牌标题为FarSight-T.N.E铁路运输,调整部分页面的中文显示文案,例如删除ticket-board.html中的冗余说明文字。格式化重构blog.html的代码结构与缩进,修复末尾无换行的问题。
后端完善/ic-cards/query接口:支持空查询返回全部IC卡列表,按创建时间倒序排序,添加卡片状态和类型的标准化标签,优化请求日志记录。
全面重构IC卡搜索页面的前端逻辑,新增批量查看所有IC卡功能,支持点击卡片查看详情与操作历史,优化状态管理与渲染流程。
2026-06-28 11:02:32 +08:00
Henry_Du 7fe1acd9d7 fix: 修复网页中文乱码并优化代码与添加提交规则
修复ic-card-search.html、ic-card-order.html、ic-card-admin.html中的乱码文本,替换为正确简体中文;重新格式化三个HTML文件的内嵌脚本提升可读性;新增.trae目录下的提交规则配置文件
2026-06-28 10:53:45 +08:00
29 changed files with 1440 additions and 392 deletions
+6
View File
@@ -0,0 +1,6 @@
---
alwaysApply: true
scene: git_message
---
在此处编写规则,自定义 AI 生成提交信息的风格。
+84 -14
View File
@@ -1,7 +1,8 @@
local DEFAULT_SERVER_BASE = "http://ticket.fse-media.group" local DEFAULT_SERVER_BASE = "http://ticket.fse-media.group"
local DEFAULT_SERVER_PATH = "/api/tickets/check" local DEFAULT_SERVER_PATH = "/api/tickets/check"
local GATE_OPEN_SECONDS = 2 local GATE_OPEN_SECONDS = 2
local VERSION = "v1.5.7" local VERSION = "v1.5.8"
local VERSION_CHECK_INTERVAL = 60
local CONFIG_PATH = "gate_config.json" local CONFIG_PATH = "gate_config.json"
@@ -26,6 +27,15 @@ local function trim(s)
return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", "")) return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end end
local function normalizeVersionTag(v)
local s = trim(v)
if #s == 0 then return "" end
if s:sub(1, 1):lower() ~= "v" then
s = "v" .. s
end
return s:lower()
end
local function splitCsv(s) local function splitCsv(s)
local out = {} local out = {}
s = trim(s) s = trim(s)
@@ -80,6 +90,8 @@ local inspection = peripheral.find("ticket_inspection_machine")
local serverConnected = nil local serverConnected = nil
local serverLastChangeTs = 0 local serverLastChangeTs = 0
local expectedGateVersion = nil
local versionMismatch = nil
local function setServerConnected(ok) local function setServerConnected(ok)
if serverConnected == ok then return end if serverConnected == ok then return end
@@ -126,10 +138,20 @@ local function drawVersionIndicator(w)
local s = tostring(VERSION or "") local s = tostring(VERSION or "")
if #s == 0 then return end if #s == 0 then return end
if w < #s then return end if w < #s then return end
local markerColor = colors.yellow
if versionMismatch == true then
markerColor = colors.red
elseif versionMismatch == false then
markerColor = colors.lime
end
termDev.setBackgroundColor(colors.black) termDev.setBackgroundColor(colors.black)
termDev.setTextColor(colors.gray) termDev.setTextColor(colors.gray)
termDev.setCursorPos(1, 1) termDev.setCursorPos(1, 1)
termDev.write(s) termDev.write(s)
if w >= (#s + 1) then
termDev.setTextColor(markerColor)
termDev.write("*")
end
termDev.setTextColor(colors.white) termDev.setTextColor(colors.white)
end end
@@ -337,6 +359,23 @@ local function refreshStationNameMap(serverBase)
return true return true
end end
local function refreshRemoteLuaVersion(serverBase)
serverBase = trim(serverBase or "")
if #serverBase == 0 then return false end
local url = serverBase:gsub("/+$", "") .. "/api/public/config"
local ok, parsed = getJSON(url)
if not ok or type(parsed) ~= "table" then return false end
local remote = normalizeVersionTag(type(parsed.lua_versions) == "table" and parsed.lua_versions.gate or nil)
if #remote == 0 then
expectedGateVersion = nil
versionMismatch = nil
return true
end
expectedGateVersion = remote
versionMismatch = (remote ~= normalizeVersionTag(VERSION))
return true
end
local function inferStationCodeFromName(name) local function inferStationCodeFromName(name)
local key = normKey(name or "") local key = normKey(name or "")
if #key == 0 then return "" end if #key == 0 then return "" end
@@ -655,6 +694,10 @@ pcall(function()
refreshStationNameMap(guessBaseFromStatusURL(serverURL)) refreshStationNameMap(guessBaseFromStatusURL(serverURL))
end) end)
pcall(function()
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
end)
if not inspection then if not inspection then
if modeBySide == nil then if modeBySide == nil then
draw("Missing peripheral:", "ticket_inspection_machine", colors.red) draw("Missing peripheral:", "ticket_inspection_machine", colors.red)
@@ -667,18 +710,6 @@ if next(stationSet) == nil then
error("No station codes configured") error("No station codes configured")
end end
local stationListText = table.concat(cfg.station_codes, ",")
local function readyLine1()
if not modeBySide then
return "Ready (" .. mode:upper() .. ")"
end
local f = modeBySide.front and modeBySide.front:upper() or "-"
local b = modeBySide.back and modeBySide.back:upper() or "-"
return "Ready (BI) F:" .. f .. " B:" .. b
end
draw(readyLine1(), "Station: " .. stationListText, colors.lime)
local stationCodesPayload = {} local stationCodesPayload = {}
for k, _ in pairs(stationSet) do table.insert(stationCodesPayload, k) end for k, _ in pairs(stationSet) do table.insert(stationCodesPayload, k) end
table.sort(stationCodesPayload) table.sort(stationCodesPayload)
@@ -785,6 +816,37 @@ local function actionForSide(side)
return modeBySide[side] or mode return modeBySide[side] or mode
end end
local function shortModeLabel(v)
v = trim(v):lower()
if v == "exit" then return "OUT" end
return "IN"
end
local function readyLine1()
if not modeBySide then
return "READY " .. shortModeLabel(mode)
end
return "F " .. shortModeLabel(modeBySide.front or "") .. " B " .. shortModeLabel(modeBySide.back or "")
end
local function readyLine2()
if not modeBySide then
return "ST " .. stationCodeForSide(nil)
end
local frontCode = stationCodeForSide("front")
local backCode = stationCodeForSide("back")
if frontCode == backCode then
return "ST " .. frontCode
end
return "F " .. frontCode .. " B " .. backCode
end
local function drawReadyScreen()
draw(readyLine1(), readyLine2(), colors.lime)
end
drawReadyScreen()
local function collectInspectionDevices(side, modeBySideRef) local function collectInspectionDevices(side, modeBySideRef)
local sideKnown = trimSide(side) ~= nil local sideKnown = trimSide(side) ~= nil
local inspectionDevs = {} local inspectionDevs = {}
@@ -1278,11 +1340,19 @@ local function processInspectionEvent(eventName, ev)
end end
end end
local versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
while true do while true do
local ev = pack(os.pullEvent()) local ev = pack(os.pullEvent())
if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then
processInspectionEvent(ev[1], ev) processInspectionEvent(ev[1], ev)
os.sleep(0.35) os.sleep(0.35)
draw(readyLine1(), "Station: " .. stationListText, colors.lime) drawReadyScreen()
elseif ev[1] == "timer" and ev[2] == versionTimer then
pcall(function()
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
end)
drawReadyScreen()
versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
end end
end end
+90
View File
@@ -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
View File
@@ -1,6 +1,7 @@
local URL_ERROR = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/src/commit/108435e90d81c1c0a6e34486dee6cc6efd48ca53/error.dfpwm" local URL_ERROR = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/error.dfpwm"
local URL_PASS = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/src/commit/108435e90d81c1c0a6e34486dee6cc6efd48ca53/pass.dfpwm" 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
View File
@@ -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.")
+95
View File
@@ -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.")
+1 -1
View File
@@ -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
View File
@@ -148,14 +148,16 @@ const resolveCurrentStationCode = (body, resolveStation) => {
// Config // Config
router.get('/config', (req, res) => { router.get('/config', (req, res) => {
const cfg = DataService.getConfig();
res.json({ res.json({
api_base: DataService.getConfig().api_base, api_base: cfg.api_base,
current_station: DataService.getConfig().current_station, current_station: cfg.current_station,
stations: DataService.getStations(), stations: DataService.getStations(),
lines: DataService.getLines(), lines: DataService.getLines(),
fares: DataService.getFares(), fares: DataService.getFares(),
transfers: DataService.getConfig().transfers || [], transfers: cfg.transfers || [],
promotion: DataService.getConfig().promotion || { name: '', discount: 1 } promotion: cfg.promotion || { name: '', discount: 1 },
lua_versions: cfg.lua_versions || {}
}); });
}); });
@@ -219,6 +221,12 @@ router.put('/config', async (req, res) => {
if (!(d >= 0 && d <= 1)) return res.status(400).json({ ok: false, error: 'promotion.discount must be between 0 and 1' }); if (!(d >= 0 && d <= 1)) return res.status(400).json({ ok: false, error: 'promotion.discount must be between 0 and 1' });
cfg.promotion = { name: String(p.name || ''), discount: d }; cfg.promotion = { name: String(p.name || ''), discount: d };
} }
if (incoming.lua_versions && typeof incoming.lua_versions === 'object') {
cfg.lua_versions = {
...(cfg.lua_versions || {}),
...(incoming.lua_versions || {})
};
}
await DataService.saveConfig(cfg); await DataService.saveConfig(cfg);
appendReqLog(req, { category: 'admin', type: 'update_config_generic', detail: incoming }); appendReqLog(req, { category: 'admin', type: 'update_config_generic', detail: incoming });
io.emit('config:updated', cfg); io.emit('config:updated', cfg);
+12 -3
View File
@@ -221,7 +221,8 @@ router.get('/fares/query', (req, res) => {
router.get('/config', (req, res) => { router.get('/config', (req, res) => {
const cfg = DataService.getConfig(); const cfg = DataService.getConfig();
res.json({ res.json({
promotion: { name: cfg.promotion?.name || '', discount: cfg.promotion?.discount ?? 1 } promotion: { name: cfg.promotion?.name || '', discount: cfg.promotion?.discount ?? 1 },
lua_versions: cfg.lua_versions || {}
}); });
}); });
@@ -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
View File
@@ -21,14 +21,39 @@ const pool = mysql.createPool({
queueLimit: 0 queueLimit: 0
}); });
const DEFAULT_LUA_VERSIONS = {
ticketmachine: 'v1.5.8',
gate: 'v1.5.8'
};
function normalizeLuaVersions(input) {
const src = (input && typeof input === 'object') ? input : {};
return {
ticketmachine: String(src.ticketmachine || DEFAULT_LUA_VERSIONS.ticketmachine),
gate: String(src.gate || DEFAULT_LUA_VERSIONS.gate)
};
}
function normalizeConfig(input) {
const src = (input && typeof input === 'object') ? input : {};
return {
...src,
api_base: String(src.api_base || 'http://127.0.0.1:23333/api'),
current_station: (src.current_station && typeof src.current_station === 'object')
? src.current_station
: { name: 'Station1', code: '01-01' },
transfers: Array.isArray(src.transfers) ? src.transfers : [],
promotion: {
name: String(src?.promotion?.name || ''),
discount: Number(src?.promotion?.discount ?? 1)
},
lua_versions: normalizeLuaVersions(src.lua_versions)
};
}
// In-memory cache for synchronous read access // In-memory cache for synchronous read access
const cache = { const cache = {
config: { config: normalizeConfig({}),
api_base: 'http://127.0.0.1:23333/api',
current_station: { name: 'Station1', code: '01-01' },
transfers: [],
promotion: { name: '', discount: 1 }
},
stations: [], stations: [],
lines: [], lines: [],
fares: [], fares: [],
@@ -66,7 +91,7 @@ const DataService = {
// Load Cache // Load Cache
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']); const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
if (configs.length > 0) cache.config = configs[0].v; if (configs.length > 0) cache.config = normalizeConfig(configs[0].v);
else await conn.query('INSERT INTO kv_store (k, v) VALUES (?, ?)', ['config', JSON.stringify(cache.config)]); else await conn.query('INSERT INTO kv_store (k, v) VALUES (?, ?)', ['config', JSON.stringify(cache.config)]);
const [stations] = await conn.query('SELECT data FROM stations'); const [stations] = await conn.query('SELECT data FROM stations');
@@ -114,8 +139,8 @@ const DataService = {
// Config // Config
getConfig: () => cache.config, getConfig: () => cache.config,
saveConfig: async (cfg) => { saveConfig: async (cfg) => {
cache.config = cfg; cache.config = normalizeConfig(cfg);
await pool.query('INSERT INTO kv_store (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?', ['config', JSON.stringify(cfg), JSON.stringify(cfg)]); await pool.query('INSERT INTO kv_store (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?', ['config', JSON.stringify(cache.config), JSON.stringify(cache.config)]);
}, },
// Stations // Stations
+293 -62
View File
@@ -1,6 +1,6 @@
local CURRENT_STATION_CODE = 'Ticket-Machine' local CURRENT_STATION_CODE = 'Ticket-Machine'
local API_BASE = 'http://ticket.fse-media.group/api' local API_BASE = 'http://ticket.fse-media.group/api'
local VERSION = 'v1.5.7' local VERSION = 'v1.5.8'
-- ########################### -- ###########################
-- Core HTTP & JSON Utilities -- Core HTTP & JSON Utilities
@@ -13,6 +13,17 @@ end
local serverConnected = nil local serverConnected = nil
local serverLastChangeTs = 0 local serverLastChangeTs = 0
local expectedMachineVersion = nil
local versionMismatch = nil
local function normalizeVersionTag(v)
local s = tostring(v or ''):gsub('^%s+', ''):gsub('%s+$', '')
if #s == 0 then return '' end
if s:sub(1, 1):lower() ~= 'v' then
s = 'v' .. s
end
return s:lower()
end
local function setServerConnected(ok) local function setServerConnected(ok)
if serverConnected == ok then return end if serverConnected == ok then return end
@@ -314,22 +325,93 @@ end
-- ########################### -- ###########################
-- Peripheral discovery -- Peripheral discovery
-- ########################### -- ###########################
local monitor = peripheral.find('monitor') local SIDE_PRIORITY = { top = 1, bottom = 2, left = 3, right = 4, front = 5, back = 6 }
local ticketVendingMachine = peripheral.find('ticket_vending_machine') local REDSTONE_SIDES = { 'right', 'left', 'top', 'bottom', 'front', 'back' }
local speaker = peripheral.find('speaker') local monitor = nil
local monitorName = nil
local ticketVendingMachine = nil
local ticketVendingMachineName = nil
local speaker = nil
local speakerName = nil
local detectedPaymentSide = nil
local MOD_DEBUG = true local MOD_DEBUG = true
pcall(math.randomseed, (os.epoch and os.epoch('utc')) or os.time()) pcall(math.randomseed, (os.epoch and os.epoch('utc')) or os.time())
local function safe(term) local function comparePeripheralName(a, b)
if monitor then return peripheral.wrap(peripheral.getName(monitor)) end local pa = SIDE_PRIORITY[tostring(a or '')] or 99
return term local pb = SIDE_PRIORITY[tostring(b or '')] or 99
if pa ~= pb then return pa < pb end
return tostring(a or '') < tostring(b or '')
end end
local termDev = safe(term) local function peripheralTypeMatches(name, typeName)
if monitor then pcall(monitor.setTextScale, 0.5) end if not peripheral or type(peripheral.getType) ~= 'function' then return false end
local got = peripheral.getType(name)
if type(got) == 'string' then return got == typeName end
if type(got) == 'table' then
for _, item in ipairs(got) do
if item == typeName then return true end
end
end
return false
end
local function findPeripheralByType(typeName)
if not peripheral then return nil, nil end
if type(peripheral.getNames) == 'function' and type(peripheral.wrap) == 'function' then
local names = peripheral.getNames() or {}
table.sort(names, comparePeripheralName)
for _, name in ipairs(names) do
if peripheralTypeMatches(name, typeName) then
local okWrap, dev = pcall(peripheral.wrap, name)
if okWrap and type(dev) == 'table' then
return dev, name
end
end
end
end
if type(peripheral.find) == 'function' then
local dev = peripheral.find(typeName)
if type(dev) == 'table' then
local name = nil
if type(peripheral.getName) == 'function' then
local okName, gotName = pcall(peripheral.getName, dev)
if okName then name = gotName end
end
return dev, name
end
end
return nil, nil
end
local termDev = term
local w, h = termDev.getSize() local w, h = termDev.getSize()
local function refreshDevices()
local prevSignature = table.concat({
tostring(monitorName or ''),
tostring(ticketVendingMachineName or ''),
tostring(speakerName or '')
}, '|')
monitor, monitorName = findPeripheralByType('monitor')
ticketVendingMachine, ticketVendingMachineName = findPeripheralByType('ticket_vending_machine')
speaker, speakerName = findPeripheralByType('speaker')
termDev = monitor or term
if monitor then pcall(monitor.setTextScale, 0.5) end
w, h = termDev.getSize()
local nextSignature = table.concat({
tostring(monitorName or ''),
tostring(ticketVendingMachineName or ''),
tostring(speakerName or '')
}, '|')
if prevSignature ~= nextSignature then
os.queueEvent('config_updated')
end
end
refreshDevices()
local function saveCardIssueSnapshot(cardData) local function saveCardIssueSnapshot(cardData)
pcall(function() pcall(function()
ensureDir('logs/last_card_issue.json') ensureDir('logs/last_card_issue.json')
@@ -356,6 +438,14 @@ local function peripheralCallSucceeded(r1)
return r1 ~= nil and r1 ~= false return r1 ~= nil and r1 ~= false
end end
local function getTicketVendingMachine()
refreshDevices()
if type(ticketVendingMachine) == 'table' then
return ticketVendingMachine
end
return nil
end
local function callPeripheralMethods(dev, methodNames, variants) local function callPeripheralMethods(dev, methodNames, variants)
if type(dev) ~= 'table' then return false, 'peripheral_unavailable' end if type(dev) ~= 'table' then return false, 'peripheral_unavailable' end
for _, methodName in ipairs(methodNames) do for _, methodName in ipairs(methodNames) do
@@ -373,7 +463,7 @@ local function callPeripheralMethods(dev, methodNames, variants)
end end
local function issueBlankICCard(holderName, initialBalance) local function issueBlankICCard(holderName, initialBalance)
local dev = ticketVendingMachine local dev = getTicketVendingMachine()
if type(dev) ~= 'table' then return false, '', 'peripheral_unavailable' end if type(dev) ~= 'table' then return false, '', 'peripheral_unavailable' end
local safeHolderName = firstString(holderName, 'CARD USER') local safeHolderName = firstString(holderName, 'CARD USER')
local safeInitialBalance = math.max(0, math.floor(tonumber(initialBalance) or 0)) local safeInitialBalance = math.max(0, math.floor(tonumber(initialBalance) or 0))
@@ -395,8 +485,9 @@ local function issueBlankICCard(holderName, initialBalance)
return false, '', 'unsupported_method' return false, '', 'unsupported_method'
end end
local function writeICCard(cardData) local function writeICCard(cardData, opts)
local dev = ticketVendingMachine local dev = getTicketVendingMachine()
local options = opts or {}
local payload = {} local payload = {}
for k, v in pairs(cardData or {}) do payload[k] = v end for k, v in pairs(cardData or {}) do payload[k] = v end
payload.media = payload.media or 'ic_card' payload.media = payload.media or 'ic_card'
@@ -409,7 +500,9 @@ local function writeICCard(cardData)
saveCardIssueSnapshot(payload) saveCardIssueSnapshot(payload)
local okWrite, methodName, r1, r2, r3 = callPeripheralMethods(dev, local okWrite, methodName, r1, r2, r3 = callPeripheralMethods(dev,
{ 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' }, options.writeOnly
and { 'writeCard', 'writeICCard', 'writeTicketData', 'issueTicketData' }
or { 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' },
{ {
{ payload }, { payload },
{ tostring(payload.holder_name or ''), tonumber(payload.initial_balance) or 0 }, { tostring(payload.holder_name or ''), tonumber(payload.initial_balance) or 0 },
@@ -430,6 +523,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
View File
@@ -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
View File
@@ -1,5 +1,5 @@
local URL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/src/commit/db1562b83045284bfdec9e4a3feb829193963943/ticketmachine.lua" 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"
+85
View File
@@ -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.")
+71 -63
View File
@@ -1,66 +1,74 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FMG</title>
<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="style.css?v=13">
<link rel="stylesheet" href="blog.css?v=2">
</head>
<body class="public-search">
<div class="public-container">
<header class="search-header" style="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;">
<i class="fas fa-arrow-left"></i> 返回首页
</a>
</div>
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<img src="logo.png" alt="Logo" style="height: 40px; margin-right: 12px;">
<h1 style="font-weight: 800; letter-spacing: -1px; margin: 0; text-align: left; font-size: 26px;">
FMG
</h1>
</div>
</header>
<main>
<section class="tab-panel show">
<div class="portal-grid">
<a href="http://forum.fse-media.group" class="portal-card">
<div class="portal-icon">
<i class="fas fa-comments"></i>
</div>
<h3>论坛</h3>
<p>forum.fse-media.group</p>
</a>
<a href="http://b.igtm.ooooo.ink:11324" class="portal-card">
<div class="portal-icon">
<i class="fas fa-poll-h"></i>
</div>
<h3>问卷</h3>
<p>b.igtm.ooooo.ink</p>
</a>
</div>
<div class="card" style="margin-top: 20px;">
<div class="card-title" style="font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; color: var(--text);">
<i class="fas fa-server text-primary"></i> 服务器状态</div>
<div style="overflow-x: auto; width: 100%;">
<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>
</div>
</div>
</section>
</main>
<footer style="margin-top: 60px; padding: 20px 0; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; text-align: center;">
<p>&copy; 2026 FSE Media Group. All rights reserved.</p>
</footer>
<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>
</html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FMG</title>
<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="style.css?v=13">
<link rel="stylesheet" href="blog.css?v=2">
</head>
<body class="public-search">
<div class="public-container">
<header class="search-header" style="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;">
<i class="fas fa-arrow-left"></i> 返回首页
</a>
</div>
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<img src="logo.png" alt="Logo" style="height: 40px; margin-right: 12px;">
<h1 style="font-weight: 800; letter-spacing: -1px; margin: 0; text-align: left; font-size: 26px;">
FMG
</h1>
</div>
</header>
<main>
<section class="tab-panel show">
<div class="portal-grid">
<a href="http://forum.fse-media.group" class="portal-card">
<div class="portal-icon">
<i class="fas fa-comments"></i>
</div>
<h3>论坛</h3>
<p>forum.fse-media.group</p>
</a>
<a href="http://b.igtm.ooooo.ink:11324" class="portal-card">
<div class="portal-icon">
<i class="fas fa-poll-h"></i>
</div>
<h3>问卷</h3>
<p>b.igtm.ooooo.ink</p>
</a>
</div>
<div class="card" style="margin-top: 20px;">
<div class="card-title"
style="font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; color: var(--text);">
<i class="fas fa-server text-primary"></i> 服务器状态
</div>
<div style="overflow-x: auto; width: 100%;">
<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>
</div>
</div>
</section>
</main>
<footer
style="margin-top: 60px; padding: 20px 0; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; text-align: center;">
<p>&copy; 2026 FSE Media Group. All rights reserved.</p>
</footer>
<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>
</html>
+53 -51
View File
@@ -1,10 +1,10 @@
<!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=13"> <link rel="stylesheet" href="/style.css?v=13">
@@ -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>
@@ -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');
+2 -2
View File
@@ -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="站点导航">
+55 -37
View File
@@ -1,10 +1,10 @@
<!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=13"> <link rel="stylesheet" href="/style.css?v=13">
@@ -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,89 +30,89 @@
<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>
@@ -121,11 +121,29 @@
<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>
+136 -43
View File
@@ -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=13"> <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,75 +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=12"></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
View File
@@ -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, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
@@ -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)));
} }
})(); })();
+22 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<!-- 充满未知和不稳定的票务系统! --> <!-- 充满未知和不稳定的票务系统! -->
@@ -841,6 +841,27 @@
<button @click="saveConfig"><i class="fas fa-save"></i> 保存</button> <button @click="saveConfig"><i class="fas fa-save"></i> 保存</button>
</div> </div>
</div> </div>
<div>
<label style="display:block; margin-bottom:8px; font-weight:600;">Lua 脚本更新控制</label>
<div class="flex" style="flex-direction: column; gap: 10px;">
<div class="flex" style="flex-wrap: wrap; gap: 10px; align-items: center;">
<span style="min-width: 70px;">售票机</span>
<input v-model="config.lua_versions.ticketmachine" placeholder="例如 v1.5.8" style="max-width: 180px;">
<button @click="bumpLuaVersion('ticketmachine')"><i class="fas fa-arrow-up"></i> 补丁 +1</button>
</div>
<div class="flex" style="flex-wrap: wrap; gap: 10px; align-items: center;">
<span style="min-width: 70px;">检票机</span>
<input v-model="config.lua_versions.gate" placeholder="例如 v1.5.8" style="max-width: 180px;">
<button @click="bumpLuaVersion('gate')"><i class="fas fa-arrow-up"></i> 补丁 +1</button>
</div>
<div class="text-muted" style="font-size: 0.9rem;">
每次发布新的 Lua 脚本后,在这里手动提升一次版本号;设备检测到不一致时会在左上角版本号旁显示更新标记。
</div>
<div class="flex">
<button @click="saveConfig"><i class="fas fa-save"></i> 保存 Lua 版本</button>
</div>
</div>
</div>
</div> </div>
<div class="card"> <div class="card">
<h4>数据管理</h4> <h4>数据管理</h4>
+29 -2
View File
@@ -44,7 +44,11 @@ createApp({
const fares = ref([]); const fares = ref([]);
const tickets = ref([]); const tickets = ref([]);
const stats = reactive({ sold_tickets: 0, revenue: 0 }); const stats = reactive({ sold_tickets: 0, revenue: 0 });
const config = reactive({ api_base: '', promotion: { name: '', discount: 1 } }); const config = reactive({
api_base: '',
promotion: { name: '', discount: 1 },
lua_versions: { ticketmachine: 'v1.5.8', gate: 'v1.5.8' }
});
const logs = ref([]); const logs = ref([]);
const logCategory = ref(''); const logCategory = ref('');
const logTypeFilter = ref(''); const logTypeFilter = ref('');
@@ -224,6 +228,20 @@ createApp({
} }
}; };
const normalizeLuaVersion = (value) => {
let text = String(value || '').trim();
if (!text) text = 'v1.0.0';
if (!/^v/i.test(text)) text = `v${text}`;
return text;
};
const bumpPatchVersion = (value) => {
const normalized = normalizeLuaVersion(value);
const matched = /^v?(\d+)\.(\d+)\.(\d+)$/i.exec(normalized);
if (!matched) return normalized;
return `v${matched[1]}.${matched[2]}.${Number(matched[3]) + 1}`;
};
// Methods // Methods
const formatTime = (ts) => { const formatTime = (ts) => {
if (ts == null || ts === '') return '---'; if (ts == null || ts === '') return '---';
@@ -1344,10 +1362,18 @@ createApp({
const saveConfig = async () => { const saveConfig = async () => {
await runMutation(async () => { await runMutation(async () => {
config.lua_versions.ticketmachine = normalizeLuaVersion(config.lua_versions.ticketmachine);
config.lua_versions.gate = normalizeLuaVersion(config.lua_versions.gate);
await requestJson('/api/config', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(config) }, { expectOk: true }); await requestJson('/api/config', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(config) }, { expectOk: true });
}, { successMessage: '保存成功' }); }, { successMessage: '保存成功' });
}; };
const bumpLuaVersion = async (device) => {
if (!config.lua_versions[device]) config.lua_versions[device] = 'v1.0.0';
config.lua_versions[device] = bumpPatchVersion(config.lua_versions[device]);
await saveConfig();
};
const exportData = () => { const exportData = () => {
window.open('/api/export', '_blank'); window.open('/api/export', '_blank');
}; };
@@ -1390,6 +1416,7 @@ createApp({
}); });
socket.on('config:updated', (data) => { socket.on('config:updated', (data) => {
Object.assign(config, data); Object.assign(config, data);
if (!config.lua_versions) config.lua_versions = { ticketmachine: 'v1.5.8', gate: 'v1.5.8' };
coreLoaded = true; coreLoaded = true;
fareMapLoaded = false; fareMapLoaded = false;
if (currentView.value === 'faremap') { if (currentView.value === 'faremap') {
@@ -1579,7 +1606,7 @@ createApp({
saveCurrentFare, deleteCurrentFare, closeFareModal, saveCurrentFare, deleteCurrentFare, closeFareModal,
saveConfig, exportData, exportFareMap, saveConfig, bumpLuaVersion, exportData, exportFareMap,
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData, formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset, fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,
isTransferStation, getTransferTitleSuffix, getTransferLineBadges isTransferStation, getTransferTitleSuffix, getTransferLineBadges
+1 -1
View File
@@ -23,7 +23,7 @@
<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>
+126 -2
View File
@@ -2590,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));
@@ -2693,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%;
@@ -2752,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;
} }
@@ -2779,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;
@@ -2920,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;
@@ -3173,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 {
@@ -3584,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;
} }
@@ -3610,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;
+2 -2
View File
@@ -63,7 +63,7 @@
<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>
@@ -80,7 +80,7 @@
<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">
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+26 -8
View File
@@ -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=13" /> <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>
@@ -118,7 +137,7 @@
</div> </div>
<script src="/custom-dialog.js?v=12"></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>
@@ -146,4 +165,3 @@
</body> </body>
</html> </html>
+34 -6
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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>';