Compare commits
48
Commits
4ea58772d3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
def7afd5ce | ||
|
|
c52d4dac03 | ||
|
|
2748aa91ca | ||
|
|
228077a49c | ||
|
|
e18f732b38 | ||
|
|
e76d0188db | ||
|
|
ca77d762c9 | ||
|
|
41546cce96 | ||
|
|
3d38e16917 | ||
|
|
3d7fa994ae | ||
|
|
cf0a49867c | ||
|
|
fa51aad3a7 | ||
|
|
9cc3aba5e9 | ||
|
|
cdf2c39220 | ||
|
|
7e0a114635 | ||
|
|
0e3f1a6e86 | ||
|
|
0a37c0cba2 | ||
|
|
6605c67f23 | ||
|
|
1046aa8dda | ||
|
|
b7cdcc570a | ||
|
|
a59e9e60a4 | ||
|
|
d685dee2a4 | ||
|
|
9c7e99633b | ||
|
|
879445b9f6 | ||
|
|
200dbda9bd | ||
|
|
7e72bbeae5 | ||
|
|
0e8f86b478 | ||
|
|
4db60ddc9e | ||
|
|
c840e2abfe | ||
|
|
be0077743c | ||
|
|
4ddc4cb4ad | ||
|
|
04d0badce8 | ||
|
|
847957be61 | ||
|
|
6195ebb6be | ||
|
|
ec31c12130 | ||
|
|
289ed65e79 | ||
|
|
95bf4d3d8e | ||
|
|
c6ab010974 | ||
|
|
234e22468a | ||
|
|
77b487c326 | ||
|
|
1f5bc1d40d | ||
|
|
2096dd8d89 | ||
|
|
5e8d4f7bbe | ||
|
|
9d145c3d16 | ||
|
|
cfaa622dcd | ||
|
|
7812d47f0c | ||
|
|
d32d72a3da | ||
|
|
86f3d478d6 |
BIN
Binary file not shown.
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
1. 完成功能制作后自行编辑git commit并推送
|
1. 完成功能制作后自行编辑git commit并推送
|
||||||
2.
|
2. 提交时请添加功能描述
|
||||||
@@ -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.23"
|
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], ...)
|
||||||
termDev = monitor
|
if not ok then return nil end
|
||||||
|
return r1, r2, r3
|
||||||
|
end
|
||||||
|
|
||||||
|
local function safeCallMonitor(target, method, ...)
|
||||||
|
if not target or type(target[method]) ~= "function" then return false end
|
||||||
|
local ok, r1 = pcall(target[method], ...)
|
||||||
|
if not ok then return false end
|
||||||
|
return r1 ~= false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handshakeMonitor(target)
|
||||||
|
if not target then return false end
|
||||||
|
safeCallMonitor(target, "setTextScale", 0.5)
|
||||||
|
safeCallMonitor(target, "setBackgroundColor", colors.black)
|
||||||
|
safeCallMonitor(target, "setTextColor", colors.white)
|
||||||
|
safeCallMonitor(target, "clear")
|
||||||
|
local w, h = safeCallTerm("getSize")
|
||||||
|
return (type(w) == "number" and type(h) == "number" and w >= 2 and h >= 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function refreshDisplayTarget()
|
||||||
|
local nextMonitor = nil
|
||||||
|
local nextMonitorSide = monitorSide
|
||||||
|
if peripheral then
|
||||||
|
if nextMonitorSide and type(peripheral.isPresent) == "function" then
|
||||||
|
local okPres, pres = pcall(peripheral.isPresent, nextMonitorSide)
|
||||||
|
if okPres and pres and type(peripheral.wrap) == "function" then
|
||||||
|
local okWrap, wrapped = pcall(peripheral.wrap, nextMonitorSide)
|
||||||
|
if okWrap and wrapped then nextMonitor = wrapped end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not nextMonitor and type(peripheral.find) == "function" then
|
||||||
|
local foundName = nil
|
||||||
|
local found = nil
|
||||||
|
pcall(function()
|
||||||
|
found = peripheral.find("monitor", function(name, wrapped)
|
||||||
|
foundName = name
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
if found then
|
||||||
|
nextMonitor = found
|
||||||
|
nextMonitorSide = foundName
|
||||||
|
else
|
||||||
|
nextMonitorSide = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if nextMonitor and nextMonitor ~= monitor then
|
||||||
|
monitor = nextMonitor
|
||||||
|
monitorSide = nextMonitorSide
|
||||||
|
pcall(function() handshakeMonitor(nextMonitor) end)
|
||||||
|
elseif nextMonitor then
|
||||||
|
monitor = nextMonitor
|
||||||
|
monitorSide = nextMonitorSide
|
||||||
|
end
|
||||||
|
if monitor then
|
||||||
|
if not safeCallMonitor(monitor, "setTextScale", 0.5) then
|
||||||
|
pcall(function() handshakeMonitor(monitor) end)
|
||||||
|
end
|
||||||
|
termDev = monitor
|
||||||
|
else
|
||||||
|
termDev = nativeTerm or term
|
||||||
|
end
|
||||||
|
return termDev
|
||||||
end
|
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
|
end
|
||||||
if statusLine2 and #statusLine2 > 0 then
|
safeCallTerm("setCursorPos", 1, h)
|
||||||
centerText(math.min(h, math.max(3, math.floor(h / 2) + 1)), statusLine2, statusColor or colors.white)
|
safeCallTerm("setTextColor", colors.gray)
|
||||||
end
|
safeCallTerm("write", string.rep(" ", w))
|
||||||
termDev.setCursorPos(1, h)
|
|
||||||
termDev.setTextColor(colors.gray)
|
|
||||||
termDev.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
|
||||||
end
|
end
|
||||||
return "F " .. frontCode .. " B " .. backCode
|
|
||||||
|
if #lines == 0 then
|
||||||
|
for _, code in ipairs(stationCodesPayload) do
|
||||||
|
addLine(displayStationCode(code))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #lines == 0 then
|
||||||
|
addLine(displayStationCode(stationCodeForSide(nil)))
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(lines, "\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
local function drawReadyScreen()
|
local function drawReadyScreen()
|
||||||
@@ -972,19 +1207,19 @@ local function updateDeviceField(dev, key, value)
|
|||||||
pcall(dev.updateTicket, key, value)
|
pcall(dev.updateTicket, key, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function callTicketStateMethod(dev, methodName)
|
local function callTicketStateMethod(dev, methodName, ...)
|
||||||
if type(dev) ~= "table" then return false end
|
if type(dev) ~= "table" then return false end
|
||||||
local fn = dev[methodName]
|
local fn = dev[methodName]
|
||||||
if type(fn) ~= "function" then return false end
|
if type(fn) ~= "function" then return false end
|
||||||
local okCall, okRes = pcall(fn)
|
local okCall, okRes = pcall(fn, ...)
|
||||||
if not okCall then return false end
|
if not okCall then return false end
|
||||||
return okRes ~= false
|
return okRes ~= false
|
||||||
end
|
end
|
||||||
|
|
||||||
local function applyTicketDeviceState(dev, action, rides)
|
local function applyTicketDeviceState(dev, action, rides, stationCode)
|
||||||
if type(dev) ~= "table" then return end
|
if type(dev) ~= "table" then return end
|
||||||
if action == "entry" then
|
if action == "entry" then
|
||||||
if not callTicketStateMethod(dev, "markEntered") then
|
if not callTicketStateMethod(dev, "markEntered", stationCode) then
|
||||||
updateDeviceField(dev, "entered", true)
|
updateDeviceField(dev, "entered", true)
|
||||||
updateDeviceField(dev, "exited", false)
|
updateDeviceField(dev, "exited", false)
|
||||||
end
|
end
|
||||||
@@ -1003,13 +1238,32 @@ local function applyTicketDeviceState(dev, action, rides)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function updateICCardField(dev, key, value)
|
local function updateICCardField(dev, key, value)
|
||||||
if type(dev) ~= "table" or type(dev.updateICCard) ~= "function" then return false end
|
if type(dev) ~= "table" then return false end
|
||||||
|
if type(dev.updateICCard) ~= "function" then return true end
|
||||||
local okCall, okRes, detail = pcall(dev.updateICCard, key, value)
|
local okCall, okRes, detail = pcall(dev.updateICCard, key, value)
|
||||||
if not okCall then return false, tostring(okRes) end
|
if not okCall then return false, tostring(okRes) end
|
||||||
if okRes == false then return false, tostring(detail or "update_failed") end
|
if okRes == false then return false, tostring(detail or "update_failed") end
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function updateICCardFieldWithFallback(dev, keys, value)
|
||||||
|
if type(keys) == "string" then
|
||||||
|
return updateICCardField(dev, keys, value)
|
||||||
|
end
|
||||||
|
local firstErr = nil
|
||||||
|
if type(keys) ~= "table" then return false, "invalid_keys" end
|
||||||
|
for _, key in ipairs(keys) do
|
||||||
|
local okField, errField = updateICCardField(dev, key, value)
|
||||||
|
if okField then
|
||||||
|
return true, key
|
||||||
|
end
|
||||||
|
if not firstErr then
|
||||||
|
firstErr = tostring(key) .. ": " .. tostring(errField or "update_failed")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false, firstErr or "update_failed"
|
||||||
|
end
|
||||||
|
|
||||||
local function updateICCardFields(dev, patch)
|
local function updateICCardFields(dev, patch)
|
||||||
local allOk = true
|
local allOk = true
|
||||||
local firstErr = nil
|
local firstErr = nil
|
||||||
@@ -1086,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)
|
||||||
@@ -1134,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
|
||||||
|
|
||||||
@@ -1145,56 +1428,64 @@ 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
|
||||||
local writeErr = nil
|
local writeErr = nil
|
||||||
for _, dev in ipairs(inspectionDevs) do
|
for _, dev in ipairs(inspectionDevs) do
|
||||||
local okPatch, errPatch = updateICCardFields(dev, {
|
local okPatch, errPatch = updateICCardFieldWithFallback(dev, {
|
||||||
entry = exitStation,
|
"entry_station",
|
||||||
})
|
"entry",
|
||||||
|
"start_station"
|
||||||
|
}, exitStation)
|
||||||
okWrite = okPatch and okWrite
|
okWrite = okPatch and okWrite
|
||||||
if not okPatch and not writeErr then writeErr = errPatch end
|
if not okPatch and not writeErr then writeErr = errPatch end
|
||||||
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
|
||||||
@@ -1203,7 +1494,11 @@ local function handleICCardScan(scan, side, scanDev)
|
|||||||
local okDeduct, newBalanceOrErr = deductICCardBalance(dev, fare)
|
local okDeduct, newBalanceOrErr = deductICCardBalance(dev, fare)
|
||||||
if okDeduct then
|
if okDeduct then
|
||||||
balance = toMoney(newBalanceOrErr or (balance - fare)) or 0
|
balance = toMoney(newBalanceOrErr or (balance - fare)) or 0
|
||||||
local okClear, clearErr = updateICCardField(dev, "entry", nil)
|
local okClear, clearErr = updateICCardFieldWithFallback(dev, {
|
||||||
|
"entry_station",
|
||||||
|
"entry",
|
||||||
|
"start_station"
|
||||||
|
}, nil)
|
||||||
local okFare, fareErr = updateICCardField(dev, "last_fare", fare)
|
local okFare, fareErr = updateICCardField(dev, "last_fare", fare)
|
||||||
if not okClear or not okFare then
|
if not okClear or not okFare then
|
||||||
okWrite = false
|
okWrite = false
|
||||||
@@ -1213,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
|
||||||
@@ -1224,20 +1522,20 @@ 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
|
||||||
end
|
end
|
||||||
|
|
||||||
for _, dev in ipairs(inspectionDevs) do
|
for _, dev in ipairs(inspectionDevs) do
|
||||||
|
applyTicketDeviceState(dev, usedAction, nil, exitStation)
|
||||||
if usedAction == "entry" then
|
if usedAction == "entry" then
|
||||||
updateDeviceField(dev, "entered", true)
|
|
||||||
updateDeviceField(dev, "exited", false)
|
|
||||||
if #entryStation > 0 then updateDeviceField(dev, "entry_station", entryStation) end
|
if #entryStation > 0 then updateDeviceField(dev, "entry_station", entryStation) end
|
||||||
else
|
else
|
||||||
updateDeviceField(dev, "exited", true)
|
|
||||||
updateDeviceField(dev, "entered", false)
|
|
||||||
if #exitStation > 0 then updateDeviceField(dev, "exit_station", exitStation) end
|
if #exitStation > 0 then updateDeviceField(dev, "exit_station", exitStation) end
|
||||||
updateDeviceField(dev, "last_fare", fare)
|
updateDeviceField(dev, "last_fare", fare)
|
||||||
end
|
end
|
||||||
@@ -1293,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
|
||||||
|
|
||||||
@@ -1331,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
|
||||||
|
|
||||||
@@ -1347,7 +1654,7 @@ local function handleScan(scan, side, scanDev)
|
|||||||
or tonumber(scan.trips_remaining or scan.rides_remaining)
|
or tonumber(scan.trips_remaining or scan.rides_remaining)
|
||||||
or tonumber(scan.rides)
|
or tonumber(scan.rides)
|
||||||
for _, dev in ipairs(inspectionDevs) do
|
for _, dev in ipairs(inspectionDevs) do
|
||||||
applyTicketDeviceState(dev, usedAction, newRides)
|
applyTicketDeviceState(dev, usedAction, newRides, stationCodeForSide(side))
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -1402,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
|
||||||
@@ -1455,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)))
|
||||||
@@ -1521,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)
|
||||||
@@ -1528,4 +1852,4 @@ local function backgroundSyncTask()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
parallel.waitForAny(mainLoop, backgroundSyncTask)
|
parallel.waitForAny(mainLoop, startupRedrawTask, backgroundSyncTask)
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
--[[
|
|
||||||
Author: HenryDu8133 813367384@qq.com
|
|
||||||
Date: 2026-06-28 12:58:42
|
|
||||||
LastEditors: HenryDu8133 813367384@qq.com
|
|
||||||
LastEditTime: 2026-07-11 00:02:33
|
|
||||||
FilePath: \TicketMachine\install_ticketmachine.lua
|
|
||||||
Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
|
||||||
--]]
|
|
||||||
|
|
||||||
local URL_MACHINE_HTTP = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/ticketmachine.lua"
|
local URL_MACHINE_HTTP = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/ticketmachine.lua"
|
||||||
local URL_UPDATE_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_machine.lua"
|
local URL_UPDATE_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_machine.lua"
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@ module.exports = {
|
|||||||
cors: {
|
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(),
|
||||||
|
|||||||
+176
-706
@@ -1,10 +1,3 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const DEFAULT_VIEWPORT = { width: 1440, height: 920 };
|
|
||||||
const DEFAULT_LINE_COLOR = '#93a2b7';
|
|
||||||
const REFERENCE_SVG_PATH = path.resolve(__dirname, '..', '..', 'FSE铁路线路图.svg');
|
|
||||||
|
|
||||||
const escapeHTML = (s) => String(s || '')
|
const escapeHTML = (s) => String(s || '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
@@ -12,738 +5,215 @@ const escapeHTML = (s) => String(s || '')
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
function generate(stations, lines, fares, nameByCode, transfers) {
|
||||||
const round = (value, step = 2) => Math.round(Number(value || 0) / step) * step;
|
const enNameByCode = (() => {
|
||||||
const toCode = (value) => String(value || '').trim();
|
try {
|
||||||
const normalizeName = (value) => String(value || '').trim().toLowerCase().replace(/\s+/g, '');
|
const map = {};
|
||||||
const parseNumber = (value) => {
|
for (const s of (stations || [])) {
|
||||||
const num = Number(value);
|
map[String(s.code || '')] = s.en_name || s.enName || '';
|
||||||
return Number.isFinite(num) ? num : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const attrRegex = /([\w:-]+)="([^"]*)"/g;
|
|
||||||
|
|
||||||
function parseAttributes(raw) {
|
|
||||||
const attrs = {};
|
|
||||||
if (!raw) return attrs;
|
|
||||||
for (const match of raw.matchAll(attrRegex)) {
|
|
||||||
attrs[match[1]] = match[2];
|
|
||||||
}
|
|
||||||
return attrs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseReferenceSvgMeta() {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(REFERENCE_SVG_PATH)) return { ...DEFAULT_VIEWPORT, anchorPoints: [] };
|
|
||||||
const raw = fs.readFileSync(REFERENCE_SVG_PATH, 'utf8');
|
|
||||||
const viewBoxMatch = raw.match(/viewBox="([\d.\s-]+)"/i);
|
|
||||||
const widthMatch = raw.match(/\bwidth="([\d.]+)"/i);
|
|
||||||
const heightMatch = raw.match(/\bheight="([\d.]+)"/i);
|
|
||||||
let width = DEFAULT_VIEWPORT.width;
|
|
||||||
let height = DEFAULT_VIEWPORT.height;
|
|
||||||
if (viewBoxMatch) {
|
|
||||||
const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number);
|
|
||||||
if (parts.length === 4 && Number.isFinite(parts[2]) && Number.isFinite(parts[3])) {
|
|
||||||
width = parts[2];
|
|
||||||
height = parts[3];
|
|
||||||
}
|
}
|
||||||
} else {
|
return map;
|
||||||
width = parseNumber(widthMatch?.[1]) || width;
|
} catch (_) {
|
||||||
height = parseNumber(heightMatch?.[1]) || height;
|
return {};
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const circles = [];
|
const linesForStation = {};
|
||||||
for (const match of raw.matchAll(/<circle\b([^>]*)\/?>/gi)) {
|
(lines || []).forEach((li) => {
|
||||||
const attrs = parseAttributes(match[1]);
|
const id = li.id || '';
|
||||||
const cx = parseNumber(attrs.cx);
|
const name = li.name || li.cn_name || '';
|
||||||
const cy = parseNumber(attrs.cy);
|
const color = li.color || '#93a2b7';
|
||||||
const r = parseNumber(attrs.r);
|
const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map((x) => String(x));
|
||||||
if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(r)) continue;
|
for (const code of arr) {
|
||||||
circles.push({
|
if (!linesForStation[code]) linesForStation[code] = [];
|
||||||
cx,
|
linesForStation[code].push({ id, name, color, stations: arr });
|
||||||
cy,
|
|
||||||
r,
|
|
||||||
fill: attrs.fill || '',
|
|
||||||
stroke: attrs.stroke || '',
|
|
||||||
strokeWidth: parseNumber(attrs['stroke-width']) || 0
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorCandidates = circles.filter((circle) => {
|
|
||||||
if (circle.r < 4) return false;
|
|
||||||
if (!circle.stroke || circle.stroke.toLowerCase() === 'none') return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const clusters = [];
|
|
||||||
for (const circle of anchorCandidates) {
|
|
||||||
let target = null;
|
|
||||||
for (const cluster of clusters) {
|
|
||||||
const dx = cluster.x - circle.cx;
|
|
||||||
const dy = cluster.y - circle.cy;
|
|
||||||
const distance = Math.sqrt((dx * dx) + (dy * dy));
|
|
||||||
if (distance <= 18) {
|
|
||||||
target = cluster;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!target) {
|
|
||||||
target = { x: circle.cx, y: circle.cy, count: 0 };
|
|
||||||
clusters.push(target);
|
|
||||||
}
|
|
||||||
target.count += 1;
|
|
||||||
target.x += (circle.cx - target.x) / target.count;
|
|
||||||
target.y += (circle.cy - target.y) / target.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
const anchorPoints = clusters
|
|
||||||
.sort((a, b) => (a.y - b.y) || (a.x - b.x))
|
|
||||||
.map((point) => ({ x: point.x, y: point.y }));
|
|
||||||
|
|
||||||
return { width, height, anchorPoints };
|
|
||||||
} catch (_) {
|
|
||||||
return { ...DEFAULT_VIEWPORT, anchorPoints: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createUnionFind() {
|
|
||||||
const parent = new Map();
|
|
||||||
const find = (value) => {
|
|
||||||
if (!parent.has(value)) parent.set(value, value);
|
|
||||||
const root = parent.get(value);
|
|
||||||
if (root !== value) {
|
|
||||||
const collapsed = find(root);
|
|
||||||
parent.set(value, collapsed);
|
|
||||||
return collapsed;
|
|
||||||
}
|
|
||||||
return root;
|
|
||||||
};
|
|
||||||
const union = (a, b) => {
|
|
||||||
if (!a || !b) return;
|
|
||||||
const ra = find(a);
|
|
||||||
const rb = find(b);
|
|
||||||
if (ra !== rb) parent.set(rb, ra);
|
|
||||||
};
|
|
||||||
return { parent, find, union };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTransferGroups(stations, transfers) {
|
|
||||||
const uf = createUnionFind();
|
|
||||||
const codesByName = new Map();
|
|
||||||
|
|
||||||
for (const station of (stations || [])) {
|
|
||||||
const code = toCode(station?.code);
|
|
||||||
if (!code) continue;
|
|
||||||
uf.find(code);
|
|
||||||
const nameKey = `${normalizeName(station?.name || station?.cn_name || station?.station_name)}|${normalizeName(station?.en_name || station?.enName || station?.en)}`;
|
|
||||||
if (nameKey !== '|') {
|
|
||||||
if (!codesByName.has(nameKey)) codesByName.set(nameKey, []);
|
|
||||||
codesByName.get(nameKey).push(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const station of (stations || [])) {
|
|
||||||
const from = toCode(station?.code);
|
|
||||||
if (!from) continue;
|
|
||||||
const transferTo = Array.isArray(station?.transfer_to) ? station.transfer_to : [];
|
|
||||||
for (const item of transferTo) {
|
|
||||||
const to = toCode((typeof item === 'string') ? item : (item?.code || item?.station || item?.id || item?.[0]));
|
|
||||||
if (to) uf.union(from, to);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pair of (transfers || [])) {
|
|
||||||
const from = toCode(pair?.[0]);
|
|
||||||
const to = toCode(pair?.[1]);
|
|
||||||
if (from && to) uf.union(from, to);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const group of codesByName.values()) {
|
|
||||||
if (!Array.isArray(group) || group.length < 2) continue;
|
|
||||||
for (let index = 1; index < group.length; index += 1) {
|
|
||||||
uf.union(group[0], group[index]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const codesByCanonical = {};
|
|
||||||
const canonicalByCode = {};
|
|
||||||
for (const station of (stations || [])) {
|
|
||||||
const code = toCode(station?.code);
|
|
||||||
if (!code) continue;
|
|
||||||
const canonical = uf.find(code);
|
|
||||||
if (!codesByCanonical[canonical]) codesByCanonical[canonical] = [];
|
|
||||||
codesByCanonical[canonical].push(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const root of Object.keys(codesByCanonical)) {
|
|
||||||
const sortedCodes = Array.from(new Set(codesByCanonical[root])).sort((a, b) => a.localeCompare(b));
|
|
||||||
const canonical = sortedCodes[0];
|
|
||||||
delete codesByCanonical[root];
|
|
||||||
codesByCanonical[canonical] = sortedCodes;
|
|
||||||
for (const code of sortedCodes) canonicalByCode[code] = canonical;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { canonicalByCode, codesByCanonical };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNodes(stations, nameByCode, transferGroups) {
|
|
||||||
const nodes = new Map();
|
|
||||||
for (const station of (stations || [])) {
|
|
||||||
const code = toCode(station?.code);
|
|
||||||
if (!code) continue;
|
|
||||||
const canonical = transferGroups.canonicalByCode[code] || code;
|
|
||||||
const name = String(
|
|
||||||
station?.name
|
|
||||||
|| station?.cn_name
|
|
||||||
|| station?.station_name
|
|
||||||
|| nameByCode?.[code]
|
|
||||||
|| code
|
|
||||||
);
|
|
||||||
const enName = String(station?.en_name || station?.enName || station?.en || '');
|
|
||||||
const svgX = parseNumber(station?.svg_x);
|
|
||||||
const svgY = parseNumber(station?.svg_y);
|
|
||||||
if (!nodes.has(canonical)) {
|
|
||||||
nodes.set(canonical, {
|
|
||||||
canonical,
|
|
||||||
codes: [],
|
|
||||||
primaryCode: code,
|
|
||||||
name,
|
|
||||||
enName,
|
|
||||||
lines: new Set(),
|
|
||||||
explicitPositions: [],
|
|
||||||
explicitLineIndices: [],
|
|
||||||
isTransfer: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const node = nodes.get(canonical);
|
|
||||||
node.codes.push(code);
|
|
||||||
if (!node.name && name) node.name = name;
|
|
||||||
if (!node.enName && enName) node.enName = enName;
|
|
||||||
if (Number.isFinite(svgX) && Number.isFinite(svgY)) {
|
|
||||||
node.explicitPositions.push({ x: svgX, y: svgY });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLineSegments(lines, transferGroups, nodeMap) {
|
|
||||||
const segments = [];
|
|
||||||
const uniqueEdgeSet = new Set();
|
|
||||||
|
|
||||||
(lines || []).forEach((line, lineIndex) => {
|
|
||||||
const color = line?.color || DEFAULT_LINE_COLOR;
|
|
||||||
const lineId = String(line?.id || `line-${lineIndex + 1}`);
|
|
||||||
const name = String(line?.name || line?.cn_name || line?.线路名称 || lineId);
|
|
||||||
const enName = String(line?.en_name || line?.英文名称 || line?.英文名 || '');
|
|
||||||
const stopsRaw = Array.isArray(line?.stations)
|
|
||||||
? line.stations
|
|
||||||
: (Array.isArray(line?.stops) ? line.stops : (Array.isArray(line?.站点列表) ? line.站点列表 : []));
|
|
||||||
let current = [];
|
|
||||||
|
|
||||||
const commitSegment = () => {
|
|
||||||
const canonicalStops = [];
|
|
||||||
for (const stop of current) {
|
|
||||||
const code = toCode(stop);
|
|
||||||
if (!code || code === '-') continue;
|
|
||||||
const canonical = transferGroups.canonicalByCode[code] || code;
|
|
||||||
if (canonicalStops[canonicalStops.length - 1] !== canonical) canonicalStops.push(canonical);
|
|
||||||
const node = nodeMap.get(canonical);
|
|
||||||
if (node) {
|
|
||||||
node.lines.add(lineId);
|
|
||||||
node.explicitLineIndices.push(lineIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (canonicalStops.length < 2) {
|
|
||||||
current = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
segments.push({ id: lineId, name, enName, color, lineIndex, stops: canonicalStops });
|
|
||||||
for (let index = 0; index < canonicalStops.length - 1; index += 1) {
|
|
||||||
const a = canonicalStops[index];
|
|
||||||
const b = canonicalStops[index + 1];
|
|
||||||
uniqueEdgeSet.add([a, b].sort().join('|'));
|
|
||||||
}
|
|
||||||
current = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const stop of stopsRaw) {
|
|
||||||
if (toCode(stop) === '-') {
|
|
||||||
commitSegment();
|
|
||||||
} else {
|
|
||||||
current.push(stop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
commitSegment();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const groupsMap = {};
|
||||||
segments,
|
(stations || []).forEach((s) => {
|
||||||
edges: Array.from(uniqueEdgeSet).map((key) => {
|
const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim();
|
||||||
const [from, to] = key.split('|');
|
if (!k) return;
|
||||||
return { from, to };
|
if (!groupsMap[k]) groupsMap[k] = [];
|
||||||
})
|
groupsMap[k].push(String(s.code || ''));
|
||||||
};
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function buildSeedLayout(nodeMap, lineSegments, referenceMeta) {
|
const groupsForColors = Object.values(groupsMap).filter((arr) => arr.length > 1);
|
||||||
const nodes = Array.from(nodeMap.values());
|
const colorsForRoute = (a, b) => {
|
||||||
const baseX = 140;
|
const la = linesForStation[a] || [];
|
||||||
const baseY = 120;
|
const lb = linesForStation[b] || [];
|
||||||
const gapX = 96;
|
const same = la.find((x) => lb.some((y) => y.id === x.id));
|
||||||
const gapY = 92;
|
if (same) return [same.color || '#93a2b7'];
|
||||||
const seed = {};
|
|
||||||
|
|
||||||
const hasExplicitPositions = nodes.some((node) => node.explicitPositions.length > 0);
|
for (const g of groupsForColors) {
|
||||||
if (hasExplicitPositions) {
|
const startLine = la.find((li) => (li.stations || []).some((code) => g.includes(code)));
|
||||||
for (const node of nodes) {
|
const endLine = lb.find((li) => (li.stations || []).some((code) => g.includes(code)));
|
||||||
if (node.explicitPositions.length > 0) {
|
if (startLine && endLine) {
|
||||||
const x = node.explicitPositions.reduce((sum, pos) => sum + pos.x, 0) / node.explicitPositions.length;
|
const c1 = startLine.color || '#93a2b7';
|
||||||
const y = node.explicitPositions.reduce((sum, pos) => sum + pos.y, 0) / node.explicitPositions.length;
|
const c2 = endLine.color || '#93a2b7';
|
||||||
seed[node.canonical] = { x, y, locked: true };
|
return (startLine.id === endLine.id) ? [c1] : [c1, c2];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
const occurrenceMap = {};
|
const buildGraph = () => {
|
||||||
for (const segment of lineSegments.segments) {
|
const adj = new Map();
|
||||||
segment.stops.forEach((canonical, stopIndex) => {
|
const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); };
|
||||||
if (!occurrenceMap[canonical]) occurrenceMap[canonical] = [];
|
const addEdge = (u, v, w) => {
|
||||||
occurrenceMap[canonical].push({
|
addNode(u);
|
||||||
x: baseX + (stopIndex * gapX),
|
addNode(v);
|
||||||
y: baseY + (segment.lineIndex * gapY)
|
adj.get(u).set(v, w);
|
||||||
});
|
adj.get(v).set(u, w);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let fallbackIndex = 0;
|
|
||||||
const fallbackCols = Math.max(4, Math.ceil(Math.sqrt(Math.max(nodes.length, 1))));
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (!seed[node.canonical] && occurrenceMap[node.canonical]?.length) {
|
|
||||||
const samples = occurrenceMap[node.canonical];
|
|
||||||
seed[node.canonical] = {
|
|
||||||
x: samples.reduce((sum, item) => sum + item.x, 0) / samples.length,
|
|
||||||
y: samples.reduce((sum, item) => sum + item.y, 0) / samples.length,
|
|
||||||
locked: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!seed[node.canonical]) {
|
|
||||||
seed[node.canonical] = {
|
|
||||||
x: baseX + ((fallbackIndex % fallbackCols) * gapX),
|
|
||||||
y: baseY + (Math.floor(fallbackIndex / fallbackCols) * gapY),
|
|
||||||
locked: false
|
|
||||||
};
|
|
||||||
fallbackIndex += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const anchorPoints = Array.isArray(referenceMeta?.anchorPoints) ? referenceMeta.anchorPoints : [];
|
|
||||||
if (!hasExplicitPositions && anchorPoints.length >= nodes.length * 0.6) {
|
|
||||||
const nodeOrder = nodes
|
|
||||||
.map((node) => ({ node, seed: seed[node.canonical] }))
|
|
||||||
.sort((a, b) => (a.seed.y - b.seed.y) || (a.seed.x - b.seed.x));
|
|
||||||
const anchors = anchorPoints
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (a.y - b.y) || (a.x - b.x));
|
|
||||||
const limit = Math.min(nodeOrder.length, anchors.length);
|
|
||||||
for (let index = 0; index < limit; index += 1) {
|
|
||||||
const { node } = nodeOrder[index];
|
|
||||||
const anchor = anchors[index];
|
|
||||||
seed[node.canonical] = {
|
|
||||||
x: (seed[node.canonical].x * 0.45) + (anchor.x * 0.55),
|
|
||||||
y: (seed[node.canonical].y * 0.45) + (anchor.y * 0.55),
|
|
||||||
locked: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return seed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function optimizeLayout(nodeMap, lineSegments, referenceMeta) {
|
|
||||||
const nodes = Array.from(nodeMap.values());
|
|
||||||
const seed = buildSeedLayout(nodeMap, lineSegments, referenceMeta);
|
|
||||||
const positions = {};
|
|
||||||
const velocities = {};
|
|
||||||
const neighbors = new Map();
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
positions[node.canonical] = {
|
|
||||||
x: seed[node.canonical]?.x || 0,
|
|
||||||
y: seed[node.canonical]?.y || 0
|
|
||||||
};
|
};
|
||||||
velocities[node.canonical] = { x: 0, y: 0 };
|
|
||||||
neighbors.set(node.canonical, new Set());
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const edge of lineSegments.edges) {
|
const validStationCodes = new Set((stations || []).map((s) => String(s.code || '')));
|
||||||
neighbors.get(edge.from)?.add(edge.to);
|
|
||||||
neighbors.get(edge.to)?.add(edge.from);
|
|
||||||
}
|
|
||||||
|
|
||||||
const iterations = 450;
|
(fares || []).forEach((f) => {
|
||||||
const repulsionBase = 45000;
|
const a = String(f.from || '');
|
||||||
const springLength = 120;
|
const b = String(f.to || '');
|
||||||
const springStrength = 0.015;
|
if (!a || !b) return;
|
||||||
const bendStrength = 0.025;
|
if (!validStationCodes.has(a) || !validStationCodes.has(b)) return;
|
||||||
const anchorStrength = 0.02;
|
|
||||||
const damping = 0.82;
|
|
||||||
|
|
||||||
for (let iteration = 0; iteration < iterations; iteration += 1) {
|
const w = Number(f.cost_regular ?? f.cost) || 0;
|
||||||
const alpha = 1 - (iteration / iterations);
|
addEdge(a, b, w);
|
||||||
const forces = {};
|
});
|
||||||
for (const node of nodes) forces[node.canonical] = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
// 1. Repulsion to untangle graph
|
if (Array.isArray(transfers)) {
|
||||||
for (let i = 0; i < nodes.length; i += 1) {
|
transfers.forEach((p) => {
|
||||||
const a = nodes[i];
|
const u = String(p[0] || '');
|
||||||
const pa = positions[a.canonical];
|
const v = String(p[1] || '');
|
||||||
for (let j = i + 1; j < nodes.length; j += 1) {
|
if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) {
|
||||||
const b = nodes[j];
|
addEdge(u, v, 0);
|
||||||
const pb = positions[b.canonical];
|
|
||||||
let dx = pa.x - pb.x;
|
|
||||||
let dy = pa.y - pb.y;
|
|
||||||
let distanceSq = (dx * dx) + (dy * dy);
|
|
||||||
if (distanceSq < 1) {
|
|
||||||
dx = 0.5 - Math.random();
|
|
||||||
dy = 0.5 - Math.random();
|
|
||||||
distanceSq = (dx * dx) + (dy * dy);
|
|
||||||
}
|
}
|
||||||
const distance = Math.sqrt(distanceSq);
|
});
|
||||||
const scale = (repulsionBase * alpha) / distanceSq;
|
|
||||||
const fx = (dx / distance) * scale;
|
|
||||||
const fy = (dy / distance) * scale;
|
|
||||||
forces[a.canonical].x += fx;
|
|
||||||
forces[a.canonical].y += fy;
|
|
||||||
forces[b.canonical].x -= fx;
|
|
||||||
forces[b.canonical].y -= fy;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Spring forces for edges & bend smoothing
|
(stations || []).forEach((s) => {
|
||||||
for (const segment of lineSegments.segments) {
|
const c = String(s.code || '');
|
||||||
for (let index = 0; index < segment.stops.length - 1; index += 1) {
|
if (c) addNode(c);
|
||||||
const from = segment.stops[index];
|
});
|
||||||
const to = segment.stops[index + 1];
|
return adj;
|
||||||
const pa = positions[from];
|
|
||||||
const pb = positions[to];
|
|
||||||
let dx = pb.x - pa.x;
|
|
||||||
let dy = pb.y - pa.y;
|
|
||||||
let distance = Math.sqrt((dx * dx) + (dy * dy));
|
|
||||||
if (distance < 1) distance = 1;
|
|
||||||
const delta = distance - springLength;
|
|
||||||
const force = delta * springStrength;
|
|
||||||
const fx = (dx / distance) * force;
|
|
||||||
const fy = (dy / distance) * force;
|
|
||||||
forces[from].x += fx;
|
|
||||||
forces[from].y += fy;
|
|
||||||
forces[to].x -= fx;
|
|
||||||
forces[to].y -= fy;
|
|
||||||
}
|
|
||||||
for (let index = 1; index < segment.stops.length - 1; index += 1) {
|
|
||||||
const prev = positions[segment.stops[index - 1]];
|
|
||||||
const current = positions[segment.stops[index]];
|
|
||||||
const next = positions[segment.stops[index + 1]];
|
|
||||||
const targetX = (prev.x + next.x) / 2;
|
|
||||||
const targetY = (prev.y + next.y) / 2;
|
|
||||||
forces[segment.stops[index]].x += (targetX - current.x) * bendStrength;
|
|
||||||
forces[segment.stops[index]].y += (targetY - current.y) * bendStrength;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Orthogonal snapping force (Octilinear bias)
|
|
||||||
const orthoStrength = 0.03 * alpha;
|
|
||||||
for (const edge of lineSegments.edges) {
|
|
||||||
const pa = positions[edge.from];
|
|
||||||
const pb = positions[edge.to];
|
|
||||||
const dx = pb.x - pa.x;
|
|
||||||
const dy = pb.y - pa.y;
|
|
||||||
const adx = Math.abs(dx);
|
|
||||||
const ady = Math.abs(dy);
|
|
||||||
|
|
||||||
if (adx < ady && adx < ady * 0.4) {
|
|
||||||
// pull towards vertical
|
|
||||||
forces[edge.from].x += dx * orthoStrength;
|
|
||||||
forces[edge.to].x -= dx * orthoStrength;
|
|
||||||
} else if (ady < adx && ady < adx * 0.4) {
|
|
||||||
// pull towards horizontal
|
|
||||||
forces[edge.from].y += dy * orthoStrength;
|
|
||||||
forces[edge.to].y -= dy * orthoStrength;
|
|
||||||
} else {
|
|
||||||
// pull towards diagonal (45 degrees)
|
|
||||||
const avg = (adx + ady) / 2;
|
|
||||||
const targetDx = avg * Math.sign(dx || 1);
|
|
||||||
const targetDy = avg * Math.sign(dy || 1);
|
|
||||||
forces[edge.from].x += (dx - targetDx) * orthoStrength;
|
|
||||||
forces[edge.to].x -= (dx - targetDx) * orthoStrength;
|
|
||||||
forces[edge.from].y += (dy - targetDy) * orthoStrength;
|
|
||||||
forces[edge.to].y -= (dy - targetDy) * orthoStrength;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Anchor forces
|
|
||||||
for (const node of nodes) {
|
|
||||||
const anchor = seed[node.canonical];
|
|
||||||
if (!anchor) continue;
|
|
||||||
const strength = anchor.locked ? 0.2 : anchorStrength;
|
|
||||||
forces[node.canonical].x += (anchor.x - positions[node.canonical].x) * strength;
|
|
||||||
forces[node.canonical].y += (anchor.y - positions[node.canonical].y) * strength;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Update velocities and positions
|
|
||||||
for (const node of nodes) {
|
|
||||||
const velocity = velocities[node.canonical];
|
|
||||||
const force = forces[node.canonical];
|
|
||||||
velocity.x = (velocity.x + (force.x * 0.6)) * damping;
|
|
||||||
velocity.y = (velocity.y + (force.y * 0.6)) * damping;
|
|
||||||
positions[node.canonical].x += velocity.x;
|
|
||||||
positions[node.canonical].y += velocity.y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final alignment to Grid to ensure perfect octilinear rendering
|
|
||||||
const xs = nodes.map((node) => positions[node.canonical].x);
|
|
||||||
const ys = nodes.map((node) => positions[node.canonical].y);
|
|
||||||
const minX = Math.min(...xs);
|
|
||||||
const maxX = Math.max(...xs);
|
|
||||||
const minY = Math.min(...ys);
|
|
||||||
const maxY = Math.max(...ys);
|
|
||||||
const sourceWidth = Math.max(maxX - minX, 1);
|
|
||||||
const sourceHeight = Math.max(maxY - minY, 1);
|
|
||||||
const targetWidth = Math.max((referenceMeta?.width || DEFAULT_VIEWPORT.width) - 260, 640);
|
|
||||||
const targetHeight = Math.max((referenceMeta?.height || DEFAULT_VIEWPORT.height) - 220, 420);
|
|
||||||
const scale = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight);
|
|
||||||
const offsetX = 130;
|
|
||||||
const offsetY = 110;
|
|
||||||
|
|
||||||
const GRID_SIZE = 24;
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
const point = positions[node.canonical];
|
|
||||||
let rawX = ((point.x - minX) * scale) + offsetX;
|
|
||||||
let rawY = ((point.y - minY) * scale) + offsetY;
|
|
||||||
|
|
||||||
// Snap to grid
|
|
||||||
point.x = Math.round(rawX / GRID_SIZE) * GRID_SIZE;
|
|
||||||
point.y = Math.round(rawY / GRID_SIZE) * GRID_SIZE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return positions;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLinePath(points) {
|
|
||||||
if (!Array.isArray(points) || points.length === 0) return '';
|
|
||||||
let d = `M ${points[0].x} ${points[0].y}`;
|
|
||||||
|
|
||||||
for (let index = 1; index < points.length; index += 1) {
|
|
||||||
const p1 = points[index - 1];
|
|
||||||
const p2 = points[index];
|
|
||||||
const dx = p2.x - p1.x;
|
|
||||||
const dy = p2.y - p1.y;
|
|
||||||
const adx = Math.abs(dx);
|
|
||||||
const ady = Math.abs(dy);
|
|
||||||
|
|
||||||
if (adx === 0 || ady === 0 || Math.abs(adx - ady) < 2) {
|
|
||||||
// Already orthogonal or 45-degree diagonal
|
|
||||||
d += ` L ${p2.x} ${p2.y}`;
|
|
||||||
} else {
|
|
||||||
// Need an octilinear chamfer (a single bend)
|
|
||||||
const signX = Math.sign(dx);
|
|
||||||
const signY = Math.sign(dy);
|
|
||||||
|
|
||||||
if (adx > ady) {
|
|
||||||
// Horizontal is longer: straight horizontal, then 45-degree
|
|
||||||
const hDist = adx - ady;
|
|
||||||
const midX = p1.x + hDist * signX;
|
|
||||||
const midY = p1.y;
|
|
||||||
d += ` L ${midX} ${midY} L ${p2.x} ${p2.y}`;
|
|
||||||
} else {
|
|
||||||
// Vertical is longer: straight vertical, then 45-degree
|
|
||||||
const vDist = ady - adx;
|
|
||||||
const midX = p1.x;
|
|
||||||
const midY = p1.y + vDist * signY;
|
|
||||||
d += ` L ${midX} ${midY} L ${p2.x} ${p2.y}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getContrastColor(hex) {
|
|
||||||
if (!hex) return '#ffffff';
|
|
||||||
let value = String(hex).replace('#', '');
|
|
||||||
if (value.length === 3) value = value.split('').map((item) => item + item).join('');
|
|
||||||
const r = parseInt(value.slice(0, 2), 16) || 0;
|
|
||||||
const g = parseInt(value.slice(2, 4), 16) || 0;
|
|
||||||
const b = parseInt(value.slice(4, 6), 16) || 0;
|
|
||||||
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
|
||||||
return yiq >= 140 ? '#0f172a' : '#ffffff';
|
|
||||||
}
|
|
||||||
|
|
||||||
function piePath(cx, cy, r, a0, a1) {
|
|
||||||
const x0 = cx + (r * Math.cos(a0));
|
|
||||||
const y0 = cy + (r * Math.sin(a0));
|
|
||||||
const x1 = cx + (r * Math.cos(a1));
|
|
||||||
const y1 = cy + (r * Math.sin(a1));
|
|
||||||
const largeArc = (a1 - a0) > Math.PI ? 1 : 0;
|
|
||||||
return `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${largeArc} 1 ${x1} ${y1} Z`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pieSvg(cx, cy, r, colors) {
|
|
||||||
const palette = Array.from(new Set((Array.isArray(colors) ? colors : []).filter(Boolean))).slice(0, 4);
|
|
||||||
if (palette.length === 0) return '';
|
|
||||||
if (palette.length === 1) return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${escapeHTML(palette[0])}" />`;
|
|
||||||
const step = (Math.PI * 2) / palette.length;
|
|
||||||
let output = '';
|
|
||||||
for (let index = 0; index < palette.length; index += 1) {
|
|
||||||
const a0 = (-Math.PI / 2) + (index * step);
|
|
||||||
const a1 = a0 + step;
|
|
||||||
output += `<path d="${piePath(cx, cy, r, a0, a1)}" fill="${escapeHTML(palette[index])}" />`;
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStation(node, point, lineColorMap) {
|
|
||||||
const colors = Array.from(node.lines)
|
|
||||||
.map((lineId) => lineColorMap.get(lineId) || DEFAULT_LINE_COLOR)
|
|
||||||
.filter(Boolean);
|
|
||||||
const uniqueColors = Array.from(new Set(colors));
|
|
||||||
const isTransfer = node.isTransfer || uniqueColors.length >= 2 || node.codes.length >= 2;
|
|
||||||
const code = escapeHTML(node.primaryCode);
|
|
||||||
const name = escapeHTML(node.name || node.primaryCode);
|
|
||||||
const enName = escapeHTML(node.enName || '');
|
|
||||||
const x = point.x;
|
|
||||||
const y = point.y;
|
|
||||||
const codeFontSize = 9;
|
|
||||||
const nameFontSize = 13;
|
|
||||||
const enFontSize = 8.5;
|
|
||||||
const codeWidth = Math.max((node.primaryCode || '').length * 6 + 10, 28);
|
|
||||||
const preferTop = isTransfer;
|
|
||||||
|
|
||||||
let markerSvg = '';
|
|
||||||
if (isTransfer) {
|
|
||||||
markerSvg += `<circle cx="${x}" cy="${y}" r="15" fill="#ffffff" stroke="#1f2937" stroke-width="2.5" />`;
|
|
||||||
markerSvg += pieSvg(x, y, 11.5, uniqueColors);
|
|
||||||
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="6.5" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
|
|
||||||
} else {
|
|
||||||
const color = uniqueColors[0] || DEFAULT_LINE_COLOR;
|
|
||||||
markerSvg += `<circle cx="${x}" cy="${y}" r="12.5" fill="#ffffff" stroke="${escapeHTML(color)}" stroke-width="5" />`;
|
|
||||||
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="7" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let textSvg = '';
|
|
||||||
if (preferTop) {
|
|
||||||
const baseY = y - 14;
|
|
||||||
const enY = baseY;
|
|
||||||
const nameY = enName ? (baseY - 12) : baseY;
|
|
||||||
const codeY = nameY - 16;
|
|
||||||
textSvg += `<rect x="${x - (codeWidth / 2)}" y="${codeY - 8}" width="${codeWidth}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
|
|
||||||
textSvg += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${code}</text>`;
|
|
||||||
textSvg += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="4" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
|
|
||||||
if (enName) {
|
|
||||||
textSvg += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const topY = y + 24;
|
|
||||||
const codeY = topY;
|
|
||||||
const nameY = topY + 16;
|
|
||||||
const enY = nameY + 12;
|
|
||||||
textSvg += `<rect x="${x - (codeWidth / 2)}" y="${codeY - 8}" width="${codeWidth}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
|
|
||||||
textSvg += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${code}</text>`;
|
|
||||||
textSvg += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="4" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
|
|
||||||
if (enName) {
|
|
||||||
textSvg += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<g class="map-station ${isTransfer ? 'transfer' : ''}" data-code="${code}" data-canonical="${escapeHTML(node.canonical)}" onclick="handleStationClick('${code}')" style="cursor:pointer">
|
|
||||||
<rect x="${x - 22}" y="${y - 22}" width="44" height="44" fill="transparent" />
|
|
||||||
${markerSvg}
|
|
||||||
${textSvg}
|
|
||||||
</g>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLineLabels(segment, points) {
|
|
||||||
if (!Array.isArray(points) || points.length < 2) return '';
|
|
||||||
const color = segment.color || DEFAULT_LINE_COLOR;
|
|
||||||
const textColor = getContrastColor(color);
|
|
||||||
const subTextColor = (textColor === '#ffffff') ? 'rgba(255,255,255,0.9)' : 'rgba(15,23,42,0.8)';
|
|
||||||
const labelWidth = Math.max(String(segment.name || '').length * 14 + 20, String(segment.enName || '').length * 7 + 20, 80);
|
|
||||||
const height = segment.enName ? 36 : 24;
|
|
||||||
const build = (point, alignLeft) => {
|
|
||||||
const x = alignLeft ? point.x - 54 - labelWidth : point.x + 54;
|
|
||||||
const y = point.y - (height / 2);
|
|
||||||
const textX = x + (labelWidth / 2);
|
|
||||||
let output = `<rect x="${x}" y="${y}" width="${labelWidth}" height="${height}" rx="6" fill="${escapeHTML(color)}" />`;
|
|
||||||
if (segment.enName) {
|
|
||||||
output += `<text x="${textX}" y="${y + 15}" text-anchor="middle" fill="${textColor}" font-size="12" font-weight="bold">${escapeHTML(segment.name)}</text>`;
|
|
||||||
output += `<text x="${textX}" y="${y + 28}" text-anchor="middle" fill="${subTextColor}" font-size="9" font-weight="600">${escapeHTML(segment.enName)}</text>`;
|
|
||||||
} else {
|
|
||||||
output += `<text x="${textX}" y="${y + 16}" text-anchor="middle" fill="${textColor}" font-size="12" font-weight="bold">${escapeHTML(segment.name)}</text>`;
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return build(points[0], true) + build(points[points.length - 1], false);
|
const adj = buildGraph();
|
||||||
}
|
const codes = Array.from(adj.keys()).sort((a, b) => a.localeCompare(b));
|
||||||
|
const neighborsOf = (u) => adj.get(u) || new Map();
|
||||||
|
|
||||||
function calculateBounds(positions) {
|
const dijkstra = (src) => {
|
||||||
const items = Object.values(positions || {});
|
const dist = {};
|
||||||
if (!items.length) return { minX: 0, minY: 0, maxX: DEFAULT_VIEWPORT.width, maxY: DEFAULT_VIEWPORT.height };
|
const visited = new Set();
|
||||||
return {
|
for (const n of adj.keys()) dist[n] = Infinity;
|
||||||
minX: Math.min(...items.map((item) => item.x)),
|
dist[src] = 0;
|
||||||
minY: Math.min(...items.map((item) => item.y)),
|
|
||||||
maxX: Math.max(...items.map((item) => item.x)),
|
while (true) {
|
||||||
maxY: Math.max(...items.map((item) => item.y))
|
let u = null;
|
||||||
|
let best = Infinity;
|
||||||
|
for (const n of adj.keys()) {
|
||||||
|
if (!visited.has(n) && dist[n] < best) {
|
||||||
|
best = dist[n];
|
||||||
|
u = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (u === null) break;
|
||||||
|
visited.add(u);
|
||||||
|
for (const [v, w] of neighborsOf(u)) {
|
||||||
|
const nd = dist[u] + Number(w || 0);
|
||||||
|
if (nd < dist[v]) dist[v] = nd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dist;
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function generate(stations, lines, fares, nameByCode, transfers) {
|
const distAll = {};
|
||||||
const referenceMeta = parseReferenceSvgMeta();
|
codes.forEach((c) => {
|
||||||
const transferGroups = buildTransferGroups(stations, transfers);
|
distAll[c] = dijkstra(c);
|
||||||
const nodeMap = buildNodes(stations, nameByCode || {}, transferGroups);
|
});
|
||||||
const lineSegments = buildLineSegments(lines, transferGroups, nodeMap);
|
|
||||||
|
|
||||||
for (const node of nodeMap.values()) {
|
let maxFare = 1;
|
||||||
node.codes = Array.from(new Set(node.codes)).sort((a, b) => a.localeCompare(b));
|
for (const r of codes) {
|
||||||
node.primaryCode = node.codes[0] || node.primaryCode;
|
for (const c of codes) {
|
||||||
node.isTransfer = node.codes.length >= 2 || node.lines.size >= 2;
|
if (r === c) continue;
|
||||||
|
const v = Number(distAll[r][c] || 0);
|
||||||
|
if (v > maxFare) maxFare = v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nodeMap.size) {
|
const margin = 8;
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${DEFAULT_VIEWPORT.width}" height="${DEFAULT_VIEWPORT.height}" viewBox="0 0 ${DEFAULT_VIEWPORT.width} ${DEFAULT_VIEWPORT.height}" style="background:#ffffff"><text x="80" y="120" fill="#64748b" font-size="18" font-family="Inter, system-ui, sans-serif">暂无可显示的站点数据</text></svg>`;
|
const cellW = 56;
|
||||||
}
|
const cellH = 40;
|
||||||
|
const estimateWidth = (text, size) => Math.ceil(String(text || '').length * (size <= 10 ? 7 : 10));
|
||||||
const positions = optimizeLayout(nodeMap, lineSegments, referenceMeta);
|
const maxCnW = Math.max(1, ...codes.map((c) => estimateWidth(nameByCode[c] || c, 14)));
|
||||||
const bounds = calculateBounds(positions);
|
const maxEnW = Math.max(1, ...codes.map((c) => estimateWidth(enNameByCode[c] || '', 10)));
|
||||||
const width = Math.max(referenceMeta.width || DEFAULT_VIEWPORT.width, Math.ceil(bounds.maxX + 180));
|
const maxCodeW = Math.max(1, ...codes.map((c) => estimateWidth(c, 10)));
|
||||||
const height = Math.max(referenceMeta.height || DEFAULT_VIEWPORT.height, Math.ceil(bounds.maxY + 160));
|
const leftW = Math.max(140, 24 + Math.max(maxCnW, maxEnW, maxCodeW));
|
||||||
const lineColorMap = new Map();
|
const topH = 64;
|
||||||
(lineSegments.segments || []).forEach((segment) => lineColorMap.set(segment.id, segment.color || DEFAULT_LINE_COLOR));
|
const n = codes.length;
|
||||||
|
const width = margin + leftW + n * cellW + margin;
|
||||||
|
const height = margin + topH + n * cellH + margin;
|
||||||
|
|
||||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:#ffffff;font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif">`;
|
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:#ffffff;font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif">`;
|
||||||
svg += `<metadata>generated-by=svg-generator; mode=network-map; reference-svg=${escapeHTML(path.basename(REFERENCE_SVG_PATH))}; stations=${nodeMap.size}; lineSegments=${lineSegments.segments.length}; fares=${Array.isArray(fares) ? fares.length : 0}</metadata>`;
|
|
||||||
svg += `<rect width="${width}" height="${height}" fill="#ffffff" />`;
|
|
||||||
|
|
||||||
for (const segment of lineSegments.segments) {
|
for (let j = 0; j < n; j++) {
|
||||||
const points = segment.stops.map((canonical) => positions[canonical]).filter(Boolean);
|
const code = codes[j];
|
||||||
if (points.length < 2) continue;
|
const name = nameByCode[code] || code;
|
||||||
const d = buildLinePath(points);
|
const en = enNameByCode[code] || '';
|
||||||
svg += `<path d="${d}" fill="none" stroke="${escapeHTML(segment.color || DEFAULT_LINE_COLOR)}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
|
const x = margin + leftW + j * cellW + cellW / 2;
|
||||||
svg += renderLineLabels(segment, points);
|
const y = margin + 20;
|
||||||
|
svg += `<text x="${x}" y="${y}" font-size="14" font-weight="900" fill="#000000" text-anchor="middle">${escapeHTML(name)}</text>`;
|
||||||
|
if (en) svg += `<text x="${x}" y="${y + 14}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(en)}</text>`;
|
||||||
|
svg += `<text x="${x}" y="${y + 26}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(code)}</text>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedNodes = Array.from(nodeMap.values()).sort((a, b) => {
|
for (let i = 0; i < n; i++) {
|
||||||
const pa = positions[a.canonical];
|
const code = codes[i];
|
||||||
const pb = positions[b.canonical];
|
const name = nameByCode[code] || code;
|
||||||
return (pa.x - pb.x) || (pa.y - pb.y);
|
const en = enNameByCode[code] || '';
|
||||||
});
|
const xText = margin + 6;
|
||||||
|
const baseY = margin + topH + i * cellH + 22;
|
||||||
|
svg += `<text x="${xText}" y="${baseY - 10}" font-size="14" font-weight="900" fill="#000000">${escapeHTML(name)}</text>`;
|
||||||
|
if (en) svg += `<text x="${xText}" y="${baseY + 4}" font-size="10" fill="#000000">${escapeHTML(en)}</text>`;
|
||||||
|
svg += `<text x="${xText}" y="${baseY + 16}" font-size="10" fill="#000000">${escapeHTML(code)}</text>`;
|
||||||
|
|
||||||
for (const node of orderedNodes) {
|
for (let j = 0; j < n; j++) {
|
||||||
const point = positions[node.canonical];
|
const x0 = margin + leftW + j * cellW;
|
||||||
if (!point) continue;
|
const y0 = margin + topH + i * cellH;
|
||||||
svg += renderStation(node, point, lineColorMap);
|
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#ffffff" stroke="#e5e7eb"/>`;
|
||||||
|
if (i === j) {
|
||||||
|
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" fill="#6b7280" text-anchor="middle">-</text>`;
|
||||||
|
} else {
|
||||||
|
const distVal = distAll[code][codes[j]];
|
||||||
|
if (distVal === Infinity || !Number.isFinite(distVal)) {
|
||||||
|
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="14" font-weight="800" fill="#ef4444" text-anchor="middle">×</text>`;
|
||||||
|
} else {
|
||||||
|
const val = Math.round(Number(distVal || 0));
|
||||||
|
const routeColors = colorsForRoute(code, codes[j]);
|
||||||
|
|
||||||
|
if (routeColors.length > 0) {
|
||||||
|
const stripW = cellW / routeColors.length;
|
||||||
|
for (let k = 0; k < routeColors.length; k++) {
|
||||||
|
const c = routeColors[k] || '#93a2b7';
|
||||||
|
const stripX = x0 + k * stripW;
|
||||||
|
svg += `<rect x="${stripX}" y="${y0}" width="${stripW}" height="${cellH}" fill="${escapeHTML(c)}" fill-opacity="0.15" stroke="none"/>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#000000" fill-opacity="0.03" stroke="none"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>`;
|
||||||
|
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" font-weight="800" fill="#000000" text-anchor="middle">${escapeHTML(String(val))}</text>`;
|
||||||
|
|
||||||
|
const dots = routeColors.slice(0, 4);
|
||||||
|
for (let k = 0; k < dots.length; k++) {
|
||||||
|
const cx = x0 + 8 + k * 10;
|
||||||
|
const cy = y0 + 10;
|
||||||
|
const dotColor = dots[k] || '#93a2b7';
|
||||||
|
svg += `<circle cx="${cx}" cy="${cy}" r="3.5" fill="${escapeHTML(dotColor)}" stroke="white" stroke-width="1"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
svg += `</svg>`;
|
svg += `</svg>`;
|
||||||
|
|||||||
+424
-122
@@ -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')
|
||||||
end
|
table.insert(typeCandidates, 'Limited Express')
|
||||||
local issuedId = extractPeripheralId(r2, r3, r1, fallbackTicketId)
|
elseif apiType == 'single' then
|
||||||
local normalizedId = normalizeIssuedTicketId(issuedId)
|
table.insert(typeCandidates, 'Single')
|
||||||
if #normalizedId == 0 then
|
else
|
||||||
return false, '', 'invalid_ticket_id'
|
table.insert(typeCandidates, 'Local')
|
||||||
end
|
table.insert(typeCandidates, 'Ordinary')
|
||||||
return true, normalizedId, 'issueTicket'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local okIssue, ticketId, issueErr = tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
|
local function buildIssuePayload(typeValue)
|
||||||
if okIssue then
|
return {
|
||||||
return true, ticketId, 'issueTicket'
|
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
|
end
|
||||||
|
|
||||||
return tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
|
local variants = {}
|
||||||
|
local seenVariantKey = {}
|
||||||
|
local function addVariant(methodName, args)
|
||||||
|
local parts = { methodName }
|
||||||
|
for i = 1, #args do
|
||||||
|
parts[#parts + 1] = tostring(args[i])
|
||||||
|
end
|
||||||
|
local key = table.concat(parts, '\31')
|
||||||
|
if not seenVariantKey[key] then
|
||||||
|
seenVariantKey[key] = true
|
||||||
|
variants[#variants + 1] = { method = methodName, args = args }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, typeValue in ipairs(typeCandidates) do
|
||||||
|
local payload = buildIssuePayload(typeValue)
|
||||||
|
addVariant('issueTicket', {
|
||||||
|
tostring(fromNameEnArg or ''),
|
||||||
|
tostring(toNameEnArg or ''),
|
||||||
|
typeValue,
|
||||||
|
tonumber(rides) or 1,
|
||||||
|
tonumber(cost) or 0,
|
||||||
|
tostring(startStationArg or ''),
|
||||||
|
tostring(terminalStationArg or ''),
|
||||||
|
tostring(fromNameCnUArg or ''),
|
||||||
|
tostring(toNameCnUArg or '')
|
||||||
|
})
|
||||||
|
addVariant('issueTicket', {
|
||||||
|
tostring(fromNameEnArg or ''),
|
||||||
|
tostring(toNameEnArg or ''),
|
||||||
|
typeValue,
|
||||||
|
tonumber(rides) or 1,
|
||||||
|
tonumber(cost) or 0,
|
||||||
|
tostring(startStationArg or ''),
|
||||||
|
tostring(terminalStationArg or '')
|
||||||
|
})
|
||||||
|
addVariant('issueTicketData', { payload })
|
||||||
|
addVariant('writeTicketData', { payload })
|
||||||
|
end
|
||||||
|
|
||||||
|
local lastError = 'unsupported_method'
|
||||||
|
for _, variant in ipairs(variants) do
|
||||||
|
local fn = dev[variant.method]
|
||||||
|
if type(fn) == 'function' then
|
||||||
|
local okCall, r1, r2, r3 = pcall(fn, table.unpack(variant.args))
|
||||||
|
if okCall and peripheralCallSucceeded(r1) then
|
||||||
|
local payloadArg = type(variant.args[1]) == 'table' and variant.args[1] or nil
|
||||||
|
local issuedId = extractPeripheralId(r2, r3, r1, payloadArg and payloadArg.ticket_id, payloadArg and payloadArg.id, fallbackId)
|
||||||
|
local normalizedId = normalizeIssuedTicketId(issuedId)
|
||||||
|
if #normalizedId > 0 then
|
||||||
|
return true, normalizedId, variant.method
|
||||||
|
end
|
||||||
|
lastError = 'invalid_ticket_id'
|
||||||
|
else
|
||||||
|
local detail = extractPeripheralId(r1, r2, r3)
|
||||||
|
if okCall then
|
||||||
|
lastError = variant.method .. ':' .. tostring(detail ~= '' and detail or 'issue_failed')
|
||||||
|
else
|
||||||
|
lastError = variant.method .. ':' .. tostring(r1 or 'issue_call_failed')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return false, '', lastError
|
||||||
end
|
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,36 +1636,54 @@ 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)
|
||||||
local ev, p1, p2, p3 = os.pullEvent()
|
|
||||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
while state.page == 'card_name' and keyboardMode == currentKeyboardMode do
|
||||||
for _, b in ipairs(Buttons) do
|
local ev, p1, p2, p3 = os.pullEvent()
|
||||||
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||||
end
|
for _, b in ipairs(Buttons) do
|
||||||
elseif ev == 'char' then
|
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
||||||
appendHolderChar(tostring(p1 or ''))
|
end
|
||||||
elseif ev == 'key' and p1 == keys.backspace then
|
elseif ev == 'char' then
|
||||||
name = name:sub(1, -2)
|
appendHolderChar(tostring(p1 or ''))
|
||||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
elseif ev == 'key' and p1 == keys.backspace then
|
||||||
local clean = firstString(name)
|
name = name:sub(1, -2)
|
||||||
if #clean >= 2 then
|
msg = ''
|
||||||
state.holderName = clean
|
renderInput()
|
||||||
state.page = 'card_topup'
|
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
||||||
else
|
local clean = firstString(name)
|
||||||
msg, msgCol = 'Name too short', colors.red
|
if #clean >= 2 then
|
||||||
|
state.holderName = clean
|
||||||
|
state.page = 'card_topup'
|
||||||
|
else
|
||||||
|
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,26 +1804,46 @@ 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)
|
|
||||||
addButton(w-11, h-3, 'OK', 10, 3, {colors.black, colors.green}, submitCode)
|
|
||||||
local ev, p1, p2, p3 = os.pullEvent()
|
|
||||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
|
||||||
for _, b in ipairs(Buttons) do
|
|
||||||
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
|
||||||
end
|
|
||||||
elseif ev == 'char' and #code < 5 then
|
|
||||||
code = code .. tostring(p1 or ''):upper()
|
|
||||||
elseif ev == 'key' and p1 == keys.backspace then
|
|
||||||
code = code:sub(1, -2)
|
code = code:sub(1, -2)
|
||||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
msg = ''
|
||||||
submitCode()
|
renderInput()
|
||||||
|
end)
|
||||||
|
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function()
|
||||||
|
code = ''
|
||||||
|
msg = ''
|
||||||
|
renderInput()
|
||||||
|
end)
|
||||||
|
addButton(w-11, h-3, 'OK', 10, 3, {colors.black, colors.green}, submitCode)
|
||||||
|
renderInput()
|
||||||
|
while state.page == 'card_online' do
|
||||||
|
local ev, p1, p2, p3 = os.pullEvent()
|
||||||
|
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||||
|
for _, b in ipairs(Buttons) do
|
||||||
|
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
||||||
|
end
|
||||||
|
elseif ev == 'char' and #code < 5 then
|
||||||
|
code = code .. tostring(p1 or ''):upper()
|
||||||
|
msg = ''
|
||||||
|
renderInput()
|
||||||
|
elseif ev == 'key' and p1 == keys.backspace then
|
||||||
|
code = code:sub(1, -2)
|
||||||
|
msg = ''
|
||||||
|
renderInput()
|
||||||
|
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
||||||
|
submitCode()
|
||||||
|
elseif ev == 'config_updated' then
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
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,27 +2755,47 @@ 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)
|
||||||
|
|
||||||
local ev, p1, p2, p3 = os.pullEvent()
|
renderInput()
|
||||||
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
while state.page == 'online' do
|
||||||
for _, b in ipairs(Buttons) do if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end end
|
local ev, p1, p2, p3 = os.pullEvent()
|
||||||
elseif ev == 'char' and #code < 5 then code = code .. p1:upper()
|
if ev == 'mouse_click' or ev == 'monitor_touch' then
|
||||||
elseif ev == 'key' and p1 == keys.backspace then code = code:sub(1, -2)
|
for _, b in ipairs(Buttons) do
|
||||||
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode()
|
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
|
||||||
elseif ev == 'config_updated' then
|
end
|
||||||
|
elseif ev == 'char' and #code < 5 then
|
||||||
|
code = code .. tostring(p1 or ''):upper()
|
||||||
|
msg = ''
|
||||||
|
renderInput()
|
||||||
|
elseif ev == 'key' and p1 == keys.backspace then
|
||||||
|
code = code:sub(1, -2)
|
||||||
|
msg = ''
|
||||||
|
renderInput()
|
||||||
|
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
|
||||||
|
submitCode()
|
||||||
|
elseif ev == 'config_updated' then
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
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">
|
||||||
|
|||||||
+1034
-866
File diff suppressed because it is too large
Load Diff
+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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
<!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">
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 0; background: #f8fafc; display: flex; flex-direction: column; height: 100vh; font-family: system-ui, -apple-system, sans-serif; }
|
|
||||||
header { background: white; padding: 16px 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; justify-content: space-between; align-items: center; z-index: 10; }
|
|
||||||
h1 { margin: 0; font-size: 18px; color: #0f172a; font-weight: 700; }
|
|
||||||
.btn { background: #3b82f6; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; text-decoration: none; font-size: 14px; font-weight: 500; transition: background 0.2s; }
|
|
||||||
.btn:hover { background: #2563eb; }
|
|
||||||
.btn-secondary { background: #e2e8f0; color: #475569; }
|
|
||||||
.btn-secondary:hover { background: #cbd5e1; }
|
|
||||||
.controls { display: flex; gap: 12px; }
|
|
||||||
#map-container { flex: 1; overflow: auto; display: flex; justify-content: center; align-items: center; padding: 32px; position: relative; }
|
|
||||||
#map-container svg { box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); border-radius: 12px; background: white; max-width: none; transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); transform-origin: center; }
|
|
||||||
.loading-hint { color: #64748b; font-size: 15px; position: absolute; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>全网线路图预览 (正交布线)</h1>
|
|
||||||
<div class="controls">
|
|
||||||
<a href="/index.html" class="btn btn-secondary">返回后台</a>
|
|
||||||
<button class="btn" onclick="loadMap()">重新生成</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div id="map-container">
|
|
||||||
<div class="loading-hint">正在计算布局,请稍候...</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
let scale = 1;
|
|
||||||
async function loadMap() {
|
|
||||||
const container = document.getElementById('map-container');
|
|
||||||
container.innerHTML = '<div class="loading-hint">正在计算正交布局,请稍候...</div>';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/public/fares/map/light?t=' + Date.now());
|
|
||||||
const svg = await res.text();
|
|
||||||
container.innerHTML = svg;
|
|
||||||
scale = 1;
|
|
||||||
updateZoom();
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = '<div style="color: #ef4444;">加载失败</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function updateZoom() {
|
|
||||||
const svg = document.querySelector('#map-container svg');
|
|
||||||
if(svg) svg.style.transform = `scale(${scale})`;
|
|
||||||
}
|
|
||||||
document.getElementById('map-container').addEventListener('wheel', (e) => {
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
scale += e.deltaY * -0.002;
|
|
||||||
scale = Math.min(Math.max(0.125, scale), 4);
|
|
||||||
updateZoom();
|
|
||||||
}
|
|
||||||
}, { passive: false });
|
|
||||||
loadMap();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
| |||||||