Compare commits
44
Commits
cfaa622dcd
...
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 |
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)
|
||||
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
|
||||
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
|
||||
return "F " .. frontCode .. " B " .. backCode
|
||||
end
|
||||
end
|
||||
|
||||
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(),
|
||||
|
||||
+393
-91
@@ -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'
|
||||
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 issuedId = extractPeripheralId(r2, r3, r1, fallbackTicketId)
|
||||
|
||||
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
|
||||
|
||||
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 false, '', 'invalid_ticket_id'
|
||||
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
|
||||
return true, normalizedId, 'issueTicket'
|
||||
end
|
||||
|
||||
local okIssue, ticketId, issueErr = tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
|
||||
if okIssue then
|
||||
return true, ticketId, 'issueTicket'
|
||||
end
|
||||
|
||||
return tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
|
||||
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,20 +1636,34 @@ 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)
|
||||
|
||||
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
|
||||
@@ -1483,6 +1673,8 @@ local function showCardNameInput()
|
||||
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
|
||||
@@ -1490,6 +1682,8 @@ local function showCardNameInput()
|
||||
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,15 +1804,29 @@ 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-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function()
|
||||
code = code:sub(1, -2)
|
||||
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
|
||||
@@ -1611,10 +1834,16 @@ local function showCardOnlineRedeem()
|
||||
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,29 +2755,49 @@ 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)
|
||||
|
||||
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 .. 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()
|
||||
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 @@
|
||||
<!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>
|
||||
+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">
|
||||
|
||||
+333
-165
@@ -7,7 +7,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>控制台|FSE铁路运输票务系统</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="style.css?v=14">
|
||||
<link rel="stylesheet" href="style.css?v=22">
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
</head>
|
||||
@@ -53,30 +53,44 @@
|
||||
<p class="jr-admin-sidebar-copy">集中处理票务、线路、资源和运营日志。</p>
|
||||
</div>
|
||||
<div class="nav">
|
||||
<a href="https://ticket.fse-media.group" id="homeLink" class="nav-item" style="text-decoration: none;">
|
||||
<a href="https://ticket.fse-media.group" id="homeLink" class="nav-item"
|
||||
style="text-decoration: none;">
|
||||
<span class="nav-icon"><i class="fas fa-home"></i></span> 返回首页
|
||||
</a>
|
||||
<div class="nav-item" :class="{active: currentView === 'dashboard'}" @click="currentView = 'dashboard'">
|
||||
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> 仪表盘 </div>
|
||||
<div class="nav-item" :class="{active: currentView === 'dashboard'}"
|
||||
@click="currentView = 'dashboard'">
|
||||
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> 仪表盘
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: currentView === 'management'}"
|
||||
@click="currentView = 'management'">
|
||||
<span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路与票价 </div>
|
||||
<div class="nav-item" :class="{active: currentView === 'faremap'}" @click="currentView = 'faremap'">
|
||||
<span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路与票价
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: currentView === 'faremap'}"
|
||||
@click="currentView = 'faremap'">
|
||||
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: currentView === 'tickets'}" @click="currentView = 'tickets'">
|
||||
<div class="nav-item" :class="{active: currentView === 'tickets'}"
|
||||
@click="currentView = 'tickets'">
|
||||
<span class="nav-icon"><i class="fas fa-ticket-alt"></i></span> 车票记录
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: currentView === 'vouchers'}" @click="currentView = 'vouchers'">
|
||||
<div class="nav-item" :class="{active: currentView === 'vouchers'}"
|
||||
@click="currentView = 'vouchers'">
|
||||
<span class="nav-icon"><i class="fas fa-receipt"></i></span> 凭证记录
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: currentView === 'iccards'}" @click="currentView = 'iccards'">
|
||||
<div class="nav-item" :class="{active: currentView === 'iccards'}"
|
||||
@click="currentView = 'iccards'">
|
||||
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC卡管理
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: currentView === 'assets'}" @click="currentView = 'assets'">
|
||||
<span class="nav-icon"><i class="fas fa-route"></i></span> 拓扑图布局
|
||||
<div class="nav-item" :class="{active: currentView === 'issues'}"
|
||||
@click="currentView = 'issues'">
|
||||
<span class="nav-icon"><i class="fas fa-exclamation-triangle"></i></span> 问题汇总
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="currentView = 'settings'">
|
||||
<div class="nav-item" :class="{active: currentView === 'assets'}"
|
||||
@click="currentView = 'assets'">
|
||||
<span class="nav-icon"><i class="fas fa-route"></i></span> 线路图上传
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: currentView === 'settings'}"
|
||||
@click="currentView = 'settings'">
|
||||
<span class="nav-icon"><i class="fas fa-cog"></i></span> 系统设置
|
||||
</div>
|
||||
<div class="nav-item" :class="{active: currentView === 'logs'}" @click="currentView = 'logs'">
|
||||
@@ -90,7 +104,8 @@
|
||||
<div class="header">
|
||||
<div class="jr-admin-header-copy">
|
||||
<div class="flex" style="gap: 12px;">
|
||||
<button class="icon-btn mobile-only" @click="sidebarOpen = !sidebarOpen" title="菜单"><i class="fas fa-bars"></i></button>
|
||||
<button class="icon-btn mobile-only" @click="sidebarOpen = !sidebarOpen" title="菜单"><i
|
||||
class="fas fa-bars"></i></button>
|
||||
<div>
|
||||
<span class="jr-kicker">OPERATIONS CONSOLE</span>
|
||||
<h3 style="margin: 0;">{{ viewTitle }}</h3>
|
||||
@@ -106,9 +121,9 @@
|
||||
<span class="jr-admin-sync-label">最近同步</span>
|
||||
<strong>{{ lastSyncText }}</strong>
|
||||
</div>
|
||||
<button class="btn primary" @click="refreshData" :disabled="isViewBusy">
|
||||
<button class="btn" style="background: white; color: var(--text); border-color: var(--border);" @click="refreshData" :disabled="isViewBusy">
|
||||
<i class="fas" :class="isViewBusy ? 'fa-spinner fa-spin' : 'fa-rotate-right'"></i>
|
||||
{{ isViewBusy ? '同步中' : '刷新视图' }}
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +158,8 @@
|
||||
style="height: 100%; display: flex; flex-direction: column; margin-bottom: 0;">
|
||||
<div class="flex between mb-4">
|
||||
<h4>线路列表</h4>
|
||||
<button @click="showAddLine = true" title="新建线路"><i class="fas fa-plus"></i></button>
|
||||
<button @click="showAddLine = true" title="新建线路"><i
|
||||
class="fas fa-plus"></i></button>
|
||||
</div>
|
||||
|
||||
<!--添加车站-->
|
||||
@@ -156,7 +172,8 @@
|
||||
<input v-model="newLine.en_name" placeholder="英文名称"
|
||||
style="margin-bottom: 8px; width: 100%;">
|
||||
<div class="flex">
|
||||
<input type="text" v-model="newLine.color" placeholder="#HEX颜色" style="flex: 1;">
|
||||
<input type="text" v-model="newLine.color" placeholder="#HEX颜色"
|
||||
style="flex: 1;">
|
||||
<input type="color" v-model="newLine.color" title="选择颜色"
|
||||
style="width: 40px; padding: 0; border: none; height: 32px;">
|
||||
<button @click="createLine" style="padding: 0 12px;" title="确认创建线路"><i
|
||||
@@ -167,14 +184,16 @@
|
||||
</div>
|
||||
<div class="list-lines" style="flex: 1; overflow-y: auto;">
|
||||
<div v-for="l in lines" :key="l.id" class="line-item"
|
||||
:class="{active: selectedLine && selectedLine.id === l.id}" @click="selectLine(l)">
|
||||
:class="{active: selectedLine && selectedLine.id === l.id}"
|
||||
@click="selectLine(l)">
|
||||
<div class="line-color-dot" :style="{background: l.color}"></div>
|
||||
<div class="line-info">
|
||||
<div class="line-name">{{ l.name || l.id }}</div>
|
||||
<div class="line-meta">{{ (l.stations || []).length }} 站</div>
|
||||
</div>
|
||||
<div class="line-actions" v-if="selectedLine && selectedLine.id === l.id">
|
||||
<button class="danger sm" @click.stop="deleteLine(l.id)"><i class="fas fa-trash"></i></button>
|
||||
<button class="danger sm" @click.stop="deleteLine(l.id)"><i
|
||||
class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,13 +205,15 @@
|
||||
<div class="flex between">
|
||||
<div v-if="selectedLine">
|
||||
<div class="flex">
|
||||
<h3 :style="{color: selectedLine.color}">{{ selectedLine.name || selectedLine.id
|
||||
<h3 :style="{color: selectedLine.color}">{{ selectedLine.name ||
|
||||
selectedLine.id
|
||||
}}</h3>
|
||||
<span class="badge">{{ selectedLine.id }}</span>
|
||||
</div>
|
||||
<div class="flex mt-2" style="align-items:center; gap:8px;">
|
||||
<label style="font-size:0.8em; color:var(--muted);">EN:</label>
|
||||
<span style="font-size:0.9em;">{{ selectedLine.en_name || 'N/A' }}</span>
|
||||
<span style="font-size:0.9em;">{{ selectedLine.en_name || 'N/A'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -200,8 +221,10 @@
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<button v-if="selectedLine" @click="openLineModal" title="编辑线路"><i class="fas fa-pen"></i></button>
|
||||
<button @click="refreshData" title="刷新"><i class="fas fa-sync-alt"></i></button>
|
||||
<button v-if="selectedLine" @click="openLineModal" title="编辑线路"><i
|
||||
class="fas fa-pen"></i></button>
|
||||
<button @click="refreshData" title="刷新"><i
|
||||
class="fas fa-sync-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,17 +236,20 @@
|
||||
<label class="switch-label">
|
||||
<input type="checkbox" v-model="fareMode">
|
||||
<span class="slider"></span>
|
||||
<span class="label-text"><i class="fas fa-coins"></i> 票价设置/车站编辑模式</span>
|
||||
<span class="label-text"><i class="fas fa-coins"></i> 票价设置</span>
|
||||
</label>
|
||||
<label class="switch-label" style="margin-left: 10px;">
|
||||
<input type="checkbox" v-model="stationEditMode">
|
||||
<span class="slider"></span>
|
||||
<span class="label-text"><i class="fas fa-exchange-alt"></i> 换乘设置模式</span>
|
||||
<span class="label-text"><i class="fas fa-exchange-alt"></i>
|
||||
换乘设置模式/车站编辑模式</span>
|
||||
</label>
|
||||
<div v-if="fareMode" class="hint-text text-warning">
|
||||
<i class="fas fa-info-circle"></i> 点击两个站点以设置票价 </div>
|
||||
<i class="fas fa-info-circle"></i> 点击两个站点以设置票价
|
||||
</div>
|
||||
<div v-else-if="stationEditMode" class="hint-text text-info">
|
||||
<i class="fas fa-info-circle"></i> 点击站点以设置换乘 </div>
|
||||
<i class="fas fa-info-circle"></i> 点击站点以设置换乘/编辑车站
|
||||
</div>
|
||||
<div v-else class="hint-text text-muted">
|
||||
<i class="fas fa-info-circle"></i> 点击站点删除
|
||||
</div>
|
||||
@@ -232,29 +258,31 @@
|
||||
<div class="flex"
|
||||
style="background: rgba(255,255,255,0.05); padding: 8px; border-radius: 6px;">
|
||||
<div style="font-weight: bold; margin-right: 8px;">添加站点:</div>
|
||||
<input v-model="newStation.code" placeholder="编号 (01-01)" style="width: 100px;">
|
||||
<input v-model="newStation.code" placeholder="编号 (01-01)"
|
||||
style="width: 100px;">
|
||||
<input v-model="newStation.name" placeholder="中文名" style="width: 120px;">
|
||||
<input v-model="newStation.en_name" placeholder="英文名" style="width: 120px;">
|
||||
<button @click="addStationToLine"
|
||||
:disabled="!newStation.code || !newStation.name"><i class="fas fa-plus"></i>
|
||||
:disabled="!newStation.code || !newStation.name"><i
|
||||
class="fas fa-plus"></i>
|
||||
添加</button>
|
||||
<button @click="addBreakpointToLine" class="btn" style="margin-left: 8px;" title="在末尾插入一个线路断点">
|
||||
<button @click="addBreakpointToLine" class="btn" style="margin-left: 8px;"
|
||||
title="在末尾插入一个线路断点">
|
||||
<i class="fas fa-cut"></i> 插入断点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 可视化线路编辑-->
|
||||
<div class="visual-line-container"
|
||||
ref="visualLineViewport"
|
||||
<div class="visual-line-container" ref="visualLineViewport"
|
||||
:class="{ 'is-panning': lineViewportPan.active }"
|
||||
@mousedown="startLineViewportPan"
|
||||
@mousemove="moveLineViewportPan">
|
||||
@mousedown="startLineViewportPan" @mousemove="moveLineViewportPan">
|
||||
<svg :width="lineEditorSvgWidth" height="200"
|
||||
v-if="selectedLine.stations && selectedLine.stations.length > 0">
|
||||
<!--站点连接线-->
|
||||
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100"
|
||||
:stroke="selectedLine.color" stroke-width="4" stroke-linecap="round" />
|
||||
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120"
|
||||
y2="100" :stroke="selectedLine.color" stroke-width="4"
|
||||
stroke-linecap="round" />
|
||||
|
||||
<!--票价显示-->
|
||||
<g v-for="(s, i) in selectedLine.stations.slice(0, selectedLine.stations.length-1)"
|
||||
@@ -266,42 +294,57 @@
|
||||
<!--车站节点-->
|
||||
<g v-for="(sCode, index) in selectedLine.stations" :key="index"
|
||||
@mousedown="onStationDragStart(index)" @mouseup="onStationDrop"
|
||||
@mousemove="onStationDragOver(index)" @click="handleStationClick(sCode, index)"
|
||||
class="station-node" :class="{
|
||||
@mousemove="onStationDragOver(index)"
|
||||
@click="handleStationClick(sCode, index)" class="station-node" :class="{
|
||||
'selected': isStationSelected(sCode),
|
||||
'fare-source': fareSelection[0] === sCode,
|
||||
'fare-target': fareSelection[1] === sCode
|
||||
}">
|
||||
|
||||
<template v-if="sCode === '-'">
|
||||
<rect :x="50 + index * 120 - 10" :y="90" width="20" height="20" fill="var(--bg)" stroke="var(--danger)" stroke-dasharray="2,2" />
|
||||
<text :x="50 + index * 120" y="105" text-anchor="middle" fill="var(--danger)" font-size="12" style="pointer-events: none;">✂</text>
|
||||
<text :x="50 + index * 120" y="135" text-anchor="middle" fill="var(--danger)" font-size="10" style="pointer-events: none;">断点</text>
|
||||
<rect :x="50 + index * 120 - 10" :y="90" width="20" height="20"
|
||||
fill="var(--bg)" stroke="var(--danger)"
|
||||
stroke-dasharray="2,2" />
|
||||
<text :x="50 + index * 120" y="105" text-anchor="middle"
|
||||
fill="var(--danger)" font-size="12"
|
||||
style="pointer-events: none;">✂</text>
|
||||
<text :x="50 + index * 120" y="135" text-anchor="middle"
|
||||
fill="var(--danger)" font-size="10"
|
||||
style="pointer-events: none;">断点</text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!--车站节点图形-->
|
||||
<circle :cx="50 + index * 120" cy="100" r="14" fill="var(--bg)"
|
||||
:stroke="selectedLine.color" stroke-width="3" />
|
||||
<circle v-if="isStationSelected(sCode)" :cx="50 + index * 120" cy="100" r="8"
|
||||
:fill="selectedLine.color" />
|
||||
<circle v-if="isStationSelected(sCode)" :cx="50 + index * 120"
|
||||
cy="100" r="8" :fill="selectedLine.color" />
|
||||
|
||||
<!--节点标签-->
|
||||
<text :x="50 + index * 120" y="70" text-anchor="middle" fill="var(--text)"
|
||||
font-weight="bold" font-size="12" style="pointer-events: none;">{{
|
||||
<text :x="50 + index * 120" y="70" text-anchor="middle"
|
||||
fill="var(--text)" font-weight="bold" font-size="12"
|
||||
style="pointer-events: none;">{{
|
||||
getStationName(sCode) }}</text>
|
||||
<text :x="50 + index * 120" y="135" text-anchor="middle" fill="var(--muted)"
|
||||
font-size="10" style="pointer-events: none;">{{ sCode }}</text>
|
||||
<text :x="50 + index * 120" y="135" text-anchor="middle"
|
||||
fill="var(--muted)" font-size="10"
|
||||
style="pointer-events: none;">{{ sCode }}</text>
|
||||
<g v-if="getTransferLineBadges(sCode).length > 0">
|
||||
<g v-for="(li, liIdx) in getTransferLineBadges(sCode)" :key="`${sCode}-xfer-${li.id}`">
|
||||
<circle :cx="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" cy="150" r="5"
|
||||
:fill="li.color" stroke="#ffffff" stroke-width="1" />
|
||||
<text :x="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" y="165" text-anchor="middle"
|
||||
fill="var(--muted)" font-size="7" style="pointer-events: none;">{{ li.id }}</text>
|
||||
<g v-for="(li, liIdx) in getTransferLineBadges(sCode)"
|
||||
:key="`${sCode}-xfer-${li.id}`">
|
||||
<circle
|
||||
:cx="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14"
|
||||
cy="150" r="5" :fill="li.color" stroke="#ffffff"
|
||||
stroke-width="1" />
|
||||
<text
|
||||
:x="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14"
|
||||
y="165" text-anchor="middle" fill="var(--muted)"
|
||||
font-size="7" style="pointer-events: none;">{{ li.id
|
||||
}}</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!--删除-->
|
||||
<title>{{ getStationName(sCode) }} ({{ sCode }}){{ getTransferTitleSuffix(sCode) }}</title>
|
||||
<title>{{ getStationName(sCode) }} ({{ sCode }}){{
|
||||
getTransferTitleSuffix(sCode) }}</title>
|
||||
</template>
|
||||
</g>
|
||||
</svg>
|
||||
@@ -327,11 +370,13 @@
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label>常规票价</label>
|
||||
<input v-model.number="currentFare.cost_regular" type="number" class="w-100">
|
||||
<input v-model.number="currentFare.cost_regular" type="number"
|
||||
class="w-100">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label>特急票价</label>
|
||||
<input v-model.number="currentFare.cost_express" type="number" class="w-100">
|
||||
<input v-model.number="currentFare.cost_express" type="number"
|
||||
class="w-100">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="danger" @click="deleteCurrentFare"
|
||||
@@ -364,27 +409,34 @@
|
||||
<span class="label-text">设置跨名/强制换乘</span>
|
||||
</label>
|
||||
<p class="text-muted" style="font-size: 0.85em; margin-top: 6px;">
|
||||
<i class="fas fa-magic text-primary"></i> 提示:系统会自动将中文名或英文名相同的站点连接为换乘站,无需手动设置。
|
||||
<i class="fas fa-magic text-primary"></i>
|
||||
提示:系统会自动将中文名或英文名相同的站点连接为换乘站,无需手动设置。
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-4" v-if="stationForm.transfer_enabled">
|
||||
<label>可换乘到的站点</label>
|
||||
<select v-model="stationForm.transfer_to" multiple class="w-100" style="height: 180px;">
|
||||
<optgroup label="同名站点 (已自动换乘)" v-if="transferTargets.sameName.length > 0">
|
||||
<option v-for="t in transferTargets.sameName" :key="t.code" :value="t.code">
|
||||
<select v-model="stationForm.transfer_to" multiple class="w-100"
|
||||
style="height: 180px;">
|
||||
<optgroup label="同名站点 (已自动换乘)"
|
||||
v-if="transferTargets.sameName.length > 0">
|
||||
<option v-for="t in transferTargets.sameName" :key="t.code"
|
||||
:value="t.code">
|
||||
{{ t.name }} ({{ t.en_name }}) - {{ t.code }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="其他站点">
|
||||
<option v-for="t in transferTargets.others" :key="t.code" :value="t.code">
|
||||
<option v-for="t in transferTargets.others" :key="t.code"
|
||||
:value="t.code">
|
||||
{{ t.name }} ({{ t.en_name }}) - {{ t.code }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<p class="text-muted" style="font-size: 0.8em; margin-top: 4px;">按住 Ctrl / Cmd 键多选</p>
|
||||
<p class="text-muted" style="font-size: 0.8em; margin-top: 4px;">按住 Ctrl /
|
||||
Cmd 键多选</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="danger" @click="deleteStation(stationFormOriginalCode)">删除</button>
|
||||
<button class="danger"
|
||||
@click="deleteStation(stationFormOriginalCode)">删除</button>
|
||||
<button @click="saveStationSettings">保存</button>
|
||||
<button class="danger" @click="closeStationModal">取消</button>
|
||||
</div>
|
||||
@@ -409,7 +461,8 @@
|
||||
<div class="mb-4">
|
||||
<label>颜色</label>
|
||||
<div class="flex" style="gap:8px;">
|
||||
<input type="text" v-model="lineForm.color" class="w-100" placeholder="#3366cc">
|
||||
<input type="text" v-model="lineForm.color" class="w-100"
|
||||
placeholder="#3366cc">
|
||||
<input type="color" v-model="lineForm.color" title="选择颜色"
|
||||
style="width: 48px; padding: 0; border: none; height: 32px;">
|
||||
</div>
|
||||
@@ -432,14 +485,17 @@
|
||||
<button @click="loadFareMap" title="刷新"><i class="fas fa-sync-alt"></i></button>
|
||||
<button @click="zoomFareMapOut" title="缩小"><i class="fas fa-minus"></i></button>
|
||||
<button @click="zoomFareMapIn" title="放大"><i class="fas fa-plus"></i></button>
|
||||
<button @click="zoomFareMapReset" title="重置"><i class="fas fa-crosshairs"></i></button>
|
||||
<button @click="exportFareMap" title="导出图像"><i class="fas fa-download"></i></button>
|
||||
<button @click="zoomFareMapReset" title="重置"><i
|
||||
class="fas fa-crosshairs"></i></button>
|
||||
<button @click="exportFareMap" title="导出图像"><i
|
||||
class="fas fa-download"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="fareMapLoading" class="loading">加载中...</div>
|
||||
<div v-else-if="fareMapError" class="loading">{{ fareMapError }}</div>
|
||||
<div v-else class="faremap-viewport">
|
||||
<div class="faremap-canvas" :style="{ transform: `scale(${fareMapScale})` }" v-html="fareMapSvg"></div>
|
||||
<div class="faremap-canvas" :style="{ transform: `scale(${fareMapScale})` }"
|
||||
v-html="fareMapSvg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,12 +541,14 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orderList" :key="o.code">
|
||||
<td class="mono" style="font-weight:bold; font-size:1.1em;">{{ o.code }}</td>
|
||||
<td class="mono" style="font-weight:bold; font-size:1.1em;">{{ o.code }}
|
||||
</td>
|
||||
<td>{{ o.start_name }} <i class="fas fa-arrow-right text-muted"></i> {{
|
||||
o.terminal_name }}</td>
|
||||
<td>{{ formatTrainType(o.train_type) }}</td>
|
||||
<td>{{ o.price }}</td>
|
||||
<td><span class="badge" :class="formatTicketStatus(o.status).class">{{ formatTicketStatus(o.status).text }}</span></td>
|
||||
<td><span class="badge" :class="formatTicketStatus(o.status).class">{{
|
||||
formatTicketStatus(o.status).text }}</span></td>
|
||||
<td>{{ formatTime(o.created_ts) }}</td>
|
||||
<td>
|
||||
<div class="flex" style="gap:4px;">
|
||||
@@ -509,6 +567,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 站点管理迁移引导 移除 -->
|
||||
|
||||
<div v-if="currentView === 'iccards'">
|
||||
|
||||
<div class="grid">
|
||||
@@ -533,7 +593,8 @@
|
||||
<div class="management-container ic-admin-layout">
|
||||
<div class="management-sidebar">
|
||||
|
||||
<div class="card jr-admin-list-card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
|
||||
<div class="card jr-admin-list-card"
|
||||
style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
|
||||
<div class="flex between mb-4">
|
||||
<div>
|
||||
<h4>卡片列表</h4>
|
||||
@@ -542,27 +603,29 @@
|
||||
<span class="badge">{{ icCards.length }}</span>
|
||||
</div>
|
||||
<div class="flex mb-4" style="flex-wrap:wrap;">
|
||||
<input v-model="icCardSearch" placeholder="搜索卡号 / 订单号 / 凭证码 / 姓名" style="flex:1;">
|
||||
<input v-model="icCardSearch" placeholder="搜索卡号 / 订单号 / 凭证码 / 姓名"
|
||||
style="flex:1;">
|
||||
</div>
|
||||
<div class="list-lines jr-scroll-box" style="flex:1; min-height:320px;">
|
||||
<div v-if="!icCards.length" class="empty-state" style="padding:24px 0;">
|
||||
<p>暂无 IC 卡记录。</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="card in icCards"
|
||||
:key="card.card_id"
|
||||
<div v-for="card in icCards" :key="card.card_id"
|
||||
class="line-item ic-card-item"
|
||||
:class="{ active: icSelectedId === card.card_id }"
|
||||
@click="loadIcCard(card.card_id)"
|
||||
>
|
||||
<div class="line-color-dot" :style="{ background: icStatusColor(card.status) }"></div>
|
||||
@click="loadIcCard(card.card_id)">
|
||||
<div class="line-color-dot"
|
||||
:style="{ background: icStatusColor(card.status) }"></div>
|
||||
<div class="line-info">
|
||||
<div class="line-name">{{ displayIcCardId(card) }}</div>
|
||||
<div class="line-meta">{{ card.holder_name || '未登记持卡人' }} · IC 储值卡</div>
|
||||
<div class="line-meta">订单 {{ cardOrderCode(card) }} · 余额 {{ formatMoney(card.balance) }}</div>
|
||||
<div class="line-meta">{{ card.holder_name || '未登记持卡人' }} · IC 储值卡
|
||||
</div>
|
||||
<div class="line-meta">订单 {{ cardOrderCode(card) }} · 余额 {{
|
||||
formatMoney(card.balance) }}</div>
|
||||
</div>
|
||||
<div class="line-actions" style="opacity:1;">
|
||||
<span class="badge" :class="icStatusInfo(card.status).className">{{ icStatusInfo(card.status).text }}</span>
|
||||
<span class="badge" :class="icStatusInfo(card.status).className">{{
|
||||
icStatusInfo(card.status).text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,10 +639,15 @@
|
||||
<h4>卡片详情</h4>
|
||||
</div>
|
||||
<div class="flex" style="gap:8px;">
|
||||
<button class="btn" @click="loadIcCard(icSelectedId)" :disabled="!icSelectedCard || isViewBusy"><i class="fas fa-rotate-right"></i> 刷新</button>
|
||||
<button class="btn" @click="loadIcCard(icSelectedId)"
|
||||
:disabled="!icSelectedCard || isViewBusy"><i
|
||||
class="fas fa-rotate-right"></i> 刷新</button>
|
||||
|
||||
<button class="btn danger" @click="deleteIcCard" :disabled="!icSelectedCard"><i class="fas fa-trash"></i> 删除卡</button>
|
||||
<button class="btn primary" @click="saveIcCard" :disabled="!icSelectedCard"><i class="fas fa-save"></i> 保存</button>
|
||||
<button class="btn danger" @click="deleteIcCard"
|
||||
:disabled="!icSelectedCard"><i class="fas fa-trash"></i>
|
||||
删除卡</button>
|
||||
<button class="btn primary" @click="saveIcCard"
|
||||
:disabled="!icSelectedCard"><i class="fas fa-save"></i> 保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!icSelectedCard" class="empty-state">
|
||||
@@ -589,10 +657,15 @@
|
||||
<div v-else>
|
||||
<div class="flex between mb-4" style="align-items:flex-start;">
|
||||
<div>
|
||||
<div class="mono" style="font-size:1.4rem; font-weight:700;">{{ displayIcCardId(icSelectedCard) }}</div>
|
||||
<div class="text-muted" style="margin-top:6px;">订单号 {{ cardOrderCode(icSelectedCard) }} · 来源 {{ icSelectedCard.source || '---' }}</div>
|
||||
<div class="mono" style="font-size:1.4rem; font-weight:700;">{{
|
||||
displayIcCardId(icSelectedCard) }}</div>
|
||||
<div class="text-muted" style="margin-top:6px;">订单号 {{
|
||||
cardOrderCode(icSelectedCard) }} · 来源 {{ icSelectedCard.source
|
||||
|| '---' }}</div>
|
||||
</div>
|
||||
<span class="badge" :class="icStatusInfo(icSelectedCard.status).className">{{ icStatusInfo(icSelectedCard.status).text }}</span>
|
||||
<span class="badge"
|
||||
:class="icStatusInfo(icSelectedCard.status).className">{{
|
||||
icStatusInfo(icSelectedCard.status).text }}</span>
|
||||
</div>
|
||||
<div class="jr-admin-summary-grid">
|
||||
<div class="jr-admin-summary-item">
|
||||
@@ -632,10 +705,15 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="ic-inline-meta">
|
||||
<div class="list-item"><span class="k">创建时间</span><span class="v">{{ formatTime(icSelectedCard.created_ts) }}</span></div>
|
||||
<div class="list-item"><span class="k">最后更新</span><span class="v">{{ formatTime(icSelectedCard.last_update_ts) }}</span></div>
|
||||
<div class="list-item"><span class="k">首次充值</span><span class="v">{{ formatMoney(icSelectedCard.purchase_amount ?? icSelectedCard.balance) }}</span></div>
|
||||
<div class="list-item"><span class="k">购卡金额</span><span class="v">{{ formatMoney(icSelectedCard.purchase_amount) }}</span></div>
|
||||
<div class="list-item"><span class="k">创建时间</span><span class="v">{{
|
||||
formatTime(icSelectedCard.created_ts) }}</span></div>
|
||||
<div class="list-item"><span class="k">最后更新</span><span class="v">{{
|
||||
formatTime(icSelectedCard.last_update_ts) }}</span></div>
|
||||
<div class="list-item"><span class="k">首次充值</span><span class="v">{{
|
||||
formatMoney(icSelectedCard.purchase_amount ??
|
||||
icSelectedCard.balance) }}</span></div>
|
||||
<div class="list-item"><span class="k">购卡金额</span><span class="v">{{
|
||||
formatMoney(icSelectedCard.purchase_amount) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -647,14 +725,18 @@
|
||||
<div class="timeline">
|
||||
<div v-if="!icSelectedCard" class="loading">选择卡片后显示事件流。</div>
|
||||
<div v-else-if="!icSelectedEvents.length" class="loading">暂无事件记录。</div>
|
||||
<div v-for="(event, idx) in icSelectedEvents" :key="`${event.ts || 0}-${event.type || 'event'}-${idx}`" class="timeline-item">
|
||||
<div v-for="(event, idx) in icSelectedEvents"
|
||||
:key="`${event.ts || 0}-${event.type || 'event'}-${idx}`"
|
||||
class="timeline-item">
|
||||
<div class="timeline-dot"></div>
|
||||
<div class="timeline-content">
|
||||
<div class="flex between">
|
||||
<span style="font-weight:600;">{{ icEventTitle(event) }}</span>
|
||||
<span class="text-muted" style="font-size:0.8rem;">{{ formatTime(event.ts) }}</span>
|
||||
<span class="text-muted" style="font-size:0.8rem;">{{
|
||||
formatTime(event.ts) }}</span>
|
||||
</div>
|
||||
<div class="log-detail" style="margin-top:8px;">{{ formatIcEventDetail(event) }}</div>
|
||||
<div class="log-detail" style="margin-top:8px;">{{
|
||||
formatIcEventDetail(event) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -683,7 +765,8 @@
|
||||
<div class="flex between mb-4">
|
||||
<h4>车票记录</h4>
|
||||
<div class="flex">
|
||||
<input v-model="ticketSearch" placeholder="搜索 Ticket ID / 站点" style="width: 200px;">
|
||||
<input v-model="ticketSearch" placeholder="搜索 Ticket ID / 站点"
|
||||
style="width: 200px;">
|
||||
<button @click="refreshData"><i class="fas fa-sync-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -714,7 +797,8 @@
|
||||
<td>
|
||||
<div class="st-container">
|
||||
<div class="st-main-row">
|
||||
<span class="st-name">{{ getStationInfo(t.terminal).name }}</span>
|
||||
<span class="st-name">{{ getStationInfo(t.terminal).name
|
||||
}}</span>
|
||||
<span class="st-code">{{ t.terminal }}</span>
|
||||
</div>
|
||||
<div class="st-en">{{ getStationInfo(t.terminal).en_name }}</div>
|
||||
@@ -764,23 +848,27 @@
|
||||
selectedTicket.index.terminal }}</span>
|
||||
<span class="st-code">{{ selectedTicket.index.terminal }}</span>
|
||||
</div>
|
||||
<div class="st-en">{{ selectedTicket.index.terminal_en || '' }}</div>
|
||||
<div class="st-en">{{ selectedTicket.index.terminal_en || '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted mt-2" style="font-size: 0.9rem;">
|
||||
类型: {{ formatTrainType(selectedTicket.index.type ||
|
||||
selectedTicket.index.train_type) }} | 票价: {{ selectedTicket.index.price ||
|
||||
selectedTicket.index.train_type) }} | 票价: {{ selectedTicket.index.price
|
||||
||
|
||||
selectedTicket.index.cost }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>行程记录</h5>
|
||||
<div class="timeline">
|
||||
<div v-for="ev in selectedTicket.events" :key="ev.ts || ev['时间戳']" class="timeline-item">
|
||||
<div v-for="ev in selectedTicket.events" :key="ev.ts || ev['时间戳']"
|
||||
class="timeline-item">
|
||||
<div class="timeline-dot"></div>
|
||||
<div class="timeline-content">
|
||||
<div class="flex between">
|
||||
<span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span>
|
||||
<span style="font-weight: 600;">{{ formatTicketEvent(ev)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size: 0.9rem; margin-top: 4px;">
|
||||
{{ formatTicketEventUnified(ev) }}
|
||||
@@ -794,72 +882,84 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentView === 'assets'" class="assets-editor-container" style="display: flex; flex-direction: column; height: 80vh;">
|
||||
<div class="card mb-4" style="flex: 1; display: flex; flex-direction: column;">
|
||||
<div v-if="currentView === 'assets'" class="assets-editor-container">
|
||||
<div class="card mb-4">
|
||||
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
||||
<div class="flex" style="align-items: center; gap: 10px;">
|
||||
<h4>线路拓扑图布局编辑器</h4>
|
||||
<span class="badge" style="background: #2563eb; color: #fff;">拖拽站点以自定义位置</span>
|
||||
<div>
|
||||
<h4 style="margin-bottom: 6px;">线路图上传</h4>
|
||||
<div class="text-muted">上传后会用于首页展示,也会在购票页未选择线路时显示为总览图。</div>
|
||||
</div>
|
||||
<div class="flex" style="gap: 8px;">
|
||||
<button class="btn" @click="resetTopologyLayout">
|
||||
<i class="fas fa-undo"></i> 重置布局
|
||||
</button>
|
||||
<button class="btn primary" @click="saveTopologyLayout">
|
||||
<i class="fas fa-save"></i> 保存坐标
|
||||
<div class="flex" style="gap: 8px; flex-wrap: wrap;">
|
||||
<label class="btn primary" style="cursor: pointer;">
|
||||
<i class="fas fa-upload"></i> 上传线路图
|
||||
<input type="file" accept=".png,.jpg,.jpeg,.webp,.svg"
|
||||
style="display: none;" @change="uploadRouteMap">
|
||||
</label>
|
||||
<button class="btn" @click="deleteRouteMap"
|
||||
:disabled="!assetsManifest.routeMap || mutationBusy">
|
||||
<i class="fas fa-trash"></i> 删除当前线路图
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #0f172a; position: relative; user-select: none;"
|
||||
ref="topologyCanvas"
|
||||
@mousedown="onTopologyCanvasMouseDown"
|
||||
@mousemove="onTopologyCanvasMouseMove"
|
||||
@mouseup="onTopologyCanvasMouseUp"
|
||||
@mouseleave="onTopologyCanvasMouseUp"
|
||||
@wheel="onTopologyCanvasWheel">
|
||||
<div :style="{
|
||||
position: 'absolute',
|
||||
left: '0', top: '0',
|
||||
width: '100%', height: '100%',
|
||||
transformOrigin: '0 0',
|
||||
transform: `translate(${topologyPan.x}px, ${topologyPan.y}px) scale(${topologyPan.scale})`
|
||||
}">
|
||||
<!-- Grid Background (Optional) -->
|
||||
<svg width="10000" height="10000" style="position: absolute; left: -5000px; top: -5000px; pointer-events: none;">
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<rect width="40" height="40" fill="none" stroke="rgba(255,255,255,0.05)" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="10000" height="10000" fill="url(#grid)" />
|
||||
<!-- Lines -->
|
||||
<path v-for="l in topologyLines" :key="'path-'+l.id" :d="l.d" fill="none" :stroke="l.color" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0.6" />
|
||||
</svg>
|
||||
|
||||
<!-- Stations -->
|
||||
<div v-for="node in topologyNodes" :key="node.canonical"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
left: node.x + 'px',
|
||||
top: node.y + 'px',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
cursor: 'grab',
|
||||
zIndex: node.canonical === topologyDraggingNode ? 100 : 10
|
||||
}"
|
||||
@mousedown.stop="onTopologyNodeMouseDown($event, node)">
|
||||
|
||||
<div v-if="node.isTransfer" style="width: 24px; height: 24px; border-radius: 50%; background: #fff; border: 3px solid #1f2937; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 6px rgba(0,0,0,0.3);">
|
||||
<div style="width: 10px; height: 10px; border-radius: 50%; background: #cbd5e1;"></div>
|
||||
<div class="grid"
|
||||
style="display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 16px; align-items: start;">
|
||||
<div
|
||||
style="border: 1px solid var(--border); padding: 16px; background: var(--surface);">
|
||||
<div style="font-weight: 700; margin-bottom: 12px;">上传说明</div>
|
||||
<div class="text-muted" style="display: grid; gap: 8px; line-height: 1.7;">
|
||||
<div>支持格式:PNG、JPG、WEBP、SVG</div>
|
||||
<div>建议上传横向长图,便于首页和购票页展示。</div>
|
||||
<div>再次上传会自动替换旧文件。</div>
|
||||
</div>
|
||||
<div
|
||||
style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); display: grid; gap: 10px;">
|
||||
<label style="display: grid; gap: 6px;">
|
||||
<span style="font-weight: 700;">线路图署名</span>
|
||||
<input v-model="assetsForm.routeMapCredit" class="input" type="text"
|
||||
maxlength="200"
|
||||
placeholder="例如:OpenStreetMap contributors / 作者名 / 来源名">
|
||||
</label>
|
||||
<div class="text-muted">许可协议固定为 <a
|
||||
href="https://creativecommons.org/licenses/by/4.0/" target="_blank"
|
||||
rel="noopener noreferrer">CC BY 4.0</a></div>
|
||||
<div class="flex" style="gap: 8px; flex-wrap: wrap;">
|
||||
<button class="btn" @click="saveRouteMapCredit"
|
||||
:disabled="mutationBusy">
|
||||
<i class="fas fa-pen"></i> 保存署名
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); display: grid; gap: 8px;">
|
||||
<div><strong>当前文件:</strong> {{ assetsManifest.routeMap || '未上传' }}</div>
|
||||
<div><strong>当前署名:</strong> {{ assetsManifest.routeMapCredit || '未填写' }}
|
||||
</div>
|
||||
<div><strong>许可协议:</strong> <a
|
||||
href="https://creativecommons.org/licenses/by/4.0/" target="_blank"
|
||||
rel="noopener noreferrer">{{ assetsManifest.routeMapLicense }}</a>
|
||||
</div>
|
||||
<div><strong>更新时间:</strong> {{ assetsManifest.updatedAt ?
|
||||
formatTime(assetsManifest.updatedAt) : '暂无' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :style="{
|
||||
width: '20px', height: '20px', borderRadius: '50%', background: '#fff',
|
||||
border: `4px solid ${node.color || '#94a3b8'}`,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
|
||||
}"></div>
|
||||
|
||||
<div style="position: absolute; top: 26px; left: 50%; transform: translateX(-50%); white-space: nowrap; font-size: 12px; font-weight: 600; color: #f8fafc; text-shadow: 0 1px 3px rgba(0,0,0,0.8); pointer-events: none;">
|
||||
{{ node.name }}
|
||||
<div
|
||||
style="border: 1px solid var(--border); background: #0f172a; min-height: 420px; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 16px; box-sizing: border-box;">
|
||||
<div style="width: 100%;">
|
||||
<template v-if="assetsRouteMapUrl">
|
||||
<img :src="assetsRouteMapUrl" alt="线路图预览"
|
||||
style="display: block; max-width: 100%; height: auto; background: #ffffff; margin: 0 auto;">
|
||||
<div class="jr-asset-credit" style="color: #cbd5e1;">
|
||||
<span>线路图署名:{{ assetsManifest.routeMapCredit || '未填写' }}</span>
|
||||
<span>许可协议:</span>
|
||||
<a href="https://creativecommons.org/licenses/by/4.0/"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
style="color: #93c5fd;">{{ assetsManifest.routeMapLicense }}</a>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-muted" style="padding: 24px; text-align: center;">
|
||||
暂无线路图,请先上传一张线路图。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -884,7 +984,8 @@
|
||||
<div class="flex" style="flex-direction: column; gap: 10px;">
|
||||
<div>
|
||||
<button @click="pushLuaUpdate" :disabled="isPushingUpdate">
|
||||
<i class="fas fa-satellite-dish"></i> {{ isPushingUpdate ? '推送中...' : '手动推送更新' }}
|
||||
<i class="fas fa-satellite-dish"></i> {{ isPushingUpdate ? '推送中...' :
|
||||
'手动推送更新' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size: 0.9rem;">
|
||||
@@ -901,6 +1002,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<div v-if="currentView === 'logs'">
|
||||
<div class="card">
|
||||
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
||||
@@ -920,7 +1022,8 @@
|
||||
</select>
|
||||
<input v-model="logTypeFilter" placeholder="type (可逗号分隔)" style="width: 260px;">
|
||||
<input v-model="logQuery" placeholder="关键字" style="width: 220px;">
|
||||
<input v-model.number="logMax" type="number" min="10" max="5000" step="10" style="width: 120px;">
|
||||
<input v-model.number="logMax" type="number" min="10" max="5000" step="10"
|
||||
style="width: 120px;">
|
||||
<button @click="fetchLogs" :disabled="logLoading">
|
||||
<i class="fas" :class="logLoading ? 'fa-spinner fa-spin' : 'fa-filter'"></i> 筛选
|
||||
</button>
|
||||
@@ -946,7 +1049,8 @@
|
||||
<td>
|
||||
<details>
|
||||
<summary class="text-muted" style="cursor: pointer;">查看</summary>
|
||||
<pre style="white-space: pre-wrap; margin: 8px 0 0; font-size: 0.85rem; line-height: 1.35;">{{ formatLogDetail(l) }}</pre>
|
||||
<pre
|
||||
style="white-space: pre-wrap; margin: 8px 0 0; font-size: 0.85rem; line-height: 1.35;">{{ formatLogDetail(l) }}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -955,8 +1059,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 问题汇总 -->
|
||||
<div v-if="currentView === 'issues'">
|
||||
<div class="card">
|
||||
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
||||
<h4>问题汇总</h4>
|
||||
<div class="flex" style="gap: 8px;">
|
||||
<button @click="fetchIssues" title="刷新"><i class="fas fa-sync-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table v-if="issues && issues.length > 0" class="jr-issue-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>设备</th>
|
||||
<th>车站</th>
|
||||
<th>错误信息</th>
|
||||
<th>详细信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(issue, idx) in issues" :key="idx">
|
||||
<td style="white-space: nowrap;">{{ formatTime(issue.ts) }}</td>
|
||||
<td>
|
||||
<div class="jr-issue-primary mono">{{ issue.displayDevice }}</div>
|
||||
<div v-if="issue.machine_id && issue.machine_id !== issue.displayDevice"
|
||||
class="jr-issue-sub mono">{{ issue.machine_id }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="jr-issue-primary">{{ issue.displayStation }}</div>
|
||||
<div v-if="issue.station_code" class="jr-issue-sub mono">{{
|
||||
issue.station_code }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="jr-issue-error">{{ issue.displayError }}</div>
|
||||
</td>
|
||||
<td class="jr-issue-detail-cell">
|
||||
<div v-if="issue.detailEntries && issue.detailEntries.length"
|
||||
class="jr-issue-detail-card">
|
||||
<div class="jr-issue-detail-grid">
|
||||
<div v-for="entry in issue.detailEntries"
|
||||
:key="`${idx}-${entry.key}`" class="jr-issue-detail-item">
|
||||
<span>{{ entry.label }}</span>
|
||||
<strong>{{ entry.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<details v-if="issue.detailRaw" class="jr-issue-raw-block">
|
||||
<summary>查看原始内容</summary>
|
||||
<pre>{{ issue.detailRaw }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div v-else-if="issue.detailRaw" class="jr-issue-plain-detail">
|
||||
<pre>{{ issue.detailRaw }}</pre>
|
||||
</div>
|
||||
<span v-else class="text-muted">无</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state">
|
||||
<i class="fas fa-check-circle"
|
||||
style="font-size: 48px; margin-bottom: 16px; opacity: 0.3; color: var(--success);"></i>
|
||||
<p>暂无问题上报记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="site-footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank"
|
||||
rel="noopener noreferrer">粤ICP备2025450093号</a>
|
||||
<span class="version">v1.0.12</span>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -965,7 +1137,7 @@
|
||||
</main>
|
||||
</div>
|
||||
<script src="/custom-dialog.js?v=11"></script>
|
||||
<script src="index.js?v=6"></script>
|
||||
<script src="index.js?v=7"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const isDomain = location.hostname.includes('fse-media.group');
|
||||
@@ -992,7 +1164,3 @@
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
| ||||