Compare commits
39
Commits
77b487c326
...
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 |
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_BASE = "https://ticket.fse-media.group"
|
||||||
local DEFAULT_SERVER_PATH = "/api/tickets/check"
|
local DEFAULT_SERVER_PATH = "/api/tickets/check"
|
||||||
local GATE_OPEN_SECONDS = 2
|
local GATE_OPEN_SECONDS = 2
|
||||||
local VERSION = "v1.5.26"
|
local VERSION = "v1.5.33"
|
||||||
local VERSION_CHECK_INTERVAL = 5
|
local VERSION_CHECK_INTERVAL = 15
|
||||||
|
local PERIPHERAL_WAIT_INTERVAL = 0.2
|
||||||
|
local PERIPHERAL_WAIT_MAX_RETRIES = 20
|
||||||
|
|
||||||
|
|
||||||
local serverURL = nil
|
local serverURL = nil
|
||||||
|
|
||||||
@@ -86,9 +89,97 @@ local function stationSetFromList(list)
|
|||||||
return set
|
return set
|
||||||
end
|
end
|
||||||
|
|
||||||
local monitor = peripheral.find("monitor")
|
local nativeTerm = (term and type(term.current) == "function" and term.current()) or term
|
||||||
local speaker = peripheral.find("speaker")
|
local monitor = nil
|
||||||
local inspection = peripheral.find("ticket_inspection_machine")
|
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 serverConnected = nil
|
||||||
local isUpdating = false
|
local isUpdating = false
|
||||||
@@ -162,53 +253,125 @@ local function jsonDecode(str)
|
|||||||
if ok then return res else return nil end
|
if ok then return res else return nil end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function setServerConnected(ok)
|
local termDev = nativeTerm
|
||||||
if serverConnected == ok then return end
|
if peripheral and type(peripheral.getName) == "function" and monitor then
|
||||||
serverConnected = ok
|
pcall(function() monitorSide = peripheral.getName(monitor) end)
|
||||||
serverLastChangeTs = os.epoch("utc")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local termDev = term
|
local function safeCallTerm(method, ...)
|
||||||
if monitor then
|
if not termDev or type(termDev[method]) ~= "function" then return nil end
|
||||||
pcall(monitor.setTextScale, 0.5)
|
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
|
termDev = monitor
|
||||||
|
else
|
||||||
|
termDev = nativeTerm or term
|
||||||
|
end
|
||||||
|
return termDev
|
||||||
end
|
end
|
||||||
|
|
||||||
local function clear()
|
local function clear()
|
||||||
termDev.setBackgroundColor(colors.black)
|
pcall(refreshDisplayTarget)
|
||||||
termDev.setTextColor(colors.white)
|
if not termDev then return end
|
||||||
termDev.clear()
|
safeCallTerm("setBackgroundColor", colors.black)
|
||||||
termDev.setCursorPos(1, 1)
|
safeCallTerm("setTextColor", colors.white)
|
||||||
|
safeCallTerm("clear")
|
||||||
|
safeCallTerm("setCursorPos", 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function centerText(y, text, color)
|
local function centerText(y, text, color)
|
||||||
local w = termDev.getSize()
|
if not termDev then return end
|
||||||
termDev.setTextColor(color or colors.white)
|
local w = safeCallTerm("getSize")
|
||||||
local x = math.max(1, math.floor((w - #text) / 2) + 1)
|
w = tonumber(w) or 0
|
||||||
termDev.setCursorPos(x, y)
|
if w < 2 then return end
|
||||||
termDev.write(text)
|
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
|
end
|
||||||
|
|
||||||
|
local lastStartupRedrawAt = 0
|
||||||
|
|
||||||
local function drawServerStatusIndicator(w)
|
local function drawServerStatusIndicator(w)
|
||||||
|
w = tonumber(w) or 0
|
||||||
if w < 2 then return end
|
if w < 2 then return end
|
||||||
local col = colors.yellow
|
local col = colors.yellow
|
||||||
if serverConnected == true then col = colors.lime
|
if serverConnected == true then col = colors.lime
|
||||||
elseif serverConnected == false then col = colors.red end
|
elseif serverConnected == false then col = colors.red end
|
||||||
termDev.setBackgroundColor(colors.black)
|
safeCallTerm("setBackgroundColor", colors.black)
|
||||||
termDev.setTextColor(col)
|
safeCallTerm("setTextColor", col)
|
||||||
termDev.setCursorPos(w - 1, 1)
|
safeCallTerm("setCursorPos", w - 1, 1)
|
||||||
termDev.write("S")
|
safeCallTerm("write", "S")
|
||||||
termDev.setCursorPos(w, 1)
|
safeCallTerm("setCursorPos", w, 1)
|
||||||
termDev.write("*")
|
safeCallTerm("write", "*")
|
||||||
termDev.setTextColor(colors.white)
|
safeCallTerm("setTextColor", colors.white)
|
||||||
end
|
end
|
||||||
|
|
||||||
local lastUpdateError = nil
|
local lastUpdateError = nil
|
||||||
|
|
||||||
local function drawVersionIndicator(w)
|
local function drawVersionIndicator(w)
|
||||||
|
w = tonumber(w) or 0
|
||||||
local s = tostring(VERSION or "")
|
local s = tostring(VERSION or "")
|
||||||
if #s == 0 then return end
|
if #s == 0 or w < #s then return end
|
||||||
if w < #s then return end
|
|
||||||
local markerColor = colors.lime
|
local markerColor = colors.lime
|
||||||
local markerText = "* "
|
local markerText = "* "
|
||||||
if isUpdating then
|
if isUpdating then
|
||||||
@@ -218,37 +381,61 @@ local function drawVersionIndicator(w)
|
|||||||
markerColor = colors.red
|
markerColor = colors.red
|
||||||
markerText = "* "
|
markerText = "* "
|
||||||
end
|
end
|
||||||
termDev.setBackgroundColor(colors.black)
|
safeCallTerm("setBackgroundColor", colors.black)
|
||||||
termDev.setTextColor(colors.gray)
|
safeCallTerm("setTextColor", colors.gray)
|
||||||
termDev.setCursorPos(1, 1)
|
safeCallTerm("setCursorPos", 1, 1)
|
||||||
termDev.write(s)
|
safeCallTerm("write", s)
|
||||||
termDev.setTextColor(markerColor)
|
safeCallTerm("setTextColor", markerColor)
|
||||||
termDev.write(markerText)
|
safeCallTerm("write", markerText)
|
||||||
|
|
||||||
if lastUpdateError then
|
if lastUpdateError then
|
||||||
termDev.setCursorPos(1, 2)
|
safeCallTerm("setCursorPos", 1, 2)
|
||||||
termDev.setTextColor(colors.red)
|
safeCallTerm("setTextColor", colors.red)
|
||||||
termDev.write(string.sub(tostring(lastUpdateError), 1, w))
|
safeCallTerm("write", string.sub(tostring(lastUpdateError), 1, w))
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
local function draw(statusLine1, statusLine2, statusColor)
|
local function draw(statusLine1, statusLine2, statusColor)
|
||||||
clear()
|
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)
|
centerText(2, "GATE", colors.cyan)
|
||||||
drawVersionIndicator(w)
|
drawVersionIndicator(w)
|
||||||
drawServerStatusIndicator(w)
|
drawServerStatusIndicator(w)
|
||||||
if statusLine1 and #statusLine1 > 0 then
|
local lines = {}
|
||||||
centerText(math.max(2, math.floor(h / 2)), statusLine1, statusColor or colors.white)
|
for _, line in ipairs(splitDisplayLines(statusLine1)) do table.insert(lines, line) end
|
||||||
|
for _, line in ipairs(splitDisplayLines(statusLine2)) do table.insert(lines, line) end
|
||||||
|
if #lines > 0 then
|
||||||
|
local maxBodyY = math.max(3, h - 1)
|
||||||
|
local startY = math.max(3, math.floor((maxBodyY - #lines) / 2) + 1)
|
||||||
|
for idx, line in ipairs(lines) do
|
||||||
|
local y = startY + idx - 1
|
||||||
|
if y > maxBodyY then break end
|
||||||
|
centerText(y, line, statusColor or colors.white)
|
||||||
end
|
end
|
||||||
if statusLine2 and #statusLine2 > 0 then
|
|
||||||
centerText(math.min(h, math.max(3, math.floor(h / 2) + 1)), statusLine2, statusColor or colors.white)
|
|
||||||
end
|
end
|
||||||
termDev.setCursorPos(1, h)
|
safeCallTerm("setCursorPos", 1, h)
|
||||||
termDev.setTextColor(colors.gray)
|
safeCallTerm("setTextColor", colors.gray)
|
||||||
termDev.write(string.rep(" ", w))
|
safeCallTerm("write", string.rep(" ", w))
|
||||||
end
|
end
|
||||||
|
|
||||||
local function pulseLeftRedstone(seconds)
|
local function pulseLeftRedstone(seconds)
|
||||||
@@ -770,15 +957,31 @@ if type(cfg.side_station_codes) == "table" then
|
|||||||
end
|
end
|
||||||
|
|
||||||
pcall(function()
|
pcall(function()
|
||||||
refreshStationNameMap(guessBaseFromStatusURL(serverURL))
|
|
||||||
end)
|
|
||||||
|
|
||||||
pcall(function()
|
|
||||||
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
|
|
||||||
end)
|
|
||||||
|
|
||||||
if not inspection then
|
|
||||||
if modeBySide == nil 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)
|
draw("Missing peripheral:", "ticket_inspection_machine", colors.red)
|
||||||
error("ticket_inspection_machine not found")
|
error("ticket_inspection_machine not found")
|
||||||
end
|
end
|
||||||
@@ -814,6 +1017,15 @@ local function stationCodeForSide(side)
|
|||||||
return defaultStationCode()
|
return defaultStationCode()
|
||||||
end
|
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)
|
local function isInspectionPeripheral(p)
|
||||||
return type(p) == "table" and (
|
return type(p) == "table" and (
|
||||||
type(p.getLastScanned) == "function"
|
type(p.getLastScanned) == "function"
|
||||||
@@ -909,15 +1121,38 @@ local function readyLine1()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function readyLine2()
|
local function readyLine2()
|
||||||
if not modeBySide then
|
local lines = {}
|
||||||
return "ST " .. stationCodeForSide(nil)
|
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
|
end
|
||||||
local frontCode = stationCodeForSide("front")
|
|
||||||
local backCode = stationCodeForSide("back")
|
if type(sideStationCodeBySide) == "table" and next(sideStationCodeBySide) ~= nil then
|
||||||
if frontCode == backCode then
|
local orderedSides = { "front", "back", "left", "right", "top", "bottom" }
|
||||||
return "ST " .. frontCode
|
for _, side in ipairs(orderedSides) do
|
||||||
|
local code = displayStationCode(sideStationCodeBySide[side] or "")
|
||||||
|
if #code > 0 then
|
||||||
|
addLine(string.upper(side:sub(1, 1)) .. " " .. code)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
local function drawReadyScreen()
|
local function drawReadyScreen()
|
||||||
@@ -1105,9 +1340,35 @@ local function queryFare(fromStation, toStation)
|
|||||||
return toMoney(fare), nil
|
return toMoney(fare), nil
|
||||||
end
|
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)
|
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
|
end
|
||||||
|
|
||||||
local function deductICCardBalance(dev, amount)
|
local function deductICCardBalance(dev, amount)
|
||||||
@@ -1153,7 +1414,10 @@ local function handleICCardScan(scan, side, scanDev)
|
|||||||
local cardId = getCardId(scan)
|
local cardId = getCardId(scan)
|
||||||
if #cardId == 0 then
|
if #cardId == 0 then
|
||||||
draw("Invalid card.", "Missing card_id.", colors.red)
|
draw("Invalid card.", "Missing card_id.", colors.red)
|
||||||
playDfpwm("error.dfpwm")
|
parallel.waitForAll(
|
||||||
|
function() playDfpwm("error.dfpwm") end,
|
||||||
|
function() triggerFaultLights(inspectionDevs) end
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1164,13 +1428,13 @@ local function handleICCardScan(scan, side, scanDev)
|
|||||||
local usedAction = action
|
local usedAction = action
|
||||||
|
|
||||||
if #exitStation == 0 then
|
if #exitStation == 0 then
|
||||||
denyCard("missing_station", "Missing gate station")
|
denyCard("missing_station", "Missing gate station", inspectionDevs)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if usedAction == "entry" then
|
if usedAction == "entry" then
|
||||||
if getCardEntered(scan) and not getCardExited(scan) then
|
if getCardEntered(scan) and not getCardExited(scan) then
|
||||||
denyCard("already_entered", "Already entered")
|
denyCard("already_entered", "Already entered", inspectionDevs)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local okWrite = true
|
local okWrite = true
|
||||||
@@ -1186,36 +1450,42 @@ local function handleICCardScan(scan, side, scanDev)
|
|||||||
end
|
end
|
||||||
if not okWrite then
|
if not okWrite then
|
||||||
draw("WRITE ERROR", tostring(writeErr or "Failed to update card"), colors.red)
|
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
|
return
|
||||||
end
|
end
|
||||||
entryStation = exitStation
|
entryStation = exitStation
|
||||||
else
|
else
|
||||||
if not getCardEntered(scan) then
|
if not getCardEntered(scan) then
|
||||||
denyCard("not_entered", "Not entered")
|
denyCard("not_entered", "Not entered", inspectionDevs)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if getCardExited(scan) then
|
if getCardExited(scan) then
|
||||||
denyCard("already_exited", "Already exited")
|
denyCard("already_exited", "Already exited", inspectionDevs)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if #entryStation == 0 then
|
if #entryStation == 0 then
|
||||||
denyCard("missing_entry_station", "Missing entry station")
|
denyCard("missing_entry_station", "Missing entry station", inspectionDevs)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local fareValue, fareErr = queryFare(entryStation, exitStation)
|
local fareValue, fareErr = queryFare(entryStation, exitStation)
|
||||||
if fareValue == nil then
|
if fareValue == nil then
|
||||||
if fareErr == "net_error" then
|
if fareErr == "net_error" then
|
||||||
draw("NET ERROR", "Fare lookup failed", colors.red)
|
draw("NET ERROR", "Fare lookup failed", colors.red)
|
||||||
playDfpwm("error.dfpwm")
|
parallel.waitForAll(
|
||||||
|
function() playDfpwm("error.dfpwm") end,
|
||||||
|
function() triggerFaultLights(inspectionDevs) end
|
||||||
|
)
|
||||||
else
|
else
|
||||||
denyCard("fare_not_found", fareErr)
|
denyCard("fare_not_found", fareErr, inspectionDevs)
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
fare = fareValue
|
fare = fareValue
|
||||||
if balance < fare then
|
if balance < fare then
|
||||||
denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance))
|
denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance), inspectionDevs)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local okWrite = true
|
local okWrite = true
|
||||||
@@ -1238,10 +1508,13 @@ local function handleICCardScan(scan, side, scanDev)
|
|||||||
else
|
else
|
||||||
okWrite = false
|
okWrite = false
|
||||||
if tostring(newBalanceOrErr) == "insufficient" then
|
if tostring(newBalanceOrErr) == "insufficient" then
|
||||||
denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance))
|
denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance), inspectionDevs)
|
||||||
else
|
else
|
||||||
draw("WRITE ERROR", tostring(newBalanceOrErr or "deduct_failed"), colors.red)
|
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
|
end
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
@@ -1249,7 +1522,10 @@ local function handleICCardScan(scan, side, scanDev)
|
|||||||
if not okWrite then
|
if not okWrite then
|
||||||
if writeErr then
|
if writeErr then
|
||||||
draw("WRITE ERROR", tostring(writeErr), colors.red)
|
draw("WRITE ERROR", tostring(writeErr), colors.red)
|
||||||
playDfpwm("error.dfpwm")
|
parallel.waitForAll(
|
||||||
|
function() playDfpwm("error.dfpwm") end,
|
||||||
|
function() triggerFaultLights(inspectionDevs) end
|
||||||
|
)
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -1315,7 +1591,10 @@ local function handleScan(scan, side, scanDev)
|
|||||||
local ticketId = getTicketId(scan)
|
local ticketId = getTicketId(scan)
|
||||||
if not ticketId then
|
if not ticketId then
|
||||||
draw("Invalid ticket.", "Missing ticketId.", colors.red)
|
draw("Invalid ticket.", "Missing ticketId.", colors.red)
|
||||||
playDfpwm("error.dfpwm")
|
parallel.waitForAll(
|
||||||
|
function() playDfpwm("error.dfpwm") end,
|
||||||
|
function() triggerFaultLights(inspectionDevs) end
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1353,13 +1632,19 @@ local function handleScan(scan, side, scanDev)
|
|||||||
|
|
||||||
if not ok or type(resp) ~= "table" then
|
if not ok or type(resp) ~= "table" then
|
||||||
draw("NET ERROR", "Server check failed.", colors.red)
|
draw("NET ERROR", "Server check failed.", colors.red)
|
||||||
playDfpwm("error.dfpwm")
|
parallel.waitForAll(
|
||||||
|
function() playDfpwm("error.dfpwm") end,
|
||||||
|
function() triggerFaultLights(inspectionDevs) end
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if resp.result ~= "pass" then
|
if resp.result ~= "pass" then
|
||||||
draw("DENIED", tostring(resp.reason or "deny"), colors.red)
|
draw("DENIED", tostring(resp.reason or "deny"), colors.red)
|
||||||
playDfpwm("error.dfpwm")
|
parallel.waitForAll(
|
||||||
|
function() playDfpwm("error.dfpwm") end,
|
||||||
|
function() triggerFaultLights(inspectionDevs) end
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1424,7 +1709,7 @@ local function shouldProcessScan(scan, side, eventName)
|
|||||||
local now = os.epoch("utc")
|
local now = os.epoch("utc")
|
||||||
local prev = recentScans[key]
|
local prev = recentScans[key]
|
||||||
recentScans[key] = now
|
recentScans[key] = now
|
||||||
if prev and (now - prev) < 500 then
|
if prev and (now - prev) < 200 then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
return true
|
return true
|
||||||
@@ -1477,12 +1762,28 @@ local function mainLoop()
|
|||||||
local ev = pack(os.pullEvent())
|
local ev = pack(os.pullEvent())
|
||||||
if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then
|
if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then
|
||||||
processInspectionEvent(ev[1], ev)
|
processInspectionEvent(ev[1], ev)
|
||||||
os.sleep(0.35)
|
os.sleep(0.1)
|
||||||
drawReadyScreen()
|
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
|
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 function checkAndApplyUpdate()
|
||||||
local serverBase = guessBaseFromStatusURL(serverURL)
|
local serverBase = guessBaseFromStatusURL(serverURL)
|
||||||
local txt = fetchHTTP(serverBase .. '/api/check-update?_t=' .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999)))
|
local txt = fetchHTTP(serverBase .. '/api/check-update?_t=' .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999)))
|
||||||
@@ -1543,6 +1844,7 @@ end
|
|||||||
|
|
||||||
local function backgroundSyncTask()
|
local function backgroundSyncTask()
|
||||||
pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end)
|
pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end)
|
||||||
|
pcall(function() refreshStationNameMap(guessBaseFromStatusURL(serverURL)) end)
|
||||||
while true do
|
while true do
|
||||||
sleep(VERSION_CHECK_INTERVAL)
|
sleep(VERSION_CHECK_INTERVAL)
|
||||||
pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end)
|
pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end)
|
||||||
@@ -1550,4 +1852,4 @@ local function backgroundSyncTask()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
parallel.waitForAny(mainLoop, backgroundSyncTask)
|
parallel.waitForAny(mainLoop, startupRedrawTask, backgroundSyncTask)
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@ module.exports = {
|
|||||||
cors: {
|
cors: {
|
||||||
origin: "*",
|
origin: "*",
|
||||||
methods: ["GET", "POST"]
|
methods: ["GET", "POST"]
|
||||||
}
|
},
|
||||||
|
transports: ['websocket']
|
||||||
});
|
});
|
||||||
|
|
||||||
io.engine.on('connection_error', (err) => {
|
io.engine.on('connection_error', (err) => {
|
||||||
|
|||||||
@@ -1191,6 +1191,29 @@ router.get('/export', (req, res) => {
|
|||||||
res.json(DataService.buildExportPayload());
|
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
|
// Ticket Operations
|
||||||
router.post('/tickets/sale', async (req, res) => {
|
router.post('/tickets/sale', async (req, res) => {
|
||||||
const { start, terminal, train_type, cost, station_code, device, trips_total, trips_remaining } = req.body || {};
|
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 ASSETS_DIR = path.join(__dirname, '../../Assets');
|
||||||
const MANIFEST_PATH = path.join(ASSETS_DIR, 'manifest.json');
|
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 || '';
|
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');
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(out, null, 2), 'utf8');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeCredit = (value) => String(value || '').trim().slice(0, 200);
|
||||||
|
|
||||||
const removeByPrefix = (prefix) => {
|
const removeByPrefix = (prefix) => {
|
||||||
ensureDir(ASSETS_DIR);
|
ensureDir(ASSETS_DIR);
|
||||||
const list = fs.readdirSync(ASSETS_DIR);
|
const list = fs.readdirSync(ASSETS_DIR);
|
||||||
@@ -97,6 +100,8 @@ router.get('/manifest', (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
routeMap,
|
routeMap,
|
||||||
|
routeMapCredit: normalizeCredit(m.routeMapCredit || ''),
|
||||||
|
routeMapLicense: ROUTE_MAP_LICENSE,
|
||||||
fareTable,
|
fareTable,
|
||||||
updatedAt: m.updatedAt || null
|
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: '缺少文件' });
|
if (!req.file) return res.status(400).json({ ok: false, error: '缺少文件' });
|
||||||
const m = readManifest();
|
const m = readManifest();
|
||||||
m.routeMap = path.basename(req.file.filename);
|
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();
|
m.updatedAt = Date.now();
|
||||||
writeManifest(m);
|
writeManifest(m);
|
||||||
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap } });
|
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap, credit: m.routeMapCredit || '' } });
|
||||||
res.json({ ok: true, routeMap: m.routeMap, updatedAt: m.updatedAt });
|
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) => {
|
router.post('/fare-table', uploadFareTable.single('file'), (req, res) => {
|
||||||
@@ -129,6 +148,7 @@ router.delete('/route-map', (req, res) => {
|
|||||||
}
|
}
|
||||||
removeByPrefix('route-map');
|
removeByPrefix('route-map');
|
||||||
m.routeMap = null;
|
m.routeMap = null;
|
||||||
|
m.routeMapCredit = '';
|
||||||
m.updatedAt = Date.now();
|
m.updatedAt = Date.now();
|
||||||
writeManifest(m);
|
writeManifest(m);
|
||||||
appendReqLog(req, { category: 'admin', type: 'asset_delete_route_map', detail: { ok: true } });
|
appendReqLog(req, { category: 'admin', type: 'asset_delete_route_map', detail: { ok: true } });
|
||||||
|
|||||||
+15
-1
@@ -64,7 +64,8 @@ const cache = {
|
|||||||
icCardIndex: {},
|
icCardIndex: {},
|
||||||
icCardEvents: [],
|
icCardEvents: [],
|
||||||
statsTicket: [],
|
statsTicket: [],
|
||||||
statsGate: []
|
statsGate: [],
|
||||||
|
issues: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const DataService = {
|
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 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_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 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
|
// Load Cache
|
||||||
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
|
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
|
||||||
@@ -126,6 +128,9 @@ const DataService = {
|
|||||||
const [statsG] = await conn.query('SELECT data FROM stats_gate');
|
const [statsG] = await conn.query('SELECT data FROM stats_gate');
|
||||||
cache.statsGate = statsG.map(r => r.data);
|
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');
|
console.log('DataService initialized with MySQL');
|
||||||
} finally {
|
} finally {
|
||||||
conn.release();
|
conn.release();
|
||||||
@@ -511,6 +516,15 @@ const DataService = {
|
|||||||
},
|
},
|
||||||
getStatsGate: () => cache.statsGate,
|
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
|
// Export
|
||||||
buildExportPayload: () => ({
|
buildExportPayload: () => ({
|
||||||
config: DataService.getConfig(),
|
config: DataService.getConfig(),
|
||||||
|
|||||||
+393
-91
@@ -1,6 +1,6 @@
|
|||||||
local CURRENT_STATION_CODE = 'Ticket-Machine'
|
local CURRENT_STATION_CODE = 'Ticket-Machine'
|
||||||
local API_BASE = 'https://ticket.fse-media.group/api'
|
local API_BASE = 'https://ticket.fse-media.group/api'
|
||||||
local VERSION = 'v1.5.22'
|
local VERSION = 'v1.5.30'
|
||||||
local UPDATE_CHECK_INTERVAL = 5
|
local UPDATE_CHECK_INTERVAL = 5
|
||||||
|
|
||||||
-- ###########################
|
-- ###########################
|
||||||
@@ -327,6 +327,19 @@ local function enqueueTicketUpload(ticketData)
|
|||||||
return false
|
return false
|
||||||
end
|
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
|
-- Peripheral discovery
|
||||||
-- ###########################
|
-- ###########################
|
||||||
@@ -562,10 +575,6 @@ local function issueTicketFromPeripheral(fromNameEnArg, toNameEnArg, apiType, ri
|
|||||||
if type(dev) ~= 'table' then
|
if type(dev) ~= 'table' then
|
||||||
return false, '', 'peripheral_unavailable'
|
return false, '', 'peripheral_unavailable'
|
||||||
end
|
end
|
||||||
local fn = dev.issueTicket
|
|
||||||
if type(fn) ~= 'function' then
|
|
||||||
return false, '', 'unsupported_method'
|
|
||||||
end
|
|
||||||
|
|
||||||
local function normalizeIssuedTicketId(id)
|
local function normalizeIssuedTicketId(id)
|
||||||
if id == nil then return '' end
|
if id == nil then return '' end
|
||||||
@@ -582,25 +591,107 @@ local function issueTicketFromPeripheral(fromNameEnArg, toNameEnArg, apiType, ri
|
|||||||
return s
|
return s
|
||||||
end
|
end
|
||||||
|
|
||||||
local function tryIssue(...)
|
local fallbackId = normalizeIssuedTicketId(fallbackTicketId)
|
||||||
local okCall, r1, r2, r3 = pcall(fn, ...)
|
local typeCandidates = { tostring(apiType or 'local') }
|
||||||
if not (okCall and peripheralCallSucceeded(r1)) then
|
if apiType == 'limited_express' then
|
||||||
return false, '', okCall and 'issue_failed' or 'issue_call_failed'
|
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
|
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)
|
local normalizedId = normalizeIssuedTicketId(issuedId)
|
||||||
if #normalizedId == 0 then
|
if #normalizedId > 0 then
|
||||||
return false, '', 'invalid_ticket_id'
|
return true, normalizedId, variant.method
|
||||||
|
end
|
||||||
|
lastError = 'invalid_ticket_id'
|
||||||
|
else
|
||||||
|
local detail = extractPeripheralId(r1, r2, r3)
|
||||||
|
if okCall then
|
||||||
|
lastError = variant.method .. ':' .. tostring(detail ~= '' and detail or 'issue_failed')
|
||||||
|
else
|
||||||
|
lastError = variant.method .. ':' .. tostring(r1 or 'issue_call_failed')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
return true, normalizedId, 'issueTicket'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local okIssue, ticketId, issueErr = tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
|
return false, '', lastError
|
||||||
if okIssue then
|
|
||||||
return true, ticketId, 'issueTicket'
|
|
||||||
end
|
|
||||||
|
|
||||||
return tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ###########################
|
-- ###########################
|
||||||
@@ -990,6 +1081,16 @@ local function nearestCCColor(val)
|
|||||||
return colors.gray
|
return colors.gray
|
||||||
end
|
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()
|
local function clear()
|
||||||
termDev.setBackgroundColor(colors.black)
|
termDev.setBackgroundColor(colors.black)
|
||||||
termDev.clear()
|
termDev.clear()
|
||||||
@@ -1024,6 +1125,31 @@ local function addButton(x, y, label, wBtn, hBtn, colorsPair, onClick)
|
|||||||
termDev.write(label)
|
termDev.write(label)
|
||||||
end
|
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)
|
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)
|
return px >= btn.x and px <= (btn.x + btn.w - 1) and py >= btn.y and py <= (btn.y + btn.h - 1)
|
||||||
end
|
end
|
||||||
@@ -1055,7 +1181,7 @@ state = {
|
|||||||
departure = nil, terminal = nil, trainType = nil, trips = 1, cost = 0, paid = 0, doneAudioPlayed = false,
|
departure = nil, terminal = nil, trainType = nil, trips = 1, cost = 0, paid = 0, doneAudioPlayed = false,
|
||||||
productMode = 'ticket', cardMode = nil, cardPaymentMode = 'local',
|
productMode = 'ticket', cardMode = nil, cardPaymentMode = 'local',
|
||||||
holderName = '', cardDeposit = 0, cardTopup = 0, cardBalance = 0, cardOrderValue = 0,
|
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()
|
local function getCardConfig()
|
||||||
@@ -1090,6 +1216,7 @@ local function resetTicketFlow()
|
|||||||
state.card_id = nil
|
state.card_id = nil
|
||||||
state.card_server_data = nil
|
state.card_server_data = nil
|
||||||
state.pendingBlankCardId = nil
|
state.pendingBlankCardId = nil
|
||||||
|
state.cardIssueWarning = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function resetCardFlow(mode)
|
local function resetCardFlow(mode)
|
||||||
@@ -1109,6 +1236,7 @@ local function resetCardFlow(mode)
|
|||||||
state.card_id = nil
|
state.card_id = nil
|
||||||
state.card_server_data = nil
|
state.card_server_data = nil
|
||||||
state.pendingBlankCardId = nil
|
state.pendingBlankCardId = nil
|
||||||
|
state.cardIssueWarning = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function isCardOrderLike(data)
|
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)
|
addButton(bx+4+bw, by+4, 'NO', bw, 1, {colors.green, colors.white}, function() ui_cancel_confirmed = false; ui_cancel_request = false end)
|
||||||
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 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
|
local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1
|
||||||
termDev.setBackgroundColor(colors.gray)
|
termDev.setBackgroundColor(colors.gray)
|
||||||
for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end
|
for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end
|
||||||
msg = tostring(msg or '')
|
local displayMsg = tostring(msg or '')
|
||||||
if #msg > boxW-4 then msg = msg:sub(1, boxW-4) end
|
if #displayMsg > boxW-4 then displayMsg = displayMsg:sub(1, boxW-4) end
|
||||||
termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(msg)
|
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 = {}
|
Buttons = {}
|
||||||
addButton(bx+math.floor((boxW-6)/2), by+4, 'OK', 6, 1, {colors.green, colors.white}, function() end)
|
addButton(bx+math.floor((boxW-6)/2), by+4, 'OK', 6, 1, {colors.green, colors.white}, function() end)
|
||||||
waitButtons()
|
waitButtons()
|
||||||
@@ -1282,8 +1418,12 @@ local function showSingleSegmentSelection()
|
|||||||
local startIdx = singleSegmentScroll * itemsPerPage + 1
|
local startIdx = singleSegmentScroll * itemsPerPage + 1
|
||||||
local endIdx = math.min(startIdx + itemsPerPage - 1, #singleSegments)
|
local endIdx = math.min(startIdx + itemsPerPage - 1, #singleSegments)
|
||||||
|
|
||||||
local bw = math.floor((w - 6) / 2)
|
local sbX = w - 3
|
||||||
local bx1, bx2 = 2, w - bw - 1
|
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
|
local by = 5
|
||||||
for i = startIdx, endIdx do
|
for i = startIdx, endIdx do
|
||||||
local seg = singleSegments[i]
|
local seg = singleSegments[i]
|
||||||
@@ -1292,20 +1432,28 @@ local function showSingleSegmentSelection()
|
|||||||
local x = colIdx == 0 and bx1 or bx2
|
local x = colIdx == 0 and bx1 or bx2
|
||||||
local y = by + rowIdx * 4
|
local y = by + rowIdx * 4
|
||||||
|
|
||||||
local label = seg.lineName .. ' cogs:' .. tostring(seg.cost)
|
local lineColor = nearestCCColor(seg.color)
|
||||||
if #label > bw then label = label:sub(1, bw) end
|
local textColor = contrastTextColor(lineColor)
|
||||||
local isSelected = (state.singleSegment == seg)
|
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
|
state.singleSegment = seg
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
if singleSegmentScroll > 0 then
|
drawVerticalScrollbar(
|
||||||
addButton(w-8, 2, 'UP', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll - 1 end)
|
sbX,
|
||||||
end
|
by,
|
||||||
if singleSegmentScroll < maxScroll then
|
h - 6,
|
||||||
addButton(w-8, h-5, 'DN', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll + 1 end)
|
singleSegmentScroll * itemsPerPage,
|
||||||
end
|
maxScroll * itemsPerPage,
|
||||||
|
itemsPerPage,
|
||||||
|
function() singleSegmentScroll = math.max(0, singleSegmentScroll - 1) end,
|
||||||
|
function() singleSegmentScroll = math.min(maxScroll, singleSegmentScroll + 1) end
|
||||||
|
)
|
||||||
|
|
||||||
addCancelButton()
|
addCancelButton()
|
||||||
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
|
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
|
local cx = math.floor((w - btnW) / 2) + 1
|
||||||
addButton(cx, y-4, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
|
addButton(cx, y-4, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
|
||||||
resetTicketFlow()
|
resetTicketFlow()
|
||||||
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
|
stopAudio()
|
||||||
state.page = 'departure'
|
state.page = 'new_menu'
|
||||||
end)
|
end)
|
||||||
addButton(cx, y, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function()
|
addButton(cx, y, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function()
|
||||||
resetTicketFlow()
|
resetTicketFlow()
|
||||||
@@ -1353,34 +1501,48 @@ local function showHome()
|
|||||||
resetCardFlow('open')
|
resetCardFlow('open')
|
||||||
stopAudio(); state.page = 'card_home'
|
stopAudio(); state.page = 'card_home'
|
||||||
end)
|
end)
|
||||||
addButton(cx, y+8, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
|
|
||||||
resetTicketFlow()
|
|
||||||
stopAudio(); state.page = 'online'
|
|
||||||
end)
|
|
||||||
else
|
else
|
||||||
local cx = math.floor((w - btnW) / 2) + 1
|
local cx = math.floor((w - btnW) / 2) + 1
|
||||||
addButton(x1, y-2, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
|
addButton(x1, y-2, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
|
||||||
resetTicketFlow()
|
resetTicketFlow()
|
||||||
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
|
stopAudio()
|
||||||
state.page = 'departure'
|
state.page = 'new_menu'
|
||||||
end)
|
end)
|
||||||
addButton(x2, y-2, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function()
|
addButton(x2, y-2, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function()
|
||||||
resetTicketFlow()
|
resetTicketFlow()
|
||||||
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
|
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
|
||||||
state.page = 'single_segment'
|
state.page = 'single_segment'
|
||||||
end)
|
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')
|
resetCardFlow('open')
|
||||||
stopAudio(); state.page = 'card_home'
|
stopAudio(); state.page = 'card_home'
|
||||||
end)
|
end)
|
||||||
addButton(x2, y+2, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
|
|
||||||
resetTicketFlow()
|
|
||||||
stopAudio(); state.page = 'online'
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
waitButtons()
|
waitButtons()
|
||||||
end
|
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()
|
local function showCardHome()
|
||||||
while state.page == 'card_home' do
|
while state.page == 'card_home' do
|
||||||
drawHeader('IC Card Service', 'Open card or redeem online order')
|
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)
|
local function isAllowedHolderChar(ch)
|
||||||
if type(ch) ~= 'string' or #ch ~= 1 then return false end
|
if type(ch) ~= 'string' or #ch ~= 1 then return false end
|
||||||
if ch:match('[A-Za-z ]') then return true 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:match('[A-Za-z]') then ch = ch:upper() end
|
||||||
if ch == ' ' and (#name == 0 or name:sub(-1) == ' ') then return end
|
if ch == ' ' and (#name == 0 or name:sub(-1) == ' ') then return end
|
||||||
name = name .. ch
|
name = name .. ch
|
||||||
|
msg = ''
|
||||||
|
renderInput()
|
||||||
end
|
end
|
||||||
|
|
||||||
while state.page == 'card_name' do
|
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
|
local rows = (keyboardMode == 'symbol') and symbolRows or alphaRows
|
||||||
drawHeader('Enter Holder Name', (keyboardMode == 'symbol') and 'Symbol keyboard' or 'Letters / symbols / space')
|
drawHeader('Enter Holder Name', (keyboardMode == 'symbol') and 'Symbol keyboard' or 'Letters / symbols / space')
|
||||||
centerText(4, '[' .. placeholder .. ']', colors.yellow)
|
renderInput()
|
||||||
if msg and #msg > 0 then centerText(5, msg, msgCol) end
|
|
||||||
Buttons = {}
|
Buttons = {}
|
||||||
local keyW, keyH = (w < 44 and 2 or 3), 2
|
local keyW, keyH = (w < 44 and 2 or 3), 2
|
||||||
local sY = 7
|
local sY = 7
|
||||||
@@ -1460,20 +1636,34 @@ local function showCardNameInput()
|
|||||||
appendHolderChar(' ')
|
appendHolderChar(' ')
|
||||||
end)
|
end)
|
||||||
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' 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()
|
addButton(12, h-3, (keyboardMode == 'symbol') and 'ABC' or 'SYM', 8, 3, {colors.black, colors.orange}, function()
|
||||||
keyboardMode = (keyboardMode == 'symbol') and 'alpha' or 'symbol'
|
keyboardMode = (keyboardMode == 'symbol') and 'alpha' or 'symbol'
|
||||||
end)
|
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()
|
addButton(w-11, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function()
|
||||||
local clean = firstString(name)
|
local clean = firstString(name)
|
||||||
if #clean < 2 then
|
if #clean < 2 then
|
||||||
msg, msgCol = 'Name too short', colors.red
|
msg, msgCol = 'Name too short', colors.red
|
||||||
|
renderInput()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
state.holderName = clean
|
state.holderName = clean
|
||||||
state.page = 'card_topup'
|
state.page = 'card_topup'
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
while state.page == 'card_name' and keyboardMode == currentKeyboardMode do
|
||||||
local ev, p1, p2, p3 = os.pullEvent()
|
local ev, p1, p2, p3 = os.pullEvent()
|
||||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||||
for _, b in ipairs(Buttons) do
|
for _, b in ipairs(Buttons) do
|
||||||
@@ -1483,6 +1673,8 @@ local function showCardNameInput()
|
|||||||
appendHolderChar(tostring(p1 or ''))
|
appendHolderChar(tostring(p1 or ''))
|
||||||
elseif ev == 'key' and p1 == keys.backspace then
|
elseif ev == 'key' and p1 == keys.backspace then
|
||||||
name = name:sub(1, -2)
|
name = name:sub(1, -2)
|
||||||
|
msg = ''
|
||||||
|
renderInput()
|
||||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
||||||
local clean = firstString(name)
|
local clean = firstString(name)
|
||||||
if #clean >= 2 then
|
if #clean >= 2 then
|
||||||
@@ -1490,6 +1682,8 @@ local function showCardNameInput()
|
|||||||
state.page = 'card_topup'
|
state.page = 'card_topup'
|
||||||
else
|
else
|
||||||
msg, msgCol = 'Name too short', colors.red
|
msg, msgCol = 'Name too short', colors.red
|
||||||
|
renderInput()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1544,9 +1738,24 @@ local function showCardOnlineRedeem()
|
|||||||
{'Z','X','C','V','B','N','M'}
|
{'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()
|
local function submitCode()
|
||||||
if #code ~= 5 then
|
if #code ~= 5 then
|
||||||
msg, msgCol = 'Need 5 chars', colors.red
|
msg, msgCol = 'Need 5 chars', colors.red
|
||||||
|
renderInput()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local res = fetchJSON(API_BASE .. '/public/ic-cards/orders/' .. code)
|
local res = fetchJSON(API_BASE .. '/public/ic-cards/orders/' .. code)
|
||||||
@@ -1555,17 +1764,20 @@ local function showCardOnlineRedeem()
|
|||||||
end
|
end
|
||||||
if not (res and res.ok) then
|
if not (res and res.ok) then
|
||||||
msg, msgCol = 'Voucher Invalid', colors.red
|
msg, msgCol = 'Voucher Invalid', colors.red
|
||||||
|
renderInput()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local d = res.data or res
|
local d = res.data or res
|
||||||
local cardStatus = tostring(d.status or ''):lower()
|
local cardStatus = tostring(d.status or ''):lower()
|
||||||
if d.consumed or (cardStatus ~= '' and cardStatus ~= 'pending_pickup' and isCardOrderLike(d)) then
|
if d.consumed or (cardStatus ~= '' and cardStatus ~= 'pending_pickup' and isCardOrderLike(d)) then
|
||||||
msg, msgCol = 'Already Used!', colors.red
|
msg, msgCol = 'Already Used!', colors.red
|
||||||
|
renderInput()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local cardOrder = buildCardOrderState(d, code)
|
local cardOrder = buildCardOrderState(d, code)
|
||||||
if not cardOrder then
|
if not cardOrder then
|
||||||
msg, msgCol = 'Not a card order', colors.red
|
msg, msgCol = 'Not a card order', colors.red
|
||||||
|
renderInput()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
resetCardFlow('redeem')
|
resetCardFlow('redeem')
|
||||||
@@ -1583,10 +1795,7 @@ local function showCardOnlineRedeem()
|
|||||||
end
|
end
|
||||||
|
|
||||||
while state.page == 'card_online' do
|
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')
|
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 = {}
|
Buttons = {}
|
||||||
local keyW, keyH = (w < 44 and 2 or 3), 2
|
local keyW, keyH = (w < 44 and 2 or 3), 2
|
||||||
local kbW = 10 * (keyW + 1)
|
local kbW = 10 * (keyW + 1)
|
||||||
@@ -1595,15 +1804,29 @@ local function showCardOnlineRedeem()
|
|||||||
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
|
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
|
||||||
for _, ch in ipairs(row) do
|
for _, ch in ipairs(row) do
|
||||||
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function()
|
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)
|
end)
|
||||||
x = x + keyW + 1
|
x = x + keyW + 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' 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-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function()
|
||||||
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() code = '' end)
|
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)
|
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()
|
local ev, p1, p2, p3 = os.pullEvent()
|
||||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||||
for _, b in ipairs(Buttons) do
|
for _, b in ipairs(Buttons) do
|
||||||
@@ -1611,10 +1834,16 @@ local function showCardOnlineRedeem()
|
|||||||
end
|
end
|
||||||
elseif ev == 'char' and #code < 5 then
|
elseif ev == 'char' and #code < 5 then
|
||||||
code = code .. tostring(p1 or ''):upper()
|
code = code .. tostring(p1 or ''):upper()
|
||||||
|
msg = ''
|
||||||
|
renderInput()
|
||||||
elseif ev == 'key' and p1 == keys.backspace then
|
elseif ev == 'key' and p1 == keys.backspace then
|
||||||
code = code:sub(1, -2)
|
code = code:sub(1, -2)
|
||||||
|
msg = ''
|
||||||
|
renderInput()
|
||||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
||||||
submitCode()
|
submitCode()
|
||||||
|
elseif ev == 'config_updated' then
|
||||||
|
end
|
||||||
end
|
end
|
||||||
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))
|
local ch = computeHeight(); local maxO = math.max(0, ch - (endY-startY+1))
|
||||||
ui_scroll_offset = math.min(ui_scroll_offset, maxO)
|
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)
|
drawVerticalScrollbar(
|
||||||
addButton(sbX, endY+1, 'v', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end)
|
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
|
local y = startY
|
||||||
for _, l in ipairs(CFG.lines) do
|
for _, l in ipairs(CFG.lines) do
|
||||||
@@ -1737,7 +1974,7 @@ local function showTerminal()
|
|||||||
invalidUntil = os.epoch('utc') + 1500
|
invalidUntil = os.epoch('utc') + 1500
|
||||||
sel = nil
|
sel = nil
|
||||||
state.terminal = nil
|
state.terminal = nil
|
||||||
showAlert('Same station')
|
showAlert('Same station', 'Dep and Term are identical')
|
||||||
else
|
else
|
||||||
state.terminal = sel
|
state.terminal = sel
|
||||||
end
|
end
|
||||||
@@ -1995,7 +2232,7 @@ local function showOrderAndAudio()
|
|||||||
else
|
else
|
||||||
local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
|
local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
|
||||||
if not unit then
|
if not unit then
|
||||||
showAlert('No route')
|
showAlert('No route', 'Cannot find fare path')
|
||||||
state.page = 'terminal'
|
state.page = 'terminal'
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2006,7 +2243,7 @@ local function showOrderAndAudio()
|
|||||||
end
|
end
|
||||||
|
|
||||||
if state.productMode ~= 'card' and state.trainType ~= 'single' and sameLogicalStation(state.departure, state.terminal) then
|
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'
|
state.page = state.voucher_code and 'online' or 'terminal'
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2019,7 +2256,7 @@ local function showOrderAndAudio()
|
|||||||
local function confirmAction()
|
local function confirmAction()
|
||||||
if processing or confirmed then return end
|
if processing or confirmed then return end
|
||||||
if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then
|
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
|
if render then render() end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2054,6 +2291,7 @@ local function showOrderAndAudio()
|
|||||||
end
|
end
|
||||||
if okIssueBlank and #blankCardId > 0 then
|
if okIssueBlank and #blankCardId > 0 then
|
||||||
payload.card_id = blankCardId
|
payload.card_id = blankCardId
|
||||||
|
state.card_id = blankCardId
|
||||||
state.pendingBlankCardId = blankCardId
|
state.pendingBlankCardId = blankCardId
|
||||||
local okReq, code, parsed, err = submitCardOpen(payload)
|
local okReq, code, parsed, err = submitCardOpen(payload)
|
||||||
if okReq then
|
if okReq then
|
||||||
@@ -2065,11 +2303,15 @@ local function showOrderAndAudio()
|
|||||||
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
|
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
|
||||||
state.card_server_data = respData
|
state.card_server_data = respData
|
||||||
state.pendingBlankCardId = nil
|
state.pendingBlankCardId = nil
|
||||||
|
state.cardIssueWarning = nil
|
||||||
confirmed = true
|
confirmed = true
|
||||||
statusMsg, statusCol = 'Card ready', colors.green
|
statusMsg, statusCol = 'Card ready', colors.green
|
||||||
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
|
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
|
||||||
else
|
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
|
end
|
||||||
else
|
else
|
||||||
local errorMsg = 'Card API Err'
|
local errorMsg = 'Card API Err'
|
||||||
@@ -2080,9 +2322,14 @@ local function showOrderAndAudio()
|
|||||||
elseif code and type(code) == 'number' then
|
elseif code and type(code) == 'number' then
|
||||||
errorMsg = 'HTTP ' .. tostring(code)
|
errorMsg = 'HTTP ' .. tostring(code)
|
||||||
end
|
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
|
end
|
||||||
elseif okIssueBlank then
|
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
|
statusMsg, statusCol = 'Card issued without ID', colors.red
|
||||||
else
|
else
|
||||||
local okReq, code, parsed, err = submitCardOpen(payload)
|
local okReq, code, parsed, err = submitCardOpen(payload)
|
||||||
@@ -2095,6 +2342,7 @@ local function showOrderAndAudio()
|
|||||||
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
|
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
|
||||||
state.card_server_data = respData
|
state.card_server_data = respData
|
||||||
state.pendingBlankCardId = nil
|
state.pendingBlankCardId = nil
|
||||||
|
state.cardIssueWarning = nil
|
||||||
confirmed = true
|
confirmed = true
|
||||||
statusMsg, statusCol = 'Card ready', colors.green
|
statusMsg, statusCol = 'Card ready', colors.green
|
||||||
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
|
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
|
||||||
@@ -2227,7 +2475,8 @@ end
|
|||||||
|
|
||||||
local function showDone()
|
local function showDone()
|
||||||
if state.productMode == 'card' then
|
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 y = 5
|
||||||
local function line(label, value, col)
|
local function line(label, value, col)
|
||||||
termDev.setTextColor(colors.white)
|
termDev.setTextColor(colors.white)
|
||||||
@@ -2242,6 +2491,7 @@ local function showDone()
|
|||||||
line('Balance', tonumber(state.cardBalance) or 0, colors.green)
|
line('Balance', tonumber(state.cardBalance) or 0, colors.green)
|
||||||
line('Card ID', firstString(state.card_id, 'PENDING'), colors.cyan)
|
line('Card ID', firstString(state.card_id, 'PENDING'), colors.cyan)
|
||||||
if state.voucher_code then line('Voucher', state.voucher_code, colors.cyan) end
|
if state.voucher_code then line('Voucher', state.voucher_code, colors.cyan) end
|
||||||
|
if warning ~= '' then line('Status', warning, colors.red) end
|
||||||
saveCardIssueSnapshot({
|
saveCardIssueSnapshot({
|
||||||
card_id = state.card_id,
|
card_id = state.card_id,
|
||||||
holder_name = state.holderName,
|
holder_name = state.holderName,
|
||||||
@@ -2249,6 +2499,7 @@ local function showDone()
|
|||||||
topup = state.cardTopup,
|
topup = state.cardTopup,
|
||||||
balance = state.cardBalance,
|
balance = state.cardBalance,
|
||||||
voucher_code = state.voucher_code,
|
voucher_code = state.voucher_code,
|
||||||
|
issue_warning = warning,
|
||||||
station_code = state.stationCode or CURRENT_STATION_CODE,
|
station_code = state.stationCode or CURRENT_STATION_CODE,
|
||||||
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
|
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')
|
local issueError = tostring(issueMethod or 'ticket_issue_failed')
|
||||||
print('Ticket issue failed: ' .. issueError)
|
print('Ticket issue failed: ' .. issueError)
|
||||||
_G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError
|
_G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError
|
||||||
showAlert('Ticket issue failed')
|
showAlert('Ticket issue failed', issueError)
|
||||||
resetTicketFlow()
|
resetTicketFlow()
|
||||||
state.page = 'home'
|
state.page = 'home'
|
||||||
return
|
return
|
||||||
@@ -2432,6 +2683,20 @@ local function showOnlineVoucher()
|
|||||||
{'Z','X','C','V','B','N','M'}
|
{'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()
|
local function submitCode()
|
||||||
if #code == 5 then
|
if #code == 5 then
|
||||||
local res = fetchJSON(API_BASE .. '/public/orders/' .. code)
|
local res = fetchJSON(API_BASE .. '/public/orders/' .. code)
|
||||||
@@ -2441,6 +2706,7 @@ local function showOnlineVoucher()
|
|||||||
-- Check if already consumed
|
-- Check if already consumed
|
||||||
if d.consumed then
|
if d.consumed then
|
||||||
msg, msgCol = 'Already Used!', colors.red
|
msg, msgCol = 'Already Used!', colors.red
|
||||||
|
renderInput()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2454,15 +2720,16 @@ local function showOnlineVoucher()
|
|||||||
state.page = 'order'
|
state.page = 'order'
|
||||||
else
|
else
|
||||||
msg, msgCol = 'Voucher Invalid', colors.red
|
msg, msgCol = 'Voucher Invalid', colors.red
|
||||||
|
renderInput()
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
msg, msgCol = 'Need 5 chars', colors.red
|
||||||
|
renderInput()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
while state.page == 'online' do
|
while state.page == 'online' do
|
||||||
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
|
|
||||||
drawHeader('Enter Voucher', 'Type 5 chars then OK')
|
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 = {}
|
Buttons = {}
|
||||||
|
|
||||||
local keyW, keyH = (w < 44 and 2 or 3), 2
|
local keyW, keyH = (w < 44 and 2 or 3), 2
|
||||||
@@ -2472,7 +2739,13 @@ local function showOnlineVoucher()
|
|||||||
for rIdx, row in ipairs(rows) do
|
for rIdx, row in ipairs(rows) do
|
||||||
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
|
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
|
||||||
for _, ch in ipairs(row) do
|
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
|
x = x + keyW + 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2482,29 +2755,49 @@ local function showOnlineVoucher()
|
|||||||
local gap = 2
|
local gap = 2
|
||||||
local total = bw + gap + cw + gap + ow
|
local total = bw + gap + cw + gap + ow
|
||||||
local ax = math.floor((w - total) / 2) + 1
|
local ax = math.floor((w - total) / 2) + 1
|
||||||
|
local smallMode = false
|
||||||
if ax < 1 then
|
if ax < 1 then
|
||||||
|
smallMode = true
|
||||||
bw, cw, ow = 6, 6, 6
|
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
|
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
|
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)
|
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()
|
local ev, p1, p2, p3 = os.pullEvent()
|
||||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
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
|
for _, b in ipairs(Buttons) do
|
||||||
elseif ev == 'char' and #code < 5 then code = code .. p1:upper()
|
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
||||||
elseif ev == 'key' and p1 == keys.backspace then code = code:sub(1, -2)
|
end
|
||||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode()
|
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
|
elseif ev == 'config_updated' then
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ###########################
|
-- ###########################
|
||||||
@@ -2513,6 +2806,7 @@ end
|
|||||||
local function mainPageLoop()
|
local function mainPageLoop()
|
||||||
while true do
|
while true do
|
||||||
if state.page == 'home' then showHome()
|
if state.page == 'home' then showHome()
|
||||||
|
elseif state.page == 'new_menu' then showNewMenu()
|
||||||
elseif state.page == 'single_segment' then showSingleSegmentSelection()
|
elseif state.page == 'single_segment' then showSingleSegmentSelection()
|
||||||
elseif state.page == 'card_home' then showCardHome()
|
elseif state.page == 'card_home' then showCardHome()
|
||||||
elseif state.page == 'card_name' then showCardNameInput()
|
elseif state.page == 'card_name' then showCardNameInput()
|
||||||
@@ -2530,4 +2824,12 @@ local function mainPageLoop()
|
|||||||
end
|
end
|
||||||
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_REFILL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/refillmachine.lua"
|
||||||
local URL_UPDATE_REFILL = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_refill.lua"
|
local 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>
|
</html>
|
||||||
+23
-8
@@ -6,7 +6,7 @@
|
|||||||
<title>首页|FSE铁路运输票务系统</title>
|
<title>首页|FSE铁路运输票务系统</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=14">
|
<link rel="stylesheet" href="/style.css?v=22">
|
||||||
</head>
|
</head>
|
||||||
<body class="public-search jr-public-page">
|
<body class="public-search jr-public-page">
|
||||||
<div class="jr-public-shell">
|
<div class="jr-public-shell">
|
||||||
@@ -16,11 +16,7 @@
|
|||||||
<i class="fas fa-train"></i>
|
<i class="fas fa-train"></i>
|
||||||
<span>FSE铁路运输票务系统</span>
|
<span>FSE铁路运输票务系统</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
|
||||||
<span class="jr-top-status-label">服务器状态</span>
|
|
||||||
<span class="jr-top-status-dot"></span>
|
|
||||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -184,6 +180,26 @@
|
|||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=11"></script>
|
||||||
<script src="/public-status.js?v=14"></script>
|
<script src="/public-status.js?v=14"></script>
|
||||||
<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 loadPublicAssets = async () => {
|
||||||
const fareMapBox = document.getElementById('fareMapBox');
|
const fareMapBox = document.getElementById('fareMapBox');
|
||||||
const routeMapBox = document.getElementById('routeMapBox');
|
const routeMapBox = document.getElementById('routeMapBox');
|
||||||
@@ -209,6 +225,7 @@
|
|||||||
img.src = `/assets/${encodeURIComponent(m.routeMap)}?v=${encodeURIComponent(mv)}`;
|
img.src = `/assets/${encodeURIComponent(m.routeMap)}?v=${encodeURIComponent(mv)}`;
|
||||||
routeMapBox.innerHTML = '<div class="jr-asset-frame"></div>';
|
routeMapBox.innerHTML = '<div class="jr-asset-frame"></div>';
|
||||||
routeMapBox.firstElementChild.appendChild(img);
|
routeMapBox.firstElementChild.appendChild(img);
|
||||||
|
routeMapBox.appendChild(createRouteMapCredit(m.routeMapCredit, m.routeMapLicense));
|
||||||
} else {
|
} else {
|
||||||
routeMapBox.innerHTML = '<div class="text-muted">暂无线路图</div>';
|
routeMapBox.innerHTML = '<div class="text-muted">暂无线路图</div>';
|
||||||
}
|
}
|
||||||
@@ -245,5 +262,3 @@
|
|||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>IC卡管理|FSE铁路运输票务系统</title>
|
<title>IC卡管理|FSE铁路运输票务系统</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=14">
|
<link rel="stylesheet" href="/style.css?v=22">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="jr-admin-page jr-admin-ic-page jr-public-page">
|
<body class="jr-admin-page jr-admin-ic-page jr-public-page">
|
||||||
@@ -18,11 +18,7 @@
|
|||||||
<i class="fas fa-train"></i>
|
<i class="fas fa-train"></i>
|
||||||
<span>FSE 铁路运输后台系统</span>
|
<span>FSE 铁路运输后台系统</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
|
||||||
<span class="jr-top-status-label">服务器状态</span>
|
|
||||||
<span class="jr-top-status-dot"></span>
|
|
||||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="jr-brandbar">
|
<div class="jr-brandbar">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>IC卡详情|FSE铁路运输票务系统</title>
|
<title>IC卡详情|FSE铁路运输票务系统</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=14">
|
<link rel="stylesheet" href="/style.css?v=22">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="public-search jr-public-page">
|
<body class="public-search jr-public-page">
|
||||||
@@ -18,11 +18,7 @@
|
|||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
<span>返回首页</span>
|
<span>返回首页</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
|
||||||
<span class="jr-top-status-label">服务器状态</span>
|
|
||||||
<span class="jr-top-status-dot"></span>
|
|
||||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="jr-brandbar">
|
<div class="jr-brandbar">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>线上购卡|FSE铁路运输票务系统</title>
|
<title>线上购卡|FSE铁路运输票务系统</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=14">
|
<link rel="stylesheet" href="/style.css?v=22">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="public-search jr-public-page">
|
<body class="public-search jr-public-page">
|
||||||
@@ -18,11 +18,7 @@
|
|||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
<span>返回首页</span>
|
<span>返回首页</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
|
||||||
<span class="jr-top-status-label">服务器状态</span>
|
|
||||||
<span class="jr-top-status-dot"></span>
|
|
||||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="jr-brandbar">
|
<div class="jr-brandbar">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>IC卡查询|FSE铁路运输票务系统</title>
|
<title>IC卡查询|FSE铁路运输票务系统</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=14">
|
<link rel="stylesheet" href="/style.css?v=22">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="public-search jr-public-page">
|
<body class="public-search jr-public-page">
|
||||||
@@ -18,11 +18,7 @@
|
|||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
<span>返回首页</span>
|
<span>返回首页</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
|
||||||
<span class="jr-top-status-label">服务器状态</span>
|
|
||||||
<span class="jr-top-status-dot"></span>
|
|
||||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="jr-brandbar">
|
<div class="jr-brandbar">
|
||||||
|
|||||||
+333
-165
@@ -7,7 +7,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>控制台|FSE铁路运输票务系统</title>
|
<title>控制台|FSE铁路运输票务系统</title>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="style.css?v=14">
|
<link rel="stylesheet" href="style.css?v=22">
|
||||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -53,30 +53,44 @@
|
|||||||
<p class="jr-admin-sidebar-copy">集中处理票务、线路、资源和运营日志。</p>
|
<p class="jr-admin-sidebar-copy">集中处理票务、线路、资源和运营日志。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav">
|
<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> 返回首页
|
<span class="nav-icon"><i class="fas fa-home"></i></span> 返回首页
|
||||||
</a>
|
</a>
|
||||||
<div class="nav-item" :class="{active: currentView === 'dashboard'}" @click="currentView = 'dashboard'">
|
<div class="nav-item" :class="{active: currentView === 'dashboard'}"
|
||||||
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> 仪表盘 </div>
|
@click="currentView = 'dashboard'">
|
||||||
|
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> 仪表盘
|
||||||
|
</div>
|
||||||
<div class="nav-item" :class="{active: currentView === 'management'}"
|
<div class="nav-item" :class="{active: currentView === 'management'}"
|
||||||
@click="currentView = 'management'">
|
@click="currentView = 'management'">
|
||||||
<span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路与票价 </div>
|
<span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路与票价
|
||||||
<div class="nav-item" :class="{active: currentView === 'faremap'}" @click="currentView = 'faremap'">
|
</div>
|
||||||
|
<div class="nav-item" :class="{active: currentView === 'faremap'}"
|
||||||
|
@click="currentView = 'faremap'">
|
||||||
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
|
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
|
||||||
</div>
|
</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> 车票记录
|
<span class="nav-icon"><i class="fas fa-ticket-alt"></i></span> 车票记录
|
||||||
</div>
|
</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> 凭证记录
|
<span class="nav-icon"><i class="fas fa-receipt"></i></span> 凭证记录
|
||||||
</div>
|
</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卡管理
|
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC卡管理
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" :class="{active: currentView === 'assets'}" @click="currentView = 'assets'">
|
<div class="nav-item" :class="{active: currentView === 'issues'}"
|
||||||
<span class="nav-icon"><i class="fas fa-route"></i></span> 拓扑图布局
|
@click="currentView = 'issues'">
|
||||||
|
<span class="nav-icon"><i class="fas fa-exclamation-triangle"></i></span> 问题汇总
|
||||||
</div>
|
</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> 系统设置
|
<span class="nav-icon"><i class="fas fa-cog"></i></span> 系统设置
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" :class="{active: currentView === 'logs'}" @click="currentView = 'logs'">
|
<div class="nav-item" :class="{active: currentView === 'logs'}" @click="currentView = 'logs'">
|
||||||
@@ -90,7 +104,8 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="jr-admin-header-copy">
|
<div class="jr-admin-header-copy">
|
||||||
<div class="flex" style="gap: 12px;">
|
<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>
|
<div>
|
||||||
<span class="jr-kicker">OPERATIONS CONSOLE</span>
|
<span class="jr-kicker">OPERATIONS CONSOLE</span>
|
||||||
<h3 style="margin: 0;">{{ viewTitle }}</h3>
|
<h3 style="margin: 0;">{{ viewTitle }}</h3>
|
||||||
@@ -106,9 +121,9 @@
|
|||||||
<span class="jr-admin-sync-label">最近同步</span>
|
<span class="jr-admin-sync-label">最近同步</span>
|
||||||
<strong>{{ lastSyncText }}</strong>
|
<strong>{{ lastSyncText }}</strong>
|
||||||
</div>
|
</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>
|
<i class="fas" :class="isViewBusy ? 'fa-spinner fa-spin' : 'fa-rotate-right'"></i>
|
||||||
{{ isViewBusy ? '同步中' : '刷新视图' }}
|
刷新
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +158,8 @@
|
|||||||
style="height: 100%; display: flex; flex-direction: column; margin-bottom: 0;">
|
style="height: 100%; display: flex; flex-direction: column; margin-bottom: 0;">
|
||||||
<div class="flex between mb-4">
|
<div class="flex between mb-4">
|
||||||
<h4>线路列表</h4>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!--添加车站-->
|
<!--添加车站-->
|
||||||
@@ -156,7 +172,8 @@
|
|||||||
<input v-model="newLine.en_name" placeholder="英文名称"
|
<input v-model="newLine.en_name" placeholder="英文名称"
|
||||||
style="margin-bottom: 8px; width: 100%;">
|
style="margin-bottom: 8px; width: 100%;">
|
||||||
<div class="flex">
|
<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="选择颜色"
|
<input type="color" v-model="newLine.color" title="选择颜色"
|
||||||
style="width: 40px; padding: 0; border: none; height: 32px;">
|
style="width: 40px; padding: 0; border: none; height: 32px;">
|
||||||
<button @click="createLine" style="padding: 0 12px;" title="确认创建线路"><i
|
<button @click="createLine" style="padding: 0 12px;" title="确认创建线路"><i
|
||||||
@@ -167,14 +184,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="list-lines" style="flex: 1; overflow-y: auto;">
|
<div class="list-lines" style="flex: 1; overflow-y: auto;">
|
||||||
<div v-for="l in lines" :key="l.id" class="line-item"
|
<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-color-dot" :style="{background: l.color}"></div>
|
||||||
<div class="line-info">
|
<div class="line-info">
|
||||||
<div class="line-name">{{ l.name || l.id }}</div>
|
<div class="line-name">{{ l.name || l.id }}</div>
|
||||||
<div class="line-meta">{{ (l.stations || []).length }} 站</div>
|
<div class="line-meta">{{ (l.stations || []).length }} 站</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="line-actions" v-if="selectedLine && selectedLine.id === l.id">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,13 +205,15 @@
|
|||||||
<div class="flex between">
|
<div class="flex between">
|
||||||
<div v-if="selectedLine">
|
<div v-if="selectedLine">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<h3 :style="{color: selectedLine.color}">{{ selectedLine.name || selectedLine.id
|
<h3 :style="{color: selectedLine.color}">{{ selectedLine.name ||
|
||||||
|
selectedLine.id
|
||||||
}}</h3>
|
}}</h3>
|
||||||
<span class="badge">{{ selectedLine.id }}</span>
|
<span class="badge">{{ selectedLine.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex mt-2" style="align-items:center; gap:8px;">
|
<div class="flex mt-2" style="align-items:center; gap:8px;">
|
||||||
<label style="font-size:0.8em; color:var(--muted);">EN:</label>
|
<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>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@@ -200,8 +221,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<button v-if="selectedLine" @click="openLineModal" title="编辑线路"><i class="fas fa-pen"></i></button>
|
<button v-if="selectedLine" @click="openLineModal" title="编辑线路"><i
|
||||||
<button @click="refreshData" title="刷新"><i class="fas fa-sync-alt"></i></button>
|
class="fas fa-pen"></i></button>
|
||||||
|
<button @click="refreshData" title="刷新"><i
|
||||||
|
class="fas fa-sync-alt"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,17 +236,20 @@
|
|||||||
<label class="switch-label">
|
<label class="switch-label">
|
||||||
<input type="checkbox" v-model="fareMode">
|
<input type="checkbox" v-model="fareMode">
|
||||||
<span class="slider"></span>
|
<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>
|
||||||
<label class="switch-label" style="margin-left: 10px;">
|
<label class="switch-label" style="margin-left: 10px;">
|
||||||
<input type="checkbox" v-model="stationEditMode">
|
<input type="checkbox" v-model="stationEditMode">
|
||||||
<span class="slider"></span>
|
<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>
|
</label>
|
||||||
<div v-if="fareMode" class="hint-text text-warning">
|
<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">
|
<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">
|
<div v-else class="hint-text text-muted">
|
||||||
<i class="fas fa-info-circle"></i> 点击站点删除
|
<i class="fas fa-info-circle"></i> 点击站点删除
|
||||||
</div>
|
</div>
|
||||||
@@ -232,29 +258,31 @@
|
|||||||
<div class="flex"
|
<div class="flex"
|
||||||
style="background: rgba(255,255,255,0.05); padding: 8px; border-radius: 6px;">
|
style="background: rgba(255,255,255,0.05); padding: 8px; border-radius: 6px;">
|
||||||
<div style="font-weight: bold; margin-right: 8px;">添加站点:</div>
|
<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.name" placeholder="中文名" style="width: 120px;">
|
||||||
<input v-model="newStation.en_name" placeholder="英文名" style="width: 120px;">
|
<input v-model="newStation.en_name" placeholder="英文名" style="width: 120px;">
|
||||||
<button @click="addStationToLine"
|
<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>
|
||||||
<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> 插入断点
|
<i class="fas fa-cut"></i> 插入断点
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 可视化线路编辑-->
|
<!-- 可视化线路编辑-->
|
||||||
<div class="visual-line-container"
|
<div class="visual-line-container" ref="visualLineViewport"
|
||||||
ref="visualLineViewport"
|
|
||||||
:class="{ 'is-panning': lineViewportPan.active }"
|
:class="{ 'is-panning': lineViewportPan.active }"
|
||||||
@mousedown="startLineViewportPan"
|
@mousedown="startLineViewportPan" @mousemove="moveLineViewportPan">
|
||||||
@mousemove="moveLineViewportPan">
|
|
||||||
<svg :width="lineEditorSvgWidth" height="200"
|
<svg :width="lineEditorSvgWidth" height="200"
|
||||||
v-if="selectedLine.stations && selectedLine.stations.length > 0">
|
v-if="selectedLine.stations && selectedLine.stations.length > 0">
|
||||||
<!--站点连接线-->
|
<!--站点连接线-->
|
||||||
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100"
|
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120"
|
||||||
:stroke="selectedLine.color" stroke-width="4" stroke-linecap="round" />
|
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)"
|
<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"
|
<g v-for="(sCode, index) in selectedLine.stations" :key="index"
|
||||||
@mousedown="onStationDragStart(index)" @mouseup="onStationDrop"
|
@mousedown="onStationDragStart(index)" @mouseup="onStationDrop"
|
||||||
@mousemove="onStationDragOver(index)" @click="handleStationClick(sCode, index)"
|
@mousemove="onStationDragOver(index)"
|
||||||
class="station-node" :class="{
|
@click="handleStationClick(sCode, index)" class="station-node" :class="{
|
||||||
'selected': isStationSelected(sCode),
|
'selected': isStationSelected(sCode),
|
||||||
'fare-source': fareSelection[0] === sCode,
|
'fare-source': fareSelection[0] === sCode,
|
||||||
'fare-target': fareSelection[1] === sCode
|
'fare-target': fareSelection[1] === sCode
|
||||||
}">
|
}">
|
||||||
|
|
||||||
<template v-if="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" />
|
<rect :x="50 + index * 120 - 10" :y="90" width="20" height="20"
|
||||||
<text :x="50 + index * 120" y="105" text-anchor="middle" fill="var(--danger)" font-size="12" style="pointer-events: none;">✂</text>
|
fill="var(--bg)" stroke="var(--danger)"
|
||||||
<text :x="50 + index * 120" y="135" text-anchor="middle" fill="var(--danger)" font-size="10" style="pointer-events: none;">断点</text>
|
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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!--车站节点图形-->
|
<!--车站节点图形-->
|
||||||
<circle :cx="50 + index * 120" cy="100" r="14" fill="var(--bg)"
|
<circle :cx="50 + index * 120" cy="100" r="14" fill="var(--bg)"
|
||||||
:stroke="selectedLine.color" stroke-width="3" />
|
:stroke="selectedLine.color" stroke-width="3" />
|
||||||
<circle v-if="isStationSelected(sCode)" :cx="50 + index * 120" cy="100" r="8"
|
<circle v-if="isStationSelected(sCode)" :cx="50 + index * 120"
|
||||||
:fill="selectedLine.color" />
|
cy="100" r="8" :fill="selectedLine.color" />
|
||||||
|
|
||||||
<!--节点标签-->
|
<!--节点标签-->
|
||||||
<text :x="50 + index * 120" y="70" text-anchor="middle" fill="var(--text)"
|
<text :x="50 + index * 120" y="70" text-anchor="middle"
|
||||||
font-weight="bold" font-size="12" style="pointer-events: none;">{{
|
fill="var(--text)" font-weight="bold" font-size="12"
|
||||||
|
style="pointer-events: none;">{{
|
||||||
getStationName(sCode) }}</text>
|
getStationName(sCode) }}</text>
|
||||||
<text :x="50 + index * 120" y="135" text-anchor="middle" fill="var(--muted)"
|
<text :x="50 + index * 120" y="135" text-anchor="middle"
|
||||||
font-size="10" style="pointer-events: none;">{{ sCode }}</text>
|
fill="var(--muted)" font-size="10"
|
||||||
|
style="pointer-events: none;">{{ sCode }}</text>
|
||||||
<g v-if="getTransferLineBadges(sCode).length > 0">
|
<g v-if="getTransferLineBadges(sCode).length > 0">
|
||||||
<g v-for="(li, liIdx) in getTransferLineBadges(sCode)" :key="`${sCode}-xfer-${li.id}`">
|
<g v-for="(li, liIdx) in getTransferLineBadges(sCode)"
|
||||||
<circle :cx="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" cy="150" r="5"
|
:key="`${sCode}-xfer-${li.id}`">
|
||||||
:fill="li.color" stroke="#ffffff" stroke-width="1" />
|
<circle
|
||||||
<text :x="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" y="165" text-anchor="middle"
|
:cx="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14"
|
||||||
fill="var(--muted)" font-size="7" style="pointer-events: none;">{{ li.id }}</text>
|
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>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!--删除-->
|
<!--删除-->
|
||||||
<title>{{ getStationName(sCode) }} ({{ sCode }}){{ getTransferTitleSuffix(sCode) }}</title>
|
<title>{{ getStationName(sCode) }} ({{ sCode }}){{
|
||||||
|
getTransferTitleSuffix(sCode) }}</title>
|
||||||
</template>
|
</template>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -327,11 +370,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label>常规票价</label>
|
<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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label>特急票价</label>
|
<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>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="danger" @click="deleteCurrentFare"
|
<button class="danger" @click="deleteCurrentFare"
|
||||||
@@ -364,27 +409,34 @@
|
|||||||
<span class="label-text">设置跨名/强制换乘</span>
|
<span class="label-text">设置跨名/强制换乘</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-muted" style="font-size: 0.85em; margin-top: 6px;">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4" v-if="stationForm.transfer_enabled">
|
<div class="mb-4" v-if="stationForm.transfer_enabled">
|
||||||
<label>可换乘到的站点</label>
|
<label>可换乘到的站点</label>
|
||||||
<select v-model="stationForm.transfer_to" multiple class="w-100" style="height: 180px;">
|
<select v-model="stationForm.transfer_to" multiple class="w-100"
|
||||||
<optgroup label="同名站点 (已自动换乘)" v-if="transferTargets.sameName.length > 0">
|
style="height: 180px;">
|
||||||
<option v-for="t in transferTargets.sameName" :key="t.code" :value="t.code">
|
<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 }}
|
{{ t.name }} ({{ t.en_name }}) - {{ t.code }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="其他站点">
|
<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 }}
|
{{ t.name }} ({{ t.en_name }}) - {{ t.code }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</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>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="danger" @click="deleteStation(stationFormOriginalCode)">删除</button>
|
<button class="danger"
|
||||||
|
@click="deleteStation(stationFormOriginalCode)">删除</button>
|
||||||
<button @click="saveStationSettings">保存</button>
|
<button @click="saveStationSettings">保存</button>
|
||||||
<button class="danger" @click="closeStationModal">取消</button>
|
<button class="danger" @click="closeStationModal">取消</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,7 +461,8 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label>颜色</label>
|
<label>颜色</label>
|
||||||
<div class="flex" style="gap:8px;">
|
<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="选择颜色"
|
<input type="color" v-model="lineForm.color" title="选择颜色"
|
||||||
style="width: 48px; padding: 0; border: none; height: 32px;">
|
style="width: 48px; padding: 0; border: none; height: 32px;">
|
||||||
</div>
|
</div>
|
||||||
@@ -432,14 +485,17 @@
|
|||||||
<button @click="loadFareMap" title="刷新"><i class="fas fa-sync-alt"></i></button>
|
<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="zoomFareMapOut" title="缩小"><i class="fas fa-minus"></i></button>
|
||||||
<button @click="zoomFareMapIn" title="放大"><i class="fas fa-plus"></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="zoomFareMapReset" title="重置"><i
|
||||||
<button @click="exportFareMap" title="导出图像"><i class="fas fa-download"></i></button>
|
class="fas fa-crosshairs"></i></button>
|
||||||
|
<button @click="exportFareMap" title="导出图像"><i
|
||||||
|
class="fas fa-download"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="fareMapLoading" class="loading">加载中...</div>
|
<div v-if="fareMapLoading" class="loading">加载中...</div>
|
||||||
<div v-else-if="fareMapError" class="loading">{{ fareMapError }}</div>
|
<div v-else-if="fareMapError" class="loading">{{ fareMapError }}</div>
|
||||||
<div v-else class="faremap-viewport">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -485,12 +541,14 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="o in orderList" :key="o.code">
|
<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> {{
|
<td>{{ o.start_name }} <i class="fas fa-arrow-right text-muted"></i> {{
|
||||||
o.terminal_name }}</td>
|
o.terminal_name }}</td>
|
||||||
<td>{{ formatTrainType(o.train_type) }}</td>
|
<td>{{ formatTrainType(o.train_type) }}</td>
|
||||||
<td>{{ o.price }}</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>{{ formatTime(o.created_ts) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex" style="gap:4px;">
|
<div class="flex" style="gap:4px;">
|
||||||
@@ -509,6 +567,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 站点管理迁移引导 移除 -->
|
||||||
|
|
||||||
<div v-if="currentView === 'iccards'">
|
<div v-if="currentView === 'iccards'">
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -533,7 +593,8 @@
|
|||||||
<div class="management-container ic-admin-layout">
|
<div class="management-container ic-admin-layout">
|
||||||
<div class="management-sidebar">
|
<div class="management-sidebar">
|
||||||
|
|
||||||
<div class="card 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 class="flex between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h4>卡片列表</h4>
|
<h4>卡片列表</h4>
|
||||||
@@ -542,27 +603,29 @@
|
|||||||
<span class="badge">{{ icCards.length }}</span>
|
<span class="badge">{{ icCards.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex mb-4" style="flex-wrap:wrap;">
|
<div class="flex mb-4" style="flex-wrap:wrap;">
|
||||||
<input v-model="icCardSearch" placeholder="搜索卡号 / 订单号 / 凭证码 / 姓名" style="flex:1;">
|
<input v-model="icCardSearch" placeholder="搜索卡号 / 订单号 / 凭证码 / 姓名"
|
||||||
|
style="flex:1;">
|
||||||
</div>
|
</div>
|
||||||
<div class="list-lines jr-scroll-box" style="flex:1; min-height:320px;">
|
<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;">
|
<div v-if="!icCards.length" class="empty-state" style="padding:24px 0;">
|
||||||
<p>暂无 IC 卡记录。</p>
|
<p>暂无 IC 卡记录。</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-for="card in icCards" :key="card.card_id"
|
||||||
v-for="card in icCards"
|
|
||||||
:key="card.card_id"
|
|
||||||
class="line-item ic-card-item"
|
class="line-item ic-card-item"
|
||||||
:class="{ active: icSelectedId === card.card_id }"
|
:class="{ active: icSelectedId === card.card_id }"
|
||||||
@click="loadIcCard(card.card_id)"
|
@click="loadIcCard(card.card_id)">
|
||||||
>
|
<div class="line-color-dot"
|
||||||
<div class="line-color-dot" :style="{ background: icStatusColor(card.status) }"></div>
|
:style="{ background: icStatusColor(card.status) }"></div>
|
||||||
<div class="line-info">
|
<div class="line-info">
|
||||||
<div class="line-name">{{ displayIcCardId(card) }}</div>
|
<div class="line-name">{{ displayIcCardId(card) }}</div>
|
||||||
<div class="line-meta">{{ card.holder_name || '未登记持卡人' }} · IC 储值卡</div>
|
<div class="line-meta">{{ card.holder_name || '未登记持卡人' }} · IC 储值卡
|
||||||
<div class="line-meta">订单 {{ cardOrderCode(card) }} · 余额 {{ formatMoney(card.balance) }}</div>
|
</div>
|
||||||
|
<div class="line-meta">订单 {{ cardOrderCode(card) }} · 余额 {{
|
||||||
|
formatMoney(card.balance) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="line-actions" style="opacity:1;">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -576,10 +639,15 @@
|
|||||||
<h4>卡片详情</h4>
|
<h4>卡片详情</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex" style="gap:8px;">
|
<div class="flex" style="gap:8px;">
|
||||||
<button class="btn" @click="loadIcCard(icSelectedId)" :disabled="!icSelectedCard || isViewBusy"><i class="fas fa-rotate-right"></i> 刷新</button>
|
<button class="btn" @click="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 danger" @click="deleteIcCard"
|
||||||
<button class="btn primary" @click="saveIcCard" :disabled="!icSelectedCard"><i class="fas fa-save"></i> 保存</button>
|
: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>
|
</div>
|
||||||
<div v-if="!icSelectedCard" class="empty-state">
|
<div v-if="!icSelectedCard" class="empty-state">
|
||||||
@@ -589,10 +657,15 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="flex between mb-4" style="align-items:flex-start;">
|
<div class="flex between mb-4" style="align-items:flex-start;">
|
||||||
<div>
|
<div>
|
||||||
<div class="mono" style="font-size:1.4rem; font-weight:700;">{{ displayIcCardId(icSelectedCard) }}</div>
|
<div class="mono" style="font-size:1.4rem; font-weight:700;">{{
|
||||||
<div class="text-muted" style="margin-top:6px;">订单号 {{ cardOrderCode(icSelectedCard) }} · 来源 {{ icSelectedCard.source || '---' }}</div>
|
displayIcCardId(icSelectedCard) }}</div>
|
||||||
|
<div class="text-muted" style="margin-top:6px;">订单号 {{
|
||||||
|
cardOrderCode(icSelectedCard) }} · 来源 {{ icSelectedCard.source
|
||||||
|
|| '---' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="icStatusInfo(icSelectedCard.status).className">{{ icStatusInfo(icSelectedCard.status).text }}</span>
|
<span class="badge"
|
||||||
|
:class="icStatusInfo(icSelectedCard.status).className">{{
|
||||||
|
icStatusInfo(icSelectedCard.status).text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="jr-admin-summary-grid">
|
<div class="jr-admin-summary-grid">
|
||||||
<div class="jr-admin-summary-item">
|
<div class="jr-admin-summary-item">
|
||||||
@@ -632,10 +705,15 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="ic-inline-meta">
|
<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">{{
|
||||||
<div class="list-item"><span class="k">最后更新</span><span class="v">{{ formatTime(icSelectedCard.last_update_ts) }}</span></div>
|
formatTime(icSelectedCard.created_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">{{
|
||||||
<div class="list-item"><span class="k">购卡金额</span><span class="v">{{ formatMoney(icSelectedCard.purchase_amount) }}</span></div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -647,14 +725,18 @@
|
|||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
<div v-if="!icSelectedCard" class="loading">选择卡片后显示事件流。</div>
|
<div v-if="!icSelectedCard" class="loading">选择卡片后显示事件流。</div>
|
||||||
<div v-else-if="!icSelectedEvents.length" 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-dot"></div>
|
||||||
<div class="timeline-content">
|
<div class="timeline-content">
|
||||||
<div class="flex between">
|
<div class="flex between">
|
||||||
<span style="font-weight:600;">{{ icEventTitle(event) }}</span>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -683,7 +765,8 @@
|
|||||||
<div class="flex between mb-4">
|
<div class="flex between mb-4">
|
||||||
<h4>车票记录</h4>
|
<h4>车票记录</h4>
|
||||||
<div class="flex">
|
<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>
|
<button @click="refreshData"><i class="fas fa-sync-alt"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -714,7 +797,8 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="st-container">
|
<div class="st-container">
|
||||||
<div class="st-main-row">
|
<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>
|
<span class="st-code">{{ t.terminal }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="st-en">{{ getStationInfo(t.terminal).en_name }}</div>
|
<div class="st-en">{{ getStationInfo(t.terminal).en_name }}</div>
|
||||||
@@ -764,23 +848,27 @@
|
|||||||
selectedTicket.index.terminal }}</span>
|
selectedTicket.index.terminal }}</span>
|
||||||
<span class="st-code">{{ selectedTicket.index.terminal }}</span>
|
<span class="st-code">{{ selectedTicket.index.terminal }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="st-en">{{ selectedTicket.index.terminal_en || '' }}</div>
|
<div class="st-en">{{ selectedTicket.index.terminal_en || '' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted mt-2" style="font-size: 0.9rem;">
|
<div class="text-muted mt-2" style="font-size: 0.9rem;">
|
||||||
类型: {{ formatTrainType(selectedTicket.index.type ||
|
类型: {{ formatTrainType(selectedTicket.index.type ||
|
||||||
selectedTicket.index.train_type) }} | 票价: {{ selectedTicket.index.price ||
|
selectedTicket.index.train_type) }} | 票价: {{ selectedTicket.index.price
|
||||||
|
||
|
||||||
selectedTicket.index.cost }}
|
selectedTicket.index.cost }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h5>行程记录</h5>
|
<h5>行程记录</h5>
|
||||||
<div class="timeline">
|
<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-dot"></div>
|
||||||
<div class="timeline-content">
|
<div class="timeline-content">
|
||||||
<div class="flex between">
|
<div class="flex between">
|
||||||
<span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span>
|
<span style="font-weight: 600;">{{ formatTicketEvent(ev)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted" style="font-size: 0.9rem; margin-top: 4px;">
|
<div class="text-muted" style="font-size: 0.9rem; margin-top: 4px;">
|
||||||
{{ formatTicketEventUnified(ev) }}
|
{{ formatTicketEventUnified(ev) }}
|
||||||
@@ -794,72 +882,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentView === 'assets'" class="assets-editor-container" style="display: flex; flex-direction: column; height: 80vh;">
|
<div v-if="currentView === 'assets'" class="assets-editor-container">
|
||||||
<div class="card mb-4" style="flex: 1; display: flex; flex-direction: column;">
|
<div class="card mb-4">
|
||||||
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
||||||
<div class="flex" style="align-items: center; gap: 10px;">
|
<div>
|
||||||
<h4>线路拓扑图布局编辑器</h4>
|
<h4 style="margin-bottom: 6px;">线路图上传</h4>
|
||||||
<span class="badge" style="background: #2563eb; color: #fff;">拖拽站点以自定义位置</span>
|
<div class="text-muted">上传后会用于首页展示,也会在购票页未选择线路时显示为总览图。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex" style="gap: 8px;">
|
<div class="flex" style="gap: 8px; flex-wrap: wrap;">
|
||||||
<button class="btn" @click="resetTopologyLayout">
|
<label class="btn primary" style="cursor: pointer;">
|
||||||
<i class="fas fa-undo"></i> 重置布局
|
<i class="fas fa-upload"></i> 上传线路图
|
||||||
</button>
|
<input type="file" accept=".png,.jpg,.jpeg,.webp,.svg"
|
||||||
<button class="btn primary" @click="saveTopologyLayout">
|
style="display: none;" @change="uploadRouteMap">
|
||||||
<i class="fas fa-save"></i> 保存坐标
|
</label>
|
||||||
|
<button class="btn" @click="deleteRouteMap"
|
||||||
|
:disabled="!assetsManifest.routeMap || mutationBusy">
|
||||||
|
<i class="fas fa-trash"></i> 删除当前线路图
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="flex: 1; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #0f172a; position: relative; user-select: none;"
|
<div class="grid"
|
||||||
ref="topologyCanvas"
|
style="display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 16px; align-items: start;">
|
||||||
@mousedown="onTopologyCanvasMouseDown"
|
<div
|
||||||
@mousemove="onTopologyCanvasMouseMove"
|
style="border: 1px solid var(--border); padding: 16px; background: var(--surface);">
|
||||||
@mouseup="onTopologyCanvasMouseUp"
|
<div style="font-weight: 700; margin-bottom: 12px;">上传说明</div>
|
||||||
@mouseleave="onTopologyCanvasMouseUp"
|
<div class="text-muted" style="display: grid; gap: 8px; line-height: 1.7;">
|
||||||
@wheel="onTopologyCanvasWheel">
|
<div>支持格式:PNG、JPG、WEBP、SVG</div>
|
||||||
<div :style="{
|
<div>建议上传横向长图,便于首页和购票页展示。</div>
|
||||||
position: 'absolute',
|
<div>再次上传会自动替换旧文件。</div>
|
||||||
left: '0', top: '0',
|
</div>
|
||||||
width: '100%', height: '100%',
|
<div
|
||||||
transformOrigin: '0 0',
|
style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); display: grid; gap: 10px;">
|
||||||
transform: `translate(${topologyPan.x}px, ${topologyPan.y}px) scale(${topologyPan.scale})`
|
<label style="display: grid; gap: 6px;">
|
||||||
}">
|
<span style="font-weight: 700;">线路图署名</span>
|
||||||
<!-- Grid Background (Optional) -->
|
<input v-model="assetsForm.routeMapCredit" class="input" type="text"
|
||||||
<svg width="10000" height="10000" style="position: absolute; left: -5000px; top: -5000px; pointer-events: none;">
|
maxlength="200"
|
||||||
<defs>
|
placeholder="例如:OpenStreetMap contributors / 作者名 / 来源名">
|
||||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
</label>
|
||||||
<rect width="40" height="40" fill="none" stroke="rgba(255,255,255,0.05)" />
|
<div class="text-muted">许可协议固定为 <a
|
||||||
</pattern>
|
href="https://creativecommons.org/licenses/by/4.0/" target="_blank"
|
||||||
</defs>
|
rel="noopener noreferrer">CC BY 4.0</a></div>
|
||||||
<rect width="10000" height="10000" fill="url(#grid)" />
|
<div class="flex" style="gap: 8px; flex-wrap: wrap;">
|
||||||
<!-- Lines -->
|
<button class="btn" @click="saveRouteMapCredit"
|
||||||
<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" />
|
:disabled="mutationBusy">
|
||||||
</svg>
|
<i class="fas fa-pen"></i> 保存署名
|
||||||
|
</button>
|
||||||
<!-- Stations -->
|
</div>
|
||||||
<div v-for="node in topologyNodes" :key="node.canonical"
|
</div>
|
||||||
:style="{
|
<div
|
||||||
position: 'absolute',
|
style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); display: grid; gap: 8px;">
|
||||||
left: node.x + 'px',
|
<div><strong>当前文件:</strong> {{ assetsManifest.routeMap || '未上传' }}</div>
|
||||||
top: node.y + 'px',
|
<div><strong>当前署名:</strong> {{ assetsManifest.routeMapCredit || '未填写' }}
|
||||||
transform: 'translate(-50%, -50%)',
|
</div>
|
||||||
cursor: 'grab',
|
<div><strong>许可协议:</strong> <a
|
||||||
zIndex: node.canonical === topologyDraggingNode ? 100 : 10
|
href="https://creativecommons.org/licenses/by/4.0/" target="_blank"
|
||||||
}"
|
rel="noopener noreferrer">{{ assetsManifest.routeMapLicense }}</a>
|
||||||
@mousedown.stop="onTopologyNodeMouseDown($event, node)">
|
</div>
|
||||||
|
<div><strong>更新时间:</strong> {{ assetsManifest.updatedAt ?
|
||||||
<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);">
|
formatTime(assetsManifest.updatedAt) : '暂无' }}</div>
|
||||||
<div style="width: 10px; height: 10px; border-radius: 50%; background: #cbd5e1;"></div>
|
</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;">
|
<div
|
||||||
{{ node.name }}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -884,7 +984,8 @@
|
|||||||
<div class="flex" style="flex-direction: column; gap: 10px;">
|
<div class="flex" style="flex-direction: column; gap: 10px;">
|
||||||
<div>
|
<div>
|
||||||
<button @click="pushLuaUpdate" :disabled="isPushingUpdate">
|
<button @click="pushLuaUpdate" :disabled="isPushingUpdate">
|
||||||
<i class="fas fa-satellite-dish"></i> {{ isPushingUpdate ? '推送中...' : '手动推送更新' }}
|
<i class="fas fa-satellite-dish"></i> {{ isPushingUpdate ? '推送中...' :
|
||||||
|
'手动推送更新' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted" style="font-size: 0.9rem;">
|
<div class="text-muted" style="font-size: 0.9rem;">
|
||||||
@@ -901,6 +1002,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 日志 -->
|
||||||
<div v-if="currentView === 'logs'">
|
<div v-if="currentView === 'logs'">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
||||||
@@ -920,7 +1022,8 @@
|
|||||||
</select>
|
</select>
|
||||||
<input v-model="logTypeFilter" placeholder="type (可逗号分隔)" style="width: 260px;">
|
<input v-model="logTypeFilter" placeholder="type (可逗号分隔)" style="width: 260px;">
|
||||||
<input v-model="logQuery" placeholder="关键字" style="width: 220px;">
|
<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">
|
<button @click="fetchLogs" :disabled="logLoading">
|
||||||
<i class="fas" :class="logLoading ? 'fa-spinner fa-spin' : 'fa-filter'"></i> 筛选
|
<i class="fas" :class="logLoading ? 'fa-spinner fa-spin' : 'fa-filter'"></i> 筛选
|
||||||
</button>
|
</button>
|
||||||
@@ -946,7 +1049,8 @@
|
|||||||
<td>
|
<td>
|
||||||
<details>
|
<details>
|
||||||
<summary class="text-muted" style="cursor: pointer;">查看</summary>
|
<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>
|
</details>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -955,8 +1059,76 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<span class="version">v1.0.12</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -965,7 +1137,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=11"></script>
|
||||||
<script src="index.js?v=6"></script>
|
<script src="index.js?v=7"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const isDomain = location.hostname.includes('fse-media.group');
|
const isDomain = location.hostname.includes('fse-media.group');
|
||||||
@@ -992,7 +1164,3 @@
|
|||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+183
-12
@@ -27,6 +27,7 @@ createApp({
|
|||||||
tickets: '车票记录',
|
tickets: '车票记录',
|
||||||
vouchers: '凭证管理',
|
vouchers: '凭证管理',
|
||||||
iccards: 'IC 卡管理',
|
iccards: 'IC 卡管理',
|
||||||
|
issues: '问题汇总',
|
||||||
assets: '线路图',
|
assets: '线路图',
|
||||||
settings: '系统设置',
|
settings: '系统设置',
|
||||||
logs: '日志'
|
logs: '日志'
|
||||||
@@ -34,15 +35,15 @@ createApp({
|
|||||||
return map[currentView.value] || '票价图';
|
return map[currentView.value] || '票价图';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prefer polling first so admin remains connected even when the proxy
|
// 强制使用 WebSocket,避免轮询 (polling) 在负载均衡/反代下产生 400 Bad Request
|
||||||
// does not support WebSocket upgrades reliably.
|
const socket = io({ transports: ['websocket'], timeout: 20000 });
|
||||||
const socket = io({ transports: ['polling', 'websocket'] });
|
|
||||||
|
|
||||||
// Data State
|
// Data State
|
||||||
const stations = ref([]);
|
const stations = ref([]);
|
||||||
const lines = ref([]);
|
const lines = ref([]);
|
||||||
const fares = ref([]);
|
const fares = ref([]);
|
||||||
const tickets = ref([]);
|
const tickets = ref([]);
|
||||||
|
const issues = ref([]);
|
||||||
const stats = reactive({ sold_tickets: 0, revenue: 0 });
|
const stats = reactive({ sold_tickets: 0, revenue: 0 });
|
||||||
const config = reactive({
|
const config = reactive({
|
||||||
api_base: '',
|
api_base: '',
|
||||||
@@ -56,10 +57,11 @@ createApp({
|
|||||||
const logMax = ref(200);
|
const logMax = ref(200);
|
||||||
const logLoading = ref(false);
|
const logLoading = ref(false);
|
||||||
const orders = ref([]);
|
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 assetsFarePreview = reactive({ headers: [], rows: [] });
|
||||||
const assetsRouteMapUrl = ref('');
|
const assetsRouteMapUrl = ref('');
|
||||||
const assetsFareTableUrl = ref('');
|
const assetsFareTableUrl = ref('');
|
||||||
|
const assetsForm = reactive({ routeMapCredit: '' });
|
||||||
const icCards = ref([]);
|
const icCards = ref([]);
|
||||||
const icCardSearch = ref('');
|
const icCardSearch = ref('');
|
||||||
const icSelectedId = ref('');
|
const icSelectedId = ref('');
|
||||||
@@ -76,6 +78,7 @@ createApp({
|
|||||||
let ticketDataLoaded = false;
|
let ticketDataLoaded = false;
|
||||||
let orderDataLoaded = false;
|
let orderDataLoaded = false;
|
||||||
let logDataLoaded = false;
|
let logDataLoaded = false;
|
||||||
|
let issueDataLoaded = false;
|
||||||
let assetsLoaded = false;
|
let assetsLoaded = false;
|
||||||
let fareMapLoaded = false;
|
let fareMapLoaded = false;
|
||||||
const loadingState = reactive({
|
const loadingState = reactive({
|
||||||
@@ -83,7 +86,8 @@ createApp({
|
|||||||
tickets: false,
|
tickets: false,
|
||||||
orders: false,
|
orders: false,
|
||||||
logs: false,
|
logs: false,
|
||||||
iccards: false
|
iccards: false,
|
||||||
|
issues: false
|
||||||
});
|
});
|
||||||
const lastSyncAt = ref(0);
|
const lastSyncAt = ref(0);
|
||||||
|
|
||||||
@@ -159,6 +163,132 @@ createApp({
|
|||||||
try { return JSON.parse(t); } catch (e) { return null; }
|
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 parseCsvSimple = (text) => {
|
||||||
const out = { headers: [], rows: [] };
|
const out = { headers: [], rows: [] };
|
||||||
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
@@ -577,8 +707,11 @@ createApp({
|
|||||||
const fetchAssetsManifest = async () => {
|
const fetchAssetsManifest = async () => {
|
||||||
const data = await requestJson('/api/assets/manifest');
|
const data = await requestJson('/api/assets/manifest');
|
||||||
assetsManifest.routeMap = data ? (data.routeMap || null) : null;
|
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.fareTable = data ? (data.fareTable || null) : null;
|
||||||
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
|
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
|
||||||
|
assetsForm.routeMapCredit = assetsManifest.routeMapCredit;
|
||||||
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
|
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
|
||||||
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
|
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
|
||||||
assetsLoaded = true;
|
assetsLoaded = true;
|
||||||
@@ -622,9 +755,12 @@ createApp({
|
|||||||
markSynced();
|
markSynced();
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadAssetFile = async (url, file) => {
|
const uploadAssetFile = async (url, file, extraFields = {}) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
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 requestJson(url, { method: 'POST', body: fd }, { expectOk: true });
|
||||||
await fetchAssetsManifest();
|
await fetchAssetsManifest();
|
||||||
};
|
};
|
||||||
@@ -633,7 +769,7 @@ createApp({
|
|||||||
const f = ev && ev.target && ev.target.files && ev.target.files[0];
|
const f = ev && ev.target && ev.target.files && ev.target.files[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
await uploadAssetFile('/api/assets/route-map', f);
|
await uploadAssetFile('/api/assets/route-map', f, { credit: assetsForm.routeMapCredit });
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFareTable = async (ev) => {
|
const uploadFareTable = async (ev) => {
|
||||||
@@ -648,6 +784,15 @@ createApp({
|
|||||||
await fetchAssetsManifest();
|
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 () => {
|
const deleteFareTable = async () => {
|
||||||
await requestJson('/api/assets/fare-table', { method: 'DELETE' }, { expectOk: true });
|
await requestJson('/api/assets/fare-table', { method: 'DELETE' }, { expectOk: true });
|
||||||
await fetchAssetsManifest();
|
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) => {
|
const fetchIcCards = async (keepSelection = true) => {
|
||||||
if (loadingState.iccards) return;
|
if (loadingState.iccards) return;
|
||||||
loadingState.iccards = true;
|
loadingState.iccards = true;
|
||||||
@@ -1131,6 +1296,7 @@ createApp({
|
|||||||
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
|
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
|
||||||
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
|
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
|
||||||
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
|
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
|
||||||
|
if (view === 'issues' && (force || !issueDataLoaded)) await fetchIssues();
|
||||||
if (view === 'iccards') {
|
if (view === 'iccards') {
|
||||||
await fetchIcCards(true);
|
await fetchIcCards(true);
|
||||||
if (icSelectedId.value) {
|
if (icSelectedId.value) {
|
||||||
@@ -1600,6 +1766,12 @@ createApp({
|
|||||||
stats.sold_tickets += item.sold_tickets;
|
stats.sold_tickets += item.sold_tickets;
|
||||||
stats.revenue += item.revenue;
|
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:created', () => { fetchIcCards(false).catch(console.error); });
|
||||||
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
|
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
|
||||||
socket.on('ic-card:check', () => {
|
socket.on('ic-card:check', () => {
|
||||||
@@ -1843,9 +2015,6 @@ createApp({
|
|||||||
} else {
|
} else {
|
||||||
stopIcCardSync();
|
stopIcCardSync();
|
||||||
}
|
}
|
||||||
if (v === 'assets') {
|
|
||||||
setTimeout(initTopologyLayout, 300);
|
|
||||||
}
|
|
||||||
ensureViewData(v).catch(console.error);
|
ensureViewData(v).catch(console.error);
|
||||||
const sp = new URLSearchParams(location.search);
|
const sp = new URLSearchParams(location.search);
|
||||||
if (v === 'dashboard') sp.delete('view');
|
if (v === 'dashboard') sp.delete('view');
|
||||||
@@ -1915,6 +2084,7 @@ createApp({
|
|||||||
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
|
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
|
||||||
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
|
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
|
||||||
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
|
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
|
||||||
|
issues: `当前共有 ${issues.value.length} 条问题记录`,
|
||||||
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
|
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
|
||||||
settings: '可维护优惠活动与导出数据',
|
settings: '可维护优惠活动与导出数据',
|
||||||
logs: `当前筛选结果 ${logs.value.length} 条日志`
|
logs: `当前筛选结果 ${logs.value.length} 条日志`
|
||||||
@@ -2000,11 +2170,12 @@ createApp({
|
|||||||
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
|
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
|
||||||
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
|
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
|
||||||
orders, orderList, fetchOrders, deleteOrder,
|
orders, orderList, fetchOrders, deleteOrder,
|
||||||
|
issues, fetchIssues,
|
||||||
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
|
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
|
||||||
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
|
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
|
||||||
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
|
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
|
||||||
assetsManifest, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
|
assetsManifest, assetsForm, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
|
||||||
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable,
|
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable, saveRouteMapCredit,
|
||||||
|
|
||||||
// Management
|
// Management
|
||||||
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
|
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>
|
<title>票务查询|FSE铁路运输票务系统</title>
|
||||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/style.css?v=14" />
|
<link rel="stylesheet" href="/style.css?v=22" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="public-search jr-public-page">
|
<body class="public-search jr-public-page">
|
||||||
@@ -18,11 +18,7 @@
|
|||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
<span>返回首页</span>
|
<span>返回首页</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="jr-top-status is-checking" data-server-status-root>
|
|
||||||
<span class="jr-top-status-label">服务器状态</span>
|
|
||||||
<span class="jr-top-status-dot"></span>
|
|
||||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
+479
-5
@@ -1,3 +1,7 @@
|
|||||||
|
[v-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #09090b;
|
--bg: #09090b;
|
||||||
--surface: #18181b;
|
--surface: #18181b;
|
||||||
@@ -251,7 +255,7 @@ button, .btn {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 0;
|
border-radius: 0 !important;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -477,6 +481,9 @@ tr:hover td {
|
|||||||
.mobile-only {
|
.mobile-only {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.mobile-only-inline {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
:root { --sidebar-width: 260px; }
|
:root { --sidebar-width: 260px; }
|
||||||
@@ -1780,6 +1787,7 @@ body.jr-order-page .site-footer .version {
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #d7e0d3;
|
border: 1px solid #d7e0d3;
|
||||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
|
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jr-hero-copy {
|
.jr-hero-copy {
|
||||||
@@ -2054,6 +2062,7 @@ body.jr-order-page .site-footer .version {
|
|||||||
background:
|
background:
|
||||||
linear-gradient(180deg, #f7faf7 0, #edf4ed 100%);
|
linear-gradient(180deg, #f7faf7 0, #edf4ed 100%);
|
||||||
border: 1px solid #d7e0d3;
|
border: 1px solid #d7e0d3;
|
||||||
|
border-radius: 0;
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2441,11 +2450,14 @@ body.jr-order-page .station-map-container text {
|
|||||||
|
|
||||||
.jr-selection-summary {
|
.jr-selection-summary {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jr-selection-arrow {
|
.jr-selection-arrow {
|
||||||
min-height: 20px;
|
min-height: 12px;
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jr-form-grid,
|
.jr-form-grid,
|
||||||
@@ -2459,11 +2471,25 @@ body.jr-order-page .station-map-container text {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.jr-station-map {
|
.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 {
|
body.jr-order-page .station-map-container svg {
|
||||||
min-width: 620px;
|
min-width: 560px;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2725,6 +2751,25 @@ body.jr-public-page {
|
|||||||
height: auto;
|
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 {
|
.jr-search-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -4199,6 +4244,123 @@ body.jr-admin-login-page {
|
|||||||
gap: 16px;
|
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 {
|
.jr-admin-login-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
| |||||||