Compare commits

..
46 Commits
Author SHA1 Message Date
Henry_Du def7afd5ce 1 2026-08-15 22:56:52 +08:00
Henry_Du c52d4dac03 perf(gate): 移除显示器握手等待循环,直接设置参数后继续
- 版本号 v1.5.32 -> v1.5.33
- handshakeMonitor:改为单次设置 scale/颜色/清屏即返回,不再循环重试
- waitForPeripherals:移除 handshakeDone 等待分支与 'Waking monitor...' 提示
- 显示器一旦发现立即调用 handshakeMonitor 完成初始化,不做额外阻塞等待
- MONITOR_HANDSHAKE_RETRIES 置 0 作为占位常量
2026-08-15 22:55:17 +08:00
Henry_Du 2748aa91ca perf(gate): 大幅优化启动速度与票卡事件响应
- 版本号从 v1.5.31 升级至 v1.5.32
- 启动参数优化:外设等待间隔 0.5s→0.2s、最大重试 40→20、握手重试 20→8、启动重绘时长 20s→3s
- handshakeMonitor:移除不必要的 clearOk 严格检查,满足 scale+size 即视为握手成功
- 移除启动阶段阻塞的两次同步 HTTP 请求(站名映射/远程版本),改由后台异步任务执行
- startupRedrawTask:简化为仅启动初重绘 3 次即进入长休眠,避免频繁重绘抢占主循环
- 票卡处理:扫描去重窗口 500ms→200ms、PASS 后回退休眠 0.35s→0.1s
- 后台版本检查:VERSION_CHECK_INTERVAL 5s→15s,降低网络占用
2026-08-15 19:45:20 +08:00
Henry_Du 228077a49c fix(gate): 修复缺失的 splitDisplayLines 函数定义导致的启动崩溃
- 添加 splitDisplayLines 函数,用于按换行符拆分多行显示文本
- 处理 nil 输入、空字符串及无换行场景的边界情况
- 版本号从 v1.5.30 升级至 v1.5.31
2026-08-15 19:25:19 +08:00
Henry_Du e18f732b38 fix(gate): 修复区块加载时检票机屏幕无显示问题 - 新增 monitor 握手阶段,所有 termDev 渲染调用加 pcall 保护,启动后 20 秒持续重绘热修复,修复 setServerConnected 重复定义丢失 queueEvent 的 bug (v1.5.30) 2026-08-11 17:25:31 +08:00
Henry_Du e76d0188db fix: 修复检票机首次加载黑屏及完善亮灯模块对接
- 增加外设初始化重试机制,等待monitor和检票机外设就绪(最多20秒),
  在等待期间显示进度提示,避免区块加载时脚本提前退出导致屏幕无显示
- 修复Socket.IO服务端未强制websocket传输配置,避免负载均衡环境下轮询产生400错误
- 所有业务拒绝(deny)场景下调用markFault触发红灯,包括:
  * 无效票/卡、卡ID缺失、票ID缺失
  * 站点不匹配、未进站/已出站、已进站/已出站
  * 余额不足、票价查询失败、网络错误
  * 外设写入/扣费失败等所有错误路径
- 版本号升级至v1.5.29
2026-08-11 14:29:03 +08:00
Henry_Du ca77d762c9 style(web): 强制移除所有按钮的圆角
为所有 button, .btn, [class*='btn'] 等按钮元素统一追加了 border-radius: 0 !important,确保全站按钮显示为直角方块。
2026-08-08 13:29:57 +08:00
Henry_Du 41546cce96 fix(web): 强制 Socket.IO 使用 WebSocket 传输
因为线上环境没有配置 IP Hash (Sticky Sessions),导致默认的 polling 长轮询请求会随机分配到不同后端,从而引发 400 Bad Request 和连接断开重连刷屏的问题。
现将 transports 锁定为 ['websocket'],直接使用 WebSocket 连接。
2026-08-08 10:42:00 +08:00
Henry_Du 3d38e16917 style(web): 清理多余的状态显示并优化刷新按钮
1. 全局移除了所有页面顶部的 \jr-top-status\ (服务器状态检测) 和 \jr-admin-header-pill\ (服务器在线标签)。
2. 将 /admin 控制台顶部的「刷新视图」按钮优化为次要按钮样式(白底边框),使其不喧宾夺主。
2026-08-08 10:19:00 +08:00
Henry_Du 3d7fa994ae feat(web): 站点编辑支持修改所属线路及编号级联更新
1. 在“站点编辑”弹窗中新增了“所属线路”的多选组件。现在可以在编辑站名和换乘时,一并把该站添加到新线路或从现有线路中移除了。
2. 重构了 saveStationSettings,不仅保存站点本身信息,还会对比前后的线路差异(增加、删除或站点编号变更),并自动调用 updateLineStations 接口保存,确保“站点管理”里的操作能实时反映到“线路规划”的可视化拓扑图中。
2026-08-08 10:08:40 +08:00
Henry_Du cf0a49867c feat(ui): 优化购卡键盘渲染逻辑与单程区间选中样式
- 重构 showCardNameInput 渲染逻辑,添加 renderInput 局部更新,解决按键闪烁问题
- 优化单程区间的选中样式,移除文本前缀,改为绿色背景高亮,与购票界面保持一致
- 更新版本号至 v1.5.30
2026-08-08 09:58:34 +08:00
Henry_Du fa51aad3a7 fix(web): 统一UI规范并移除多余菜单
1. 从 /admin 侧边栏彻底移除了多余的“站点管理”导航项和对应的引导页,统一仅在 /route 中维护。
2. 修复了 ticket-route.js 中添加、编辑、删除站点后列表不同步的问题,通过补充 await fetchData() 实现实时更新。
3. 更新 style.css 将所有 button 和 .btn 的 border-radius 设置为 0 !important,统一取消所有按钮圆角。
2026-08-08 09:51:14 +08:00
Henry_Du 9cc3aba5e9 fix(web/route): 修复站点管理无法编辑的问题 2026-08-08 09:38:44 +08:00
Henry_Du cdf2c39220 fix(web): 彻底清理车票详情弹窗并统一后台导航
1. 在 /route 页面中彻底移除了遗留的 \showTicketModal\ 车票详情弹窗 HTML 代码,解决页面刷新时偶发的乱码闪烁问题。
2. 在 /admin (index.html) 的左侧边栏补齐了「站点管理」导航入口。
3. 为了保证体验一致性,当用户在 /admin 点击「站点管理」时,会显示一个友好的迁移引导页,引导用户跳转到更专业的 /route 线路规划后台进行站点编辑。
2026-08-08 00:07:53 +08:00
Henry_Du 7e0a114635 refactor(web/route): 精简导航菜单 & 修复初始加载闪屏
1. 根据需求移除了“仪表盘”、“车票记录”、“系统设置”、“凭证管理”等多余的页面导航和对应代码。
2. 目前仅保留三个核心功能页:【线路规划】、【站点管理】、【票价地图】。
3. 修复了刷新页面时Vue未初始化完成导致的大段未解析模板闪屏的问题:引入了 v-cloak 并在 CSS 中隐藏未就绪的 DOM。
4. 确认了 /api/stations 请求本身已经直接对接数据库 DataService.saveStations,前端现也去掉了多余的视图判断,保证修改后立即反映。
2026-08-08 00:03:47 +08:00
Henry_Du 0e3f1a6e86 feat(web/route): 新增全局站点管理页面 (Station Management)
1. 在左侧侧边栏新增「站点管理」导航项,并支持在 /route 内无缝切换视图。
2. 实现了全局的站点列表:
   - 支持显示所有已入库站点的编号、中英文名称。
   - 动态计算并展示每个站点目前被关联到了哪些线路(所属线路)。
   - 支持通过站点编号或中英文名称进行快速搜索过滤。
3. 提供了独立于线路外的全局新增、编辑和删除站点功能:
   - 新增:不再强制需要选中线路,直接在库中创建。
   - 编辑:复用原有的站点编辑弹窗。
   - 删除:可从库中全局删除无用站点。
4. 移动端和电脑端均已应用自适应表格和工具栏布局。
2026-08-07 23:56:40 +08:00
Henry_Du 0a37c0cba2 fix(web/route): 解决移动端列表与空状态卡片重叠问题
1. 修复移动端下由于 .management-sidebar 高度受限导致内容溢出、与下方主面板重叠的布局 bug。
2. 将 .management-sidebar 高度锁定在最大 40vh 并限制内部 .card 溢出,同时保持线路列表自身的内部滚动,优化了上下排布时的展示效果。
3. 当移动端未选中任何线路时,直接隐藏掉右侧面板(.management-main.is-empty),避免多余的“选择左侧线路”空状态卡片挤占宝贵的手机屏幕空间。
4. 修复了桌面端误显移动端专属提示文本的问题。
2026-08-07 23:41:47 +08:00
Henry_Du 6605c67f23 feat(web/route): 支持电脑端鼠标平移画布 & 优化动态SVG宽度
1. 修复电脑端画布无法拖拽滑动问题:
   - 新增 onPanStart/onPanMove/onPanEnd 逻辑
   - 绑定鼠标事件到 .visual-line-container,实现按住左键平移 scrollLeft
2. 修复长线路无法完全滚动的问题:
   - 将固定 100% 的宽度替换为动态宽度计算:Math.max(100, 50 + stations.length * 120 + 50) + px
   - 确保原生 overflow-x: auto 能正确感知超出容器的内部站点
3. 更新静态资源版本号避免缓存
2026-08-07 23:32:05 +08:00
Henry_Du 1046aa8dda feat(web/route): 适配移动端响应式布局 + 统一站点拖拽逻辑
1. 线路规划页面(/route)移动端适配:
   - 新增 980px / 768px / 480px 三档响应式CSS
   - 侧边栏改为抽屉式叠加层,主容器垂直流式布局
   - 顶部导航、工具栏、编辑器堆叠式排列
   - SVG线路容器开启横向惯性滚动,最小宽度720px
   - 票价地图、表格、模态框均针对小屏优化
   - 按钮/输入框统一最小高度44px,满足可访问性

2. 重构站点拖拽逻辑(修复电脑端拖拽失效):
   - 原实现:mousemove绑定单站点<g>,鼠标离开即丢失拖拽
   - 新实现:mousedown/touchstart仅记录起点,
     window级别监听 mousemove/mouseup/touchmove/touchend
   - 通过 SVG createSVGPoint + getScreenCTM().inverse()
     统一将屏幕坐标转换为SVG逻辑坐标再换算站点索引
   - 共用 swapStationByIndex() / saveDragResult() 核心函数

3. 静态资源版本号升级: style.css v16, ticket-route.js v5
2026-08-07 23:01:33 +08:00
Henry_Du b7cdcc570a feat(卡片): 新增卡片发行警告提示功能
将版本号更新至v1.5.29。新增cardIssueWarning状态变量以管理卡片发行过程中的警告信息,在重置票务和卡片流程时同步重置该状态。在卡片发行的各逻辑分支中设置对应警告文案,优化完成界面的展示逻辑以显示警告信息并调整提示语,同时在卡片发行快照中新增对应存储字段。
2026-07-31 13:29:23 +08:00
Henry_Du a59e9e60a4 feat(gate): 优化显示屏显示逻辑并更新版本
为 install_ticketmachine.lua 添加标准文件头注释
升级 gate.lua 版本至 v1.5.28
新增 splitDisplayLines 函数用于拆分多行显示文本
重构 draw 与 readyLine2 函数以支持多行状态文本的居中渲染
新增 displayStationCode 函数优化车站代码格式,移除 ST 前缀与多余空格
2026-07-31 12:43:58 +08:00
Henry_Du d685dee2a4 refactor(peripheral): 移除tm_gpu外设支持并清理冗余代码
删除gate.lua与ticketmachine.lua中所有与tm_gpu相关的代码逻辑,移除冗余的table.pack兼容函数与事件重写代码,简化外设发现与终端处理流程,同步更新两个文件的版本号。
2026-07-31 11:22:21 +08:00
Henry_Du 9c7e99633b fix(peripheral): 完善tm_gpu外设检测逻辑并升级版本号
新增scanForTmGpu函数用于遍历所有外设查找tm_gpu设备,在原有按类型匹配的查找失败时启用该后备逻辑,同时更新两个脚本文件的版本号。
2026-07-31 11:18:38 +08:00
Henry_Du 879445b9f6 feat(ticketmachine): 新增自定义TM GPU外设支持并更新版本至v1.5.26
- 新增table.pack兼容函数适配旧版Lua环境
- 实现TM GPU设备检测、终端仿真与坐标颜色转换逻辑
- 添加事件兼容层转换自定义触摸事件为标准事件并修正坐标
- 更新外设发现逻辑优先使用TM GPU终端
- 将版本从v1.5.25升级至v1.5.26
2026-07-31 11:15:31 +08:00
Henry_Du 200dbda9bd feat(display): 新增tm_gpu外设显示支持并升级版本至v1.5.28
实现tm_gpu外设的终端包装与适配逻辑,将其纳入显示外设选择流程。
2026-07-31 11:10:39 +08:00
Henry_Du 7e72bbeae5 feat: 新增错误问题追踪与管理功能
- 升级程序版本至 v1.5.25
- 新增对比度文本颜色计算与垂直滚动条绘制工具函数,优化选择界面滚动显示
- 网页端新增问题记录管理页面,配套完整的表格与详情展示样式
- 实现issue记录标准化处理、实时事件推送接收与按时间倒序排序展示
- 重构原有选择界面的滚动逻辑,替换为通用滚动条绘制函数
2026-07-29 12:29:54 +08:00
Henry_Du 0e8f86b478 1 2026-07-29 11:48:08 +08:00
Henry_Du 4db60ddc9e feat(ui): 新增购票兑换菜单,优化输入交互与界面布局
新增new_menu中间页面,拆分首页NEW按钮为购票和兑换码两个入口
抽离通用输入渲染函数,重构线上券码和IC卡兑换页面的代码逻辑
优化输入交互,按键、退格、清空时实时更新输入显示与提示
完善键盘快捷键支持,调整部分按钮布局与显示文本
统一音频播放逻辑,仅购票时播放欢迎音效
2026-07-29 11:34:47 +08:00
Henry_Du c840e2abfe feat: 新增客户端问题主动上报与控制台问题汇总功能
1. 客户端 (ticketmachine.lua): 修改 showAlert 支持详细错误并主动上报,使用 pcall 包装主循环捕获 Fatal Error 并上报。
2. 服务端 (data.js, api.js): 增加 issues 数据表,提供 /report_issue 和 /issues 接口。
3. 控制台 (index.html, index.js): 添加“问题汇总”菜单及界面状态管理。
2026-07-24 22:37:26 +08:00
Henry_Du be0077743c fix: 修复售票机付款成功后出票失败 2026-07-24 21:35:30 +08:00
Henry_Du 4ddc4cb4ad style: 稳定订票页线路按钮布局与尺寸 2026-07-19 03:41:50 +08:00
Henry_Du 04d0badce8 1 2026-07-19 00:38:18 +08:00
Henry_Du 847957be61 feat: 支持编辑线路图署名并固定 CC BY 4.0 2026-07-19 00:16:20 +08:00
Henry_Du 6195ebb6be chore: 为线路图展示补充 CC-BY 4.0 署名 2026-07-18 23:51:32 +08:00
Henry_Du ec31c12130 feat: 改造线路图上传后台并接入购票页总览图 2026-07-17 03:17:03 +08:00
Henry_Du 289ed65e79 style: 优化移动端线路按钮与区间卡片布局 2026-07-17 02:54:35 +08:00
Henry_Du 95bf4d3d8e style: 同步购票页移动端直角与线路列表布局 2026-07-17 01:19:35 +08:00
Henry_Du c6ab010974 style: 调整购票页外框直角样式并上移线路列表 2026-07-17 01:16:24 +08:00
Henry_Du 234e22468a style: 合并断点线路展示并改为直角线段 2026-07-17 01:13:10 +08:00
Henry_Du 77b487c326 feat(gate): support passing stationId to markEntered API 2026-07-17 00:44:37 +08:00
Henry_Du 1f5bc1d40d fix(gate): support java peripheral markEntered and bypass missing updateICCard 2026-07-17 00:33:35 +08:00
Henry_Du 2096dd8d89 fix(gate,installer): 修复进站刷卡字段兼容并清理安装器头注释 2026-07-17 00:19:35 +08:00
Henry_Du 5e8d4f7bbe fix: 统一不同线路的地图容器大小,无论站点多少,容器最小宽度都保持一致 2026-07-16 22:49:13 +08:00
Henry_Du 9d145c3d16 style: 优化 TVM 购票地图,缩小元素尺寸,增加切换动画,适配移动端 2026-07-16 22:16:10 +08:00
Henry_Du cfaa622dcd feat: 重构选择购票区间地图为 TVM 瀑布流样式 2026-07-16 06:05:33 +08:00
Henry_Du 7812d47f0c refactor & feat & fix: 重写SVG生成器,优化前端线路绘制与修复样式
重写服务器端SVG生成逻辑,将原铁路线路图生成功能替换为站点间票价矩阵生成。
优化前端换乘路径绘制:新增线路偏移与弯道处理,避免多条线路路径重叠。
修复站点标记的外层圆圈填充颜色,使其正确使用对应线路的配色。
2026-07-16 03:49:45 +08:00
29 changed files with 3846 additions and 2802 deletions
BIN
View File
Binary file not shown.
+296
View File
@@ -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 返回中提供)
+426 -102
View File
@@ -1,8 +1,11 @@
local DEFAULT_SERVER_BASE = "https://ticket.fse-media.group"
local DEFAULT_SERVER_PATH = "/api/tickets/check"
local GATE_OPEN_SECONDS = 2
local VERSION = "v1.5.23"
local VERSION_CHECK_INTERVAL = 5
local VERSION = "v1.5.33"
local VERSION_CHECK_INTERVAL = 15
local PERIPHERAL_WAIT_INTERVAL = 0.2
local PERIPHERAL_WAIT_MAX_RETRIES = 20
local serverURL = nil
@@ -86,9 +89,97 @@ local function stationSetFromList(list)
return set
end
local monitor = peripheral.find("monitor")
local speaker = peripheral.find("speaker")
local inspection = peripheral.find("ticket_inspection_machine")
local nativeTerm = (term and type(term.current) == "function" and term.current()) or term
local monitor = nil
local speaker = nil
local inspection = nil
local monitorSide = nil
local function safeFindPeripheral(peripheralType, sideHint)
if not peripheral or type(peripheral.find) ~= "function" then return nil, nil end
if sideHint and type(peripheral.wrap) == "function" and type(peripheral.isPresent) == "function" then
local okPres, pres = pcall(peripheral.isPresent, sideHint)
if okPres and pres then
local okW, wrapped = pcall(peripheral.wrap, sideHint)
if okW and wrapped then return wrapped, sideHint end
end
end
local foundName = nil
local found = nil
pcall(function()
found = peripheral.find(peripheralType, function(name, wrapped)
foundName = name
return true
end)
end)
if found then return found, foundName end
return nil, nil
end
local function waitForPeripherals(requireInspection)
local attempts = 0
local lastDrawMsg = nil
while attempts < PERIPHERAL_WAIT_MAX_RETRIES do
local hadChange = false
if not monitor then
local m, ms = safeFindPeripheral("monitor", monitorSide)
if m then
monitor = m
monitorSide = ms
termDev = monitor
pcall(function() handshakeMonitor(monitor) end)
hadChange = true
end
end
if not speaker then
local s, _ = safeFindPeripheral("speaker")
if s then speaker = s; hadChange = true end
end
if not inspection and requireInspection then
local i, _ = safeFindPeripheral("ticket_inspection_machine")
if i then inspection = i; hadChange = true end
end
local ok = true
if not monitor then ok = false end
if requireInspection and not inspection then ok = false end
if ok then return true end
local msg = nil
if not monitor then
msg = "Waiting for monitor..."
elseif requireInspection and not inspection then
msg = "Waiting for gate peripheral..."
end
if msg and msg ~= lastDrawMsg then
lastDrawMsg = msg
pcall(function()
local tmpTerm = nativeTerm or term
if tmpTerm and type(tmpTerm.getSize) == "function" then
tmpTerm.setBackgroundColor(colors.black)
tmpTerm.setTextColor(colors.yellow)
tmpTerm.clear()
tmpTerm.setCursorPos(1, 1)
local w, _ = tmpTerm.getSize()
local pad = string.rep(" ", math.max(0, math.floor((w - #VERSION) / 2)))
tmpTerm.write(pad .. VERSION)
local msg2 = tostring(msg)
tmpTerm.setCursorPos(1, 3)
local pad2 = string.rep(" ", math.max(0, math.floor((w - #msg2) / 2)))
tmpTerm.write(pad2 .. msg2)
tmpTerm.setCursorPos(1, 5)
tmpTerm.setTextColor(colors.gray)
local prog = "Attempt " .. tostring(attempts + 1) .. "/" .. tostring(PERIPHERAL_WAIT_MAX_RETRIES)
local pad3 = string.rep(" ", math.max(0, math.floor((w - #prog) / 2)))
tmpTerm.write(pad3 .. prog)
end
end)
end
sleep(PERIPHERAL_WAIT_INTERVAL)
attempts = attempts + 1
end
return not not monitor
end
pcall(function() waitForPeripherals(false) end)
local serverConnected = nil
local isUpdating = false
@@ -162,53 +253,125 @@ local function jsonDecode(str)
if ok then return res else return nil end
end
local function setServerConnected(ok)
if serverConnected == ok then return end
serverConnected = ok
serverLastChangeTs = os.epoch("utc")
local termDev = nativeTerm
if peripheral and type(peripheral.getName) == "function" and monitor then
pcall(function() monitorSide = peripheral.getName(monitor) end)
end
local termDev = term
if monitor then
pcall(monitor.setTextScale, 0.5)
termDev = monitor
local function safeCallTerm(method, ...)
if not termDev or type(termDev[method]) ~= "function" then return nil end
local ok, r1, r2, r3 = pcall(termDev[method], ...)
if not ok then return nil end
return r1, r2, r3
end
local function safeCallMonitor(target, method, ...)
if not target or type(target[method]) ~= "function" then return false end
local ok, r1 = pcall(target[method], ...)
if not ok then return false end
return r1 ~= false
end
local function handshakeMonitor(target)
if not target then return false end
safeCallMonitor(target, "setTextScale", 0.5)
safeCallMonitor(target, "setBackgroundColor", colors.black)
safeCallMonitor(target, "setTextColor", colors.white)
safeCallMonitor(target, "clear")
local w, h = safeCallTerm("getSize")
return (type(w) == "number" and type(h) == "number" and w >= 2 and h >= 2)
end
local function refreshDisplayTarget()
local nextMonitor = nil
local nextMonitorSide = monitorSide
if peripheral then
if nextMonitorSide and type(peripheral.isPresent) == "function" then
local okPres, pres = pcall(peripheral.isPresent, nextMonitorSide)
if okPres and pres and type(peripheral.wrap) == "function" then
local okWrap, wrapped = pcall(peripheral.wrap, nextMonitorSide)
if okWrap and wrapped then nextMonitor = wrapped end
end
end
if not nextMonitor and type(peripheral.find) == "function" then
local foundName = nil
local found = nil
pcall(function()
found = peripheral.find("monitor", function(name, wrapped)
foundName = name
return true
end)
end)
if found then
nextMonitor = found
nextMonitorSide = foundName
else
nextMonitorSide = nil
end
end
end
if nextMonitor and nextMonitor ~= monitor then
monitor = nextMonitor
monitorSide = nextMonitorSide
pcall(function() handshakeMonitor(nextMonitor) end)
elseif nextMonitor then
monitor = nextMonitor
monitorSide = nextMonitorSide
end
if monitor then
if not safeCallMonitor(monitor, "setTextScale", 0.5) then
pcall(function() handshakeMonitor(monitor) end)
end
termDev = monitor
else
termDev = nativeTerm or term
end
return termDev
end
local function clear()
termDev.setBackgroundColor(colors.black)
termDev.setTextColor(colors.white)
termDev.clear()
termDev.setCursorPos(1, 1)
pcall(refreshDisplayTarget)
if not termDev then return end
safeCallTerm("setBackgroundColor", colors.black)
safeCallTerm("setTextColor", colors.white)
safeCallTerm("clear")
safeCallTerm("setCursorPos", 1, 1)
end
local function centerText(y, text, color)
local w = termDev.getSize()
termDev.setTextColor(color or colors.white)
local x = math.max(1, math.floor((w - #text) / 2) + 1)
termDev.setCursorPos(x, y)
termDev.write(text)
if not termDev then return end
local w = safeCallTerm("getSize")
w = tonumber(w) or 0
if w < 2 then return end
safeCallTerm("setTextColor", color or colors.white)
local x = math.max(1, math.floor((w - #tostring(text or "")) / 2) + 1)
safeCallTerm("setCursorPos", x, y)
safeCallTerm("write", tostring(text or ""))
end
local lastStartupRedrawAt = 0
local function drawServerStatusIndicator(w)
w = tonumber(w) or 0
if w < 2 then return end
local col = colors.yellow
if serverConnected == true then col = colors.lime
elseif serverConnected == false then col = colors.red end
termDev.setBackgroundColor(colors.black)
termDev.setTextColor(col)
termDev.setCursorPos(w - 1, 1)
termDev.write("S")
termDev.setCursorPos(w, 1)
termDev.write("*")
termDev.setTextColor(colors.white)
safeCallTerm("setBackgroundColor", colors.black)
safeCallTerm("setTextColor", col)
safeCallTerm("setCursorPos", w - 1, 1)
safeCallTerm("write", "S")
safeCallTerm("setCursorPos", w, 1)
safeCallTerm("write", "*")
safeCallTerm("setTextColor", colors.white)
end
local lastUpdateError = nil
local function drawVersionIndicator(w)
w = tonumber(w) or 0
local s = tostring(VERSION or "")
if #s == 0 then return end
if w < #s then return end
if #s == 0 or w < #s then return end
local markerColor = colors.lime
local markerText = "* "
if isUpdating then
@@ -218,37 +381,61 @@ local function drawVersionIndicator(w)
markerColor = colors.red
markerText = "* "
end
termDev.setBackgroundColor(colors.black)
termDev.setTextColor(colors.gray)
termDev.setCursorPos(1, 1)
termDev.write(s)
termDev.setTextColor(markerColor)
termDev.write(markerText)
safeCallTerm("setBackgroundColor", colors.black)
safeCallTerm("setTextColor", colors.gray)
safeCallTerm("setCursorPos", 1, 1)
safeCallTerm("write", s)
safeCallTerm("setTextColor", markerColor)
safeCallTerm("write", markerText)
if lastUpdateError then
termDev.setCursorPos(1, 2)
termDev.setTextColor(colors.red)
termDev.write(string.sub(tostring(lastUpdateError), 1, w))
safeCallTerm("setCursorPos", 1, 2)
safeCallTerm("setTextColor", colors.red)
safeCallTerm("write", string.sub(tostring(lastUpdateError), 1, w))
end
termDev.setTextColor(colors.white)
safeCallTerm("setTextColor", colors.white)
end
local _cachedDrawArgHash = nil
local function splitDisplayLines(s)
if s == nil then return {} end
local str = tostring(s)
if #trim(str) == 0 then return {} end
local lines = {}
for line in str:gmatch("[^\n]+") do
table.insert(lines, line)
end
if #lines == 0 then table.insert(lines, str) end
return lines
end
local function draw(statusLine1, statusLine2, statusColor)
clear()
local w, h = termDev.getSize()
if not termDev then return end
local w, h = safeCallTerm("getSize")
w = tonumber(w) or 0
h = tonumber(h) or 0
if w < 2 or h < 2 then return end
centerText(2, "GATE", colors.cyan)
drawVersionIndicator(w)
drawServerStatusIndicator(w)
if statusLine1 and #statusLine1 > 0 then
centerText(math.max(2, math.floor(h / 2)), statusLine1, statusColor or colors.white)
local lines = {}
for _, line in ipairs(splitDisplayLines(statusLine1)) do table.insert(lines, line) end
for _, line in ipairs(splitDisplayLines(statusLine2)) do table.insert(lines, line) end
if #lines > 0 then
local maxBodyY = math.max(3, h - 1)
local startY = math.max(3, math.floor((maxBodyY - #lines) / 2) + 1)
for idx, line in ipairs(lines) do
local y = startY + idx - 1
if y > maxBodyY then break end
centerText(y, line, statusColor or colors.white)
end
end
if statusLine2 and #statusLine2 > 0 then
centerText(math.min(h, math.max(3, math.floor(h / 2) + 1)), statusLine2, statusColor or colors.white)
end
termDev.setCursorPos(1, h)
termDev.setTextColor(colors.gray)
termDev.write(string.rep(" ", w))
safeCallTerm("setCursorPos", 1, h)
safeCallTerm("setTextColor", colors.gray)
safeCallTerm("write", string.rep(" ", w))
end
local function pulseLeftRedstone(seconds)
@@ -770,15 +957,31 @@ if type(cfg.side_station_codes) == "table" then
end
pcall(function()
refreshStationNameMap(guessBaseFromStatusURL(serverURL))
end)
pcall(function()
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
end)
if not inspection then
if modeBySide == nil then
waitForPeripherals(true)
else
for side, _ in pairs(modeBySide) do
local dev = resolveInspection(side)
local retryCount = 0
while not dev and retryCount < PERIPHERAL_WAIT_MAX_RETRIES do
sleep(PERIPHERAL_WAIT_INTERVAL)
dev = resolveInspection(side)
retryCount = retryCount + 1
end
end
if not monitor then waitForPeripherals(false) end
end
end)
if not inspection and modeBySide == nil then
local okDev = false
if peripheral and type(peripheral.find) == "function" then
pcall(function()
inspection = peripheral.find("ticket_inspection_machine")
if inspection then okDev = true end
end)
end
if not okDev then
draw("Missing peripheral:", "ticket_inspection_machine", colors.red)
error("ticket_inspection_machine not found")
end
@@ -814,6 +1017,15 @@ local function stationCodeForSide(side)
return defaultStationCode()
end
local function displayStationCode(code)
local s = trim(code)
if #s == 0 then return s end
if s:sub(1, 2):upper() == "ST" then
s = s:sub(3):gsub("^[%s%-_]+", "")
end
return s
end
local function isInspectionPeripheral(p)
return type(p) == "table" and (
type(p.getLastScanned) == "function"
@@ -909,15 +1121,38 @@ local function readyLine1()
end
local function readyLine2()
if not modeBySide then
return "ST " .. stationCodeForSide(nil)
local lines = {}
local seen = {}
local function addLine(line)
line = trim(line)
if #line == 0 then return end
local key = line:lower()
if seen[key] then return end
seen[key] = true
table.insert(lines, line)
end
local frontCode = stationCodeForSide("front")
local backCode = stationCodeForSide("back")
if frontCode == backCode then
return "ST " .. frontCode
if type(sideStationCodeBySide) == "table" and next(sideStationCodeBySide) ~= nil then
local orderedSides = { "front", "back", "left", "right", "top", "bottom" }
for _, side in ipairs(orderedSides) do
local code = displayStationCode(sideStationCodeBySide[side] or "")
if #code > 0 then
addLine(string.upper(side:sub(1, 1)) .. " " .. code)
end
end
end
return "F " .. frontCode .. " B " .. backCode
if #lines == 0 then
for _, code in ipairs(stationCodesPayload) do
addLine(displayStationCode(code))
end
end
if #lines == 0 then
addLine(displayStationCode(stationCodeForSide(nil)))
end
return table.concat(lines, "\n")
end
local function drawReadyScreen()
@@ -972,19 +1207,19 @@ local function updateDeviceField(dev, key, value)
pcall(dev.updateTicket, key, value)
end
local function callTicketStateMethod(dev, methodName)
local function callTicketStateMethod(dev, methodName, ...)
if type(dev) ~= "table" then return false end
local fn = dev[methodName]
if type(fn) ~= "function" then return false end
local okCall, okRes = pcall(fn)
local okCall, okRes = pcall(fn, ...)
if not okCall then return false end
return okRes ~= false
end
local function applyTicketDeviceState(dev, action, rides)
local function applyTicketDeviceState(dev, action, rides, stationCode)
if type(dev) ~= "table" then return end
if action == "entry" then
if not callTicketStateMethod(dev, "markEntered") then
if not callTicketStateMethod(dev, "markEntered", stationCode) then
updateDeviceField(dev, "entered", true)
updateDeviceField(dev, "exited", false)
end
@@ -1003,13 +1238,32 @@ local function applyTicketDeviceState(dev, action, rides)
end
local function updateICCardField(dev, key, value)
if type(dev) ~= "table" or type(dev.updateICCard) ~= "function" then return false end
if type(dev) ~= "table" then return false end
if type(dev.updateICCard) ~= "function" then return true end
local okCall, okRes, detail = pcall(dev.updateICCard, key, value)
if not okCall then return false, tostring(okRes) end
if okRes == false then return false, tostring(detail or "update_failed") end
return true
end
local function updateICCardFieldWithFallback(dev, keys, value)
if type(keys) == "string" then
return updateICCardField(dev, keys, value)
end
local firstErr = nil
if type(keys) ~= "table" then return false, "invalid_keys" end
for _, key in ipairs(keys) do
local okField, errField = updateICCardField(dev, key, value)
if okField then
return true, key
end
if not firstErr then
firstErr = tostring(key) .. ": " .. tostring(errField or "update_failed")
end
end
return false, firstErr or "update_failed"
end
local function updateICCardFields(dev, patch)
local allOk = true
local firstErr = nil
@@ -1086,9 +1340,35 @@ local function queryFare(fromStation, toStation)
return toMoney(fare), nil
end
local function denyCard(reason, detail)
local function triggerFaultLights(devs)
local targets = {}
if type(devs) == "table" then
for _, d in ipairs(devs) do table.insert(targets, d) end
end
if inspection and not targets[1] then table.insert(targets, inspection) end
if modeBySide then
for side, _ in pairs(modeBySide) do
local d = resolveInspection(side)
if d then
local found = false
for _, t in ipairs(targets) do if t == d then found = true; break end end
if not found then table.insert(targets, d) end
end
end
end
for _, dev in ipairs(targets) do
if type(dev) == "table" and type(dev.markFault) == "function" then
pcall(dev.markFault)
end
end
end
local function denyCard(reason, detail, inspectionDevs)
draw("DENIED", tostring(detail or reason or "deny"), colors.red)
playDfpwm("error.dfpwm")
parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
end
local function deductICCardBalance(dev, amount)
@@ -1134,7 +1414,10 @@ local function handleICCardScan(scan, side, scanDev)
local cardId = getCardId(scan)
if #cardId == 0 then
draw("Invalid card.", "Missing card_id.", colors.red)
playDfpwm("error.dfpwm")
parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
return
end
@@ -1145,56 +1428,64 @@ local function handleICCardScan(scan, side, scanDev)
local usedAction = action
if #exitStation == 0 then
denyCard("missing_station", "Missing gate station")
denyCard("missing_station", "Missing gate station", inspectionDevs)
return
end
if usedAction == "entry" then
if getCardEntered(scan) and not getCardExited(scan) then
denyCard("already_entered", "Already entered")
denyCard("already_entered", "Already entered", inspectionDevs)
return
end
local okWrite = true
local writeErr = nil
for _, dev in ipairs(inspectionDevs) do
local okPatch, errPatch = updateICCardFields(dev, {
entry = exitStation,
})
local okPatch, errPatch = updateICCardFieldWithFallback(dev, {
"entry_station",
"entry",
"start_station"
}, exitStation)
okWrite = okPatch and okWrite
if not okPatch and not writeErr then writeErr = errPatch end
end
if not okWrite then
draw("WRITE ERROR", tostring(writeErr or "Failed to update card"), colors.red)
playDfpwm("error.dfpwm")
parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
return
end
entryStation = exitStation
else
if not getCardEntered(scan) then
denyCard("not_entered", "Not entered")
denyCard("not_entered", "Not entered", inspectionDevs)
return
end
if getCardExited(scan) then
denyCard("already_exited", "Already exited")
denyCard("already_exited", "Already exited", inspectionDevs)
return
end
if #entryStation == 0 then
denyCard("missing_entry_station", "Missing entry station")
denyCard("missing_entry_station", "Missing entry station", inspectionDevs)
return
end
local fareValue, fareErr = queryFare(entryStation, exitStation)
if fareValue == nil then
if fareErr == "net_error" then
draw("NET ERROR", "Fare lookup failed", colors.red)
playDfpwm("error.dfpwm")
parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
else
denyCard("fare_not_found", fareErr)
denyCard("fare_not_found", fareErr, inspectionDevs)
end
return
end
fare = fareValue
if balance < fare then
denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance))
denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance), inspectionDevs)
return
end
local okWrite = true
@@ -1203,7 +1494,11 @@ local function handleICCardScan(scan, side, scanDev)
local okDeduct, newBalanceOrErr = deductICCardBalance(dev, fare)
if okDeduct then
balance = toMoney(newBalanceOrErr or (balance - fare)) or 0
local okClear, clearErr = updateICCardField(dev, "entry", nil)
local okClear, clearErr = updateICCardFieldWithFallback(dev, {
"entry_station",
"entry",
"start_station"
}, nil)
local okFare, fareErr = updateICCardField(dev, "last_fare", fare)
if not okClear or not okFare then
okWrite = false
@@ -1213,10 +1508,13 @@ local function handleICCardScan(scan, side, scanDev)
else
okWrite = false
if tostring(newBalanceOrErr) == "insufficient" then
denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance))
denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance), inspectionDevs)
else
draw("WRITE ERROR", tostring(newBalanceOrErr or "deduct_failed"), colors.red)
playDfpwm("error.dfpwm")
parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
end
break
end
@@ -1224,20 +1522,20 @@ local function handleICCardScan(scan, side, scanDev)
if not okWrite then
if writeErr then
draw("WRITE ERROR", tostring(writeErr), colors.red)
playDfpwm("error.dfpwm")
parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
end
return
end
end
for _, dev in ipairs(inspectionDevs) do
applyTicketDeviceState(dev, usedAction, nil, exitStation)
if usedAction == "entry" then
updateDeviceField(dev, "entered", true)
updateDeviceField(dev, "exited", false)
if #entryStation > 0 then updateDeviceField(dev, "entry_station", entryStation) end
else
updateDeviceField(dev, "exited", true)
updateDeviceField(dev, "entered", false)
if #exitStation > 0 then updateDeviceField(dev, "exit_station", exitStation) end
updateDeviceField(dev, "last_fare", fare)
end
@@ -1293,7 +1591,10 @@ local function handleScan(scan, side, scanDev)
local ticketId = getTicketId(scan)
if not ticketId then
draw("Invalid ticket.", "Missing ticketId.", colors.red)
playDfpwm("error.dfpwm")
parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
return
end
@@ -1331,13 +1632,19 @@ local function handleScan(scan, side, scanDev)
if not ok or type(resp) ~= "table" then
draw("NET ERROR", "Server check failed.", colors.red)
playDfpwm("error.dfpwm")
parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
return
end
if resp.result ~= "pass" then
draw("DENIED", tostring(resp.reason or "deny"), colors.red)
playDfpwm("error.dfpwm")
parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
return
end
@@ -1347,7 +1654,7 @@ local function handleScan(scan, side, scanDev)
or tonumber(scan.trips_remaining or scan.rides_remaining)
or tonumber(scan.rides)
for _, dev in ipairs(inspectionDevs) do
applyTicketDeviceState(dev, usedAction, newRides)
applyTicketDeviceState(dev, usedAction, newRides, stationCodeForSide(side))
end
end)
@@ -1402,7 +1709,7 @@ local function shouldProcessScan(scan, side, eventName)
local now = os.epoch("utc")
local prev = recentScans[key]
recentScans[key] = now
if prev and (now - prev) < 500 then
if prev and (now - prev) < 200 then
return false
end
return true
@@ -1455,12 +1762,28 @@ local function mainLoop()
local ev = pack(os.pullEvent())
if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then
processInspectionEvent(ev[1], ev)
os.sleep(0.35)
os.sleep(0.1)
drawReadyScreen()
elseif ev[1] == "peripheral" or ev[1] == "peripheral_detach" or ev[1] == "monitor_resize" or ev[1] == "config_updated" then
pcall(drawReadyScreen)
elseif ev[1] == "timer" then
pcall(drawReadyScreen)
end
end
end
local function startupRedrawTask()
local count = 0
while count < 3 do
sleep(0.5)
pcall(drawReadyScreen)
count = count + 1
end
while true do
sleep(600)
end
end
local function checkAndApplyUpdate()
local serverBase = guessBaseFromStatusURL(serverURL)
local txt = fetchHTTP(serverBase .. '/api/check-update?_t=' .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999)))
@@ -1521,6 +1844,7 @@ end
local function backgroundSyncTask()
pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end)
pcall(function() refreshStationNameMap(guessBaseFromStatusURL(serverURL)) end)
while true do
sleep(VERSION_CHECK_INTERVAL)
pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end)
@@ -1528,4 +1852,4 @@ local function backgroundSyncTask()
end
end
parallel.waitForAny(mainLoop, backgroundSyncTask)
parallel.waitForAny(mainLoop, startupRedrawTask, backgroundSyncTask)
+1 -8
View File
@@ -1,11 +1,4 @@
--[[
Author: HenryDu8133 813367384@qq.com
Date: 2026-06-28 12:58:42
LastEditors: HenryDu8133 813367384@qq.com
LastEditTime: 2026-07-11 00:02:33
FilePath: \TicketMachine\install_ticketmachine.lua
Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
--]]
local URL_MACHINE_HTTP = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/ticketmachine.lua"
local URL_UPDATE_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_machine.lua"
+2 -1
View File
@@ -6,7 +6,8 @@ module.exports = {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
},
transports: ['websocket']
});
io.engine.on('connection_error', (err) => {
+23
View File
@@ -1191,6 +1191,29 @@ router.get('/export', (req, res) => {
res.json(DataService.buildExportPayload());
});
// Issues
router.post('/report_issue', async (req, res) => {
const r = req.body || {};
const item = {
ts: Number(r.ts || Date.now()),
device: String(r.device || 'unknown'),
machine_id: String(r.machine_id || ''),
station_code: String(r.station_code || ''),
error_msg: String(r.error_msg || ''),
detail: String(r.detail || ''),
type: 'issue'
};
await DataService.appendIssue(item);
io.emit('issue:new', item);
appendReqLog(req, { category: 'device', type: 'report_issue', detail: item });
res.json({ ok: true });
});
router.get('/issues', (req, res) => {
const issues = DataService.getIssues();
res.json({ ok: true, issues });
});
// Ticket Operations
router.post('/tickets/sale', async (req, res) => {
const { start, terminal, train_type, cost, station_code, device, trips_total, trips_remaining } = req.body || {};
+22 -2
View File
@@ -8,6 +8,7 @@ const router = express.Router();
const ASSETS_DIR = path.join(__dirname, '../../Assets');
const MANIFEST_PATH = path.join(ASSETS_DIR, 'manifest.json');
const ROUTE_MAP_LICENSE = 'CC BY 4.0';
const getIp = (req) => (req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || req.connection?.remoteAddress || '';
@@ -48,6 +49,8 @@ const writeManifest = (m) => {
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(out, null, 2), 'utf8');
};
const normalizeCredit = (value) => String(value || '').trim().slice(0, 200);
const removeByPrefix = (prefix) => {
ensureDir(ASSETS_DIR);
const list = fs.readdirSync(ASSETS_DIR);
@@ -97,6 +100,8 @@ router.get('/manifest', (req, res) => {
res.json({
ok: true,
routeMap,
routeMapCredit: normalizeCredit(m.routeMapCredit || ''),
routeMapLicense: ROUTE_MAP_LICENSE,
fareTable,
updatedAt: m.updatedAt || null
});
@@ -106,10 +111,24 @@ router.post('/route-map', uploadRouteMap.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ ok: false, error: '缺少文件' });
const m = readManifest();
m.routeMap = path.basename(req.file.filename);
if (req.body && Object.prototype.hasOwnProperty.call(req.body, 'credit')) {
m.routeMapCredit = normalizeCredit(req.body.credit);
} else if (typeof m.routeMapCredit !== 'string') {
m.routeMapCredit = '';
}
m.updatedAt = Date.now();
writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap } });
res.json({ ok: true, routeMap: m.routeMap, updatedAt: m.updatedAt });
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap, credit: m.routeMapCredit || '' } });
res.json({ ok: true, routeMap: m.routeMap, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
});
router.post('/route-map/credit', (req, res) => {
const m = readManifest();
m.routeMapCredit = normalizeCredit(req.body?.credit);
m.updatedAt = Date.now();
writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_update_route_map_credit', detail: { credit: m.routeMapCredit || '' } });
res.json({ ok: true, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
});
router.post('/fare-table', uploadFareTable.single('file'), (req, res) => {
@@ -129,6 +148,7 @@ router.delete('/route-map', (req, res) => {
}
removeByPrefix('route-map');
m.routeMap = null;
m.routeMapCredit = '';
m.updatedAt = Date.now();
writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_delete_route_map', detail: { ok: true } });
+15 -1
View File
@@ -64,7 +64,8 @@ const cache = {
icCardIndex: {},
icCardEvents: [],
statsTicket: [],
statsGate: []
statsGate: [],
issues: []
};
const DataService = {
@@ -88,6 +89,7 @@ const DataService = {
await conn.query(`CREATE TABLE IF NOT EXISTS ticket_events (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
await conn.query(`CREATE TABLE IF NOT EXISTS stats_ticket (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
await conn.query(`CREATE TABLE IF NOT EXISTS stats_gate (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
await conn.query(`CREATE TABLE IF NOT EXISTS issues (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
// Load Cache
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
@@ -126,6 +128,9 @@ const DataService = {
const [statsG] = await conn.query('SELECT data FROM stats_gate');
cache.statsGate = statsG.map(r => r.data);
const [issues] = await conn.query('SELECT data FROM issues');
cache.issues = issues.map(r => r.data);
console.log('DataService initialized with MySQL');
} finally {
conn.release();
@@ -511,6 +516,15 @@ const DataService = {
},
getStatsGate: () => cache.statsGate,
// Issues
appendIssue: async (item) => {
cache.issues.push(item);
try {
await pool.query('INSERT INTO issues (data) VALUES (?)', [JSON.stringify(item)]);
} catch(e) { console.error('appendIssue error', e); }
},
getIssues: () => cache.issues,
// Export
buildExportPayload: () => ({
config: DataService.getConfig(),
+176 -706
View File
@@ -1,10 +1,3 @@
const fs = require('fs');
const path = require('path');
const DEFAULT_VIEWPORT = { width: 1440, height: 920 };
const DEFAULT_LINE_COLOR = '#93a2b7';
const REFERENCE_SVG_PATH = path.resolve(__dirname, '..', '..', 'FSE铁路线路图.svg');
const escapeHTML = (s) => String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
@@ -12,738 +5,215 @@ const escapeHTML = (s) => String(s || '')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const round = (value, step = 2) => Math.round(Number(value || 0) / step) * step;
const toCode = (value) => String(value || '').trim();
const normalizeName = (value) => String(value || '').trim().toLowerCase().replace(/\s+/g, '');
const parseNumber = (value) => {
const num = Number(value);
return Number.isFinite(num) ? num : null;
};
const attrRegex = /([\w:-]+)="([^"]*)"/g;
function parseAttributes(raw) {
const attrs = {};
if (!raw) return attrs;
for (const match of raw.matchAll(attrRegex)) {
attrs[match[1]] = match[2];
}
return attrs;
}
function parseReferenceSvgMeta() {
try {
if (!fs.existsSync(REFERENCE_SVG_PATH)) return { ...DEFAULT_VIEWPORT, anchorPoints: [] };
const raw = fs.readFileSync(REFERENCE_SVG_PATH, 'utf8');
const viewBoxMatch = raw.match(/viewBox="([\d.\s-]+)"/i);
const widthMatch = raw.match(/\bwidth="([\d.]+)"/i);
const heightMatch = raw.match(/\bheight="([\d.]+)"/i);
let width = DEFAULT_VIEWPORT.width;
let height = DEFAULT_VIEWPORT.height;
if (viewBoxMatch) {
const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number);
if (parts.length === 4 && Number.isFinite(parts[2]) && Number.isFinite(parts[3])) {
width = parts[2];
height = parts[3];
function generate(stations, lines, fares, nameByCode, transfers) {
const enNameByCode = (() => {
try {
const map = {};
for (const s of (stations || [])) {
map[String(s.code || '')] = s.en_name || s.enName || '';
}
} else {
width = parseNumber(widthMatch?.[1]) || width;
height = parseNumber(heightMatch?.[1]) || height;
return map;
} catch (_) {
return {};
}
})();
const circles = [];
for (const match of raw.matchAll(/<circle\b([^>]*)\/?>/gi)) {
const attrs = parseAttributes(match[1]);
const cx = parseNumber(attrs.cx);
const cy = parseNumber(attrs.cy);
const r = parseNumber(attrs.r);
if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(r)) continue;
circles.push({
cx,
cy,
r,
fill: attrs.fill || '',
stroke: attrs.stroke || '',
strokeWidth: parseNumber(attrs['stroke-width']) || 0
});
const linesForStation = {};
(lines || []).forEach((li) => {
const id = li.id || '';
const name = li.name || li.cn_name || '';
const color = li.color || '#93a2b7';
const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map((x) => String(x));
for (const code of arr) {
if (!linesForStation[code]) linesForStation[code] = [];
linesForStation[code].push({ id, name, color, stations: arr });
}
const anchorCandidates = circles.filter((circle) => {
if (circle.r < 4) return false;
if (!circle.stroke || circle.stroke.toLowerCase() === 'none') return false;
return true;
});
const clusters = [];
for (const circle of anchorCandidates) {
let target = null;
for (const cluster of clusters) {
const dx = cluster.x - circle.cx;
const dy = cluster.y - circle.cy;
const distance = Math.sqrt((dx * dx) + (dy * dy));
if (distance <= 18) {
target = cluster;
break;
}
}
if (!target) {
target = { x: circle.cx, y: circle.cy, count: 0 };
clusters.push(target);
}
target.count += 1;
target.x += (circle.cx - target.x) / target.count;
target.y += (circle.cy - target.y) / target.count;
}
const anchorPoints = clusters
.sort((a, b) => (a.y - b.y) || (a.x - b.x))
.map((point) => ({ x: point.x, y: point.y }));
return { width, height, anchorPoints };
} catch (_) {
return { ...DEFAULT_VIEWPORT, anchorPoints: [] };
}
}
function createUnionFind() {
const parent = new Map();
const find = (value) => {
if (!parent.has(value)) parent.set(value, value);
const root = parent.get(value);
if (root !== value) {
const collapsed = find(root);
parent.set(value, collapsed);
return collapsed;
}
return root;
};
const union = (a, b) => {
if (!a || !b) return;
const ra = find(a);
const rb = find(b);
if (ra !== rb) parent.set(rb, ra);
};
return { parent, find, union };
}
function buildTransferGroups(stations, transfers) {
const uf = createUnionFind();
const codesByName = new Map();
for (const station of (stations || [])) {
const code = toCode(station?.code);
if (!code) continue;
uf.find(code);
const nameKey = `${normalizeName(station?.name || station?.cn_name || station?.station_name)}|${normalizeName(station?.en_name || station?.enName || station?.en)}`;
if (nameKey !== '|') {
if (!codesByName.has(nameKey)) codesByName.set(nameKey, []);
codesByName.get(nameKey).push(code);
}
}
for (const station of (stations || [])) {
const from = toCode(station?.code);
if (!from) continue;
const transferTo = Array.isArray(station?.transfer_to) ? station.transfer_to : [];
for (const item of transferTo) {
const to = toCode((typeof item === 'string') ? item : (item?.code || item?.station || item?.id || item?.[0]));
if (to) uf.union(from, to);
}
}
for (const pair of (transfers || [])) {
const from = toCode(pair?.[0]);
const to = toCode(pair?.[1]);
if (from && to) uf.union(from, to);
}
for (const group of codesByName.values()) {
if (!Array.isArray(group) || group.length < 2) continue;
for (let index = 1; index < group.length; index += 1) {
uf.union(group[0], group[index]);
}
}
const codesByCanonical = {};
const canonicalByCode = {};
for (const station of (stations || [])) {
const code = toCode(station?.code);
if (!code) continue;
const canonical = uf.find(code);
if (!codesByCanonical[canonical]) codesByCanonical[canonical] = [];
codesByCanonical[canonical].push(code);
}
for (const root of Object.keys(codesByCanonical)) {
const sortedCodes = Array.from(new Set(codesByCanonical[root])).sort((a, b) => a.localeCompare(b));
const canonical = sortedCodes[0];
delete codesByCanonical[root];
codesByCanonical[canonical] = sortedCodes;
for (const code of sortedCodes) canonicalByCode[code] = canonical;
}
return { canonicalByCode, codesByCanonical };
}
function buildNodes(stations, nameByCode, transferGroups) {
const nodes = new Map();
for (const station of (stations || [])) {
const code = toCode(station?.code);
if (!code) continue;
const canonical = transferGroups.canonicalByCode[code] || code;
const name = String(
station?.name
|| station?.cn_name
|| station?.station_name
|| nameByCode?.[code]
|| code
);
const enName = String(station?.en_name || station?.enName || station?.en || '');
const svgX = parseNumber(station?.svg_x);
const svgY = parseNumber(station?.svg_y);
if (!nodes.has(canonical)) {
nodes.set(canonical, {
canonical,
codes: [],
primaryCode: code,
name,
enName,
lines: new Set(),
explicitPositions: [],
explicitLineIndices: [],
isTransfer: false
});
}
const node = nodes.get(canonical);
node.codes.push(code);
if (!node.name && name) node.name = name;
if (!node.enName && enName) node.enName = enName;
if (Number.isFinite(svgX) && Number.isFinite(svgY)) {
node.explicitPositions.push({ x: svgX, y: svgY });
}
}
return nodes;
}
function buildLineSegments(lines, transferGroups, nodeMap) {
const segments = [];
const uniqueEdgeSet = new Set();
(lines || []).forEach((line, lineIndex) => {
const color = line?.color || DEFAULT_LINE_COLOR;
const lineId = String(line?.id || `line-${lineIndex + 1}`);
const name = String(line?.name || line?.cn_name || line?.线路名称 || lineId);
const enName = String(line?.en_name || line?.英文名称 || line?.英文名 || '');
const stopsRaw = Array.isArray(line?.stations)
? line.stations
: (Array.isArray(line?.stops) ? line.stops : (Array.isArray(line?.站点列表) ? line.站点列表 : []));
let current = [];
const commitSegment = () => {
const canonicalStops = [];
for (const stop of current) {
const code = toCode(stop);
if (!code || code === '-') continue;
const canonical = transferGroups.canonicalByCode[code] || code;
if (canonicalStops[canonicalStops.length - 1] !== canonical) canonicalStops.push(canonical);
const node = nodeMap.get(canonical);
if (node) {
node.lines.add(lineId);
node.explicitLineIndices.push(lineIndex);
}
}
if (canonicalStops.length < 2) {
current = [];
return;
}
segments.push({ id: lineId, name, enName, color, lineIndex, stops: canonicalStops });
for (let index = 0; index < canonicalStops.length - 1; index += 1) {
const a = canonicalStops[index];
const b = canonicalStops[index + 1];
uniqueEdgeSet.add([a, b].sort().join('|'));
}
current = [];
};
for (const stop of stopsRaw) {
if (toCode(stop) === '-') {
commitSegment();
} else {
current.push(stop);
}
}
commitSegment();
});
return {
segments,
edges: Array.from(uniqueEdgeSet).map((key) => {
const [from, to] = key.split('|');
return { from, to };
})
};
}
const groupsMap = {};
(stations || []).forEach((s) => {
const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim();
if (!k) return;
if (!groupsMap[k]) groupsMap[k] = [];
groupsMap[k].push(String(s.code || ''));
});
function buildSeedLayout(nodeMap, lineSegments, referenceMeta) {
const nodes = Array.from(nodeMap.values());
const baseX = 140;
const baseY = 120;
const gapX = 96;
const gapY = 92;
const seed = {};
const groupsForColors = Object.values(groupsMap).filter((arr) => arr.length > 1);
const colorsForRoute = (a, b) => {
const la = linesForStation[a] || [];
const lb = linesForStation[b] || [];
const same = la.find((x) => lb.some((y) => y.id === x.id));
if (same) return [same.color || '#93a2b7'];
const hasExplicitPositions = nodes.some((node) => node.explicitPositions.length > 0);
if (hasExplicitPositions) {
for (const node of nodes) {
if (node.explicitPositions.length > 0) {
const x = node.explicitPositions.reduce((sum, pos) => sum + pos.x, 0) / node.explicitPositions.length;
const y = node.explicitPositions.reduce((sum, pos) => sum + pos.y, 0) / node.explicitPositions.length;
seed[node.canonical] = { x, y, locked: true };
for (const g of groupsForColors) {
const startLine = la.find((li) => (li.stations || []).some((code) => g.includes(code)));
const endLine = lb.find((li) => (li.stations || []).some((code) => g.includes(code)));
if (startLine && endLine) {
const c1 = startLine.color || '#93a2b7';
const c2 = endLine.color || '#93a2b7';
return (startLine.id === endLine.id) ? [c1] : [c1, c2];
}
}
}
return [];
};
const occurrenceMap = {};
for (const segment of lineSegments.segments) {
segment.stops.forEach((canonical, stopIndex) => {
if (!occurrenceMap[canonical]) occurrenceMap[canonical] = [];
occurrenceMap[canonical].push({
x: baseX + (stopIndex * gapX),
y: baseY + (segment.lineIndex * gapY)
});
});
}
let fallbackIndex = 0;
const fallbackCols = Math.max(4, Math.ceil(Math.sqrt(Math.max(nodes.length, 1))));
for (const node of nodes) {
if (!seed[node.canonical] && occurrenceMap[node.canonical]?.length) {
const samples = occurrenceMap[node.canonical];
seed[node.canonical] = {
x: samples.reduce((sum, item) => sum + item.x, 0) / samples.length,
y: samples.reduce((sum, item) => sum + item.y, 0) / samples.length,
locked: false
};
}
if (!seed[node.canonical]) {
seed[node.canonical] = {
x: baseX + ((fallbackIndex % fallbackCols) * gapX),
y: baseY + (Math.floor(fallbackIndex / fallbackCols) * gapY),
locked: false
};
fallbackIndex += 1;
}
}
const anchorPoints = Array.isArray(referenceMeta?.anchorPoints) ? referenceMeta.anchorPoints : [];
if (!hasExplicitPositions && anchorPoints.length >= nodes.length * 0.6) {
const nodeOrder = nodes
.map((node) => ({ node, seed: seed[node.canonical] }))
.sort((a, b) => (a.seed.y - b.seed.y) || (a.seed.x - b.seed.x));
const anchors = anchorPoints
.slice()
.sort((a, b) => (a.y - b.y) || (a.x - b.x));
const limit = Math.min(nodeOrder.length, anchors.length);
for (let index = 0; index < limit; index += 1) {
const { node } = nodeOrder[index];
const anchor = anchors[index];
seed[node.canonical] = {
x: (seed[node.canonical].x * 0.45) + (anchor.x * 0.55),
y: (seed[node.canonical].y * 0.45) + (anchor.y * 0.55),
locked: false
};
}
}
return seed;
}
function optimizeLayout(nodeMap, lineSegments, referenceMeta) {
const nodes = Array.from(nodeMap.values());
const seed = buildSeedLayout(nodeMap, lineSegments, referenceMeta);
const positions = {};
const velocities = {};
const neighbors = new Map();
for (const node of nodes) {
positions[node.canonical] = {
x: seed[node.canonical]?.x || 0,
y: seed[node.canonical]?.y || 0
const buildGraph = () => {
const adj = new Map();
const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); };
const addEdge = (u, v, w) => {
addNode(u);
addNode(v);
adj.get(u).set(v, w);
adj.get(v).set(u, w);
};
velocities[node.canonical] = { x: 0, y: 0 };
neighbors.set(node.canonical, new Set());
}
for (const edge of lineSegments.edges) {
neighbors.get(edge.from)?.add(edge.to);
neighbors.get(edge.to)?.add(edge.from);
}
const validStationCodes = new Set((stations || []).map((s) => String(s.code || '')));
const iterations = 450;
const repulsionBase = 45000;
const springLength = 120;
const springStrength = 0.015;
const bendStrength = 0.025;
const anchorStrength = 0.02;
const damping = 0.82;
(fares || []).forEach((f) => {
const a = String(f.from || '');
const b = String(f.to || '');
if (!a || !b) return;
if (!validStationCodes.has(a) || !validStationCodes.has(b)) return;
for (let iteration = 0; iteration < iterations; iteration += 1) {
const alpha = 1 - (iteration / iterations);
const forces = {};
for (const node of nodes) forces[node.canonical] = { x: 0, y: 0 };
const w = Number(f.cost_regular ?? f.cost) || 0;
addEdge(a, b, w);
});
// 1. Repulsion to untangle graph
for (let i = 0; i < nodes.length; i += 1) {
const a = nodes[i];
const pa = positions[a.canonical];
for (let j = i + 1; j < nodes.length; j += 1) {
const b = nodes[j];
const pb = positions[b.canonical];
let dx = pa.x - pb.x;
let dy = pa.y - pb.y;
let distanceSq = (dx * dx) + (dy * dy);
if (distanceSq < 1) {
dx = 0.5 - Math.random();
dy = 0.5 - Math.random();
distanceSq = (dx * dx) + (dy * dy);
if (Array.isArray(transfers)) {
transfers.forEach((p) => {
const u = String(p[0] || '');
const v = String(p[1] || '');
if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) {
addEdge(u, v, 0);
}
const distance = Math.sqrt(distanceSq);
const scale = (repulsionBase * alpha) / distanceSq;
const fx = (dx / distance) * scale;
const fy = (dy / distance) * scale;
forces[a.canonical].x += fx;
forces[a.canonical].y += fy;
forces[b.canonical].x -= fx;
forces[b.canonical].y -= fy;
}
});
}
// 2. Spring forces for edges & bend smoothing
for (const segment of lineSegments.segments) {
for (let index = 0; index < segment.stops.length - 1; index += 1) {
const from = segment.stops[index];
const to = segment.stops[index + 1];
const pa = positions[from];
const pb = positions[to];
let dx = pb.x - pa.x;
let dy = pb.y - pa.y;
let distance = Math.sqrt((dx * dx) + (dy * dy));
if (distance < 1) distance = 1;
const delta = distance - springLength;
const force = delta * springStrength;
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
forces[from].x += fx;
forces[from].y += fy;
forces[to].x -= fx;
forces[to].y -= fy;
}
for (let index = 1; index < segment.stops.length - 1; index += 1) {
const prev = positions[segment.stops[index - 1]];
const current = positions[segment.stops[index]];
const next = positions[segment.stops[index + 1]];
const targetX = (prev.x + next.x) / 2;
const targetY = (prev.y + next.y) / 2;
forces[segment.stops[index]].x += (targetX - current.x) * bendStrength;
forces[segment.stops[index]].y += (targetY - current.y) * bendStrength;
}
}
// 3. Orthogonal snapping force (Octilinear bias)
const orthoStrength = 0.03 * alpha;
for (const edge of lineSegments.edges) {
const pa = positions[edge.from];
const pb = positions[edge.to];
const dx = pb.x - pa.x;
const dy = pb.y - pa.y;
const adx = Math.abs(dx);
const ady = Math.abs(dy);
if (adx < ady && adx < ady * 0.4) {
// pull towards vertical
forces[edge.from].x += dx * orthoStrength;
forces[edge.to].x -= dx * orthoStrength;
} else if (ady < adx && ady < adx * 0.4) {
// pull towards horizontal
forces[edge.from].y += dy * orthoStrength;
forces[edge.to].y -= dy * orthoStrength;
} else {
// pull towards diagonal (45 degrees)
const avg = (adx + ady) / 2;
const targetDx = avg * Math.sign(dx || 1);
const targetDy = avg * Math.sign(dy || 1);
forces[edge.from].x += (dx - targetDx) * orthoStrength;
forces[edge.to].x -= (dx - targetDx) * orthoStrength;
forces[edge.from].y += (dy - targetDy) * orthoStrength;
forces[edge.to].y -= (dy - targetDy) * orthoStrength;
}
}
// 4. Anchor forces
for (const node of nodes) {
const anchor = seed[node.canonical];
if (!anchor) continue;
const strength = anchor.locked ? 0.2 : anchorStrength;
forces[node.canonical].x += (anchor.x - positions[node.canonical].x) * strength;
forces[node.canonical].y += (anchor.y - positions[node.canonical].y) * strength;
}
// 5. Update velocities and positions
for (const node of nodes) {
const velocity = velocities[node.canonical];
const force = forces[node.canonical];
velocity.x = (velocity.x + (force.x * 0.6)) * damping;
velocity.y = (velocity.y + (force.y * 0.6)) * damping;
positions[node.canonical].x += velocity.x;
positions[node.canonical].y += velocity.y;
}
}
// Final alignment to Grid to ensure perfect octilinear rendering
const xs = nodes.map((node) => positions[node.canonical].x);
const ys = nodes.map((node) => positions[node.canonical].y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const sourceWidth = Math.max(maxX - minX, 1);
const sourceHeight = Math.max(maxY - minY, 1);
const targetWidth = Math.max((referenceMeta?.width || DEFAULT_VIEWPORT.width) - 260, 640);
const targetHeight = Math.max((referenceMeta?.height || DEFAULT_VIEWPORT.height) - 220, 420);
const scale = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight);
const offsetX = 130;
const offsetY = 110;
const GRID_SIZE = 24;
for (const node of nodes) {
const point = positions[node.canonical];
let rawX = ((point.x - minX) * scale) + offsetX;
let rawY = ((point.y - minY) * scale) + offsetY;
// Snap to grid
point.x = Math.round(rawX / GRID_SIZE) * GRID_SIZE;
point.y = Math.round(rawY / GRID_SIZE) * GRID_SIZE;
}
return positions;
}
function buildLinePath(points) {
if (!Array.isArray(points) || points.length === 0) return '';
let d = `M ${points[0].x} ${points[0].y}`;
for (let index = 1; index < points.length; index += 1) {
const p1 = points[index - 1];
const p2 = points[index];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const adx = Math.abs(dx);
const ady = Math.abs(dy);
if (adx === 0 || ady === 0 || Math.abs(adx - ady) < 2) {
// Already orthogonal or 45-degree diagonal
d += ` L ${p2.x} ${p2.y}`;
} else {
// Need an octilinear chamfer (a single bend)
const signX = Math.sign(dx);
const signY = Math.sign(dy);
if (adx > ady) {
// Horizontal is longer: straight horizontal, then 45-degree
const hDist = adx - ady;
const midX = p1.x + hDist * signX;
const midY = p1.y;
d += ` L ${midX} ${midY} L ${p2.x} ${p2.y}`;
} else {
// Vertical is longer: straight vertical, then 45-degree
const vDist = ady - adx;
const midX = p1.x;
const midY = p1.y + vDist * signY;
d += ` L ${midX} ${midY} L ${p2.x} ${p2.y}`;
}
}
}
return d;
}
function getContrastColor(hex) {
if (!hex) return '#ffffff';
let value = String(hex).replace('#', '');
if (value.length === 3) value = value.split('').map((item) => item + item).join('');
const r = parseInt(value.slice(0, 2), 16) || 0;
const g = parseInt(value.slice(2, 4), 16) || 0;
const b = parseInt(value.slice(4, 6), 16) || 0;
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
return yiq >= 140 ? '#0f172a' : '#ffffff';
}
function piePath(cx, cy, r, a0, a1) {
const x0 = cx + (r * Math.cos(a0));
const y0 = cy + (r * Math.sin(a0));
const x1 = cx + (r * Math.cos(a1));
const y1 = cy + (r * Math.sin(a1));
const largeArc = (a1 - a0) > Math.PI ? 1 : 0;
return `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${largeArc} 1 ${x1} ${y1} Z`;
}
function pieSvg(cx, cy, r, colors) {
const palette = Array.from(new Set((Array.isArray(colors) ? colors : []).filter(Boolean))).slice(0, 4);
if (palette.length === 0) return '';
if (palette.length === 1) return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${escapeHTML(palette[0])}" />`;
const step = (Math.PI * 2) / palette.length;
let output = '';
for (let index = 0; index < palette.length; index += 1) {
const a0 = (-Math.PI / 2) + (index * step);
const a1 = a0 + step;
output += `<path d="${piePath(cx, cy, r, a0, a1)}" fill="${escapeHTML(palette[index])}" />`;
}
return output;
}
function renderStation(node, point, lineColorMap) {
const colors = Array.from(node.lines)
.map((lineId) => lineColorMap.get(lineId) || DEFAULT_LINE_COLOR)
.filter(Boolean);
const uniqueColors = Array.from(new Set(colors));
const isTransfer = node.isTransfer || uniqueColors.length >= 2 || node.codes.length >= 2;
const code = escapeHTML(node.primaryCode);
const name = escapeHTML(node.name || node.primaryCode);
const enName = escapeHTML(node.enName || '');
const x = point.x;
const y = point.y;
const codeFontSize = 9;
const nameFontSize = 13;
const enFontSize = 8.5;
const codeWidth = Math.max((node.primaryCode || '').length * 6 + 10, 28);
const preferTop = isTransfer;
let markerSvg = '';
if (isTransfer) {
markerSvg += `<circle cx="${x}" cy="${y}" r="15" fill="#ffffff" stroke="#1f2937" stroke-width="2.5" />`;
markerSvg += pieSvg(x, y, 11.5, uniqueColors);
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="6.5" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
} else {
const color = uniqueColors[0] || DEFAULT_LINE_COLOR;
markerSvg += `<circle cx="${x}" cy="${y}" r="12.5" fill="#ffffff" stroke="${escapeHTML(color)}" stroke-width="5" />`;
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="7" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
}
let textSvg = '';
if (preferTop) {
const baseY = y - 14;
const enY = baseY;
const nameY = enName ? (baseY - 12) : baseY;
const codeY = nameY - 16;
textSvg += `<rect x="${x - (codeWidth / 2)}" y="${codeY - 8}" width="${codeWidth}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
textSvg += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${code}</text>`;
textSvg += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="4" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
if (enName) {
textSvg += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
}
} else {
const topY = y + 24;
const codeY = topY;
const nameY = topY + 16;
const enY = nameY + 12;
textSvg += `<rect x="${x - (codeWidth / 2)}" y="${codeY - 8}" width="${codeWidth}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
textSvg += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${code}</text>`;
textSvg += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="4" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
if (enName) {
textSvg += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
}
}
return `
<g class="map-station ${isTransfer ? 'transfer' : ''}" data-code="${code}" data-canonical="${escapeHTML(node.canonical)}" onclick="handleStationClick('${code}')" style="cursor:pointer">
<rect x="${x - 22}" y="${y - 22}" width="44" height="44" fill="transparent" />
${markerSvg}
${textSvg}
</g>
`;
}
function renderLineLabels(segment, points) {
if (!Array.isArray(points) || points.length < 2) return '';
const color = segment.color || DEFAULT_LINE_COLOR;
const textColor = getContrastColor(color);
const subTextColor = (textColor === '#ffffff') ? 'rgba(255,255,255,0.9)' : 'rgba(15,23,42,0.8)';
const labelWidth = Math.max(String(segment.name || '').length * 14 + 20, String(segment.enName || '').length * 7 + 20, 80);
const height = segment.enName ? 36 : 24;
const build = (point, alignLeft) => {
const x = alignLeft ? point.x - 54 - labelWidth : point.x + 54;
const y = point.y - (height / 2);
const textX = x + (labelWidth / 2);
let output = `<rect x="${x}" y="${y}" width="${labelWidth}" height="${height}" rx="6" fill="${escapeHTML(color)}" />`;
if (segment.enName) {
output += `<text x="${textX}" y="${y + 15}" text-anchor="middle" fill="${textColor}" font-size="12" font-weight="bold">${escapeHTML(segment.name)}</text>`;
output += `<text x="${textX}" y="${y + 28}" text-anchor="middle" fill="${subTextColor}" font-size="9" font-weight="600">${escapeHTML(segment.enName)}</text>`;
} else {
output += `<text x="${textX}" y="${y + 16}" text-anchor="middle" fill="${textColor}" font-size="12" font-weight="bold">${escapeHTML(segment.name)}</text>`;
}
return output;
(stations || []).forEach((s) => {
const c = String(s.code || '');
if (c) addNode(c);
});
return adj;
};
return build(points[0], true) + build(points[points.length - 1], false);
}
const adj = buildGraph();
const codes = Array.from(adj.keys()).sort((a, b) => a.localeCompare(b));
const neighborsOf = (u) => adj.get(u) || new Map();
function calculateBounds(positions) {
const items = Object.values(positions || {});
if (!items.length) return { minX: 0, minY: 0, maxX: DEFAULT_VIEWPORT.width, maxY: DEFAULT_VIEWPORT.height };
return {
minX: Math.min(...items.map((item) => item.x)),
minY: Math.min(...items.map((item) => item.y)),
maxX: Math.max(...items.map((item) => item.x)),
maxY: Math.max(...items.map((item) => item.y))
const dijkstra = (src) => {
const dist = {};
const visited = new Set();
for (const n of adj.keys()) dist[n] = Infinity;
dist[src] = 0;
while (true) {
let u = null;
let best = Infinity;
for (const n of adj.keys()) {
if (!visited.has(n) && dist[n] < best) {
best = dist[n];
u = n;
}
}
if (u === null) break;
visited.add(u);
for (const [v, w] of neighborsOf(u)) {
const nd = dist[u] + Number(w || 0);
if (nd < dist[v]) dist[v] = nd;
}
}
return dist;
};
}
function generate(stations, lines, fares, nameByCode, transfers) {
const referenceMeta = parseReferenceSvgMeta();
const transferGroups = buildTransferGroups(stations, transfers);
const nodeMap = buildNodes(stations, nameByCode || {}, transferGroups);
const lineSegments = buildLineSegments(lines, transferGroups, nodeMap);
const distAll = {};
codes.forEach((c) => {
distAll[c] = dijkstra(c);
});
for (const node of nodeMap.values()) {
node.codes = Array.from(new Set(node.codes)).sort((a, b) => a.localeCompare(b));
node.primaryCode = node.codes[0] || node.primaryCode;
node.isTransfer = node.codes.length >= 2 || node.lines.size >= 2;
let maxFare = 1;
for (const r of codes) {
for (const c of codes) {
if (r === c) continue;
const v = Number(distAll[r][c] || 0);
if (v > maxFare) maxFare = v;
}
}
if (!nodeMap.size) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${DEFAULT_VIEWPORT.width}" height="${DEFAULT_VIEWPORT.height}" viewBox="0 0 ${DEFAULT_VIEWPORT.width} ${DEFAULT_VIEWPORT.height}" style="background:#ffffff"><text x="80" y="120" fill="#64748b" font-size="18" font-family="Inter, system-ui, sans-serif">暂无可显示的站点数据</text></svg>`;
}
const positions = optimizeLayout(nodeMap, lineSegments, referenceMeta);
const bounds = calculateBounds(positions);
const width = Math.max(referenceMeta.width || DEFAULT_VIEWPORT.width, Math.ceil(bounds.maxX + 180));
const height = Math.max(referenceMeta.height || DEFAULT_VIEWPORT.height, Math.ceil(bounds.maxY + 160));
const lineColorMap = new Map();
(lineSegments.segments || []).forEach((segment) => lineColorMap.set(segment.id, segment.color || DEFAULT_LINE_COLOR));
const margin = 8;
const cellW = 56;
const cellH = 40;
const estimateWidth = (text, size) => Math.ceil(String(text || '').length * (size <= 10 ? 7 : 10));
const maxCnW = Math.max(1, ...codes.map((c) => estimateWidth(nameByCode[c] || c, 14)));
const maxEnW = Math.max(1, ...codes.map((c) => estimateWidth(enNameByCode[c] || '', 10)));
const maxCodeW = Math.max(1, ...codes.map((c) => estimateWidth(c, 10)));
const leftW = Math.max(140, 24 + Math.max(maxCnW, maxEnW, maxCodeW));
const topH = 64;
const n = codes.length;
const width = margin + leftW + n * cellW + margin;
const height = margin + topH + n * cellH + margin;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:#ffffff;font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif">`;
svg += `<metadata>generated-by=svg-generator; mode=network-map; reference-svg=${escapeHTML(path.basename(REFERENCE_SVG_PATH))}; stations=${nodeMap.size}; lineSegments=${lineSegments.segments.length}; fares=${Array.isArray(fares) ? fares.length : 0}</metadata>`;
svg += `<rect width="${width}" height="${height}" fill="#ffffff" />`;
for (const segment of lineSegments.segments) {
const points = segment.stops.map((canonical) => positions[canonical]).filter(Boolean);
if (points.length < 2) continue;
const d = buildLinePath(points);
svg += `<path d="${d}" fill="none" stroke="${escapeHTML(segment.color || DEFAULT_LINE_COLOR)}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
svg += renderLineLabels(segment, points);
for (let j = 0; j < n; j++) {
const code = codes[j];
const name = nameByCode[code] || code;
const en = enNameByCode[code] || '';
const x = margin + leftW + j * cellW + cellW / 2;
const y = margin + 20;
svg += `<text x="${x}" y="${y}" font-size="14" font-weight="900" fill="#000000" text-anchor="middle">${escapeHTML(name)}</text>`;
if (en) svg += `<text x="${x}" y="${y + 14}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(en)}</text>`;
svg += `<text x="${x}" y="${y + 26}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(code)}</text>`;
}
const orderedNodes = Array.from(nodeMap.values()).sort((a, b) => {
const pa = positions[a.canonical];
const pb = positions[b.canonical];
return (pa.x - pb.x) || (pa.y - pb.y);
});
for (let i = 0; i < n; i++) {
const code = codes[i];
const name = nameByCode[code] || code;
const en = enNameByCode[code] || '';
const xText = margin + 6;
const baseY = margin + topH + i * cellH + 22;
svg += `<text x="${xText}" y="${baseY - 10}" font-size="14" font-weight="900" fill="#000000">${escapeHTML(name)}</text>`;
if (en) svg += `<text x="${xText}" y="${baseY + 4}" font-size="10" fill="#000000">${escapeHTML(en)}</text>`;
svg += `<text x="${xText}" y="${baseY + 16}" font-size="10" fill="#000000">${escapeHTML(code)}</text>`;
for (const node of orderedNodes) {
const point = positions[node.canonical];
if (!point) continue;
svg += renderStation(node, point, lineColorMap);
for (let j = 0; j < n; j++) {
const x0 = margin + leftW + j * cellW;
const y0 = margin + topH + i * cellH;
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#ffffff" stroke="#e5e7eb"/>`;
if (i === j) {
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" fill="#6b7280" text-anchor="middle">-</text>`;
} else {
const distVal = distAll[code][codes[j]];
if (distVal === Infinity || !Number.isFinite(distVal)) {
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="14" font-weight="800" fill="#ef4444" text-anchor="middle">×</text>`;
} else {
const val = Math.round(Number(distVal || 0));
const routeColors = colorsForRoute(code, codes[j]);
if (routeColors.length > 0) {
const stripW = cellW / routeColors.length;
for (let k = 0; k < routeColors.length; k++) {
const c = routeColors[k] || '#93a2b7';
const stripX = x0 + k * stripW;
svg += `<rect x="${stripX}" y="${y0}" width="${stripW}" height="${cellH}" fill="${escapeHTML(c)}" fill-opacity="0.15" stroke="none"/>`;
}
} else {
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#000000" fill-opacity="0.03" stroke="none"/>`;
}
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>`;
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" font-weight="800" fill="#000000" text-anchor="middle">${escapeHTML(String(val))}</text>`;
const dots = routeColors.slice(0, 4);
for (let k = 0; k < dots.length; k++) {
const cx = x0 + 8 + k * 10;
const cy = y0 + 10;
const dotColor = dots[k] || '#93a2b7';
svg += `<circle cx="${cx}" cy="${cy}" r="3.5" fill="${escapeHTML(dotColor)}" stroke="white" stroke-width="1"/>`;
}
}
}
}
}
svg += `</svg>`;
+425 -123
View File
@@ -1,6 +1,6 @@
local CURRENT_STATION_CODE = 'Ticket-Machine'
local API_BASE = 'https://ticket.fse-media.group/api'
local VERSION = 'v1.5.22'
local VERSION = 'v1.5.30'
local UPDATE_CHECK_INTERVAL = 5
-- ###########################
@@ -327,6 +327,19 @@ local function enqueueTicketUpload(ticketData)
return false
end
local function reportIssue(errMsg, detail)
local url = API_BASE .. '/report_issue'
local payload = {
device = currentDeviceId(),
machine_id = currentDeviceId(),
station_code = state.stationCode or CURRENT_STATION_CODE,
error_msg = tostring(errMsg or ''),
detail = tostring(detail or ''),
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
}
postJSONResult(url, payload)
end
-- ###########################
-- Peripheral discovery
-- ###########################
@@ -562,10 +575,6 @@ local function issueTicketFromPeripheral(fromNameEnArg, toNameEnArg, apiType, ri
if type(dev) ~= 'table' then
return false, '', 'peripheral_unavailable'
end
local fn = dev.issueTicket
if type(fn) ~= 'function' then
return false, '', 'unsupported_method'
end
local function normalizeIssuedTicketId(id)
if id == nil then return '' end
@@ -582,25 +591,107 @@ local function issueTicketFromPeripheral(fromNameEnArg, toNameEnArg, apiType, ri
return s
end
local function tryIssue(...)
local okCall, r1, r2, r3 = pcall(fn, ...)
if not (okCall and peripheralCallSucceeded(r1)) then
return false, '', okCall and 'issue_failed' or 'issue_call_failed'
end
local issuedId = extractPeripheralId(r2, r3, r1, fallbackTicketId)
local normalizedId = normalizeIssuedTicketId(issuedId)
if #normalizedId == 0 then
return false, '', 'invalid_ticket_id'
end
return true, normalizedId, 'issueTicket'
local fallbackId = normalizeIssuedTicketId(fallbackTicketId)
local typeCandidates = { tostring(apiType or 'local') }
if apiType == 'limited_express' then
table.insert(typeCandidates, 'Express')
table.insert(typeCandidates, 'Limited Express')
elseif apiType == 'single' then
table.insert(typeCandidates, 'Single')
else
table.insert(typeCandidates, 'Local')
table.insert(typeCandidates, 'Ordinary')
end
local okIssue, ticketId, issueErr = tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg)
if okIssue then
return true, ticketId, 'issueTicket'
local function buildIssuePayload(typeValue)
return {
ticket_id = fallbackId,
id = fallbackId,
start_station = tostring(startStationArg or ''),
terminal_station = tostring(terminalStationArg or ''),
start_station_id = tostring(startStationArg or ''),
terminal_station_id = tostring(terminalStationArg or ''),
start_name_en = tostring(fromNameEnArg or ''),
terminal_name_en = tostring(toNameEnArg or ''),
start_name = tostring(fromNameCnUArg or ''),
terminal_name = tostring(toNameCnUArg or ''),
fromNameCnU = tostring(fromNameCnUArg or ''),
toNameCnU = tostring(toNameCnUArg or ''),
rides = tonumber(rides) or 1,
trips = tonumber(rides) or 1,
trips_total = tonumber(rides) or 1,
trips_remaining = tonumber(rides) or 1,
cost = tonumber(cost) or 0,
train_type = tostring(apiType or 'local'),
type = tostring(typeValue or apiType or 'local')
}
end
return tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg)
local variants = {}
local seenVariantKey = {}
local function addVariant(methodName, args)
local parts = { methodName }
for i = 1, #args do
parts[#parts + 1] = tostring(args[i])
end
local key = table.concat(parts, '\31')
if not seenVariantKey[key] then
seenVariantKey[key] = true
variants[#variants + 1] = { method = methodName, args = args }
end
end
for _, typeValue in ipairs(typeCandidates) do
local payload = buildIssuePayload(typeValue)
addVariant('issueTicket', {
tostring(fromNameEnArg or ''),
tostring(toNameEnArg or ''),
typeValue,
tonumber(rides) or 1,
tonumber(cost) or 0,
tostring(startStationArg or ''),
tostring(terminalStationArg or ''),
tostring(fromNameCnUArg or ''),
tostring(toNameCnUArg or '')
})
addVariant('issueTicket', {
tostring(fromNameEnArg or ''),
tostring(toNameEnArg or ''),
typeValue,
tonumber(rides) or 1,
tonumber(cost) or 0,
tostring(startStationArg or ''),
tostring(terminalStationArg or '')
})
addVariant('issueTicketData', { payload })
addVariant('writeTicketData', { payload })
end
local lastError = 'unsupported_method'
for _, variant in ipairs(variants) do
local fn = dev[variant.method]
if type(fn) == 'function' then
local okCall, r1, r2, r3 = pcall(fn, table.unpack(variant.args))
if okCall and peripheralCallSucceeded(r1) then
local payloadArg = type(variant.args[1]) == 'table' and variant.args[1] or nil
local issuedId = extractPeripheralId(r2, r3, r1, payloadArg and payloadArg.ticket_id, payloadArg and payloadArg.id, fallbackId)
local normalizedId = normalizeIssuedTicketId(issuedId)
if #normalizedId > 0 then
return true, normalizedId, variant.method
end
lastError = 'invalid_ticket_id'
else
local detail = extractPeripheralId(r1, r2, r3)
if okCall then
lastError = variant.method .. ':' .. tostring(detail ~= '' and detail or 'issue_failed')
else
lastError = variant.method .. ':' .. tostring(r1 or 'issue_call_failed')
end
end
end
end
return false, '', lastError
end
-- ###########################
@@ -990,6 +1081,16 @@ local function nearestCCColor(val)
return colors.gray
end
local function contrastTextColor(bg)
for _, c in ipairs(CC_PALETTE) do
if c.val == bg then
local yiq = (c.rgb[1] * 299 + c.rgb[2] * 587 + c.rgb[3] * 114) / 1000
return yiq >= 140 and colors.black or colors.white
end
end
return colors.white
end
local function clear()
termDev.setBackgroundColor(colors.black)
termDev.clear()
@@ -1024,6 +1125,31 @@ local function addButton(x, y, label, wBtn, hBtn, colorsPair, onClick)
termDev.write(label)
end
local function drawVerticalScrollbar(sbX, startY, endY, offset, maxOffset, step, onUp, onDown)
addButton(sbX, startY - 1, '^', 3, 1, {colors.black, colors.white}, onUp)
addButton(sbX, endY + 1, 'v', 3, 1, {colors.black, colors.white}, onDown)
for py = startY, endY do
termDev.setBackgroundColor(colors.black)
termDev.setCursorPos(sbX, py)
termDev.write(' ')
termDev.setBackgroundColor(colors.gray)
termDev.setCursorPos(sbX, py)
termDev.write(' ')
end
if maxOffset <= 0 then
return
end
local railH = endY - startY + 1
local thumbH = math.max(2, math.floor((railH * railH) / (railH + maxOffset)))
if thumbH > railH then thumbH = railH end
local thumbY = startY + math.floor((railH - thumbH) * (offset / maxOffset))
for py = thumbY, thumbY + thumbH - 1 do
termDev.setBackgroundColor(colors.lightGray)
termDev.setCursorPos(sbX, py)
termDev.write(' ')
end
end
local function inRect(btn, px, py)
return px >= btn.x and px <= (btn.x + btn.w - 1) and py >= btn.y and py <= (btn.y + btn.h - 1)
end
@@ -1055,7 +1181,7 @@ state = {
departure = nil, terminal = nil, trainType = nil, trips = 1, cost = 0, paid = 0, doneAudioPlayed = false,
productMode = 'ticket', cardMode = nil, cardPaymentMode = 'local',
holderName = '', cardDeposit = 0, cardTopup = 0, cardBalance = 0, cardOrderValue = 0,
card_id = nil, card_server_data = nil, pendingBlankCardId = nil
card_id = nil, card_server_data = nil, pendingBlankCardId = nil, cardIssueWarning = nil
}
local function getCardConfig()
@@ -1090,6 +1216,7 @@ local function resetTicketFlow()
state.card_id = nil
state.card_server_data = nil
state.pendingBlankCardId = nil
state.cardIssueWarning = nil
end
local function resetCardFlow(mode)
@@ -1109,6 +1236,7 @@ local function resetCardFlow(mode)
state.card_id = nil
state.card_server_data = nil
state.pendingBlankCardId = nil
state.cardIssueWarning = nil
end
local function isCardOrderLike(data)
@@ -1208,14 +1336,22 @@ local function renderConfirmCancel()
addButton(bx+4+bw, by+4, 'NO', bw, 1, {colors.green, colors.white}, function() ui_cancel_confirmed = false; ui_cancel_request = false end)
end
local function showAlert(msg)
local function showAlert(msg, detail)
if detail then
reportIssue(msg, detail)
end
local boxW, boxH = math.max(24, math.min(32, w-4)), 6
local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1
termDev.setBackgroundColor(colors.gray)
for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end
msg = tostring(msg or '')
if #msg > boxW-4 then msg = msg:sub(1, boxW-4) end
termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(msg)
local displayMsg = tostring(msg or '')
if #displayMsg > boxW-4 then displayMsg = displayMsg:sub(1, boxW-4) end
termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(displayMsg)
if detail then
local displayDetail = tostring(detail)
if #displayDetail > boxW-4 then displayDetail = displayDetail:sub(1, boxW-4) end
termDev.setCursorPos(bx+2, by+3); termDev.write(displayDetail)
end
Buttons = {}
addButton(bx+math.floor((boxW-6)/2), by+4, 'OK', 6, 1, {colors.green, colors.white}, function() end)
waitButtons()
@@ -1282,8 +1418,12 @@ local function showSingleSegmentSelection()
local startIdx = singleSegmentScroll * itemsPerPage + 1
local endIdx = math.min(startIdx + itemsPerPage - 1, #singleSegments)
local bw = math.floor((w - 6) / 2)
local bx1, bx2 = 2, w - bw - 1
local sbX = w - 3
local contentRight = sbX - 2
local colGap = 2
local bw = math.floor((contentRight - 1 - colGap) / 2)
if bw < 8 then bw = 8 end
local bx1, bx2 = 2, 2 + bw + colGap
local by = 5
for i = startIdx, endIdx do
local seg = singleSegments[i]
@@ -1292,20 +1432,28 @@ local function showSingleSegmentSelection()
local x = colIdx == 0 and bx1 or bx2
local y = by + rowIdx * 4
local label = seg.lineName .. ' cogs:' .. tostring(seg.cost)
if #label > bw then label = label:sub(1, bw) end
local lineColor = nearestCCColor(seg.color)
local textColor = contrastTextColor(lineColor)
local isSelected = (state.singleSegment == seg)
addButton(x, y, label, bw, 3, {isSelected and colors.green or colors.blue, colors.white}, function()
local bg = isSelected and colors.green or lineColor
local fg = isSelected and colors.white or textColor
local label = seg.lineName .. ' ' .. tostring(seg.cost) .. 'c'
if #label > bw then label = label:sub(1, bw) end
addButton(x, y, label, bw, 3, {bg, fg}, function()
state.singleSegment = seg
end)
end
if singleSegmentScroll > 0 then
addButton(w-8, 2, 'UP', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll - 1 end)
end
if singleSegmentScroll < maxScroll then
addButton(w-8, h-5, 'DN', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll + 1 end)
end
drawVerticalScrollbar(
sbX,
by,
h - 6,
singleSegmentScroll * itemsPerPage,
maxScroll * itemsPerPage,
itemsPerPage,
function() singleSegmentScroll = math.max(0, singleSegmentScroll - 1) end,
function() singleSegmentScroll = math.min(maxScroll, singleSegmentScroll + 1) end
)
addCancelButton()
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
@@ -1341,8 +1489,8 @@ local function showHome()
local cx = math.floor((w - btnW) / 2) + 1
addButton(cx, y-4, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
resetTicketFlow()
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
state.page = 'departure'
stopAudio()
state.page = 'new_menu'
end)
addButton(cx, y, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function()
resetTicketFlow()
@@ -1353,34 +1501,48 @@ local function showHome()
resetCardFlow('open')
stopAudio(); state.page = 'card_home'
end)
addButton(cx, y+8, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
resetTicketFlow()
stopAudio(); state.page = 'online'
end)
else
local cx = math.floor((w - btnW) / 2) + 1
addButton(x1, y-2, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
resetTicketFlow()
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
state.page = 'departure'
stopAudio()
state.page = 'new_menu'
end)
addButton(x2, y-2, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function()
resetTicketFlow()
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
state.page = 'single_segment'
end)
addButton(x1, y+2, 'CARD', btnW, btnH, {colors.orange, colors.white}, function()
addButton(cx, y+2, 'CARD', btnW, btnH, {colors.orange, colors.white}, function()
resetCardFlow('open')
stopAudio(); state.page = 'card_home'
end)
addButton(x2, y+2, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
resetTicketFlow()
stopAudio(); state.page = 'online'
end)
end
waitButtons()
end
local function showNewMenu()
while state.page == 'new_menu' do
drawHeader('Ticket Service', 'Buy ticket or redeem voucher')
Buttons = {}
local btnW, btnH = 16, 3
local cx = math.floor((w - btnW) / 2) + 1
local y = math.floor(h / 2) - 3
addButton(cx, y, 'BUY TICKET', btnW, btnH, {colors.green, colors.white}, function()
resetTicketFlow()
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
state.page = 'departure'
end)
addButton(cx, y + 4, 'REDEEM CODE', btnW, btnH, {colors.cyan, colors.white}, function()
resetTicketFlow()
stopAudio()
state.page = 'online'
end)
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
waitButtons()
end
end
local function showCardHome()
while state.page == 'card_home' do
drawHeader('IC Card Service', 'Open card or redeem online order')
@@ -1418,6 +1580,25 @@ local function showCardNameInput()
{'+'}
}
local function clearLine(y)
termDev.setBackgroundColor(colors.black)
termDev.setCursorPos(1, y)
termDev.write(string.rep(' ', w))
end
local function renderInput()
local placeholder = name
if #placeholder == 0 then
placeholder = 'ENTER NAME'
elseif #placeholder > (w - 8) then
placeholder = '...' .. placeholder:sub(-(w - 11))
end
clearLine(4)
clearLine(5)
centerText(4, '[' .. placeholder .. ']', colors.yellow)
if msg and #msg > 0 then centerText(5, msg, msgCol) end
end
local function isAllowedHolderChar(ch)
if type(ch) ~= 'string' or #ch ~= 1 then return false end
if ch:match('[A-Za-z ]') then return true end
@@ -1429,19 +1610,14 @@ local function showCardNameInput()
if ch:match('[A-Za-z]') then ch = ch:upper() end
if ch == ' ' and (#name == 0 or name:sub(-1) == ' ') then return end
name = name .. ch
msg = ''
renderInput()
end
while state.page == 'card_name' do
local placeholder = name
if #placeholder == 0 then
placeholder = 'ENTER NAME'
elseif #placeholder > (w - 8) then
placeholder = '...' .. placeholder:sub(-(w - 11))
end
local rows = (keyboardMode == 'symbol') and symbolRows or alphaRows
drawHeader('Enter Holder Name', (keyboardMode == 'symbol') and 'Symbol keyboard' or 'Letters / symbols / space')
centerText(4, '[' .. placeholder .. ']', colors.yellow)
if msg and #msg > 0 then centerText(5, msg, msgCol) end
renderInput()
Buttons = {}
local keyW, keyH = (w < 44 and 2 or 3), 2
local sY = 7
@@ -1460,36 +1636,54 @@ local function showCardNameInput()
appendHolderChar(' ')
end)
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end)
local currentKeyboardMode = keyboardMode
addButton(12, h-3, (keyboardMode == 'symbol') and 'ABC' or 'SYM', 8, 3, {colors.black, colors.orange}, function()
keyboardMode = (keyboardMode == 'symbol') and 'alpha' or 'symbol'
end)
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() name = name:sub(1, -2) end)
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() name = '' end)
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function()
name = name:sub(1, -2)
msg = ''
renderInput()
end)
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function()
name = ''
msg = ''
renderInput()
end)
addButton(w-11, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function()
local clean = firstString(name)
if #clean < 2 then
msg, msgCol = 'Name too short', colors.red
renderInput()
return
end
state.holderName = clean
state.page = 'card_topup'
end)
local ev, p1, p2, p3 = os.pullEvent()
if ev == 'mouse_click' or ev == 'monitor_touch' then
for _, b in ipairs(Buttons) do
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
end
elseif ev == 'char' then
appendHolderChar(tostring(p1 or ''))
elseif ev == 'key' and p1 == keys.backspace then
name = name:sub(1, -2)
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
local clean = firstString(name)
if #clean >= 2 then
state.holderName = clean
state.page = 'card_topup'
else
msg, msgCol = 'Name too short', colors.red
while state.page == 'card_name' and keyboardMode == currentKeyboardMode do
local ev, p1, p2, p3 = os.pullEvent()
if ev == 'mouse_click' or ev == 'monitor_touch' then
for _, b in ipairs(Buttons) do
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
end
elseif ev == 'char' then
appendHolderChar(tostring(p1 or ''))
elseif ev == 'key' and p1 == keys.backspace then
name = name:sub(1, -2)
msg = ''
renderInput()
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
local clean = firstString(name)
if #clean >= 2 then
state.holderName = clean
state.page = 'card_topup'
else
msg, msgCol = 'Name too short', colors.red
renderInput()
end
end
end
end
@@ -1544,9 +1738,24 @@ local function showCardOnlineRedeem()
{'Z','X','C','V','B','N','M'}
}
local function clearLine(y)
termDev.setBackgroundColor(colors.black)
termDev.setCursorPos(1, y)
termDev.write(string.rep(' ', w))
end
local function renderInput()
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
clearLine(4)
clearLine(5)
centerText(4, '[' .. placeholder .. ']', colors.yellow)
if msg and #msg > 0 then centerText(5, msg, msgCol) end
end
local function submitCode()
if #code ~= 5 then
msg, msgCol = 'Need 5 chars', colors.red
renderInput()
return
end
local res = fetchJSON(API_BASE .. '/public/ic-cards/orders/' .. code)
@@ -1555,17 +1764,20 @@ local function showCardOnlineRedeem()
end
if not (res and res.ok) then
msg, msgCol = 'Voucher Invalid', colors.red
renderInput()
return
end
local d = res.data or res
local cardStatus = tostring(d.status or ''):lower()
if d.consumed or (cardStatus ~= '' and cardStatus ~= 'pending_pickup' and isCardOrderLike(d)) then
msg, msgCol = 'Already Used!', colors.red
renderInput()
return
end
local cardOrder = buildCardOrderState(d, code)
if not cardOrder then
msg, msgCol = 'Not a card order', colors.red
renderInput()
return
end
resetCardFlow('redeem')
@@ -1583,10 +1795,7 @@ local function showCardOnlineRedeem()
end
while state.page == 'card_online' do
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
drawHeader('Redeem IC Card', 'Type 5 chars then OK')
centerText(4, '[' .. placeholder .. ']', colors.yellow)
if msg and #msg > 0 then centerText(5, msg, msgCol) end
Buttons = {}
local keyW, keyH = (w < 44 and 2 or 3), 2
local kbW = 10 * (keyW + 1)
@@ -1595,26 +1804,46 @@ local function showCardOnlineRedeem()
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
for _, ch in ipairs(row) do
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function()
if #code < 5 then code = code .. ch end
if #code < 5 then
code = code .. ch
msg = ''
renderInput()
end
end)
x = x + keyW + 1
end
end
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end)
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() code = '' end)
addButton(w-11, h-3, 'OK', 10, 3, {colors.black, colors.green}, submitCode)
local ev, p1, p2, p3 = os.pullEvent()
if ev == 'mouse_click' or ev == 'monitor_touch' then
for _, b in ipairs(Buttons) do
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
end
elseif ev == 'char' and #code < 5 then
code = code .. tostring(p1 or ''):upper()
elseif ev == 'key' and p1 == keys.backspace then
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function()
code = code:sub(1, -2)
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
submitCode()
msg = ''
renderInput()
end)
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function()
code = ''
msg = ''
renderInput()
end)
addButton(w-11, h-3, 'OK', 10, 3, {colors.black, colors.green}, submitCode)
renderInput()
while state.page == 'card_online' do
local ev, p1, p2, p3 = os.pullEvent()
if ev == 'mouse_click' or ev == 'monitor_touch' then
for _, b in ipairs(Buttons) do
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
end
elseif ev == 'char' and #code < 5 then
code = code .. tostring(p1 or ''):upper()
msg = ''
renderInput()
elseif ev == 'key' and p1 == keys.backspace then
code = code:sub(1, -2)
msg = ''
renderInput()
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
submitCode()
elseif ev == 'config_updated' then
end
end
end
end
@@ -1653,8 +1882,16 @@ local function renderLinesSelection(title, selectedCode, backPage, nextPage, inf
local ch = computeHeight(); local maxO = math.max(0, ch - (endY-startY+1))
ui_scroll_offset = math.min(ui_scroll_offset, maxO)
addButton(sbX, startY-1, '^', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.max(0, ui_scroll_offset-4) end)
addButton(sbX, endY+1, 'v', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end)
drawVerticalScrollbar(
sbX,
startY,
endY,
ui_scroll_offset,
maxO,
4,
function() ui_scroll_offset = math.max(0, ui_scroll_offset-4) end,
function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end
)
local y = startY
for _, l in ipairs(CFG.lines) do
@@ -1737,7 +1974,7 @@ local function showTerminal()
invalidUntil = os.epoch('utc') + 1500
sel = nil
state.terminal = nil
showAlert('Same station')
showAlert('Same station', 'Dep and Term are identical')
else
state.terminal = sel
end
@@ -1995,7 +2232,7 @@ local function showOrderAndAudio()
else
local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
if not unit then
showAlert('No route')
showAlert('No route', 'Cannot find fare path')
state.page = 'terminal'
return
end
@@ -2006,7 +2243,7 @@ local function showOrderAndAudio()
end
if state.productMode ~= 'card' and state.trainType ~= 'single' and sameLogicalStation(state.departure, state.terminal) then
showAlert('Same station')
showAlert('Same station', 'Cannot purchase to same station')
state.page = state.voucher_code and 'online' or 'terminal'
return
end
@@ -2019,7 +2256,7 @@ local function showOrderAndAudio()
local function confirmAction()
if processing or confirmed then return end
if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then
showAlert('Same station')
showAlert('Same station', 'Dep and Term are identical on confirm')
if render then render() end
return
end
@@ -2054,6 +2291,7 @@ local function showOrderAndAudio()
end
if okIssueBlank and #blankCardId > 0 then
payload.card_id = blankCardId
state.card_id = blankCardId
state.pendingBlankCardId = blankCardId
local okReq, code, parsed, err = submitCardOpen(payload)
if okReq then
@@ -2065,11 +2303,15 @@ local function showOrderAndAudio()
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
state.card_server_data = respData
state.pendingBlankCardId = nil
state.cardIssueWarning = nil
confirmed = true
statusMsg, statusCol = 'Card ready', colors.green
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
else
statusMsg, statusCol = 'Write failed: ' .. tostring(writeMethod), colors.red
state.card_server_data = respData
state.cardIssueWarning = 'Write pending: ' .. tostring(writeMethod)
confirmed = true
statusMsg, statusCol = 'Card issued, write pending', colors.red
end
else
local errorMsg = 'Card API Err'
@@ -2080,9 +2322,14 @@ local function showOrderAndAudio()
elseif code and type(code) == 'number' then
errorMsg = 'HTTP ' .. tostring(code)
end
statusMsg, statusCol = 'Issued, retry sync: ' .. errorMsg, colors.red
state.cardIssueWarning = 'Sync pending: ' .. errorMsg
confirmed = true
statusMsg, statusCol = 'Card issued, sync pending', colors.red
end
elseif okIssueBlank then
state.card_id = payload.card_id
state.cardIssueWarning = 'Issued without readable ID'
confirmed = true
statusMsg, statusCol = 'Card issued without ID', colors.red
else
local okReq, code, parsed, err = submitCardOpen(payload)
@@ -2095,6 +2342,7 @@ local function showOrderAndAudio()
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
state.card_server_data = respData
state.pendingBlankCardId = nil
state.cardIssueWarning = nil
confirmed = true
statusMsg, statusCol = 'Card ready', colors.green
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
@@ -2227,7 +2475,8 @@ end
local function showDone()
if state.productMode == 'card' then
drawHeader('Card Ready', 'Please take your IC card')
local warning = firstString(state.cardIssueWarning)
drawHeader(warning ~= '' and 'Card Issued' or 'Card Ready', warning ~= '' and 'Please check issue status' or 'Please take your IC card')
local y = 5
local function line(label, value, col)
termDev.setTextColor(colors.white)
@@ -2242,6 +2491,7 @@ local function showDone()
line('Balance', tonumber(state.cardBalance) or 0, colors.green)
line('Card ID', firstString(state.card_id, 'PENDING'), colors.cyan)
if state.voucher_code then line('Voucher', state.voucher_code, colors.cyan) end
if warning ~= '' then line('Status', warning, colors.red) end
saveCardIssueSnapshot({
card_id = state.card_id,
holder_name = state.holderName,
@@ -2249,6 +2499,7 @@ local function showDone()
topup = state.cardTopup,
balance = state.cardBalance,
voucher_code = state.voucher_code,
issue_warning = warning,
station_code = state.stationCode or CURRENT_STATION_CODE,
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
})
@@ -2333,7 +2584,7 @@ local function showDone()
local issueError = tostring(issueMethod or 'ticket_issue_failed')
print('Ticket issue failed: ' .. issueError)
_G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError
showAlert('Ticket issue failed')
showAlert('Ticket issue failed', issueError)
resetTicketFlow()
state.page = 'home'
return
@@ -2432,6 +2683,20 @@ local function showOnlineVoucher()
{'Z','X','C','V','B','N','M'}
}
local function clearLine(y)
termDev.setBackgroundColor(colors.black)
termDev.setCursorPos(1, y)
termDev.write(string.rep(' ', w))
end
local function renderInput()
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
clearLine(4)
clearLine(5)
centerText(4, '[' .. placeholder .. ']', colors.yellow)
if msg and #msg > 0 then centerText(5, msg, msgCol) end
end
local function submitCode()
if #code == 5 then
local res = fetchJSON(API_BASE .. '/public/orders/' .. code)
@@ -2441,6 +2706,7 @@ local function showOnlineVoucher()
-- Check if already consumed
if d.consumed then
msg, msgCol = 'Already Used!', colors.red
renderInput()
return
end
@@ -2454,15 +2720,16 @@ local function showOnlineVoucher()
state.page = 'order'
else
msg, msgCol = 'Voucher Invalid', colors.red
renderInput()
end
else
msg, msgCol = 'Need 5 chars', colors.red
renderInput()
end
end
while state.page == 'online' do
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
drawHeader('Enter Voucher', 'Type 5 chars then OK')
centerText(4, '[' .. placeholder .. ']', colors.yellow)
if msg and #msg > 0 then centerText(5, msg, msgCol) end
Buttons = {}
local keyW, keyH = (w < 44 and 2 or 3), 2
@@ -2472,7 +2739,13 @@ local function showOnlineVoucher()
for rIdx, row in ipairs(rows) do
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
for _, ch in ipairs(row) do
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function() if #code < 5 then code = code .. ch end end)
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function()
if #code < 5 then
code = code .. ch
msg = ''
renderInput()
end
end)
x = x + keyW + 1
end
end
@@ -2482,27 +2755,47 @@ local function showOnlineVoucher()
local gap = 2
local total = bw + gap + cw + gap + ow
local ax = math.floor((w - total) / 2) + 1
local smallMode = false
if ax < 1 then
smallMode = true
bw, cw, ow = 6, 6, 6
total = bw + 1 + cw + 1 + ow
gap = 1
total = bw + gap + cw + gap + ow
ax = math.floor((w - total) / 2) + 1
addButton(math.max(1, ax), actY, 'BKSP', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
addButton(math.max(1, ax) + bw + 1, actY, 'CLEAR', cw, 3, {colors.black, colors.red}, function() code = '' end)
addButton(math.max(1, ax) + bw + 1 + cw + 1, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
else
addButton(ax, actY, 'Backspace', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
addButton(ax + bw + gap, actY, 'Clear', cw, 3, {colors.black, colors.red}, function() code = '' end)
addButton(ax + bw + gap + cw + gap, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
end
local bx = math.max(1, ax)
addButton(bx, actY, smallMode and 'BKSP' or 'Backspace', bw, 3, {colors.black, colors.red}, function()
code = code:sub(1, -2)
msg = ''
renderInput()
end)
addButton(bx + bw + gap, actY, smallMode and 'CLEAR' or 'Clear', cw, 3, {colors.black, colors.red}, function()
code = ''
msg = ''
renderInput()
end)
addButton(bx + bw + gap + cw + gap, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
local ev, p1, p2, p3 = os.pullEvent()
if ev == 'mouse_click' or ev == 'monitor_touch' then
for _, b in ipairs(Buttons) do if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end end
elseif ev == 'char' and #code < 5 then code = code .. p1:upper()
elseif ev == 'key' and p1 == keys.backspace then code = code:sub(1, -2)
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode()
elseif ev == 'config_updated' then
renderInput()
while state.page == 'online' do
local ev, p1, p2, p3 = os.pullEvent()
if ev == 'mouse_click' or ev == 'monitor_touch' then
for _, b in ipairs(Buttons) do
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
end
elseif ev == 'char' and #code < 5 then
code = code .. tostring(p1 or ''):upper()
msg = ''
renderInput()
elseif ev == 'key' and p1 == keys.backspace then
code = code:sub(1, -2)
msg = ''
renderInput()
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
submitCode()
elseif ev == 'config_updated' then
end
end
end
end
@@ -2513,6 +2806,7 @@ end
local function mainPageLoop()
while true do
if state.page == 'home' then showHome()
elseif state.page == 'new_menu' then showNewMenu()
elseif state.page == 'single_segment' then showSingleSegmentSelection()
elseif state.page == 'card_home' then showCardHome()
elseif state.page == 'card_name' then showCardNameInput()
@@ -2530,4 +2824,12 @@ local function mainPageLoop()
end
end
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)
local function safeMain()
local ok, err = pcall(parallel.waitForAny, mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)
if not ok then
reportIssue("Fatal Error", tostring(err))
error(err)
end
end
safeMain()
-1
View File
@@ -1,5 +1,4 @@
local URL_REFILL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/refillmachine.lua"
local URL_UPDATE_REFILL = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_refill.lua"
+1 -74
View File
@@ -1,74 +1 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FMG</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="style.css?v=14">
<link rel="stylesheet" href="blog.css?v=2">
</head>
<body class="public-search">
<div class="public-container">
<header class="search-header" style="text-align: left;">
<div style="margin-bottom: 10px; text-align: left;">
<a href="https://ticket.fse-media.group" id="homeLink"
style="color: var(--primary); text-decoration: none; font-weight: 500;">
<i class="fas fa-arrow-left"></i> 返回首页
</a>
</div>
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<img src="logo.png" alt="Logo" style="height: 40px; margin-right: 12px;">
<h1 style="font-weight: 800; letter-spacing: -1px; margin: 0; text-align: left; font-size: 26px;">
FMG
</h1>
</div>
</header>
<main>
<section class="tab-panel show">
<div class="portal-grid">
<a href="http://forum.fse-media.group" class="portal-card">
<div class="portal-icon">
<i class="fas fa-comments"></i>
</div>
<h3>论坛</h3>
<p>forum.fse-media.group</p>
</a>
<a href="http://b.igtm.ooooo.ink:11324" class="portal-card">
<div class="portal-icon">
<i class="fas fa-poll-h"></i>
</div>
<h3>问卷</h3>
<p>b.igtm.ooooo.ink</p>
</a>
</div>
<div class="card" style="margin-top: 20px;">
<div class="card-title"
style="font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; color: var(--text);">
<i class="fas fa-server text-primary"></i> 服务器状态
</div>
<div style="overflow-x: auto; width: 100%;">
<iframe id="mc-status-traintown-online-" frameborder="0" width="1000" height="500"
style="max-width:100%; border-radius: 8px;" scrolling="no"
src="https://motd.minebbs.com/iframe?ip=traintown.online&stype=auto&dark=true"></iframe>
</div>
</div>
</section>
</main>
<footer
style="margin-top: 60px; padding: 20px 0; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; text-align: center;">
<p>&copy; 2026 FSE Media Group. All rights reserved.</p>
</footer>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="blog.js?v=2"></script>
</body>
</html>
</html>
+23 -8
View File
@@ -6,7 +6,7 @@
<title>首页|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14">
<link rel="stylesheet" href="/style.css?v=22">
</head>
<body class="public-search jr-public-page">
<div class="jr-public-shell">
@@ -16,11 +16,7 @@
<i class="fas fa-train"></i>
<span>FSE铁路运输票务系统</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
@@ -184,6 +180,26 @@
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script>
const createRouteMapCredit = (credit, license) => {
const wrap = document.createElement('div');
wrap.className = 'jr-asset-credit';
const creditText = document.createElement('span');
creditText.textContent = `线路图署名:${credit || '未填写'}`;
wrap.appendChild(creditText);
wrap.appendChild(document.createTextNode(' 许可协议:'));
const licenseLink = document.createElement('a');
licenseLink.href = 'https://creativecommons.org/licenses/by/4.0/';
licenseLink.target = '_blank';
licenseLink.rel = 'noopener noreferrer';
licenseLink.textContent = license || 'CC BY 4.0';
wrap.appendChild(licenseLink);
return wrap;
};
const loadPublicAssets = async () => {
const fareMapBox = document.getElementById('fareMapBox');
const routeMapBox = document.getElementById('routeMapBox');
@@ -209,6 +225,7 @@
img.src = `/assets/${encodeURIComponent(m.routeMap)}?v=${encodeURIComponent(mv)}`;
routeMapBox.innerHTML = '<div class="jr-asset-frame"></div>';
routeMapBox.firstElementChild.appendChild(img);
routeMapBox.appendChild(createRouteMapCredit(m.routeMapCredit, m.routeMapLicense));
} else {
routeMapBox.innerHTML = '<div class="text-muted">暂无线路图</div>';
}
@@ -245,5 +262,3 @@
</html>
+2 -6
View File
@@ -7,7 +7,7 @@
<title>IC卡管理|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14">
<link rel="stylesheet" href="/style.css?v=22">
</head>
<body class="jr-admin-page jr-admin-ic-page jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-train"></i>
<span>FSE 铁路运输后台系统</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
+2 -6
View File
@@ -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">
+2 -6
View File
@@ -7,7 +7,7 @@
<title>线上购卡|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14">
<link rel="stylesheet" href="/style.css?v=22">
</head>
<body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
+2 -6
View File
@@ -7,7 +7,7 @@
<title>IC卡查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14">
<link rel="stylesheet" href="/style.css?v=22">
</head>
<body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
+1036 -868
View File
File diff suppressed because it is too large Load Diff
+183 -12
View File
@@ -27,6 +27,7 @@ createApp({
tickets: '车票记录',
vouchers: '凭证管理',
iccards: 'IC 卡管理',
issues: '问题汇总',
assets: '线路图',
settings: '系统设置',
logs: '日志'
@@ -34,15 +35,15 @@ createApp({
return map[currentView.value] || '票价图';
});
// Prefer polling first so admin remains connected even when the proxy
// does not support WebSocket upgrades reliably.
const socket = io({ transports: ['polling', 'websocket'] });
// 强制使用 WebSocket,避免轮询 (polling) 在负载均衡/反代下产生 400 Bad Request
const socket = io({ transports: ['websocket'], timeout: 20000 });
// Data State
const stations = ref([]);
const lines = ref([]);
const fares = ref([]);
const tickets = ref([]);
const issues = ref([]);
const stats = reactive({ sold_tickets: 0, revenue: 0 });
const config = reactive({
api_base: '',
@@ -56,10 +57,11 @@ createApp({
const logMax = ref(200);
const logLoading = ref(false);
const orders = ref([]);
const assetsManifest = reactive({ routeMap: null, fareTable: null, updatedAt: null });
const assetsManifest = reactive({ routeMap: null, routeMapCredit: '', routeMapLicense: 'CC BY 4.0', fareTable: null, updatedAt: null });
const assetsFarePreview = reactive({ headers: [], rows: [] });
const assetsRouteMapUrl = ref('');
const assetsFareTableUrl = ref('');
const assetsForm = reactive({ routeMapCredit: '' });
const icCards = ref([]);
const icCardSearch = ref('');
const icSelectedId = ref('');
@@ -76,6 +78,7 @@ createApp({
let ticketDataLoaded = false;
let orderDataLoaded = false;
let logDataLoaded = false;
let issueDataLoaded = false;
let assetsLoaded = false;
let fareMapLoaded = false;
const loadingState = reactive({
@@ -83,7 +86,8 @@ createApp({
tickets: false,
orders: false,
logs: false,
iccards: false
iccards: false,
issues: false
});
const lastSyncAt = ref(0);
@@ -159,6 +163,132 @@ createApp({
try { return JSON.parse(t); } catch (e) { return null; }
};
const safeJsonStringify = (value) => {
try {
return JSON.stringify(value, null, 2);
} catch (e) {
return String(value);
}
};
const formatIssueKey = (key) => {
const map = {
device: '设备',
device_id: '设备编号',
machine_id: '机器 ID',
station_code: '车站编号',
station_name: '车站名称',
station: '车站',
error_msg: '错误信息',
ticket_id: '车票 ID',
card_id: '卡号',
holder_name: '持卡人',
balance: '余额',
amount: '金额',
price: '价格',
order_code: '订单号',
voucher_code: '凭证码',
method: '方法',
source: '来源',
reason: '原因',
status: '状态',
action: '动作',
line: '线路',
route: '区间',
from: '起点',
to: '终点',
time: '时间',
ts: '时间戳'
};
const raw = String(key || '').trim();
if (!raw) return '字段';
if (map[raw]) return map[raw];
return raw
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^\w/, (ch) => ch.toUpperCase());
};
const formatIssueValue = (value) => {
if (value == null || value === '') return '---';
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '---';
if (typeof value === 'string') return value.trim() || '---';
if (Array.isArray(value)) {
if (!value.length) return '---';
if (value.every((item) => item == null || ['string', 'number', 'boolean'].includes(typeof item))) {
return value.map(formatIssueValue).join(' / ');
}
return safeJsonStringify(value);
}
if (typeof value === 'object') return safeJsonStringify(value);
return String(value);
};
const buildIssueDetailView = (detail) => {
const rawText = String(detail || '').trim();
if (!rawText) return { entries: [], rawText: '', rawExpanded: false };
const hiddenKeys = new Set(['device', 'device_id', 'machine_id', 'station_code', 'station_name', 'error_msg', 'detail', 'type']);
const parsed = parseJsonSafe(rawText);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const entries = Object.entries(parsed)
.filter(([key, value]) => !hiddenKeys.has(String(key || '').trim()) && value != null && value !== '')
.map(([key, value]) => ({
key: String(key),
label: formatIssueKey(key),
value: formatIssueValue(value)
}));
return {
entries,
rawText: entries.length ? rawText : '',
rawExpanded: false
};
}
const segments = rawText
.split(/\r?\n|;|;/)
.map((item) => item.trim())
.filter(Boolean);
const entries = [];
for (const segment of segments) {
const matched = /^([^:=:]{1,30})\s*[:=:]\s*(.+)$/.exec(segment);
if (!matched) continue;
const key = String(matched[1] || '').trim();
if (!key || hiddenKeys.has(key)) continue;
entries.push({
key,
label: formatIssueKey(key),
value: formatIssueValue(matched[2])
});
}
const hasPlainBody = segments.length <= 1 && entries.length <= 1;
return {
entries: hasPlainBody ? [] : entries,
rawText,
rawExpanded: hasPlainBody
};
};
const normalizeIssueRecord = (issue) => {
const item = issue && typeof issue === 'object' ? issue : {};
const device = String(item.device || item.machine_id || '').trim();
const stationCode = String(item.station_code || '').trim();
const stationName = stationCode ? getStationName(stationCode) : '';
const detailView = buildIssueDetailView(item.detail);
return {
...item,
displayDevice: device || '---',
displayStation: stationName && stationName !== stationCode ? `${stationName} (${stationCode})` : (stationCode || '---'),
displayError: String(item.error_msg || '').trim() || '未提供错误信息',
detailEntries: detailView.entries,
detailRaw: detailView.rawText,
detailRawExpanded: detailView.rawExpanded
};
};
const parseCsvSimple = (text) => {
const out = { headers: [], rows: [] };
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
@@ -577,8 +707,11 @@ createApp({
const fetchAssetsManifest = async () => {
const data = await requestJson('/api/assets/manifest');
assetsManifest.routeMap = data ? (data.routeMap || null) : null;
assetsManifest.routeMapCredit = data ? String(data.routeMapCredit || '') : '';
assetsManifest.routeMapLicense = data ? String(data.routeMapLicense || 'CC BY 4.0') : 'CC BY 4.0';
assetsManifest.fareTable = data ? (data.fareTable || null) : null;
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
assetsForm.routeMapCredit = assetsManifest.routeMapCredit;
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
assetsLoaded = true;
@@ -622,9 +755,12 @@ createApp({
markSynced();
};
const uploadAssetFile = async (url, file) => {
const uploadAssetFile = async (url, file, extraFields = {}) => {
const fd = new FormData();
fd.append('file', file);
for (const [key, value] of Object.entries(extraFields || {})) {
fd.append(key, value == null ? '' : String(value));
}
await requestJson(url, { method: 'POST', body: fd }, { expectOk: true });
await fetchAssetsManifest();
};
@@ -633,7 +769,7 @@ createApp({
const f = ev && ev.target && ev.target.files && ev.target.files[0];
if (!f) return;
ev.target.value = '';
await uploadAssetFile('/api/assets/route-map', f);
await uploadAssetFile('/api/assets/route-map', f, { credit: assetsForm.routeMapCredit });
};
const uploadFareTable = async (ev) => {
@@ -648,6 +784,15 @@ createApp({
await fetchAssetsManifest();
};
const saveRouteMapCredit = async () => {
await requestJson('/api/assets/route-map/credit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credit: assetsForm.routeMapCredit })
}, { expectOk: true });
await fetchAssetsManifest();
};
const deleteFareTable = async () => {
await requestJson('/api/assets/fare-table', { method: 'DELETE' }, { expectOk: true });
await fetchAssetsManifest();
@@ -845,6 +990,26 @@ createApp({
}
};
// --- Issues Management ---
const fetchIssues = async () => {
if (loadingState.issues) return;
loadingState.issues = true;
try {
const res = await requestJson('/api/issues');
if (res && res.ok) {
issues.value = (res.issues || [])
.map(normalizeIssueRecord)
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
issueDataLoaded = true;
markSynced();
}
} catch (e) {
console.error(e);
} finally {
loadingState.issues = false;
}
};
const fetchIcCards = async (keepSelection = true) => {
if (loadingState.iccards) return;
loadingState.iccards = true;
@@ -1131,6 +1296,7 @@ createApp({
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
if (view === 'issues' && (force || !issueDataLoaded)) await fetchIssues();
if (view === 'iccards') {
await fetchIcCards(true);
if (icSelectedId.value) {
@@ -1600,6 +1766,12 @@ createApp({
stats.sold_tickets += item.sold_tickets;
stats.revenue += item.revenue;
});
socket.on('issue:new', (item) => {
issues.value = [normalizeIssueRecord(item), ...issues.value]
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
issueDataLoaded = true;
markSynced();
});
socket.on('ic-card:created', () => { fetchIcCards(false).catch(console.error); });
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
socket.on('ic-card:check', () => {
@@ -1843,9 +2015,6 @@ createApp({
} else {
stopIcCardSync();
}
if (v === 'assets') {
setTimeout(initTopologyLayout, 300);
}
ensureViewData(v).catch(console.error);
const sp = new URLSearchParams(location.search);
if (v === 'dashboard') sp.delete('view');
@@ -1915,6 +2084,7 @@ createApp({
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
issues: `当前共有 ${issues.value.length} 条问题记录`,
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
settings: '可维护优惠活动与导出数据',
logs: `当前筛选结果 ${logs.value.length} 条日志`
@@ -2000,11 +2170,12 @@ createApp({
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
orders, orderList, fetchOrders, deleteOrder,
issues, fetchIssues,
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
assetsManifest, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable,
assetsManifest, assetsForm, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable, saveRouteMapCredit,
// Management
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
-70
View File
@@ -1,71 +1 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录后台|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="/style.css?v=14" />
</head>
<body class="jr-admin-login-page">
<div class="jr-admin-login-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="/" class="jr-top-link">
<span>FSE铁路票务系统控制台</span>
</a>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="/" class="jr-brand">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE铁路票务系统控制台</strong>
<span>登录</span>
</div>
</a>
</div>
</div>
<main class="jr-admin-login-main">
<section class="jr-admin-login-panel">
<div class="jr-admin-login-copy">
<span class="jr-kicker">OPERATIONS ACCESS</span>
<h1>后台控制台</h1>
<p>线路维护、票务管理、日志查询与 IC 卡管理统一从·这里进入。</p>
<ul class="jr-admin-login-points">
<li>统一管理线路、票价和资源图文件</li>
<li>查看电子票、凭证与操作日志</li>
<li>维护 IC 卡发放、充值与状态记录</li>
</ul>
</div>
<section class="jr-admin-login-card">
<div class="jr-page-intro jr-page-intro-compact">
<span class="jr-kicker">SIGN IN</span>
<h2>控制台登录</h2>
<p>请输入管理员账号和密码。</p>
</div>
<div class="login-row"><input id="loginUser" type="text" placeholder="用户名" /></div>
<div class="login-row"><input id="loginPass" type="password" placeholder="密码" /></div>
<div class="login-actions">
<button id="loginBtn" class="btn primary">登录</button>
<span id="loginHint" class="hint"></span>
</div>
</section>
</section>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="login.js?v=2"></script>
</body>
</html>
+2 -6
View File
@@ -7,7 +7,7 @@
<title>票务查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14" />
<link rel="stylesheet" href="/style.css?v=22" />
</head>
<body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
+479 -5
View File
@@ -1,3 +1,7 @@
[v-cloak] {
display: none !important;
}
:root {
--bg: #09090b;
--surface: #18181b;
@@ -251,7 +255,7 @@ button, .btn {
align-items: center;
justify-content: center;
padding: 8px 16px;
border-radius: 0;
border-radius: 0 !important;
border: 1px solid var(--border);
background-color: transparent;
color: var(--text);
@@ -477,6 +481,9 @@ tr:hover td {
.mobile-only {
display: none;
}
.mobile-only-inline {
display: none;
}
@media (max-width: 980px) {
:root { --sidebar-width: 260px; }
@@ -1780,6 +1787,7 @@ body.jr-order-page .site-footer .version {
background: #ffffff;
border: 1px solid #d7e0d3;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
border-radius: 0;
}
.jr-hero-copy {
@@ -2054,6 +2062,7 @@ body.jr-order-page .site-footer .version {
background:
linear-gradient(180deg, #f7faf7 0, #edf4ed 100%);
border: 1px solid #d7e0d3;
border-radius: 0;
padding: 22px;
}
@@ -2441,11 +2450,14 @@ body.jr-order-page .station-map-container text {
.jr-selection-summary {
grid-template-columns: 1fr;
gap: 8px;
margin-bottom: 16px;
}
.jr-selection-arrow {
min-height: 20px;
min-height: 12px;
transform: rotate(90deg);
font-size: 1rem;
}
.jr-form-grid,
@@ -2459,11 +2471,25 @@ body.jr-order-page .station-map-container text {
}
.jr-station-map {
padding: 14px;
padding: 10px;
}
.jr-selection-card {
padding: 12px 14px;
}
.jr-selection-tag {
margin-bottom: 4px;
font-size: 0.76rem;
}
.jr-selection-card strong {
font-size: 1rem;
line-height: 1.35;
}
body.jr-order-page .station-map-container svg {
min-width: 620px;
min-width: 560px;
transform: none;
}
@@ -2725,6 +2751,25 @@ body.jr-public-page {
height: auto;
}
.jr-asset-credit {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
font-size: 0.82rem;
line-height: 1.5;
color: #6a786d;
}
.jr-asset-credit a {
color: #0b6b3a;
text-decoration: none;
}
.jr-asset-credit a:hover {
text-decoration: underline;
}
.jr-search-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -4199,6 +4244,123 @@ body.jr-admin-login-page {
gap: 16px;
}
.jr-issue-table {
width: 100%;
table-layout: fixed;
}
.jr-issue-table th:nth-child(1) {
width: 168px;
}
.jr-issue-table th:nth-child(2) {
width: 150px;
}
.jr-issue-table th:nth-child(3) {
width: 190px;
}
.jr-issue-table th:nth-child(4) {
width: 220px;
}
.jr-issue-table td {
vertical-align: top;
}
.jr-issue-primary {
font-weight: 700;
line-height: 1.45;
overflow-wrap: anywhere;
}
.jr-issue-sub {
margin-top: 4px;
font-size: 0.78rem;
color: #6d7f73;
overflow-wrap: anywhere;
}
.jr-issue-error {
color: #c43f3f;
font-weight: 700;
line-height: 1.6;
}
.jr-issue-detail-cell {
min-width: 0;
}
.jr-issue-detail-card,
.jr-issue-plain-detail {
border: 1px solid #dbe4d7;
background: #f8fbf7;
padding: 12px;
border-radius: 0;
}
.jr-issue-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
}
.jr-issue-detail-item {
border: 1px solid #dbe4d7;
background: #ffffff;
padding: 10px 12px;
min-width: 0;
}
.jr-issue-detail-item span {
display: block;
margin-bottom: 4px;
color: #6d7f73;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.jr-issue-detail-item strong {
display: block;
color: #183225;
font-size: 0.92rem;
line-height: 1.55;
font-weight: 600;
overflow-wrap: anywhere;
}
.jr-issue-raw-block {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #dbe4d7;
}
.jr-issue-raw-block summary {
cursor: pointer;
color: #607466;
font-size: 0.82rem;
font-weight: 700;
}
.jr-issue-raw-block pre,
.jr-issue-plain-detail pre {
margin: 8px 0 0;
padding: 12px;
border: 1px solid #1e293b;
background: #0f172a;
color: #e2e8f0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.82rem;
line-height: 1.5;
overflow: auto;
user-select: text;
-webkit-user-select: text;
}
.jr-admin-login-shell {
min-height: 100vh;
display: flex;
@@ -4492,6 +4654,312 @@ body.jr-admin-login-page {
}
}
/* ========== Route 鬘オ髱「遘サ蜉ィ遶ッ荳馴。ケ騾る� ========== */
@media (max-width: 980px) {
body.jr-admin-route-page {
overflow: auto;
}
body.jr-admin-route-page #app {
min-height: auto;
height: auto;
}
.jr-admin-route-page .jr-admin-app {
min-height: auto;
border-radius: 0;
}
.jr-admin-route-page .jr-admin-main-shell {
width: min(100%, calc(100% - 24px));
padding: 12px 0 24px;
}
.jr-admin-route-page .jr-admin-app {
flex-direction: column;
}
.jr-admin-route-page .sidebar {
width: 86%;
max-width: 340px;
}
.jr-admin-route-page .jr-admin-header-side {
width: auto;
justify-content: flex-end;
}
}
@media (max-width: 768px) {
body.jr-admin-route-page .jr-topbar-inner {
min-height: 52px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 8px 0;
gap: 6px;
}
body.jr-admin-route-page .jr-top-status {
font-size: 0.8rem;
padding: 5px 10px;
}
body.jr-admin-route-page .jr-brandbar-inner {
min-height: auto;
padding: 14px 0;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
body.jr-admin-route-page .jr-nav {
width: 100%;
justify-content: flex-start;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
body.jr-admin-route-page .jr-nav a {
min-width: 88px;
padding: 8px 12px;
font-size: 0.85rem;
flex-shrink: 0;
}
.jr-admin-route-page .jr-admin-page .header,
.jr-admin-route-page .jr-admin-page .content {
padding-left: 14px;
padding-right: 14px;
}
.jr-admin-route-page .content {
padding: 14px;
}
.jr-admin-route-page .header {
padding: 12px 14px;
min-height: 60px;
flex-wrap: wrap;
gap: 10px;
}
.jr-admin-route-page .jr-page-intro {
padding: 18px 16px;
}
.jr-admin-route-page .jr-page-intro h1 {
font-size: clamp(1.4rem, 6vw, 2rem);
}
.jr-admin-route-page .jr-page-intro p {
font-size: 0.9rem;
line-height: 1.6;
}
.jr-admin-route-page .card {
padding: 14px;
margin-bottom: 14px;
}
.jr-admin-route-page .management-container {
flex-direction: column;
gap: 14px;
height: auto;
overflow: visible;
}
.jr-admin-route-page .management-sidebar {
width: 100%;
height: 380px;
max-height: 40vh;
}
.jr-admin-route-page .management-sidebar .card {
height: 100%;
overflow: hidden;
}
.jr-admin-route-page .management-main {
overflow: visible;
min-height: auto;
}
.jr-admin-route-page .management-main.is-empty {
display: none !important;
}
.desktop-only {
display: none !important;
}
.mobile-only-inline {
display: inline-block !important;
}
.jr-admin-route-page .editor-toolbar {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.jr-admin-route-page .editor-toolbar > .flex:first-child {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.jr-admin-route-page .switch-label {
width: 100%;
margin-left: 0 !important;
}
.jr-admin-route-page .hint-text {
margin-left: 0;
width: 100%;
word-break: break-word;
}
.jr-admin-route-page .editor-toolbar > .flex:last-child {
background: transparent !important;
padding: 0 !important;
flex-direction: column;
gap: 8px;
}
.jr-admin-route-page .editor-toolbar > .flex:last-child input,
.jr-admin-route-page .editor-toolbar > .flex:last-child button {
width: 100%;
}
.jr-admin-route-page .visual-line-container {
padding: 16px 10px;
min-height: 280px;
cursor: default;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
.jr-admin-route-page .visual-line-container svg {
min-width: 720px;
height: 220px;
}
.jr-admin-route-page .faremap-card .flex.between {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.jr-admin-route-page .faremap-card .flex.between > .flex:last-child {
width: 100%;
flex-wrap: wrap;
}
.jr-admin-route-page .faremap-card .flex.between > .flex:last-child button {
flex: 1;
min-width: 52px;
}
.jr-admin-route-page .faremap-viewport {
max-height: 60vh;
}
.jr-admin-route-page .faremap-canvas {
min-width: 100%;
}
.jr-admin-route-page .ticket-table,
.jr-admin-route-page table {
display: block;
overflow-x: auto;
white-space: nowrap;
font-size: 0.85rem;
}
.jr-admin-route-page .ticket-table th,
.jr-admin-route-page .ticket-table td,
.jr-admin-route-page table th,
.jr-admin-route-page table td {
padding: 10px 12px;
white-space: nowrap;
}
.jr-admin-route-page .modal-card {
width: 96%;
max-width: 96%;
padding: 18px 16px;
max-height: 92vh;
border-radius: 0;
}
.jr-admin-route-page .modal-title {
font-size: 1.05rem;
margin-bottom: 16px;
}
.jr-admin-route-page .modal-actions {
flex-direction: column-reverse;
margin-top: 20px;
gap: 8px;
}
.jr-admin-route-page .modal-actions button {
width: 100%;
min-height: 48px;
}
.jr-admin-route-page select[multiple] {
height: 160px;
font-size: 0.9rem;
}
.jr-admin-route-page button,
.jr-admin-route-page .btn {
min-height: 44px;
padding: 0 14px;
}
.jr-admin-route-page button.sm,
.jr-admin-route-page .btn.sm {
min-height: 36px;
min-width: 36px;
}
.jr-admin-route-page .icon-btn {
width: 44px;
height: 44px;
}
.jr-admin-route-page input,
.jr-admin-route-page select {
min-height: 44px;
font-size: 1rem;
}
.jr-admin-route-page .line-item {
padding: 14px 12px;
min-height: 56px;
}
.jr-admin-route-page .line-item .line-actions {
opacity: 1;
}
.jr-admin-route-page .line-item .line-actions button {
min-height: 36px;
min-width: 36px;
}
.jr-admin-route-page .site-footer {
margin-top: 20px;
padding-top: 12px;
font-size: 11px;
}
}
@media (max-width: 480px) {
.jr-admin-route-page .header h3 {
font-size: 1rem;
}
.jr-admin-route-page .jr-admin-header-pill {
font-size: 11px;
padding: 6px 10px;
}
.jr-admin-route-page .badge {
font-size: 0.7rem;
padding: 2px 5px;
}
.jr-admin-route-page h4 {
font-size: 0.95rem;
margin-bottom: 0.75rem;
}
.jr-admin-route-page h3 {
font-size: 1.1rem;
}
.jr-admin-route-page .jr-brand-copy strong {
font-size: 1rem;
}
.jr-admin-route-page .jr-brand-copy span {
font-size: 0.78rem;
}
.jr-admin-route-page .jr-brand-logo {
width: 40px;
height: 40px;
}
.jr-admin-route-page .faremap-viewport {
max-height: 50vh;
}
.jr-admin-route-page .visual-line-container svg {
min-width: 640px;
}
.jr-admin-route-page #app .ticket-header .flex.between:last-of-type {
flex-direction: column;
align-items: flex-start;
}
}
/* Static Map Overlays */
.map-station-overlay {
z-index: 10;
@@ -4527,5 +4995,11 @@ body.jr-admin-login-page {
background-color: var(--warning);
border-color: white;
}
␍
/* ヌソヨニヒ�ミーエナ・ネ・ウ�ヤイスヌ */
button, .btn, [class*="btn"], .jr-btn, .action-btn, input[type="button"], input[type="submit"], input[type="reset"] {
border-radius: 0 !important;
}
+2 -6
View File
@@ -7,7 +7,7 @@
<title>电子客票|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14">
<link rel="stylesheet" href="/style.css?v=22">
<style>
[v-cloak] {
display: none;
@@ -51,11 +51,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">
+2 -6
View File
@@ -7,7 +7,7 @@
<title>线上预定|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14">
<link rel="stylesheet" href="/style.css?v=22">
</head>
<body class="public-search jr-order-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">
+339 -520
View File
File diff suppressed because it is too large Load Diff
+188 -195
View File
@@ -8,7 +8,7 @@
<title>线路规划|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14">
<link rel="stylesheet" href="/style.css?v=22">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
@@ -21,11 +21,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>
@@ -49,7 +45,7 @@
</div>
<main class="jr-public-main jr-admin-main-shell">
<div id="app" class="jr-admin-app">
<div id="app" class="jr-admin-app" v-cloak>
<div class="sidebar" :class="{ open: sidebarOpen }">
<div class="jr-admin-sidebar-head">
<span class="jr-kicker">ROUTE PLANNING</span>
@@ -64,6 +60,10 @@
@click="currentView = 'management'">
<span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路规划
</div>
<div class="nav-item" :class="{active: currentView === 'stations'}"
@click="currentView = 'stations'">
<span class="nav-icon"><i class="fas fa-map-marker-alt"></i></span> 站点管理
</div>
<div class="nav-item" :class="{active: currentView === 'faremap'}" @click="currentView = 'faremap'">
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
</div>
@@ -100,10 +100,7 @@
</div>
</div>
<div class="jr-admin-header-side">
<span class="jr-admin-header-pill" :class="connected ? 'is-online' : 'is-offline'">
<i class="fas fa-circle"></i>
{{ connected ? '服务器在线' : '服务器离线' }}
</span>
</div>
</div>
@@ -159,7 +156,7 @@
</div>
</div>
<!--右侧面板-->
<div class="management-main">
<div class="management-main" :class="{'is-empty': !selectedLine}">
<div class="card mb-4">
<div class="flex between">
<div v-if="selectedLine">
@@ -174,7 +171,8 @@
</div>
</div>
<div v-else>
<h4>选择左侧线路进行管理</h4>
<h4 class="desktop-only">选择左侧线路进行管理</h4>
<h4 class="mobile-only-inline">请选择上方线路进行管理</h4>
</div>
<div class="flex">
@@ -223,8 +221,13 @@
</div>
<!-- 可视化线路编辑-->
<div class="visual-line-container">
<svg width="100%" height="200"
<div class="visual-line-container" ref="visualLineContainer"
:class="{ 'is-panning': isPanning }"
@mousedown="onPanStart"
@mousemove="onPanMove"
@mouseup="onPanEnd"
@mouseleave="onPanEnd">
<svg :style="{ minWidth: '100%', width: Math.max(100, 50 + selectedLine.stations.length * 120 + 50) + 'px' }" height="200"
v-if="selectedLine.stations && selectedLine.stations.length > 0">
<!--站点连接线-->
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100"
@@ -239,8 +242,10 @@
<!--车站节点-->
<g v-for="(sCode, index) in selectedLine.stations" :key="sCode"
@mousedown="onStationDragStart(index)" @mouseup="onStationDrop"
@mousemove="onStationDragOver(index)" @click="handleStationClick(sCode)"
@mousedown="onStationDragStart(index, $event)"
@mouseup="onStationDrop"
@click="handleStationClick(sCode)"
@touchstart.passive="onStationTouchStart(index, $event)"
class="station-node" :class="{
'selected': isStationSelected(sCode),
'fare-source': fareSelection[0] === sCode,
@@ -284,7 +289,6 @@
</div>
</div>
<!-- 票价设置弹窗 -->
<div v-if="showFareModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">设置票价</h4>
@@ -384,9 +388,72 @@
</div>
</div>
<div v-if="currentView === 'stations'" class="card">
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 12px;">
<h4 style="margin: 0;">站点总览 ({{ stations.length }})</h4>
<div class="flex" style="gap: 12px; flex-wrap: wrap;">
<div class="search-box">
<i class="fas fa-search"></i>
<input v-model="stationSearch" placeholder="搜索站点编号/名称..." style="width: 200px;">
</div>
<button @click="showAddStation = true"><i class="fas fa-plus"></i> 新增站点</button>
</div>
</div>
<div v-if="showAddStation" class="mb-4" style="background: rgba(255,255,255,0.05); padding: 15px; border-radius: 8px;">
<h5 style="margin-top:0; margin-bottom:12px;">创建新站点</h5>
<div class="flex" style="flex-wrap: wrap; gap: 12px;">
<input v-model="newStation.code" placeholder="站点编号 (必填)" style="flex: 1; min-width: 120px;">
<input v-model="newStation.name" placeholder="中文名称 (必填)" style="flex: 1; min-width: 120px;">
<input v-model="newStation.en_name" placeholder="英文名称" style="flex: 1; min-width: 120px;">
<button @click="addStationToLine" :disabled="!newStation.code || !newStation.name" style="min-width: 80px;">创建</button>
<button class="danger" @click="showAddStation = false" style="min-width: 80px;">取消</button>
</div>
<p class="text-muted mt-2" style="font-size: 0.85rem; margin-bottom: 0;"><i class="fas fa-info-circle"></i> 在此创建的站点将被保存到数据库中,可以在线路规划中添加到任意线路。</p>
</div>
<div class="table-responsive">
<table class="ticket-table" style="width: 100%;">
<thead>
<tr>
<th>站点编号</th>
<th>中文名称</th>
<th>英文名称</th>
<th>所属线路</th>
<th>换乘支持</th>
<th style="text-align: right;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="s in stationList" :key="s.code">
<td class="mono" style="font-weight:bold;">{{ s.code }}</td>
<td>{{ s.name || s.cn_name || '-' }}</td>
<td>{{ s.en_name || s.enName || '-' }}</td>
<td>
<div class="flex" style="gap: 4px; flex-wrap: wrap;">
<span v-for="l in getStationLines(s.code)" :key="l.id" class="badge" :style="{background: l.color, color: '#fff', border: 'none'}">{{ l.id }}</span>
<span v-if="getStationLines(s.code).length === 0" class="text-muted" style="font-size: 0.85em;">暂无线路</span>
</div>
</td>
<td>
<span v-if="s.transfer_enabled" class="badge" style="background: #10b981; color: white;">是</span>
<span v-else class="text-muted">-</span>
</td>
<td style="text-align: right;">
<button class="sm" @click="editStationGlobal(s.code)" title="编辑"><i class="fas fa-pen"></i></button>
<button class="danger sm" @click="deleteStationGlobal(s.code)" title="删除"><i class="fas fa-trash"></i></button>
</td>
</tr>
<tr v-if="stationList.length === 0">
<td colspan="6" class="text-center text-muted" style="padding: 30px;">无匹配站点</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 票价地图 -->
<div v-if="currentView === 'faremap'">
<div class="card faremap-card">
<div v-if="currentView === 'faremap'" class="card faremap-card">
<div class="flex between mb-4">
<h4>票价地图</h4>
<div class="flex" style="flex-wrap: wrap; gap: 8px;">
@@ -404,187 +471,112 @@
v-html="fareMapSvg">
</div>
</div>
</div>
</div>
<!-- 凭证管理 -->
<div v-if="currentView === 'vouchers'">
<div class="card">
<div class="flex between mb-4">
<h4>凭证列表</h4>
<div class="flex">
<button @click="fetchOrders"><i class="fas fa-sync-alt"></i></button>
<!-- 提取出来的弹窗,避免被 currentView = 'management' 屏蔽 -->
<div v-if="showFareModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">设置票价</h4>
<div class="mb-4 text-center">
<div class="flex between"
style="justify-content: center; gap: 20px; font-size: 1.1em; font-weight: bold;">
<span>{{ getStationName(fareSelection[0]) }}</span>
<i class="fas fa-arrow-right text-muted"></i>
<span>{{ getStationName(fareSelection[1]) }}</span>
</div>
</div>
<table class="ticket-table">
<thead>
<tr>
<th>凭证</th>
<th>线路</th>
<th>车型</th>
<th>票价</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orderList" :key="o.code">
<td class="mono" style="font-weight:bold; font-size:1.1em;">{{ o.code }}</td>
<td>{{ o.start_name }} <i class="fas fa-arrow-right text-muted"></i> {{
o.terminal_name }}</td>
<td>{{ formatTrainType(o.train_type) }}</td>
<td>{{ o.price }}</td>
<td><span class="badge" :class="formatTicketStatus(o.status).class">{{
formatTicketStatus(o.status).text }}</span></td>
<td>{{ formatTime(o.created_ts) }}</td>
<td>
<div class="flex" style="gap:4px;">
<a :href="'token.html?code='+o.code" target="_blank" class="btn sm"
title="查看"
style="height:28px; width:28px; padding:0; display:flex; align-items:center; justify-content:center;"><i
class="fas fa-eye"></i></a>
<button class="danger sm" @click="deleteOrder(o.code)"
style="height:28px; width:28px; padding:0; display:flex; align-items:center; justify-content:center;"><i
class="fas fa-trash"></i></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 车票记录 -->
<div v-if="currentView === 'tickets'">
<div class="card mb-4">
<div class="flex between mb-4">
<h4>车票记录</h4>
<div class="flex">
<input v-model="ticketSearch" placeholder="搜索 Ticket ID / 站点" style="width: 200px;">
<button @click="refreshData"><i class="fas fa-sync-alt"></i></button>
</div>
</div>
<table class="ticket-table">
<thead>
<tr>
<th>ID</th>
<th>起点</th>
<th>终点</th>
<th>类型</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<tr v-for="t in ticketList" :key="t.ticket_id" @click="viewTicketDetails(t)"
class="clickable-row">
<td><span class="mono">{{ t.ticket_id }}</span></td>
<td>
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ getStationInfo(t.start).name }}</span>
<span class="st-code">{{ t.start }}</span>
</div>
<div class="st-en">{{ getStationInfo(t.start).en_name }}</div>
</div>
</td>
<td>
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ getStationInfo(t.terminal).name }}</span>
<span class="st-code">{{ t.terminal }}</span>
</div>
<div class="st-en">{{ getStationInfo(t.terminal).en_name }}</div>
</div>
</td>
<td>{{ formatTrainType(t.type) }}</td>
<td><span class="badge" :class="formatTicketStatus(t.status).class">{{
formatTicketStatus(t.status).text }}</span></td>
<td>{{ formatTime(t.ts) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 车票详情弹窗 -->
<div v-if="showTicketModal" class="modal show" @click.self="closeTicketModal">
<div class="modal-card" style="width: 600px;">
<div class="flex between mb-4">
<h4 class="modal-title">车票详情</h4>
<button class="sm" @click="closeTicketModal" title="关闭"><i
class="fas fa-times"></i></button>
</div>
<div v-if="selectedTicket">
<div class="ticket-header mb-4"
style="background: rgba(255,255,255,0.05); padding: 16px; border-radius: 8px;">
<div class="flex between mb-2">
<span class="mono text-muted">{{ selectedTicket.ticket_id }}</span>
<span class="badge"
:class="formatTicketStatus(selectedTicket.index.status).class">
{{ formatTicketStatus(selectedTicket.index.status).text }}
</span>
</div>
<div class="flex between" style="font-size: 1.2rem; font-weight: bold;">
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ selectedTicket.index.start_name ||
selectedTicket.index.start }}</span>
<span class="st-code">{{ selectedTicket.index.start }}</span>
</div>
<div class="st-en">{{ selectedTicket.index.start_en || '' }}</div>
</div>
<i class="fas fa-arrow-right text-muted"></i>
<div class="st-container" style="align-items: flex-end;">
<div class="st-main-row">
<span class="st-name">{{ selectedTicket.index.terminal_name ||
selectedTicket.index.terminal }}</span>
<span class="st-code">{{ selectedTicket.index.terminal }}</span>
</div>
<div class="st-en">{{ selectedTicket.index.terminal_en || '' }}</div>
</div>
</div>
<div class="text-muted mt-2" style="font-size: 0.9rem;">
类型: {{ formatTrainType(selectedTicket.index.type ||
selectedTicket.index.train_type) }} | 票价: {{ selectedTicket.index.price ||
selectedTicket.index.cost }}
</div>
</div>
<h5>行程记录</h5>
<div class="timeline">
<div v-for="ev in selectedTicket.events" :key="ev.ts || ev['时间戳']" class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<div class="flex between">
<span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span>
</div>
<div class="text-muted" style="font-size: 0.9rem; margin-top: 4px;">
{{ formatTicketEventUnified(ev) }}
</div>
</div>
</div>
</div>
</div>
<div v-else class="loading">加载中...</div>
</div>
</div>
</div>
<!-- 设置 -->
<div v-if="currentView === 'settings'">
<div class="card mb-4">
<h4>优惠设置</h4>
<div class="mb-4">
<label style="display:block; margin-bottom:8px; font-weight:600;">优惠活动</label>
<div class="flex">
<input v-model="config.promotion.name" placeholder="活动名称">
<input v-model.number="config.promotion.discount" type="number" step="0.1"
placeholder="折扣 (0.1-1.0)">
<button @click="saveConfig"><i class="fas fa-save"></i> 保存</button>
<label>常规票价</label>
<input v-model.number="currentFare.cost_regular" type="number" class="w-100">
</div>
<div class="mb-4">
<label>特急票价</label>
<input v-model.number="currentFare.cost_express" type="number" class="w-100">
</div>
<div class="modal-actions">
<button class="danger" @click="deleteCurrentFare"
v-if="currentFare.exists">删除</button>
<button @click="saveCurrentFare">保存</button>
<button class="danger" @click="closeFareModal">取消</button>
</div>
</div>
</div>
<div v-if="showStationModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">站点编辑</h4>
<div class="mb-4">
<label>站点编号</label>
<input v-model="stationForm.code" class="w-100">
</div>
<div class="mb-4">
<label>中文名</label>
<input v-model="stationForm.name" class="w-100">
</div>
<div class="mb-4">
<label>英文名</label>
<input v-model="stationForm.en_name" class="w-100">
</div>
<div class="mb-4">
<label>所属线路</label>
<select v-model="stationForm.lines" multiple class="w-100" style="height: 100px;">
<option v-for="l in lines" :key="l.id" :value="l.id">
{{ l.name || l.id }} ({{ l.id }})
</option>
</select>
<p class="text-muted mt-1" style="font-size: 0.8em;">按住 Ctrl / Command 多选,保存后会自动同步到线路图中。</p>
</div>
<div class="mb-4">
<label class="switch-label">
<input type="checkbox" v-model="stationForm.transfer_enabled">
<span class="slider"></span>
<span class="label-text">可换乘</span>
</label>
</div>
<div class="mb-4">
<label>可换乘到的站点</label>
<select v-model="stationForm.transfer_to" multiple class="w-100"
:disabled="!stationForm.transfer_enabled" style="height: 180px;">
<option v-for="t in transferTargets" :key="t.code" :value="t.code">
{{ t.name }} ({{ t.en_name }}) - {{ t.code }}
</option>
</select>
</div>
<div class="modal-actions">
<button class="danger" @click="deleteStation(stationFormOriginalCode)">删除</button>
<button @click="saveStationSettings">保存</button>
<button class="danger" @click="closeStationModal">取消</button>
</div>
</div>
</div>
<div v-if="showLineModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">线路编辑</h4>
<div class="mb-4">
<label>线路编号</label>
<input v-model="lineForm.id" class="w-100">
</div>
<div class="mb-4">
<label>中文名</label>
<input v-model="lineForm.name" class="w-100">
</div>
<div class="mb-4">
<label>英文名</label>
<input v-model="lineForm.en_name" class="w-100">
</div>
<div class="mb-4">
<label>颜色</label>
<div class="flex" style="gap:8px;">
<input type="text" v-model="lineForm.color" class="w-100" placeholder="#3366cc">
<input type="color" v-model="lineForm.color" title="选择颜色"
style="width: 48px; padding: 0; border: none; height: 32px;">
</div>
</div>
<div class="modal-actions">
<button @click="saveLineSettings">保存</button>
<button class="danger" @click="closeLineModal">取消</button>
</div>
</div>
</div>
@@ -593,6 +585,7 @@
<span class="version">v1.0.12</span>
</footer>
</div>
</div>
</div>
@@ -600,7 +593,7 @@
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script src="ticket-route.js?v=3"></script>
<script src="ticket-route.js?v=8"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+193 -52
View File
@@ -15,17 +15,16 @@ createApp({
const sidebarOpen = ref(false);
const viewTitle = computed(() => {
const map = {
dashboard: '仪表盘',
management: '线路与票价管理',
tickets: '车票记录',
settings: '系统设置'
stations: '站点管理',
faremap: '票价地图'
};
return map[currentView.value] || '线路规划系统';
});
const connected = ref(false);
// Keep the legacy route console usable behind proxies that only allow polling.
const socket = io({ transports: ['polling', 'websocket'], timeout: 20000 });
// 强制使用 WebSocket,避免轮询 (polling) 在负载均衡/反代下产生 400 Bad Request
const socket = io({ transports: ['websocket'], timeout: 20000 });
const stations = ref([]);
@@ -400,24 +399,38 @@ createApp({
selectedTicket.value = null;
};
/* 拖动调整站序 */
const onStationDragStart = (index) => {
if (fareMode.value) return;
draggingStationIndex.value = index;
/* 拖动调整站序 - 统一实现(鼠标+触摸) */
const dragActiveSvg = ref(null);
const computeStationIndexFromClientXY = (clientX, clientY) => {
const svgEl = dragActiveSvg.value;
if (!svgEl || !selectedLine.value) return -1;
const pt = svgEl.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const ctm = svgEl.getScreenCTM();
if (!ctm) return -1;
const loc = pt.matrixTransform(ctm.inverse());
return Math.max(0, Math.min(
(selectedLine.value.stations?.length || 1) - 1,
Math.round((loc.x - 50) / 120)
));
};
const onStationDragOver = (index) => {
if (draggingStationIndex.value === null) return;
if (draggingStationIndex.value === index) return;
const list = selectedLine.value.stations;
const temp = list[draggingStationIndex.value];
list.splice(draggingStationIndex.value, 1);
list.splice(index, 0, temp);
draggingStationIndex.value = index;
const swapStationByIndex = (targetIndex) => {
if (draggingStationIndex.value === null) return false;
if (targetIndex < 0) return false;
if (draggingStationIndex.value === targetIndex) return false;
const list = selectedLine.value.stations;
if (!list) return false;
const temp = list[draggingStationIndex.value];
list.splice(draggingStationIndex.value, 1);
list.splice(targetIndex, 0, temp);
draggingStationIndex.value = targetIndex;
return true;
};
const onStationDrop = async () => {
const saveDragResult = async () => {
if (draggingStationIndex.value === null) return;
try {
await updateLineStations(selectedLine.value.id, selectedLine.value.stations);
@@ -426,6 +439,88 @@ createApp({
await fetchData();
}
draggingStationIndex.value = null;
dragActiveSvg.value = null;
};
/* 鼠标事件 */
const onStationDragStart = (index, event) => {
if (fareMode.value) return;
draggingStationIndex.value = index;
const svgEl = (event.currentTarget && event.currentTarget.closest)
? event.currentTarget.closest('svg')
: null;
dragActiveSvg.value = svgEl || document.querySelector('.visual-line-container svg');
};
const onStationDragOver = (index) => {
swapStationByIndex(index);
};
const onStationDrop = async () => {
await saveDragResult();
};
const globalMouseMoveHandler = (e) => {
if (draggingStationIndex.value === null) return;
const targetIndex = computeStationIndexFromClientXY(e.clientX, e.clientY);
swapStationByIndex(targetIndex);
};
const globalMouseUpHandler = async () => {
if (draggingStationIndex.value === null) return;
await saveDragResult();
};
/* 触摸事件 - 移动端拖拽支持 */
const onStationTouchStart = (index, event) => {
if (fareMode.value) return;
if (!event.touches || event.touches.length !== 1) return;
draggingStationIndex.value = index;
const svgEl = (event.currentTarget && event.currentTarget.closest)
? event.currentTarget.closest('svg')
: null;
dragActiveSvg.value = svgEl || document.querySelector('.visual-line-container svg');
};
const globalTouchMoveHandler = (e) => {
if (draggingStationIndex.value === null) return;
if (!e.touches || e.touches.length !== 1) return;
if (e.cancelable) e.preventDefault();
const t = e.touches[0];
const targetIndex = computeStationIndexFromClientXY(t.clientX, t.clientY);
swapStationByIndex(targetIndex);
};
const globalTouchEndHandler = async () => {
if (draggingStationIndex.value === null) return;
await saveDragResult();
};
/* 画布平移*/
const visualLineContainer = ref(null);
const isPanning = ref(false);
const panStartX = ref(0);
const panScrollLeft = ref(0);
const onPanStart = (e) => {
if (e.button !== 0) return;
if (e.target.closest && e.target.closest('.station-node')) return;
isPanning.value = true;
panStartX.value = e.clientX;
if (visualLineContainer.value) {
panScrollLeft.value = visualLineContainer.value.scrollLeft;
}
};
const onPanMove = (e) => {
if (!isPanning.value || !visualLineContainer.value) return;
e.preventDefault();
const dx = e.clientX - panStartX.value;
visualLineContainer.value.scrollLeft = panScrollLeft.value - dx;
};
const onPanEnd = () => {
isPanning.value = false;
};
/* 订单管理 */
@@ -554,6 +649,7 @@ createApp({
})) return;
await runMutation(async () => {
await requestJson(`/api/stations/${encodeURIComponent(code)}`, { method: 'DELETE' }, { expectOk: true });
await fetchData();
});
};
@@ -595,7 +691,6 @@ createApp({
};
const addStationToLine = async () => {
if (!selectedLine.value) return;
const { code, name, en_name } = newStation;
if (!code || !name) return alert('请填写编号和名称');
@@ -610,12 +705,18 @@ createApp({
}, { expectOk: true });
}
if (isStationInLine(code)) throw new Error('该站点已在此线路中');
const newStations = [...(selectedLine.value.stations || [])];
newStations.push(code);
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
if (selectedLine.value) {
if (isStationInLine(code)) throw new Error('该站点已在此线路中');
const newStations = [...(selectedLine.value.stations || [])];
newStations.push(code);
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
}
Object.assign(newStation, { code: '', name: '', en_name: '' });
if (currentView.value === 'stations') {
showAddStation.value = false;
}
await fetchData();
});
};
@@ -648,6 +749,10 @@ createApp({
stationForm.en_name = s.en_name || s.enName || '';
stationForm.transfer_enabled = !!s.transfer_enabled;
stationForm.transfer_to = Array.isArray(s.transfer_to) ? [...s.transfer_to] : [];
// 找出该站点当前所属的线路
stationForm.lines = lines.value.filter(l => l.stations && l.stations.includes(s.code || code)).map(l => l.id);
showStationModal.value = true;
};
@@ -676,8 +781,36 @@ createApp({
transfer_to: stationForm.transfer_enabled ? (Array.isArray(stationForm.transfer_to) ? stationForm.transfer_to : []) : []
};
await runMutation(async () => {
// 1. 保存站点本身信息
await requestJson(`/api/stations/${encodeURIComponent(oldCode)}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true });
// 2. 更新所属线路
const newLinesIds = Array.isArray(stationForm.lines) ? stationForm.lines : [];
for (const line of lines.value) {
const wasInLine = line.stations && line.stations.includes(oldCode);
const shouldBeInLine = newLinesIds.includes(line.id);
let lineStations = [...(line.stations || [])];
let changed = false;
if (wasInLine && !shouldBeInLine) {
lineStations = lineStations.filter(c => c !== oldCode);
changed = true;
} else if (!wasInLine && shouldBeInLine) {
lineStations.push(newCode);
changed = true;
} else if (wasInLine && shouldBeInLine && oldCode !== newCode) {
lineStations = lineStations.map(c => c === oldCode ? newCode : c);
changed = true;
}
if (changed) {
await updateLineStations(line.id, lineStations, { skipFetchData: true });
}
}
showStationModal.value = false;
await fetchData();
});
};
@@ -889,42 +1022,47 @@ createApp({
});
watch(currentView, () => { sidebarOpen.value = false; });
/* 失败 */
onMounted(() => {
fetchData();
loadFareMap();
window.addEventListener('mouseup', async () => {
if (draggingStationIndex.value !== null) {
if (selectedLine.value) {
try {
await requestJson(`/api/lines/${encodeURIComponent(selectedLine.value.id)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ ...(lines.value.find(l => l.id === selectedLine.value.id) || selectedLine.value), stations: selectedLine.value.stations })
}, { expectOk: true });
await fetchData();
} catch (e) {
alert(`保存站序失败:${e?.message || String(e)}`);
await fetchData();
}
}
draggingStationIndex.value = null;
}
});
window.addEventListener('mousemove', globalMouseMoveHandler);
window.addEventListener('mouseup', globalMouseUpHandler);
window.addEventListener('touchmove', globalTouchMoveHandler, { passive: false });
window.addEventListener('touchend', globalTouchEndHandler);
window.addEventListener('touchcancel', globalTouchEndHandler);
});
/* 计算 */
const recentLogs = computed(() => logs.value);
const orderList = computed(() => orders.value);
const stationSearch = ref('');
const stationList = computed(() => {
if (!stationSearch.value) return stations.value;
const q = stationSearch.value.toLowerCase();
return stations.value.filter(s =>
(s.code && s.code.toLowerCase().includes(q)) ||
(s.name && s.name.toLowerCase().includes(q)) ||
(s.en_name && s.en_name.toLowerCase().includes(q))
);
});
const getStationLines = (code) => {
return lines.value.filter(l => l.stations && l.stations.includes(code));
};
const editStationGlobal = (code) => {
stationEditMode.value = true;
openStationModal(code);
// reset mode when modal closes is already handled? Wait, we need to ensure stationEditMode state isn't breaking anything, but openStationModal is generic.
};
const deleteStationGlobal = async (code) => {
await deleteStation(code);
};
const ticketList = computed(() => {
if (!ticketSearch.value) return tickets.value.slice(0, 50);
const q = ticketSearch.value.toLowerCase();
return tickets.value.filter(t =>
t.ticket_id.toLowerCase().includes(q) ||
(t.start && t.start.toLowerCase().includes(q)) ||
(t.terminal && t.terminal.toLowerCase().includes(q))
).slice(0, 50);
return [];
});
const exportFareMap = () => {
@@ -976,9 +1114,12 @@ createApp({
isStationInLine, addStationToLine, removeStationFromLine,
handleStationClick, isStationSelected,
onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex,
onStationTouchStart,
visualLineContainer, isPanning, onPanStart, onPanMove, onPanEnd,
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,
stationSearch, stationList, getStationLines, editStationGlobal, deleteStationGlobal,
/* 订单 */
fetchOrders, deleteOrder,
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventUnified, formatLogType, formatTrainType,
+2 -6
View File
@@ -7,7 +7,7 @@
<title>票务查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14" />
<link rel="stylesheet" href="/style.css?v=22" />
</head>
<body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
+2 -6
View File
@@ -7,7 +7,7 @@
<title>凭证详情|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14" />
<link rel="stylesheet" href="/style.css?v=22" />
</head>
<body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">