Compare commits
134
Commits
fa2457ce7f
...
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 | ||
|
|
4ea58772d3 | ||
|
|
620289e836 | ||
|
|
e6eae25cf1 | ||
|
|
5bd2e5b882 | ||
|
|
fefd919031 | ||
|
|
3311ee70ce | ||
|
|
63d710114d | ||
|
|
a79142660f | ||
|
|
139a65f24c | ||
|
|
ae11f9e34b | ||
|
|
6a80db6d1d | ||
|
|
ccb7ad5721 | ||
|
|
20cfdc5174 | ||
|
|
e1637da9c6 | ||
|
|
46f8a17157 | ||
|
|
423a5972fc | ||
|
|
9ef5d2099e | ||
|
|
a651fac095 | ||
|
|
83c7d25cbe | ||
|
|
dc835f498d | ||
|
|
d6f37d1415 | ||
|
|
4052cd2f58 | ||
|
|
db24b4da18 | ||
|
|
1ce7fd630b | ||
|
|
0c1341e9c7 | ||
|
|
116b2af4ef | ||
|
|
aede84f164 | ||
|
|
690173c522 | ||
|
|
3040b294ef | ||
|
|
1265deca0f | ||
|
|
ca9805f13a | ||
|
|
ef6af4bc62 | ||
|
|
a228c24b33 | ||
|
|
c5cd4b6a95 | ||
|
|
1d9f08d5c6 | ||
|
|
4fe3a24eaf | ||
|
|
de187db646 | ||
|
|
f0b62eca49 | ||
|
|
37675c07a2 | ||
|
|
84dbdf58c4 | ||
|
|
becd8f0e20 | ||
|
|
cbdb0d3ed3 | ||
|
|
efc061e471 | ||
|
|
2eddb8b84c | ||
|
|
d5b7a51dae | ||
|
|
79ae234232 | ||
|
|
bd6ef05722 | ||
|
|
c9845ff15a | ||
|
|
1f60c74ec2 | ||
|
|
61e98cd6d5 | ||
|
|
de29ecac84 | ||
|
|
74b9754b7d | ||
|
|
6d0596f614 | ||
|
|
b528a3ae56 | ||
|
|
0186e14eb2 | ||
|
|
b0361fc366 | ||
|
|
bc5fe471ab | ||
|
|
1049dfd79d | ||
|
|
0d11292273 | ||
|
|
3781f63708 | ||
|
|
6c41c4c53d | ||
|
|
2ffb449502 | ||
|
|
331b1700b4 | ||
|
|
446a8b0d09 | ||
|
|
d848f1c438 | ||
|
|
f0b8db1662 | ||
|
|
565f0008dd | ||
|
|
701cceee02 | ||
|
|
8a78034ad3 | ||
|
|
78f302381a | ||
|
|
fddffee348 | ||
|
|
908fdc2af0 | ||
|
|
0ef09239fd | ||
|
|
33155ef2ed | ||
|
|
f1cdfe3a84 | ||
|
|
5f184bf184 | ||
|
|
4daa0b6185 | ||
|
|
1bdf0ca5f2 | ||
|
|
967c6740a3 | ||
|
|
230418079c | ||
|
|
f65e043130 | ||
|
|
8435717a58 | ||
|
|
2e113232d9 | ||
|
|
8b10117487 | ||
|
|
29d66c43b0 | ||
|
|
d2809c4d6d |
@@ -0,0 +1,2 @@
|
||||
DEBUG_SERVER_URL=http://127.0.0.1:7777/event
|
||||
DEBUG_SESSION_ID=device-id-missing
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
1. 完成功能制作后自行编辑git commit并推送
|
||||
2. 提交时请添加功能描述
|
||||
@@ -0,0 +1,2 @@
|
||||
1. 服务器地址为192.140.163.241,服务端运行的端口为22222
|
||||
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,2 +0,0 @@
|
||||
{}
|
||||
|
||||
+111
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 473 KiB |
@@ -1,4 +1,3 @@
|
||||
|
||||
# FTC 票务系统
|
||||
|
||||
[](https://makeapullrequest.com) []() [](https://tweaked.cc/) [](https://nodejs.org/) []() []()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Debug Session: device-id-missing
|
||||
- **Status**: [OPEN]
|
||||
- **Issue**: 数据记录中的设备 ID 当前全部未显示,界面只看到“机器ID:”后没有具体值。
|
||||
- **Debug Server**: http://127.0.0.1:7777/event
|
||||
- **Log File**: .dbg/trae-debug-log-device-id-missing.ndjson
|
||||
|
||||
## Reproduction Steps
|
||||
1. 使用售票机完成一次售票。
|
||||
2. 使用闸机完成一次进站或出站。
|
||||
3. 打开后台或票据时间线页面查看“机器ID”字段。
|
||||
4. 观察“机器ID:”后方是否为空。
|
||||
|
||||
## Hypotheses & Verification
|
||||
| ID | Hypothesis | Likelihood | Effort | Evidence |
|
||||
|----|------------|------------|--------|----------|
|
||||
| A | 后端接口返回的事件对象里根本没有 `device` / `detail.device` 字段 | High | Low | Rejected |
|
||||
| B | 前端格式化函数读到了值,但后续字符串拼接或编码问题把值丢掉了 | High | Low | Rejected |
|
||||
| C | 售票与检票两类事件的数据结构不同,当前前端只兼容了其中一类 | Med | Low | Confirmed |
|
||||
| D | 设备端 Lua 确实上报了 ID,但服务端在写库或对外返回时被覆盖成空串 | Med | Med | Rejected |
|
||||
| E | 页面实际运行的不是当前工作区这份前端代码,导致本地修改未生效 | Med | Med | Pending |
|
||||
|
||||
## Log Evidence
|
||||
- 2026-07-10: 已启动 Debug Server `http://127.0.0.1:7777/event`
|
||||
- 2026-07-10: 已在 `[public.js]` 的票据时间线组装处添加 A/C 埋点
|
||||
- 2026-07-10: 已在 `[index.js]` 与 `[ticket-board.html]` 的设备 ID 格式化处添加 B 埋点
|
||||
- 2026-07-10: 本地运行时复现结果:`saleRawDevice="#0"` 但 `saleMappedDevice=null`;同时 `checkRawDevice="#1"` 且 `checkMappedDevice="#1"`,证明 `sale` 映射阶段丢失了 `device`
|
||||
|
||||
## Verification Conclusion
|
||||
- 已实施最小修复:在公开票据详情的 `sale` 事件映射中保留 `device`
|
||||
@@ -1,4 +1,5 @@
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -55,20 +56,25 @@ local function httpGet(url)
|
||||
end
|
||||
end
|
||||
|
||||
local function getCacheBustedUrl(url)
|
||||
local sep = url:find("?") and "&" or "?"
|
||||
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
|
||||
end
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("Ticket Machine Installer")
|
||||
print("")
|
||||
print("Downloading ticket machine program...")
|
||||
|
||||
local ok, code = httpGet(URL_MACHINE_HTTP)
|
||||
local ok, code = httpGet(getCacheBustedUrl(URL_MACHINE_HTTP))
|
||||
|
||||
if not ok or type(code) ~= "string" or #code == 0 then
|
||||
print("Download failed: " .. tostring(code or ""))
|
||||
return
|
||||
end
|
||||
|
||||
local okUpdate, updateCode = httpGet(URL_UPDATE_MACHINE)
|
||||
local okUpdate, updateCode = httpGet(getCacheBustedUrl(URL_UPDATE_MACHINE))
|
||||
if not okUpdate or type(updateCode) ~= "string" or #updateCode == 0 then
|
||||
print("Download failed: " .. tostring(updateCode or ""))
|
||||
return
|
||||
|
||||
+14
-5
@@ -1,3 +1,4 @@
|
||||
|
||||
local URL_ERROR = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/error.dfpwm"
|
||||
local URL_PASS = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/pass.dfpwm"
|
||||
local URL_GATE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/gate.lua"
|
||||
@@ -9,6 +10,9 @@ local function trim(s)
|
||||
return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
|
||||
|
||||
|
||||
local function splitCsv(s)
|
||||
local out = {}
|
||||
s = trim(s)
|
||||
@@ -20,7 +24,6 @@ local function splitCsv(s)
|
||||
return out
|
||||
end
|
||||
|
||||
|
||||
local function writeFile(path, content, binary)
|
||||
local mode = binary and "wb" or "w"
|
||||
local f = fs.open(path, mode)
|
||||
@@ -70,9 +73,14 @@ local function httpGet(url)
|
||||
end
|
||||
end
|
||||
|
||||
local function getCacheBustedUrl(url)
|
||||
local sep = url:find("?") and "&" or "?"
|
||||
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
|
||||
end
|
||||
|
||||
local function download(url, path, binary)
|
||||
print("Downloading: " .. path)
|
||||
local ok, data = httpGet(url)
|
||||
local ok, data = httpGet(getCacheBustedUrl(url))
|
||||
if not ok then
|
||||
print("Download failed: " .. tostring(data or ""))
|
||||
return false
|
||||
@@ -89,7 +97,7 @@ term.setCursorPos(1, 1)
|
||||
print("Ticket Gate Installer")
|
||||
print("")
|
||||
|
||||
local stationCode = trim(prompt("Station code: "))
|
||||
local stationCode = trim(prompt("Station code(s) [comma or / separated]: "))
|
||||
if #stationCode == 0 then
|
||||
print("No station code provided.")
|
||||
return
|
||||
@@ -97,7 +105,8 @@ end
|
||||
|
||||
local modeRaw = prompt("Gate mode (entry/exit): ")
|
||||
local mode = (trim(modeRaw):lower() == "exit") and "exit" or "entry"
|
||||
local cfg = { mode = mode, station_codes = { stationCode }, station_code = stationCode }
|
||||
local parsedCodes = splitCsv(stationCode)
|
||||
local cfg = { mode = mode, station_codes = parsedCodes, station_code = parsedCodes[1] or stationCode }
|
||||
local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg)
|
||||
if not okCfg then
|
||||
print("Config serialize failed.")
|
||||
@@ -111,7 +120,7 @@ end
|
||||
if not download(URL_PASS, "pass.dfpwm", true) then return end
|
||||
if not download(URL_ERROR, "error.dfpwm", true) then return end
|
||||
|
||||
local okGate, gateCode = httpGet(URL_GATE)
|
||||
local okGate, gateCode = httpGet(getCacheBustedUrl(URL_GATE))
|
||||
if not okGate then
|
||||
print("Download failed: startup")
|
||||
return
|
||||
|
||||
+13
-7
@@ -69,9 +69,14 @@ local function httpGet(url)
|
||||
end
|
||||
end
|
||||
|
||||
local function getCacheBustedUrl(url)
|
||||
local sep = url:find("?") and "&" or "?"
|
||||
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
|
||||
end
|
||||
|
||||
local function download(url, path, binary)
|
||||
print("Downloading: " .. path)
|
||||
local ok, data = httpGet(url)
|
||||
local ok, data = httpGet(getCacheBustedUrl(url))
|
||||
if not ok then
|
||||
print("Download failed: " .. tostring(data or ""))
|
||||
return false
|
||||
@@ -92,7 +97,7 @@ term.setCursorPos(1, 1)
|
||||
print("Bidirectional Ticket Gate Installer")
|
||||
print("")
|
||||
|
||||
local stationCode = trim(prompt("Station code: "))
|
||||
local stationCode = trim(prompt("Station code(s) [comma or / separated]: "))
|
||||
if #stationCode == 0 then
|
||||
print("No station code provided.")
|
||||
return
|
||||
@@ -105,16 +110,17 @@ local backRaw = prompt("Back mode (entry/exit): ")
|
||||
local frontMode = normalizeMode(frontRaw)
|
||||
local backMode = normalizeMode(backRaw)
|
||||
|
||||
local parsedCodes = splitCsv(stationCode)
|
||||
local cfg = {
|
||||
station_codes = { stationCode },
|
||||
station_code = stationCode,
|
||||
station_codes = parsedCodes,
|
||||
station_code = parsedCodes[1] or stationCode,
|
||||
side_modes = {
|
||||
front = frontMode,
|
||||
back = backMode,
|
||||
},
|
||||
side_station_codes = {
|
||||
front = stationCode,
|
||||
back = stationCode,
|
||||
front = parsedCodes[1] or stationCode,
|
||||
back = parsedCodes[1] or stationCode,
|
||||
}
|
||||
}
|
||||
local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg)
|
||||
@@ -130,7 +136,7 @@ end
|
||||
if not download(URL_PASS, "pass.dfpwm", true) then return end
|
||||
if not download(URL_ERROR, "error.dfpwm", true) then return end
|
||||
|
||||
local okGate, gateCode = httpGet(URL_GATE)
|
||||
local okGate, gateCode = httpGet(getCacheBustedUrl(URL_GATE))
|
||||
if not okGate then
|
||||
print("Download failed: startup")
|
||||
return
|
||||
|
||||
@@ -54,19 +54,24 @@ local function httpGet(url)
|
||||
end
|
||||
end
|
||||
|
||||
local function getCacheBustedUrl(url)
|
||||
local sep = url:find("?") and "&" or "?"
|
||||
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
|
||||
end
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("Refill Machine Installer")
|
||||
print("")
|
||||
print("Downloading refill machine program...")
|
||||
|
||||
local ok, code = httpGet(URL_REFILL_MACHINE)
|
||||
local ok, code = httpGet(getCacheBustedUrl(URL_REFILL_MACHINE))
|
||||
if not ok or type(code) ~= "string" or #code == 0 then
|
||||
print("Download failed: " .. tostring(code or ""))
|
||||
return
|
||||
end
|
||||
|
||||
local okUpdate, updateCode = httpGet(URL_UPDATE_REFILL)
|
||||
local okUpdate, updateCode = httpGet(getCacheBustedUrl(URL_UPDATE_REFILL))
|
||||
if not okUpdate or type(updateCode) ~= "string" or #updateCode == 0 then
|
||||
print("Download failed: " .. tostring(updateCode or ""))
|
||||
return
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ticketmachine",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.18",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"start": "node server/index.js",
|
||||
|
||||
+10
-2
@@ -1,4 +1,4 @@
|
||||
local VERSION = "v1.5.7"
|
||||
local VERSION = "v1.5.22"
|
||||
local MACHINE_SIDE = "bottom"
|
||||
local PAYMENT_SIDE = "right"
|
||||
local PRESET_AMOUNTS = { 5, 10, 15, 20 }
|
||||
@@ -115,13 +115,21 @@ local function postJSON(url, payload)
|
||||
end
|
||||
end
|
||||
|
||||
local function currentDeviceId()
|
||||
local label = os.getComputerLabel()
|
||||
if label and label ~= "" then
|
||||
return label
|
||||
end
|
||||
return "充值机#" .. tostring(os.getComputerID())
|
||||
end
|
||||
|
||||
local function syncRefillResult(cardInfo, amount, balance)
|
||||
local cardId = trim(cardInfo and cardInfo.cardId or "")
|
||||
if #cardId == 0 then return false, "missing_card_id" end
|
||||
local url = API_BASE .. "/ic-cards/" .. urlEncodeComponent(cardId) .. "/sync"
|
||||
return postJSON(url, {
|
||||
type = "topup",
|
||||
device = "refill_machine",
|
||||
device = currentDeviceId(),
|
||||
amount = tonumber(amount) or 0,
|
||||
balance = tonumber(balance) or 0,
|
||||
result = "pass",
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ module.exports = {
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
}
|
||||
},
|
||||
transports: ['websocket']
|
||||
});
|
||||
|
||||
io.engine.on('connection_error', (err) => {
|
||||
|
||||
+77
-4
@@ -151,6 +151,18 @@ const resolveCurrentStationCode = (body, resolveStation) => {
|
||||
return codes[0] || direct || fallback || '';
|
||||
};
|
||||
|
||||
// Dedicated lightweight OTA update endpoints
|
||||
let globalUpdateCounter = Date.now();
|
||||
|
||||
router.post('/trigger-update', (req, res) => {
|
||||
globalUpdateCounter++;
|
||||
res.json({ ok: true, update_counter: globalUpdateCounter });
|
||||
});
|
||||
|
||||
router.get('/check-update', (req, res) => {
|
||||
res.json({ update_counter: globalUpdateCounter });
|
||||
});
|
||||
|
||||
// Config
|
||||
router.get('/config', (req, res) => {
|
||||
const cfg = DataService.getConfig();
|
||||
@@ -162,7 +174,9 @@ router.get('/config', (req, res) => {
|
||||
fares: DataService.getFares(),
|
||||
transfers: cfg.transfers || [],
|
||||
promotion: cfg.promotion || { name: '', discount: 1 },
|
||||
lua_versions: cfg.lua_versions || {}
|
||||
lua_versions: cfg.lua_versions || {},
|
||||
force_update: cfg.force_update === true,
|
||||
update_counter: cfg.update_counter || 0
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,6 +199,10 @@ router.put('/config', async (req, res) => {
|
||||
...(incoming.lua_versions || {})
|
||||
};
|
||||
}
|
||||
if (typeof incoming.force_update === 'boolean') {
|
||||
cfg.force_update = incoming.force_update;
|
||||
}
|
||||
// remove unused update_counter
|
||||
await DataService.saveConfig(cfg);
|
||||
appendReqLog(req, { category: 'admin', type: 'update_config_generic', detail: incoming });
|
||||
io.emit('config:updated', cfg);
|
||||
@@ -1173,6 +1191,29 @@ router.get('/export', (req, res) => {
|
||||
res.json(DataService.buildExportPayload());
|
||||
});
|
||||
|
||||
// Issues
|
||||
router.post('/report_issue', async (req, res) => {
|
||||
const r = req.body || {};
|
||||
const item = {
|
||||
ts: Number(r.ts || Date.now()),
|
||||
device: String(r.device || 'unknown'),
|
||||
machine_id: String(r.machine_id || ''),
|
||||
station_code: String(r.station_code || ''),
|
||||
error_msg: String(r.error_msg || ''),
|
||||
detail: String(r.detail || ''),
|
||||
type: 'issue'
|
||||
};
|
||||
await DataService.appendIssue(item);
|
||||
io.emit('issue:new', item);
|
||||
appendReqLog(req, { category: 'device', type: 'report_issue', detail: item });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/issues', (req, res) => {
|
||||
const issues = DataService.getIssues();
|
||||
res.json({ ok: true, issues });
|
||||
});
|
||||
|
||||
// Ticket Operations
|
||||
router.post('/tickets/sale', async (req, res) => {
|
||||
const { start, terminal, train_type, cost, station_code, device, trips_total, trips_remaining } = req.body || {};
|
||||
@@ -1338,8 +1379,35 @@ router.post('/tickets/check', async (req, res) => {
|
||||
|
||||
const isSingleTicket = String(cur.train_type || '').toLowerCase() === 'single';
|
||||
|
||||
const checkStationOnLine = (lineName, codeRaw, codesSet, codeNorm) => {
|
||||
if (!lineName) return false;
|
||||
const target = String(lineName).trim().toLowerCase().replace(/\s+/g, '');
|
||||
const lines = DataService.getLines() || [];
|
||||
const line = lines.find(l => {
|
||||
const ln = String(l.name || l['线路名称'] || '').trim().toLowerCase().replace(/\s+/g, '');
|
||||
const le = String(l.en_name || l.en || l.enName || '').trim().toLowerCase().replace(/\s+/g, '');
|
||||
return ln === target || le === target;
|
||||
});
|
||||
if (!line) return false; // Line not found
|
||||
const stops = Array.isArray(line.stations) ? line.stations : (Array.isArray(line.stops) ? line.stops : (line['站点列表'] || line['站点'] || []));
|
||||
const stopSet = new Set(stops.map(normStationKey).filter(Boolean));
|
||||
if (codeNorm && stopSet.has(codeNorm)) return true;
|
||||
if (codesSet.size > 0) {
|
||||
for (const sc of codesSet) {
|
||||
if (stopSet.has(sc)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (action === 'entry') {
|
||||
if (!isSingleTicket && !matchesStation(start)) return deny('wrong_station');
|
||||
if (isSingleTicket) {
|
||||
if (!checkStationOnLine(cur.start, station_code_raw, station_codes_norm, station_code_norm)) {
|
||||
return deny('wrong_station');
|
||||
}
|
||||
} else {
|
||||
if (!matchesStation(start)) return deny('wrong_station');
|
||||
}
|
||||
if (entered && !exited) return deny('already_entered');
|
||||
|
||||
const entryCode = isSingleTicket ? (station_code_raw || start) : start;
|
||||
@@ -1352,7 +1420,8 @@ router.post('/tickets/check', async (req, res) => {
|
||||
exited: false,
|
||||
last_action: 'entry',
|
||||
last_station_code: entryCode,
|
||||
start: isSingleTicket ? entryCode : cur.start,
|
||||
start: isSingleTicket ? cur.start : cur.start,
|
||||
entered_station: entryCode,
|
||||
last_result: 'pass',
|
||||
last_reason: '',
|
||||
last_event: 'check',
|
||||
@@ -1367,7 +1436,11 @@ router.post('/tickets/check', async (req, res) => {
|
||||
|
||||
const exitCode = station_code_raw || terminal;
|
||||
if (isSingleTicket) {
|
||||
const fareInfo = LogicService.computeFareBoth(cur.start, exitCode);
|
||||
if (!checkStationOnLine(cur.start, station_code_raw, station_codes_norm, station_code_norm)) {
|
||||
return deny('wrong_station');
|
||||
}
|
||||
const entryCode = cur.entered_station || cur.last_station_code;
|
||||
const fareInfo = LogicService.computeFareBoth(entryCode, exitCode);
|
||||
if (!fareInfo || fareInfo.paid_segments !== 1) {
|
||||
return deny('not_single_segment');
|
||||
}
|
||||
|
||||
+22
-2
@@ -8,6 +8,7 @@ const router = express.Router();
|
||||
|
||||
const ASSETS_DIR = path.join(__dirname, '../../Assets');
|
||||
const MANIFEST_PATH = path.join(ASSETS_DIR, 'manifest.json');
|
||||
const ROUTE_MAP_LICENSE = 'CC BY 4.0';
|
||||
|
||||
const getIp = (req) => (req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || req.connection?.remoteAddress || '';
|
||||
|
||||
@@ -48,6 +49,8 @@ const writeManifest = (m) => {
|
||||
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(out, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const normalizeCredit = (value) => String(value || '').trim().slice(0, 200);
|
||||
|
||||
const removeByPrefix = (prefix) => {
|
||||
ensureDir(ASSETS_DIR);
|
||||
const list = fs.readdirSync(ASSETS_DIR);
|
||||
@@ -97,6 +100,8 @@ router.get('/manifest', (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
routeMap,
|
||||
routeMapCredit: normalizeCredit(m.routeMapCredit || ''),
|
||||
routeMapLicense: ROUTE_MAP_LICENSE,
|
||||
fareTable,
|
||||
updatedAt: m.updatedAt || null
|
||||
});
|
||||
@@ -106,10 +111,24 @@ router.post('/route-map', uploadRouteMap.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ ok: false, error: '缺少文件' });
|
||||
const m = readManifest();
|
||||
m.routeMap = path.basename(req.file.filename);
|
||||
if (req.body && Object.prototype.hasOwnProperty.call(req.body, 'credit')) {
|
||||
m.routeMapCredit = normalizeCredit(req.body.credit);
|
||||
} else if (typeof m.routeMapCredit !== 'string') {
|
||||
m.routeMapCredit = '';
|
||||
}
|
||||
m.updatedAt = Date.now();
|
||||
writeManifest(m);
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap } });
|
||||
res.json({ ok: true, routeMap: m.routeMap, updatedAt: m.updatedAt });
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap, credit: m.routeMapCredit || '' } });
|
||||
res.json({ ok: true, routeMap: m.routeMap, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
|
||||
});
|
||||
|
||||
router.post('/route-map/credit', (req, res) => {
|
||||
const m = readManifest();
|
||||
m.routeMapCredit = normalizeCredit(req.body?.credit);
|
||||
m.updatedAt = Date.now();
|
||||
writeManifest(m);
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_update_route_map_credit', detail: { credit: m.routeMapCredit || '' } });
|
||||
res.json({ ok: true, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
|
||||
});
|
||||
|
||||
router.post('/fare-table', uploadFareTable.single('file'), (req, res) => {
|
||||
@@ -129,6 +148,7 @@ router.delete('/route-map', (req, res) => {
|
||||
}
|
||||
removeByPrefix('route-map');
|
||||
m.routeMap = null;
|
||||
m.routeMapCredit = '';
|
||||
m.updatedAt = Date.now();
|
||||
writeManifest(m);
|
||||
appendReqLog(req, { category: 'admin', type: 'asset_delete_route_map', detail: { ok: true } });
|
||||
|
||||
+98
-3
@@ -228,7 +228,9 @@ router.get('/config', (req, res) => {
|
||||
const cfg = DataService.getConfig();
|
||||
res.json({
|
||||
promotion: { name: cfg.promotion?.name || '', discount: cfg.promotion?.discount ?? 1 },
|
||||
lua_versions: cfg.lua_versions || {}
|
||||
lua_versions: cfg.lua_versions || {},
|
||||
force_update: cfg.force_update === true,
|
||||
update_counter: cfg.update_counter || 0
|
||||
});
|
||||
});
|
||||
|
||||
@@ -694,20 +696,82 @@ router.get('/tickets/:id', async (req, res, next) => {
|
||||
};
|
||||
|
||||
const formattedEvents = events.map(e => {
|
||||
// #region debug-point A:public-ticket-event-raw
|
||||
(() => {
|
||||
const fs = require('fs');
|
||||
let u = 'http://127.0.0.1:7777/event';
|
||||
let s = 'device-id-missing';
|
||||
try {
|
||||
const env = fs.readFileSync('.dbg/device-id-missing.env', 'utf8');
|
||||
u = env.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
|
||||
s = env.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
|
||||
} catch {}
|
||||
fetch(u, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: s,
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'A',
|
||||
location: 'server/routes/public.js:696',
|
||||
msg: '[DEBUG] public ticket event raw',
|
||||
data: {
|
||||
type: e?.type || '',
|
||||
action: e?.action || '',
|
||||
ticket_id: e?.ticket_id || '',
|
||||
device: e?.device ?? null,
|
||||
detail_device: e?.detail?.device ?? null
|
||||
},
|
||||
ts: Date.now()
|
||||
})
|
||||
}).catch(() => {});
|
||||
})();
|
||||
// #endregion
|
||||
if (e.type === 'sale') {
|
||||
return {
|
||||
const mapped = {
|
||||
type: 'sale',
|
||||
ts: e.ts,
|
||||
station_name: e.station_name || nameFor(e.station_code),
|
||||
station_code: e.station_code || '',
|
||||
station_en: e.station_code ? enNameFor(e.station_code) : (e.station_en || ''),
|
||||
ticket_id: e.ticket_id,
|
||||
device: e.device || e.device_id || e.detail?.device || '',
|
||||
start_name: nameFor(e.start),
|
||||
terminal_name: nameFor(e.terminal),
|
||||
train_type: e.train_type || '',
|
||||
trips_total: e.trips_total ?? 0,
|
||||
amount: e.cost ?? 0
|
||||
};
|
||||
// #region debug-point A:public-ticket-event-sale-mapped
|
||||
(() => {
|
||||
const fs = require('fs');
|
||||
let u = 'http://127.0.0.1:7777/event';
|
||||
let s = 'device-id-missing';
|
||||
try {
|
||||
const env = fs.readFileSync('.dbg/device-id-missing.env', 'utf8');
|
||||
u = env.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
|
||||
s = env.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
|
||||
} catch {}
|
||||
fetch(u, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: s,
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'A',
|
||||
location: 'server/routes/public.js:701',
|
||||
msg: '[DEBUG] public sale event mapped',
|
||||
data: {
|
||||
ticket_id: mapped.ticket_id || '',
|
||||
device: mapped.device ?? null,
|
||||
station_code: mapped.station_code || ''
|
||||
},
|
||||
ts: Date.now()
|
||||
})
|
||||
}).catch(() => {});
|
||||
})();
|
||||
// #endregion
|
||||
return mapped;
|
||||
} else if (e.type === 'status') {
|
||||
return {
|
||||
type: 'status',
|
||||
@@ -720,11 +784,42 @@ router.get('/tickets/:id', async (req, res, next) => {
|
||||
trips_remaining: e.trips_remaining ?? e.rides_remaining ?? null
|
||||
};
|
||||
} else if (e.type === 'check') {
|
||||
return {
|
||||
const mapped = {
|
||||
...e,
|
||||
station_name: e.station_name || nameFor(e.station_code),
|
||||
station_en: e.station_code ? enNameFor(e.station_code) : (e.station_en || '')
|
||||
};
|
||||
// #region debug-point C:public-ticket-event-check-mapped
|
||||
(() => {
|
||||
const fs = require('fs');
|
||||
let u = 'http://127.0.0.1:7777/event';
|
||||
let s = 'device-id-missing';
|
||||
try {
|
||||
const env = fs.readFileSync('.dbg/device-id-missing.env', 'utf8');
|
||||
u = env.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
|
||||
s = env.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
|
||||
} catch {}
|
||||
fetch(u, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: s,
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'C',
|
||||
location: 'server/routes/public.js:718',
|
||||
msg: '[DEBUG] public check event mapped',
|
||||
data: {
|
||||
ticket_id: mapped.ticket_id || '',
|
||||
action: mapped.action || '',
|
||||
device: mapped.device ?? null,
|
||||
station_code: mapped.station_code || ''
|
||||
},
|
||||
ts: Date.now()
|
||||
})
|
||||
}).catch(() => {});
|
||||
})();
|
||||
// #endregion
|
||||
return mapped;
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
+15
-1
@@ -64,7 +64,8 @@ const cache = {
|
||||
icCardIndex: {},
|
||||
icCardEvents: [],
|
||||
statsTicket: [],
|
||||
statsGate: []
|
||||
statsGate: [],
|
||||
issues: []
|
||||
};
|
||||
|
||||
const DataService = {
|
||||
@@ -88,6 +89,7 @@ const DataService = {
|
||||
await conn.query(`CREATE TABLE IF NOT EXISTS ticket_events (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||
await conn.query(`CREATE TABLE IF NOT EXISTS stats_ticket (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||
await conn.query(`CREATE TABLE IF NOT EXISTS stats_gate (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||
await conn.query(`CREATE TABLE IF NOT EXISTS issues (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||
|
||||
// Load Cache
|
||||
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
|
||||
@@ -126,6 +128,9 @@ const DataService = {
|
||||
const [statsG] = await conn.query('SELECT data FROM stats_gate');
|
||||
cache.statsGate = statsG.map(r => r.data);
|
||||
|
||||
const [issues] = await conn.query('SELECT data FROM issues');
|
||||
cache.issues = issues.map(r => r.data);
|
||||
|
||||
console.log('DataService initialized with MySQL');
|
||||
} finally {
|
||||
conn.release();
|
||||
@@ -511,6 +516,15 @@ const DataService = {
|
||||
},
|
||||
getStatsGate: () => cache.statsGate,
|
||||
|
||||
// Issues
|
||||
appendIssue: async (item) => {
|
||||
cache.issues.push(item);
|
||||
try {
|
||||
await pool.query('INSERT INTO issues (data) VALUES (?)', [JSON.stringify(item)]);
|
||||
} catch(e) { console.error('appendIssue error', e); }
|
||||
},
|
||||
getIssues: () => cache.issues,
|
||||
|
||||
// Export
|
||||
buildExportPayload: () => ({
|
||||
config: DataService.getConfig(),
|
||||
|
||||
@@ -49,7 +49,7 @@ const buildRouteGraph = (stations, lines, fares, cfg) => {
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const a = String(stops[i] || '').trim();
|
||||
const b = String(stops[i + 1] || '').trim();
|
||||
if (!a || !b || a === b) continue;
|
||||
if (!a || !b || a === b || a === '-' || b === '-') continue;
|
||||
const fare = fareBySegment.get(segKey(a, b));
|
||||
if (!fare) continue;
|
||||
addEdge(a, b, {
|
||||
@@ -86,6 +86,33 @@ const buildRouteGraph = (stations, lines, fares, cfg) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-link stations with the same name (Chinese or English)
|
||||
const stationsByName = new Map();
|
||||
const normKey = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, '');
|
||||
for (const s of (stations || [])) {
|
||||
const code = String(s?.code || '').trim();
|
||||
if (!code) continue;
|
||||
const cn = normKey(s?.name || s?.cn_name || s?.station_name);
|
||||
const en = normKey(s?.en_name || s?.en || s?.enName);
|
||||
if (cn) {
|
||||
if (!stationsByName.has(cn)) stationsByName.set(cn, []);
|
||||
stationsByName.get(cn).push(code);
|
||||
}
|
||||
if (en && en !== cn) {
|
||||
if (!stationsByName.has(en)) stationsByName.set(en, []);
|
||||
stationsByName.get(en).push(code);
|
||||
}
|
||||
}
|
||||
|
||||
for (const codes of stationsByName.values()) {
|
||||
if (codes.length < 2) continue;
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
for (let j = i + 1; j < codes.length; j++) {
|
||||
addTransferEdge(codes[i], codes[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transfers = Array.isArray(cfg?.transfers) ? cfg.transfers : [];
|
||||
for (const p of transfers) {
|
||||
const u = String(p?.[0] || '').trim();
|
||||
|
||||
+117
-101
@@ -1,22 +1,29 @@
|
||||
const fs = require('fs');
|
||||
|
||||
const escapeHTML = (s) => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
const escapeHTML = (s) => String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
function generate(stations, lines, fares, nameByCode, transfers) {
|
||||
const enNameByCode = (() => {
|
||||
try {
|
||||
const map = {}; for (const s of stations) { map[String(s.code || '')] = s.en_name || s.enName || ''; }
|
||||
const map = {};
|
||||
for (const s of (stations || [])) {
|
||||
map[String(s.code || '')] = s.en_name || s.enName || '';
|
||||
}
|
||||
return map;
|
||||
} catch (_) { return {}; }
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
|
||||
// Build linesForStation for coloring (still needed for visualization)
|
||||
|
||||
const linesForStation = {};
|
||||
(lines || []).forEach(li => {
|
||||
(lines || []).forEach((li) => {
|
||||
const id = li.id || '';
|
||||
const name = li.name || li.cn_name || '';
|
||||
const color = li.color || '#93a2b7';
|
||||
const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x));
|
||||
const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map((x) => String(x));
|
||||
for (const code of arr) {
|
||||
if (!linesForStation[code]) linesForStation[code] = [];
|
||||
linesForStation[code].push({ id, name, color, stations: arr });
|
||||
@@ -24,23 +31,23 @@ function generate(stations, lines, fares, nameByCode, transfers) {
|
||||
});
|
||||
|
||||
const groupsMap = {};
|
||||
// Optional: We can still use groups for coloring logic, but NOT for graph edges if we want strict mode.
|
||||
// But let's keep groupsMap for color logic (lines 30-46) as it helps determine if lines connect visually.
|
||||
stations.forEach(s => {
|
||||
(stations || []).forEach((s) => {
|
||||
const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim();
|
||||
if (!k) return; (groupsMap[k] ||= []).push(String(s.code || ''));
|
||||
if (!k) return;
|
||||
if (!groupsMap[k]) groupsMap[k] = [];
|
||||
groupsMap[k].push(String(s.code || ''));
|
||||
});
|
||||
|
||||
const groupsForColors = Object.values(groupsMap).filter(arr => arr.length > 1);
|
||||
|
||||
const groupsForColors = Object.values(groupsMap).filter((arr) => arr.length > 1);
|
||||
const colorsForRoute = (a, b) => {
|
||||
const la = linesForStation[a] || [];
|
||||
const lb = linesForStation[b] || [];
|
||||
const same = la.find(x => lb.some(y => y.id === x.id));
|
||||
const same = la.find((x) => lb.some((y) => y.id === x.id));
|
||||
if (same) return [same.color || '#93a2b7'];
|
||||
// Logic to find connecting colors via transfer groups
|
||||
|
||||
for (const g of groupsForColors) {
|
||||
const startLine = la.find(li => (li.stations || []).some(code => g.includes(code)));
|
||||
const endLine = lb.find(li => (li.stations || []).some(code => g.includes(code)));
|
||||
const startLine = la.find((li) => (li.stations || []).some((code) => g.includes(code)));
|
||||
const endLine = lb.find((li) => (li.stations || []).some((code) => g.includes(code)));
|
||||
if (startLine && endLine) {
|
||||
const c1 = startLine.color || '#93a2b7';
|
||||
const c2 = endLine.color || '#93a2b7';
|
||||
@@ -53,153 +60,162 @@ function generate(stations, lines, fares, nameByCode, transfers) {
|
||||
const buildGraph = () => {
|
||||
const adj = new Map();
|
||||
const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); };
|
||||
const addEdge = (u, v, w) => { addNode(u); addNode(v); adj.get(u).set(v, w); adj.get(v).set(u, w); };
|
||||
|
||||
// Create a set of valid station codes for fast lookup
|
||||
const validStationCodes = new Set(stations.map(s => String(s.code || '')));
|
||||
const addEdge = (u, v, w) => {
|
||||
addNode(u);
|
||||
addNode(v);
|
||||
adj.get(u).set(v, w);
|
||||
adj.get(v).set(u, w);
|
||||
};
|
||||
|
||||
// 1. Edges from explicit FARES only
|
||||
(fares || []).forEach(f => {
|
||||
const a = String(f.from || ''), b = String(f.to || '');
|
||||
const validStationCodes = new Set((stations || []).map((s) => String(s.code || '')));
|
||||
|
||||
(fares || []).forEach((f) => {
|
||||
const a = String(f.from || '');
|
||||
const b = String(f.to || '');
|
||||
if (!a || !b) return;
|
||||
if (!validStationCodes.has(a) || !validStationCodes.has(b)) return;
|
||||
|
||||
|
||||
const w = Number(f.cost_regular ?? f.cost) || 0;
|
||||
addEdge(a, b, w);
|
||||
});
|
||||
|
||||
// 2. Edges from TRANSFERS (0 cost)
|
||||
if (Array.isArray(transfers)) {
|
||||
transfers.forEach(p => {
|
||||
const a = String(p[1] || ''), b = String(p[2] || ''); // startup.lua used index 1,2? No, lua is 1-based. JS is 0-based.
|
||||
// Wait, config transfers is [[a,b], [c,d]].
|
||||
// Let's check how it's stored. usually JSON array of arrays.
|
||||
// In startup.lua: p[1], p[2].
|
||||
// In JS: p[0], p[1].
|
||||
// But let's check standard.
|
||||
// If I look at startup.lua: `local a, b = tostring(p[1] or ''), tostring(p[2] or '')` (Lua 1-based index)
|
||||
// So in JSON it is `["A", "B"]`.
|
||||
// In JS `p[0]` is "A", `p[1]` is "B".
|
||||
// Let's be safe and check both or iterate.
|
||||
|
||||
const u = String(p[0] || '');
|
||||
const v = String(p[1] || '');
|
||||
|
||||
if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) {
|
||||
addEdge(u, v, 0);
|
||||
}
|
||||
});
|
||||
transfers.forEach((p) => {
|
||||
const u = String(p[0] || '');
|
||||
const v = String(p[1] || '');
|
||||
if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) {
|
||||
addEdge(u, v, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// REMOVED: Edges from Lines (implicit 0 cost)
|
||||
// REMOVED: Edges from Groups (implicit 0 cost) - unless we want to keep it as fallback?
|
||||
// User said "Strictly use database data". If transfers are in DB, we use them.
|
||||
// If user relies on name grouping but didn't put it in DB transfers, they might be unhappy.
|
||||
// But "Strictly use database data" implies we shouldn't automagically connect things.
|
||||
// I will remove the auto-grouping for graph edges to be consistent with startup.lua changes.
|
||||
|
||||
stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); });
|
||||
(stations || []).forEach((s) => {
|
||||
const c = String(s.code || '');
|
||||
if (c) addNode(c);
|
||||
});
|
||||
return adj;
|
||||
};
|
||||
|
||||
const adj = buildGraph();
|
||||
const codes = Array.from(adj.keys()).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const neighborsOf = (u) => adj.get(u) || new Map();
|
||||
|
||||
const dijkstra = (src) => {
|
||||
const dist = {}; const visited = new Set();
|
||||
for (const n of adj.keys()) dist[n] = Infinity; dist[src] = 0;
|
||||
const dist = {};
|
||||
const visited = new Set();
|
||||
for (const n of adj.keys()) dist[n] = Infinity;
|
||||
dist[src] = 0;
|
||||
|
||||
while (true) {
|
||||
let u = null; let best = Infinity;
|
||||
for (const n of adj.keys()) { if (!visited.has(n) && dist[n] < best) { best = dist[n]; u = n; } }
|
||||
if (u === null) break; visited.add(u);
|
||||
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;
|
||||
};
|
||||
const distAll = {}; codes.forEach(c => { distAll[c] = dijkstra(c); });
|
||||
|
||||
const distAll = {};
|
||||
codes.forEach((c) => {
|
||||
distAll[c] = dijkstra(c);
|
||||
});
|
||||
|
||||
let maxFare = 1;
|
||||
for (const r of codes) {
|
||||
for (const c of codes) { if (r === c) continue; const v = Number(distAll[r][c] || 0); if (v > maxFare) maxFare = v; }
|
||||
for (const c of codes) {
|
||||
if (r === c) continue;
|
||||
const v = Number(distAll[r][c] || 0);
|
||||
if (v > maxFare) maxFare = v;
|
||||
}
|
||||
}
|
||||
|
||||
const margin = 8;
|
||||
const cellW = 56;
|
||||
const cellH = 40;
|
||||
const estimateWidth = (text, size) => Math.ceil(String(text || '').length * (size <= 10 ? 7 : 10));
|
||||
const maxCnW = Math.max(1, ...codes.map(c => estimateWidth(nameByCode[c] || c, 14)));
|
||||
const maxEnW = Math.max(1, ...codes.map(c => estimateWidth(enNameByCode[c] || '', 10)));
|
||||
const maxCodeW = Math.max(1, ...codes.map(c => estimateWidth(c, 10)));
|
||||
const maxCnW = Math.max(1, ...codes.map((c) => estimateWidth(nameByCode[c] || c, 14)));
|
||||
const maxEnW = Math.max(1, ...codes.map((c) => estimateWidth(enNameByCode[c] || '', 10)));
|
||||
const maxCodeW = Math.max(1, ...codes.map((c) => estimateWidth(c, 10)));
|
||||
const leftW = Math.max(140, 24 + Math.max(maxCnW, maxEnW, maxCodeW));
|
||||
const topH = 64;
|
||||
const n = codes.length;
|
||||
const width = margin + leftW + n * cellW + margin;
|
||||
const height = margin + topH + n * cellH + margin;
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:#ffffff;font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif">`;
|
||||
// Column headers
|
||||
|
||||
for (let j = 0; j < n; j++) {
|
||||
const code = codes[j]; const name = nameByCode[code] || code; const en = enNameByCode[code] || '';
|
||||
const x = margin + leftW + j * cellW + cellW / 2; const y = margin + 20;
|
||||
const code = codes[j];
|
||||
const name = nameByCode[code] || code;
|
||||
const en = enNameByCode[code] || '';
|
||||
const x = margin + leftW + j * cellW + cellW / 2;
|
||||
const y = margin + 20;
|
||||
svg += `<text x="${x}" y="${y}" font-size="14" font-weight="900" fill="#000000" text-anchor="middle">${escapeHTML(name)}</text>`;
|
||||
if (en) svg += `<text x="${x}" y="${y + 14}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(en)}</text>`;
|
||||
svg += `<text x="${x}" y="${y + 26}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(code)}</text>`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const code = codes[i]; const name = nameByCode[code] || code; const en = enNameByCode[code] || '';
|
||||
const xText = margin + 6; const baseY = margin + topH + i * cellH + 22;
|
||||
const code = codes[i];
|
||||
const name = nameByCode[code] || code;
|
||||
const en = enNameByCode[code] || '';
|
||||
const xText = margin + 6;
|
||||
const baseY = margin + topH + i * cellH + 22;
|
||||
svg += `<text x="${xText}" y="${baseY - 10}" font-size="14" font-weight="900" fill="#000000">${escapeHTML(name)}</text>`;
|
||||
if (en) svg += `<text x="${xText}" y="${baseY + 4}" font-size="10" fill="#000000">${escapeHTML(en)}</text>`;
|
||||
svg += `<text x="${xText}" y="${baseY + 16}" font-size="10" fill="#000000">${escapeHTML(code)}</text>`;
|
||||
|
||||
for (let j = 0; j < n; j++) {
|
||||
const x0 = margin + leftW + j * cellW; const y0 = margin + topH + i * cellH;
|
||||
const x0 = margin + leftW + j * cellW;
|
||||
const y0 = margin + topH + i * cellH;
|
||||
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#ffffff" stroke="#e5e7eb"/>`;
|
||||
if (i === j) {
|
||||
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" fill="#6b7280" text-anchor="middle">-</text>`;
|
||||
} else {
|
||||
const distVal = distAll[code][codes[j]];
|
||||
if (distVal === Infinity || !Number.isFinite(distVal)) {
|
||||
// Unreachable station
|
||||
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="14" font-weight="800" fill="#ef4444" text-anchor="middle">×</text>`;
|
||||
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 intensity = maxFare > 0 ? Math.min(1, val / maxFare) : 0;
|
||||
const val = Math.round(Number(distVal || 0));
|
||||
const routeColors = colorsForRoute(code, codes[j]);
|
||||
|
||||
// Get route colors
|
||||
const routeColors = colorsForRoute(code, codes[j]);
|
||||
|
||||
// Draw background strips for multiple lines
|
||||
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;
|
||||
// Use fill-opacity for robustness against any color format
|
||||
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"/>`;
|
||||
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"/>`;
|
||||
}
|
||||
|
||||
// Add cell border
|
||||
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>`;
|
||||
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>`;
|
||||
|
||||
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" font-weight="800" fill="#000000" text-anchor="middle">${escapeHTML(String(val))}</text>`;
|
||||
|
||||
// Show small color indicators at top left
|
||||
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"/>`;
|
||||
}
|
||||
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>`;
|
||||
return svg;
|
||||
}
|
||||
|
||||
+533
-195
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
async function run() {
|
||||
const pool = mysql.createPool({
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
user: 'cc-ticket-machine',
|
||||
password: 'cc-ticket-machine',
|
||||
database: 'cc-ticket-machine',
|
||||
});
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT data FROM config LIMIT 1');
|
||||
if (rows.length > 0) {
|
||||
let data = rows[0].data;
|
||||
if (typeof data === 'string') data = JSON.parse(data);
|
||||
data.lua_versions = data.lua_versions || {};
|
||||
data.lua_versions.ticketmachine = 'v1.5.12';
|
||||
data.lua_versions.gate = 'v1.5.9';
|
||||
await pool.query('UPDATE config SET data = ?', [JSON.stringify(data)]);
|
||||
console.log('Updated DB config to v1.5.12');
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
run();
|
||||
+37
-13
@@ -1,5 +1,19 @@
|
||||
|
||||
local URL_GATE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/gate.lua"
|
||||
local URL_UPDATE_GATE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_gate.lua"
|
||||
local args = { ... }
|
||||
local silent = AUTO_UPDATE_SILENT == true or args[1] == "--silent"
|
||||
|
||||
local function getCacheBustedUrl(url)
|
||||
local sep = url:find("?") and "&" or "?"
|
||||
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
|
||||
end
|
||||
|
||||
local function log(msg)
|
||||
if not silent then
|
||||
print(msg)
|
||||
end
|
||||
end
|
||||
|
||||
local function writeFile(path, content, binary)
|
||||
local mode = binary and "wb" or "w"
|
||||
@@ -54,25 +68,35 @@ local function httpGet(url)
|
||||
end
|
||||
end
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("Ticket Gate Updater")
|
||||
print("")
|
||||
print("Downloading gate program...")
|
||||
if not silent then
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
log("Ticket Gate Updater")
|
||||
log("")
|
||||
end
|
||||
log("Downloading gate program...")
|
||||
|
||||
local ok, code = httpGet(URL_GATE)
|
||||
local ok, code = httpGet(getCacheBustedUrl(URL_GATE))
|
||||
if not ok or type(code) ~= "string" or #code == 0 then
|
||||
print("Download failed: " .. tostring(code or ""))
|
||||
return
|
||||
error("Download failed: " .. tostring(code or ""))
|
||||
end
|
||||
|
||||
if not atomicWrite("startup.lua", code, false) then
|
||||
print("Write failed: startup.lua")
|
||||
return
|
||||
error("Write failed: startup.lua")
|
||||
end
|
||||
atomicWrite("startup", code, false)
|
||||
if fs.exists("gate.lua") then atomicWrite("gate.lua", code, false) end
|
||||
|
||||
print("")
|
||||
print("Done.")
|
||||
print("Reboot the computer to apply the update.")
|
||||
log("Downloading updater program...")
|
||||
local okUp, codeUp = httpGet(getCacheBustedUrl(URL_UPDATE_GATE))
|
||||
if okUp and type(codeUp) == "string" and #codeUp > 0 then
|
||||
atomicWrite("update_gate.lua", codeUp, false)
|
||||
end
|
||||
|
||||
if not silent then
|
||||
log("")
|
||||
log("Done.")
|
||||
log("Reboot the computer to apply the update.")
|
||||
end
|
||||
|
||||
return true
|
||||
|
||||
+37
-14
@@ -1,5 +1,18 @@
|
||||
|
||||
local URL_MACHINE = "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 args = { ... }
|
||||
local silent = AUTO_UPDATE_SILENT == true or args[1] == "--silent"
|
||||
|
||||
local function getCacheBustedUrl(url)
|
||||
local sep = url:find("?") and "&" or "?"
|
||||
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
|
||||
end
|
||||
|
||||
local function log(msg)
|
||||
if not silent then
|
||||
print(msg)
|
||||
end
|
||||
end
|
||||
|
||||
local function writeFile(path, content, binary)
|
||||
local mode = binary and "wb" or "w"
|
||||
@@ -54,25 +67,35 @@ local function httpGet(url)
|
||||
end
|
||||
end
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("Ticket Machine Updater")
|
||||
print("")
|
||||
print("Downloading ticket machine program...")
|
||||
if not silent then
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
log("Ticket Machine Updater")
|
||||
log("")
|
||||
end
|
||||
log("Downloading ticket machine program...")
|
||||
|
||||
local ok, code = httpGet(URL_MACHINE)
|
||||
local ok, code = httpGet(getCacheBustedUrl(URL_MACHINE))
|
||||
if not ok or type(code) ~= "string" or #code == 0 then
|
||||
print("Download failed: " .. tostring(code or ""))
|
||||
return
|
||||
error("Download failed: " .. tostring(code or ""))
|
||||
end
|
||||
|
||||
if not atomicWrite("startup.lua", code, false) then
|
||||
print("Write failed: startup.lua")
|
||||
return
|
||||
error("Write failed: startup.lua")
|
||||
end
|
||||
atomicWrite("startup", code, false)
|
||||
if fs.exists("ticketmachine.lua") then atomicWrite("ticketmachine.lua", code, false) end
|
||||
|
||||
print("")
|
||||
print("Done.")
|
||||
print("Reboot the computer to apply the update.")
|
||||
log("Downloading updater program...")
|
||||
local okUp, codeUp = httpGet(getCacheBustedUrl(URL_UPDATE_MACHINE))
|
||||
if okUp and type(codeUp) == "string" and #codeUp > 0 then
|
||||
atomicWrite("update_machine.lua", codeUp, false)
|
||||
end
|
||||
|
||||
if not silent then
|
||||
log("")
|
||||
log("Done.")
|
||||
log("Reboot the computer to apply the update.")
|
||||
end
|
||||
|
||||
return true
|
||||
|
||||
+13
-1
@@ -1,5 +1,11 @@
|
||||
|
||||
local URL_REFILL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/refillmachine.lua"
|
||||
local URL_UPDATE_REFILL = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_refill.lua"
|
||||
|
||||
local function getCacheBustedUrl(url)
|
||||
local sep = url:find("?") and "&" or "?"
|
||||
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
|
||||
end
|
||||
|
||||
local function writeFile(path, content, binary)
|
||||
local mode = binary and "wb" or "w"
|
||||
@@ -60,12 +66,18 @@ print("Refill Machine Updater")
|
||||
print("")
|
||||
print("Downloading refill machine program...")
|
||||
|
||||
local ok, code = httpGet(URL_REFILL_MACHINE)
|
||||
local ok, code = httpGet(getCacheBustedUrl(URL_REFILL_MACHINE))
|
||||
if not ok or type(code) ~= "string" or #code == 0 then
|
||||
print("Download failed: " .. tostring(code or ""))
|
||||
return
|
||||
end
|
||||
|
||||
print("Downloading updater program...")
|
||||
local okUp, codeUp = httpGet(getCacheBustedUrl(URL_UPDATE_REFILL))
|
||||
if okUp and type(codeUp) == "string" and #codeUp > 0 then
|
||||
atomicWrite("update_refill.lua", codeUp, false)
|
||||
end
|
||||
|
||||
if not atomicWrite("startup.lua", code, false) then
|
||||
print("Write failed: startup.lua")
|
||||
return
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"style.css": {
|
||||
"size": 77276,
|
||||
"mtimeMs": 1781972131010.4126
|
||||
"size": 86571,
|
||||
"mtimeMs": 1783784204130.9187
|
||||
},
|
||||
"blog.css": {
|
||||
"size": 994,
|
||||
@@ -12,40 +12,40 @@
|
||||
"mtimeMs": 1781967608378.205
|
||||
},
|
||||
"public-status.js": {
|
||||
"size": 3814,
|
||||
"mtimeMs": 1781967627973.2036
|
||||
"size": 2782,
|
||||
"mtimeMs": 1783602619602.658
|
||||
},
|
||||
"ticket-order.js": {
|
||||
"size": 23614,
|
||||
"mtimeMs": 1781974289008.8884
|
||||
"size": 38975,
|
||||
"mtimeMs": 1783947975169.5613
|
||||
},
|
||||
"ticket-search.js": {
|
||||
"size": 11647,
|
||||
"mtimeMs": 1781966517813.3787
|
||||
"size": 17269,
|
||||
"mtimeMs": 1783666241741.0564
|
||||
},
|
||||
"ticket-route.js": {
|
||||
"size": 42284,
|
||||
"mtimeMs": 1781967610560.6072
|
||||
"size": 44004,
|
||||
"mtimeMs": 1783666241687.1482
|
||||
},
|
||||
"index.js": {
|
||||
"size": 63063,
|
||||
"mtimeMs": 1781967609550.651
|
||||
"size": 93210,
|
||||
"mtimeMs": 1783947920227.9448
|
||||
},
|
||||
"ic-card-admin.js": {
|
||||
"size": 15188,
|
||||
"mtimeMs": 1781967608706.734
|
||||
"size": 19095,
|
||||
"mtimeMs": 1783666241303.3374
|
||||
},
|
||||
"ic-card-order.js": {
|
||||
"size": 11528,
|
||||
"mtimeMs": 1781967363353.6257
|
||||
"size": 11488,
|
||||
"mtimeMs": 1783234306158.3623
|
||||
},
|
||||
"ic-card-detail.js": {
|
||||
"size": 5322,
|
||||
"mtimeMs": 1781967346854.6172
|
||||
},
|
||||
"ic-card-search.js": {
|
||||
"size": 5462,
|
||||
"mtimeMs": 1781933937174.1592
|
||||
"size": 14392,
|
||||
"mtimeMs": 1783666241398.5178
|
||||
},
|
||||
"token.js": {
|
||||
"size": 4064,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"style.css": 12,
|
||||
"style.css": 14,
|
||||
"blog.css": 2,
|
||||
"custom-dialog.js": 11,
|
||||
"public-status.js": 13,
|
||||
"ticket-order.js": 16,
|
||||
"ticket-search.js": 11,
|
||||
"ticket-route.js": 2,
|
||||
"index.js": 2,
|
||||
"ic-card-admin.js": 2,
|
||||
"ic-card-order.js": 2,
|
||||
"public-status.js": 14,
|
||||
"ticket-order.js": 31,
|
||||
"ticket-search.js": 12,
|
||||
"ticket-route.js": 3,
|
||||
"index.js": 6,
|
||||
"ic-card-admin.js": 3,
|
||||
"ic-card-order.js": 3,
|
||||
"ic-card-detail.js": 2,
|
||||
"ic-card-search.js": 2,
|
||||
"ic-card-search.js": 3,
|
||||
"token.js": 2,
|
||||
"login.js": 2,
|
||||
"blog.js": 2
|
||||
|
||||
+1
-74
@@ -1,74 +1 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FMG</title>
|
||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="style.css?v=13">
|
||||
<link rel="stylesheet" href="blog.css?v=2">
|
||||
</head>
|
||||
|
||||
<body class="public-search">
|
||||
<div class="public-container">
|
||||
<header class="search-header" style="text-align: left;">
|
||||
<div style="margin-bottom: 10px; text-align: left;">
|
||||
<a href="https://ticket.fse-media.group" id="homeLink"
|
||||
style="color: var(--primary); text-decoration: none; font-weight: 500;">
|
||||
<i class="fas fa-arrow-left"></i> 返回首页
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; margin-bottom: 10px;">
|
||||
<img src="logo.png" alt="Logo" style="height: 40px; margin-right: 12px;">
|
||||
<h1 style="font-weight: 800; letter-spacing: -1px; margin: 0; text-align: left; font-size: 26px;">
|
||||
FMG
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="tab-panel show">
|
||||
<div class="portal-grid">
|
||||
<a href="http://forum.fse-media.group" class="portal-card">
|
||||
<div class="portal-icon">
|
||||
<i class="fas fa-comments"></i>
|
||||
</div>
|
||||
<h3>论坛</h3>
|
||||
<p>forum.fse-media.group</p>
|
||||
</a>
|
||||
<a href="http://b.igtm.ooooo.ink:11324" class="portal-card">
|
||||
<div class="portal-icon">
|
||||
<i class="fas fa-poll-h"></i>
|
||||
</div>
|
||||
<h3>问卷</h3>
|
||||
<p>b.igtm.ooooo.ink</p>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card" style="margin-top: 20px;">
|
||||
<div class="card-title"
|
||||
style="font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; color: var(--text);">
|
||||
<i class="fas fa-server text-primary"></i> 服务器状态
|
||||
</div>
|
||||
<div style="overflow-x: auto; width: 100%;">
|
||||
<iframe id="mc-status-traintown-online-" frameborder="0" width="1000" height="500"
|
||||
style="max-width:100%; border-radius: 8px;" scrolling="no"
|
||||
src="https://motd.minebbs.com/iframe?ip=traintown.online&stype=auto&dark=true"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer
|
||||
style="margin-top: 60px; padding: 20px 0; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; text-align: center;">
|
||||
<p>© 2026 FSE Media Group. All rights reserved.</p>
|
||||
</footer>
|
||||
<footer class="site-footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
|
||||
<span class="version">v1.0.12</span>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/custom-dialog.js?v=12"></script>
|
||||
<script src="blog.js?v=2"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+26
-11
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>首页|FSE铁路运输票务系统</title>
|
||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/style.css?v=13">
|
||||
<link rel="stylesheet" href="/style.css?v=22">
|
||||
</head>
|
||||
<body class="public-search jr-public-page">
|
||||
<div class="jr-public-shell">
|
||||
@@ -16,11 +16,7 @@
|
||||
<i class="fas fa-train"></i>
|
||||
<span>FSE铁路运输票务系统</span>
|
||||
</a>
|
||||
<div class="jr-top-status is-checking" data-server-status-root>
|
||||
<span class="jr-top-status-label">服务器状态</span>
|
||||
<span class="jr-top-status-dot"></span>
|
||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -181,9 +177,29 @@
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/custom-dialog.js?v=12"></script>
|
||||
<script src="/public-status.js?v=13"></script>
|
||||
<script src="/custom-dialog.js?v=11"></script>
|
||||
<script src="/public-status.js?v=14"></script>
|
||||
<script>
|
||||
const createRouteMapCredit = (credit, license) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'jr-asset-credit';
|
||||
|
||||
const creditText = document.createElement('span');
|
||||
creditText.textContent = `线路图署名:${credit || '未填写'}`;
|
||||
wrap.appendChild(creditText);
|
||||
|
||||
wrap.appendChild(document.createTextNode(' 许可协议:'));
|
||||
|
||||
const licenseLink = document.createElement('a');
|
||||
licenseLink.href = 'https://creativecommons.org/licenses/by/4.0/';
|
||||
licenseLink.target = '_blank';
|
||||
licenseLink.rel = 'noopener noreferrer';
|
||||
licenseLink.textContent = license || 'CC BY 4.0';
|
||||
wrap.appendChild(licenseLink);
|
||||
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const loadPublicAssets = async () => {
|
||||
const fareMapBox = document.getElementById('fareMapBox');
|
||||
const routeMapBox = document.getElementById('routeMapBox');
|
||||
@@ -209,6 +225,7 @@
|
||||
img.src = `/assets/${encodeURIComponent(m.routeMap)}?v=${encodeURIComponent(mv)}`;
|
||||
routeMapBox.innerHTML = '<div class="jr-asset-frame"></div>';
|
||||
routeMapBox.firstElementChild.appendChild(img);
|
||||
routeMapBox.appendChild(createRouteMapCredit(m.routeMapCredit, m.routeMapLicense));
|
||||
} else {
|
||||
routeMapBox.innerHTML = '<div class="text-muted">暂无线路图</div>';
|
||||
}
|
||||
@@ -245,5 +262,3 @@
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>IC卡管理|FSE铁路运输票务系统</title>
|
||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/style.css?v=13">
|
||||
<link rel="stylesheet" href="/style.css?v=22">
|
||||
</head>
|
||||
|
||||
<body class="jr-admin-page jr-admin-ic-page jr-public-page">
|
||||
@@ -18,11 +18,7 @@
|
||||
<i class="fas fa-train"></i>
|
||||
<span>FSE 铁路运输后台系统</span>
|
||||
</a>
|
||||
<div class="jr-top-status is-checking" data-server-status-root>
|
||||
<span class="jr-top-status-label">服务器状态</span>
|
||||
<span class="jr-top-status-dot"></span>
|
||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
<div class="jr-brandbar">
|
||||
@@ -172,9 +168,9 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/custom-dialog.js?v=12"></script>
|
||||
<script src="/public-status.js?v=13"></script>
|
||||
<script src="/ic-card-admin.js?v=2"></script>
|
||||
<script src="/custom-dialog.js?v=11"></script>
|
||||
<script src="/public-status.js?v=14"></script>
|
||||
<script src="/ic-card-admin.js?v=3"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const isDomain = location.hostname.includes('fse-media.group');
|
||||
|
||||
+18
-1
@@ -191,7 +191,24 @@
|
||||
const formatIcCardEventUnified = (ev) => {
|
||||
const type = String(ev?.type || '').toLowerCase();
|
||||
const action = String(ev?.action || '').toLowerCase();
|
||||
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
|
||||
let deviceId = String(ev?.device || ev?.device_id || ev?.detail?.device || ev?.detail?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---').trim();
|
||||
const dStr = deviceId.toLowerCase();
|
||||
|
||||
if (dStr === 'gate') {
|
||||
deviceId = '闸机';
|
||||
} else if (dStr === 'refill_machine') {
|
||||
deviceId = '充值机';
|
||||
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
|
||||
deviceId = '售票机';
|
||||
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
|
||||
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
|
||||
deviceId = '闸机';
|
||||
} else if (type === 'topup' || type === 'refill' || action === 'topup' || type === '充值') {
|
||||
deviceId = '充值机';
|
||||
} else {
|
||||
deviceId = '售票机';
|
||||
}
|
||||
}
|
||||
const timeStr = formatTime(ev?.ts);
|
||||
const balance = ev?.balance ?? ev?.["余额"] ?? 0;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!doctype html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>IC卡详情|FSE铁路运输票务系统</title>
|
||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/style.css?v=13">
|
||||
<link rel="stylesheet" href="/style.css?v=22">
|
||||
</head>
|
||||
|
||||
<body class="public-search jr-public-page">
|
||||
@@ -18,11 +18,7 @@
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>返回首页</span>
|
||||
</a>
|
||||
<div class="jr-top-status is-checking" data-server-status-root>
|
||||
<span class="jr-top-status-label">服务器状态</span>
|
||||
<span class="jr-top-status-dot"></span>
|
||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
<div class="jr-brandbar">
|
||||
@@ -99,9 +95,9 @@
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/custom-dialog.js?v=12"></script>
|
||||
<script src="/custom-dialog.js?v=11"></script>
|
||||
<script src="/ic-card-detail.js?v=2"></script>
|
||||
<script src="/public-status.js?v=13"></script>
|
||||
<script src="/public-status.js?v=14"></script>
|
||||
<script>document.addEventListener('DOMContentLoaded', () => {
|
||||
const isDomain = location.hostname.includes('fse-media.group'); const links = {
|
||||
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html', order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html', search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html', 'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html', 'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>线上购卡|FSE铁路运输票务系统</title>
|
||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/style.css?v=13">
|
||||
<link rel="stylesheet" href="/style.css?v=22">
|
||||
</head>
|
||||
|
||||
<body class="public-search jr-public-page">
|
||||
@@ -18,11 +18,7 @@
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>返回首页</span>
|
||||
</a>
|
||||
<div class="jr-top-status is-checking" data-server-status-root>
|
||||
<span class="jr-top-status-label">服务器状态</span>
|
||||
<span class="jr-top-status-dot"></span>
|
||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
<div class="jr-brandbar">
|
||||
@@ -105,9 +101,9 @@
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/custom-dialog.js?v=12"></script>
|
||||
<script src="/ic-card-order.js?v=2"></script>
|
||||
<script src="/public-status.js?v=13"></script>
|
||||
<script src="/custom-dialog.js?v=11"></script>
|
||||
<script src="/ic-card-order.js?v=3"></script>
|
||||
<script src="/public-status.js?v=14"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const isDomain = location.hostname.includes('fse-media.group');
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>IC卡查询|FSE铁路运输票务系统</title>
|
||||
<link rel="icon" type="image/png" href="/FSE-ticket.png">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/style.css?v=14">
|
||||
<link rel="stylesheet" href="/style.css?v=22">
|
||||
</head>
|
||||
|
||||
<body class="public-search jr-public-page">
|
||||
@@ -18,11 +18,7 @@
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>返回首页</span>
|
||||
</a>
|
||||
<div class="jr-top-status is-checking" data-server-status-root>
|
||||
<span class="jr-top-status-label">服务器状态</span>
|
||||
<span class="jr-top-status-dot"></span>
|
||||
<span class="jr-top-status-value" data-server-status-value>检测中</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
<div class="jr-brandbar">
|
||||
@@ -109,9 +105,9 @@
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/custom-dialog.js?v=12"></script>
|
||||
<script src="/custom-dialog.js?v=11"></script>
|
||||
<script src="/ic-card-search.js?v=3"></script>
|
||||
<script src="/public-status.js?v=13"></script>
|
||||
<script src="/public-status.js?v=14"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const isDomain = location.hostname.includes('fse-media.group');
|
||||
|
||||
+18
-1
@@ -90,7 +90,24 @@
|
||||
const formatIcCardEventUnified = (ev) => {
|
||||
const type = String(ev?.type || '').toLowerCase();
|
||||
const action = String(ev?.action || '').toLowerCase();
|
||||
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
|
||||
let deviceId = String(ev?.device || ev?.device_id || ev?.detail?.device || ev?.detail?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---').trim();
|
||||
const dStr = deviceId.toLowerCase();
|
||||
|
||||
if (dStr === 'gate') {
|
||||
deviceId = '闸机';
|
||||
} else if (dStr === 'refill_machine') {
|
||||
deviceId = '充值机';
|
||||
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
|
||||
deviceId = '售票机';
|
||||
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
|
||||
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
|
||||
deviceId = '闸机';
|
||||
} else if (type === 'topup' || type === 'refill' || action === 'topup' || type === '充值') {
|
||||
deviceId = '充值机';
|
||||
} else {
|
||||
deviceId = '售票机';
|
||||
}
|
||||
}
|
||||
const timeStr = formatTime(ev?.ts);
|
||||
const balance = ev?.balance ?? ev?.["余额"] ?? 0;
|
||||
|
||||
|
||||
+1032
-805
File diff suppressed because it is too large
Load Diff
+560
-21
@@ -27,6 +27,7 @@ createApp({
|
||||
tickets: '车票记录',
|
||||
vouchers: '凭证管理',
|
||||
iccards: 'IC 卡管理',
|
||||
issues: '问题汇总',
|
||||
assets: '线路图',
|
||||
settings: '系统设置',
|
||||
logs: '日志'
|
||||
@@ -34,15 +35,15 @@ createApp({
|
||||
return map[currentView.value] || '票价图';
|
||||
});
|
||||
|
||||
// Prefer polling first so admin remains connected even when the proxy
|
||||
// does not support WebSocket upgrades reliably.
|
||||
const socket = io({ transports: ['polling', 'websocket'] });
|
||||
// 强制使用 WebSocket,避免轮询 (polling) 在负载均衡/反代下产生 400 Bad Request
|
||||
const socket = io({ transports: ['websocket'], timeout: 20000 });
|
||||
|
||||
// Data State
|
||||
const stations = ref([]);
|
||||
const lines = ref([]);
|
||||
const fares = ref([]);
|
||||
const tickets = ref([]);
|
||||
const issues = ref([]);
|
||||
const stats = reactive({ sold_tickets: 0, revenue: 0 });
|
||||
const config = reactive({
|
||||
api_base: '',
|
||||
@@ -56,10 +57,11 @@ createApp({
|
||||
const logMax = ref(200);
|
||||
const logLoading = ref(false);
|
||||
const orders = ref([]);
|
||||
const assetsManifest = reactive({ routeMap: null, fareTable: null, updatedAt: null });
|
||||
const assetsManifest = reactive({ routeMap: null, routeMapCredit: '', routeMapLicense: 'CC BY 4.0', fareTable: null, updatedAt: null });
|
||||
const assetsFarePreview = reactive({ headers: [], rows: [] });
|
||||
const assetsRouteMapUrl = ref('');
|
||||
const assetsFareTableUrl = ref('');
|
||||
const assetsForm = reactive({ routeMapCredit: '' });
|
||||
const icCards = ref([]);
|
||||
const icCardSearch = ref('');
|
||||
const icSelectedId = ref('');
|
||||
@@ -76,6 +78,7 @@ createApp({
|
||||
let ticketDataLoaded = false;
|
||||
let orderDataLoaded = false;
|
||||
let logDataLoaded = false;
|
||||
let issueDataLoaded = false;
|
||||
let assetsLoaded = false;
|
||||
let fareMapLoaded = false;
|
||||
const loadingState = reactive({
|
||||
@@ -83,7 +86,8 @@ createApp({
|
||||
tickets: false,
|
||||
orders: false,
|
||||
logs: false,
|
||||
iccards: false
|
||||
iccards: false,
|
||||
issues: false
|
||||
});
|
||||
const lastSyncAt = ref(0);
|
||||
|
||||
@@ -121,6 +125,13 @@ createApp({
|
||||
const lineFormOriginalId = ref('');
|
||||
const lineForm = reactive({ id: '', name: '', en_name: '', color: '#3366cc', stations: [] });
|
||||
|
||||
// Topology Editor State
|
||||
const topologyPan = reactive({ x: 0, y: 0, scale: 1, active: false, startX: 0, startY: 0, initialX: 0, initialY: 0 });
|
||||
const topologyNodes = ref([]);
|
||||
const topologyLines = ref([]);
|
||||
const topologyDraggingNode = ref(null);
|
||||
let topologyNodeDragStart = { x: 0, y: 0, nodeX: 0, nodeY: 0 };
|
||||
|
||||
// Legacy/Other State
|
||||
const fareMapSvg = ref('');
|
||||
const fareMapScale = ref(1);
|
||||
@@ -152,6 +163,132 @@ createApp({
|
||||
try { return JSON.parse(t); } catch (e) { return null; }
|
||||
};
|
||||
|
||||
const safeJsonStringify = (value) => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch (e) {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const formatIssueKey = (key) => {
|
||||
const map = {
|
||||
device: '设备',
|
||||
device_id: '设备编号',
|
||||
machine_id: '机器 ID',
|
||||
station_code: '车站编号',
|
||||
station_name: '车站名称',
|
||||
station: '车站',
|
||||
error_msg: '错误信息',
|
||||
ticket_id: '车票 ID',
|
||||
card_id: '卡号',
|
||||
holder_name: '持卡人',
|
||||
balance: '余额',
|
||||
amount: '金额',
|
||||
price: '价格',
|
||||
order_code: '订单号',
|
||||
voucher_code: '凭证码',
|
||||
method: '方法',
|
||||
source: '来源',
|
||||
reason: '原因',
|
||||
status: '状态',
|
||||
action: '动作',
|
||||
line: '线路',
|
||||
route: '区间',
|
||||
from: '起点',
|
||||
to: '终点',
|
||||
time: '时间',
|
||||
ts: '时间戳'
|
||||
};
|
||||
const raw = String(key || '').trim();
|
||||
if (!raw) return '字段';
|
||||
if (map[raw]) return map[raw];
|
||||
return raw
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^\w/, (ch) => ch.toUpperCase());
|
||||
};
|
||||
|
||||
const formatIssueValue = (value) => {
|
||||
if (value == null || value === '') return '---';
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '---';
|
||||
if (typeof value === 'string') return value.trim() || '---';
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) return '---';
|
||||
if (value.every((item) => item == null || ['string', 'number', 'boolean'].includes(typeof item))) {
|
||||
return value.map(formatIssueValue).join(' / ');
|
||||
}
|
||||
return safeJsonStringify(value);
|
||||
}
|
||||
if (typeof value === 'object') return safeJsonStringify(value);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const buildIssueDetailView = (detail) => {
|
||||
const rawText = String(detail || '').trim();
|
||||
if (!rawText) return { entries: [], rawText: '', rawExpanded: false };
|
||||
|
||||
const hiddenKeys = new Set(['device', 'device_id', 'machine_id', 'station_code', 'station_name', 'error_msg', 'detail', 'type']);
|
||||
const parsed = parseJsonSafe(rawText);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const entries = Object.entries(parsed)
|
||||
.filter(([key, value]) => !hiddenKeys.has(String(key || '').trim()) && value != null && value !== '')
|
||||
.map(([key, value]) => ({
|
||||
key: String(key),
|
||||
label: formatIssueKey(key),
|
||||
value: formatIssueValue(value)
|
||||
}));
|
||||
return {
|
||||
entries,
|
||||
rawText: entries.length ? rawText : '',
|
||||
rawExpanded: false
|
||||
};
|
||||
}
|
||||
|
||||
const segments = rawText
|
||||
.split(/\r?\n|;|;/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const entries = [];
|
||||
for (const segment of segments) {
|
||||
const matched = /^([^:=:]{1,30})\s*[:=:]\s*(.+)$/.exec(segment);
|
||||
if (!matched) continue;
|
||||
const key = String(matched[1] || '').trim();
|
||||
if (!key || hiddenKeys.has(key)) continue;
|
||||
entries.push({
|
||||
key,
|
||||
label: formatIssueKey(key),
|
||||
value: formatIssueValue(matched[2])
|
||||
});
|
||||
}
|
||||
|
||||
const hasPlainBody = segments.length <= 1 && entries.length <= 1;
|
||||
return {
|
||||
entries: hasPlainBody ? [] : entries,
|
||||
rawText,
|
||||
rawExpanded: hasPlainBody
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeIssueRecord = (issue) => {
|
||||
const item = issue && typeof issue === 'object' ? issue : {};
|
||||
const device = String(item.device || item.machine_id || '').trim();
|
||||
const stationCode = String(item.station_code || '').trim();
|
||||
const stationName = stationCode ? getStationName(stationCode) : '';
|
||||
const detailView = buildIssueDetailView(item.detail);
|
||||
return {
|
||||
...item,
|
||||
displayDevice: device || '---',
|
||||
displayStation: stationName && stationName !== stationCode ? `${stationName} (${stationCode})` : (stationCode || '---'),
|
||||
displayError: String(item.error_msg || '').trim() || '未提供错误信息',
|
||||
detailEntries: detailView.entries,
|
||||
detailRaw: detailView.rawText,
|
||||
detailRawExpanded: detailView.rawExpanded
|
||||
};
|
||||
};
|
||||
|
||||
const parseCsvSimple = (text) => {
|
||||
const out = { headers: [], rows: [] };
|
||||
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
@@ -348,7 +485,65 @@ createApp({
|
||||
const formatTicketEventUnified = (ev) => {
|
||||
const type = getTicketEventType(ev);
|
||||
const action = getTicketEventAction(ev);
|
||||
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
|
||||
let deviceId = String(ev?.device || ev?.device_id || ev?.detail?.device || ev?.detail?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---').trim();
|
||||
const dStr = deviceId.toLowerCase();
|
||||
|
||||
// #region debug-point B:index-ticket-device-before-map
|
||||
fetch('http://127.0.0.1:7777/event', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: 'device-id-missing',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'B',
|
||||
location: 'web/index.js:351',
|
||||
msg: '[DEBUG] admin ticket formatter raw device',
|
||||
data: {
|
||||
type,
|
||||
action,
|
||||
raw_device: ev?.device ?? null,
|
||||
raw_device_id: ev?.device_id ?? null,
|
||||
raw_detail_device: ev?.detail?.device ?? null,
|
||||
resolved_before_map: deviceId
|
||||
},
|
||||
ts: Date.now()
|
||||
})
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
|
||||
if (dStr === 'gate') {
|
||||
deviceId = '闸机';
|
||||
} else if (dStr === 'refill_machine') {
|
||||
deviceId = '充值机';
|
||||
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
|
||||
deviceId = '售票机';
|
||||
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
|
||||
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
|
||||
deviceId = '闸机';
|
||||
} else {
|
||||
deviceId = '售票机';
|
||||
}
|
||||
}
|
||||
|
||||
// #region debug-point B:index-ticket-device-after-map
|
||||
fetch('http://127.0.0.1:7777/event', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: 'device-id-missing',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'B',
|
||||
location: 'web/index.js:373',
|
||||
msg: '[DEBUG] admin ticket formatter mapped device',
|
||||
data: {
|
||||
type,
|
||||
action,
|
||||
mapped_device: deviceId
|
||||
},
|
||||
ts: Date.now()
|
||||
})
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
const timeStr = formatTime(ev?.ts || ev?.["时间戳"]);
|
||||
|
||||
if (type === 'sale' || type === '售票') {
|
||||
@@ -451,7 +646,24 @@ createApp({
|
||||
const formatIcEventDetail = (ev) => {
|
||||
const type = String(ev?.type || '').toLowerCase();
|
||||
const action = String(ev?.action || '').toLowerCase();
|
||||
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
|
||||
let deviceId = String(ev?.device || ev?.device_id || ev?.detail?.device || ev?.detail?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---').trim();
|
||||
const dStr = deviceId.toLowerCase();
|
||||
|
||||
if (dStr === 'gate') {
|
||||
deviceId = '闸机';
|
||||
} else if (dStr === 'refill_machine') {
|
||||
deviceId = '充值机';
|
||||
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
|
||||
deviceId = '售票机';
|
||||
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
|
||||
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
|
||||
deviceId = '闸机';
|
||||
} else if (type === 'topup' || type === 'refill' || action === 'topup' || type === '充值') {
|
||||
deviceId = '充值机';
|
||||
} else {
|
||||
deviceId = '售票机';
|
||||
}
|
||||
}
|
||||
const timeStr = formatTime(ev?.ts);
|
||||
const balance = ev?.balance ?? ev?.["余额"] ?? 0;
|
||||
|
||||
@@ -495,8 +707,11 @@ createApp({
|
||||
const fetchAssetsManifest = async () => {
|
||||
const data = await requestJson('/api/assets/manifest');
|
||||
assetsManifest.routeMap = data ? (data.routeMap || null) : null;
|
||||
assetsManifest.routeMapCredit = data ? String(data.routeMapCredit || '') : '';
|
||||
assetsManifest.routeMapLicense = data ? String(data.routeMapLicense || 'CC BY 4.0') : 'CC BY 4.0';
|
||||
assetsManifest.fareTable = data ? (data.fareTable || null) : null;
|
||||
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
|
||||
assetsForm.routeMapCredit = assetsManifest.routeMapCredit;
|
||||
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
|
||||
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
|
||||
assetsLoaded = true;
|
||||
@@ -540,9 +755,12 @@ createApp({
|
||||
markSynced();
|
||||
};
|
||||
|
||||
const uploadAssetFile = async (url, file) => {
|
||||
const uploadAssetFile = async (url, file, extraFields = {}) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
for (const [key, value] of Object.entries(extraFields || {})) {
|
||||
fd.append(key, value == null ? '' : String(value));
|
||||
}
|
||||
await requestJson(url, { method: 'POST', body: fd }, { expectOk: true });
|
||||
await fetchAssetsManifest();
|
||||
};
|
||||
@@ -551,7 +769,7 @@ createApp({
|
||||
const f = ev && ev.target && ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
ev.target.value = '';
|
||||
await uploadAssetFile('/api/assets/route-map', f);
|
||||
await uploadAssetFile('/api/assets/route-map', f, { credit: assetsForm.routeMapCredit });
|
||||
};
|
||||
|
||||
const uploadFareTable = async (ev) => {
|
||||
@@ -566,6 +784,15 @@ createApp({
|
||||
await fetchAssetsManifest();
|
||||
};
|
||||
|
||||
const saveRouteMapCredit = async () => {
|
||||
await requestJson('/api/assets/route-map/credit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credit: assetsForm.routeMapCredit })
|
||||
}, { expectOk: true });
|
||||
await fetchAssetsManifest();
|
||||
};
|
||||
|
||||
const deleteFareTable = async () => {
|
||||
await requestJson('/api/assets/fare-table', { method: 'DELETE' }, { expectOk: true });
|
||||
await fetchAssetsManifest();
|
||||
@@ -763,6 +990,26 @@ createApp({
|
||||
}
|
||||
};
|
||||
|
||||
// --- Issues Management ---
|
||||
const fetchIssues = async () => {
|
||||
if (loadingState.issues) return;
|
||||
loadingState.issues = true;
|
||||
try {
|
||||
const res = await requestJson('/api/issues');
|
||||
if (res && res.ok) {
|
||||
issues.value = (res.issues || [])
|
||||
.map(normalizeIssueRecord)
|
||||
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
|
||||
issueDataLoaded = true;
|
||||
markSynced();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loadingState.issues = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchIcCards = async (keepSelection = true) => {
|
||||
if (loadingState.iccards) return;
|
||||
loadingState.iccards = true;
|
||||
@@ -998,6 +1245,9 @@ createApp({
|
||||
const found = lines.value.find((line) => line.id === selectedLine.value.id);
|
||||
selectedLine.value = found || null;
|
||||
}
|
||||
if (currentView.value === 'assets') {
|
||||
initTopologyLayout();
|
||||
}
|
||||
coreLoaded = true;
|
||||
markSynced();
|
||||
} catch (e) {
|
||||
@@ -1046,6 +1296,7 @@ createApp({
|
||||
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
|
||||
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
|
||||
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
|
||||
if (view === 'issues' && (force || !issueDataLoaded)) await fetchIssues();
|
||||
if (view === 'iccards') {
|
||||
await fetchIcCards(true);
|
||||
if (icSelectedId.value) {
|
||||
@@ -1154,6 +1405,22 @@ createApp({
|
||||
await updateLineStations(selectedLine.value.id, newStations);
|
||||
};
|
||||
|
||||
const removeStationFromLineIndex = async (index) => {
|
||||
if (!selectedLine.value) return;
|
||||
const newStations = [...(selectedLine.value.stations || [])];
|
||||
newStations.splice(index, 1);
|
||||
await updateLineStations(selectedLine.value.id, newStations);
|
||||
};
|
||||
|
||||
const addBreakpointToLine = async () => {
|
||||
if (!selectedLine.value) return;
|
||||
await runMutation(async () => {
|
||||
const newStations = [...(selectedLine.value.stations || [])];
|
||||
newStations.push('-');
|
||||
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
|
||||
});
|
||||
};
|
||||
|
||||
const updateLineStations = async (lineId, stationsList, { skipFetchData } = {}) => {
|
||||
// Need to update the whole line object usually, or a specific endpoint
|
||||
// Assuming PUT /api/lines/:id updates fields provided
|
||||
@@ -1214,9 +1481,24 @@ createApp({
|
||||
|
||||
const transferTargets = computed(() => {
|
||||
const fromCode = stationForm.code;
|
||||
return stations.value
|
||||
.filter(s => s && s.code && s.code !== fromCode)
|
||||
.map(s => ({ code: s.code, name: s.name || s.cn_name || s.code, en_name: s.en_name || s.enName || '' }));
|
||||
const fromName = String(stationForm.name || '').trim().toLowerCase();
|
||||
|
||||
const sameName = [];
|
||||
const others = [];
|
||||
|
||||
stations.value.forEach(s => {
|
||||
if (!s || !s.code || s.code === fromCode) return;
|
||||
const targetName = String(s.name || s.cn_name || s.code).trim().toLowerCase();
|
||||
const opt = { code: s.code, name: s.name || s.cn_name || s.code, en_name: s.en_name || s.enName || '' };
|
||||
|
||||
if (fromName && targetName === fromName) {
|
||||
sameName.push(opt);
|
||||
} else {
|
||||
others.push(opt);
|
||||
}
|
||||
});
|
||||
|
||||
return { sameName, others };
|
||||
});
|
||||
|
||||
const openLineModal = () => {
|
||||
@@ -1261,11 +1543,21 @@ createApp({
|
||||
});
|
||||
};
|
||||
|
||||
const handleStationClick = async (code) => {
|
||||
const handleStationClick = async (code, index) => {
|
||||
if (lineViewportPan.moved) {
|
||||
lineViewportPan.moved = false;
|
||||
return;
|
||||
}
|
||||
if (code === '-') {
|
||||
if (await appDialog.confirm({
|
||||
title: '移除断点',
|
||||
message: `确定移除该断点?`,
|
||||
confirmText: '确认移除'
|
||||
})) {
|
||||
await removeStationFromLineIndex(index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stationEditMode.value) {
|
||||
openStationModal(code);
|
||||
return;
|
||||
@@ -1401,10 +1693,23 @@ createApp({
|
||||
}, { successMessage: '保存成功' });
|
||||
};
|
||||
|
||||
const bumpLuaVersion = async (device) => {
|
||||
if (!config.lua_versions[device]) config.lua_versions[device] = 'v1.0.0';
|
||||
config.lua_versions[device] = bumpPatchVersion(config.lua_versions[device]);
|
||||
await saveConfig();
|
||||
const isPushingUpdate = ref(false);
|
||||
|
||||
const pushLuaUpdate = async () => {
|
||||
if (!confirm('确定要向所有机器推送更新指令吗?这将会使空闲机器静默更新并重启。')) return;
|
||||
|
||||
isPushingUpdate.value = true;
|
||||
|
||||
try {
|
||||
// 直接请求专门的轻量级触发更新接口,避免和庞大的配置文件保存混在一起导致超时
|
||||
await requestJson('/api/trigger-update', { method: 'POST' }, { expectOk: true });
|
||||
alert('推送指令发送完毕,终端将陆续更新');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('推送更新失败: ' + err.message);
|
||||
} finally {
|
||||
isPushingUpdate.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = () => {
|
||||
@@ -1461,6 +1766,12 @@ createApp({
|
||||
stats.sold_tickets += item.sold_tickets;
|
||||
stats.revenue += item.revenue;
|
||||
});
|
||||
socket.on('issue:new', (item) => {
|
||||
issues.value = [normalizeIssueRecord(item), ...issues.value]
|
||||
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
|
||||
issueDataLoaded = true;
|
||||
markSynced();
|
||||
});
|
||||
socket.on('ic-card:created', () => { fetchIcCards(false).catch(console.error); });
|
||||
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
|
||||
socket.on('ic-card:check', () => {
|
||||
@@ -1476,6 +1787,227 @@ createApp({
|
||||
syncSelectedIcCard().catch(console.error);
|
||||
});
|
||||
|
||||
// Topology Editor Logic
|
||||
const initTopologyLayout = () => {
|
||||
const nodesMap = new Map();
|
||||
const linesArr = lines.value || [];
|
||||
const stationsArr = stations.value || [];
|
||||
|
||||
// Helper to get transfer canonicals
|
||||
const canonicals = {};
|
||||
const isTransfer = {};
|
||||
stationsArr.forEach(s => {
|
||||
const code = String(s.code || '').trim();
|
||||
canonicals[code] = code;
|
||||
isTransfer[code] = false;
|
||||
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
|
||||
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
|
||||
const canonical = group[0];
|
||||
group.forEach(g => canonicals[g] = canonical);
|
||||
}
|
||||
});
|
||||
|
||||
// Count lines per canonical to identify transfer hubs
|
||||
const lineCountByCanonical = {};
|
||||
linesArr.forEach(line => {
|
||||
const stops = Array.isArray(line.stations) ? line.stations : [];
|
||||
stops.forEach(stop => {
|
||||
if (stop === '-') return;
|
||||
const canonical = canonicals[stop] || stop;
|
||||
if (!lineCountByCanonical[canonical]) lineCountByCanonical[canonical] = new Set();
|
||||
lineCountByCanonical[canonical].add(line.id);
|
||||
});
|
||||
});
|
||||
Object.keys(lineCountByCanonical).forEach(c => {
|
||||
if (lineCountByCanonical[c].size > 1) {
|
||||
isTransfer[c] = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize Nodes
|
||||
stationsArr.forEach((s, idx) => {
|
||||
const code = String(s.code || '').trim();
|
||||
const canonical = canonicals[code] || code;
|
||||
if (nodesMap.has(canonical)) return;
|
||||
|
||||
nodesMap.set(canonical, {
|
||||
canonical,
|
||||
code: code,
|
||||
name: s.name || code,
|
||||
color: null,
|
||||
isTransfer: isTransfer[canonical],
|
||||
x: Number(s.svg_x) || (100 + (idx % 10) * 80),
|
||||
y: Number(s.svg_y) || (100 + Math.floor(idx / 10) * 80),
|
||||
originalX: Number(s.svg_x) || (100 + (idx % 10) * 80),
|
||||
originalY: Number(s.svg_y) || (100 + Math.floor(idx / 10) * 80)
|
||||
});
|
||||
});
|
||||
|
||||
// Assign colors to nodes based on lines
|
||||
linesArr.forEach(line => {
|
||||
const stops = Array.isArray(line.stations) ? line.stations : [];
|
||||
stops.forEach(stop => {
|
||||
if (stop === '-') return;
|
||||
const canonical = canonicals[stop] || stop;
|
||||
const node = nodesMap.get(canonical);
|
||||
if (node && !node.color) {
|
||||
node.color = line.color || '#93a2b7';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
topologyNodes.value = Array.from(nodesMap.values());
|
||||
updateTopologyLines();
|
||||
};
|
||||
|
||||
const updateTopologyLines = () => {
|
||||
const canonicals = {};
|
||||
stations.value.forEach(s => {
|
||||
const code = String(s.code || '').trim();
|
||||
canonicals[code] = code;
|
||||
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
|
||||
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
|
||||
const canonical = group[0];
|
||||
group.forEach(g => canonicals[g] = canonical);
|
||||
}
|
||||
});
|
||||
|
||||
const newLines = [];
|
||||
(lines.value || []).forEach(line => {
|
||||
const stops = Array.isArray(line.stations) ? line.stations : [];
|
||||
let d = '';
|
||||
let isFirst = true;
|
||||
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const stop = stops[i];
|
||||
if (stop === '-') {
|
||||
isFirst = true;
|
||||
continue;
|
||||
}
|
||||
const canonical = canonicals[stop] || stop;
|
||||
const node = topologyNodes.value.find(n => n.canonical === canonical);
|
||||
if (!node) continue;
|
||||
|
||||
if (isFirst) {
|
||||
d += `M ${node.x} ${node.y} `;
|
||||
isFirst = false;
|
||||
} else {
|
||||
d += `L ${node.x} ${node.y} `;
|
||||
}
|
||||
}
|
||||
if (d) {
|
||||
newLines.push({ id: line.id, color: line.color || '#93a2b7', d });
|
||||
}
|
||||
});
|
||||
topologyLines.value = newLines;
|
||||
};
|
||||
|
||||
const onTopologyCanvasMouseDown = (e) => {
|
||||
topologyPan.active = true;
|
||||
topologyPan.startX = e.clientX;
|
||||
topologyPan.startY = e.clientY;
|
||||
topologyPan.initialX = topologyPan.x;
|
||||
topologyPan.initialY = topologyPan.y;
|
||||
};
|
||||
|
||||
const onTopologyCanvasMouseMove = (e) => {
|
||||
if (topologyPan.active) {
|
||||
topologyPan.x = topologyPan.initialX + (e.clientX - topologyPan.startX);
|
||||
topologyPan.y = topologyPan.initialY + (e.clientY - topologyPan.startY);
|
||||
} else if (topologyDraggingNode.value) {
|
||||
const node = topologyNodes.value.find(n => n.canonical === topologyDraggingNode.value);
|
||||
if (node) {
|
||||
const dx = (e.clientX - topologyNodeDragStart.x) / topologyPan.scale;
|
||||
const dy = (e.clientY - topologyNodeDragStart.y) / topologyPan.scale;
|
||||
// Snap to grid of 20
|
||||
node.x = Math.round((topologyNodeDragStart.nodeX + dx) / 20) * 20;
|
||||
node.y = Math.round((topologyNodeDragStart.nodeY + dy) / 20) * 20;
|
||||
updateTopologyLines();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTopologyCanvasMouseUp = () => {
|
||||
topologyPan.active = false;
|
||||
topologyDraggingNode.value = null;
|
||||
};
|
||||
|
||||
const onTopologyCanvasWheel = (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
topologyPan.scale = Math.max(0.1, Math.min(3, topologyPan.scale * delta));
|
||||
};
|
||||
|
||||
const onTopologyNodeMouseDown = (e, node) => {
|
||||
topologyDraggingNode.value = node.canonical;
|
||||
topologyNodeDragStart.x = e.clientX;
|
||||
topologyNodeDragStart.y = e.clientY;
|
||||
topologyNodeDragStart.nodeX = node.x;
|
||||
topologyNodeDragStart.nodeY = node.y;
|
||||
};
|
||||
|
||||
const resetTopologyLayout = () => {
|
||||
if (!confirm('确定要放弃当前修改并重置吗?')) return;
|
||||
topologyNodes.value.forEach(node => {
|
||||
node.x = node.originalX;
|
||||
node.y = node.originalY;
|
||||
});
|
||||
topologyPan.x = 0;
|
||||
topologyPan.y = 0;
|
||||
topologyPan.scale = 1;
|
||||
updateTopologyLines();
|
||||
};
|
||||
|
||||
const saveTopologyLayout = async () => {
|
||||
if (!confirm('确定要保存当前站点坐标吗?这会覆盖所有站点的 svg_x 和 svg_y。')) return;
|
||||
|
||||
mutationBusy.value = true;
|
||||
try {
|
||||
// We need to update svg_x and svg_y for all stations that belong to each canonical group
|
||||
const canonicals = {};
|
||||
stations.value.forEach(s => {
|
||||
const code = String(s.code || '').trim();
|
||||
canonicals[code] = code;
|
||||
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
|
||||
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
|
||||
const canonical = group[0];
|
||||
group.forEach(g => canonicals[g] = canonical);
|
||||
}
|
||||
});
|
||||
|
||||
const updates = [];
|
||||
for (const s of stations.value) {
|
||||
const code = String(s.code || '').trim();
|
||||
const canonical = canonicals[code] || code;
|
||||
const node = topologyNodes.value.find(n => n.canonical === canonical);
|
||||
if (node) {
|
||||
updates.push({
|
||||
...s,
|
||||
svg_x: node.x,
|
||||
svg_y: node.y
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Batch update one by one
|
||||
for (const update of updates) {
|
||||
await fetch(`/api/admin/stations/${encodeURIComponent(update.code)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(update)
|
||||
});
|
||||
}
|
||||
|
||||
alert('坐标保存成功!');
|
||||
await fetchData();
|
||||
initTopologyLayout(); // Re-initialize to update originalX/Y
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message);
|
||||
} finally {
|
||||
mutationBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(currentView, (v) => {
|
||||
sidebarOpen.value = false;
|
||||
if (v === 'iccards') {
|
||||
@@ -1552,6 +2084,7 @@ createApp({
|
||||
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
|
||||
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
|
||||
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
|
||||
issues: `当前共有 ${issues.value.length} 条问题记录`,
|
||||
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
|
||||
settings: '可维护优惠活动与导出数据',
|
||||
logs: `当前筛选结果 ${logs.value.length} 条日志`
|
||||
@@ -1637,17 +2170,18 @@ createApp({
|
||||
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
|
||||
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
|
||||
orders, orderList, fetchOrders, deleteOrder,
|
||||
issues, fetchIssues,
|
||||
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
|
||||
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
|
||||
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
|
||||
assetsManifest, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
|
||||
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable,
|
||||
assetsManifest, assetsForm, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
|
||||
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable, saveRouteMapCredit,
|
||||
|
||||
// Management
|
||||
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
|
||||
visualLineViewport, lineViewportPan, lineEditorSvgWidth, startLineViewportPan, moveLineViewportPan,
|
||||
selectLine, createLine, deleteLine, deleteStation, updateLineInfo, getFareText,
|
||||
isStationInLine, addStationToLine, removeStationFromLine,
|
||||
isStationInLine, addStationToLine, removeStationFromLine, removeStationFromLineIndex, addBreakpointToLine,
|
||||
handleStationClick, isStationSelected,
|
||||
onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex,
|
||||
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
|
||||
@@ -1658,7 +2192,12 @@ createApp({
|
||||
|
||||
saveCurrentFare, deleteCurrentFare, closeFareModal,
|
||||
|
||||
saveConfig, bumpLuaVersion, exportData, exportFareMap,
|
||||
// Topology Editor
|
||||
topologyPan, topologyNodes, topologyLines, topologyDraggingNode,
|
||||
onTopologyCanvasMouseDown, onTopologyCanvasMouseMove, onTopologyCanvasMouseUp, onTopologyCanvasWheel, onTopologyNodeMouseDown,
|
||||
resetTopologyLayout, saveTopologyLayout,
|
||||
|
||||
saveConfig, isPushingUpdate, pushLuaUpdate, exportData, exportFareMap,
|
||||
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
|
||||
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,
|
||||
isTransferStation, getTransferTitleSuffix, getTransferLineBadges
|
||||
|
||||
@@ -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=13" />
|
||||
</head>
|
||||
<body class="jr-admin-login-page">
|
||||
<div class="jr-admin-login-shell">
|
||||
<header class="jr-topbar">
|
||||
<div class="jr-topbar-inner">
|
||||
<a href="/" class="jr-top-link">
|
||||
<span>FSE铁路票务系统控制台</span>
|
||||
</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=12"></script>
|
||||
<script src="login.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<!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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/style.css?v=22" />
|
||||
</head>
|
||||
|
||||
<body class="public-search jr-public-page">
|
||||
<div class="jr-public-shell">
|
||||
<header class="jr-topbar">
|
||||
<div class="jr-topbar-inner">
|
||||
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>返回首页</span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="jr-brandbar">
|
||||
<div class="jr-brandbar-inner">
|
||||
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
|
||||
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
|
||||
<div class="jr-brand-copy">
|
||||
<strong>FarSight-T.N.E铁路运输</strong>
|
||||
<span>票务查询</span>
|
||||
</div>
|
||||
</a>
|
||||
<nav class="jr-nav" aria-label="站点导航">
|
||||
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
|
||||
<a href="https://ticket.fse-media.group/order" data-link="order">线上预定</a>
|
||||
<a href="https://ticket.fse-media.group/search" data-link="search" class="is-active">车票查询</a>
|
||||
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
|
||||
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="jr-public-main">
|
||||
<section class="jr-page-intro">
|
||||
<span class="jr-kicker">TICKET SEARCH</span>
|
||||
<h1>按票号、站点或日期快速查询票据</h1>
|
||||
<p>输入完整票号、起终点、日期或关键词,左侧浏览结果,右侧查看票据详情与最近流转记录。</p>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<section class="jr-panel-card" style="margin-bottom:24px;">
|
||||
<div class="jr-panel-headline">
|
||||
<h2>检索条件</h2>
|
||||
<span class="jr-panel-note">Ticket ID / Station / Date</span>
|
||||
</div>
|
||||
<div class="jr-search-form">
|
||||
<input id="q" class="jr-search-input" type="text"
|
||||
placeholder="输入完整票号 / 起点 / 终点 / 日期 (YYYY-MM-DD)" />
|
||||
<button id="searchBtn" class="btn primary jr-search-button"><i class="fas fa-search"></i>
|
||||
立即搜索</button>
|
||||
</div>
|
||||
<p class="jr-search-helper">可直接输入完整票号,也可输入起点、终点或日期关键字进行模糊检索。</p>
|
||||
</section>
|
||||
|
||||
<section class="jr-search-results">
|
||||
<article class="jr-panel-card">
|
||||
<div class="jr-panel-headline">
|
||||
<h3>结果列表</h3>
|
||||
<span class="jr-panel-note">Search Results</span>
|
||||
</div>
|
||||
<div id="list" class="jr-scroll-box">
|
||||
<div class="jr-center-empty">
|
||||
<p>请输入关键词开始查询。</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section id="detail-section" class="jr-detail-stack">
|
||||
<article class="jr-panel-card" style="margin-bottom:20px;">
|
||||
<div class="jr-panel-headline">
|
||||
<h3>车票详情</h3>
|
||||
<span class="jr-panel-note">Ticket Overview</span>
|
||||
</div>
|
||||
<div id="detail">
|
||||
<div class="jr-center-empty">
|
||||
<p>从左侧选择一张车票以查看详情。</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="jr-grid-two">
|
||||
<article class="jr-panel-card">
|
||||
<div class="jr-panel-headline">
|
||||
<h3>热门站点</h3>
|
||||
<span class="jr-panel-note">Popular Stations</span>
|
||||
</div>
|
||||
<div id="popularStations" class="jr-popular-list"></div>
|
||||
</article>
|
||||
<article class="jr-panel-card">
|
||||
<div class="jr-panel-headline">
|
||||
<h3>热门路线</h3>
|
||||
<span class="jr-panel-note">Popular Routes</span>
|
||||
</div>
|
||||
<div id="popularRoutes" class="jr-popular-list"></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<footer class="site-footer jr-footer-space">
|
||||
<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="/ticket-search.js?v=12"></script>
|
||||
<script src="/public-status.js?v=14"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const isDomain = location.hostname.includes('fse-media.group');
|
||||
const links = {
|
||||
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html',
|
||||
order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html',
|
||||
search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html',
|
||||
'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html',
|
||||
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
|
||||
};
|
||||
|
||||
const homeLink = document.getElementById('homeLink');
|
||||
const brandLink = document.getElementById('brandLink');
|
||||
if (homeLink) homeLink.href = links.home;
|
||||
if (brandLink) brandLink.href = links.home;
|
||||
|
||||
document.querySelectorAll('[data-link]').forEach((el) => {
|
||||
const key = el.getAttribute('data-link');
|
||||
if (links[key]) el.href = links[key];
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+528
-10
@@ -1,3 +1,7 @@
|
||||
[v-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--surface: #18181b;
|
||||
@@ -251,7 +255,7 @@ button, .btn {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 0;
|
||||
border-radius: 0 !important;
|
||||
border: 1px solid var(--border);
|
||||
background-color: transparent;
|
||||
color: var(--text);
|
||||
@@ -477,6 +481,9 @@ tr:hover td {
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
.mobile-only-inline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
:root { --sidebar-width: 260px; }
|
||||
@@ -1432,22 +1439,24 @@ input[type="date"].input::-webkit-calendar-picker-indicator {
|
||||
|
||||
/* Station Map */
|
||||
.station-map-container {
|
||||
background: #00000033;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
background: radial-gradient(circle at top left, rgba(255,255,255,0.78), rgba(248,250,252,0.95) 38%, rgba(241,245,249,0.98) 100%);
|
||||
padding: 22px 24px 16px;
|
||||
border-radius: 24px;
|
||||
margin-bottom: 20px;
|
||||
overflow-x: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 380px;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.66);
|
||||
}
|
||||
|
||||
.station-map-container svg {
|
||||
min-width: 680px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
transform: scale(1.06);
|
||||
transform: scale(1.02);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
@@ -1458,7 +1467,7 @@ input[type="date"].input::-webkit-calendar-picker-indicator {
|
||||
.map-station.start .node-core { fill: var(--success) !important; r: 10; stroke: white; stroke-width: 3; }
|
||||
.map-station.end .node-core { fill: var(--danger) !important; r: 10; stroke: white; stroke-width: 3; }
|
||||
.map-station text { pointer-events: none; }
|
||||
.map-station.transfer .node-core { stroke: #ffffffcc; stroke-width: 3; }
|
||||
.map-station.transfer .node-core { stroke: #e2e8f0; stroke-width: 3; }
|
||||
.map-station.route .node-core { stroke: var(--primary); stroke-width: 4; }
|
||||
.map-station.route-transfer .node-core { stroke: #f59e0b; stroke-width: 4; }
|
||||
|
||||
@@ -1778,6 +1787,7 @@ body.jr-order-page .site-footer .version {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d7e0d3;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.jr-hero-copy {
|
||||
@@ -2052,6 +2062,7 @@ body.jr-order-page .site-footer .version {
|
||||
background:
|
||||
linear-gradient(180deg, #f7faf7 0, #edf4ed 100%);
|
||||
border: 1px solid #d7e0d3;
|
||||
border-radius: 0;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
@@ -2350,7 +2361,7 @@ body.jr-order-page .map-station:hover .node-core {
|
||||
}
|
||||
|
||||
body.jr-order-page .map-station .node-core {
|
||||
stroke: #1d3326;
|
||||
stroke: #334155;
|
||||
}
|
||||
|
||||
body.jr-order-page .map-station.start .node-core {
|
||||
@@ -2369,6 +2380,10 @@ body.jr-order-page .map-station.route-transfer .node-core {
|
||||
stroke: #d69a0f;
|
||||
}
|
||||
|
||||
body.jr-order-page .station-map-container text {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.jr-hero,
|
||||
.jr-content-grid {
|
||||
@@ -2435,11 +2450,14 @@ body.jr-order-page .map-station.route-transfer .node-core {
|
||||
|
||||
.jr-selection-summary {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.jr-selection-arrow {
|
||||
min-height: 20px;
|
||||
min-height: 12px;
|
||||
transform: rotate(90deg);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.jr-form-grid,
|
||||
@@ -2453,11 +2471,25 @@ body.jr-order-page .map-station.route-transfer .node-core {
|
||||
}
|
||||
|
||||
.jr-station-map {
|
||||
padding: 14px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.jr-selection-card {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.jr-selection-tag {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.jr-selection-card strong {
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
body.jr-order-page .station-map-container svg {
|
||||
min-width: 620px;
|
||||
min-width: 560px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@@ -2719,6 +2751,25 @@ body.jr-public-page {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.jr-asset-credit {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: #6a786d;
|
||||
}
|
||||
|
||||
.jr-asset-credit a {
|
||||
color: #0b6b3a;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.jr-asset-credit a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.jr-search-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -4193,6 +4244,123 @@ body.jr-admin-login-page {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.jr-issue-table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.jr-issue-table th:nth-child(1) {
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
.jr-issue-table th:nth-child(2) {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.jr-issue-table th:nth-child(3) {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.jr-issue-table th:nth-child(4) {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.jr-issue-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.jr-issue-primary {
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.jr-issue-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: #6d7f73;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.jr-issue-error {
|
||||
color: #c43f3f;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.jr-issue-detail-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jr-issue-detail-card,
|
||||
.jr-issue-plain-detail {
|
||||
border: 1px solid #dbe4d7;
|
||||
background: #f8fbf7;
|
||||
padding: 12px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.jr-issue-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jr-issue-detail-item {
|
||||
border: 1px solid #dbe4d7;
|
||||
background: #ffffff;
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jr-issue-detail-item span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #6d7f73;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.jr-issue-detail-item strong {
|
||||
display: block;
|
||||
color: #183225;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.jr-issue-raw-block {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #dbe4d7;
|
||||
}
|
||||
|
||||
.jr-issue-raw-block summary {
|
||||
cursor: pointer;
|
||||
color: #607466;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.jr-issue-raw-block pre,
|
||||
.jr-issue-plain-detail pre {
|
||||
margin: 8px 0 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #1e293b;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.jr-admin-login-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -4485,3 +4653,353 @@ body.jr-admin-login-page {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 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;
|
||||
}
|
||||
}
|
||||
|
||||
| ||||