Compare commits

...

18 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
Henry_Du b614ff663c chore(web): 移除过时的socket调试与服务器状态监控代码
移除了登录页与后台管理页的服务器状态展示UI、public-status.js脚本引用,删除了index.js中的socket运行时日志上报逻辑与连接状态追踪代码,同时删除了用于排查socket polling 400问题的调试文档。
2026-06-21 16:11:54 +08:00
Henry_Du e78557f335 perf(web): 切换Vue CDN为生产优化版本
更新所有Web页面的Vue脚本引用为生产压缩版本以提升客户端加载性能,同时修复index.html中DOCTYPE行的微小格式问题
2026-06-21 15:44:42 +08:00
Henry_Du 2ddcd18e1e feat(socket): 添加 Socket 运行时调试日志及轮询 400 错误调试文档
- 更新 web/index.html 中 index.js 的资源版本为 v6 以清除旧缓存
- 在 web/index.js 中新增 Socket 运行时日志上报逻辑,捕获并上报连接、断开、错误及重连事件
- 新增调试文档记录生产环境 Socket.IO polling 400 错误的问题、现象与排查计划
2026-06-21 15:39:59 +08:00
Henry_Du b1cb84f736 feat(管理后台): 新增线路编辑器拖拽平移并修复代理下Socket连接问题
调整socket.io传输顺序优先使用轮询以适配代理服务器,新增可视化线路编辑器拖拽平移功能,修复多处CSS布局问题并更新静态资源缓存版本。
2026-06-21 11:21:09 +08:00
Henry_Du 7fea8807b8 feat(web,installer): 更新下载源、升级资源缓存版本、本地化界面并新增管理功能
- 更新 update_machine.lua 和 installer.lua 中的远程资源下载地址,从旧云存储链接切换为 Gitea 仓库提交镜像地址
- 新增双向闸机专用安装脚本 installer_bi.lua
- 为所有网页HTML文件更新静态资源的缓存版本号,避免浏览器加载过期的静态文件缓存
- 修复登录页面的乱码文本,替换为标准简体中文内容,修正ICP备案标识文本
- 新增管理后台概览板块、快捷操作按钮,优化IC卡管理界面与响应式布局样式
2026-06-21 10:37:25 +08:00
Henry_Du 108435e90d Merge branch 'main' of http://192.140.163.241:3000/Henry_Du/FSE-Ticket.sys 2026-06-21 10:19:34 +08:00
Henry_Du ea5c0a0d5a feat: 新增 error 和 pass 两个 DFPWM 二进制文件 2026-06-21 10:18:04 +08:00
Henry_Du db1562b830 删除 IC储蓄卡功能开发.md 2026-06-21 10:04:43 +08:00
Henry_Du d35ae5e75b 删除 gate.lua 2026-06-21 10:04:32 +08:00
Henry_Du 585e498235 删除 installer_bi.lua 2026-06-21 10:04:26 +08:00
57 changed files with 2204 additions and 2111 deletions
+6
View File
@@ -0,0 +1,6 @@
---
alwaysApply: true
scene: git_message
---
在此处编写规则,自定义 AI 生成提交信息的风格。
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+84 -14
View File
@@ -1,7 +1,8 @@
local DEFAULT_SERVER_BASE = "http://ticket.fse-media.group"
local DEFAULT_SERVER_PATH = "/api/tickets/check"
local GATE_OPEN_SECONDS = 2
local VERSION = "v1.5.7"
local VERSION = "v1.5.8"
local VERSION_CHECK_INTERVAL = 60
local CONFIG_PATH = "gate_config.json"
@@ -26,6 +27,15 @@ local function trim(s)
return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end
local function normalizeVersionTag(v)
local s = trim(v)
if #s == 0 then return "" end
if s:sub(1, 1):lower() ~= "v" then
s = "v" .. s
end
return s:lower()
end
local function splitCsv(s)
local out = {}
s = trim(s)
@@ -80,6 +90,8 @@ local inspection = peripheral.find("ticket_inspection_machine")
local serverConnected = nil
local serverLastChangeTs = 0
local expectedGateVersion = nil
local versionMismatch = nil
local function setServerConnected(ok)
if serverConnected == ok then return end
@@ -126,10 +138,20 @@ local function drawVersionIndicator(w)
local s = tostring(VERSION or "")
if #s == 0 then return end
if w < #s then return end
local markerColor = colors.yellow
if versionMismatch == true then
markerColor = colors.red
elseif versionMismatch == false then
markerColor = colors.lime
end
termDev.setBackgroundColor(colors.black)
termDev.setTextColor(colors.gray)
termDev.setCursorPos(1, 1)
termDev.write(s)
if w >= (#s + 1) then
termDev.setTextColor(markerColor)
termDev.write("*")
end
termDev.setTextColor(colors.white)
end
@@ -337,6 +359,23 @@ local function refreshStationNameMap(serverBase)
return true
end
local function refreshRemoteLuaVersion(serverBase)
serverBase = trim(serverBase or "")
if #serverBase == 0 then return false end
local url = serverBase:gsub("/+$", "") .. "/api/public/config"
local ok, parsed = getJSON(url)
if not ok or type(parsed) ~= "table" then return false end
local remote = normalizeVersionTag(type(parsed.lua_versions) == "table" and parsed.lua_versions.gate or nil)
if #remote == 0 then
expectedGateVersion = nil
versionMismatch = nil
return true
end
expectedGateVersion = remote
versionMismatch = (remote ~= normalizeVersionTag(VERSION))
return true
end
local function inferStationCodeFromName(name)
local key = normKey(name or "")
if #key == 0 then return "" end
@@ -655,6 +694,10 @@ pcall(function()
refreshStationNameMap(guessBaseFromStatusURL(serverURL))
end)
pcall(function()
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
end)
if not inspection then
if modeBySide == nil then
draw("Missing peripheral:", "ticket_inspection_machine", colors.red)
@@ -667,18 +710,6 @@ if next(stationSet) == nil then
error("No station codes configured")
end
local stationListText = table.concat(cfg.station_codes, ",")
local function readyLine1()
if not modeBySide then
return "Ready (" .. mode:upper() .. ")"
end
local f = modeBySide.front and modeBySide.front:upper() or "-"
local b = modeBySide.back and modeBySide.back:upper() or "-"
return "Ready (BI) F:" .. f .. " B:" .. b
end
draw(readyLine1(), "Station: " .. stationListText, colors.lime)
local stationCodesPayload = {}
for k, _ in pairs(stationSet) do table.insert(stationCodesPayload, k) end
table.sort(stationCodesPayload)
@@ -785,6 +816,37 @@ local function actionForSide(side)
return modeBySide[side] or mode
end
local function shortModeLabel(v)
v = trim(v):lower()
if v == "exit" then return "OUT" end
return "IN"
end
local function readyLine1()
if not modeBySide then
return "READY " .. shortModeLabel(mode)
end
return "F " .. shortModeLabel(modeBySide.front or "") .. " B " .. shortModeLabel(modeBySide.back or "")
end
local function readyLine2()
if not modeBySide then
return "ST " .. stationCodeForSide(nil)
end
local frontCode = stationCodeForSide("front")
local backCode = stationCodeForSide("back")
if frontCode == backCode then
return "ST " .. frontCode
end
return "F " .. frontCode .. " B " .. backCode
end
local function drawReadyScreen()
draw(readyLine1(), readyLine2(), colors.lime)
end
drawReadyScreen()
local function collectInspectionDevices(side, modeBySideRef)
local sideKnown = trimSide(side) ~= nil
local inspectionDevs = {}
@@ -1278,11 +1340,19 @@ local function processInspectionEvent(eventName, ev)
end
end
local versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
while true do
local ev = pack(os.pullEvent())
if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then
processInspectionEvent(ev[1], ev)
os.sleep(0.35)
draw(readyLine1(), "Station: " .. stationListText, colors.lime)
drawReadyScreen()
elseif ev[1] == "timer" and ev[2] == versionTimer then
pcall(function()
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
end)
drawReadyScreen()
versionTimer = os.startTimer(VERSION_CHECK_INTERVAL)
end
end
+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://cloud.fse-media.group/d/API/TicketMachine/error.dfpwm?sign=DvQ39wQDN6Cej4KPhAkM3JyXPM466koan6w9CckPxWU=:0"
local URL_PASS = "http://cloud.fse-media.group/d/API/TicketMachine/pass.dfpwm?sign=ZJfGDYnaxQU4JrGSh4NDzKZtjq3eMjYh9KHmMSAMKZA=:0"
local URL_GATE = "http://cloud.fse-media.group/d/API/TicketMachine/gate.lua?sign=OWy1wKkKhUhpnxXKeX6fRLePSg1XcaQgWOLvQbMuHRQ=:0"
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/raw/branch/main/pass.dfpwm"
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"
@@ -34,13 +35,6 @@ local function prompt(label)
return trim(read() or "")
end
local function promptOptionalUrl(label)
local raw = prompt(label)
raw = trim(raw)
if #raw == 0 then return nil end
return raw
end
local function httpGet(url)
if not http then return false, "HTTP API disabled" end
local okReq, err = pcall(function()
@@ -95,27 +89,15 @@ term.setCursorPos(1, 1)
print("Ticket Gate Installer")
print("")
local stationsRaw = prompt("Station codes (comma or slash): ")
local stationCodes = splitCsv(stationsRaw)
if #stationCodes == 0 then
print("No station codes provided.")
local stationCode = trim(prompt("Station code: "))
if #stationCode == 0 then
print("No station code provided.")
return
end
local modeRaw = prompt("Gate mode (entry/exit): ")
local mode = (trim(modeRaw):lower() == "exit") and "exit" or "entry"
local stationCode = prompt("Gate station code (default first code): ")
stationCode = trim(stationCode)
if #stationCode == 0 then stationCode = stationCodes[1] end
print("")
print("Optional server config for ticket / IC card checks.")
local ticketServerUrl = promptOptionalUrl("Ticket check URL (blank=auto): ")
local cardServerUrl = promptOptionalUrl("IC card check URL (blank=same server): ")
local cfg = { mode = mode, station_codes = stationCodes, station_code = stationCode }
if ticketServerUrl then cfg.server_url = ticketServerUrl end
if cardServerUrl then cfg.card_server_url = cardServerUrl end
local cfg = { mode = mode, station_codes = { stationCode }, station_code = stationCode }
local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg)
if not okCfg then
print("Config serialize failed.")
@@ -137,6 +119,7 @@ end
writeFile("startup.lua", gateCode, false)
writeFile("startup", gateCode, false)
if not download(URL_UPDATE_GATE, "update_gate.lua", false) then return end
print("")
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_PASS = "http://cloud.fse-media.group/d/API/TicketMachine/pass.dfpwm?sign=ZJfGDYnaxQU4JrGSh4NDzKZtjq3eMjYh9KHmMSAMKZA=:0"
local URL_GATE = "http://cloud.fse-media.group/d/API/TicketMachine/gate.lua?sign=OWy1wKkKhUhpnxXKeX6fRLePSg1XcaQgWOLvQbMuHRQ=:0"
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/raw/branch/main/pass.dfpwm"
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"
@@ -33,13 +34,6 @@ local function prompt(label)
return trim(read() or "")
end
local function promptOptionalUrl(label)
local raw = prompt(label)
raw = trim(raw)
if #raw == 0 then return nil end
return raw
end
local function httpGet(url)
if not http then return false, "HTTP API disabled" end
local okReq, err = pcall(function()
@@ -98,10 +92,9 @@ term.setCursorPos(1, 1)
print("Bidirectional Ticket Gate Installer")
print("")
local stationsRaw = prompt("Station codes (comma or slash): ")
local stationCodes = splitCsv(stationsRaw)
if #stationCodes == 0 then
print("No station codes provided.")
local stationCode = trim(prompt("Station code: "))
if #stationCode == 0 then
print("No station code provided.")
return
end
@@ -111,30 +104,19 @@ local frontRaw = prompt("Front mode (entry/exit): ")
local backRaw = prompt("Back mode (entry/exit): ")
local frontMode = normalizeMode(frontRaw)
local backMode = normalizeMode(backRaw)
local frontStationCode = trim(prompt("Front station code (default first code): "))
local backStationCode = trim(prompt("Back station code (default first code): "))
if #frontStationCode == 0 then frontStationCode = stationCodes[1] end
if #backStationCode == 0 then backStationCode = stationCodes[1] end
print("")
print("Optional server config for ticket / IC card checks.")
local ticketServerUrl = promptOptionalUrl("Ticket check URL (blank=auto): ")
local cardServerUrl = promptOptionalUrl("IC card check URL (blank=same server): ")
local cfg = {
station_codes = stationCodes,
station_code = stationCodes[1],
station_codes = { stationCode },
station_code = stationCode,
side_modes = {
front = frontMode,
back = backMode,
},
side_station_codes = {
front = frontStationCode,
back = backStationCode,
front = stationCode,
back = stationCode,
}
}
if ticketServerUrl then cfg.server_url = ticketServerUrl end
if cardServerUrl then cfg.card_server_url = cardServerUrl end
local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg)
if not okCfg then
print("Config serialize failed.")
@@ -156,6 +138,7 @@ end
writeFile("startup.lua", gateCode, false)
writeFile("startup", gateCode, false)
if not download(URL_UPDATE_GATE, "update_gate.lua", false) then return end
print("")
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.")
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -550,7 +550,7 @@ local function refillLoop()
local errMessage = okCall and tostring(newBalance or "refill_failed") or "refill_call_failed"
drawErrorPage(errMessage)
sleep(2)
sleep(8)
state.page = "home"
return
end
+12 -4
View File
@@ -148,14 +148,16 @@ const resolveCurrentStationCode = (body, resolveStation) => {
// Config
router.get('/config', (req, res) => {
const cfg = DataService.getConfig();
res.json({
api_base: DataService.getConfig().api_base,
current_station: DataService.getConfig().current_station,
api_base: cfg.api_base,
current_station: cfg.current_station,
stations: DataService.getStations(),
lines: DataService.getLines(),
fares: DataService.getFares(),
transfers: DataService.getConfig().transfers || [],
promotion: DataService.getConfig().promotion || { name: '', discount: 1 }
transfers: cfg.transfers || [],
promotion: cfg.promotion || { name: '', discount: 1 },
lua_versions: cfg.lua_versions || {}
});
});
@@ -219,6 +221,12 @@ router.put('/config', async (req, res) => {
if (!(d >= 0 && d <= 1)) return res.status(400).json({ ok: false, error: 'promotion.discount must be between 0 and 1' });
cfg.promotion = { name: String(p.name || ''), discount: d };
}
if (incoming.lua_versions && typeof incoming.lua_versions === 'object') {
cfg.lua_versions = {
...(cfg.lua_versions || {}),
...(incoming.lua_versions || {})
};
}
await DataService.saveConfig(cfg);
appendReqLog(req, { category: 'admin', type: 'update_config_generic', detail: incoming });
io.emit('config:updated', cfg);
+12 -3
View File
@@ -221,7 +221,8 @@ router.get('/fares/query', (req, res) => {
router.get('/config', (req, res) => {
const cfg = DataService.getConfig();
res.json({
promotion: { name: cfg.promotion?.name || '', discount: cfg.promotion?.discount ?? 1 }
promotion: { name: cfg.promotion?.name || '', discount: cfg.promotion?.discount ?? 1 },
lua_versions: cfg.lua_versions || {}
});
});
@@ -345,8 +346,16 @@ router.post('/orders/:code/consume', async (req, res) => {
router.get('/ic-cards/query', async (req, res) => {
const q = String(req.query.q || '').trim();
if (!q) {
appendReqLog(req, { category: 'public', type: 'ic_card_query_invalid', level: 'warn', detail: { q } });
return res.status(400).json({ ok: false, error: 'query required' });
const cards = (DataService.getIcCards() || [])
.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 normOrderCode = String(q || '').trim().toUpperCase();
+34 -9
View File
@@ -21,14 +21,39 @@ const pool = mysql.createPool({
queueLimit: 0
});
const DEFAULT_LUA_VERSIONS = {
ticketmachine: 'v1.5.8',
gate: 'v1.5.8'
};
function normalizeLuaVersions(input) {
const src = (input && typeof input === 'object') ? input : {};
return {
ticketmachine: String(src.ticketmachine || DEFAULT_LUA_VERSIONS.ticketmachine),
gate: String(src.gate || DEFAULT_LUA_VERSIONS.gate)
};
}
function normalizeConfig(input) {
const src = (input && typeof input === 'object') ? input : {};
return {
...src,
api_base: String(src.api_base || 'http://127.0.0.1:23333/api'),
current_station: (src.current_station && typeof src.current_station === 'object')
? src.current_station
: { name: 'Station1', code: '01-01' },
transfers: Array.isArray(src.transfers) ? src.transfers : [],
promotion: {
name: String(src?.promotion?.name || ''),
discount: Number(src?.promotion?.discount ?? 1)
},
lua_versions: normalizeLuaVersions(src.lua_versions)
};
}
// In-memory cache for synchronous read access
const cache = {
config: {
api_base: 'http://127.0.0.1:23333/api',
current_station: { name: 'Station1', code: '01-01' },
transfers: [],
promotion: { name: '', discount: 1 }
},
config: normalizeConfig({}),
stations: [],
lines: [],
fares: [],
@@ -66,7 +91,7 @@ const DataService = {
// Load Cache
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
if (configs.length > 0) cache.config = configs[0].v;
if (configs.length > 0) cache.config = normalizeConfig(configs[0].v);
else await conn.query('INSERT INTO kv_store (k, v) VALUES (?, ?)', ['config', JSON.stringify(cache.config)]);
const [stations] = await conn.query('SELECT data FROM stations');
@@ -114,8 +139,8 @@ const DataService = {
// Config
getConfig: () => cache.config,
saveConfig: async (cfg) => {
cache.config = cfg;
await pool.query('INSERT INTO kv_store (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?', ['config', JSON.stringify(cfg), JSON.stringify(cfg)]);
cache.config = normalizeConfig(cfg);
await pool.query('INSERT INTO kv_store (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?', ['config', JSON.stringify(cache.config), JSON.stringify(cache.config)]);
},
// Stations
+286 -55
View File
@@ -1,6 +1,6 @@
local CURRENT_STATION_CODE = 'Ticket-Machine'
local API_BASE = 'http://ticket.fse-media.group/api'
local VERSION = 'v1.5.7'
local VERSION = 'v1.5.8'
-- ###########################
-- Core HTTP & JSON Utilities
@@ -13,6 +13,17 @@ end
local serverConnected = nil
local serverLastChangeTs = 0
local expectedMachineVersion = nil
local versionMismatch = nil
local function normalizeVersionTag(v)
local s = tostring(v or ''):gsub('^%s+', ''):gsub('%s+$', '')
if #s == 0 then return '' end
if s:sub(1, 1):lower() ~= 'v' then
s = 'v' .. s
end
return s:lower()
end
local function setServerConnected(ok)
if serverConnected == ok then return end
@@ -314,22 +325,93 @@ end
-- ###########################
-- Peripheral discovery
-- ###########################
local monitor = peripheral.find('monitor')
local ticketVendingMachine = peripheral.find('ticket_vending_machine')
local speaker = peripheral.find('speaker')
local SIDE_PRIORITY = { top = 1, bottom = 2, left = 3, right = 4, front = 5, back = 6 }
local REDSTONE_SIDES = { 'right', 'left', 'top', 'bottom', 'front', 'back' }
local monitor = nil
local monitorName = nil
local ticketVendingMachine = nil
local ticketVendingMachineName = nil
local speaker = nil
local speakerName = nil
local detectedPaymentSide = nil
local MOD_DEBUG = true
pcall(math.randomseed, (os.epoch and os.epoch('utc')) or os.time())
local function safe(term)
if monitor then return peripheral.wrap(peripheral.getName(monitor)) end
return term
local function comparePeripheralName(a, b)
local pa = SIDE_PRIORITY[tostring(a or '')] or 99
local pb = SIDE_PRIORITY[tostring(b or '')] or 99
if pa ~= pb then return pa < pb end
return tostring(a or '') < tostring(b or '')
end
local termDev = safe(term)
if monitor then pcall(monitor.setTextScale, 0.5) end
local function peripheralTypeMatches(name, typeName)
if not peripheral or type(peripheral.getType) ~= 'function' then return false end
local got = peripheral.getType(name)
if type(got) == 'string' then return got == typeName end
if type(got) == 'table' then
for _, item in ipairs(got) do
if item == typeName then return true end
end
end
return false
end
local function findPeripheralByType(typeName)
if not peripheral then return nil, nil end
if type(peripheral.getNames) == 'function' and type(peripheral.wrap) == 'function' then
local names = peripheral.getNames() or {}
table.sort(names, comparePeripheralName)
for _, name in ipairs(names) do
if peripheralTypeMatches(name, typeName) then
local okWrap, dev = pcall(peripheral.wrap, name)
if okWrap and type(dev) == 'table' then
return dev, name
end
end
end
end
if type(peripheral.find) == 'function' then
local dev = peripheral.find(typeName)
if type(dev) == 'table' then
local name = nil
if type(peripheral.getName) == 'function' then
local okName, gotName = pcall(peripheral.getName, dev)
if okName then name = gotName end
end
return dev, name
end
end
return nil, nil
end
local termDev = term
local w, h = termDev.getSize()
local function refreshDevices()
local prevSignature = table.concat({
tostring(monitorName or ''),
tostring(ticketVendingMachineName or ''),
tostring(speakerName or '')
}, '|')
monitor, monitorName = findPeripheralByType('monitor')
ticketVendingMachine, ticketVendingMachineName = findPeripheralByType('ticket_vending_machine')
speaker, speakerName = findPeripheralByType('speaker')
termDev = monitor or term
if monitor then pcall(monitor.setTextScale, 0.5) end
w, h = termDev.getSize()
local nextSignature = table.concat({
tostring(monitorName or ''),
tostring(ticketVendingMachineName or ''),
tostring(speakerName or '')
}, '|')
if prevSignature ~= nextSignature then
os.queueEvent('config_updated')
end
end
refreshDevices()
local function saveCardIssueSnapshot(cardData)
pcall(function()
ensureDir('logs/last_card_issue.json')
@@ -356,6 +438,14 @@ local function peripheralCallSucceeded(r1)
return r1 ~= nil and r1 ~= false
end
local function getTicketVendingMachine()
refreshDevices()
if type(ticketVendingMachine) == 'table' then
return ticketVendingMachine
end
return nil
end
local function callPeripheralMethods(dev, methodNames, variants)
if type(dev) ~= 'table' then return false, 'peripheral_unavailable' end
for _, methodName in ipairs(methodNames) do
@@ -373,7 +463,7 @@ local function callPeripheralMethods(dev, methodNames, variants)
end
local function issueBlankICCard(holderName, initialBalance)
local dev = ticketVendingMachine
local dev = getTicketVendingMachine()
if type(dev) ~= 'table' then return false, '', 'peripheral_unavailable' end
local safeHolderName = firstString(holderName, 'CARD USER')
local safeInitialBalance = math.max(0, math.floor(tonumber(initialBalance) or 0))
@@ -395,8 +485,9 @@ local function issueBlankICCard(holderName, initialBalance)
return false, '', 'unsupported_method'
end
local function writeICCard(cardData)
local dev = ticketVendingMachine
local function writeICCard(cardData, opts)
local dev = getTicketVendingMachine()
local options = opts or {}
local payload = {}
for k, v in pairs(cardData or {}) do payload[k] = v end
payload.media = payload.media or 'ic_card'
@@ -409,7 +500,9 @@ local function writeICCard(cardData)
saveCardIssueSnapshot(payload)
local okWrite, methodName, r1, r2, r3 = callPeripheralMethods(dev,
{ 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' },
options.writeOnly
and { 'writeCard', 'writeICCard', 'writeTicketData', 'issueTicketData' }
or { 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' },
{
{ payload },
{ tostring(payload.holder_name or ''), tonumber(payload.initial_balance) or 0 },
@@ -430,6 +523,81 @@ local function submitCardOpen(payload)
return postJSON(API_BASE .. '/cards/open', payload)
end
local function buildFinalCardData(payload, respData)
local data = (type(respData) == 'table') and respData or {}
return {
card_id = firstString(data.card_id, data.id, payload.card_id),
holder_name = payload.holder_name,
balance = firstNumber(data.balance, data.stored_value, payload.balance) or payload.balance,
deposit = firstNumber(data.deposit, payload.deposit) or payload.deposit,
topup = firstNumber(data.topup, data.first_topup, payload.topup) or payload.topup,
station_code = payload.station_code,
device = payload.device,
voucher_code = payload.voucher_code,
media = 'ic_card',
product_type = 'stored_value',
order_value = payload.order_value,
initial_balance = firstNumber(data.first_topup, data.topup, payload.topup, payload.balance) or payload.topup or payload.balance or 0
}
end
local function 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
-- ###########################
@@ -489,6 +657,15 @@ local function backgroundSyncTask()
end
end
local function backgroundPeripheralTask()
while true do
local ev = os.pullEvent()
if ev == 'peripheral' or ev == 'peripheral_detach' then
pcall(refreshDevices)
end
end
end
local function backgroundTicketUploadTask()
loadPendingUploadsOnce()
local backoff = 2
@@ -516,6 +693,17 @@ local stationByCode = {}
local adjacency_regular, adjacency_express = {}, {}
local transferGroupByCode = {}
local function updateVersionStateFromConfig()
local remote = normalizeVersionTag(type(CFG.lua_versions) == 'table' and CFG.lua_versions.ticketmachine or nil)
if #remote == 0 then
expectedMachineVersion = nil
versionMismatch = nil
return
end
expectedMachineVersion = remote
versionMismatch = (remote ~= normalizeVersionTag(VERSION))
end
local function normalizeCode(s)
s = tostring(s or '')
s = s:gsub('[\239\187\191]', ''):gsub('%s+', '')
@@ -644,6 +832,7 @@ local function refreshConfigOnce()
if f then f.write(textutils.serializeJSON(cfg)); f.close() end
CFG = cfg
rebuildMaps()
updateVersionStateFromConfig()
os.queueEvent('config_updated')
return true
end
@@ -665,6 +854,7 @@ end
CFG = loadConfig() or CFG
rebuildMaps()
updateVersionStateFromConfig()
-- ###########################
@@ -889,10 +1079,20 @@ end
local function drawVersionIndicator()
if w < 1 then return end
local markerColor = colors.yellow
if versionMismatch == true then
markerColor = colors.red
elseif versionMismatch == false then
markerColor = colors.lime
end
termDev.setBackgroundColor(colors.black)
termDev.setTextColor(colors.gray)
termDev.setCursorPos(1, 1)
termDev.write(tostring(VERSION))
if w >= (#tostring(VERSION) + 1) then
termDev.setTextColor(markerColor)
termDev.write('*')
end
termDev.setTextColor(colors.white)
end
@@ -1457,6 +1657,33 @@ local function computeCost(src, dst, trainType)
return nil
end
local function paymentHintText()
if detectedPaymentSide and #tostring(detectedPaymentSide) > 0 then
return 'Payment side: ' .. tostring(detectedPaymentSide):upper()
end
return 'Insert payment on any side'
end
local function snapshotPaymentInputs()
local states = {}
if not redstone or type(redstone.getInput) ~= 'function' then return states end
for _, side in ipairs(REDSTONE_SIDES) do
states[side] = redstone.getInput(side) and true or false
end
return states
end
local function detectPaymentPulse(prevStates)
local nowStates = snapshotPaymentInputs()
for _, side in ipairs(REDSTONE_SIDES) do
if nowStates[side] and not prevStates[side] then
detectedPaymentSide = side
return true, side, nowStates
end
end
return false, nil, nowStates
end
local function drawOrder()
if state.productMode == 'card' then
local cardSub = (state.cardMode == 'redeem') and 'Redeem IC card order' or 'Open new stored-value card'
@@ -1536,7 +1763,7 @@ local function drawOrder()
if total <= 0 then
centerText(statusY + 1, 'Ready to confirm', colors.lightGray)
else
centerText(statusY + 1, 'Insert payment on RIGHT side', colors.lightGray)
centerText(statusY + 1, paymentHintText(), colors.lightGray)
end
end
@@ -1603,6 +1830,8 @@ local function showOrderAndAudio()
local reuseBlankCardId = firstString(state.pendingBlankCardId)
if #reuseBlankCardId > 0 then
payload.card_id = reuseBlankCardId
else
payload.card_id = generateCardId()
end
local okIssueBlank, blankCardId, issueMethod = false, '', 'reuse_pending'
if #reuseBlankCardId == 0 then
@@ -1616,13 +1845,19 @@ local function showOrderAndAudio()
local okReq, code, parsed, err = submitCardOpen(payload)
if okReq then
local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {}
state.card_id = firstString(respData.card_id, respData.id, payload.card_id)
state.cardBalance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance
local finalCard = buildFinalCardData(payload, respData)
local okWrite, writtenCard, writeMethod = writeICCard(finalCard, { writeOnly = true })
if okWrite then
state.card_id = firstString(writtenCard.card_id, finalCard.card_id)
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
state.card_server_data = respData
state.pendingBlankCardId = nil
confirmed = true
statusMsg, statusCol = 'Card ready', colors.green
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
else
statusMsg, statusCol = 'Write failed: ' .. tostring(writeMethod), colors.red
end
else
local errorMsg = 'Card API Err'
if code == 409 then
@@ -1640,20 +1875,7 @@ local function showOrderAndAudio()
local okReq, code, parsed, err = submitCardOpen(payload)
if okReq then
local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {}
local finalCard = {
card_id = firstString(respData.card_id, respData.id, payload.card_id),
holder_name = payload.holder_name,
balance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance,
deposit = firstNumber(respData.deposit, payload.deposit) or payload.deposit,
topup = firstNumber(respData.topup, respData.first_topup, payload.topup) or payload.topup,
station_code = payload.station_code,
device = payload.device,
voucher_code = payload.voucher_code,
media = 'ic_card',
product_type = 'stored_value',
order_value = payload.order_value,
initial_balance = payload.topup
}
local finalCard = buildFinalCardData(payload, respData)
local okWrite, writtenCard, writeMethod = writeICCard(finalCard)
if okWrite then
state.card_id = firstString(writtenCard.card_id, finalCard.card_id)
@@ -1736,12 +1958,12 @@ local function showOrderAndAudio()
confirmAction()
end
local prev = redstone.getInput('right')
local prevInputs = snapshotPaymentInputs()
while state.page == 'order' do
local ev, p1, p2, p3 = os.pullEvent()
if ev == 'redstone' then
local now = redstone.getInput('right')
if now and not prev then
local pulsed, _, nextInputs = detectPaymentPulse(prevInputs)
if pulsed then
playNote('hat', 20, 1, 0.01)
state.paid = (state.paid or 0) + 1; render()
if state.paid >= (state.cost or 0) then
@@ -1752,7 +1974,8 @@ local function showOrderAndAudio()
sleep(0.5) -- Wait for UI/Audio slightly
confirmAction()
end
end; prev = now
end
prevInputs = nextInputs
elseif ev == 'mouse_click' or ev == 'monitor_touch' then
-- For mouse_click: p1=button, p2=x, p3=y
-- For monitor_touch: p1=side, p2=x, p3=y
@@ -1785,18 +2008,14 @@ local function showPrePrintCheck()
end
local function generateTicketId()
local function randLetter()
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
return generateNumericCode('TK', 8)
end
local function ensureTicketIdFormat(id)
if id == nil then return generateTicketId() end
if id == nil then return '' end
local s = tostring(id)
s = s:gsub('%s+', '')
if #s == 0 then return '' end
local prefix, num = s:match('^([A-Za-z][A-Za-z])%-?([0-9]+)$')
if prefix and num then
prefix = prefix:upper()
@@ -1890,22 +2109,35 @@ local function showDone()
start_name = startObj and unicodeEscape(startObj.name) or nil,
terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil,
start_name_en = fromNameEn,
terminal_name_en = toNameEn
terminal_name_en = toNameEn,
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
}
if ticketVendingMachine and ticketVendingMachine.issueTicket then
local apiType = (state.trainType == 'Express') and 'limited_express' or 'local'
local okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
if not (okCall and okIssue and ticketId) then
okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
end
if okCall and okIssue and ticketId then
state.ticket_id = ensureTicketIdFormat(ticketId)
local localGeneratedTicketId = generateTicketId()
local okIssueTicket, issuedTicketId, issueMethod = issueTicketFromPeripheral(
fromNameEnArg,
toNameEnArg,
apiType,
rides,
cost,
startStationArg,
terminalStationArg,
fromNameCnUArg,
toNameCnUArg,
localGeneratedTicketId
)
if okIssueTicket then
state.ticket_id = issuedTicketId
issueSource = 'ticket_vending_machine'
else
state.ticket_id = generateTicketId()
end
else
state.ticket_id = generateTicketId()
local issueError = tostring(issueMethod or 'ticket_issue_failed')
print('Ticket issue failed: ' .. issueError)
_G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError
showAlert('Ticket issue failed')
resetTicketFlow()
state.page = 'home'
return
end
pcall(function()
@@ -2074,7 +2306,6 @@ local function showOnlineVoucher()
elseif ev == 'key' and p1 == keys.backspace then code = code:sub(1, -2)
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode()
elseif ev == 'config_updated' then
-- Config updated in background, continue to redraw
end
end
end
@@ -2101,4 +2332,4 @@ local function mainPageLoop()
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 mode = binary and "wb" or "w"
+1 -1
View File
@@ -1,5 +1,5 @@
local URL_MACHINE = "http://cloud.fse-media.group/d/API/TicketMachine/ticketmachine.lua?sign=UIiheDcpyzwKdovDZM3G-8IRZqApFgU2Kpnhe9k5ETQ=:0"
local URL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/ticketmachine.lua"
local function writeFile(path, content, binary)
local 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.")
+67 -58
View File
@@ -1,65 +1,74 @@
<!DOCTYPE html>
<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=12">
<link rel="stylesheet" href="blog.css?v=2">
<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=11"></script>
<script src="blog.js?v=2"></script>
<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>
+3 -2
View File
@@ -6,7 +6,7 @@
<title>FSE 铁路票务系统 - 首页</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=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="public-search jr-public-page">
<div class="jr-public-shell">
@@ -181,7 +181,7 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>
@@ -247,3 +247,4 @@
+56 -53
View File
@@ -1,13 +1,13 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<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="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="jr-admin-page jr-admin-ic-page jr-public-page">
@@ -16,12 +16,12 @@
<div class="jr-topbar-inner">
<a href="/" class="jr-top-link" id="icTopLink">
<i class="fas fa-train"></i>
<span>FSE 閾佽矾杩愯緭鍚庡彴绯荤粺</span>
<span>FSE 铁路运输后台系统</span>
</a>
<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-value" data-server-status-value>妫€娴嬩腑</span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
@@ -30,16 +30,16 @@
<a href="/" class="jr-brand" id="icBrandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE 閾佽矾杩愯緭</strong>
<span>IC 鍗$鐞嗗悗鍙?/span>
<strong>FarSight-T.N.E铁路运输</strong>
<span>IC 卡管理后台</span>
</div>
</a>
<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/order" data-link="order">绾夸笂棰勫畾</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/order" data-link="card-order">绾夸笂璐崱</a>
<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/order" data-link="order">线上预定</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/order" data-link="card-order">线上购卡</a>
</nav>
</div>
</div>
@@ -48,22 +48,26 @@
<div class="sidebar">
<div class="jr-admin-sidebar-head">
<span class="jr-kicker">IC CARD CONSOLE</span>
<div class="brand">FSE 閾佽矾绁ㄥ姟绯荤粺鎺у埗鍙?/div>
<p class="jr-admin-sidebar-copy">缁熶竴绠$悊 IC 鍗″彂琛屻€佸厖鍊笺€佹寔鍗′汉淇℃伅鍜屽巻鍙叉搷浣滆褰曘€?/p>
<div class="brand">FSE 铁路票务系统控制台</div>
<p class="jr-admin-sidebar-copy">统一管理 IC 卡发卡、充值、持卡人信息以及历史操作记录。</p>
</div>
<div class="nav">
<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 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 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 class="sidebar-footer jr-admin-sidebar-status">
<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 class="main">
@@ -72,42 +76,42 @@
<div class="flex" style="gap: 12px;">
<div>
<span class="jr-kicker">JR STYLE ADMIN</span>
<h3 style="margin: 0;">IC 鍗$鐞?/h3>
<h3 style="margin: 0;">IC 卡管理</h3>
</div>
</div>
</div>
<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 class="content">
<section class="jr-page-intro jr-admin-intro">
<span class="jr-kicker">IC MANAGEMENT</span>
<h1>IC 鍗″彂琛屼笌鐘舵€佺鐞?/h1>
<p>寤剁画鍏紑椤电殑鐧藉簳闂ㄦ埛鍐欐硶锛岃鍙戝崱銆佸偍鍊煎拰浜嬩欢璁板綍鍦ㄥ悓涓€鍧楃鐞嗗伐浣滃尯涓繚鎸佹竻鏅扮殑闃呰鑺傚銆?/p>
<h1>IC 卡发行与状态管理</h1>
<p>延续公共页面的白底门户风格,让发卡、储值与事件记录在同一块工作区域中保持清晰易读。</p>
</section>
<section class="jr-home-alert jr-admin-alert">
<div class="jr-alert-title">
<i class="fas fa-circle-info"></i>
<span>涓氬姟鑼冨洿</span>
<span>业务范围</span>
</div>
<p>褰撳墠椤甸潰鐢ㄤ簬澶勭悊 IC 鍗″垱寤恒€佷綑棰濈鐞嗐€佹寔鍗′汉璧勬枡鍜屼簨浠舵祦鏌ョ湅锛岄€傚悎浣滀负鍚庡彴鍗″姟绠$悊鐨勫崟鐙叆鍙c€?/p>
<p>当前页面用于处理 IC 卡创建、余额管理、持卡人资料和事件流查看,适合作为后台卡务管理的单独入口。</p>
</section>
<div class="grid">
<div class="card">
<div class="stat-label">IC 鍗℃€绘暟</div>
<div class="stat-label">IC 卡总数</div>
<div class="stat-value" id="statTotal">0</div>
</div>
<div class="card">
<div class="stat-label">寰呴鍗?/div>
<div class="stat-label">待领卡</div>
<div class="stat-value" id="statPending">0</div>
</div>
<div class="card">
<div class="stat-label">姝e父鍚敤</div>
<div class="stat-label">正常启用</div>
<div class="stat-value" id="statActive">0</div>
</div>
<div class="card">
<div class="stat-label">鍌ㄥ€兼€婚</div>
<div class="stat-label">储值总额</div>
<div class="stat-value" id="statBalance">0</div>
</div>
</div>
@@ -115,28 +119,25 @@
<div class="management-sidebar">
<div class="card">
<div class="flex between mb-4">
<h4>蹇€熷缓鍗?/h4>
<h4>快速建卡</h4>
</div>
<div class="ic-form-grid">
<input id="createHolder" placeholder="鎸佸崱浜哄鍚嶏紙浠呰嫳鏂囦笌绗﹀彿锛?>
<input id="createBalance" type="number" min="0" step="1" value="50"
placeholder="鍒濆浣欓">
<input id="createHolder" placeholder="持卡人姓名,仅支持英文与常用符号">
<input id="createBalance" type="number" min="0" step="1" value="50" placeholder="初始余额">
</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;">
<button id="createBtn" class="btn primary"><i class="fas fa-plus"></i> 鍒涘缓 IC
鍗?/button>
<button id="createBtn" class="btn primary"><i class="fas fa-plus"></i> 创建 IC 卡</button>
</div>
</div>
<div class="card"
style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
<div class="card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
<div class="flex between mb-4">
<h4>鍗$墖鍒楄〃</h4>
<h4>卡片列表</h4>
<span class="badge" id="listCountBadge">0</span>
</div>
<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 id="cardList" class="list-lines" style="flex:1; overflow-y:auto;"></div>
</div>
@@ -145,33 +146,31 @@
<div class="management-main">
<div class="card">
<div class="flex between mb-4">
<h4>鍗$墖璇︽儏</h4>
<h4>卡片详情</h4>
<div class="flex" style="gap:8px;">
<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>
<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>
</div>
</div>
<div id="detailPanel" class="empty-state">
<i class="fas fa-credit-card" style="font-size:48px; opacity:0.3;"></i>
<p>浠庡乏渚ч€夋嫨涓€寮?IC 鍗′互鏌ョ湅璇︽儏銆?/p>
<p>从左侧选择一张 IC 卡以查看详情。</p>
</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="flex between mb-4">
<h4>鎿嶄綔璁板綍</h4>
<h4>操作记录</h4>
</div>
<div id="eventList" class="timeline">
<div class="loading">閫夋嫨鍗$墖鍚庢樉绀轰簨浠舵祦銆?/div>
<div class="loading">选择卡片后显示事件流。</div>
</div>
</div>
</div>
</div>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank"
rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</div>
@@ -180,7 +179,7 @@
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ic-card-admin.js?v=2"></script>
<script>
@@ -194,8 +193,11 @@
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
};
document.getElementById('icTopLink').href = links.home;
document.getElementById('icBrandLink').href = links.home;
const topLink = document.getElementById('icTopLink');
const brandLink = document.getElementById('icBrandLink');
if (topLink) topLink.href = links.home;
if (brandLink) brandLink.href = links.home;
document.querySelectorAll('[data-link]').forEach((el) => {
const key = el.getAttribute('data-link');
@@ -206,3 +208,4 @@
</body>
</html>
+5 -4
View File
@@ -7,7 +7,7 @@
<title>IC 卡详情</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=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="public-search jr-public-page">
@@ -30,8 +30,8 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE Railway</strong>
<span>IC Card Detail</span>
<strong>FarSight-T.N.E铁路运输</strong>
<span>IC卡 详情</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
@@ -99,7 +99,7 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ic-card-detail.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
@@ -110,3 +110,4 @@
</body>
</html>
+58 -39
View File
@@ -1,13 +1,13 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<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="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="public-search jr-public-page">
@@ -16,12 +16,12 @@
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>杩斿洖棣栭〉</span>
<span>返回首页</span>
</a>
<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-value" data-server-status-value>妫€娴嬩腑</span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
@@ -30,102 +30,121 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE Railway</strong>
<span>IC Card Online Order</span>
<strong>FarSight-T.N.E铁路运输</strong>
<span>IC卡 线上预定</span>
</div>
</a>
<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/order" data-link="order">绾夸笂棰勫畾</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/order" data-link="card-order"
class="is-active">绾夸笂璐崱</a>
<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/order" data-link="order">线上预定</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/order" data-link="card-order" class="is-active">线上购卡</a>
</nav>
</div>
</div>
<main class="jr-public-main">
<section class="jr-page-intro">
<span class="jr-kicker">IC CARD ORDER</span>
<h1>鍦ㄧ嚎璐拱 IC 鍗″苟鐢熸垚棰嗗崱鍑瘉</h1>
<p>鎻愪氦鎸佸崱浜哄鍚嶅苟閫夋嫨棣栨鍏呭€奸噾棰濆悗锛岀郴缁熶細鍗虫椂鐢熸垚鍗″彿鍜?5 浣嶅嚟璇佺爜锛屾梾瀹㈠彲鍑嚟璇佺爜鍒扮珯鍐呭姙鐞嗛鍗°€?/p>
<h1>在线购买 IC 卡并生成领卡凭证</h1>
<p>提交持卡人姓名并选择首次充值金额后,系统会即时生成卡号和 5 位凭证码,旅客可凭凭证码到站内办理领卡。</p>
</section>
<section class="jr-home-alert">
<div class="jr-alert-title">
<i class="fas fa-circle-info"></i>
<span>璐崱鎻愰啋</span>
<span>购卡提醒</span>
</div>
<p>绾夸笂璐崱鍒涘缓鍚庨粯璁ょ姸鎬佷负鈥滃緟棰嗗崱鈥濓紱鎸佸崱浜哄鍚嶄粎鏀寔鑻辨枃涓庡父鐢ㄧ鍙枫€傚闇€琛ユ煡鍑瘉鎴栧崱鐗囩姸鎬侊紝鍙墠寰€ IC 鍗℃煡璇㈤〉闈㈣緭鍏ュ崱鍙锋垨鍑瘉鐮佹绱€?/p>
<p>线上购卡创建后默认状态为“待领卡”;持卡人姓名仅支持英文与常用符号。如需补查凭证或卡片状态,可前往 IC 卡查询页输入卡号或凭证码检索。</p>
</section>
<section class="jr-grid-two">
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h2>棣栨鍏呭€?/h2>
<h2>首次充值</h2>
<span class="jr-panel-note">First Top-up</span>
</div>
<div id="rechargeOptionList" class="jr-card-plan-grid">
<div class="jr-center-empty">
<p>姝e湪鍔犺浇鍏呭€奸厤缃?..</p>
<p>正在加载充值配置...</p>
</div>
</div>
<div id="customRechargeBox" class="jr-card-plan-custom-box">
<input id="customInitialBalance" class="jr-search-input" type="number" min="1" step="1"
placeholder="鑷畾涔夐娆″厖鍊奸噾棰濓紙閫夋嫨鈥滆嚜瀹氫箟鈥濆悗鍚敤锛? disabled>
placeholder="自定义首次充值金额,选择“自定义”后启用" disabled>
</div>
<div class="jr-panel-headline" style="margin-top:24px;">
<h3>鎸佸崱浜轰俊鎭?/h3>
<h3>持卡人信息</h3>
<span class="jr-panel-note">Order Form</span>
</div>
<div class="ic-form-grid">
<input id="holderName" class="jr-search-input" type="text" maxlength="24"
placeholder="鎸佸崱浜哄鍚嶏紙浠呰嫳鏂囦笌绗﹀彿锛?>
placeholder="持卡人姓名,仅支持英文与常用符号">
</div>
<p id="holderNameHint" class="text-muted" style="margin-top:12px;">浠呮敮鎸佽嫳鏂囦笌甯哥敤绗﹀彿锛屼緥濡?`Alex
Smith`銆乣A.Brown`銆乣Chris-O'Neil`銆?/p>
<p id="holderNameHint" class="text-muted" style="margin-top:12px;">仅支持英文与常用符号,例如 `Alex Smith`、`A.Brown`、`Chris-O'Neil`。</p>
<div class="jr-action-row">
<button id="submitOrderBtn" class="btn primary jr-search-button"><i
class="fas fa-credit-card"></i> 鎻愪氦璐崱</button>
<button id="submitOrderBtn" class="btn primary jr-search-button">
<i class="fas fa-credit-card"></i>
提交购卡
</button>
</div>
</article>
<div>
<article class="jr-panel-card" style="margin-bottom:20px;">
<div class="jr-panel-headline">
<h2>璐圭敤棰勪及</h2>
<h2>费用预估</h2>
<span class="jr-panel-note">Estimate</span>
</div>
<div id="estimateBox" class="ic-inline-meta">
<div class="jr-center-empty">
<p>璇烽€夋嫨棣栨鍏呭€奸噾棰濆悗鏌ョ湅璐圭敤鏋勬垚銆?/p>
<p>请选择首次充值金额后查看费用构成。</p>
</div>
</div>
</article>
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h2>璐崱缁撴灉</h2>
<h2>购卡结果</h2>
<span class="jr-panel-note">Card Result</span>
</div>
<div id="orderResultBox" class="jr-center-empty">
<p>鎻愪氦鍚庡皢鍦ㄦ鏄剧ず鍗″彿銆佸嚟璇佺爜鍜岄鍗℃彁绀恒€?/p>
<p>提交后将在此显示卡号、凭证码和领卡提示。</p>
</div>
</article>
</div>
</section>
<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>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ic-card-order.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const links = {
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>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
const links = {
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>
</html>
+129 -35
View File
@@ -1,13 +1,13 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<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="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
<link rel="stylesheet" href="/style.css?v=14">
</head>
<body class="public-search jr-public-page">
@@ -16,12 +16,12 @@
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>杩斿洖棣栭〉</span>
<span>返回首页</span>
</a>
<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-value" data-server-status-value>妫€娴嬩腑</span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
@@ -30,74 +30,168 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE閾佽矾绁ㄥ姟绯荤粺</strong>
<span>IC 鍗℃煡璇㈡湇鍔?/span>
<strong>FarSight-T.N.E铁路运输</strong>
<span>IC卡 查询</span>
</div>
</a>
<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/order" data-link="order">绾夸笂棰勫畾</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>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">绾夸笂璐崱</a>
<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/order" data-link="order">线上预定</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>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
</nav>
</div>
</div>
<main class="jr-public-main">
<section class="jr-page-intro">
<span class="jr-kicker">IC CARD SEARCH</span>
<h1>鎸夊崱鍙锋垨鍑瘉鐮佹煡璇?IC 鍗$姸鎬?/h1>
<p>鍙煡璇?IC 鍗$殑褰撳墠鐘舵€併€佷綑棰濆拰鏈€杩戞搷浣滆褰曘€傝緭鍏ョ嚎涓婅喘鍗$敓鎴愮殑鍑瘉鐮佷篃鍙弽鏌ュ搴斿崱鐗囥€?/p>
<h1>按卡号或凭证码查询 IC 卡状态</h1>
<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 class="jr-panel-card" style="margin-bottom:24px;">
<div class="jr-panel-headline">
<h2>妫€绱㈡潯浠?/h2>
<h2>检索条件</h2>
<span class="jr-panel-note">Card ID / Voucher Code</span>
</div>
<div class="jr-search-form">
<input id="queryInput" class="jr-search-input" type="text"
placeholder="杈撳叆鍗″彿鎴栧嚟璇佺爜锛屽 IC-348215 / M1SKP" />
<button id="queryBtn" class="btn primary jr-search-button"><i class="fas fa-search"></i> 鏌ヨ IC
鍗?/button>
placeholder="输入卡号或凭证码,例如 IC-348215 / M1SKP" />
<button id="queryBtn" class="btn primary jr-search-button">
<i class="fas fa-search"></i>
查询 IC 卡
</button>
</div>
<p class="jr-search-helper">留空可浏览全部 IC 卡;输入卡号或凭证码后,可直接定位到对应卡片详情。</p>
</section>
<section class="jr-grid-two">
<section class="jr-search-results">
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h3>鍗$墖姒傝</h3>
<h3>结果列表</h3>
<span class="jr-panel-note">Card Results</span>
</div>
<div id="summaryBox" class="jr-scroll-box">
<div class="jr-center-empty" style="min-height:180px;">
<p>请输入卡号或凭证码开始查询。</p>
</div>
</div>
</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="summaryBox" class="jr-center-empty">
<p>璇疯緭鍏ュ崱鍙锋垨鍑瘉鐮佸紑濮嬫煡璇€?/p>
<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>
<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>
<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>
<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>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ic-card-search.js?v=2"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ic-card-search.js?v=3"></script>
<script src="/public-status.js?v=13"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const links = {
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>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
const links = {
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>
</html>
+146 -22
View File
@@ -3,7 +3,12 @@
const inputEl = $('#queryInput');
const queryBtn = $('#queryBtn');
const summaryBoxEl = $('#summaryBox');
const detailBoxEl = $('#detailBox');
const eventBoxEl = $('#eventBox');
const state = {
cards: [],
selectedQuery: ''
};
const api = {
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)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
@@ -44,33 +58,40 @@
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
};
const renderSummary = (card) => {
const buildCardPreview = (card) => {
const shownCardId = card.display_card_id || card.card_id || '---';
summaryBoxEl.className = '';
summaryBoxEl.innerHTML = `
const detailHref = window.location.hostname.includes('fse-media.group')
? `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-row-head">
<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 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>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.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(formatTime(card.created_ts))}</strong></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>
`;
};
const renderEvents = (events) => {
const buildEventsHtml = (events) => {
if (!events.length) {
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>暂无相关事件记录。</p></div>';
return;
return '<div class="jr-center-empty" style="min-height:180px;"><p>暂无相关事件记录。</p></div>';
}
eventBoxEl.innerHTML = events.map((event) => `
return events.map((event) => `
<div class="jr-history-item">
<div class="jr-history-row">
<span class="jr-history-title">${escapeHtml(eventTitle(event))}</span>
@@ -81,37 +102,140 @@
`).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>${escapeHtml(message)}</p>`;
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>暂无可显示的事件记录。</p></div>';
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.innerHTML = '<p>正在加载全部 IC 卡...</p>';
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 q = inputEl.value.trim();
if (!q) {
renderError('请输入卡号或凭证码');
await loadAllCards();
return;
}
summaryBoxEl.className = 'jr-center-empty';
summaryBoxEl.innerHTML = '<p>正在查询 IC 卡...</p>';
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>正在加载事件记录...</p></div>';
const data = await api.query(q);
renderSummary(data.card || {});
renderEvents(data.events || []);
const newUrl = `${window.location.origin}${window.location.pathname}?q=${encodeURIComponent(q)}`;
window.history.replaceState({ path: newUrl }, '', newUrl);
renderDetailPrompt('正在查询卡片详情...');
renderEventPrompt('正在查询事件记录...');
state.cards = [];
await loadCardDetail(q);
};
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) => {
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 q = params.get('q');
if (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)));
}
})();
+120 -27
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<!-- 充满未知和不稳定的票务系统! -->
@@ -7,8 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FSE铁路票务系统控制台</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="style.css?v=12">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<link rel="stylesheet" href="style.css?v=14">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
@@ -22,11 +22,6 @@
<i class="fas fa-train"></i>
<span>FSE 铁路运输后台系统</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
@@ -88,15 +83,6 @@
<span class="nav-icon"><i class="fas fa-list"></i></span> 日志
</div>
</div>
<!--连接状态显示-->
<div class="jr-admin-sidebar-status">
<div class="jr-admin-sidebar-status-label">Server: {{ connected ? 'Online' : 'Offline' }}</div>
<div class="flex" style="align-items: center; gap: 6px; margin-bottom: 15px;">
<i class="fas fa-circle"
:style="{ color: connected ? '#10b981' : '#ef4444', fontSize: '0.6rem' }"></i>
<span>Status: {{ connected ? 'Connected' : 'Disconnected' }}</span>
</div>
</div>
</div>
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false"></div>
@@ -112,10 +98,18 @@
</div>
</div>
<div class="jr-admin-header-side">
<span class="jr-admin-header-pill" :class="connected ? 'is-online' : 'is-offline'">
<i class="fas fa-circle"></i>
{{ connected ? '服务器在线' : '服务器离线' }}
</span>
<div class="jr-admin-sync-meta">
<span class="jr-admin-sync-label">当前模块</span>
<strong>{{ viewTitle }}</strong>
</div>
<div class="jr-admin-sync-meta">
<span class="jr-admin-sync-label">最近同步</span>
<strong>{{ lastSyncText }}</strong>
</div>
<button class="btn primary" @click="refreshData" :disabled="isViewBusy">
<i class="fas" :class="isViewBusy ? 'fa-spinner fa-spin' : 'fa-rotate-right'"></i>
{{ isViewBusy ? '同步中' : '刷新视图' }}
</button>
</div>
</div>
<div class="content">
@@ -131,6 +125,32 @@
</div>
<p>当前模块:{{ viewTitle }},已加载 {{ lines.length }} 条线路、{{ stations.length }} 个站点。需要做批量调整时,可先在左侧切换模块再进入对应卡片操作区。</p>
</section>
<section class="jr-admin-overview-grid">
<article class="jr-admin-overview-card">
<span class="jr-admin-overview-label">当前模块</span>
<strong class="jr-admin-overview-value">{{ viewTitle }}</strong>
<p class="jr-admin-overview-note">{{ currentViewSummary }}</p>
</article>
<article class="jr-admin-overview-card">
<span class="jr-admin-overview-label">线路与站点</span>
<strong class="jr-admin-overview-value">{{ lines.length }} / {{ stations.length }}</strong>
<p class="jr-admin-overview-note">后台操作统一建立在线路、站点与票价的核心数据之上。</p>
</article>
<article class="jr-admin-overview-card">
<span class="jr-admin-overview-label">同步状态</span>
<strong class="jr-admin-overview-value">{{ isViewBusy ? '正在更新' : '数据已就绪' }}</strong>
<p class="jr-admin-overview-note">切换模块时只拉取当前视图需要的数据,减少等待与无效刷新。</p>
</article>
<article class="jr-admin-overview-card is-actions">
<span class="jr-admin-overview-label">快捷操作</span>
<div class="jr-admin-overview-actions">
<button class="btn" @click="currentView = 'management'"><i class="fas fa-network-wired"></i> 线路管理</button>
<button class="btn" @click="currentView = 'iccards'"><i class="fas fa-credit-card"></i> IC 卡务</button>
<button class="btn" @click="currentView = 'logs'"><i class="fas fa-list"></i> 查看日志</button>
<button class="btn primary" @click="refreshData" :disabled="isViewBusy"><i class="fas fa-rotate-right"></i> 立即同步</button>
</div>
</article>
</section>
<!-- 仪表盘-->
<div v-if="currentView === 'dashboard'">
<div class="grid">
@@ -255,8 +275,12 @@
</div>
<!-- 可视化线路编辑-->
<div class="visual-line-container">
<svg width="100%" height="200"
<div class="visual-line-container"
ref="visualLineViewport"
:class="{ 'is-panning': lineViewportPan.active }"
@mousedown="startLineViewportPan"
@mousemove="moveLineViewportPan">
<svg :width="lineEditorSvgWidth" height="200"
v-if="selectedLine.stations && selectedLine.stations.length > 0">
<!--站点连接线-->
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100"
@@ -479,6 +503,17 @@
</div>
<div v-if="currentView === 'iccards'">
<section class="jr-admin-section-toolbar">
<div class="jr-admin-section-toolbar-copy">
<span class="jr-admin-overview-label">IC CARD DESK</span>
<strong>{{ currentViewSummary }}</strong>
<p>把检索、充值、状态维护和事件核对集中在同一工作流里,减少在列表和详情之间来回跳转的成本。</p>
</div>
<div class="jr-admin-overview-actions">
<button class="btn" @click="fetchIcCards(false)" :disabled="isViewBusy"><i class="fas fa-list"></i> 刷新列表</button>
<button class="btn primary" @click="loadIcCard(icSelectedId)" :disabled="!icSelectedId || isViewBusy"><i class="fas fa-id-card"></i> 刷新详情</button>
</div>
</section>
<div class="grid">
<div class="card">
<div class="stat-label">IC 卡总数</div>
@@ -500,9 +535,24 @@
<div class="management-container ic-admin-layout">
<div class="management-sidebar">
<div class="card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
<div class="card jr-admin-note-card">
<div class="flex between mb-4">
<h4>操作说明</h4>
<span class="badge">只读入口</span>
</div>
<p class="jr-admin-card-note">本模块不提供后台快速建卡,卡片发放流程保持在线上购卡或既有开卡流程中完成,后台仅负责检索、维护、充值与记录核对。</p>
<div class="jr-admin-note-list">
<div>1. 先检索卡号、订单号或持卡人。</div>
<div>2. 在详情面板修改状态并保存。</div>
<div>3. 需要补款时直接使用充值入口。</div>
</div>
</div>
<div class="card jr-admin-list-card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
<div class="flex between mb-4">
<div>
<h4>卡片列表</h4>
<div class="jr-admin-list-meta">支持按卡号、订单号、凭证码和持卡人姓名检索。</div>
</div>
<span class="badge">{{ icCards.length }}</span>
</div>
<div class="flex mb-4" style="flex-wrap:wrap;">
@@ -536,8 +586,13 @@
<div class="management-main">
<div class="card">
<div class="flex between mb-4">
<div>
<h4>卡片详情</h4>
<div class="jr-admin-list-meta">在同一面板直接处理状态维护、充值和记录核对。</div>
</div>
<div class="flex" style="gap:8px;">
<button class="btn" @click="loadIcCard(icSelectedId)" :disabled="!icSelectedCard || isViewBusy"><i class="fas fa-rotate-right"></i> 刷新</button>
<button class="btn" @click="topupIcCard" :disabled="!icSelectedCard || isViewBusy"><i class="fas fa-wallet"></i> 充值</button>
<button class="btn danger" @click="deleteIcCard" :disabled="!icSelectedCard"><i class="fas fa-trash"></i> 删除卡</button>
<button class="btn primary" @click="saveIcCard" :disabled="!icSelectedCard"><i class="fas fa-save"></i> 保存</button>
</div>
@@ -554,6 +609,23 @@
</div>
<span class="badge" :class="icStatusInfo(icSelectedCard.status).className">{{ icStatusInfo(icSelectedCard.status).text }}</span>
</div>
<div class="jr-admin-summary-grid">
<div class="jr-admin-summary-item">
<span>当前余额</span>
<strong>{{ formatMoney(icSelectedCard.balance) }}</strong>
<small>支持直接发起充值。</small>
</div>
<div class="jr-admin-summary-item">
<span>事件记录</span>
<strong>{{ icSelectedEvents.length }}</strong>
<small>用于追踪开卡与状态变更。</small>
</div>
<div class="jr-admin-summary-item">
<span>订单来源</span>
<strong>{{ cardOrderCode(icSelectedCard) }}</strong>
<small>自动识别线上订单或现场办卡。</small>
</div>
</div>
<div class="ic-detail-grid">
<label class="ic-field">
<span>持卡人</span>
@@ -769,6 +841,27 @@
<button @click="saveConfig"><i class="fas fa-save"></i> 保存</button>
</div>
</div>
<div>
<label style="display:block; margin-bottom:8px; font-weight:600;">Lua 脚本更新控制</label>
<div class="flex" style="flex-direction: column; gap: 10px;">
<div class="flex" style="flex-wrap: wrap; gap: 10px; align-items: center;">
<span style="min-width: 70px;">售票机</span>
<input v-model="config.lua_versions.ticketmachine" placeholder="例如 v1.5.8" style="max-width: 180px;">
<button @click="bumpLuaVersion('ticketmachine')"><i class="fas fa-arrow-up"></i> 补丁 +1</button>
</div>
<div class="flex" style="flex-wrap: wrap; gap: 10px; align-items: center;">
<span style="min-width: 70px;">检票机</span>
<input v-model="config.lua_versions.gate" placeholder="例如 v1.5.8" style="max-width: 180px;">
<button @click="bumpLuaVersion('gate')"><i class="fas fa-arrow-up"></i> 补丁 +1</button>
</div>
<div class="text-muted" style="font-size: 0.9rem;">
每次发布新的 Lua 脚本后,在这里手动提升一次版本号;设备检测到不一致时会在左上角版本号旁显示更新标记。
</div>
<div class="flex">
<button @click="saveConfig"><i class="fas fa-save"></i> 保存 Lua 版本</button>
</div>
</div>
</div>
</div>
<div class="card">
<h4>数据管理</h4>
@@ -841,9 +934,8 @@
</div>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=13"></script>
<script src="index.js?v=2"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="index.js?v=6"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
@@ -873,3 +965,4 @@
+254 -60
View File
@@ -34,8 +34,9 @@ createApp({
return map[currentView.value] || '票价图';
});
const connected = ref(false);
const socket = io({ transports: ['websocket', 'polling'], upgrade: false });
// Prefer polling first so admin remains connected even when the proxy
// does not support WebSocket upgrades reliably.
const socket = io({ transports: ['polling', 'websocket'] });
// Data State
const stations = ref([]);
@@ -43,7 +44,11 @@ createApp({
const fares = ref([]);
const tickets = ref([]);
const stats = reactive({ sold_tickets: 0, revenue: 0 });
const config = reactive({ api_base: '', promotion: { name: '', discount: 1 } });
const config = reactive({
api_base: '',
promotion: { name: '', discount: 1 },
lua_versions: { ticketmachine: 'v1.5.8', gate: 'v1.5.8' }
});
const logs = ref([]);
const logCategory = ref('');
const logTypeFilter = ref('');
@@ -63,6 +68,24 @@ createApp({
const icCreateForm = reactive({ holder_name: '', balance: 50 });
const icDetailForm = reactive({ holder_name: '', status: 'active' });
let icCardSyncTimer = null;
let icCardSyncBusy = false;
let icListRequestSeq = 0;
let icDetailRequestSeq = 0;
let appMouseupHandler = null;
let coreLoaded = false;
let ticketDataLoaded = false;
let orderDataLoaded = false;
let logDataLoaded = false;
let assetsLoaded = false;
let fareMapLoaded = false;
const loadingState = reactive({
core: false,
tickets: false,
orders: false,
logs: false,
iccards: false
});
const lastSyncAt = ref(0);
// UI State
const showAddLine = ref(false);
@@ -82,6 +105,15 @@ createApp({
const showFareModal = ref(false);
const currentFare = reactive({ exists: false, cost_regular: 0, cost_express: 0 });
const draggingStationIndex = ref(null);
const visualLineViewport = ref(null);
const lineViewportPan = reactive({
active: false,
startX: 0,
startY: 0,
scrollLeft: 0,
scrollTop: 0,
moved: false
});
const showStationModal = ref(false);
const stationForm = reactive({ code: '', name: '', en_name: '', transfer_enabled: false, transfer_to: [] });
const stationFormOriginalCode = ref('');
@@ -103,6 +135,9 @@ createApp({
confirm: (message) => Promise.resolve(window.confirm(message)),
prompt: (message, defaultValue) => Promise.resolve(window.prompt(message, defaultValue))
};
const markSynced = () => {
lastSyncAt.value = Date.now();
};
const buildAssetUrl = (name) => {
if (!name) return '';
@@ -193,6 +228,20 @@ createApp({
}
};
const normalizeLuaVersion = (value) => {
let text = String(value || '').trim();
if (!text) text = 'v1.0.0';
if (!/^v/i.test(text)) text = `v${text}`;
return text;
};
const bumpPatchVersion = (value) => {
const normalized = normalizeLuaVersion(value);
const matched = /^v?(\d+)\.(\d+)\.(\d+)$/i.exec(normalized);
if (!matched) return normalized;
return `v${matched[1]}.${matched[2]}.${Number(matched[3]) + 1}`;
};
// Methods
const formatTime = (ts) => {
if (ts == null || ts === '') return '---';
@@ -417,6 +466,7 @@ createApp({
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
assetsLoaded = true;
assetsFarePreview.headers = [];
assetsFarePreview.rows = [];
@@ -454,6 +504,7 @@ createApp({
}
} catch (e) {}
}
markSynced();
};
const uploadAssetFile = async (url, file) => {
@@ -631,18 +682,63 @@ createApp({
draggingStationIndex.value = null;
};
const startLineViewportPan = (event) => {
const viewport = visualLineViewport.value;
if (!viewport) return;
if (event.button !== 0) return;
if (event.target && event.target.closest('.station-node')) return;
lineViewportPan.active = true;
lineViewportPan.moved = false;
lineViewportPan.startX = event.clientX;
lineViewportPan.startY = event.clientY;
lineViewportPan.scrollLeft = viewport.scrollLeft;
lineViewportPan.scrollTop = viewport.scrollTop;
};
const moveLineViewportPan = (event) => {
if (!lineViewportPan.active) return;
const viewport = visualLineViewport.value;
if (!viewport) return;
const deltaX = event.clientX - lineViewportPan.startX;
const deltaY = event.clientY - lineViewportPan.startY;
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
lineViewportPan.moved = true;
}
viewport.scrollLeft = lineViewportPan.scrollLeft - deltaX;
viewport.scrollTop = lineViewportPan.scrollTop - deltaY;
};
const endLineViewportPan = () => {
lineViewportPan.active = false;
};
// --- Order Management ---
const fetchOrders = async () => {
if (loadingState.orders) return;
loadingState.orders = true;
try {
const res = await requestJson('/api/orders');
if (res && res.ok) orders.value = res.orders;
} catch (e) { console.error(e); }
if (res && res.ok) {
orders.value = res.orders || [];
orderDataLoaded = true;
markSynced();
}
} catch (e) {
console.error(e);
} finally {
loadingState.orders = false;
}
};
const fetchIcCards = async (keepSelection = true) => {
if (loadingState.iccards) return;
loadingState.iccards = true;
const requestSeq = ++icListRequestSeq;
const sp = new URLSearchParams();
if (icCardSearch.value.trim()) sp.set('q', icCardSearch.value.trim());
try {
const res = await requestJson(`/api/ic-cards?${sp.toString()}`);
if (requestSeq !== icListRequestSeq) return;
icCards.value = res?.cards || [];
if (keepSelection && icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
await loadIcCard(icSelectedId.value);
@@ -651,16 +747,25 @@ createApp({
icSelectedCard.value = null;
icSelectedEvents.value = [];
}
markSynced();
} finally {
if (requestSeq === icListRequestSeq) {
loadingState.iccards = false;
}
}
};
const loadIcCard = async (id) => {
const requestSeq = ++icDetailRequestSeq;
const res = await requestJson(`/api/ic-cards/${encodeURIComponent(id)}`);
if (requestSeq !== icDetailRequestSeq) return;
const card = res?.card || null;
icSelectedId.value = id;
icSelectedCard.value = card;
icSelectedEvents.value = res?.events || [];
icDetailForm.holder_name = card?.holder_name || '';
icDetailForm.status = card?.status || 'active';
markSynced();
};
const syncSelectedIcCard = async () => {
@@ -683,9 +788,15 @@ createApp({
stopIcCardSync();
if (currentView.value !== 'iccards') return;
icCardSyncTimer = setInterval(() => {
fetchIcCards(false).catch(console.error);
syncSelectedIcCard().catch(console.error);
}, 3000);
if (document.hidden || icCardSyncBusy) return;
icCardSyncBusy = true;
Promise.all([
fetchIcCards(false).catch(console.error),
icSelectedId.value ? syncSelectedIcCard().catch(console.error) : Promise.resolve()
]).finally(() => {
icCardSyncBusy = false;
});
}, 5000);
};
const createIcCard = async () => {
@@ -775,10 +886,15 @@ createApp({
};
const fetchLogs = async () => {
if (logLoading.value) return;
logLoading.value = true;
try {
const res = await requestJson(buildLogsUrl());
if (res && res.ok) logs.value = res.logs || [];
if (res && res.ok) {
logs.value = res.logs || [];
logDataLoaded = true;
markSynced();
}
} catch (e) {
console.error(e);
} finally {
@@ -823,66 +939,67 @@ createApp({
return `¥${reg} / ¥${exp}`;
};
const fetchData = async () => {
const fetchCoreData = async ({ force = false } = {}) => {
if (loadingState.core) return;
if (coreLoaded && !force) return;
loadingState.core = true;
try {
const safeFetch = (url, defaultVal) => requestJson(url).catch(e => {
const safeFetch = (url, defaultVal) => requestJson(url).catch((e) => {
console.error(`Fetch failed for ${url}`, e);
lastActionError.value = e?.message || String(e);
return defaultVal;
});
const safeFetchList = (url, key) => requestJson(url).then(d => d?.[key] || []).catch(e => {
console.error(`Fetch list failed for ${url}`, e);
lastActionError.value = e?.message || String(e);
return [];
});
const [s, l, f, c, t, lg, st, ord, cards] = await Promise.all([
const [s, l, f, c, st] = await Promise.all([
safeFetch('/api/stations', []),
safeFetch('/api/lines', []),
safeFetch('/api/fares', []),
safeFetch('/api/config', {}),
safeFetchList('/api/tickets', 'tickets'),
safeFetchList(buildLogsUrl(), 'logs'),
safeFetch('/api/stats/ticket/total', {}).then(d => d.total || {}),
safeFetchList('/api/orders', 'orders'),
safeFetchList('/api/ic-cards', 'cards')
safeFetch('/api/stats/ticket/total', {}).then((d) => d.total || {})
]);
stations.value = s;
lines.value = l;
fares.value = f;
Object.assign(config, c);
tickets.value = t;
logs.value = lg;
Object.assign(stats, st);
orders.value = ord;
icCards.value = cards;
// Refresh selected line if it exists
if (selectedLine.value) {
const found = lines.value.find(l => l.id === selectedLine.value.id);
if (found) selectedLine.value = found;
const found = lines.value.find((line) => line.id === selectedLine.value.id);
selectedLine.value = found || null;
}
if (icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
await loadIcCard(icSelectedId.value);
}
loadFareMap();
coreLoaded = true;
markSynced();
} catch (e) {
console.error("Failed to fetch data", e);
console.error('Failed to fetch core data', e);
} finally {
loadingState.core = false;
}
};
const loadFareMap = async () => {
const fetchTicketData = async () => {
if (loadingState.tickets) return;
loadingState.tickets = true;
try {
const res = await requestJson('/api/tickets');
tickets.value = res?.tickets || [];
ticketDataLoaded = true;
markSynced();
} catch (e) {
console.error('Failed to fetch tickets', e);
} finally {
loadingState.tickets = false;
}
};
const loadFareMap = async ({ force = false } = {}) => {
if (fareMapLoading.value) return;
if (fareMapLoaded && !force) return;
fareMapLoading.value = true;
fareMapError.value = '';
try {
// Change to fetch the SVG text directly from the public API
// Add timestamp to prevent caching
const r = await fetch(`/api/public/fares/map/light?t=${Date.now()}`);
const svg = await r.text();
fareMapSvg.value = svg;
fareMapLoaded = true;
markSynced();
} catch (e) {
console.error("Failed to load fare map", e);
fareMapError.value = '加载失败';
@@ -891,6 +1008,25 @@ createApp({
}
};
const ensureViewData = async (view = currentView.value, { force = false } = {}) => {
await fetchCoreData({ force });
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
if (view === 'iccards') {
await fetchIcCards(true);
if (icSelectedId.value) {
await syncSelectedIcCard().catch(console.error);
}
}
if (view === 'faremap' && (force || !fareMapLoaded)) await loadFareMap({ force });
if (view === 'assets' && (!assetsLoaded || force)) await fetchAssetsManifest();
};
const fetchData = async () => {
await ensureViewData(currentView.value, { force: true });
};
const zoomFareMapIn = () => { fareMapScale.value = Math.min(3, Math.round((fareMapScale.value + 0.1) * 100) / 100); };
const zoomFareMapOut = () => { fareMapScale.value = Math.max(0.3, Math.round((fareMapScale.value - 0.1) * 100) / 100); };
const zoomFareMapReset = () => { fareMapScale.value = 1; };
@@ -1093,6 +1229,10 @@ createApp({
};
const handleStationClick = async (code) => {
if (lineViewportPan.moved) {
lineViewportPan.moved = false;
return;
}
if (stationEditMode.value) {
openStationModal(code);
return;
@@ -1222,26 +1362,35 @@ createApp({
const saveConfig = async () => {
await runMutation(async () => {
config.lua_versions.ticketmachine = normalizeLuaVersion(config.lua_versions.ticketmachine);
config.lua_versions.gate = normalizeLuaVersion(config.lua_versions.gate);
await requestJson('/api/config', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(config) }, { expectOk: true });
}, { successMessage: '保存成功' });
};
const bumpLuaVersion = async (device) => {
if (!config.lua_versions[device]) config.lua_versions[device] = 'v1.0.0';
config.lua_versions[device] = bumpPatchVersion(config.lua_versions[device]);
await saveConfig();
};
const exportData = () => {
window.open('/api/export', '_blank');
};
// Socket Listeners
socket.on('connect', () => { connected.value = true; });
socket.on('disconnect', () => { connected.value = false; });
socket.on('stations:updated', (data) => {
stations.value = data;
// Refresh map when stations change
loadFareMap();
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
});
socket.on('lines:updated', (data) => {
lines.value = data;
coreLoaded = true;
// Update selectedLine reference if it exists
if (selectedLine.value) {
const updated = data.find(l => l.id === selectedLine.value.id);
@@ -1251,14 +1400,29 @@ createApp({
selectedLine.value = null; // Line was deleted
}
}
loadFareMap();
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
});
socket.on('fares:updated', (data) => {
fares.value = data;
loadFareMap();
coreLoaded = true;
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
});
socket.on('config:updated', (data) => {
Object.assign(config, data);
if (!config.lua_versions) config.lua_versions = { ticketmachine: 'v1.5.8', gate: 'v1.5.8' };
coreLoaded = true;
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
});
socket.on('config:updated', (data) => { Object.assign(config, data); loadFareMap(); });
socket.on('stats:ticket:updated', (item) => {
stats.sold_tickets += item.sold_tickets;
@@ -1281,15 +1445,12 @@ createApp({
watch(currentView, (v) => {
sidebarOpen.value = false;
if (v === 'assets') fetchAssetsManifest();
if (v === 'logs') fetchLogs();
if (v === 'iccards') {
fetchIcCards(true).catch(console.error);
syncSelectedIcCard().catch(() => {});
startIcCardSync();
} else {
stopIcCardSync();
}
ensureViewData(v).catch(console.error);
const sp = new URLSearchParams(location.search);
if (v === 'dashboard') sp.delete('view');
else sp.set('view', v);
@@ -1300,13 +1461,12 @@ createApp({
// Initial Load
onMounted(() => {
fetchData();
fetchAssetsManifest();
ensureViewData(currentView.value, { force: true }).catch(console.error);
if (currentView.value === 'iccards') {
fetchIcCards(true).catch(console.error);
startIcCardSync();
}
window.addEventListener('mouseup', async () => {
appMouseupHandler = async () => {
endLineViewportPan();
if (draggingStationIndex.value !== null) {
if (selectedLine.value) {
try {
@@ -1323,16 +1483,48 @@ createApp({
}
draggingStationIndex.value = null;
}
});
};
window.addEventListener('mouseup', appMouseupHandler);
});
onUnmounted(() => {
stopIcCardSync();
if (appMouseupHandler) {
window.removeEventListener('mouseup', appMouseupHandler);
}
});
// Computed
const recentLogs = computed(() => logs.value);
const orderList = computed(() => orders.value);
const lineEditorSvgWidth = computed(() => {
const count = Array.isArray(selectedLine.value?.stations) ? selectedLine.value.stations.length : 0;
return Math.max(960, 100 + Math.max(0, count - 1) * 120 + 120);
});
const lastSyncText = computed(() => lastSyncAt.value ? formatTime(lastSyncAt.value) : '尚未同步');
const isViewBusy = computed(() => {
if (loadingState.core) return true;
if (currentView.value === 'tickets') return loadingState.tickets;
if (currentView.value === 'vouchers') return loadingState.orders;
if (currentView.value === 'logs') return logLoading.value;
if (currentView.value === 'iccards') return loadingState.iccards;
if (currentView.value === 'faremap') return fareMapLoading.value;
return false;
});
const currentViewSummary = computed(() => {
const map = {
dashboard: `已同步 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
management: `当前可编辑 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
faremap: fareMapLoading.value ? '票价图正在生成中' : '可导出当前铁路票价图',
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
settings: '可维护优惠活动与导出数据',
logs: `当前筛选结果 ${logs.value.length} 条日志`
};
return map[currentView.value] || '后台模块已就绪';
});
const icCardStats = computed(() => ({
total: icCards.value.length,
pending: icCards.value.filter((card) => card.status === 'pending_pickup').length,
@@ -1388,7 +1580,8 @@ createApp({
};
return {
currentView, viewTitle, connected, sidebarOpen,
currentView, viewTitle, sidebarOpen,
loadingState, isViewBusy, lastSyncText, currentViewSummary,
stations, lines, fares, stats, config, recentLogs, ticketList,
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
orders, orderList, fetchOrders, deleteOrder,
@@ -1400,6 +1593,7 @@ createApp({
// Management
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
visualLineViewport, lineViewportPan, lineEditorSvgWidth, startLineViewportPan, moveLineViewportPan,
selectLine, createLine, deleteLine, deleteStation, updateLineInfo, getFareText,
isStationInLine, addStationToLine, removeStationFromLine,
handleStationClick, isStationSelected,
@@ -1412,7 +1606,7 @@ createApp({
saveCurrentFare, deleteCurrentFare, closeFareModal,
saveConfig, exportData, exportFareMap,
saveConfig, bumpLuaVersion, exportData, exportFareMap,
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,
isTransferStation, getTransferTitleSuffix, getTransferLineBadges
+17 -23
View File
@@ -4,22 +4,17 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>鎺у埗鍙扮櫥褰?/title>
<title>后台控制台登录</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="/style.css?v=12" />
<link rel="stylesheet" href="/style.css?v=13" />
</head>
<body class="jr-admin-login-page">
<div class="jr-admin-login-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="/" class="jr-top-link">
<span>FSE閾佽矾绁ㄥ姟绯荤粺鎺у埗鍙?/span>
<span>FSE铁路票务系统控制台</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">鏈嶅姟鍣ㄧ姸鎬?/span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>妫€娴嬩腑</span>
</div>
</div>
</header>
@@ -28,8 +23,8 @@
<a href="/" class="jr-brand">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE 閾佽矾杩愯緭</strong>
<span>鎺у埗鍙扮櫥褰?/span>
<strong>FarSight-T.N.E铁路运输</strong>
<span>控制台登录</span>
</div>
</a>
</div>
@@ -39,38 +34,37 @@
<section class="jr-admin-login-panel">
<div class="jr-admin-login-copy">
<span class="jr-kicker">OPERATIONS ACCESS</span>
<h1>鍚庡彴鎺у埗鍙?/h1>
<p>绾胯矾缁存姢銆佺エ鎹鐞嗐€佹棩蹇楁煡璇笌 IC 鍗$鐞?/p>
<h1>后台控制台</h1>
<p>线路维护、票务管理、日志查询与 IC 卡管理统一从这里进入。</p>
<ul class="jr-admin-login-points">
<li>缁熶竴绠$悊绾胯矾銆佺エ浠峰拰璧勬簮鍥炬枃浠?/li>
<li>鏌ョ湅鐢靛瓙绁ㄣ€佸嚟璇佷笌鎿嶄綔鏃ュ織</li>
<li>缁存姢 IC 鍗″彂琛屻€佸厖鍊间笌鐘舵€佽褰?/li>
<li>统一管理线路、票价和资源图文件</li>
<li>查看电子票、凭证与操作日志</li>
<li>维护 IC 卡发放、充值与状态记录</li>
</ul>
</div>
<section class="jr-admin-login-card">
<div class="jr-page-intro jr-page-intro-compact">
<span class="jr-kicker">SIGN IN</span>
<h2>鎺у埗鍙扮櫥褰?/h2>
<p>璇疯緭鍏ョ鐞嗗憳璐﹀彿鍜屽瘑鐮併€?/p>
<h2>控制台登录</h2>
<p>请输入管理员账号和密码。</p>
</div>
<div class="login-row"><input id="loginUser" type="text" placeholder="鐢ㄦ埛鍚? /></div>
<div class="login-row"><input id="loginPass" type="password" placeholder="瀵嗙爜" /></div>
<div class="login-row"><input id="loginUser" type="text" placeholder="用户名" /></div>
<div class="login-row"><input id="loginPass" type="password" placeholder="密码" /></div>
<div class="login-actions">
<button id="loginBtn" class="btn primary">鐧诲綍</button>
<button id="loginBtn" class="btn primary">登录</button>
<span id="loginHint" class="hint"></span>
</div>
</section>
</section>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093</a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=13"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="login.js?v=2"></script>
</body>
</html>
+356 -11
View File
@@ -28,6 +28,8 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
body {
@@ -684,6 +686,7 @@ main {
display: flex;
flex-direction: column;
flex-shrink: 0;
min-height: 0;
}
.management-main {
@@ -700,6 +703,7 @@ main {
flex-direction: column;
gap: 8px;
padding-right: 4px;
min-height: 0;
}
.line-item {
@@ -769,18 +773,26 @@ main {
.visual-line-container {
flex: 1;
overflow-x: auto;
overflow-y: hidden;
overflow: auto;
background-color: #00000022;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 40px;
min-height: 0;
cursor: grab;
user-select: none;
scrollbar-width: thin;
}
.visual-line-container svg {
min-width: 100%;
flex-shrink: 0;
}
.visual-line-container.is-panning {
cursor: grabbing;
}
.station-node {
@@ -2578,6 +2590,39 @@ body.jr-public-page {
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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2681,6 +2726,13 @@ body.jr-public-page {
align-items: stretch;
}
.jr-search-helper {
margin: 12px 0 0;
color: #66756a;
line-height: 1.7;
font-size: 0.92rem;
}
.jr-search-input,
body.jr-public-page .jr-search-input {
width: 100%;
@@ -2740,16 +2792,22 @@ body.jr-public-page .jr-search-button:hover {
}
.jr-ticket-row {
padding: 18px 0;
padding: 18px 14px;
border-bottom: 1px solid #e4ece2;
border-left: 4px solid transparent;
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 {
background: #f7faf7;
}
.jr-ticket-row.is-active {
background: linear-gradient(180deg, #f4f8f4 0, #ffffff 100%);
border-left-color: #0b6b3a;
}
.jr-ticket-row:last-child {
border-bottom: none;
}
@@ -2767,6 +2825,13 @@ body.jr-public-page .jr-search-button:hover {
line-height: 1.7;
}
.jr-list-meta {
margin-top: 8px;
color: #728077;
font-size: 0.88rem;
line-height: 1.6;
}
.jr-ticket-id {
color: #1b3022;
font-weight: 800;
@@ -2908,6 +2973,12 @@ body.jr-public-page .jr-search-button:hover {
gap: 10px;
}
.jr-detail-stack {
display: flex;
flex-direction: column;
gap: 20px;
}
.jr-popular-item {
display: flex;
align-items: center;
@@ -3161,6 +3232,35 @@ body.jr-public-page .jr-secondary-btn:hover {
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 #app,
body.jr-ticket-board-page .jr-public-shell {
@@ -3381,6 +3481,12 @@ body.jr-ticket-board-page .jr-board-card:last-child {
align-items: start;
}
.ic-admin-layout .management-sidebar,
.ic-admin-layout .management-main,
.jr-admin-list-card {
min-height: 0;
}
.ic-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3566,6 +3672,37 @@ body.jr-ticket-board-page .jr-board-card:last-child {
min-width: 0;
}
.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 {
grid-template-columns: 1fr;
}
@@ -3592,6 +3729,11 @@ body.jr-ticket-board-page .jr-board-card:last-child {
letter-spacing: 0.08em;
}
.jr-action-row .btn,
.jr-action-row button {
width: 100%;
}
.jr-home-alert {
flex-direction: column;
align-items: flex-start;
@@ -3727,6 +3869,31 @@ body.jr-admin-login-page {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.jr-admin-sync-meta {
min-width: 118px;
padding: 10px 12px;
border: 1px solid #d8e2d4;
background: #f8fbf7;
color: #385446;
}
.jr-admin-sync-meta strong {
display: block;
color: #143423;
font-size: 0.94rem;
}
.jr-admin-sync-label {
display: block;
margin-bottom: 4px;
color: #6a7d72;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
.jr-admin-header-pill {
@@ -3762,6 +3929,149 @@ body.jr-admin-login-page {
margin-bottom: 20px;
}
.jr-admin-overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.jr-admin-overview-card {
padding: 20px;
border: 1px solid #d7e0d3;
background: #ffffff;
box-shadow: 0 10px 24px rgba(18, 50, 33, 0.04);
}
.jr-admin-overview-card.is-actions {
background: linear-gradient(180deg, #f8fbf7 0, #ffffff 100%);
}
.jr-admin-overview-label {
display: inline-block;
margin-bottom: 8px;
color: #0b6b3a;
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.14em;
}
.jr-admin-overview-value {
display: block;
color: #143423;
font-size: 1.35rem;
line-height: 1.3;
}
.jr-admin-overview-note {
margin: 10px 0 0;
color: #627368;
line-height: 1.7;
font-size: 0.92rem;
}
.jr-admin-overview-actions,
.jr-admin-card-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.jr-admin-overview-actions {
margin-top: 14px;
}
.jr-admin-section-toolbar {
margin-bottom: 18px;
padding: 20px 22px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border: 1px solid #d7e0d3;
background: linear-gradient(135deg, rgba(11, 107, 58, 0.05) 0, rgba(11, 107, 58, 0.015) 28%, #ffffff 28%, #ffffff 100%);
}
.jr-admin-section-toolbar-copy {
min-width: 0;
}
.jr-admin-section-toolbar-copy strong {
display: block;
color: #143423;
font-size: 1.1rem;
}
.jr-admin-section-toolbar-copy p {
margin: 8px 0 0;
color: #627368;
line-height: 1.7;
}
.jr-admin-card-note,
.jr-admin-list-meta {
color: #6a7c70;
font-size: 0.88rem;
line-height: 1.7;
}
.jr-admin-note-list {
display: grid;
gap: 10px;
margin-top: 14px;
color: #3c594a;
font-size: 0.92rem;
line-height: 1.65;
}
.jr-admin-list-card .jr-scroll-box {
padding-right: 4px;
min-height: 320px;
max-height: 560px;
overflow-y: auto;
overscroll-behavior: contain;
}
.jr-admin-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.jr-admin-summary-item {
padding: 14px 16px;
border: 1px solid #dbe5d8;
background: #f8fbf7;
}
.jr-admin-summary-item span,
.jr-admin-summary-item small {
display: block;
color: #687a70;
}
.jr-admin-summary-item span {
margin-bottom: 6px;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
}
.jr-admin-summary-item strong {
display: block;
color: #143423;
font-size: 1.18rem;
line-height: 1.3;
word-break: break-word;
}
.jr-admin-summary-item small {
margin-top: 8px;
font-size: 0.84rem;
line-height: 1.6;
}
.jr-admin-page .card {
background: #ffffff;
border: 1px solid #d7e0d3;
@@ -3962,6 +4272,16 @@ body.jr-admin-login-page {
.jr-admin-login-panel {
grid-template-columns: 1fr;
}
.jr-admin-overview-grid,
.jr-admin-summary-grid {
grid-template-columns: 1fr 1fr;
}
.jr-admin-section-toolbar {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 768px) {
@@ -3980,6 +4300,38 @@ body.jr-admin-login-page {
padding-top: 20px;
}
.jr-admin-login-copy,
.jr-admin-login-card {
padding: 22px;
border-radius: 20px;
}
.jr-admin-header-side,
.jr-admin-overview-actions,
.jr-admin-card-actions {
width: 100%;
}
.jr-admin-sync-meta,
.jr-admin-overview-grid,
.jr-admin-summary-grid {
width: 100%;
}
.jr-admin-overview-grid,
.jr-admin-summary-grid {
grid-template-columns: 1fr;
}
.jr-admin-overview-card,
.jr-admin-section-toolbar {
padding: 18px;
}
.jr-admin-header-side .btn {
width: 100%;
}
}
/* --- Custom Dialog --- */
@@ -4102,10 +4454,3 @@ body.jr-admin-login-page {
width: 100%;
}
}
.jr-admin-login-copy,
.jr-admin-login-card {
padding: 22px;
border-radius: 20px;
}
}
+103 -85
View File
@@ -4,10 +4,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FSE閾佽矾鐢靛瓙瀹㈢エ</title>
<title>FSE铁路电子客票</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
<link rel="stylesheet" href="/style.css?v=13">
<style>
[v-cloak] {
display: none;
@@ -49,12 +49,12 @@
<div class="jr-topbar-inner">
<a href="javascript:void(0)" @click="goHome" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>杩斿洖鏌ヨ</span>
<span>返回查询</span>
</a>
<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-value" data-server-status-value>妫€娴嬩腑</span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
@@ -63,28 +63,28 @@
<a href="javascript:void(0)" @click="goHome" class="jr-brand">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE閾佽矾绁ㄥ姟绯荤粺</strong>
<span>鐢靛瓙瀹㈢エ淇℃伅</span>
<strong>FarSight-T.N.E铁路运输</strong>
<span>电子客票信息</span>
</div>
</a>
<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/order" data-link="order">绾夸笂棰勫畾</a>
<a href="https://ticket.fse-media.group/search" data-link="search" class="is-active">杞︾エ鏌ヨ</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 鍗℃煡璇?/a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">绾夸笂璐崱</a>
<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/order" data-link="order">线上预定</a>
<a href="https://ticket.fse-media.group/search" data-link="search" class="is-active">车票查询</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
</nav>
</div>
</div>
<main class="jr-public-main">
<section class="jr-page-intro">
<span class="jr-kicker">ELECTRONIC TICKET</span>
<h1>鏌ョ湅杞︾エ鐘舵€佷笌鏈€杩戞祦杞褰?/h1>
<p>鐢ㄤ簬鏌ョ湅鍗曞紶鐢靛瓙瀹㈢エ鐨勪箻杞︿俊鎭€佺姸鎬佷笌杩涘嚭绔欒褰曪紝渚夸簬鏃呭鍜屽伐浣滀汉鍛樺揩閫熺‘璁ょエ鎹姸鎬併€?/p>
<h1>查看车票状态与最近流转记录</h1>
<p>用于查看单张电子客票的乘车信息、状态与进出站记录</p>
</section>
<div v-if="loading" class="jr-panel-card">
<div class="jr-center-empty">
<p>姝e湪璇诲彇绁ㄦ嵁鏁版嵁...</p>
<p>正在读取车票数据...</p>
</div>
</div>
<template v-if="!loading && hasTicket">
@@ -92,8 +92,9 @@
<article class="jr-board-card">
<div class="jr-panel-headline">
<h2 class="mono">{{ ticket.ticket_id }}</h2>
<span class="jr-status-pill" :class="statusInfo.class.replace('status-', 'jr-status-')">{{
statusInfo.text }}</span>
<span class="jr-status-pill" :class="statusInfo.class.replace('status-', 'jr-status-')">
{{ statusInfo.text }}
</span>
</div>
<div class="jr-route-board">
<div class="jr-station-block">
@@ -114,46 +115,44 @@
</div>
<div class="jr-meta-grid">
<div class="jr-meta-item">
<span>杞﹀瀷</span>
<span>车型</span>
<strong>{{ formatTrainType(ticket.overview.train_type) }}</strong>
</div>
<div class="jr-meta-item">
<span>绁ㄤ环</span>
<strong> {{ ticket.overview.amount || 0 }}</strong>
<span>票价</span>
<strong>¥ {{ ticket.overview.amount || 0 }}</strong>
</div>
<div class="jr-meta-item">
<span>涔樻</span>
<strong>{{ (ticket.overview.trips_remaining == null ? 1 :
ticket.overview.trips_remaining) }} / {{ (ticket.overview.trips_total == null ? 1 :
ticket.overview.trips_total) }}</strong>
<span>乘次</span>
<strong>{{ (ticket.overview.trips_remaining == null ? 1 : ticket.overview.trips_remaining) }} / {{ (ticket.overview.trips_total == null ? 1 : ticket.overview.trips_total) }}</strong>
</div>
<div class="jr-meta-item">
<span>鏇存柊鏃堕棿</span>
<span>更新时间</span>
<strong>{{ formatTime(ticket.overview.last_update_ts) }}</strong>
</div>
</div>
</article>
<aside class="jr-board-card">
<div class="jr-panel-headline">
<h3>娴佽浆璁板綍</h3>
<h3>流转记录</h3>
<span class="jr-panel-note">Recent Events</span>
</div>
<div class="jr-history-list" v-if="ticket.events && ticket.events.length > 0">
<div v-for="ev in ticket.events.slice().reverse().slice(0, 5)"
:key="ev.ts || ev.鏃堕棿鎴? class="jr-history-item">
:key="ev.ts || ev['时间戳'] || Math.random()"
class="jr-history-item">
<div class="jr-history-row">
<span class="jr-history-title">{{ formatEvent(ev) }}</span>
<span class="jr-history-time">{{ formatTime(ev.鏃堕棿鎴?|| ev.ts) }}</span>
<span class="jr-history-time">{{ formatTime(ev['时间戳'] || ev.ts) }}</span>
</div>
<div class="jr-history-desc">
<div>{{ formatEventLocation(ev) }}</div>
<div v-if="formatEventMeta(ev)" style="margin-top:4px;">{{ formatEventMeta(ev) }}
</div>
<div v-if="formatEventMeta(ev)" style="margin-top:4px;">{{ formatEventMeta(ev) }}</div>
</div>
</div>
</div>
<div v-else class="jr-center-empty">
<p>鏆傛棤娴佽浆璁板綍銆?/p>
<p>暂无流转记录。</p>
</div>
</aside>
</section>
@@ -161,23 +160,23 @@
<div v-if="!loading && !hasTicket" class="jr-panel-card">
<div class="jr-center-empty">
<h2 style="margin:0 0 10px;">鏃犳晥杞︾エ</h2>
<p>鏈壘鍒拌杞︾エ鐨勮缁嗕俊鎭€?/p>
<h2 style="margin:0 0 10px;">无效车票</h2>
<p>未找到该车票的详细信息。</p>
<div class="jr-action-row">
<button @click="goHome" class="btn primary jr-search-button">杩斿洖鏌ヨ</button>
<button @click="goHome" class="btn primary jr-search-button">返回查询</button>
</div>
</div>
</div>
<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>
</footer>
</main>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>
@@ -218,60 +217,74 @@
const statusInfo = computed(() => {
if (!hasTicket.value) return {};
let raw = '';
if (ticket.value && ticket.value.overview) {
if (ticket.value.overview.status != null) raw = ticket.value.overview.status;
if (ticket.value && ticket.value.overview && ticket.value.overview.status != null) {
raw = ticket.value.overview.status;
}
if (!raw && ticket.value) {
if (ticket.value.status != null) raw = ticket.value.status;
if (!raw && ticket.value && ticket.value.status != null) {
raw = ticket.value.status;
}
const status = String(raw).toLowerCase();
if (
status === '鏈夋晥' ||
status === '有效' ||
status === 'valid' ||
status === 'unused' ||
status === 'active' ||
status.includes('鏈夋晥') ||
status.includes('鏈娇鐢?) ||
status.includes('有效') ||
status.includes('未使用') ||
status.includes('unused')
) {
return { text: '鏈夋晥', class: 'status-valid' };
return { text: '有效', class: 'status-valid' };
}
if (status === '宸蹭娇鐢? || status === 'used' || status.includes('宸蹭娇鐢?) || status.includes('used')) {
return { text: '宸蹭娇鐢?, class: 'status-used' };
if (
status === '已使用' ||
status === 'used' ||
status.includes('已使用') ||
status.includes('used')
) {
return { text: '已使用', class: 'status-used' };
}
return { text: '澶辨晥', class: 'status-expired' };
return { text: '失效', class: 'status-expired' };
});
const formatTime = (timestamp) => {
if (!timestamp) return '---';
let ts = Number(timestamp);
if (!Number.isFinite(ts)) return String(timestamp);
if (ts > 0 && ts < 1000000000000) ts = ts * 1000;
if (ts > 0 && ts < 1000000000000) ts *= 1000;
const date = new Date(ts);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit'});
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
const formatEvent = (event) => {
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
const action = String(event.action || event.鍔ㄤ綔 || '').toLowerCase();
const type = String(event.type || event['类型'] || '').toLowerCase();
const action = String(event.action || event['动作'] || '').toLowerCase();
if (type === '鐘舵€? || type === 'status') {
const actionMap = { 'entry': '杩涚珯鎴愬姛', 'exit': '鍑虹珯鎴愬姛' };
return actionMap[action] || '鐘舵佸彉鏇?;
if (type === '状态' || type === 'status') {
const actionMap = { entry: '进站成功', exit: '出站成功' };
return actionMap[action] || '状态变更';
}
const typeMap = { 'sale': '鍞エ鎴愬姛', '鍞エ': '鍞エ鎴愬姛', 'entry': '杩涚珯鎴愬姛', 'exit': '鍑虹珯鎴愬姛' };
return typeMap[type] || event.type || event.绫诲瀷 || '鐘舵€佸彉鏇?;
const typeMap = {
sale: '售票成功',
售票: '售票成功',
entry: '进站成功',
exit: '出站成功'
};
return typeMap[type] || event.type || event['类型'] || '状态变更';
};
const formatEventLocation = (event) => {
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
const stationName = event.station_name || event.鍞エ绔?|| event.鍙戠敓绔?|| '';
const stationCode = event.station_code || event.绔欑偣缂栧彿 || '';
const type = String(event.type || event['类型'] || '').toLowerCase();
const stationName = event.station_name || event['售票站'] || event['发生站'] || '';
const stationCode = event.station_code || event['站点编号'] || '';
if (type === 'sale' || type === '') {
return stationName || '绾夸笂鍞';
if (type === 'sale' || type === '售票') {
return stationName || '线上售票';
}
if (!stationName && !stationCode) return '---';
@@ -279,25 +292,25 @@
};
const formatEventMeta = (event) => {
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
if (type === 'sale' || type === '') {
const amount = event.amount ?? event.鍞エ棰?
if (amount != null && amount !== '') return `绁ㄤ环锛毬?${amount}`;
const type = String(event.type || event['类型'] || '').toLowerCase();
if (type === 'sale' || type === '售票') {
const amount = event.amount ?? event['售票额'];
if (amount != null && amount !== '') return `票价:¥ ${amount}`;
}
const stationEn = event.station_en || event.绔欑偣鑻辨枃 || '';
const deviceId = event.device_id || event.璁惧缂栧彿 || '';
const stationEn = event.station_en || event['站点英文'] || '';
const deviceId = event.device_id || event['设备编号'] || '';
if (stationEn && deviceId) return `${stationEn} (${deviceId})`;
if (deviceId) return `璁惧锛?{deviceId}`;
if (deviceId) return `设备:${deviceId}`;
return stationEn;
};
const formatTrainType = (type) => {
if (!type) return '?;
const t = type.toLowerCase();
if (t === 'local') return '鏅€?;
if (t === 'ltd.exp' || t === 'express' || t === 'exp' || t === 'express_train') return '鐗规?;
if (t.includes('鐗规€?)) return '鐗规?;
if (!type) return '普通';
const t = String(type).toLowerCase();
if (t === 'local') return '普通';
if (t === 'ltd.exp' || t === 'express' || t === 'exp' || t === 'express_train') return '特急';
if (t.includes('特急')) return '特急';
return String(type);
};
@@ -311,22 +324,22 @@
ticket.value = null;
} else {
const data = await response.json();
const id = (data && (data.ticket_id || data.エ缂栧彿 || data.id)) || ticketid;
const id = (data && (data.ticket_id || data['车票编号'] || data.id)) || ticketid;
let overview = null;
if (data) {
if (data.overview != null) overview = data.overview;
else if (data.姒傝 != null) overview = data.姒傝;
else if (data['概览'] != null) overview = data['概览'];
else if (data.summary != null) overview = data.summary;
}
let events = [];
if (data) {
if (Array.isArray(data.events)) events = data.events;
else if (data.浜嬩欢 != null) events = data.浜嬩欢;
else if (data['事件'] != null) events = data['事件'];
}
if (id && overview != null) {
const out = {};
if (data && typeof data === 'object') {
for (const k in data) out[k] = data[k];
for (const key in data) out[key] = data[key];
}
out.ticket_id = id;
out.overview = overview;
@@ -337,7 +350,7 @@
}
}
} catch (e) {
console.error('鑾峰彇杞︾エ鏁版嵁澶辫触:', e);
console.error('获取车票数据失败:', e);
ticket.value = null;
} finally {
loading.value = false;
@@ -357,15 +370,20 @@
});
return {
loading, ticket, hasTicket, statusInfo,
formatTime, formatEvent, formatEventLocation, formatEventMeta, formatTrainType, goHome
loading,
ticket,
hasTicket,
statusInfo,
formatTime,
formatEvent,
formatEventLocation,
formatEventMeta,
formatTrainType,
goHome
};
}
}).mount('#app');
</script>
</body>
</html>
+5 -4
View File
@@ -7,7 +7,7 @@
<title>线上预定</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=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="public-search jr-order-page">
@@ -30,7 +30,7 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE铁路票务系统</strong>
<strong>FarSight-T.N.E铁路运输</strong>
<span>线上预定</span>
</div>
</a>
@@ -232,8 +232,8 @@
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-order.js?v=20"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ticket-order.js?v=21"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>
@@ -261,3 +261,4 @@
</body>
</html>
+54
View File
@@ -408,6 +408,58 @@
return merged;
}
function getStationPoint(code) {
const normalized = String(code || '').trim();
if (!normalized) return null;
const canonical = stationCanonicalByCode[normalized] || normalized;
const x = stationXByCanonical[canonical];
const y = stationYByCanonical[normalized];
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
}
function renderRouteOverlay() {
const svgEl = mapContainer.querySelector('svg');
if (!svgEl) return;
const existing = svgEl.querySelector('.route-overlay-group');
if (existing) existing.remove();
if (!Array.isArray(currentRoute) || currentRoute.length < 2) return;
const points = currentRoute.map(getStationPoint).filter(Boolean);
if (points.length < 2) return;
const ns = 'http://www.w3.org/2000/svg';
const group = document.createElementNS(ns, 'g');
group.setAttribute('class', 'route-overlay-group');
const pathData = points.map((pt, idx) => `${idx === 0 ? 'M' : 'L'} ${pt.x} ${pt.y}`).join(' ');
const glow = document.createElementNS(ns, 'path');
glow.setAttribute('d', pathData);
glow.setAttribute('fill', 'none');
glow.setAttribute('stroke', 'rgba(250, 204, 21, 0.38)');
glow.setAttribute('stroke-width', '18');
glow.setAttribute('stroke-linecap', 'round');
glow.setAttribute('stroke-linejoin', 'round');
const main = document.createElementNS(ns, 'path');
main.setAttribute('d', pathData);
main.setAttribute('fill', 'none');
main.setAttribute('stroke', '#facc15');
main.setAttribute('stroke-width', '8');
main.setAttribute('stroke-linecap', 'round');
main.setAttribute('stroke-linejoin', 'round');
group.appendChild(glow);
group.appendChild(main);
const firstStation = svgEl.querySelector('.map-station');
if (firstStation) svgEl.insertBefore(group, firstStation);
else svgEl.appendChild(group);
}
function updateSelectionUI(skipPreview = false) {
if (!(selection[0] && selection[1])) {
currentRoute = [];
@@ -447,6 +499,8 @@
}
}
renderRouteOverlay();
// Auto preview if both selected
if(!skipPreview && selection[0] && selection[1]) previewPrice();
}
+6 -5
View File
@@ -8,8 +8,8 @@
<title>FSE铁路票务系统 - 线路规划</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=12">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<link rel="stylesheet" href="/style.css?v=13">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
@@ -34,7 +34,7 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="routeBrandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE 铁路运输</strong>
<strong>FarSight-T.N.E铁路运输</strong>
<span>线路规划后台</span>
</div>
</a>
@@ -607,9 +607,9 @@
</div>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="ticket-route.js?v=2"></script>
<script src="ticket-route.js?v=3"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
@@ -641,3 +641,4 @@
+2 -1
View File
@@ -24,7 +24,8 @@ createApp({
});
const connected = ref(false);
const socket = io({ transports: ['websocket'], upgrade: false, timeout: 20000 });
// Keep the legacy route console usable behind proxies that only allow polling.
const socket = io({ transports: ['polling', 'websocket'], timeout: 20000 });
const stations = ref([]);
+25 -6
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
@@ -7,7 +7,7 @@
<title>票务查询</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=12" />
<link rel="stylesheet" href="/style.css?v=14" />
</head>
<body class="public-search jr-public-page">
@@ -31,7 +31,7 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE铁路售票系统</strong>
<strong>FarSight-T.N.E铁路运输</strong>
<span>票务查询</span>
</div>
</a>
@@ -52,6 +52,24 @@
<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 class="jr-panel-card" style="margin-bottom:24px;">
<div class="jr-panel-headline">
<h2>检索条件</h2>
@@ -63,6 +81,7 @@
<button id="searchBtn" class="btn primary jr-search-button"><i class="fas fa-search"></i>
立即搜索</button>
</div>
<p class="jr-search-helper">可直接输入完整票号,也可输入起点、终点或日期关键字进行模糊检索。</p>
</section>
<section class="jr-search-results">
@@ -78,7 +97,7 @@
</div>
</article>
<section id="detail-section">
<section id="detail-section" class="jr-detail-stack">
<article class="jr-panel-card" style="margin-bottom:20px;">
<div class="jr-panel-headline">
<h3>车票详情</h3>
@@ -117,8 +136,8 @@
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-search.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ticket-search.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>
+33 -5
View File
@@ -4,6 +4,10 @@
const detailEl = $('#detail');
const qEl = $('#q');
const btn = $('#searchBtn');
const state = {
items: [],
selectedId: ''
};
const api = {
searchTickets: async (q) => {
@@ -52,6 +56,13 @@
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 isValidStatus = (status) => {
@@ -111,6 +122,7 @@
};
function renderList(items) {
state.items = items;
listEl.innerHTML = '';
if (items.length === 0) {
listEl.innerHTML = '<div class="jr-center-empty"><p>未找到匹配结果。</p></div>';
@@ -119,22 +131,35 @@
items.forEach(it => {
const id = it.ticket_id || it["车票编号"] || '';
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 startName = overview ? (overview.start_name || overview["起点"]) : (it.start_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 = `
<div class="jr-ticket-row-head">
<span class="jr-ticket-id mono">${id}</span>
<i class="fas fa-chevron-right text-muted"></i>
<span class="jr-ticket-id mono">${escapeHtml(id)}</span>
<span class="jr-status-pill ${isValidStatus(statusText) ? 'jr-status-valid' : (statusText === '已使用' ? 'jr-status-used' : 'jr-status-expired')}">${escapeHtml(statusText)}</span>
</div>
<div class="jr-ticket-route">
${startName} ${terminalName}
${escapeHtml(startName)} ${escapeHtml(terminalName)}
</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);
});
}
@@ -208,6 +233,8 @@
}
async function loadDetail(id) {
state.selectedId = id;
renderList(state.items);
detailEl.innerHTML = '<div class="jr-center-empty"><p>正在加载详情...</p></div>';
try {
const d = await api.ticketDetail(id);
@@ -229,6 +256,7 @@
listEl.innerHTML = '<div class="jr-center-empty"><p>正在搜索...</p></div>';
try {
const d = await api.searchTickets(q);
state.selectedId = state.selectedId && d.some((item) => getTicketId(item) === state.selectedId) ? state.selectedId : '';
renderList(d);
} catch (e) {
listEl.innerHTML = '<div class="jr-center-empty"><p>搜索失败。</p></div>';
+3 -2
View File
@@ -7,7 +7,7 @@
<title>凭证详情</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=12" />
<link rel="stylesheet" href="/style.css?v=13" />
</head>
<body class="public-search jr-public-page">
@@ -125,7 +125,7 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/token.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
@@ -137,3 +137,4 @@
</body>
</html>