Compare commits
46
Commits
d32d72a3da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
def7afd5ce | ||
|
|
c52d4dac03 | ||
|
|
2748aa91ca | ||
|
|
228077a49c | ||
|
|
e18f732b38 | ||
|
|
e76d0188db | ||
|
|
ca77d762c9 | ||
|
|
41546cce96 | ||
|
|
3d38e16917 | ||
|
|
3d7fa994ae | ||
|
|
cf0a49867c | ||
|
|
fa51aad3a7 | ||
|
|
9cc3aba5e9 | ||
|
|
cdf2c39220 | ||
|
|
7e0a114635 | ||
|
|
0e3f1a6e86 | ||
|
|
0a37c0cba2 | ||
|
|
6605c67f23 | ||
|
|
1046aa8dda | ||
|
|
b7cdcc570a | ||
|
|
a59e9e60a4 | ||
|
|
d685dee2a4 | ||
|
|
9c7e99633b | ||
|
|
879445b9f6 | ||
|
|
200dbda9bd | ||
|
|
7e72bbeae5 | ||
|
|
0e8f86b478 | ||
|
|
4db60ddc9e | ||
|
|
c840e2abfe | ||
|
|
be0077743c | ||
|
|
4ddc4cb4ad | ||
|
|
04d0badce8 | ||
|
|
847957be61 | ||
|
|
6195ebb6be | ||
|
|
ec31c12130 | ||
|
|
289ed65e79 | ||
|
|
95bf4d3d8e | ||
|
|
c6ab010974 | ||
|
|
234e22468a | ||
|
|
77b487c326 | ||
|
|
1f5bc1d40d | ||
|
|
2096dd8d89 | ||
|
|
5e8d4f7bbe | ||
|
|
9d145c3d16 | ||
|
|
cfaa622dcd | ||
|
|
7812d47f0c |
BIN
Binary file not shown.
@@ -0,0 +1,296 @@
|
||||
# FSE Ticketing CC API
|
||||
|
||||
## 通用说明
|
||||
|
||||
- 外设类型通过 `peripheral.getType(name)` 返回,或在 `peripheral.wrap(name)` 后通过 `peripheral.call`/方法调用使用。
|
||||
- 返回值约定:
|
||||
- 成功通常返回 `true`(部分方法额外返回数据)。
|
||||
- 失败通常返回 `false, "error_message"` 或 `nil, "error_message"`(取决于具体方法)。
|
||||
- 票/卡信息表(Lua table)中的字段 key 与模组 NBT key 一致,详见“数据字段”章节。
|
||||
|
||||
## 外设:ticket\_vending\_machine(售票机)
|
||||
|
||||
### 方法:issueTicket
|
||||
|
||||
发放一张票并从售票机正面吐出(生成掉落物)。
|
||||
|
||||
```lua
|
||||
local ok, ticketId = vending.issueTicket(
|
||||
startNameEn,
|
||||
terminalNameEn,
|
||||
type,
|
||||
rides,
|
||||
cost,
|
||||
startStation,
|
||||
terminalStation,
|
||||
fromNameCnU,
|
||||
toNameCnU
|
||||
)
|
||||
```
|
||||
|
||||
- 参数(均为可选,未提供时使用默认值)
|
||||
- `startNameEn`:起点(英文/标识),默认 `"???"`
|
||||
- `terminalNameEn`:终点(英文/标识),默认 `"???"`
|
||||
- `type`:票种,默认 `"local"`
|
||||
- `"local"`:普通票(Local Ticket)
|
||||
- `"limited_express"`:特急票(Exp Ticket)
|
||||
- `"single"`:单程票(Single-trip Ticket)
|
||||
- `rides`:可乘坐次数,默认 `1`,最小会被钳制为 `1`
|
||||
- `cost`:金额,默认 `0`
|
||||
- `startStation`:起点站 ID/代码,默认 `""`
|
||||
- `terminalStation`:终点站 ID/代码,默认 `""`
|
||||
- `fromNameCnU`:中文起点名(可能包含 `\u` 转义),默认 `""`
|
||||
- `toNameCnU`:中文终点名(可能包含 `\u` 转义),默认 `""`
|
||||
- 返回
|
||||
- `ok`:`true`
|
||||
- `ticketId`:生成的票 ID(形如 `AB-12345678`)
|
||||
- 方块状态 / 模型切换(对应票种)
|
||||
- 当 `type` 为 `"local"` 时:成功调用后,方块模型暂时切换为 `ticket_vending_machine_success_localticket`(露出绿票 / 本地票图案)。
|
||||
- 当 `type` 为 `"limited_express"` 或 `"single"` 时:暂时切换为 `ticket_vending_machine_success_expticket`(露出橙红特急票 / 单程票图案)。
|
||||
- 两种状态持续约 20 tick(1 秒)后自动恢复基础款模型 `ticket_vending_machine`。
|
||||
|
||||
### 方法:issueICCard
|
||||
|
||||
发放一张 IC 卡并从售票机正面吐出(生成掉落物)。
|
||||
|
||||
```lua
|
||||
local ok, cardId = vending.issueICCard(ownerName, balance)
|
||||
```
|
||||
|
||||
- 参数(可选)
|
||||
- `ownerName`:持有人名字,默认 `""`
|
||||
- `balance`:初始余额,默认 `0`
|
||||
- 返回
|
||||
- `ok`:`true`
|
||||
- `cardId`:生成的卡号(形如 `IC-xxxxxxxx`)
|
||||
- 方块状态 / 模型切换
|
||||
- 方法成功返回时,方块模型暂时切换到 `ticket_vending_machine_success_ic`(正面露出 IC 卡图案),20 tick(1 秒)后自动恢复基础款模型。
|
||||
|
||||
### 方法:issueFSEPass
|
||||
|
||||
```lua
|
||||
local ok, ticketId = vending.issueFSEPass(ownerName, startNameEn, terminalNameEn, cost)
|
||||
```
|
||||
|
||||
发放一张 FSE Pass 通票(紫色 FSE_PASS 物品)并从售票机正面吐出。参数从左到右依次为:
|
||||
|
||||
- 参数(均为可选)
|
||||
- `ownerName`:持有人名字,默认 `""`
|
||||
- `startNameEn`:起点(英文/标识),默认 `"???"`
|
||||
- `terminalNameEn`:终点(英文/标识),默认 `"???"`
|
||||
- `cost`:金额,默认 `0`
|
||||
- 返回
|
||||
- `ok`:`true`
|
||||
- `ticketId`:生成的通票 ID(同样形如 `AB-12345678`)
|
||||
- 方块状态 / 模型切换
|
||||
- 方法成功返回时,方块模型暂时切换为 `ticket_vending_machine_success_expticket`(与特急票款外观),20 tick 后恢复基础款模型。
|
||||
|
||||
## 外设:ticket\_inspection\_machine(检票机)
|
||||
|
||||
检票机通过玩家右键扫描票/卡,保存“最近一次扫描”的数据,并向连接的 CC 电脑推送事件。
|
||||
|
||||
### 方法:getLastScanned
|
||||
|
||||
获取最近一次扫描到的票/卡信息表。
|
||||
|
||||
```lua
|
||||
local infoOrNil, err = gate.getLastScanned()
|
||||
```
|
||||
|
||||
- 返回(成功)
|
||||
- `infoOrNil`:Lua table(票信息或 IC 信息)
|
||||
- 返回(失败)
|
||||
- `nil, "no ticket scanned"`
|
||||
|
||||
### 方法:destroyTicket
|
||||
|
||||
销毁最近一次扫描的物品(直接清空玩家手上那一张票/卡)。
|
||||
|
||||
```lua
|
||||
local ok, err = gate.destroyTicket()
|
||||
```
|
||||
|
||||
- 返回
|
||||
- `true`:已投递到服务器主线程执行
|
||||
- `false, "not server level"`:当前世界不是服务端世界
|
||||
|
||||
注意:该方法当前实现为“投递执行”,不会把实际销毁结果回传给 Lua;建议通过事件与 `getLastScanned()` 侧面确认状态。
|
||||
|
||||
- 方块状态
|
||||
- 成功执行:方块模型切换到 `ticket_inspection_machine_success`(绿灯通过状态)
|
||||
- 执行失败(如找不到扫描玩家):切换到 `ticket_inspection_machine_false`(红灯拒绝状态)
|
||||
- 两种状态均持续约 40 tick(2 秒)后自动恢复 `NONE` 基础模型。
|
||||
|
||||
### 方法:deductICCard
|
||||
|
||||
对最近一次扫描到的 IC 卡扣费。
|
||||
|
||||
```lua
|
||||
local ok, err = gate.deductICCard(amount)
|
||||
```
|
||||
|
||||
- 参数
|
||||
- `amount`:扣费金额(double)
|
||||
- 返回
|
||||
- `true`:已投递到服务器主线程执行
|
||||
- `false, "not server level"`:当前世界不是服务端世界
|
||||
|
||||
注意:当前实现同样不会把扣费后的余额回传给 Lua;如果需要余额,请调用 `getLastScanned()` 或使用充值机外设完成扣费/查询。
|
||||
|
||||
- 方块状态
|
||||
- 成功扣费:`_success`(绿灯)40 tick
|
||||
- 失败(非 IC 卡 / 找不到玩家 / 卡片不匹配 / 余额不足):`_false`(红灯)40 tick
|
||||
|
||||
### 方法:markEntered
|
||||
|
||||
将最近一次扫描的票/卡标记为已进站,并(可选)写入进站站点。
|
||||
|
||||
```lua
|
||||
local ok, err = gate.markEntered(entryStationId)
|
||||
```
|
||||
|
||||
- 参数(可选)
|
||||
- `entryStationId`:站点 ID/代码,默认 `""`(为空则不写入)
|
||||
- 返回
|
||||
- `true`:已投递到服务器主线程执行
|
||||
- `false, "not server level"`
|
||||
|
||||
执行后会推送 `ticket_state_updated` 或 `ic_card_state_updated` 事件(取决于最近扫描的是票还是卡)。
|
||||
|
||||
- 方块状态
|
||||
- 成功写入:`_success`(绿灯)40 tick
|
||||
- 失败(玩家/票卡无效):`_false`(红灯)40 tick
|
||||
|
||||
### 方法:markExited
|
||||
|
||||
将最近一次扫描的票/卡标记为已出站,并清理进站站点字段。
|
||||
|
||||
```lua
|
||||
local ok, err = gate.markExited()
|
||||
```
|
||||
|
||||
- 返回
|
||||
- `true`:已投递到服务器主线程执行
|
||||
- `false, "not server level"`
|
||||
- 方块状态
|
||||
- 成功写入:`_success`(绿灯)40 tick
|
||||
- 失败(玩家/票卡无效):`_false`(红灯)40 tick
|
||||
|
||||
### 方法:resetTicketState
|
||||
|
||||
将最近一次扫描的票/卡状态复位(entered/exited 均为 false,并清理进站站点字段)。
|
||||
|
||||
```lua
|
||||
local ok, err = gate.resetTicketState()
|
||||
```
|
||||
|
||||
- 返回
|
||||
- `true`:已投递到服务器主线程执行
|
||||
- `false, "not server level"`
|
||||
- 方块状态
|
||||
- 成功复位:`_success`(绿灯)40 tick
|
||||
- 失败(玩家/票卡无效):`_false`(红灯)40 tick
|
||||
|
||||
### 方法:markFault
|
||||
|
||||
CC 侧业务判断失败时,主动触发红灯拒绝状态(例如业务逻辑拒绝进站/出站,但 CC 侧又不想调用 resetTicketState 时)。
|
||||
|
||||
```lua
|
||||
local ok, err = gate.markFault()
|
||||
```
|
||||
|
||||
- 返回
|
||||
- `true`:已投递到服务器主线程执行(红灯亮起)
|
||||
- `false, "not server level"`:当前世界不是服务端世界
|
||||
- 方块状态
|
||||
- 调用后立即切换到 `ticket_inspection_machine_false`(红灯拒绝状态),持续 40 tick(2 秒)后自动恢复 NONE 基础款。
|
||||
- 与 `markEntered`/`markExited` 等方法内部业务错误触发的红灯效果完全相同。
|
||||
|
||||
### 事件
|
||||
|
||||
检票机对所有已连接电脑(`attach` 过的电脑)推送以下事件。事件参数为 1 个 table:
|
||||
|
||||
- `ticket_scanned(info)`
|
||||
- `ic_card_scanned(info)`
|
||||
- `ticket_state_updated(info)`
|
||||
- `ic_card_state_updated(info)`
|
||||
|
||||
`info` 结构见“数据字段”章节(包含 `passenger` 字段,值为扫描者玩家名)。
|
||||
|
||||
## 外设:ic\_refill\_machine(IC 充值机)
|
||||
|
||||
充值机要求玩家先把 IC 卡插入机器(右键把 IC 卡放进去)。只有在机器内有卡时,Lua 方法才可用。
|
||||
|
||||
- 方块状态 / 模型切换
|
||||
- 玩家右键把 IC 卡(手持 `limitcard` 物品)插入机器 → `HAS_CARD=true`,方块模型切换为 `ic_refill_machine_inserted`(卡片露出卡槽状态),同时 scheduleTick 100 tick(5 秒超时)。
|
||||
- 玩家右键空手拿走卡、或取出卡、或 5 秒超时(`tick()` 触发)→ `HAS_CARD=false`,模型切回基础款 `ic_refill_machine`。
|
||||
- 充值 / 扣费时(`refill`、`deduct`)只要卡还在,始终保持 `_inserted` 模型,不会额外切换。
|
||||
|
||||
### 方法:getCardInfo
|
||||
|
||||
```lua
|
||||
local infoOrNil, err = refill.getCardInfo()
|
||||
```
|
||||
|
||||
- 返回(成功)
|
||||
- `infoOrNil`:Lua table(IC 卡信息)
|
||||
- 返回(失败)
|
||||
- `nil, "no card"`:机器内没有插卡
|
||||
|
||||
### 方法:refill
|
||||
|
||||
```lua
|
||||
local okOrNil, r2 = refill.refill(amount)
|
||||
```
|
||||
|
||||
- 参数
|
||||
- `amount`:充值金额(double)
|
||||
- 返回(成功)
|
||||
- `true, newBalance`
|
||||
- 返回(失败)
|
||||
- `nil, "no card"`
|
||||
|
||||
### 方法:deduct
|
||||
|
||||
```lua
|
||||
local okOrNil, r2 = refill.deduct(amount)
|
||||
```
|
||||
|
||||
- 参数
|
||||
- `amount`:扣费金额(double)
|
||||
- 返回(成功)
|
||||
- `true, newBalance`
|
||||
- 返回(失败)
|
||||
- `nil, "no card"`
|
||||
- `false, "insufficient"`:余额不足(扣费后会变成负数时阻止)
|
||||
|
||||
## 数据字段(info table keys)
|
||||
|
||||
### 票信息(ticket\_\* / ticket\_state\_updated / getLastScanned 返回为票时)
|
||||
|
||||
- `start_name_en`:起点名(英文/标识)
|
||||
- `terminal_name_en`:终点名(英文/标识)
|
||||
- `start_station`:起点站 ID/代码
|
||||
- `terminal_station`:终点站 ID/代码
|
||||
- `fromNameCnU`:中文起点名(可能包含 `\u` 转义)
|
||||
- `toNameCnU`:中文终点名(可能包含 `\u` 转义)
|
||||
- `type`:票种(`local` / `limited_express` / `single`)
|
||||
- `rides`:剩余/可用次数
|
||||
- `entered`:是否已进站
|
||||
- `exited`:是否已出站
|
||||
- `ticketId`:票 ID
|
||||
- `timestamp`:签发时间戳(毫秒)
|
||||
- `cost`:金额
|
||||
- `order_datetime`:下单/签发时间字符串(`yyyy-MM-dd HH:mm:ss`)
|
||||
- `passenger`:扫描者玩家名(仅检票机事件与 getLastScanned 返回中提供)
|
||||
|
||||
注意:单程票的 `line_name` 当前不在 `info` table 中返回(仅保存在票的 NBT 内)。
|
||||
|
||||
### IC 信息(ic\_\* / ic\_card\_state\_updated / getLastScanned 返回为卡时)
|
||||
|
||||
- `cardId`:卡号
|
||||
- `ownerName`:持有人
|
||||
- `balance`:余额
|
||||
- `entered`:是否已进站
|
||||
- `entry_station`:进站站点 ID/代码(可能为空)
|
||||
- `passenger`:扫描者玩家名(仅检票机事件与 getLastScanned 返回中提供)
|
||||
@@ -1,8 +1,11 @@
|
||||
local DEFAULT_SERVER_BASE = "https://ticket.fse-media.group"
|
||||
local DEFAULT_SERVER_PATH = "/api/tickets/check"
|
||||
local GATE_OPEN_SECONDS = 2
|
||||
local VERSION = "v1.5.23"
|
||||
local VERSION_CHECK_INTERVAL = 5
|
||||
local VERSION = "v1.5.33"
|
||||
local VERSION_CHECK_INTERVAL = 15
|
||||
local PERIPHERAL_WAIT_INTERVAL = 0.2
|
||||
local PERIPHERAL_WAIT_MAX_RETRIES = 20
|
||||
|
||||
|
||||
local serverURL = nil
|
||||
|
||||
@@ -86,9 +89,97 @@ local function stationSetFromList(list)
|
||||
return set
|
||||
end
|
||||
|
||||
local monitor = peripheral.find("monitor")
|
||||
local speaker = peripheral.find("speaker")
|
||||
local inspection = peripheral.find("ticket_inspection_machine")
|
||||
local nativeTerm = (term and type(term.current) == "function" and term.current()) or term
|
||||
local monitor = nil
|
||||
local speaker = nil
|
||||
local inspection = nil
|
||||
local monitorSide = nil
|
||||
|
||||
local function safeFindPeripheral(peripheralType, sideHint)
|
||||
if not peripheral or type(peripheral.find) ~= "function" then return nil, nil end
|
||||
if sideHint and type(peripheral.wrap) == "function" and type(peripheral.isPresent) == "function" then
|
||||
local okPres, pres = pcall(peripheral.isPresent, sideHint)
|
||||
if okPres and pres then
|
||||
local okW, wrapped = pcall(peripheral.wrap, sideHint)
|
||||
if okW and wrapped then return wrapped, sideHint end
|
||||
end
|
||||
end
|
||||
local foundName = nil
|
||||
local found = nil
|
||||
pcall(function()
|
||||
found = peripheral.find(peripheralType, function(name, wrapped)
|
||||
foundName = name
|
||||
return true
|
||||
end)
|
||||
end)
|
||||
if found then return found, foundName end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
local function waitForPeripherals(requireInspection)
|
||||
local attempts = 0
|
||||
local lastDrawMsg = nil
|
||||
while attempts < PERIPHERAL_WAIT_MAX_RETRIES do
|
||||
local hadChange = false
|
||||
if not monitor then
|
||||
local m, ms = safeFindPeripheral("monitor", monitorSide)
|
||||
if m then
|
||||
monitor = m
|
||||
monitorSide = ms
|
||||
termDev = monitor
|
||||
pcall(function() handshakeMonitor(monitor) end)
|
||||
hadChange = true
|
||||
end
|
||||
end
|
||||
if not speaker then
|
||||
local s, _ = safeFindPeripheral("speaker")
|
||||
if s then speaker = s; hadChange = true end
|
||||
end
|
||||
if not inspection and requireInspection then
|
||||
local i, _ = safeFindPeripheral("ticket_inspection_machine")
|
||||
if i then inspection = i; hadChange = true end
|
||||
end
|
||||
local ok = true
|
||||
if not monitor then ok = false end
|
||||
if requireInspection and not inspection then ok = false end
|
||||
if ok then return true end
|
||||
local msg = nil
|
||||
if not monitor then
|
||||
msg = "Waiting for monitor..."
|
||||
elseif requireInspection and not inspection then
|
||||
msg = "Waiting for gate peripheral..."
|
||||
end
|
||||
if msg and msg ~= lastDrawMsg then
|
||||
lastDrawMsg = msg
|
||||
pcall(function()
|
||||
local tmpTerm = nativeTerm or term
|
||||
if tmpTerm and type(tmpTerm.getSize) == "function" then
|
||||
tmpTerm.setBackgroundColor(colors.black)
|
||||
tmpTerm.setTextColor(colors.yellow)
|
||||
tmpTerm.clear()
|
||||
tmpTerm.setCursorPos(1, 1)
|
||||
local w, _ = tmpTerm.getSize()
|
||||
local pad = string.rep(" ", math.max(0, math.floor((w - #VERSION) / 2)))
|
||||
tmpTerm.write(pad .. VERSION)
|
||||
local msg2 = tostring(msg)
|
||||
tmpTerm.setCursorPos(1, 3)
|
||||
local pad2 = string.rep(" ", math.max(0, math.floor((w - #msg2) / 2)))
|
||||
tmpTerm.write(pad2 .. msg2)
|
||||
tmpTerm.setCursorPos(1, 5)
|
||||
tmpTerm.setTextColor(colors.gray)
|
||||
local prog = "Attempt " .. tostring(attempts + 1) .. "/" .. tostring(PERIPHERAL_WAIT_MAX_RETRIES)
|
||||
local pad3 = string.rep(" ", math.max(0, math.floor((w - #prog) / 2)))
|
||||
tmpTerm.write(pad3 .. prog)
|
||||
end
|
||||
end)
|
||||
end
|
||||
sleep(PERIPHERAL_WAIT_INTERVAL)
|
||||
attempts = attempts + 1
|
||||
end
|
||||
return not not monitor
|
||||
end
|
||||
|
||||
pcall(function() waitForPeripherals(false) end)
|
||||
|
||||
local serverConnected = nil
|
||||
local isUpdating = false
|
||||
@@ -162,53 +253,125 @@ local function jsonDecode(str)
|
||||
if ok then return res else return nil end
|
||||
end
|
||||
|
||||
local function setServerConnected(ok)
|
||||
if serverConnected == ok then return end
|
||||
serverConnected = ok
|
||||
serverLastChangeTs = os.epoch("utc")
|
||||
local termDev = nativeTerm
|
||||
if peripheral and type(peripheral.getName) == "function" and monitor then
|
||||
pcall(function() monitorSide = peripheral.getName(monitor) end)
|
||||
end
|
||||
|
||||
local termDev = term
|
||||
if monitor then
|
||||
pcall(monitor.setTextScale, 0.5)
|
||||
termDev = monitor
|
||||
local function safeCallTerm(method, ...)
|
||||
if not termDev or type(termDev[method]) ~= "function" then return nil end
|
||||
local ok, r1, r2, r3 = pcall(termDev[method], ...)
|
||||
if not ok then return nil end
|
||||
return r1, r2, r3
|
||||
end
|
||||
|
||||
local function safeCallMonitor(target, method, ...)
|
||||
if not target or type(target[method]) ~= "function" then return false end
|
||||
local ok, r1 = pcall(target[method], ...)
|
||||
if not ok then return false end
|
||||
return r1 ~= false
|
||||
end
|
||||
|
||||
local function handshakeMonitor(target)
|
||||
if not target then return false end
|
||||
safeCallMonitor(target, "setTextScale", 0.5)
|
||||
safeCallMonitor(target, "setBackgroundColor", colors.black)
|
||||
safeCallMonitor(target, "setTextColor", colors.white)
|
||||
safeCallMonitor(target, "clear")
|
||||
local w, h = safeCallTerm("getSize")
|
||||
return (type(w) == "number" and type(h) == "number" and w >= 2 and h >= 2)
|
||||
end
|
||||
|
||||
local function refreshDisplayTarget()
|
||||
local nextMonitor = nil
|
||||
local nextMonitorSide = monitorSide
|
||||
if peripheral then
|
||||
if nextMonitorSide and type(peripheral.isPresent) == "function" then
|
||||
local okPres, pres = pcall(peripheral.isPresent, nextMonitorSide)
|
||||
if okPres and pres and type(peripheral.wrap) == "function" then
|
||||
local okWrap, wrapped = pcall(peripheral.wrap, nextMonitorSide)
|
||||
if okWrap and wrapped then nextMonitor = wrapped end
|
||||
end
|
||||
end
|
||||
if not nextMonitor and type(peripheral.find) == "function" then
|
||||
local foundName = nil
|
||||
local found = nil
|
||||
pcall(function()
|
||||
found = peripheral.find("monitor", function(name, wrapped)
|
||||
foundName = name
|
||||
return true
|
||||
end)
|
||||
end)
|
||||
if found then
|
||||
nextMonitor = found
|
||||
nextMonitorSide = foundName
|
||||
else
|
||||
nextMonitorSide = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
if nextMonitor and nextMonitor ~= monitor then
|
||||
monitor = nextMonitor
|
||||
monitorSide = nextMonitorSide
|
||||
pcall(function() handshakeMonitor(nextMonitor) end)
|
||||
elseif nextMonitor then
|
||||
monitor = nextMonitor
|
||||
monitorSide = nextMonitorSide
|
||||
end
|
||||
if monitor then
|
||||
if not safeCallMonitor(monitor, "setTextScale", 0.5) then
|
||||
pcall(function() handshakeMonitor(monitor) end)
|
||||
end
|
||||
termDev = monitor
|
||||
else
|
||||
termDev = nativeTerm or term
|
||||
end
|
||||
return termDev
|
||||
end
|
||||
|
||||
local function clear()
|
||||
termDev.setBackgroundColor(colors.black)
|
||||
termDev.setTextColor(colors.white)
|
||||
termDev.clear()
|
||||
termDev.setCursorPos(1, 1)
|
||||
pcall(refreshDisplayTarget)
|
||||
if not termDev then return end
|
||||
safeCallTerm("setBackgroundColor", colors.black)
|
||||
safeCallTerm("setTextColor", colors.white)
|
||||
safeCallTerm("clear")
|
||||
safeCallTerm("setCursorPos", 1, 1)
|
||||
end
|
||||
|
||||
local function centerText(y, text, color)
|
||||
local w = termDev.getSize()
|
||||
termDev.setTextColor(color or colors.white)
|
||||
local x = math.max(1, math.floor((w - #text) / 2) + 1)
|
||||
termDev.setCursorPos(x, y)
|
||||
termDev.write(text)
|
||||
if not termDev then return end
|
||||
local w = safeCallTerm("getSize")
|
||||
w = tonumber(w) or 0
|
||||
if w < 2 then return end
|
||||
safeCallTerm("setTextColor", color or colors.white)
|
||||
local x = math.max(1, math.floor((w - #tostring(text or "")) / 2) + 1)
|
||||
safeCallTerm("setCursorPos", x, y)
|
||||
safeCallTerm("write", tostring(text or ""))
|
||||
end
|
||||
|
||||
local lastStartupRedrawAt = 0
|
||||
|
||||
local function drawServerStatusIndicator(w)
|
||||
w = tonumber(w) or 0
|
||||
if w < 2 then return end
|
||||
local col = colors.yellow
|
||||
if serverConnected == true then col = colors.lime
|
||||
elseif serverConnected == false then col = colors.red end
|
||||
termDev.setBackgroundColor(colors.black)
|
||||
termDev.setTextColor(col)
|
||||
termDev.setCursorPos(w - 1, 1)
|
||||
termDev.write("S")
|
||||
termDev.setCursorPos(w, 1)
|
||||
termDev.write("*")
|
||||
termDev.setTextColor(colors.white)
|
||||
safeCallTerm("setBackgroundColor", colors.black)
|
||||
safeCallTerm("setTextColor", col)
|
||||
safeCallTerm("setCursorPos", w - 1, 1)
|
||||
safeCallTerm("write", "S")
|
||||
safeCallTerm("setCursorPos", w, 1)
|
||||
safeCallTerm("write", "*")
|
||||
safeCallTerm("setTextColor", colors.white)
|
||||
end
|
||||
|
||||
local lastUpdateError = nil
|
||||
|
||||
local function drawVersionIndicator(w)
|
||||
w = tonumber(w) or 0
|
||||
local s = tostring(VERSION or "")
|
||||
if #s == 0 then return end
|
||||
if w < #s then return end
|
||||
if #s == 0 or w < #s then return end
|
||||
local markerColor = colors.lime
|
||||
local markerText = "* "
|
||||
if isUpdating then
|
||||
@@ -218,37 +381,61 @@ local function drawVersionIndicator(w)
|
||||
markerColor = colors.red
|
||||
markerText = "* "
|
||||
end
|
||||
termDev.setBackgroundColor(colors.black)
|
||||
termDev.setTextColor(colors.gray)
|
||||
termDev.setCursorPos(1, 1)
|
||||
termDev.write(s)
|
||||
termDev.setTextColor(markerColor)
|
||||
termDev.write(markerText)
|
||||
|
||||
safeCallTerm("setBackgroundColor", colors.black)
|
||||
safeCallTerm("setTextColor", colors.gray)
|
||||
safeCallTerm("setCursorPos", 1, 1)
|
||||
safeCallTerm("write", s)
|
||||
safeCallTerm("setTextColor", markerColor)
|
||||
safeCallTerm("write", markerText)
|
||||
|
||||
if lastUpdateError then
|
||||
termDev.setCursorPos(1, 2)
|
||||
termDev.setTextColor(colors.red)
|
||||
termDev.write(string.sub(tostring(lastUpdateError), 1, w))
|
||||
safeCallTerm("setCursorPos", 1, 2)
|
||||
safeCallTerm("setTextColor", colors.red)
|
||||
safeCallTerm("write", string.sub(tostring(lastUpdateError), 1, w))
|
||||
end
|
||||
|
||||
termDev.setTextColor(colors.white)
|
||||
|
||||
safeCallTerm("setTextColor", colors.white)
|
||||
end
|
||||
|
||||
local _cachedDrawArgHash = nil
|
||||
|
||||
local function splitDisplayLines(s)
|
||||
if s == nil then return {} end
|
||||
local str = tostring(s)
|
||||
if #trim(str) == 0 then return {} end
|
||||
local lines = {}
|
||||
for line in str:gmatch("[^\n]+") do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
if #lines == 0 then table.insert(lines, str) end
|
||||
return lines
|
||||
end
|
||||
|
||||
local function draw(statusLine1, statusLine2, statusColor)
|
||||
clear()
|
||||
local w, h = termDev.getSize()
|
||||
if not termDev then return end
|
||||
local w, h = safeCallTerm("getSize")
|
||||
w = tonumber(w) or 0
|
||||
h = tonumber(h) or 0
|
||||
if w < 2 or h < 2 then return end
|
||||
centerText(2, "GATE", colors.cyan)
|
||||
drawVersionIndicator(w)
|
||||
drawServerStatusIndicator(w)
|
||||
if statusLine1 and #statusLine1 > 0 then
|
||||
centerText(math.max(2, math.floor(h / 2)), statusLine1, statusColor or colors.white)
|
||||
local lines = {}
|
||||
for _, line in ipairs(splitDisplayLines(statusLine1)) do table.insert(lines, line) end
|
||||
for _, line in ipairs(splitDisplayLines(statusLine2)) do table.insert(lines, line) end
|
||||
if #lines > 0 then
|
||||
local maxBodyY = math.max(3, h - 1)
|
||||
local startY = math.max(3, math.floor((maxBodyY - #lines) / 2) + 1)
|
||||
for idx, line in ipairs(lines) do
|
||||
local y = startY + idx - 1
|
||||
if y > maxBodyY then break end
|
||||
centerText(y, line, statusColor or colors.white)
|
||||
end
|
||||
end
|
||||
if statusLine2 and #statusLine2 > 0 then
|
||||
centerText(math.min(h, math.max(3, math.floor(h / 2) + 1)), statusLine2, statusColor or colors.white)
|
||||
end
|
||||
termDev.setCursorPos(1, h)
|
||||
termDev.setTextColor(colors.gray)
|
||||
termDev.write(string.rep(" ", w))
|
||||
safeCallTerm("setCursorPos", 1, h)
|
||||
safeCallTerm("setTextColor", colors.gray)
|
||||
safeCallTerm("write", string.rep(" ", w))
|
||||
end
|
||||
|
||||
local function pulseLeftRedstone(seconds)
|
||||
@@ -770,15 +957,31 @@ if type(cfg.side_station_codes) == "table" then
|
||||
end
|
||||
|
||||
pcall(function()
|
||||
refreshStationNameMap(guessBaseFromStatusURL(serverURL))
|
||||
end)
|
||||
|
||||
pcall(function()
|
||||
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
|
||||
end)
|
||||
|
||||
if not inspection then
|
||||
if modeBySide == nil then
|
||||
waitForPeripherals(true)
|
||||
else
|
||||
for side, _ in pairs(modeBySide) do
|
||||
local dev = resolveInspection(side)
|
||||
local retryCount = 0
|
||||
while not dev and retryCount < PERIPHERAL_WAIT_MAX_RETRIES do
|
||||
sleep(PERIPHERAL_WAIT_INTERVAL)
|
||||
dev = resolveInspection(side)
|
||||
retryCount = retryCount + 1
|
||||
end
|
||||
end
|
||||
if not monitor then waitForPeripherals(false) end
|
||||
end
|
||||
end)
|
||||
|
||||
if not inspection and modeBySide == nil then
|
||||
local okDev = false
|
||||
if peripheral and type(peripheral.find) == "function" then
|
||||
pcall(function()
|
||||
inspection = peripheral.find("ticket_inspection_machine")
|
||||
if inspection then okDev = true end
|
||||
end)
|
||||
end
|
||||
if not okDev then
|
||||
draw("Missing peripheral:", "ticket_inspection_machine", colors.red)
|
||||
error("ticket_inspection_machine not found")
|
||||
end
|
||||
@@ -814,6 +1017,15 @@ local function stationCodeForSide(side)
|
||||
return defaultStationCode()
|
||||
end
|
||||
|
||||
local function displayStationCode(code)
|
||||
local s = trim(code)
|
||||
if #s == 0 then return s end
|
||||
if s:sub(1, 2):upper() == "ST" then
|
||||
s = s:sub(3):gsub("^[%s%-_]+", "")
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
local function isInspectionPeripheral(p)
|
||||
return type(p) == "table" and (
|
||||
type(p.getLastScanned) == "function"
|
||||
@@ -909,15 +1121,38 @@ local function readyLine1()
|
||||
end
|
||||
|
||||
local function readyLine2()
|
||||
if not modeBySide then
|
||||
return "ST " .. stationCodeForSide(nil)
|
||||
local lines = {}
|
||||
local seen = {}
|
||||
local function addLine(line)
|
||||
line = trim(line)
|
||||
if #line == 0 then return end
|
||||
local key = line:lower()
|
||||
if seen[key] then return end
|
||||
seen[key] = true
|
||||
table.insert(lines, line)
|
||||
end
|
||||
local frontCode = stationCodeForSide("front")
|
||||
local backCode = stationCodeForSide("back")
|
||||
if frontCode == backCode then
|
||||
return "ST " .. frontCode
|
||||
|
||||
if type(sideStationCodeBySide) == "table" and next(sideStationCodeBySide) ~= nil then
|
||||
local orderedSides = { "front", "back", "left", "right", "top", "bottom" }
|
||||
for _, side in ipairs(orderedSides) do
|
||||
local code = displayStationCode(sideStationCodeBySide[side] or "")
|
||||
if #code > 0 then
|
||||
addLine(string.upper(side:sub(1, 1)) .. " " .. code)
|
||||
end
|
||||
end
|
||||
end
|
||||
return "F " .. frontCode .. " B " .. backCode
|
||||
|
||||
if #lines == 0 then
|
||||
for _, code in ipairs(stationCodesPayload) do
|
||||
addLine(displayStationCode(code))
|
||||
end
|
||||
end
|
||||
|
||||
if #lines == 0 then
|
||||
addLine(displayStationCode(stationCodeForSide(nil)))
|
||||
end
|
||||
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
local function drawReadyScreen()
|
||||
@@ -972,19 +1207,19 @@ local function updateDeviceField(dev, key, value)
|
||||
pcall(dev.updateTicket, key, value)
|
||||
end
|
||||
|
||||
local function callTicketStateMethod(dev, methodName)
|
||||
local function callTicketStateMethod(dev, methodName, ...)
|
||||
if type(dev) ~= "table" then return false end
|
||||
local fn = dev[methodName]
|
||||
if type(fn) ~= "function" then return false end
|
||||
local okCall, okRes = pcall(fn)
|
||||
local okCall, okRes = pcall(fn, ...)
|
||||
if not okCall then return false end
|
||||
return okRes ~= false
|
||||
end
|
||||
|
||||
local function applyTicketDeviceState(dev, action, rides)
|
||||
local function applyTicketDeviceState(dev, action, rides, stationCode)
|
||||
if type(dev) ~= "table" then return end
|
||||
if action == "entry" then
|
||||
if not callTicketStateMethod(dev, "markEntered") then
|
||||
if not callTicketStateMethod(dev, "markEntered", stationCode) then
|
||||
updateDeviceField(dev, "entered", true)
|
||||
updateDeviceField(dev, "exited", false)
|
||||
end
|
||||
@@ -1003,13 +1238,32 @@ local function applyTicketDeviceState(dev, action, rides)
|
||||
end
|
||||
|
||||
local function updateICCardField(dev, key, value)
|
||||
if type(dev) ~= "table" or type(dev.updateICCard) ~= "function" then return false end
|
||||
if type(dev) ~= "table" then return false end
|
||||
if type(dev.updateICCard) ~= "function" then return true end
|
||||
local okCall, okRes, detail = pcall(dev.updateICCard, key, value)
|
||||
if not okCall then return false, tostring(okRes) end
|
||||
if okRes == false then return false, tostring(detail or "update_failed") end
|
||||
return true
|
||||
end
|
||||
|
||||
local function updateICCardFieldWithFallback(dev, keys, value)
|
||||
if type(keys) == "string" then
|
||||
return updateICCardField(dev, keys, value)
|
||||
end
|
||||
local firstErr = nil
|
||||
if type(keys) ~= "table" then return false, "invalid_keys" end
|
||||
for _, key in ipairs(keys) do
|
||||
local okField, errField = updateICCardField(dev, key, value)
|
||||
if okField then
|
||||
return true, key
|
||||
end
|
||||
if not firstErr then
|
||||
firstErr = tostring(key) .. ": " .. tostring(errField or "update_failed")
|
||||
end
|
||||
end
|
||||
return false, firstErr or "update_failed"
|
||||
end
|
||||
|
||||
local function updateICCardFields(dev, patch)
|
||||
local allOk = true
|
||||
local firstErr = nil
|
||||
@@ -1086,9 +1340,35 @@ local function queryFare(fromStation, toStation)
|
||||
return toMoney(fare), nil
|
||||
end
|
||||
|
||||
local function denyCard(reason, detail)
|
||||
local function triggerFaultLights(devs)
|
||||
local targets = {}
|
||||
if type(devs) == "table" then
|
||||
for _, d in ipairs(devs) do table.insert(targets, d) end
|
||||
end
|
||||
if inspection and not targets[1] then table.insert(targets, inspection) end
|
||||
if modeBySide then
|
||||
for side, _ in pairs(modeBySide) do
|
||||
local d = resolveInspection(side)
|
||||
if d then
|
||||
local found = false
|
||||
for _, t in ipairs(targets) do if t == d then found = true; break end end
|
||||
if not found then table.insert(targets, d) end
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, dev in ipairs(targets) do
|
||||
if type(dev) == "table" and type(dev.markFault) == "function" then
|
||||
pcall(dev.markFault)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function denyCard(reason, detail, inspectionDevs)
|
||||
draw("DENIED", tostring(detail or reason or "deny"), colors.red)
|
||||
playDfpwm("error.dfpwm")
|
||||
parallel.waitForAll(
|
||||
function() playDfpwm("error.dfpwm") end,
|
||||
function() triggerFaultLights(inspectionDevs) end
|
||||
)
|
||||
end
|
||||
|
||||
local function deductICCardBalance(dev, amount)
|
||||
@@ -1134,7 +1414,10 @@ local function handleICCardScan(scan, side, scanDev)
|
||||
local cardId = getCardId(scan)
|
||||
if #cardId == 0 then
|
||||
draw("Invalid card.", "Missing card_id.", colors.red)
|
||||
playDfpwm("error.dfpwm")
|
||||
parallel.waitForAll(
|
||||
function() playDfpwm("error.dfpwm") end,
|
||||
function() triggerFaultLights(inspectionDevs) end
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1145,56 +1428,64 @@ local function handleICCardScan(scan, side, scanDev)
|
||||
local usedAction = action
|
||||
|
||||
if #exitStation == 0 then
|
||||
denyCard("missing_station", "Missing gate station")
|
||||
denyCard("missing_station", "Missing gate station", inspectionDevs)
|
||||
return
|
||||
end
|
||||
|
||||
if usedAction == "entry" then
|
||||
if getCardEntered(scan) and not getCardExited(scan) then
|
||||
denyCard("already_entered", "Already entered")
|
||||
denyCard("already_entered", "Already entered", inspectionDevs)
|
||||
return
|
||||
end
|
||||
local okWrite = true
|
||||
local writeErr = nil
|
||||
for _, dev in ipairs(inspectionDevs) do
|
||||
local okPatch, errPatch = updateICCardFields(dev, {
|
||||
entry = exitStation,
|
||||
})
|
||||
local okPatch, errPatch = updateICCardFieldWithFallback(dev, {
|
||||
"entry_station",
|
||||
"entry",
|
||||
"start_station"
|
||||
}, exitStation)
|
||||
okWrite = okPatch and okWrite
|
||||
if not okPatch and not writeErr then writeErr = errPatch end
|
||||
end
|
||||
if not okWrite then
|
||||
draw("WRITE ERROR", tostring(writeErr or "Failed to update card"), colors.red)
|
||||
playDfpwm("error.dfpwm")
|
||||
parallel.waitForAll(
|
||||
function() playDfpwm("error.dfpwm") end,
|
||||
function() triggerFaultLights(inspectionDevs) end
|
||||
)
|
||||
return
|
||||
end
|
||||
entryStation = exitStation
|
||||
else
|
||||
if not getCardEntered(scan) then
|
||||
denyCard("not_entered", "Not entered")
|
||||
denyCard("not_entered", "Not entered", inspectionDevs)
|
||||
return
|
||||
end
|
||||
if getCardExited(scan) then
|
||||
denyCard("already_exited", "Already exited")
|
||||
denyCard("already_exited", "Already exited", inspectionDevs)
|
||||
return
|
||||
end
|
||||
if #entryStation == 0 then
|
||||
denyCard("missing_entry_station", "Missing entry station")
|
||||
denyCard("missing_entry_station", "Missing entry station", inspectionDevs)
|
||||
return
|
||||
end
|
||||
local fareValue, fareErr = queryFare(entryStation, exitStation)
|
||||
if fareValue == nil then
|
||||
if fareErr == "net_error" then
|
||||
draw("NET ERROR", "Fare lookup failed", colors.red)
|
||||
playDfpwm("error.dfpwm")
|
||||
parallel.waitForAll(
|
||||
function() playDfpwm("error.dfpwm") end,
|
||||
function() triggerFaultLights(inspectionDevs) end
|
||||
)
|
||||
else
|
||||
denyCard("fare_not_found", fareErr)
|
||||
denyCard("fare_not_found", fareErr, inspectionDevs)
|
||||
end
|
||||
return
|
||||
end
|
||||
fare = fareValue
|
||||
if balance < fare then
|
||||
denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance))
|
||||
denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance), inspectionDevs)
|
||||
return
|
||||
end
|
||||
local okWrite = true
|
||||
@@ -1203,7 +1494,11 @@ local function handleICCardScan(scan, side, scanDev)
|
||||
local okDeduct, newBalanceOrErr = deductICCardBalance(dev, fare)
|
||||
if okDeduct then
|
||||
balance = toMoney(newBalanceOrErr or (balance - fare)) or 0
|
||||
local okClear, clearErr = updateICCardField(dev, "entry", nil)
|
||||
local okClear, clearErr = updateICCardFieldWithFallback(dev, {
|
||||
"entry_station",
|
||||
"entry",
|
||||
"start_station"
|
||||
}, nil)
|
||||
local okFare, fareErr = updateICCardField(dev, "last_fare", fare)
|
||||
if not okClear or not okFare then
|
||||
okWrite = false
|
||||
@@ -1213,10 +1508,13 @@ local function handleICCardScan(scan, side, scanDev)
|
||||
else
|
||||
okWrite = false
|
||||
if tostring(newBalanceOrErr) == "insufficient" then
|
||||
denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance))
|
||||
denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance), inspectionDevs)
|
||||
else
|
||||
draw("WRITE ERROR", tostring(newBalanceOrErr or "deduct_failed"), colors.red)
|
||||
playDfpwm("error.dfpwm")
|
||||
parallel.waitForAll(
|
||||
function() playDfpwm("error.dfpwm") end,
|
||||
function() triggerFaultLights(inspectionDevs) end
|
||||
)
|
||||
end
|
||||
break
|
||||
end
|
||||
@@ -1224,20 +1522,20 @@ local function handleICCardScan(scan, side, scanDev)
|
||||
if not okWrite then
|
||||
if writeErr then
|
||||
draw("WRITE ERROR", tostring(writeErr), colors.red)
|
||||
playDfpwm("error.dfpwm")
|
||||
parallel.waitForAll(
|
||||
function() playDfpwm("error.dfpwm") end,
|
||||
function() triggerFaultLights(inspectionDevs) end
|
||||
)
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
for _, dev in ipairs(inspectionDevs) do
|
||||
applyTicketDeviceState(dev, usedAction, nil, exitStation)
|
||||
if usedAction == "entry" then
|
||||
updateDeviceField(dev, "entered", true)
|
||||
updateDeviceField(dev, "exited", false)
|
||||
if #entryStation > 0 then updateDeviceField(dev, "entry_station", entryStation) end
|
||||
else
|
||||
updateDeviceField(dev, "exited", true)
|
||||
updateDeviceField(dev, "entered", false)
|
||||
if #exitStation > 0 then updateDeviceField(dev, "exit_station", exitStation) end
|
||||
updateDeviceField(dev, "last_fare", fare)
|
||||
end
|
||||
@@ -1293,7 +1591,10 @@ local function handleScan(scan, side, scanDev)
|
||||
local ticketId = getTicketId(scan)
|
||||
if not ticketId then
|
||||
draw("Invalid ticket.", "Missing ticketId.", colors.red)
|
||||
playDfpwm("error.dfpwm")
|
||||
parallel.waitForAll(
|
||||
function() playDfpwm("error.dfpwm") end,
|
||||
function() triggerFaultLights(inspectionDevs) end
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1331,13 +1632,19 @@ local function handleScan(scan, side, scanDev)
|
||||
|
||||
if not ok or type(resp) ~= "table" then
|
||||
draw("NET ERROR", "Server check failed.", colors.red)
|
||||
playDfpwm("error.dfpwm")
|
||||
parallel.waitForAll(
|
||||
function() playDfpwm("error.dfpwm") end,
|
||||
function() triggerFaultLights(inspectionDevs) end
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
if resp.result ~= "pass" then
|
||||
draw("DENIED", tostring(resp.reason or "deny"), colors.red)
|
||||
playDfpwm("error.dfpwm")
|
||||
parallel.waitForAll(
|
||||
function() playDfpwm("error.dfpwm") end,
|
||||
function() triggerFaultLights(inspectionDevs) end
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1347,7 +1654,7 @@ local function handleScan(scan, side, scanDev)
|
||||
or tonumber(scan.trips_remaining or scan.rides_remaining)
|
||||
or tonumber(scan.rides)
|
||||
for _, dev in ipairs(inspectionDevs) do
|
||||
applyTicketDeviceState(dev, usedAction, newRides)
|
||||
applyTicketDeviceState(dev, usedAction, newRides, stationCodeForSide(side))
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -1402,7 +1709,7 @@ local function shouldProcessScan(scan, side, eventName)
|
||||
local now = os.epoch("utc")
|
||||
local prev = recentScans[key]
|
||||
recentScans[key] = now
|
||||
if prev and (now - prev) < 500 then
|
||||
if prev and (now - prev) < 200 then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
@@ -1455,12 +1762,28 @@ local function mainLoop()
|
||||
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)
|
||||
os.sleep(0.1)
|
||||
drawReadyScreen()
|
||||
elseif ev[1] == "peripheral" or ev[1] == "peripheral_detach" or ev[1] == "monitor_resize" or ev[1] == "config_updated" then
|
||||
pcall(drawReadyScreen)
|
||||
elseif ev[1] == "timer" then
|
||||
pcall(drawReadyScreen)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function startupRedrawTask()
|
||||
local count = 0
|
||||
while count < 3 do
|
||||
sleep(0.5)
|
||||
pcall(drawReadyScreen)
|
||||
count = count + 1
|
||||
end
|
||||
while true do
|
||||
sleep(600)
|
||||
end
|
||||
end
|
||||
|
||||
local function checkAndApplyUpdate()
|
||||
local serverBase = guessBaseFromStatusURL(serverURL)
|
||||
local txt = fetchHTTP(serverBase .. '/api/check-update?_t=' .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999)))
|
||||
@@ -1521,6 +1844,7 @@ end
|
||||
|
||||
local function backgroundSyncTask()
|
||||
pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end)
|
||||
pcall(function() refreshStationNameMap(guessBaseFromStatusURL(serverURL)) end)
|
||||
while true do
|
||||
sleep(VERSION_CHECK_INTERVAL)
|
||||
pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end)
|
||||
@@ -1528,4 +1852,4 @@ local function backgroundSyncTask()
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(mainLoop, backgroundSyncTask)
|
||||
parallel.waitForAny(mainLoop, startupRedrawTask, backgroundSyncTask)
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
--[[
|
||||
Author: HenryDu8133 813367384@qq.com
|
||||
Date: 2026-06-28 12:58:42
|
||||
LastEditors: HenryDu8133 813367384@qq.com
|
||||
LastEditTime: 2026-07-11 00:02:33
|
||||
FilePath: \TicketMachine\install_ticketmachine.lua
|
||||
Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
--]]
|
||||
|
||||
|
||||
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"
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ module.exports = {
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
}
|
||||
},
|
||||
transports: ['websocket']
|
||||
});
|
||||
|
||||
io.engine.on('connection_error', (err) => {
|
||||
|
||||
@@ -1191,6 +1191,29 @@ router.get('/export', (req, res) => {
|
||||
res.json(DataService.buildExportPayload());
|
||||
});
|
||||
|
||||
// Issues
|
||||
router.post('/report_issue', async (req, res) => {
|
||||
const r = req.body || {};
|
||||
const item = {
|
||||
ts: Number(r.ts || Date.now()),
|
||||
device: String(r.device || 'unknown'),
|
||||
machine_id: String(r.machine_id || ''),
|
||||
station_code: String(r.station_code || ''),
|
||||
error_msg: String(r.error_msg || ''),
|
||||
detail: String(r.detail || ''),
|
||||
type: 'issue'
|
||||
};
|
||||
await DataService.appendIssue(item);
|
||||
io.emit('issue:new', item);
|
||||
appendReqLog(req, { category: 'device', type: 'report_issue', detail: item });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/issues', (req, res) => {
|
||||
const issues = DataService.getIssues();
|
||||
res.json({ ok: true, issues });
|
||||
});
|
||||
|
||||
// Ticket Operations
|
||||
router.post('/tickets/sale', async (req, res) => {
|
||||
const { start, terminal, train_type, cost, station_code, device, trips_total, trips_remaining } = req.body || {};
|
||||
|
||||
+22
-2
@@ -8,6 +8,7 @@ const router = express.Router();
|
||||
|
||||
const ASSETS_DIR = path.join(__dirname, '../../Assets');
|
||||
const MANIFEST_PATH = path.join(ASSETS_DIR, 'manifest.json');
|
||||
const ROUTE_MAP_LICENSE = 'CC BY 4.0';
|
||||
|
||||
const getIp = (req) => (req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || req.connection?.remoteAddress || '';
|
||||
|
||||
@@ -48,6 +49,8 @@ const writeManifest = (m) => {
|
||||
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(out, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const normalizeCredit = (value) => String(value || '').trim().slice(0, 200);
|
||||
|
||||
const removeByPrefix = (prefix) => {
|
||||
ensureDir(ASSETS_DIR);
|
||||
const list = fs.readdirSync(ASSETS_DIR);
|
||||
@@ -97,6 +100,8 @@ router.get('/manifest', (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
routeMap,
|
||||
routeMapCredit: normalizeCredit(m.routeMapCredit || ''),
|
||||
routeMapLicense: ROUTE_MAP_LICENSE,
|
||||
fareTable,
|
||||
updatedAt: m.updatedAt || null
|
||||
});
|
||||
@@ -106,10 +111,24 @@ router.post('/route-map', uploadRouteMap.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ ok: false, error: '缺少文件' });
|
||||
const m = readManifest();
|
||||
m.routeMap = path.basename(req.file.filename);
|
||||
if (req.body && Object.prototype.hasOwnProperty.call(req.body, 'credit')) {
|
||||
m.routeMapCredit = normalizeCredit(req.body.credit);
|
||||
} else if (typeof m.routeMapCredit !== 'string') {
|
||||
m.routeMapCredit = '';
|
||||
}
|
||||
m.updatedAt = Date.now();
|
||||
writeManifest(m);
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap } });
|
||||
res.json({ ok: true, routeMap: m.routeMap, updatedAt: m.updatedAt });
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap, credit: m.routeMapCredit || '' } });
|
||||
res.json({ ok: true, routeMap: m.routeMap, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
|
||||
});
|
||||
|
||||
router.post('/route-map/credit', (req, res) => {
|
||||
const m = readManifest();
|
||||
m.routeMapCredit = normalizeCredit(req.body?.credit);
|
||||
m.updatedAt = Date.now();
|
||||
writeManifest(m);
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_update_route_map_credit', detail: { credit: m.routeMapCredit || '' } });
|
||||
res.json({ ok: true, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
|
||||
});
|
||||
|
||||
router.post('/fare-table', uploadFareTable.single('file'), (req, res) => {
|
||||
@@ -129,6 +148,7 @@ router.delete('/route-map', (req, res) => {
|
||||
}
|
||||
removeByPrefix('route-map');
|
||||
m.routeMap = null;
|
||||
m.routeMapCredit = '';
|
||||
m.updatedAt = Date.now();
|
||||
writeManifest(m);
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_delete_route_map', detail: { ok: true } });
|
||||
|
||||
+15
-1
@@ -64,7 +64,8 @@ const cache = {
|
||||
icCardIndex: {},
|
||||
icCardEvents: [],
|
||||
statsTicket: [],
|
||||
statsGate: []
|
||||
statsGate: [],
|
||||
issues: []
|
||||
};
|
||||
|
||||
const DataService = {
|
||||
@@ -88,6 +89,7 @@ const DataService = {
|
||||
await conn.query(`CREATE TABLE IF NOT EXISTS ticket_events (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||
await conn.query(`CREATE TABLE IF NOT EXISTS stats_ticket (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||
await conn.query(`CREATE TABLE IF NOT EXISTS stats_gate (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||
await conn.query(`CREATE TABLE IF NOT EXISTS issues (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||
|
||||
// Load Cache
|
||||
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
|
||||
@@ -126,6 +128,9 @@ const DataService = {
|
||||
const [statsG] = await conn.query('SELECT data FROM stats_gate');
|
||||
cache.statsGate = statsG.map(r => r.data);
|
||||
|
||||
const [issues] = await conn.query('SELECT data FROM issues');
|
||||
cache.issues = issues.map(r => r.data);
|
||||
|
||||
console.log('DataService initialized with MySQL');
|
||||
} finally {
|
||||
conn.release();
|
||||
@@ -511,6 +516,15 @@ const DataService = {
|
||||
},
|
||||
getStatsGate: () => cache.statsGate,
|
||||
|
||||
// Issues
|
||||
appendIssue: async (item) => {
|
||||
cache.issues.push(item);
|
||||
try {
|
||||
await pool.query('INSERT INTO issues (data) VALUES (?)', [JSON.stringify(item)]);
|
||||
} catch(e) { console.error('appendIssue error', e); }
|
||||
},
|
||||
getIssues: () => cache.issues,
|
||||
|
||||
// Export
|
||||
buildExportPayload: () => ({
|
||||
config: DataService.getConfig(),
|
||||
|
||||
+176
-706
@@ -1,10 +1,3 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_VIEWPORT = { width: 1440, height: 920 };
|
||||
const DEFAULT_LINE_COLOR = '#93a2b7';
|
||||
const REFERENCE_SVG_PATH = path.resolve(__dirname, '..', '..', 'FSE铁路线路图.svg');
|
||||
|
||||
const escapeHTML = (s) => String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
@@ -12,738 +5,215 @@ const escapeHTML = (s) => String(s || '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
const round = (value, step = 2) => Math.round(Number(value || 0) / step) * step;
|
||||
const toCode = (value) => String(value || '').trim();
|
||||
const normalizeName = (value) => String(value || '').trim().toLowerCase().replace(/\s+/g, '');
|
||||
const parseNumber = (value) => {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
};
|
||||
|
||||
const attrRegex = /([\w:-]+)="([^"]*)"/g;
|
||||
|
||||
function parseAttributes(raw) {
|
||||
const attrs = {};
|
||||
if (!raw) return attrs;
|
||||
for (const match of raw.matchAll(attrRegex)) {
|
||||
attrs[match[1]] = match[2];
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function parseReferenceSvgMeta() {
|
||||
try {
|
||||
if (!fs.existsSync(REFERENCE_SVG_PATH)) return { ...DEFAULT_VIEWPORT, anchorPoints: [] };
|
||||
const raw = fs.readFileSync(REFERENCE_SVG_PATH, 'utf8');
|
||||
const viewBoxMatch = raw.match(/viewBox="([\d.\s-]+)"/i);
|
||||
const widthMatch = raw.match(/\bwidth="([\d.]+)"/i);
|
||||
const heightMatch = raw.match(/\bheight="([\d.]+)"/i);
|
||||
let width = DEFAULT_VIEWPORT.width;
|
||||
let height = DEFAULT_VIEWPORT.height;
|
||||
if (viewBoxMatch) {
|
||||
const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number);
|
||||
if (parts.length === 4 && Number.isFinite(parts[2]) && Number.isFinite(parts[3])) {
|
||||
width = parts[2];
|
||||
height = parts[3];
|
||||
function generate(stations, lines, fares, nameByCode, transfers) {
|
||||
const enNameByCode = (() => {
|
||||
try {
|
||||
const map = {};
|
||||
for (const s of (stations || [])) {
|
||||
map[String(s.code || '')] = s.en_name || s.enName || '';
|
||||
}
|
||||
} else {
|
||||
width = parseNumber(widthMatch?.[1]) || width;
|
||||
height = parseNumber(heightMatch?.[1]) || height;
|
||||
return map;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
|
||||
const circles = [];
|
||||
for (const match of raw.matchAll(/<circle\b([^>]*)\/?>/gi)) {
|
||||
const attrs = parseAttributes(match[1]);
|
||||
const cx = parseNumber(attrs.cx);
|
||||
const cy = parseNumber(attrs.cy);
|
||||
const r = parseNumber(attrs.r);
|
||||
if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(r)) continue;
|
||||
circles.push({
|
||||
cx,
|
||||
cy,
|
||||
r,
|
||||
fill: attrs.fill || '',
|
||||
stroke: attrs.stroke || '',
|
||||
strokeWidth: parseNumber(attrs['stroke-width']) || 0
|
||||
});
|
||||
const linesForStation = {};
|
||||
(lines || []).forEach((li) => {
|
||||
const id = li.id || '';
|
||||
const name = li.name || li.cn_name || '';
|
||||
const color = li.color || '#93a2b7';
|
||||
const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map((x) => String(x));
|
||||
for (const code of arr) {
|
||||
if (!linesForStation[code]) linesForStation[code] = [];
|
||||
linesForStation[code].push({ id, name, color, stations: arr });
|
||||
}
|
||||
|
||||
const anchorCandidates = circles.filter((circle) => {
|
||||
if (circle.r < 4) return false;
|
||||
if (!circle.stroke || circle.stroke.toLowerCase() === 'none') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const clusters = [];
|
||||
for (const circle of anchorCandidates) {
|
||||
let target = null;
|
||||
for (const cluster of clusters) {
|
||||
const dx = cluster.x - circle.cx;
|
||||
const dy = cluster.y - circle.cy;
|
||||
const distance = Math.sqrt((dx * dx) + (dy * dy));
|
||||
if (distance <= 18) {
|
||||
target = cluster;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!target) {
|
||||
target = { x: circle.cx, y: circle.cy, count: 0 };
|
||||
clusters.push(target);
|
||||
}
|
||||
target.count += 1;
|
||||
target.x += (circle.cx - target.x) / target.count;
|
||||
target.y += (circle.cy - target.y) / target.count;
|
||||
}
|
||||
|
||||
const anchorPoints = clusters
|
||||
.sort((a, b) => (a.y - b.y) || (a.x - b.x))
|
||||
.map((point) => ({ x: point.x, y: point.y }));
|
||||
|
||||
return { width, height, anchorPoints };
|
||||
} catch (_) {
|
||||
return { ...DEFAULT_VIEWPORT, anchorPoints: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function createUnionFind() {
|
||||
const parent = new Map();
|
||||
const find = (value) => {
|
||||
if (!parent.has(value)) parent.set(value, value);
|
||||
const root = parent.get(value);
|
||||
if (root !== value) {
|
||||
const collapsed = find(root);
|
||||
parent.set(value, collapsed);
|
||||
return collapsed;
|
||||
}
|
||||
return root;
|
||||
};
|
||||
const union = (a, b) => {
|
||||
if (!a || !b) return;
|
||||
const ra = find(a);
|
||||
const rb = find(b);
|
||||
if (ra !== rb) parent.set(rb, ra);
|
||||
};
|
||||
return { parent, find, union };
|
||||
}
|
||||
|
||||
function buildTransferGroups(stations, transfers) {
|
||||
const uf = createUnionFind();
|
||||
const codesByName = new Map();
|
||||
|
||||
for (const station of (stations || [])) {
|
||||
const code = toCode(station?.code);
|
||||
if (!code) continue;
|
||||
uf.find(code);
|
||||
const nameKey = `${normalizeName(station?.name || station?.cn_name || station?.station_name)}|${normalizeName(station?.en_name || station?.enName || station?.en)}`;
|
||||
if (nameKey !== '|') {
|
||||
if (!codesByName.has(nameKey)) codesByName.set(nameKey, []);
|
||||
codesByName.get(nameKey).push(code);
|
||||
}
|
||||
}
|
||||
|
||||
for (const station of (stations || [])) {
|
||||
const from = toCode(station?.code);
|
||||
if (!from) continue;
|
||||
const transferTo = Array.isArray(station?.transfer_to) ? station.transfer_to : [];
|
||||
for (const item of transferTo) {
|
||||
const to = toCode((typeof item === 'string') ? item : (item?.code || item?.station || item?.id || item?.[0]));
|
||||
if (to) uf.union(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pair of (transfers || [])) {
|
||||
const from = toCode(pair?.[0]);
|
||||
const to = toCode(pair?.[1]);
|
||||
if (from && to) uf.union(from, to);
|
||||
}
|
||||
|
||||
for (const group of codesByName.values()) {
|
||||
if (!Array.isArray(group) || group.length < 2) continue;
|
||||
for (let index = 1; index < group.length; index += 1) {
|
||||
uf.union(group[0], group[index]);
|
||||
}
|
||||
}
|
||||
|
||||
const codesByCanonical = {};
|
||||
const canonicalByCode = {};
|
||||
for (const station of (stations || [])) {
|
||||
const code = toCode(station?.code);
|
||||
if (!code) continue;
|
||||
const canonical = uf.find(code);
|
||||
if (!codesByCanonical[canonical]) codesByCanonical[canonical] = [];
|
||||
codesByCanonical[canonical].push(code);
|
||||
}
|
||||
|
||||
for (const root of Object.keys(codesByCanonical)) {
|
||||
const sortedCodes = Array.from(new Set(codesByCanonical[root])).sort((a, b) => a.localeCompare(b));
|
||||
const canonical = sortedCodes[0];
|
||||
delete codesByCanonical[root];
|
||||
codesByCanonical[canonical] = sortedCodes;
|
||||
for (const code of sortedCodes) canonicalByCode[code] = canonical;
|
||||
}
|
||||
|
||||
return { canonicalByCode, codesByCanonical };
|
||||
}
|
||||
|
||||
function buildNodes(stations, nameByCode, transferGroups) {
|
||||
const nodes = new Map();
|
||||
for (const station of (stations || [])) {
|
||||
const code = toCode(station?.code);
|
||||
if (!code) continue;
|
||||
const canonical = transferGroups.canonicalByCode[code] || code;
|
||||
const name = String(
|
||||
station?.name
|
||||
|| station?.cn_name
|
||||
|| station?.station_name
|
||||
|| nameByCode?.[code]
|
||||
|| code
|
||||
);
|
||||
const enName = String(station?.en_name || station?.enName || station?.en || '');
|
||||
const svgX = parseNumber(station?.svg_x);
|
||||
const svgY = parseNumber(station?.svg_y);
|
||||
if (!nodes.has(canonical)) {
|
||||
nodes.set(canonical, {
|
||||
canonical,
|
||||
codes: [],
|
||||
primaryCode: code,
|
||||
name,
|
||||
enName,
|
||||
lines: new Set(),
|
||||
explicitPositions: [],
|
||||
explicitLineIndices: [],
|
||||
isTransfer: false
|
||||
});
|
||||
}
|
||||
const node = nodes.get(canonical);
|
||||
node.codes.push(code);
|
||||
if (!node.name && name) node.name = name;
|
||||
if (!node.enName && enName) node.enName = enName;
|
||||
if (Number.isFinite(svgX) && Number.isFinite(svgY)) {
|
||||
node.explicitPositions.push({ x: svgX, y: svgY });
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function buildLineSegments(lines, transferGroups, nodeMap) {
|
||||
const segments = [];
|
||||
const uniqueEdgeSet = new Set();
|
||||
|
||||
(lines || []).forEach((line, lineIndex) => {
|
||||
const color = line?.color || DEFAULT_LINE_COLOR;
|
||||
const lineId = String(line?.id || `line-${lineIndex + 1}`);
|
||||
const name = String(line?.name || line?.cn_name || line?.线路名称 || lineId);
|
||||
const enName = String(line?.en_name || line?.英文名称 || line?.英文名 || '');
|
||||
const stopsRaw = Array.isArray(line?.stations)
|
||||
? line.stations
|
||||
: (Array.isArray(line?.stops) ? line.stops : (Array.isArray(line?.站点列表) ? line.站点列表 : []));
|
||||
let current = [];
|
||||
|
||||
const commitSegment = () => {
|
||||
const canonicalStops = [];
|
||||
for (const stop of current) {
|
||||
const code = toCode(stop);
|
||||
if (!code || code === '-') continue;
|
||||
const canonical = transferGroups.canonicalByCode[code] || code;
|
||||
if (canonicalStops[canonicalStops.length - 1] !== canonical) canonicalStops.push(canonical);
|
||||
const node = nodeMap.get(canonical);
|
||||
if (node) {
|
||||
node.lines.add(lineId);
|
||||
node.explicitLineIndices.push(lineIndex);
|
||||
}
|
||||
}
|
||||
if (canonicalStops.length < 2) {
|
||||
current = [];
|
||||
return;
|
||||
}
|
||||
segments.push({ id: lineId, name, enName, color, lineIndex, stops: canonicalStops });
|
||||
for (let index = 0; index < canonicalStops.length - 1; index += 1) {
|
||||
const a = canonicalStops[index];
|
||||
const b = canonicalStops[index + 1];
|
||||
uniqueEdgeSet.add([a, b].sort().join('|'));
|
||||
}
|
||||
current = [];
|
||||
};
|
||||
|
||||
for (const stop of stopsRaw) {
|
||||
if (toCode(stop) === '-') {
|
||||
commitSegment();
|
||||
} else {
|
||||
current.push(stop);
|
||||
}
|
||||
}
|
||||
commitSegment();
|
||||
});
|
||||
|
||||
return {
|
||||
segments,
|
||||
edges: Array.from(uniqueEdgeSet).map((key) => {
|
||||
const [from, to] = key.split('|');
|
||||
return { from, to };
|
||||
})
|
||||
};
|
||||
}
|
||||
const groupsMap = {};
|
||||
(stations || []).forEach((s) => {
|
||||
const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim();
|
||||
if (!k) return;
|
||||
if (!groupsMap[k]) groupsMap[k] = [];
|
||||
groupsMap[k].push(String(s.code || ''));
|
||||
});
|
||||
|
||||
function buildSeedLayout(nodeMap, lineSegments, referenceMeta) {
|
||||
const nodes = Array.from(nodeMap.values());
|
||||
const baseX = 140;
|
||||
const baseY = 120;
|
||||
const gapX = 96;
|
||||
const gapY = 92;
|
||||
const seed = {};
|
||||
const groupsForColors = Object.values(groupsMap).filter((arr) => arr.length > 1);
|
||||
const colorsForRoute = (a, b) => {
|
||||
const la = linesForStation[a] || [];
|
||||
const lb = linesForStation[b] || [];
|
||||
const same = la.find((x) => lb.some((y) => y.id === x.id));
|
||||
if (same) return [same.color || '#93a2b7'];
|
||||
|
||||
const hasExplicitPositions = nodes.some((node) => node.explicitPositions.length > 0);
|
||||
if (hasExplicitPositions) {
|
||||
for (const node of nodes) {
|
||||
if (node.explicitPositions.length > 0) {
|
||||
const x = node.explicitPositions.reduce((sum, pos) => sum + pos.x, 0) / node.explicitPositions.length;
|
||||
const y = node.explicitPositions.reduce((sum, pos) => sum + pos.y, 0) / node.explicitPositions.length;
|
||||
seed[node.canonical] = { x, y, locked: true };
|
||||
for (const g of groupsForColors) {
|
||||
const startLine = la.find((li) => (li.stations || []).some((code) => g.includes(code)));
|
||||
const endLine = lb.find((li) => (li.stations || []).some((code) => g.includes(code)));
|
||||
if (startLine && endLine) {
|
||||
const c1 = startLine.color || '#93a2b7';
|
||||
const c2 = endLine.color || '#93a2b7';
|
||||
return (startLine.id === endLine.id) ? [c1] : [c1, c2];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const occurrenceMap = {};
|
||||
for (const segment of lineSegments.segments) {
|
||||
segment.stops.forEach((canonical, stopIndex) => {
|
||||
if (!occurrenceMap[canonical]) occurrenceMap[canonical] = [];
|
||||
occurrenceMap[canonical].push({
|
||||
x: baseX + (stopIndex * gapX),
|
||||
y: baseY + (segment.lineIndex * gapY)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let fallbackIndex = 0;
|
||||
const fallbackCols = Math.max(4, Math.ceil(Math.sqrt(Math.max(nodes.length, 1))));
|
||||
for (const node of nodes) {
|
||||
if (!seed[node.canonical] && occurrenceMap[node.canonical]?.length) {
|
||||
const samples = occurrenceMap[node.canonical];
|
||||
seed[node.canonical] = {
|
||||
x: samples.reduce((sum, item) => sum + item.x, 0) / samples.length,
|
||||
y: samples.reduce((sum, item) => sum + item.y, 0) / samples.length,
|
||||
locked: false
|
||||
};
|
||||
}
|
||||
if (!seed[node.canonical]) {
|
||||
seed[node.canonical] = {
|
||||
x: baseX + ((fallbackIndex % fallbackCols) * gapX),
|
||||
y: baseY + (Math.floor(fallbackIndex / fallbackCols) * gapY),
|
||||
locked: false
|
||||
};
|
||||
fallbackIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const anchorPoints = Array.isArray(referenceMeta?.anchorPoints) ? referenceMeta.anchorPoints : [];
|
||||
if (!hasExplicitPositions && anchorPoints.length >= nodes.length * 0.6) {
|
||||
const nodeOrder = nodes
|
||||
.map((node) => ({ node, seed: seed[node.canonical] }))
|
||||
.sort((a, b) => (a.seed.y - b.seed.y) || (a.seed.x - b.seed.x));
|
||||
const anchors = anchorPoints
|
||||
.slice()
|
||||
.sort((a, b) => (a.y - b.y) || (a.x - b.x));
|
||||
const limit = Math.min(nodeOrder.length, anchors.length);
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
const { node } = nodeOrder[index];
|
||||
const anchor = anchors[index];
|
||||
seed[node.canonical] = {
|
||||
x: (seed[node.canonical].x * 0.45) + (anchor.x * 0.55),
|
||||
y: (seed[node.canonical].y * 0.45) + (anchor.y * 0.55),
|
||||
locked: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return seed;
|
||||
}
|
||||
|
||||
function optimizeLayout(nodeMap, lineSegments, referenceMeta) {
|
||||
const nodes = Array.from(nodeMap.values());
|
||||
const seed = buildSeedLayout(nodeMap, lineSegments, referenceMeta);
|
||||
const positions = {};
|
||||
const velocities = {};
|
||||
const neighbors = new Map();
|
||||
|
||||
for (const node of nodes) {
|
||||
positions[node.canonical] = {
|
||||
x: seed[node.canonical]?.x || 0,
|
||||
y: seed[node.canonical]?.y || 0
|
||||
const buildGraph = () => {
|
||||
const adj = new Map();
|
||||
const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); };
|
||||
const addEdge = (u, v, w) => {
|
||||
addNode(u);
|
||||
addNode(v);
|
||||
adj.get(u).set(v, w);
|
||||
adj.get(v).set(u, w);
|
||||
};
|
||||
velocities[node.canonical] = { x: 0, y: 0 };
|
||||
neighbors.set(node.canonical, new Set());
|
||||
}
|
||||
|
||||
for (const edge of lineSegments.edges) {
|
||||
neighbors.get(edge.from)?.add(edge.to);
|
||||
neighbors.get(edge.to)?.add(edge.from);
|
||||
}
|
||||
const validStationCodes = new Set((stations || []).map((s) => String(s.code || '')));
|
||||
|
||||
const iterations = 450;
|
||||
const repulsionBase = 45000;
|
||||
const springLength = 120;
|
||||
const springStrength = 0.015;
|
||||
const bendStrength = 0.025;
|
||||
const anchorStrength = 0.02;
|
||||
const damping = 0.82;
|
||||
(fares || []).forEach((f) => {
|
||||
const a = String(f.from || '');
|
||||
const b = String(f.to || '');
|
||||
if (!a || !b) return;
|
||||
if (!validStationCodes.has(a) || !validStationCodes.has(b)) return;
|
||||
|
||||
for (let iteration = 0; iteration < iterations; iteration += 1) {
|
||||
const alpha = 1 - (iteration / iterations);
|
||||
const forces = {};
|
||||
for (const node of nodes) forces[node.canonical] = { x: 0, y: 0 };
|
||||
const w = Number(f.cost_regular ?? f.cost) || 0;
|
||||
addEdge(a, b, w);
|
||||
});
|
||||
|
||||
// 1. Repulsion to untangle graph
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
const a = nodes[i];
|
||||
const pa = positions[a.canonical];
|
||||
for (let j = i + 1; j < nodes.length; j += 1) {
|
||||
const b = nodes[j];
|
||||
const pb = positions[b.canonical];
|
||||
let dx = pa.x - pb.x;
|
||||
let dy = pa.y - pb.y;
|
||||
let distanceSq = (dx * dx) + (dy * dy);
|
||||
if (distanceSq < 1) {
|
||||
dx = 0.5 - Math.random();
|
||||
dy = 0.5 - Math.random();
|
||||
distanceSq = (dx * dx) + (dy * dy);
|
||||
if (Array.isArray(transfers)) {
|
||||
transfers.forEach((p) => {
|
||||
const u = String(p[0] || '');
|
||||
const v = String(p[1] || '');
|
||||
if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) {
|
||||
addEdge(u, v, 0);
|
||||
}
|
||||
const distance = Math.sqrt(distanceSq);
|
||||
const scale = (repulsionBase * alpha) / distanceSq;
|
||||
const fx = (dx / distance) * scale;
|
||||
const fy = (dy / distance) * scale;
|
||||
forces[a.canonical].x += fx;
|
||||
forces[a.canonical].y += fy;
|
||||
forces[b.canonical].x -= fx;
|
||||
forces[b.canonical].y -= fy;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Spring forces for edges & bend smoothing
|
||||
for (const segment of lineSegments.segments) {
|
||||
for (let index = 0; index < segment.stops.length - 1; index += 1) {
|
||||
const from = segment.stops[index];
|
||||
const to = segment.stops[index + 1];
|
||||
const pa = positions[from];
|
||||
const pb = positions[to];
|
||||
let dx = pb.x - pa.x;
|
||||
let dy = pb.y - pa.y;
|
||||
let distance = Math.sqrt((dx * dx) + (dy * dy));
|
||||
if (distance < 1) distance = 1;
|
||||
const delta = distance - springLength;
|
||||
const force = delta * springStrength;
|
||||
const fx = (dx / distance) * force;
|
||||
const fy = (dy / distance) * force;
|
||||
forces[from].x += fx;
|
||||
forces[from].y += fy;
|
||||
forces[to].x -= fx;
|
||||
forces[to].y -= fy;
|
||||
}
|
||||
for (let index = 1; index < segment.stops.length - 1; index += 1) {
|
||||
const prev = positions[segment.stops[index - 1]];
|
||||
const current = positions[segment.stops[index]];
|
||||
const next = positions[segment.stops[index + 1]];
|
||||
const targetX = (prev.x + next.x) / 2;
|
||||
const targetY = (prev.y + next.y) / 2;
|
||||
forces[segment.stops[index]].x += (targetX - current.x) * bendStrength;
|
||||
forces[segment.stops[index]].y += (targetY - current.y) * bendStrength;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Orthogonal snapping force (Octilinear bias)
|
||||
const orthoStrength = 0.03 * alpha;
|
||||
for (const edge of lineSegments.edges) {
|
||||
const pa = positions[edge.from];
|
||||
const pb = positions[edge.to];
|
||||
const dx = pb.x - pa.x;
|
||||
const dy = pb.y - pa.y;
|
||||
const adx = Math.abs(dx);
|
||||
const ady = Math.abs(dy);
|
||||
|
||||
if (adx < ady && adx < ady * 0.4) {
|
||||
// pull towards vertical
|
||||
forces[edge.from].x += dx * orthoStrength;
|
||||
forces[edge.to].x -= dx * orthoStrength;
|
||||
} else if (ady < adx && ady < adx * 0.4) {
|
||||
// pull towards horizontal
|
||||
forces[edge.from].y += dy * orthoStrength;
|
||||
forces[edge.to].y -= dy * orthoStrength;
|
||||
} else {
|
||||
// pull towards diagonal (45 degrees)
|
||||
const avg = (adx + ady) / 2;
|
||||
const targetDx = avg * Math.sign(dx || 1);
|
||||
const targetDy = avg * Math.sign(dy || 1);
|
||||
forces[edge.from].x += (dx - targetDx) * orthoStrength;
|
||||
forces[edge.to].x -= (dx - targetDx) * orthoStrength;
|
||||
forces[edge.from].y += (dy - targetDy) * orthoStrength;
|
||||
forces[edge.to].y -= (dy - targetDy) * orthoStrength;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Anchor forces
|
||||
for (const node of nodes) {
|
||||
const anchor = seed[node.canonical];
|
||||
if (!anchor) continue;
|
||||
const strength = anchor.locked ? 0.2 : anchorStrength;
|
||||
forces[node.canonical].x += (anchor.x - positions[node.canonical].x) * strength;
|
||||
forces[node.canonical].y += (anchor.y - positions[node.canonical].y) * strength;
|
||||
}
|
||||
|
||||
// 5. Update velocities and positions
|
||||
for (const node of nodes) {
|
||||
const velocity = velocities[node.canonical];
|
||||
const force = forces[node.canonical];
|
||||
velocity.x = (velocity.x + (force.x * 0.6)) * damping;
|
||||
velocity.y = (velocity.y + (force.y * 0.6)) * damping;
|
||||
positions[node.canonical].x += velocity.x;
|
||||
positions[node.canonical].y += velocity.y;
|
||||
}
|
||||
}
|
||||
|
||||
// Final alignment to Grid to ensure perfect octilinear rendering
|
||||
const xs = nodes.map((node) => positions[node.canonical].x);
|
||||
const ys = nodes.map((node) => positions[node.canonical].y);
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys);
|
||||
const maxY = Math.max(...ys);
|
||||
const sourceWidth = Math.max(maxX - minX, 1);
|
||||
const sourceHeight = Math.max(maxY - minY, 1);
|
||||
const targetWidth = Math.max((referenceMeta?.width || DEFAULT_VIEWPORT.width) - 260, 640);
|
||||
const targetHeight = Math.max((referenceMeta?.height || DEFAULT_VIEWPORT.height) - 220, 420);
|
||||
const scale = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight);
|
||||
const offsetX = 130;
|
||||
const offsetY = 110;
|
||||
|
||||
const GRID_SIZE = 24;
|
||||
|
||||
for (const node of nodes) {
|
||||
const point = positions[node.canonical];
|
||||
let rawX = ((point.x - minX) * scale) + offsetX;
|
||||
let rawY = ((point.y - minY) * scale) + offsetY;
|
||||
|
||||
// Snap to grid
|
||||
point.x = Math.round(rawX / GRID_SIZE) * GRID_SIZE;
|
||||
point.y = Math.round(rawY / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function buildLinePath(points) {
|
||||
if (!Array.isArray(points) || points.length === 0) return '';
|
||||
let d = `M ${points[0].x} ${points[0].y}`;
|
||||
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const p1 = points[index - 1];
|
||||
const p2 = points[index];
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const adx = Math.abs(dx);
|
||||
const ady = Math.abs(dy);
|
||||
|
||||
if (adx === 0 || ady === 0 || Math.abs(adx - ady) < 2) {
|
||||
// Already orthogonal or 45-degree diagonal
|
||||
d += ` L ${p2.x} ${p2.y}`;
|
||||
} else {
|
||||
// Need an octilinear chamfer (a single bend)
|
||||
const signX = Math.sign(dx);
|
||||
const signY = Math.sign(dy);
|
||||
|
||||
if (adx > ady) {
|
||||
// Horizontal is longer: straight horizontal, then 45-degree
|
||||
const hDist = adx - ady;
|
||||
const midX = p1.x + hDist * signX;
|
||||
const midY = p1.y;
|
||||
d += ` L ${midX} ${midY} L ${p2.x} ${p2.y}`;
|
||||
} else {
|
||||
// Vertical is longer: straight vertical, then 45-degree
|
||||
const vDist = ady - adx;
|
||||
const midX = p1.x;
|
||||
const midY = p1.y + vDist * signY;
|
||||
d += ` L ${midX} ${midY} L ${p2.x} ${p2.y}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function getContrastColor(hex) {
|
||||
if (!hex) return '#ffffff';
|
||||
let value = String(hex).replace('#', '');
|
||||
if (value.length === 3) value = value.split('').map((item) => item + item).join('');
|
||||
const r = parseInt(value.slice(0, 2), 16) || 0;
|
||||
const g = parseInt(value.slice(2, 4), 16) || 0;
|
||||
const b = parseInt(value.slice(4, 6), 16) || 0;
|
||||
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
||||
return yiq >= 140 ? '#0f172a' : '#ffffff';
|
||||
}
|
||||
|
||||
function piePath(cx, cy, r, a0, a1) {
|
||||
const x0 = cx + (r * Math.cos(a0));
|
||||
const y0 = cy + (r * Math.sin(a0));
|
||||
const x1 = cx + (r * Math.cos(a1));
|
||||
const y1 = cy + (r * Math.sin(a1));
|
||||
const largeArc = (a1 - a0) > Math.PI ? 1 : 0;
|
||||
return `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${largeArc} 1 ${x1} ${y1} Z`;
|
||||
}
|
||||
|
||||
function pieSvg(cx, cy, r, colors) {
|
||||
const palette = Array.from(new Set((Array.isArray(colors) ? colors : []).filter(Boolean))).slice(0, 4);
|
||||
if (palette.length === 0) return '';
|
||||
if (palette.length === 1) return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${escapeHTML(palette[0])}" />`;
|
||||
const step = (Math.PI * 2) / palette.length;
|
||||
let output = '';
|
||||
for (let index = 0; index < palette.length; index += 1) {
|
||||
const a0 = (-Math.PI / 2) + (index * step);
|
||||
const a1 = a0 + step;
|
||||
output += `<path d="${piePath(cx, cy, r, a0, a1)}" fill="${escapeHTML(palette[index])}" />`;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderStation(node, point, lineColorMap) {
|
||||
const colors = Array.from(node.lines)
|
||||
.map((lineId) => lineColorMap.get(lineId) || DEFAULT_LINE_COLOR)
|
||||
.filter(Boolean);
|
||||
const uniqueColors = Array.from(new Set(colors));
|
||||
const isTransfer = node.isTransfer || uniqueColors.length >= 2 || node.codes.length >= 2;
|
||||
const code = escapeHTML(node.primaryCode);
|
||||
const name = escapeHTML(node.name || node.primaryCode);
|
||||
const enName = escapeHTML(node.enName || '');
|
||||
const x = point.x;
|
||||
const y = point.y;
|
||||
const codeFontSize = 9;
|
||||
const nameFontSize = 13;
|
||||
const enFontSize = 8.5;
|
||||
const codeWidth = Math.max((node.primaryCode || '').length * 6 + 10, 28);
|
||||
const preferTop = isTransfer;
|
||||
|
||||
let markerSvg = '';
|
||||
if (isTransfer) {
|
||||
markerSvg += `<circle cx="${x}" cy="${y}" r="15" fill="#ffffff" stroke="#1f2937" stroke-width="2.5" />`;
|
||||
markerSvg += pieSvg(x, y, 11.5, uniqueColors);
|
||||
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="6.5" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
|
||||
} else {
|
||||
const color = uniqueColors[0] || DEFAULT_LINE_COLOR;
|
||||
markerSvg += `<circle cx="${x}" cy="${y}" r="12.5" fill="#ffffff" stroke="${escapeHTML(color)}" stroke-width="5" />`;
|
||||
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="7" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
|
||||
}
|
||||
|
||||
let textSvg = '';
|
||||
if (preferTop) {
|
||||
const baseY = y - 14;
|
||||
const enY = baseY;
|
||||
const nameY = enName ? (baseY - 12) : baseY;
|
||||
const codeY = nameY - 16;
|
||||
textSvg += `<rect x="${x - (codeWidth / 2)}" y="${codeY - 8}" width="${codeWidth}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
|
||||
textSvg += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${code}</text>`;
|
||||
textSvg += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="4" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
|
||||
if (enName) {
|
||||
textSvg += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
|
||||
}
|
||||
} else {
|
||||
const topY = y + 24;
|
||||
const codeY = topY;
|
||||
const nameY = topY + 16;
|
||||
const enY = nameY + 12;
|
||||
textSvg += `<rect x="${x - (codeWidth / 2)}" y="${codeY - 8}" width="${codeWidth}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
|
||||
textSvg += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${code}</text>`;
|
||||
textSvg += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="4" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
|
||||
if (enName) {
|
||||
textSvg += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<g class="map-station ${isTransfer ? 'transfer' : ''}" data-code="${code}" data-canonical="${escapeHTML(node.canonical)}" onclick="handleStationClick('${code}')" style="cursor:pointer">
|
||||
<rect x="${x - 22}" y="${y - 22}" width="44" height="44" fill="transparent" />
|
||||
${markerSvg}
|
||||
${textSvg}
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLineLabels(segment, points) {
|
||||
if (!Array.isArray(points) || points.length < 2) return '';
|
||||
const color = segment.color || DEFAULT_LINE_COLOR;
|
||||
const textColor = getContrastColor(color);
|
||||
const subTextColor = (textColor === '#ffffff') ? 'rgba(255,255,255,0.9)' : 'rgba(15,23,42,0.8)';
|
||||
const labelWidth = Math.max(String(segment.name || '').length * 14 + 20, String(segment.enName || '').length * 7 + 20, 80);
|
||||
const height = segment.enName ? 36 : 24;
|
||||
const build = (point, alignLeft) => {
|
||||
const x = alignLeft ? point.x - 54 - labelWidth : point.x + 54;
|
||||
const y = point.y - (height / 2);
|
||||
const textX = x + (labelWidth / 2);
|
||||
let output = `<rect x="${x}" y="${y}" width="${labelWidth}" height="${height}" rx="6" fill="${escapeHTML(color)}" />`;
|
||||
if (segment.enName) {
|
||||
output += `<text x="${textX}" y="${y + 15}" text-anchor="middle" fill="${textColor}" font-size="12" font-weight="bold">${escapeHTML(segment.name)}</text>`;
|
||||
output += `<text x="${textX}" y="${y + 28}" text-anchor="middle" fill="${subTextColor}" font-size="9" font-weight="600">${escapeHTML(segment.enName)}</text>`;
|
||||
} else {
|
||||
output += `<text x="${textX}" y="${y + 16}" text-anchor="middle" fill="${textColor}" font-size="12" font-weight="bold">${escapeHTML(segment.name)}</text>`;
|
||||
}
|
||||
return output;
|
||||
(stations || []).forEach((s) => {
|
||||
const c = String(s.code || '');
|
||||
if (c) addNode(c);
|
||||
});
|
||||
return adj;
|
||||
};
|
||||
|
||||
return build(points[0], true) + build(points[points.length - 1], false);
|
||||
}
|
||||
const adj = buildGraph();
|
||||
const codes = Array.from(adj.keys()).sort((a, b) => a.localeCompare(b));
|
||||
const neighborsOf = (u) => adj.get(u) || new Map();
|
||||
|
||||
function calculateBounds(positions) {
|
||||
const items = Object.values(positions || {});
|
||||
if (!items.length) return { minX: 0, minY: 0, maxX: DEFAULT_VIEWPORT.width, maxY: DEFAULT_VIEWPORT.height };
|
||||
return {
|
||||
minX: Math.min(...items.map((item) => item.x)),
|
||||
minY: Math.min(...items.map((item) => item.y)),
|
||||
maxX: Math.max(...items.map((item) => item.x)),
|
||||
maxY: Math.max(...items.map((item) => item.y))
|
||||
const dijkstra = (src) => {
|
||||
const dist = {};
|
||||
const visited = new Set();
|
||||
for (const n of adj.keys()) dist[n] = Infinity;
|
||||
dist[src] = 0;
|
||||
|
||||
while (true) {
|
||||
let u = null;
|
||||
let best = Infinity;
|
||||
for (const n of adj.keys()) {
|
||||
if (!visited.has(n) && dist[n] < best) {
|
||||
best = dist[n];
|
||||
u = n;
|
||||
}
|
||||
}
|
||||
if (u === null) break;
|
||||
visited.add(u);
|
||||
for (const [v, w] of neighborsOf(u)) {
|
||||
const nd = dist[u] + Number(w || 0);
|
||||
if (nd < dist[v]) dist[v] = nd;
|
||||
}
|
||||
}
|
||||
|
||||
return dist;
|
||||
};
|
||||
}
|
||||
|
||||
function generate(stations, lines, fares, nameByCode, transfers) {
|
||||
const referenceMeta = parseReferenceSvgMeta();
|
||||
const transferGroups = buildTransferGroups(stations, transfers);
|
||||
const nodeMap = buildNodes(stations, nameByCode || {}, transferGroups);
|
||||
const lineSegments = buildLineSegments(lines, transferGroups, nodeMap);
|
||||
const distAll = {};
|
||||
codes.forEach((c) => {
|
||||
distAll[c] = dijkstra(c);
|
||||
});
|
||||
|
||||
for (const node of nodeMap.values()) {
|
||||
node.codes = Array.from(new Set(node.codes)).sort((a, b) => a.localeCompare(b));
|
||||
node.primaryCode = node.codes[0] || node.primaryCode;
|
||||
node.isTransfer = node.codes.length >= 2 || node.lines.size >= 2;
|
||||
let maxFare = 1;
|
||||
for (const r of codes) {
|
||||
for (const c of codes) {
|
||||
if (r === c) continue;
|
||||
const v = Number(distAll[r][c] || 0);
|
||||
if (v > maxFare) maxFare = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (!nodeMap.size) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${DEFAULT_VIEWPORT.width}" height="${DEFAULT_VIEWPORT.height}" viewBox="0 0 ${DEFAULT_VIEWPORT.width} ${DEFAULT_VIEWPORT.height}" style="background:#ffffff"><text x="80" y="120" fill="#64748b" font-size="18" font-family="Inter, system-ui, sans-serif">暂无可显示的站点数据</text></svg>`;
|
||||
}
|
||||
|
||||
const positions = optimizeLayout(nodeMap, lineSegments, referenceMeta);
|
||||
const bounds = calculateBounds(positions);
|
||||
const width = Math.max(referenceMeta.width || DEFAULT_VIEWPORT.width, Math.ceil(bounds.maxX + 180));
|
||||
const height = Math.max(referenceMeta.height || DEFAULT_VIEWPORT.height, Math.ceil(bounds.maxY + 160));
|
||||
const lineColorMap = new Map();
|
||||
(lineSegments.segments || []).forEach((segment) => lineColorMap.set(segment.id, segment.color || DEFAULT_LINE_COLOR));
|
||||
const margin = 8;
|
||||
const cellW = 56;
|
||||
const cellH = 40;
|
||||
const estimateWidth = (text, size) => Math.ceil(String(text || '').length * (size <= 10 ? 7 : 10));
|
||||
const maxCnW = Math.max(1, ...codes.map((c) => estimateWidth(nameByCode[c] || c, 14)));
|
||||
const maxEnW = Math.max(1, ...codes.map((c) => estimateWidth(enNameByCode[c] || '', 10)));
|
||||
const maxCodeW = Math.max(1, ...codes.map((c) => estimateWidth(c, 10)));
|
||||
const leftW = Math.max(140, 24 + Math.max(maxCnW, maxEnW, maxCodeW));
|
||||
const topH = 64;
|
||||
const n = codes.length;
|
||||
const width = margin + leftW + n * cellW + margin;
|
||||
const height = margin + topH + n * cellH + margin;
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:#ffffff;font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif">`;
|
||||
svg += `<metadata>generated-by=svg-generator; mode=network-map; reference-svg=${escapeHTML(path.basename(REFERENCE_SVG_PATH))}; stations=${nodeMap.size}; lineSegments=${lineSegments.segments.length}; fares=${Array.isArray(fares) ? fares.length : 0}</metadata>`;
|
||||
svg += `<rect width="${width}" height="${height}" fill="#ffffff" />`;
|
||||
|
||||
for (const segment of lineSegments.segments) {
|
||||
const points = segment.stops.map((canonical) => positions[canonical]).filter(Boolean);
|
||||
if (points.length < 2) continue;
|
||||
const d = buildLinePath(points);
|
||||
svg += `<path d="${d}" fill="none" stroke="${escapeHTML(segment.color || DEFAULT_LINE_COLOR)}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
|
||||
svg += renderLineLabels(segment, points);
|
||||
for (let j = 0; j < n; j++) {
|
||||
const code = codes[j];
|
||||
const name = nameByCode[code] || code;
|
||||
const en = enNameByCode[code] || '';
|
||||
const x = margin + leftW + j * cellW + cellW / 2;
|
||||
const y = margin + 20;
|
||||
svg += `<text x="${x}" y="${y}" font-size="14" font-weight="900" fill="#000000" text-anchor="middle">${escapeHTML(name)}</text>`;
|
||||
if (en) svg += `<text x="${x}" y="${y + 14}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(en)}</text>`;
|
||||
svg += `<text x="${x}" y="${y + 26}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(code)}</text>`;
|
||||
}
|
||||
|
||||
const orderedNodes = Array.from(nodeMap.values()).sort((a, b) => {
|
||||
const pa = positions[a.canonical];
|
||||
const pb = positions[b.canonical];
|
||||
return (pa.x - pb.x) || (pa.y - pb.y);
|
||||
});
|
||||
for (let i = 0; i < n; i++) {
|
||||
const code = codes[i];
|
||||
const name = nameByCode[code] || code;
|
||||
const en = enNameByCode[code] || '';
|
||||
const xText = margin + 6;
|
||||
const baseY = margin + topH + i * cellH + 22;
|
||||
svg += `<text x="${xText}" y="${baseY - 10}" font-size="14" font-weight="900" fill="#000000">${escapeHTML(name)}</text>`;
|
||||
if (en) svg += `<text x="${xText}" y="${baseY + 4}" font-size="10" fill="#000000">${escapeHTML(en)}</text>`;
|
||||
svg += `<text x="${xText}" y="${baseY + 16}" font-size="10" fill="#000000">${escapeHTML(code)}</text>`;
|
||||
|
||||
for (const node of orderedNodes) {
|
||||
const point = positions[node.canonical];
|
||||
if (!point) continue;
|
||||
svg += renderStation(node, point, lineColorMap);
|
||||
for (let j = 0; j < n; j++) {
|
||||
const x0 = margin + leftW + j * cellW;
|
||||
const y0 = margin + topH + i * cellH;
|
||||
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#ffffff" stroke="#e5e7eb"/>`;
|
||||
if (i === j) {
|
||||
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" fill="#6b7280" text-anchor="middle">-</text>`;
|
||||
} else {
|
||||
const distVal = distAll[code][codes[j]];
|
||||
if (distVal === Infinity || !Number.isFinite(distVal)) {
|
||||
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="14" font-weight="800" fill="#ef4444" text-anchor="middle">×</text>`;
|
||||
} else {
|
||||
const val = Math.round(Number(distVal || 0));
|
||||
const routeColors = colorsForRoute(code, codes[j]);
|
||||
|
||||
if (routeColors.length > 0) {
|
||||
const stripW = cellW / routeColors.length;
|
||||
for (let k = 0; k < routeColors.length; k++) {
|
||||
const c = routeColors[k] || '#93a2b7';
|
||||
const stripX = x0 + k * stripW;
|
||||
svg += `<rect x="${stripX}" y="${y0}" width="${stripW}" height="${cellH}" fill="${escapeHTML(c)}" fill-opacity="0.15" stroke="none"/>`;
|
||||
}
|
||||
} else {
|
||||
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#000000" fill-opacity="0.03" stroke="none"/>`;
|
||||
}
|
||||
|
||||
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>`;
|
||||
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" font-weight="800" fill="#000000" text-anchor="middle">${escapeHTML(String(val))}</text>`;
|
||||
|
||||
const dots = routeColors.slice(0, 4);
|
||||
for (let k = 0; k < dots.length; k++) {
|
||||
const cx = x0 + 8 + k * 10;
|
||||
const cy = y0 + 10;
|
||||
const dotColor = dots[k] || '#93a2b7';
|
||||
svg += `<circle cx="${cx}" cy="${cy}" r="3.5" fill="${escapeHTML(dotColor)}" stroke="white" stroke-width="1"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg += `</svg>`;
|
||||
|
||||
+425
-123
@@ -1,6 +1,6 @@
|
||||
local CURRENT_STATION_CODE = 'Ticket-Machine'
|
||||
local API_BASE = 'https://ticket.fse-media.group/api'
|
||||
local VERSION = 'v1.5.22'
|
||||
local VERSION = 'v1.5.30'
|
||||
local UPDATE_CHECK_INTERVAL = 5
|
||||
|
||||
-- ###########################
|
||||
@@ -327,6 +327,19 @@ local function enqueueTicketUpload(ticketData)
|
||||
return false
|
||||
end
|
||||
|
||||
local function reportIssue(errMsg, detail)
|
||||
local url = API_BASE .. '/report_issue'
|
||||
local payload = {
|
||||
device = currentDeviceId(),
|
||||
machine_id = currentDeviceId(),
|
||||
station_code = state.stationCode or CURRENT_STATION_CODE,
|
||||
error_msg = tostring(errMsg or ''),
|
||||
detail = tostring(detail or ''),
|
||||
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
|
||||
}
|
||||
postJSONResult(url, payload)
|
||||
end
|
||||
|
||||
-- ###########################
|
||||
-- Peripheral discovery
|
||||
-- ###########################
|
||||
@@ -562,10 +575,6 @@ local function issueTicketFromPeripheral(fromNameEnArg, toNameEnArg, apiType, ri
|
||||
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
|
||||
@@ -582,25 +591,107 @@ local function issueTicketFromPeripheral(fromNameEnArg, toNameEnArg, apiType, ri
|
||||
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'
|
||||
local fallbackId = normalizeIssuedTicketId(fallbackTicketId)
|
||||
local typeCandidates = { tostring(apiType or 'local') }
|
||||
if apiType == 'limited_express' then
|
||||
table.insert(typeCandidates, 'Express')
|
||||
table.insert(typeCandidates, 'Limited Express')
|
||||
elseif apiType == 'single' then
|
||||
table.insert(typeCandidates, 'Single')
|
||||
else
|
||||
table.insert(typeCandidates, 'Local')
|
||||
table.insert(typeCandidates, 'Ordinary')
|
||||
end
|
||||
|
||||
local okIssue, ticketId, issueErr = tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
|
||||
if okIssue then
|
||||
return true, ticketId, 'issueTicket'
|
||||
local function buildIssuePayload(typeValue)
|
||||
return {
|
||||
ticket_id = fallbackId,
|
||||
id = fallbackId,
|
||||
start_station = tostring(startStationArg or ''),
|
||||
terminal_station = tostring(terminalStationArg or ''),
|
||||
start_station_id = tostring(startStationArg or ''),
|
||||
terminal_station_id = tostring(terminalStationArg or ''),
|
||||
start_name_en = tostring(fromNameEnArg or ''),
|
||||
terminal_name_en = tostring(toNameEnArg or ''),
|
||||
start_name = tostring(fromNameCnUArg or ''),
|
||||
terminal_name = tostring(toNameCnUArg or ''),
|
||||
fromNameCnU = tostring(fromNameCnUArg or ''),
|
||||
toNameCnU = tostring(toNameCnUArg or ''),
|
||||
rides = tonumber(rides) or 1,
|
||||
trips = tonumber(rides) or 1,
|
||||
trips_total = tonumber(rides) or 1,
|
||||
trips_remaining = tonumber(rides) or 1,
|
||||
cost = tonumber(cost) or 0,
|
||||
train_type = tostring(apiType or 'local'),
|
||||
type = tostring(typeValue or apiType or 'local')
|
||||
}
|
||||
end
|
||||
|
||||
return tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
|
||||
local variants = {}
|
||||
local seenVariantKey = {}
|
||||
local function addVariant(methodName, args)
|
||||
local parts = { methodName }
|
||||
for i = 1, #args do
|
||||
parts[#parts + 1] = tostring(args[i])
|
||||
end
|
||||
local key = table.concat(parts, '\31')
|
||||
if not seenVariantKey[key] then
|
||||
seenVariantKey[key] = true
|
||||
variants[#variants + 1] = { method = methodName, args = args }
|
||||
end
|
||||
end
|
||||
|
||||
for _, typeValue in ipairs(typeCandidates) do
|
||||
local payload = buildIssuePayload(typeValue)
|
||||
addVariant('issueTicket', {
|
||||
tostring(fromNameEnArg or ''),
|
||||
tostring(toNameEnArg or ''),
|
||||
typeValue,
|
||||
tonumber(rides) or 1,
|
||||
tonumber(cost) or 0,
|
||||
tostring(startStationArg or ''),
|
||||
tostring(terminalStationArg or ''),
|
||||
tostring(fromNameCnUArg or ''),
|
||||
tostring(toNameCnUArg or '')
|
||||
})
|
||||
addVariant('issueTicket', {
|
||||
tostring(fromNameEnArg or ''),
|
||||
tostring(toNameEnArg or ''),
|
||||
typeValue,
|
||||
tonumber(rides) or 1,
|
||||
tonumber(cost) or 0,
|
||||
tostring(startStationArg or ''),
|
||||
tostring(terminalStationArg or '')
|
||||
})
|
||||
addVariant('issueTicketData', { payload })
|
||||
addVariant('writeTicketData', { payload })
|
||||
end
|
||||
|
||||
local lastError = 'unsupported_method'
|
||||
for _, variant in ipairs(variants) do
|
||||
local fn = dev[variant.method]
|
||||
if type(fn) == 'function' then
|
||||
local okCall, r1, r2, r3 = pcall(fn, table.unpack(variant.args))
|
||||
if okCall and peripheralCallSucceeded(r1) then
|
||||
local payloadArg = type(variant.args[1]) == 'table' and variant.args[1] or nil
|
||||
local issuedId = extractPeripheralId(r2, r3, r1, payloadArg and payloadArg.ticket_id, payloadArg and payloadArg.id, fallbackId)
|
||||
local normalizedId = normalizeIssuedTicketId(issuedId)
|
||||
if #normalizedId > 0 then
|
||||
return true, normalizedId, variant.method
|
||||
end
|
||||
lastError = 'invalid_ticket_id'
|
||||
else
|
||||
local detail = extractPeripheralId(r1, r2, r3)
|
||||
if okCall then
|
||||
lastError = variant.method .. ':' .. tostring(detail ~= '' and detail or 'issue_failed')
|
||||
else
|
||||
lastError = variant.method .. ':' .. tostring(r1 or 'issue_call_failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return false, '', lastError
|
||||
end
|
||||
|
||||
-- ###########################
|
||||
@@ -990,6 +1081,16 @@ local function nearestCCColor(val)
|
||||
return colors.gray
|
||||
end
|
||||
|
||||
local function contrastTextColor(bg)
|
||||
for _, c in ipairs(CC_PALETTE) do
|
||||
if c.val == bg then
|
||||
local yiq = (c.rgb[1] * 299 + c.rgb[2] * 587 + c.rgb[3] * 114) / 1000
|
||||
return yiq >= 140 and colors.black or colors.white
|
||||
end
|
||||
end
|
||||
return colors.white
|
||||
end
|
||||
|
||||
local function clear()
|
||||
termDev.setBackgroundColor(colors.black)
|
||||
termDev.clear()
|
||||
@@ -1024,6 +1125,31 @@ local function addButton(x, y, label, wBtn, hBtn, colorsPair, onClick)
|
||||
termDev.write(label)
|
||||
end
|
||||
|
||||
local function drawVerticalScrollbar(sbX, startY, endY, offset, maxOffset, step, onUp, onDown)
|
||||
addButton(sbX, startY - 1, '^', 3, 1, {colors.black, colors.white}, onUp)
|
||||
addButton(sbX, endY + 1, 'v', 3, 1, {colors.black, colors.white}, onDown)
|
||||
for py = startY, endY do
|
||||
termDev.setBackgroundColor(colors.black)
|
||||
termDev.setCursorPos(sbX, py)
|
||||
termDev.write(' ')
|
||||
termDev.setBackgroundColor(colors.gray)
|
||||
termDev.setCursorPos(sbX, py)
|
||||
termDev.write(' ')
|
||||
end
|
||||
if maxOffset <= 0 then
|
||||
return
|
||||
end
|
||||
local railH = endY - startY + 1
|
||||
local thumbH = math.max(2, math.floor((railH * railH) / (railH + maxOffset)))
|
||||
if thumbH > railH then thumbH = railH end
|
||||
local thumbY = startY + math.floor((railH - thumbH) * (offset / maxOffset))
|
||||
for py = thumbY, thumbY + thumbH - 1 do
|
||||
termDev.setBackgroundColor(colors.lightGray)
|
||||
termDev.setCursorPos(sbX, py)
|
||||
termDev.write(' ')
|
||||
end
|
||||
end
|
||||
|
||||
local function inRect(btn, px, py)
|
||||
return px >= btn.x and px <= (btn.x + btn.w - 1) and py >= btn.y and py <= (btn.y + btn.h - 1)
|
||||
end
|
||||
@@ -1055,7 +1181,7 @@ state = {
|
||||
departure = nil, terminal = nil, trainType = nil, trips = 1, cost = 0, paid = 0, doneAudioPlayed = false,
|
||||
productMode = 'ticket', cardMode = nil, cardPaymentMode = 'local',
|
||||
holderName = '', cardDeposit = 0, cardTopup = 0, cardBalance = 0, cardOrderValue = 0,
|
||||
card_id = nil, card_server_data = nil, pendingBlankCardId = nil
|
||||
card_id = nil, card_server_data = nil, pendingBlankCardId = nil, cardIssueWarning = nil
|
||||
}
|
||||
|
||||
local function getCardConfig()
|
||||
@@ -1090,6 +1216,7 @@ local function resetTicketFlow()
|
||||
state.card_id = nil
|
||||
state.card_server_data = nil
|
||||
state.pendingBlankCardId = nil
|
||||
state.cardIssueWarning = nil
|
||||
end
|
||||
|
||||
local function resetCardFlow(mode)
|
||||
@@ -1109,6 +1236,7 @@ local function resetCardFlow(mode)
|
||||
state.card_id = nil
|
||||
state.card_server_data = nil
|
||||
state.pendingBlankCardId = nil
|
||||
state.cardIssueWarning = nil
|
||||
end
|
||||
|
||||
local function isCardOrderLike(data)
|
||||
@@ -1208,14 +1336,22 @@ local function renderConfirmCancel()
|
||||
addButton(bx+4+bw, by+4, 'NO', bw, 1, {colors.green, colors.white}, function() ui_cancel_confirmed = false; ui_cancel_request = false end)
|
||||
end
|
||||
|
||||
local function showAlert(msg)
|
||||
local function showAlert(msg, detail)
|
||||
if detail then
|
||||
reportIssue(msg, detail)
|
||||
end
|
||||
local boxW, boxH = math.max(24, math.min(32, w-4)), 6
|
||||
local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1
|
||||
termDev.setBackgroundColor(colors.gray)
|
||||
for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end
|
||||
msg = tostring(msg or '')
|
||||
if #msg > boxW-4 then msg = msg:sub(1, boxW-4) end
|
||||
termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(msg)
|
||||
local displayMsg = tostring(msg or '')
|
||||
if #displayMsg > boxW-4 then displayMsg = displayMsg:sub(1, boxW-4) end
|
||||
termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(displayMsg)
|
||||
if detail then
|
||||
local displayDetail = tostring(detail)
|
||||
if #displayDetail > boxW-4 then displayDetail = displayDetail:sub(1, boxW-4) end
|
||||
termDev.setCursorPos(bx+2, by+3); termDev.write(displayDetail)
|
||||
end
|
||||
Buttons = {}
|
||||
addButton(bx+math.floor((boxW-6)/2), by+4, 'OK', 6, 1, {colors.green, colors.white}, function() end)
|
||||
waitButtons()
|
||||
@@ -1282,8 +1418,12 @@ local function showSingleSegmentSelection()
|
||||
local startIdx = singleSegmentScroll * itemsPerPage + 1
|
||||
local endIdx = math.min(startIdx + itemsPerPage - 1, #singleSegments)
|
||||
|
||||
local bw = math.floor((w - 6) / 2)
|
||||
local bx1, bx2 = 2, w - bw - 1
|
||||
local sbX = w - 3
|
||||
local contentRight = sbX - 2
|
||||
local colGap = 2
|
||||
local bw = math.floor((contentRight - 1 - colGap) / 2)
|
||||
if bw < 8 then bw = 8 end
|
||||
local bx1, bx2 = 2, 2 + bw + colGap
|
||||
local by = 5
|
||||
for i = startIdx, endIdx do
|
||||
local seg = singleSegments[i]
|
||||
@@ -1292,20 +1432,28 @@ local function showSingleSegmentSelection()
|
||||
local x = colIdx == 0 and bx1 or bx2
|
||||
local y = by + rowIdx * 4
|
||||
|
||||
local label = seg.lineName .. ' cogs:' .. tostring(seg.cost)
|
||||
if #label > bw then label = label:sub(1, bw) end
|
||||
local lineColor = nearestCCColor(seg.color)
|
||||
local textColor = contrastTextColor(lineColor)
|
||||
local isSelected = (state.singleSegment == seg)
|
||||
addButton(x, y, label, bw, 3, {isSelected and colors.green or colors.blue, colors.white}, function()
|
||||
local bg = isSelected and colors.green or lineColor
|
||||
local fg = isSelected and colors.white or textColor
|
||||
local label = seg.lineName .. ' ' .. tostring(seg.cost) .. 'c'
|
||||
if #label > bw then label = label:sub(1, bw) end
|
||||
addButton(x, y, label, bw, 3, {bg, fg}, function()
|
||||
state.singleSegment = seg
|
||||
end)
|
||||
end
|
||||
|
||||
if singleSegmentScroll > 0 then
|
||||
addButton(w-8, 2, 'UP', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll - 1 end)
|
||||
end
|
||||
if singleSegmentScroll < maxScroll then
|
||||
addButton(w-8, h-5, 'DN', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll + 1 end)
|
||||
end
|
||||
drawVerticalScrollbar(
|
||||
sbX,
|
||||
by,
|
||||
h - 6,
|
||||
singleSegmentScroll * itemsPerPage,
|
||||
maxScroll * itemsPerPage,
|
||||
itemsPerPage,
|
||||
function() singleSegmentScroll = math.max(0, singleSegmentScroll - 1) end,
|
||||
function() singleSegmentScroll = math.min(maxScroll, singleSegmentScroll + 1) end
|
||||
)
|
||||
|
||||
addCancelButton()
|
||||
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
|
||||
@@ -1341,8 +1489,8 @@ local function showHome()
|
||||
local cx = math.floor((w - btnW) / 2) + 1
|
||||
addButton(cx, y-4, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
|
||||
resetTicketFlow()
|
||||
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
|
||||
state.page = 'departure'
|
||||
stopAudio()
|
||||
state.page = 'new_menu'
|
||||
end)
|
||||
addButton(cx, y, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function()
|
||||
resetTicketFlow()
|
||||
@@ -1353,34 +1501,48 @@ local function showHome()
|
||||
resetCardFlow('open')
|
||||
stopAudio(); state.page = 'card_home'
|
||||
end)
|
||||
addButton(cx, y+8, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
|
||||
resetTicketFlow()
|
||||
stopAudio(); state.page = 'online'
|
||||
end)
|
||||
else
|
||||
local cx = math.floor((w - btnW) / 2) + 1
|
||||
addButton(x1, y-2, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
|
||||
resetTicketFlow()
|
||||
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
|
||||
state.page = 'departure'
|
||||
stopAudio()
|
||||
state.page = 'new_menu'
|
||||
end)
|
||||
addButton(x2, y-2, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function()
|
||||
resetTicketFlow()
|
||||
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
|
||||
state.page = 'single_segment'
|
||||
end)
|
||||
addButton(x1, y+2, 'CARD', btnW, btnH, {colors.orange, colors.white}, function()
|
||||
addButton(cx, y+2, 'CARD', btnW, btnH, {colors.orange, colors.white}, function()
|
||||
resetCardFlow('open')
|
||||
stopAudio(); state.page = 'card_home'
|
||||
end)
|
||||
addButton(x2, y+2, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
|
||||
resetTicketFlow()
|
||||
stopAudio(); state.page = 'online'
|
||||
end)
|
||||
end
|
||||
waitButtons()
|
||||
end
|
||||
|
||||
local function showNewMenu()
|
||||
while state.page == 'new_menu' do
|
||||
drawHeader('Ticket Service', 'Buy ticket or redeem voucher')
|
||||
Buttons = {}
|
||||
local btnW, btnH = 16, 3
|
||||
local cx = math.floor((w - btnW) / 2) + 1
|
||||
local y = math.floor(h / 2) - 3
|
||||
addButton(cx, y, 'BUY TICKET', btnW, btnH, {colors.green, colors.white}, function()
|
||||
resetTicketFlow()
|
||||
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
|
||||
state.page = 'departure'
|
||||
end)
|
||||
addButton(cx, y + 4, 'REDEEM CODE', btnW, btnH, {colors.cyan, colors.white}, function()
|
||||
resetTicketFlow()
|
||||
stopAudio()
|
||||
state.page = 'online'
|
||||
end)
|
||||
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
|
||||
waitButtons()
|
||||
end
|
||||
end
|
||||
|
||||
local function showCardHome()
|
||||
while state.page == 'card_home' do
|
||||
drawHeader('IC Card Service', 'Open card or redeem online order')
|
||||
@@ -1418,6 +1580,25 @@ local function showCardNameInput()
|
||||
{'+'}
|
||||
}
|
||||
|
||||
local function clearLine(y)
|
||||
termDev.setBackgroundColor(colors.black)
|
||||
termDev.setCursorPos(1, y)
|
||||
termDev.write(string.rep(' ', w))
|
||||
end
|
||||
|
||||
local function renderInput()
|
||||
local placeholder = name
|
||||
if #placeholder == 0 then
|
||||
placeholder = 'ENTER NAME'
|
||||
elseif #placeholder > (w - 8) then
|
||||
placeholder = '...' .. placeholder:sub(-(w - 11))
|
||||
end
|
||||
clearLine(4)
|
||||
clearLine(5)
|
||||
centerText(4, '[' .. placeholder .. ']', colors.yellow)
|
||||
if msg and #msg > 0 then centerText(5, msg, msgCol) end
|
||||
end
|
||||
|
||||
local function isAllowedHolderChar(ch)
|
||||
if type(ch) ~= 'string' or #ch ~= 1 then return false end
|
||||
if ch:match('[A-Za-z ]') then return true end
|
||||
@@ -1429,19 +1610,14 @@ local function showCardNameInput()
|
||||
if ch:match('[A-Za-z]') then ch = ch:upper() end
|
||||
if ch == ' ' and (#name == 0 or name:sub(-1) == ' ') then return end
|
||||
name = name .. ch
|
||||
msg = ''
|
||||
renderInput()
|
||||
end
|
||||
|
||||
while state.page == 'card_name' do
|
||||
local placeholder = name
|
||||
if #placeholder == 0 then
|
||||
placeholder = 'ENTER NAME'
|
||||
elseif #placeholder > (w - 8) then
|
||||
placeholder = '...' .. placeholder:sub(-(w - 11))
|
||||
end
|
||||
local rows = (keyboardMode == 'symbol') and symbolRows or alphaRows
|
||||
drawHeader('Enter Holder Name', (keyboardMode == 'symbol') and 'Symbol keyboard' or 'Letters / symbols / space')
|
||||
centerText(4, '[' .. placeholder .. ']', colors.yellow)
|
||||
if msg and #msg > 0 then centerText(5, msg, msgCol) end
|
||||
renderInput()
|
||||
Buttons = {}
|
||||
local keyW, keyH = (w < 44 and 2 or 3), 2
|
||||
local sY = 7
|
||||
@@ -1460,36 +1636,54 @@ local function showCardNameInput()
|
||||
appendHolderChar(' ')
|
||||
end)
|
||||
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end)
|
||||
|
||||
local currentKeyboardMode = keyboardMode
|
||||
addButton(12, h-3, (keyboardMode == 'symbol') and 'ABC' or 'SYM', 8, 3, {colors.black, colors.orange}, function()
|
||||
keyboardMode = (keyboardMode == 'symbol') and 'alpha' or 'symbol'
|
||||
end)
|
||||
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() name = name:sub(1, -2) end)
|
||||
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() name = '' end)
|
||||
|
||||
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function()
|
||||
name = name:sub(1, -2)
|
||||
msg = ''
|
||||
renderInput()
|
||||
end)
|
||||
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function()
|
||||
name = ''
|
||||
msg = ''
|
||||
renderInput()
|
||||
end)
|
||||
addButton(w-11, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function()
|
||||
local clean = firstString(name)
|
||||
if #clean < 2 then
|
||||
msg, msgCol = 'Name too short', colors.red
|
||||
renderInput()
|
||||
return
|
||||
end
|
||||
state.holderName = clean
|
||||
state.page = 'card_topup'
|
||||
end)
|
||||
local ev, p1, p2, p3 = os.pullEvent()
|
||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||
for _, b in ipairs(Buttons) do
|
||||
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
||||
end
|
||||
elseif ev == 'char' then
|
||||
appendHolderChar(tostring(p1 or ''))
|
||||
elseif ev == 'key' and p1 == keys.backspace then
|
||||
name = name:sub(1, -2)
|
||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
||||
local clean = firstString(name)
|
||||
if #clean >= 2 then
|
||||
state.holderName = clean
|
||||
state.page = 'card_topup'
|
||||
else
|
||||
msg, msgCol = 'Name too short', colors.red
|
||||
|
||||
while state.page == 'card_name' and keyboardMode == currentKeyboardMode do
|
||||
local ev, p1, p2, p3 = os.pullEvent()
|
||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||
for _, b in ipairs(Buttons) do
|
||||
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
||||
end
|
||||
elseif ev == 'char' then
|
||||
appendHolderChar(tostring(p1 or ''))
|
||||
elseif ev == 'key' and p1 == keys.backspace then
|
||||
name = name:sub(1, -2)
|
||||
msg = ''
|
||||
renderInput()
|
||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
||||
local clean = firstString(name)
|
||||
if #clean >= 2 then
|
||||
state.holderName = clean
|
||||
state.page = 'card_topup'
|
||||
else
|
||||
msg, msgCol = 'Name too short', colors.red
|
||||
renderInput()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1544,9 +1738,24 @@ local function showCardOnlineRedeem()
|
||||
{'Z','X','C','V','B','N','M'}
|
||||
}
|
||||
|
||||
local function clearLine(y)
|
||||
termDev.setBackgroundColor(colors.black)
|
||||
termDev.setCursorPos(1, y)
|
||||
termDev.write(string.rep(' ', w))
|
||||
end
|
||||
|
||||
local function renderInput()
|
||||
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
|
||||
clearLine(4)
|
||||
clearLine(5)
|
||||
centerText(4, '[' .. placeholder .. ']', colors.yellow)
|
||||
if msg and #msg > 0 then centerText(5, msg, msgCol) end
|
||||
end
|
||||
|
||||
local function submitCode()
|
||||
if #code ~= 5 then
|
||||
msg, msgCol = 'Need 5 chars', colors.red
|
||||
renderInput()
|
||||
return
|
||||
end
|
||||
local res = fetchJSON(API_BASE .. '/public/ic-cards/orders/' .. code)
|
||||
@@ -1555,17 +1764,20 @@ local function showCardOnlineRedeem()
|
||||
end
|
||||
if not (res and res.ok) then
|
||||
msg, msgCol = 'Voucher Invalid', colors.red
|
||||
renderInput()
|
||||
return
|
||||
end
|
||||
local d = res.data or res
|
||||
local cardStatus = tostring(d.status or ''):lower()
|
||||
if d.consumed or (cardStatus ~= '' and cardStatus ~= 'pending_pickup' and isCardOrderLike(d)) then
|
||||
msg, msgCol = 'Already Used!', colors.red
|
||||
renderInput()
|
||||
return
|
||||
end
|
||||
local cardOrder = buildCardOrderState(d, code)
|
||||
if not cardOrder then
|
||||
msg, msgCol = 'Not a card order', colors.red
|
||||
renderInput()
|
||||
return
|
||||
end
|
||||
resetCardFlow('redeem')
|
||||
@@ -1583,10 +1795,7 @@ local function showCardOnlineRedeem()
|
||||
end
|
||||
|
||||
while state.page == 'card_online' do
|
||||
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
|
||||
drawHeader('Redeem IC Card', 'Type 5 chars then OK')
|
||||
centerText(4, '[' .. placeholder .. ']', colors.yellow)
|
||||
if msg and #msg > 0 then centerText(5, msg, msgCol) end
|
||||
Buttons = {}
|
||||
local keyW, keyH = (w < 44 and 2 or 3), 2
|
||||
local kbW = 10 * (keyW + 1)
|
||||
@@ -1595,26 +1804,46 @@ local function showCardOnlineRedeem()
|
||||
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
|
||||
for _, ch in ipairs(row) do
|
||||
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function()
|
||||
if #code < 5 then code = code .. ch end
|
||||
if #code < 5 then
|
||||
code = code .. ch
|
||||
msg = ''
|
||||
renderInput()
|
||||
end
|
||||
end)
|
||||
x = x + keyW + 1
|
||||
end
|
||||
end
|
||||
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end)
|
||||
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
|
||||
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() code = '' end)
|
||||
addButton(w-11, h-3, 'OK', 10, 3, {colors.black, colors.green}, submitCode)
|
||||
local ev, p1, p2, p3 = os.pullEvent()
|
||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||
for _, b in ipairs(Buttons) do
|
||||
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
||||
end
|
||||
elseif ev == 'char' and #code < 5 then
|
||||
code = code .. tostring(p1 or ''):upper()
|
||||
elseif ev == 'key' and p1 == keys.backspace then
|
||||
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function()
|
||||
code = code:sub(1, -2)
|
||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
||||
submitCode()
|
||||
msg = ''
|
||||
renderInput()
|
||||
end)
|
||||
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function()
|
||||
code = ''
|
||||
msg = ''
|
||||
renderInput()
|
||||
end)
|
||||
addButton(w-11, h-3, 'OK', 10, 3, {colors.black, colors.green}, submitCode)
|
||||
renderInput()
|
||||
while state.page == 'card_online' do
|
||||
local ev, p1, p2, p3 = os.pullEvent()
|
||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||
for _, b in ipairs(Buttons) do
|
||||
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
||||
end
|
||||
elseif ev == 'char' and #code < 5 then
|
||||
code = code .. tostring(p1 or ''):upper()
|
||||
msg = ''
|
||||
renderInput()
|
||||
elseif ev == 'key' and p1 == keys.backspace then
|
||||
code = code:sub(1, -2)
|
||||
msg = ''
|
||||
renderInput()
|
||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
||||
submitCode()
|
||||
elseif ev == 'config_updated' then
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1653,8 +1882,16 @@ local function renderLinesSelection(title, selectedCode, backPage, nextPage, inf
|
||||
local ch = computeHeight(); local maxO = math.max(0, ch - (endY-startY+1))
|
||||
ui_scroll_offset = math.min(ui_scroll_offset, maxO)
|
||||
|
||||
addButton(sbX, startY-1, '^', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.max(0, ui_scroll_offset-4) end)
|
||||
addButton(sbX, endY+1, 'v', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end)
|
||||
drawVerticalScrollbar(
|
||||
sbX,
|
||||
startY,
|
||||
endY,
|
||||
ui_scroll_offset,
|
||||
maxO,
|
||||
4,
|
||||
function() ui_scroll_offset = math.max(0, ui_scroll_offset-4) end,
|
||||
function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end
|
||||
)
|
||||
|
||||
local y = startY
|
||||
for _, l in ipairs(CFG.lines) do
|
||||
@@ -1737,7 +1974,7 @@ local function showTerminal()
|
||||
invalidUntil = os.epoch('utc') + 1500
|
||||
sel = nil
|
||||
state.terminal = nil
|
||||
showAlert('Same station')
|
||||
showAlert('Same station', 'Dep and Term are identical')
|
||||
else
|
||||
state.terminal = sel
|
||||
end
|
||||
@@ -1995,7 +2232,7 @@ local function showOrderAndAudio()
|
||||
else
|
||||
local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
|
||||
if not unit then
|
||||
showAlert('No route')
|
||||
showAlert('No route', 'Cannot find fare path')
|
||||
state.page = 'terminal'
|
||||
return
|
||||
end
|
||||
@@ -2006,7 +2243,7 @@ local function showOrderAndAudio()
|
||||
end
|
||||
|
||||
if state.productMode ~= 'card' and state.trainType ~= 'single' and sameLogicalStation(state.departure, state.terminal) then
|
||||
showAlert('Same station')
|
||||
showAlert('Same station', 'Cannot purchase to same station')
|
||||
state.page = state.voucher_code and 'online' or 'terminal'
|
||||
return
|
||||
end
|
||||
@@ -2019,7 +2256,7 @@ local function showOrderAndAudio()
|
||||
local function confirmAction()
|
||||
if processing or confirmed then return end
|
||||
if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then
|
||||
showAlert('Same station')
|
||||
showAlert('Same station', 'Dep and Term are identical on confirm')
|
||||
if render then render() end
|
||||
return
|
||||
end
|
||||
@@ -2054,6 +2291,7 @@ local function showOrderAndAudio()
|
||||
end
|
||||
if okIssueBlank and #blankCardId > 0 then
|
||||
payload.card_id = blankCardId
|
||||
state.card_id = blankCardId
|
||||
state.pendingBlankCardId = blankCardId
|
||||
local okReq, code, parsed, err = submitCardOpen(payload)
|
||||
if okReq then
|
||||
@@ -2065,11 +2303,15 @@ local function showOrderAndAudio()
|
||||
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
|
||||
state.card_server_data = respData
|
||||
state.pendingBlankCardId = nil
|
||||
state.cardIssueWarning = 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
|
||||
state.card_server_data = respData
|
||||
state.cardIssueWarning = 'Write pending: ' .. tostring(writeMethod)
|
||||
confirmed = true
|
||||
statusMsg, statusCol = 'Card issued, write pending', colors.red
|
||||
end
|
||||
else
|
||||
local errorMsg = 'Card API Err'
|
||||
@@ -2080,9 +2322,14 @@ local function showOrderAndAudio()
|
||||
elseif code and type(code) == 'number' then
|
||||
errorMsg = 'HTTP ' .. tostring(code)
|
||||
end
|
||||
statusMsg, statusCol = 'Issued, retry sync: ' .. errorMsg, colors.red
|
||||
state.cardIssueWarning = 'Sync pending: ' .. errorMsg
|
||||
confirmed = true
|
||||
statusMsg, statusCol = 'Card issued, sync pending', colors.red
|
||||
end
|
||||
elseif okIssueBlank then
|
||||
state.card_id = payload.card_id
|
||||
state.cardIssueWarning = 'Issued without readable ID'
|
||||
confirmed = true
|
||||
statusMsg, statusCol = 'Card issued without ID', colors.red
|
||||
else
|
||||
local okReq, code, parsed, err = submitCardOpen(payload)
|
||||
@@ -2095,6 +2342,7 @@ local function showOrderAndAudio()
|
||||
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
|
||||
state.card_server_data = respData
|
||||
state.pendingBlankCardId = nil
|
||||
state.cardIssueWarning = nil
|
||||
confirmed = true
|
||||
statusMsg, statusCol = 'Card ready', colors.green
|
||||
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
|
||||
@@ -2227,7 +2475,8 @@ end
|
||||
|
||||
local function showDone()
|
||||
if state.productMode == 'card' then
|
||||
drawHeader('Card Ready', 'Please take your IC card')
|
||||
local warning = firstString(state.cardIssueWarning)
|
||||
drawHeader(warning ~= '' and 'Card Issued' or 'Card Ready', warning ~= '' and 'Please check issue status' or 'Please take your IC card')
|
||||
local y = 5
|
||||
local function line(label, value, col)
|
||||
termDev.setTextColor(colors.white)
|
||||
@@ -2242,6 +2491,7 @@ local function showDone()
|
||||
line('Balance', tonumber(state.cardBalance) or 0, colors.green)
|
||||
line('Card ID', firstString(state.card_id, 'PENDING'), colors.cyan)
|
||||
if state.voucher_code then line('Voucher', state.voucher_code, colors.cyan) end
|
||||
if warning ~= '' then line('Status', warning, colors.red) end
|
||||
saveCardIssueSnapshot({
|
||||
card_id = state.card_id,
|
||||
holder_name = state.holderName,
|
||||
@@ -2249,6 +2499,7 @@ local function showDone()
|
||||
topup = state.cardTopup,
|
||||
balance = state.cardBalance,
|
||||
voucher_code = state.voucher_code,
|
||||
issue_warning = warning,
|
||||
station_code = state.stationCode or CURRENT_STATION_CODE,
|
||||
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
|
||||
})
|
||||
@@ -2333,7 +2584,7 @@ local function showDone()
|
||||
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')
|
||||
showAlert('Ticket issue failed', issueError)
|
||||
resetTicketFlow()
|
||||
state.page = 'home'
|
||||
return
|
||||
@@ -2432,6 +2683,20 @@ local function showOnlineVoucher()
|
||||
{'Z','X','C','V','B','N','M'}
|
||||
}
|
||||
|
||||
local function clearLine(y)
|
||||
termDev.setBackgroundColor(colors.black)
|
||||
termDev.setCursorPos(1, y)
|
||||
termDev.write(string.rep(' ', w))
|
||||
end
|
||||
|
||||
local function renderInput()
|
||||
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
|
||||
clearLine(4)
|
||||
clearLine(5)
|
||||
centerText(4, '[' .. placeholder .. ']', colors.yellow)
|
||||
if msg and #msg > 0 then centerText(5, msg, msgCol) end
|
||||
end
|
||||
|
||||
local function submitCode()
|
||||
if #code == 5 then
|
||||
local res = fetchJSON(API_BASE .. '/public/orders/' .. code)
|
||||
@@ -2441,6 +2706,7 @@ local function showOnlineVoucher()
|
||||
-- Check if already consumed
|
||||
if d.consumed then
|
||||
msg, msgCol = 'Already Used!', colors.red
|
||||
renderInput()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -2454,15 +2720,16 @@ local function showOnlineVoucher()
|
||||
state.page = 'order'
|
||||
else
|
||||
msg, msgCol = 'Voucher Invalid', colors.red
|
||||
renderInput()
|
||||
end
|
||||
else
|
||||
msg, msgCol = 'Need 5 chars', colors.red
|
||||
renderInput()
|
||||
end
|
||||
end
|
||||
|
||||
while state.page == 'online' do
|
||||
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
|
||||
drawHeader('Enter Voucher', 'Type 5 chars then OK')
|
||||
centerText(4, '[' .. placeholder .. ']', colors.yellow)
|
||||
if msg and #msg > 0 then centerText(5, msg, msgCol) end
|
||||
Buttons = {}
|
||||
|
||||
local keyW, keyH = (w < 44 and 2 or 3), 2
|
||||
@@ -2472,7 +2739,13 @@ local function showOnlineVoucher()
|
||||
for rIdx, row in ipairs(rows) do
|
||||
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
|
||||
for _, ch in ipairs(row) do
|
||||
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function() if #code < 5 then code = code .. ch end end)
|
||||
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function()
|
||||
if #code < 5 then
|
||||
code = code .. ch
|
||||
msg = ''
|
||||
renderInput()
|
||||
end
|
||||
end)
|
||||
x = x + keyW + 1
|
||||
end
|
||||
end
|
||||
@@ -2482,27 +2755,47 @@ local function showOnlineVoucher()
|
||||
local gap = 2
|
||||
local total = bw + gap + cw + gap + ow
|
||||
local ax = math.floor((w - total) / 2) + 1
|
||||
local smallMode = false
|
||||
if ax < 1 then
|
||||
smallMode = true
|
||||
bw, cw, ow = 6, 6, 6
|
||||
total = bw + 1 + cw + 1 + ow
|
||||
gap = 1
|
||||
total = bw + gap + cw + gap + ow
|
||||
ax = math.floor((w - total) / 2) + 1
|
||||
addButton(math.max(1, ax), actY, 'BKSP', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
|
||||
addButton(math.max(1, ax) + bw + 1, actY, 'CLEAR', cw, 3, {colors.black, colors.red}, function() code = '' end)
|
||||
addButton(math.max(1, ax) + bw + 1 + cw + 1, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
|
||||
else
|
||||
addButton(ax, actY, 'Backspace', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
|
||||
addButton(ax + bw + gap, actY, 'Clear', cw, 3, {colors.black, colors.red}, function() code = '' end)
|
||||
addButton(ax + bw + gap + cw + gap, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
|
||||
end
|
||||
local bx = math.max(1, ax)
|
||||
addButton(bx, actY, smallMode and 'BKSP' or 'Backspace', bw, 3, {colors.black, colors.red}, function()
|
||||
code = code:sub(1, -2)
|
||||
msg = ''
|
||||
renderInput()
|
||||
end)
|
||||
addButton(bx + bw + gap, actY, smallMode and 'CLEAR' or 'Clear', cw, 3, {colors.black, colors.red}, function()
|
||||
code = ''
|
||||
msg = ''
|
||||
renderInput()
|
||||
end)
|
||||
addButton(bx + bw + gap + cw + gap, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
|
||||
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
|
||||
|
||||
local ev, p1, p2, p3 = os.pullEvent()
|
||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||
for _, b in ipairs(Buttons) do if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end end
|
||||
elseif ev == 'char' and #code < 5 then code = code .. p1:upper()
|
||||
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
|
||||
|
||||
renderInput()
|
||||
while state.page == 'online' do
|
||||
local ev, p1, p2, p3 = os.pullEvent()
|
||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||
for _, b in ipairs(Buttons) do
|
||||
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
||||
end
|
||||
elseif ev == 'char' and #code < 5 then
|
||||
code = code .. tostring(p1 or ''):upper()
|
||||
msg = ''
|
||||
renderInput()
|
||||
elseif ev == 'key' and p1 == keys.backspace then
|
||||
code = code:sub(1, -2)
|
||||
msg = ''
|
||||
renderInput()
|
||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
||||
submitCode()
|
||||
elseif ev == 'config_updated' then
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2513,6 +2806,7 @@ end
|
||||
local function mainPageLoop()
|
||||
while true do
|
||||
if state.page == 'home' then showHome()
|
||||
elseif state.page == 'new_menu' then showNewMenu()
|
||||
elseif state.page == 'single_segment' then showSingleSegmentSelection()
|
||||
elseif state.page == 'card_home' then showCardHome()
|
||||
elseif state.page == 'card_name' then showCardNameInput()
|
||||
@@ -2530,4 +2824,12 @@ local function mainPageLoop()
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)
|
||||
local function safeMain()
|
||||
local ok, err = pcall(parallel.waitForAny, mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)
|
||||
if not ok then
|
||||
reportIssue("Fatal Error", tostring(err))
|
||||
error(err)
|
||||
end
|
||||
end
|
||||
|
||||
safeMain()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
+1
-74
@@ -1,74 +1 @@
|
||||
<!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=14">
|
||||
<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>© 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>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+23
-8
@@ -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=14">
|
||||
<link rel="stylesheet" href="/style.css?v=22">
|
||||
</head>
|
||||
<body class="public-search jr-public-page">
|
||||
<div class="jr-public-shell">
|
||||
@@ -16,11 +16,7 @@
|
||||
<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>
|
||||
|
||||
@@ -184,6 +180,26 @@
|
||||
<script src="/custom-dialog.js?v=11"></script>
|
||||
<script src="/public-status.js?v=14"></script>
|
||||
<script>
|
||||
const createRouteMapCredit = (credit, license) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'jr-asset-credit';
|
||||
|
||||
const creditText = document.createElement('span');
|
||||
creditText.textContent = `线路图署名:${credit || '未填写'}`;
|
||||
wrap.appendChild(creditText);
|
||||
|
||||
wrap.appendChild(document.createTextNode(' 许可协议:'));
|
||||
|
||||
const licenseLink = document.createElement('a');
|
||||
licenseLink.href = 'https://creativecommons.org/licenses/by/4.0/';
|
||||
licenseLink.target = '_blank';
|
||||
licenseLink.rel = 'noopener noreferrer';
|
||||
licenseLink.textContent = license || 'CC BY 4.0';
|
||||
wrap.appendChild(licenseLink);
|
||||
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const loadPublicAssets = async () => {
|
||||
const fareMapBox = document.getElementById('fareMapBox');
|
||||
const routeMapBox = document.getElementById('routeMapBox');
|
||||
@@ -209,6 +225,7 @@
|
||||
img.src = `/assets/${encodeURIComponent(m.routeMap)}?v=${encodeURIComponent(mv)}`;
|
||||
routeMapBox.innerHTML = '<div class="jr-asset-frame"></div>';
|
||||
routeMapBox.firstElementChild.appendChild(img);
|
||||
routeMapBox.appendChild(createRouteMapCredit(m.routeMapCredit, m.routeMapLicense));
|
||||
} else {
|
||||
routeMapBox.innerHTML = '<div class="text-muted">暂无线路图</div>';
|
||||
}
|
||||
@@ -245,5 +262,3 @@
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>IC卡管理|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=14">
|
||||
<link rel="stylesheet" href="/style.css?v=22">
|
||||
</head>
|
||||
|
||||
<body class="jr-admin-page jr-admin-ic-page jr-public-page">
|
||||
@@ -18,11 +18,7 @@
|
||||
<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>
|
||||
<div class="jr-brandbar">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>IC卡详情|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=14">
|
||||
<link rel="stylesheet" href="/style.css?v=22">
|
||||
</head>
|
||||
|
||||
<body class="public-search jr-public-page">
|
||||
@@ -18,11 +18,7 @@
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<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-dot"></span>
|
||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
<div class="jr-brandbar">
|
||||
|
||||
@@ -7,7 +7,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=14">
|
||||
<link rel="stylesheet" href="/style.css?v=22">
|
||||
</head>
|
||||
|
||||
<body class="public-search jr-public-page">
|
||||
@@ -18,11 +18,7 @@
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<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-dot"></span>
|
||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
<div class="jr-brandbar">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>IC卡查询|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=14">
|
||||
<link rel="stylesheet" href="/style.css?v=22">
|
||||
</head>
|
||||
|
||||
<body class="public-search jr-public-page">
|
||||
@@ -18,11 +18,7 @@
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<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-dot"></span>
|
||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
<div class="jr-brandbar">
|
||||
|
||||
+1036
-868
File diff suppressed because it is too large
Load Diff
+183
-12
@@ -27,6 +27,7 @@ createApp({
|
||||
tickets: '车票记录',
|
||||
vouchers: '凭证管理',
|
||||
iccards: 'IC 卡管理',
|
||||
issues: '问题汇总',
|
||||
assets: '线路图',
|
||||
settings: '系统设置',
|
||||
logs: '日志'
|
||||
@@ -34,15 +35,15 @@ createApp({
|
||||
return map[currentView.value] || '票价图';
|
||||
});
|
||||
|
||||
// Prefer polling first so admin remains connected even when the proxy
|
||||
// does not support WebSocket upgrades reliably.
|
||||
const socket = io({ transports: ['polling', 'websocket'] });
|
||||
// 强制使用 WebSocket,避免轮询 (polling) 在负载均衡/反代下产生 400 Bad Request
|
||||
const socket = io({ transports: ['websocket'], timeout: 20000 });
|
||||
|
||||
// Data State
|
||||
const stations = ref([]);
|
||||
const lines = ref([]);
|
||||
const fares = ref([]);
|
||||
const tickets = ref([]);
|
||||
const issues = ref([]);
|
||||
const stats = reactive({ sold_tickets: 0, revenue: 0 });
|
||||
const config = reactive({
|
||||
api_base: '',
|
||||
@@ -56,10 +57,11 @@ createApp({
|
||||
const logMax = ref(200);
|
||||
const logLoading = ref(false);
|
||||
const orders = ref([]);
|
||||
const assetsManifest = reactive({ routeMap: null, fareTable: null, updatedAt: null });
|
||||
const assetsManifest = reactive({ routeMap: null, routeMapCredit: '', routeMapLicense: 'CC BY 4.0', fareTable: null, updatedAt: null });
|
||||
const assetsFarePreview = reactive({ headers: [], rows: [] });
|
||||
const assetsRouteMapUrl = ref('');
|
||||
const assetsFareTableUrl = ref('');
|
||||
const assetsForm = reactive({ routeMapCredit: '' });
|
||||
const icCards = ref([]);
|
||||
const icCardSearch = ref('');
|
||||
const icSelectedId = ref('');
|
||||
@@ -76,6 +78,7 @@ createApp({
|
||||
let ticketDataLoaded = false;
|
||||
let orderDataLoaded = false;
|
||||
let logDataLoaded = false;
|
||||
let issueDataLoaded = false;
|
||||
let assetsLoaded = false;
|
||||
let fareMapLoaded = false;
|
||||
const loadingState = reactive({
|
||||
@@ -83,7 +86,8 @@ createApp({
|
||||
tickets: false,
|
||||
orders: false,
|
||||
logs: false,
|
||||
iccards: false
|
||||
iccards: false,
|
||||
issues: false
|
||||
});
|
||||
const lastSyncAt = ref(0);
|
||||
|
||||
@@ -159,6 +163,132 @@ createApp({
|
||||
try { return JSON.parse(t); } catch (e) { return null; }
|
||||
};
|
||||
|
||||
const safeJsonStringify = (value) => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch (e) {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const formatIssueKey = (key) => {
|
||||
const map = {
|
||||
device: '设备',
|
||||
device_id: '设备编号',
|
||||
machine_id: '机器 ID',
|
||||
station_code: '车站编号',
|
||||
station_name: '车站名称',
|
||||
station: '车站',
|
||||
error_msg: '错误信息',
|
||||
ticket_id: '车票 ID',
|
||||
card_id: '卡号',
|
||||
holder_name: '持卡人',
|
||||
balance: '余额',
|
||||
amount: '金额',
|
||||
price: '价格',
|
||||
order_code: '订单号',
|
||||
voucher_code: '凭证码',
|
||||
method: '方法',
|
||||
source: '来源',
|
||||
reason: '原因',
|
||||
status: '状态',
|
||||
action: '动作',
|
||||
line: '线路',
|
||||
route: '区间',
|
||||
from: '起点',
|
||||
to: '终点',
|
||||
time: '时间',
|
||||
ts: '时间戳'
|
||||
};
|
||||
const raw = String(key || '').trim();
|
||||
if (!raw) return '字段';
|
||||
if (map[raw]) return map[raw];
|
||||
return raw
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^\w/, (ch) => ch.toUpperCase());
|
||||
};
|
||||
|
||||
const formatIssueValue = (value) => {
|
||||
if (value == null || value === '') return '---';
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '---';
|
||||
if (typeof value === 'string') return value.trim() || '---';
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) return '---';
|
||||
if (value.every((item) => item == null || ['string', 'number', 'boolean'].includes(typeof item))) {
|
||||
return value.map(formatIssueValue).join(' / ');
|
||||
}
|
||||
return safeJsonStringify(value);
|
||||
}
|
||||
if (typeof value === 'object') return safeJsonStringify(value);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const buildIssueDetailView = (detail) => {
|
||||
const rawText = String(detail || '').trim();
|
||||
if (!rawText) return { entries: [], rawText: '', rawExpanded: false };
|
||||
|
||||
const hiddenKeys = new Set(['device', 'device_id', 'machine_id', 'station_code', 'station_name', 'error_msg', 'detail', 'type']);
|
||||
const parsed = parseJsonSafe(rawText);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const entries = Object.entries(parsed)
|
||||
.filter(([key, value]) => !hiddenKeys.has(String(key || '').trim()) && value != null && value !== '')
|
||||
.map(([key, value]) => ({
|
||||
key: String(key),
|
||||
label: formatIssueKey(key),
|
||||
value: formatIssueValue(value)
|
||||
}));
|
||||
return {
|
||||
entries,
|
||||
rawText: entries.length ? rawText : '',
|
||||
rawExpanded: false
|
||||
};
|
||||
}
|
||||
|
||||
const segments = rawText
|
||||
.split(/\r?\n|;|;/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const entries = [];
|
||||
for (const segment of segments) {
|
||||
const matched = /^([^:=:]{1,30})\s*[:=:]\s*(.+)$/.exec(segment);
|
||||
if (!matched) continue;
|
||||
const key = String(matched[1] || '').trim();
|
||||
if (!key || hiddenKeys.has(key)) continue;
|
||||
entries.push({
|
||||
key,
|
||||
label: formatIssueKey(key),
|
||||
value: formatIssueValue(matched[2])
|
||||
});
|
||||
}
|
||||
|
||||
const hasPlainBody = segments.length <= 1 && entries.length <= 1;
|
||||
return {
|
||||
entries: hasPlainBody ? [] : entries,
|
||||
rawText,
|
||||
rawExpanded: hasPlainBody
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeIssueRecord = (issue) => {
|
||||
const item = issue && typeof issue === 'object' ? issue : {};
|
||||
const device = String(item.device || item.machine_id || '').trim();
|
||||
const stationCode = String(item.station_code || '').trim();
|
||||
const stationName = stationCode ? getStationName(stationCode) : '';
|
||||
const detailView = buildIssueDetailView(item.detail);
|
||||
return {
|
||||
...item,
|
||||
displayDevice: device || '---',
|
||||
displayStation: stationName && stationName !== stationCode ? `${stationName} (${stationCode})` : (stationCode || '---'),
|
||||
displayError: String(item.error_msg || '').trim() || '未提供错误信息',
|
||||
detailEntries: detailView.entries,
|
||||
detailRaw: detailView.rawText,
|
||||
detailRawExpanded: detailView.rawExpanded
|
||||
};
|
||||
};
|
||||
|
||||
const parseCsvSimple = (text) => {
|
||||
const out = { headers: [], rows: [] };
|
||||
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
@@ -577,8 +707,11 @@ createApp({
|
||||
const fetchAssetsManifest = async () => {
|
||||
const data = await requestJson('/api/assets/manifest');
|
||||
assetsManifest.routeMap = data ? (data.routeMap || null) : null;
|
||||
assetsManifest.routeMapCredit = data ? String(data.routeMapCredit || '') : '';
|
||||
assetsManifest.routeMapLicense = data ? String(data.routeMapLicense || 'CC BY 4.0') : 'CC BY 4.0';
|
||||
assetsManifest.fareTable = data ? (data.fareTable || null) : null;
|
||||
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
|
||||
assetsForm.routeMapCredit = assetsManifest.routeMapCredit;
|
||||
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
|
||||
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
|
||||
assetsLoaded = true;
|
||||
@@ -622,9 +755,12 @@ createApp({
|
||||
markSynced();
|
||||
};
|
||||
|
||||
const uploadAssetFile = async (url, file) => {
|
||||
const uploadAssetFile = async (url, file, extraFields = {}) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
for (const [key, value] of Object.entries(extraFields || {})) {
|
||||
fd.append(key, value == null ? '' : String(value));
|
||||
}
|
||||
await requestJson(url, { method: 'POST', body: fd }, { expectOk: true });
|
||||
await fetchAssetsManifest();
|
||||
};
|
||||
@@ -633,7 +769,7 @@ createApp({
|
||||
const f = ev && ev.target && ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
ev.target.value = '';
|
||||
await uploadAssetFile('/api/assets/route-map', f);
|
||||
await uploadAssetFile('/api/assets/route-map', f, { credit: assetsForm.routeMapCredit });
|
||||
};
|
||||
|
||||
const uploadFareTable = async (ev) => {
|
||||
@@ -648,6 +784,15 @@ createApp({
|
||||
await fetchAssetsManifest();
|
||||
};
|
||||
|
||||
const saveRouteMapCredit = async () => {
|
||||
await requestJson('/api/assets/route-map/credit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credit: assetsForm.routeMapCredit })
|
||||
}, { expectOk: true });
|
||||
await fetchAssetsManifest();
|
||||
};
|
||||
|
||||
const deleteFareTable = async () => {
|
||||
await requestJson('/api/assets/fare-table', { method: 'DELETE' }, { expectOk: true });
|
||||
await fetchAssetsManifest();
|
||||
@@ -845,6 +990,26 @@ createApp({
|
||||
}
|
||||
};
|
||||
|
||||
// --- Issues Management ---
|
||||
const fetchIssues = async () => {
|
||||
if (loadingState.issues) return;
|
||||
loadingState.issues = true;
|
||||
try {
|
||||
const res = await requestJson('/api/issues');
|
||||
if (res && res.ok) {
|
||||
issues.value = (res.issues || [])
|
||||
.map(normalizeIssueRecord)
|
||||
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
|
||||
issueDataLoaded = true;
|
||||
markSynced();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loadingState.issues = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchIcCards = async (keepSelection = true) => {
|
||||
if (loadingState.iccards) return;
|
||||
loadingState.iccards = true;
|
||||
@@ -1131,6 +1296,7 @@ createApp({
|
||||
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
|
||||
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
|
||||
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
|
||||
if (view === 'issues' && (force || !issueDataLoaded)) await fetchIssues();
|
||||
if (view === 'iccards') {
|
||||
await fetchIcCards(true);
|
||||
if (icSelectedId.value) {
|
||||
@@ -1600,6 +1766,12 @@ createApp({
|
||||
stats.sold_tickets += item.sold_tickets;
|
||||
stats.revenue += item.revenue;
|
||||
});
|
||||
socket.on('issue:new', (item) => {
|
||||
issues.value = [normalizeIssueRecord(item), ...issues.value]
|
||||
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
|
||||
issueDataLoaded = true;
|
||||
markSynced();
|
||||
});
|
||||
socket.on('ic-card:created', () => { fetchIcCards(false).catch(console.error); });
|
||||
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
|
||||
socket.on('ic-card:check', () => {
|
||||
@@ -1843,9 +2015,6 @@ createApp({
|
||||
} else {
|
||||
stopIcCardSync();
|
||||
}
|
||||
if (v === 'assets') {
|
||||
setTimeout(initTopologyLayout, 300);
|
||||
}
|
||||
ensureViewData(v).catch(console.error);
|
||||
const sp = new URLSearchParams(location.search);
|
||||
if (v === 'dashboard') sp.delete('view');
|
||||
@@ -1915,6 +2084,7 @@ createApp({
|
||||
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
|
||||
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
|
||||
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
|
||||
issues: `当前共有 ${issues.value.length} 条问题记录`,
|
||||
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
|
||||
settings: '可维护优惠活动与导出数据',
|
||||
logs: `当前筛选结果 ${logs.value.length} 条日志`
|
||||
@@ -2000,11 +2170,12 @@ createApp({
|
||||
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
|
||||
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
|
||||
orders, orderList, fetchOrders, deleteOrder,
|
||||
issues, fetchIssues,
|
||||
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
|
||||
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
|
||||
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
|
||||
assetsManifest, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
|
||||
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable,
|
||||
assetsManifest, assetsForm, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
|
||||
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable, saveRouteMapCredit,
|
||||
|
||||
// Management
|
||||
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
|
||||
|
||||
@@ -1,71 +1 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录后台|FSE铁路运输票务系统</title>
|
||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||
<link rel="stylesheet" href="/style.css?v=14" />
|
||||
</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>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="jr-brandbar">
|
||||
<div class="jr-brandbar-inner">
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="jr-admin-login-main">
|
||||
<section class="jr-admin-login-panel">
|
||||
<div class="jr-admin-login-copy">
|
||||
<span class="jr-kicker">OPERATIONS ACCESS</span>
|
||||
<h1>后台控制台</h1>
|
||||
<p>线路维护、票务管理、日志查询与 IC 卡管理统一从·这里进入。</p>
|
||||
<ul class="jr-admin-login-points">
|
||||
<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>
|
||||
</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>
|
||||
<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">粤ICP备2025450093号</a>
|
||||
<span class="version">v1.0.12</span>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/custom-dialog.js?v=11"></script>
|
||||
<script src="login.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -7,7 +7,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=14" />
|
||||
<link rel="stylesheet" href="/style.css?v=22" />
|
||||
</head>
|
||||
|
||||
<body class="public-search jr-public-page">
|
||||
@@ -18,11 +18,7 @@
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<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-dot"></span>
|
||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
+479
-5
@@ -1,3 +1,7 @@
|
||||
[v-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--surface: #18181b;
|
||||
@@ -251,7 +255,7 @@ button, .btn {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 0;
|
||||
border-radius: 0 !important;
|
||||
border: 1px solid var(--border);
|
||||
background-color: transparent;
|
||||
color: var(--text);
|
||||
@@ -477,6 +481,9 @@ tr:hover td {
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
.mobile-only-inline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
:root { --sidebar-width: 260px; }
|
||||
@@ -1780,6 +1787,7 @@ body.jr-order-page .site-footer .version {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d7e0d3;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.jr-hero-copy {
|
||||
@@ -2054,6 +2062,7 @@ body.jr-order-page .site-footer .version {
|
||||
background:
|
||||
linear-gradient(180deg, #f7faf7 0, #edf4ed 100%);
|
||||
border: 1px solid #d7e0d3;
|
||||
border-radius: 0;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
@@ -2441,11 +2450,14 @@ body.jr-order-page .station-map-container text {
|
||||
|
||||
.jr-selection-summary {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.jr-selection-arrow {
|
||||
min-height: 20px;
|
||||
min-height: 12px;
|
||||
transform: rotate(90deg);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.jr-form-grid,
|
||||
@@ -2459,11 +2471,25 @@ body.jr-order-page .station-map-container text {
|
||||
}
|
||||
|
||||
.jr-station-map {
|
||||
padding: 14px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.jr-selection-card {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.jr-selection-tag {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.jr-selection-card strong {
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
body.jr-order-page .station-map-container svg {
|
||||
min-width: 620px;
|
||||
min-width: 560px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@@ -2725,6 +2751,25 @@ body.jr-public-page {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.jr-asset-credit {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: #6a786d;
|
||||
}
|
||||
|
||||
.jr-asset-credit a {
|
||||
color: #0b6b3a;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.jr-asset-credit a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.jr-search-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -4199,6 +4244,123 @@ body.jr-admin-login-page {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.jr-issue-table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.jr-issue-table th:nth-child(1) {
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
.jr-issue-table th:nth-child(2) {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.jr-issue-table th:nth-child(3) {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.jr-issue-table th:nth-child(4) {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.jr-issue-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.jr-issue-primary {
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.jr-issue-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: #6d7f73;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.jr-issue-error {
|
||||
color: #c43f3f;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.jr-issue-detail-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jr-issue-detail-card,
|
||||
.jr-issue-plain-detail {
|
||||
border: 1px solid #dbe4d7;
|
||||
background: #f8fbf7;
|
||||
padding: 12px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.jr-issue-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jr-issue-detail-item {
|
||||
border: 1px solid #dbe4d7;
|
||||
background: #ffffff;
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jr-issue-detail-item span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #6d7f73;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.jr-issue-detail-item strong {
|
||||
display: block;
|
||||
color: #183225;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.jr-issue-raw-block {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #dbe4d7;
|
||||
}
|
||||
|
||||
.jr-issue-raw-block summary {
|
||||
cursor: pointer;
|
||||
color: #607466;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.jr-issue-raw-block pre,
|
||||
.jr-issue-plain-detail pre {
|
||||
margin: 8px 0 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #1e293b;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.jr-admin-login-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -4492,6 +4654,312 @@ body.jr-admin-login-page {
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== Route 鬘オ髱「遘サ蜉ィ遶ッ荳馴。ケ騾る� ========== */
|
||||
@media (max-width: 980px) {
|
||||
body.jr-admin-route-page {
|
||||
overflow: auto;
|
||||
}
|
||||
body.jr-admin-route-page #app {
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
}
|
||||
.jr-admin-route-page .jr-admin-app {
|
||||
min-height: auto;
|
||||
border-radius: 0;
|
||||
}
|
||||
.jr-admin-route-page .jr-admin-main-shell {
|
||||
width: min(100%, calc(100% - 24px));
|
||||
padding: 12px 0 24px;
|
||||
}
|
||||
.jr-admin-route-page .jr-admin-app {
|
||||
flex-direction: column;
|
||||
}
|
||||
.jr-admin-route-page .sidebar {
|
||||
width: 86%;
|
||||
max-width: 340px;
|
||||
}
|
||||
.jr-admin-route-page .jr-admin-header-side {
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body.jr-admin-route-page .jr-topbar-inner {
|
||||
min-height: 52px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
gap: 6px;
|
||||
}
|
||||
body.jr-admin-route-page .jr-top-status {
|
||||
font-size: 0.8rem;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
body.jr-admin-route-page .jr-brandbar-inner {
|
||||
min-height: auto;
|
||||
padding: 14px 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
body.jr-admin-route-page .jr-nav {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
body.jr-admin-route-page .jr-nav a {
|
||||
min-width: 88px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jr-admin-route-page .jr-admin-page .header,
|
||||
.jr-admin-route-page .jr-admin-page .content {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
.jr-admin-route-page .content {
|
||||
padding: 14px;
|
||||
}
|
||||
.jr-admin-route-page .header {
|
||||
padding: 12px 14px;
|
||||
min-height: 60px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.jr-admin-route-page .jr-page-intro {
|
||||
padding: 18px 16px;
|
||||
}
|
||||
.jr-admin-route-page .jr-page-intro h1 {
|
||||
font-size: clamp(1.4rem, 6vw, 2rem);
|
||||
}
|
||||
.jr-admin-route-page .jr-page-intro p {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.jr-admin-route-page .card {
|
||||
padding: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.jr-admin-route-page .management-container {
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
.jr-admin-route-page .management-sidebar {
|
||||
width: 100%;
|
||||
height: 380px;
|
||||
max-height: 40vh;
|
||||
}
|
||||
.jr-admin-route-page .management-sidebar .card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.jr-admin-route-page .management-main {
|
||||
overflow: visible;
|
||||
min-height: auto;
|
||||
}
|
||||
.jr-admin-route-page .management-main.is-empty {
|
||||
display: none !important;
|
||||
}
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
.mobile-only-inline {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.jr-admin-route-page .editor-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
.jr-admin-route-page .editor-toolbar > .flex:first-child {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.jr-admin-route-page .switch-label {
|
||||
width: 100%;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.jr-admin-route-page .hint-text {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
.jr-admin-route-page .editor-toolbar > .flex:last-child {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.jr-admin-route-page .editor-toolbar > .flex:last-child input,
|
||||
.jr-admin-route-page .editor-toolbar > .flex:last-child button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jr-admin-route-page .visual-line-container {
|
||||
padding: 16px 10px;
|
||||
min-height: 280px;
|
||||
cursor: default;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.jr-admin-route-page .visual-line-container svg {
|
||||
min-width: 720px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.jr-admin-route-page .faremap-card .flex.between {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.jr-admin-route-page .faremap-card .flex.between > .flex:last-child {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.jr-admin-route-page .faremap-card .flex.between > .flex:last-child button {
|
||||
flex: 1;
|
||||
min-width: 52px;
|
||||
}
|
||||
.jr-admin-route-page .faremap-viewport {
|
||||
max-height: 60vh;
|
||||
}
|
||||
.jr-admin-route-page .faremap-canvas {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.jr-admin-route-page .ticket-table,
|
||||
.jr-admin-route-page table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.jr-admin-route-page .ticket-table th,
|
||||
.jr-admin-route-page .ticket-table td,
|
||||
.jr-admin-route-page table th,
|
||||
.jr-admin-route-page table td {
|
||||
padding: 10px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jr-admin-route-page .modal-card {
|
||||
width: 96%;
|
||||
max-width: 96%;
|
||||
padding: 18px 16px;
|
||||
max-height: 92vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
.jr-admin-route-page .modal-title {
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.jr-admin-route-page .modal-actions {
|
||||
flex-direction: column-reverse;
|
||||
margin-top: 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
.jr-admin-route-page .modal-actions button {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
}
|
||||
.jr-admin-route-page select[multiple] {
|
||||
height: 160px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.jr-admin-route-page button,
|
||||
.jr-admin-route-page .btn {
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
.jr-admin-route-page button.sm,
|
||||
.jr-admin-route-page .btn.sm {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
.jr-admin-route-page .icon-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.jr-admin-route-page input,
|
||||
.jr-admin-route-page select {
|
||||
min-height: 44px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.jr-admin-route-page .line-item {
|
||||
padding: 14px 12px;
|
||||
min-height: 56px;
|
||||
}
|
||||
.jr-admin-route-page .line-item .line-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.jr-admin-route-page .line-item .line-actions button {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.jr-admin-route-page .site-footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.jr-admin-route-page .header h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.jr-admin-route-page .jr-admin-header-pill {
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.jr-admin-route-page .badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
.jr-admin-route-page h4 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.jr-admin-route-page h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.jr-admin-route-page .jr-brand-copy strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.jr-admin-route-page .jr-brand-copy span {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.jr-admin-route-page .jr-brand-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.jr-admin-route-page .faremap-viewport {
|
||||
max-height: 50vh;
|
||||
}
|
||||
.jr-admin-route-page .visual-line-container svg {
|
||||
min-width: 640px;
|
||||
}
|
||||
.jr-admin-route-page #app .ticket-header .flex.between:last-of-type {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
| ||||