Compare commits

...
42 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
28 changed files with 3432 additions and 1636 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 返回中提供)
+424 -100
View File
@@ -1,8 +1,11 @@
local DEFAULT_SERVER_BASE = "https://ticket.fse-media.group" local DEFAULT_SERVER_BASE = "https://ticket.fse-media.group"
local DEFAULT_SERVER_PATH = "/api/tickets/check" local DEFAULT_SERVER_PATH = "/api/tickets/check"
local GATE_OPEN_SECONDS = 2 local GATE_OPEN_SECONDS = 2
local VERSION = "v1.5.23" local VERSION = "v1.5.33"
local VERSION_CHECK_INTERVAL = 5 local VERSION_CHECK_INTERVAL = 15
local PERIPHERAL_WAIT_INTERVAL = 0.2
local PERIPHERAL_WAIT_MAX_RETRIES = 20
local serverURL = nil local serverURL = nil
@@ -86,9 +89,97 @@ local function stationSetFromList(list)
return set return set
end end
local monitor = peripheral.find("monitor") local nativeTerm = (term and type(term.current) == "function" and term.current()) or term
local speaker = peripheral.find("speaker") local monitor = nil
local inspection = peripheral.find("ticket_inspection_machine") local speaker = nil
local inspection = nil
local monitorSide = nil
local function safeFindPeripheral(peripheralType, sideHint)
if not peripheral or type(peripheral.find) ~= "function" then return nil, nil end
if sideHint and type(peripheral.wrap) == "function" and type(peripheral.isPresent) == "function" then
local okPres, pres = pcall(peripheral.isPresent, sideHint)
if okPres and pres then
local okW, wrapped = pcall(peripheral.wrap, sideHint)
if okW and wrapped then return wrapped, sideHint end
end
end
local foundName = nil
local found = nil
pcall(function()
found = peripheral.find(peripheralType, function(name, wrapped)
foundName = name
return true
end)
end)
if found then return found, foundName end
return nil, nil
end
local function waitForPeripherals(requireInspection)
local attempts = 0
local lastDrawMsg = nil
while attempts < PERIPHERAL_WAIT_MAX_RETRIES do
local hadChange = false
if not monitor then
local m, ms = safeFindPeripheral("monitor", monitorSide)
if m then
monitor = m
monitorSide = ms
termDev = monitor
pcall(function() handshakeMonitor(monitor) end)
hadChange = true
end
end
if not speaker then
local s, _ = safeFindPeripheral("speaker")
if s then speaker = s; hadChange = true end
end
if not inspection and requireInspection then
local i, _ = safeFindPeripheral("ticket_inspection_machine")
if i then inspection = i; hadChange = true end
end
local ok = true
if not monitor then ok = false end
if requireInspection and not inspection then ok = false end
if ok then return true end
local msg = nil
if not monitor then
msg = "Waiting for monitor..."
elseif requireInspection and not inspection then
msg = "Waiting for gate peripheral..."
end
if msg and msg ~= lastDrawMsg then
lastDrawMsg = msg
pcall(function()
local tmpTerm = nativeTerm or term
if tmpTerm and type(tmpTerm.getSize) == "function" then
tmpTerm.setBackgroundColor(colors.black)
tmpTerm.setTextColor(colors.yellow)
tmpTerm.clear()
tmpTerm.setCursorPos(1, 1)
local w, _ = tmpTerm.getSize()
local pad = string.rep(" ", math.max(0, math.floor((w - #VERSION) / 2)))
tmpTerm.write(pad .. VERSION)
local msg2 = tostring(msg)
tmpTerm.setCursorPos(1, 3)
local pad2 = string.rep(" ", math.max(0, math.floor((w - #msg2) / 2)))
tmpTerm.write(pad2 .. msg2)
tmpTerm.setCursorPos(1, 5)
tmpTerm.setTextColor(colors.gray)
local prog = "Attempt " .. tostring(attempts + 1) .. "/" .. tostring(PERIPHERAL_WAIT_MAX_RETRIES)
local pad3 = string.rep(" ", math.max(0, math.floor((w - #prog) / 2)))
tmpTerm.write(pad3 .. prog)
end
end)
end
sleep(PERIPHERAL_WAIT_INTERVAL)
attempts = attempts + 1
end
return not not monitor
end
pcall(function() waitForPeripherals(false) end)
local serverConnected = nil local serverConnected = nil
local isUpdating = false local isUpdating = false
@@ -162,53 +253,125 @@ local function jsonDecode(str)
if ok then return res else return nil end if ok then return res else return nil end
end end
local function setServerConnected(ok) local termDev = nativeTerm
if serverConnected == ok then return end if peripheral and type(peripheral.getName) == "function" and monitor then
serverConnected = ok pcall(function() monitorSide = peripheral.getName(monitor) end)
serverLastChangeTs = os.epoch("utc")
end end
local termDev = term local function safeCallTerm(method, ...)
if monitor then if not termDev or type(termDev[method]) ~= "function" then return nil end
pcall(monitor.setTextScale, 0.5) local ok, r1, r2, r3 = pcall(termDev[method], ...)
termDev = monitor if not ok then return nil end
return r1, r2, r3
end
local function safeCallMonitor(target, method, ...)
if not target or type(target[method]) ~= "function" then return false end
local ok, r1 = pcall(target[method], ...)
if not ok then return false end
return r1 ~= false
end
local function handshakeMonitor(target)
if not target then return false end
safeCallMonitor(target, "setTextScale", 0.5)
safeCallMonitor(target, "setBackgroundColor", colors.black)
safeCallMonitor(target, "setTextColor", colors.white)
safeCallMonitor(target, "clear")
local w, h = safeCallTerm("getSize")
return (type(w) == "number" and type(h) == "number" and w >= 2 and h >= 2)
end
local function refreshDisplayTarget()
local nextMonitor = nil
local nextMonitorSide = monitorSide
if peripheral then
if nextMonitorSide and type(peripheral.isPresent) == "function" then
local okPres, pres = pcall(peripheral.isPresent, nextMonitorSide)
if okPres and pres and type(peripheral.wrap) == "function" then
local okWrap, wrapped = pcall(peripheral.wrap, nextMonitorSide)
if okWrap and wrapped then nextMonitor = wrapped end
end
end
if not nextMonitor and type(peripheral.find) == "function" then
local foundName = nil
local found = nil
pcall(function()
found = peripheral.find("monitor", function(name, wrapped)
foundName = name
return true
end)
end)
if found then
nextMonitor = found
nextMonitorSide = foundName
else
nextMonitorSide = nil
end
end
end
if nextMonitor and nextMonitor ~= monitor then
monitor = nextMonitor
monitorSide = nextMonitorSide
pcall(function() handshakeMonitor(nextMonitor) end)
elseif nextMonitor then
monitor = nextMonitor
monitorSide = nextMonitorSide
end
if monitor then
if not safeCallMonitor(monitor, "setTextScale", 0.5) then
pcall(function() handshakeMonitor(monitor) end)
end
termDev = monitor
else
termDev = nativeTerm or term
end
return termDev
end end
local function clear() local function clear()
termDev.setBackgroundColor(colors.black) pcall(refreshDisplayTarget)
termDev.setTextColor(colors.white) if not termDev then return end
termDev.clear() safeCallTerm("setBackgroundColor", colors.black)
termDev.setCursorPos(1, 1) safeCallTerm("setTextColor", colors.white)
safeCallTerm("clear")
safeCallTerm("setCursorPos", 1, 1)
end end
local function centerText(y, text, color) local function centerText(y, text, color)
local w = termDev.getSize() if not termDev then return end
termDev.setTextColor(color or colors.white) local w = safeCallTerm("getSize")
local x = math.max(1, math.floor((w - #text) / 2) + 1) w = tonumber(w) or 0
termDev.setCursorPos(x, y) if w < 2 then return end
termDev.write(text) safeCallTerm("setTextColor", color or colors.white)
local x = math.max(1, math.floor((w - #tostring(text or "")) / 2) + 1)
safeCallTerm("setCursorPos", x, y)
safeCallTerm("write", tostring(text or ""))
end end
local lastStartupRedrawAt = 0
local function drawServerStatusIndicator(w) local function drawServerStatusIndicator(w)
w = tonumber(w) or 0
if w < 2 then return end if w < 2 then return end
local col = colors.yellow local col = colors.yellow
if serverConnected == true then col = colors.lime if serverConnected == true then col = colors.lime
elseif serverConnected == false then col = colors.red end elseif serverConnected == false then col = colors.red end
termDev.setBackgroundColor(colors.black) safeCallTerm("setBackgroundColor", colors.black)
termDev.setTextColor(col) safeCallTerm("setTextColor", col)
termDev.setCursorPos(w - 1, 1) safeCallTerm("setCursorPos", w - 1, 1)
termDev.write("S") safeCallTerm("write", "S")
termDev.setCursorPos(w, 1) safeCallTerm("setCursorPos", w, 1)
termDev.write("*") safeCallTerm("write", "*")
termDev.setTextColor(colors.white) safeCallTerm("setTextColor", colors.white)
end end
local lastUpdateError = nil local lastUpdateError = nil
local function drawVersionIndicator(w) local function drawVersionIndicator(w)
w = tonumber(w) or 0
local s = tostring(VERSION or "") local s = tostring(VERSION or "")
if #s == 0 then return end if #s == 0 or w < #s then return end
if w < #s then return end
local markerColor = colors.lime local markerColor = colors.lime
local markerText = "* " local markerText = "* "
if isUpdating then if isUpdating then
@@ -218,37 +381,61 @@ local function drawVersionIndicator(w)
markerColor = colors.red markerColor = colors.red
markerText = "* " markerText = "* "
end end
termDev.setBackgroundColor(colors.black) safeCallTerm("setBackgroundColor", colors.black)
termDev.setTextColor(colors.gray) safeCallTerm("setTextColor", colors.gray)
termDev.setCursorPos(1, 1) safeCallTerm("setCursorPos", 1, 1)
termDev.write(s) safeCallTerm("write", s)
termDev.setTextColor(markerColor) safeCallTerm("setTextColor", markerColor)
termDev.write(markerText) safeCallTerm("write", markerText)
if lastUpdateError then if lastUpdateError then
termDev.setCursorPos(1, 2) safeCallTerm("setCursorPos", 1, 2)
termDev.setTextColor(colors.red) safeCallTerm("setTextColor", colors.red)
termDev.write(string.sub(tostring(lastUpdateError), 1, w)) safeCallTerm("write", string.sub(tostring(lastUpdateError), 1, w))
end end
termDev.setTextColor(colors.white) safeCallTerm("setTextColor", colors.white)
end
local _cachedDrawArgHash = nil
local function splitDisplayLines(s)
if s == nil then return {} end
local str = tostring(s)
if #trim(str) == 0 then return {} end
local lines = {}
for line in str:gmatch("[^\n]+") do
table.insert(lines, line)
end
if #lines == 0 then table.insert(lines, str) end
return lines
end end
local function draw(statusLine1, statusLine2, statusColor) local function draw(statusLine1, statusLine2, statusColor)
clear() clear()
local w, h = termDev.getSize() if not termDev then return end
local w, h = safeCallTerm("getSize")
w = tonumber(w) or 0
h = tonumber(h) or 0
if w < 2 or h < 2 then return end
centerText(2, "GATE", colors.cyan) centerText(2, "GATE", colors.cyan)
drawVersionIndicator(w) drawVersionIndicator(w)
drawServerStatusIndicator(w) drawServerStatusIndicator(w)
if statusLine1 and #statusLine1 > 0 then local lines = {}
centerText(math.max(2, math.floor(h / 2)), statusLine1, statusColor or colors.white) for _, line in ipairs(splitDisplayLines(statusLine1)) do table.insert(lines, line) end
for _, line in ipairs(splitDisplayLines(statusLine2)) do table.insert(lines, line) end
if #lines > 0 then
local maxBodyY = math.max(3, h - 1)
local startY = math.max(3, math.floor((maxBodyY - #lines) / 2) + 1)
for idx, line in ipairs(lines) do
local y = startY + idx - 1
if y > maxBodyY then break end
centerText(y, line, statusColor or colors.white)
end
end end
if statusLine2 and #statusLine2 > 0 then safeCallTerm("setCursorPos", 1, h)
centerText(math.min(h, math.max(3, math.floor(h / 2) + 1)), statusLine2, statusColor or colors.white) safeCallTerm("setTextColor", colors.gray)
end safeCallTerm("write", string.rep(" ", w))
termDev.setCursorPos(1, h)
termDev.setTextColor(colors.gray)
termDev.write(string.rep(" ", w))
end end
local function pulseLeftRedstone(seconds) local function pulseLeftRedstone(seconds)
@@ -770,15 +957,31 @@ if type(cfg.side_station_codes) == "table" then
end end
pcall(function() pcall(function()
refreshStationNameMap(guessBaseFromStatusURL(serverURL))
end)
pcall(function()
refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL))
end)
if not inspection then
if modeBySide == nil then if modeBySide == nil then
waitForPeripherals(true)
else
for side, _ in pairs(modeBySide) do
local dev = resolveInspection(side)
local retryCount = 0
while not dev and retryCount < PERIPHERAL_WAIT_MAX_RETRIES do
sleep(PERIPHERAL_WAIT_INTERVAL)
dev = resolveInspection(side)
retryCount = retryCount + 1
end
end
if not monitor then waitForPeripherals(false) end
end
end)
if not inspection and modeBySide == nil then
local okDev = false
if peripheral and type(peripheral.find) == "function" then
pcall(function()
inspection = peripheral.find("ticket_inspection_machine")
if inspection then okDev = true end
end)
end
if not okDev then
draw("Missing peripheral:", "ticket_inspection_machine", colors.red) draw("Missing peripheral:", "ticket_inspection_machine", colors.red)
error("ticket_inspection_machine not found") error("ticket_inspection_machine not found")
end end
@@ -814,6 +1017,15 @@ local function stationCodeForSide(side)
return defaultStationCode() return defaultStationCode()
end end
local function displayStationCode(code)
local s = trim(code)
if #s == 0 then return s end
if s:sub(1, 2):upper() == "ST" then
s = s:sub(3):gsub("^[%s%-_]+", "")
end
return s
end
local function isInspectionPeripheral(p) local function isInspectionPeripheral(p)
return type(p) == "table" and ( return type(p) == "table" and (
type(p.getLastScanned) == "function" type(p.getLastScanned) == "function"
@@ -909,15 +1121,38 @@ local function readyLine1()
end end
local function readyLine2() local function readyLine2()
if not modeBySide then local lines = {}
return "ST " .. stationCodeForSide(nil) local seen = {}
local function addLine(line)
line = trim(line)
if #line == 0 then return end
local key = line:lower()
if seen[key] then return end
seen[key] = true
table.insert(lines, line)
end end
local frontCode = stationCodeForSide("front")
local backCode = stationCodeForSide("back") if type(sideStationCodeBySide) == "table" and next(sideStationCodeBySide) ~= nil then
if frontCode == backCode then local orderedSides = { "front", "back", "left", "right", "top", "bottom" }
return "ST " .. frontCode for _, side in ipairs(orderedSides) do
local code = displayStationCode(sideStationCodeBySide[side] or "")
if #code > 0 then
addLine(string.upper(side:sub(1, 1)) .. " " .. code)
end
end
end end
return "F " .. frontCode .. " B " .. backCode
if #lines == 0 then
for _, code in ipairs(stationCodesPayload) do
addLine(displayStationCode(code))
end
end
if #lines == 0 then
addLine(displayStationCode(stationCodeForSide(nil)))
end
return table.concat(lines, "\n")
end end
local function drawReadyScreen() local function drawReadyScreen()
@@ -972,19 +1207,19 @@ local function updateDeviceField(dev, key, value)
pcall(dev.updateTicket, key, value) pcall(dev.updateTicket, key, value)
end end
local function callTicketStateMethod(dev, methodName) local function callTicketStateMethod(dev, methodName, ...)
if type(dev) ~= "table" then return false end if type(dev) ~= "table" then return false end
local fn = dev[methodName] local fn = dev[methodName]
if type(fn) ~= "function" then return false end if type(fn) ~= "function" then return false end
local okCall, okRes = pcall(fn) local okCall, okRes = pcall(fn, ...)
if not okCall then return false end if not okCall then return false end
return okRes ~= false return okRes ~= false
end end
local function applyTicketDeviceState(dev, action, rides) local function applyTicketDeviceState(dev, action, rides, stationCode)
if type(dev) ~= "table" then return end if type(dev) ~= "table" then return end
if action == "entry" then if action == "entry" then
if not callTicketStateMethod(dev, "markEntered") then if not callTicketStateMethod(dev, "markEntered", stationCode) then
updateDeviceField(dev, "entered", true) updateDeviceField(dev, "entered", true)
updateDeviceField(dev, "exited", false) updateDeviceField(dev, "exited", false)
end end
@@ -1003,13 +1238,32 @@ local function applyTicketDeviceState(dev, action, rides)
end end
local function updateICCardField(dev, key, value) local function updateICCardField(dev, key, value)
if type(dev) ~= "table" or type(dev.updateICCard) ~= "function" then return false end if type(dev) ~= "table" then return false end
if type(dev.updateICCard) ~= "function" then return true end
local okCall, okRes, detail = pcall(dev.updateICCard, key, value) local okCall, okRes, detail = pcall(dev.updateICCard, key, value)
if not okCall then return false, tostring(okRes) end if not okCall then return false, tostring(okRes) end
if okRes == false then return false, tostring(detail or "update_failed") end if okRes == false then return false, tostring(detail or "update_failed") end
return true return true
end end
local function updateICCardFieldWithFallback(dev, keys, value)
if type(keys) == "string" then
return updateICCardField(dev, keys, value)
end
local firstErr = nil
if type(keys) ~= "table" then return false, "invalid_keys" end
for _, key in ipairs(keys) do
local okField, errField = updateICCardField(dev, key, value)
if okField then
return true, key
end
if not firstErr then
firstErr = tostring(key) .. ": " .. tostring(errField or "update_failed")
end
end
return false, firstErr or "update_failed"
end
local function updateICCardFields(dev, patch) local function updateICCardFields(dev, patch)
local allOk = true local allOk = true
local firstErr = nil local firstErr = nil
@@ -1086,9 +1340,35 @@ local function queryFare(fromStation, toStation)
return toMoney(fare), nil return toMoney(fare), nil
end end
local function denyCard(reason, detail) local function triggerFaultLights(devs)
local targets = {}
if type(devs) == "table" then
for _, d in ipairs(devs) do table.insert(targets, d) end
end
if inspection and not targets[1] then table.insert(targets, inspection) end
if modeBySide then
for side, _ in pairs(modeBySide) do
local d = resolveInspection(side)
if d then
local found = false
for _, t in ipairs(targets) do if t == d then found = true; break end end
if not found then table.insert(targets, d) end
end
end
end
for _, dev in ipairs(targets) do
if type(dev) == "table" and type(dev.markFault) == "function" then
pcall(dev.markFault)
end
end
end
local function denyCard(reason, detail, inspectionDevs)
draw("DENIED", tostring(detail or reason or "deny"), colors.red) draw("DENIED", tostring(detail or reason or "deny"), colors.red)
playDfpwm("error.dfpwm") parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
end end
local function deductICCardBalance(dev, amount) local function deductICCardBalance(dev, amount)
@@ -1134,7 +1414,10 @@ local function handleICCardScan(scan, side, scanDev)
local cardId = getCardId(scan) local cardId = getCardId(scan)
if #cardId == 0 then if #cardId == 0 then
draw("Invalid card.", "Missing card_id.", colors.red) draw("Invalid card.", "Missing card_id.", colors.red)
playDfpwm("error.dfpwm") parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
return return
end end
@@ -1145,56 +1428,64 @@ local function handleICCardScan(scan, side, scanDev)
local usedAction = action local usedAction = action
if #exitStation == 0 then if #exitStation == 0 then
denyCard("missing_station", "Missing gate station") denyCard("missing_station", "Missing gate station", inspectionDevs)
return return
end end
if usedAction == "entry" then if usedAction == "entry" then
if getCardEntered(scan) and not getCardExited(scan) then if getCardEntered(scan) and not getCardExited(scan) then
denyCard("already_entered", "Already entered") denyCard("already_entered", "Already entered", inspectionDevs)
return return
end end
local okWrite = true local okWrite = true
local writeErr = nil local writeErr = nil
for _, dev in ipairs(inspectionDevs) do for _, dev in ipairs(inspectionDevs) do
local okPatch, errPatch = updateICCardFields(dev, { local okPatch, errPatch = updateICCardFieldWithFallback(dev, {
entry = exitStation, "entry_station",
}) "entry",
"start_station"
}, exitStation)
okWrite = okPatch and okWrite okWrite = okPatch and okWrite
if not okPatch and not writeErr then writeErr = errPatch end if not okPatch and not writeErr then writeErr = errPatch end
end end
if not okWrite then if not okWrite then
draw("WRITE ERROR", tostring(writeErr or "Failed to update card"), colors.red) draw("WRITE ERROR", tostring(writeErr or "Failed to update card"), colors.red)
playDfpwm("error.dfpwm") parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
return return
end end
entryStation = exitStation entryStation = exitStation
else else
if not getCardEntered(scan) then if not getCardEntered(scan) then
denyCard("not_entered", "Not entered") denyCard("not_entered", "Not entered", inspectionDevs)
return return
end end
if getCardExited(scan) then if getCardExited(scan) then
denyCard("already_exited", "Already exited") denyCard("already_exited", "Already exited", inspectionDevs)
return return
end end
if #entryStation == 0 then if #entryStation == 0 then
denyCard("missing_entry_station", "Missing entry station") denyCard("missing_entry_station", "Missing entry station", inspectionDevs)
return return
end end
local fareValue, fareErr = queryFare(entryStation, exitStation) local fareValue, fareErr = queryFare(entryStation, exitStation)
if fareValue == nil then if fareValue == nil then
if fareErr == "net_error" then if fareErr == "net_error" then
draw("NET ERROR", "Fare lookup failed", colors.red) draw("NET ERROR", "Fare lookup failed", colors.red)
playDfpwm("error.dfpwm") parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
else else
denyCard("fare_not_found", fareErr) denyCard("fare_not_found", fareErr, inspectionDevs)
end end
return return
end end
fare = fareValue fare = fareValue
if balance < fare then if balance < fare then
denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance)) denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance), inspectionDevs)
return return
end end
local okWrite = true local okWrite = true
@@ -1203,7 +1494,11 @@ local function handleICCardScan(scan, side, scanDev)
local okDeduct, newBalanceOrErr = deductICCardBalance(dev, fare) local okDeduct, newBalanceOrErr = deductICCardBalance(dev, fare)
if okDeduct then if okDeduct then
balance = toMoney(newBalanceOrErr or (balance - fare)) or 0 balance = toMoney(newBalanceOrErr or (balance - fare)) or 0
local okClear, clearErr = updateICCardField(dev, "entry", nil) local okClear, clearErr = updateICCardFieldWithFallback(dev, {
"entry_station",
"entry",
"start_station"
}, nil)
local okFare, fareErr = updateICCardField(dev, "last_fare", fare) local okFare, fareErr = updateICCardField(dev, "last_fare", fare)
if not okClear or not okFare then if not okClear or not okFare then
okWrite = false okWrite = false
@@ -1213,10 +1508,13 @@ local function handleICCardScan(scan, side, scanDev)
else else
okWrite = false okWrite = false
if tostring(newBalanceOrErr) == "insufficient" then if tostring(newBalanceOrErr) == "insufficient" then
denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance)) denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance), inspectionDevs)
else else
draw("WRITE ERROR", tostring(newBalanceOrErr or "deduct_failed"), colors.red) draw("WRITE ERROR", tostring(newBalanceOrErr or "deduct_failed"), colors.red)
playDfpwm("error.dfpwm") parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
end end
break break
end end
@@ -1224,20 +1522,20 @@ local function handleICCardScan(scan, side, scanDev)
if not okWrite then if not okWrite then
if writeErr then if writeErr then
draw("WRITE ERROR", tostring(writeErr), colors.red) draw("WRITE ERROR", tostring(writeErr), colors.red)
playDfpwm("error.dfpwm") parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
end end
return return
end end
end end
for _, dev in ipairs(inspectionDevs) do for _, dev in ipairs(inspectionDevs) do
applyTicketDeviceState(dev, usedAction, nil, exitStation)
if usedAction == "entry" then if usedAction == "entry" then
updateDeviceField(dev, "entered", true)
updateDeviceField(dev, "exited", false)
if #entryStation > 0 then updateDeviceField(dev, "entry_station", entryStation) end if #entryStation > 0 then updateDeviceField(dev, "entry_station", entryStation) end
else else
updateDeviceField(dev, "exited", true)
updateDeviceField(dev, "entered", false)
if #exitStation > 0 then updateDeviceField(dev, "exit_station", exitStation) end if #exitStation > 0 then updateDeviceField(dev, "exit_station", exitStation) end
updateDeviceField(dev, "last_fare", fare) updateDeviceField(dev, "last_fare", fare)
end end
@@ -1293,7 +1591,10 @@ local function handleScan(scan, side, scanDev)
local ticketId = getTicketId(scan) local ticketId = getTicketId(scan)
if not ticketId then if not ticketId then
draw("Invalid ticket.", "Missing ticketId.", colors.red) draw("Invalid ticket.", "Missing ticketId.", colors.red)
playDfpwm("error.dfpwm") parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
return return
end end
@@ -1331,13 +1632,19 @@ local function handleScan(scan, side, scanDev)
if not ok or type(resp) ~= "table" then if not ok or type(resp) ~= "table" then
draw("NET ERROR", "Server check failed.", colors.red) draw("NET ERROR", "Server check failed.", colors.red)
playDfpwm("error.dfpwm") parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
return return
end end
if resp.result ~= "pass" then if resp.result ~= "pass" then
draw("DENIED", tostring(resp.reason or "deny"), colors.red) draw("DENIED", tostring(resp.reason or "deny"), colors.red)
playDfpwm("error.dfpwm") parallel.waitForAll(
function() playDfpwm("error.dfpwm") end,
function() triggerFaultLights(inspectionDevs) end
)
return return
end end
@@ -1347,7 +1654,7 @@ local function handleScan(scan, side, scanDev)
or tonumber(scan.trips_remaining or scan.rides_remaining) or tonumber(scan.trips_remaining or scan.rides_remaining)
or tonumber(scan.rides) or tonumber(scan.rides)
for _, dev in ipairs(inspectionDevs) do for _, dev in ipairs(inspectionDevs) do
applyTicketDeviceState(dev, usedAction, newRides) applyTicketDeviceState(dev, usedAction, newRides, stationCodeForSide(side))
end end
end) end)
@@ -1402,7 +1709,7 @@ local function shouldProcessScan(scan, side, eventName)
local now = os.epoch("utc") local now = os.epoch("utc")
local prev = recentScans[key] local prev = recentScans[key]
recentScans[key] = now recentScans[key] = now
if prev and (now - prev) < 500 then if prev and (now - prev) < 200 then
return false return false
end end
return true return true
@@ -1455,12 +1762,28 @@ local function mainLoop()
local ev = pack(os.pullEvent()) local ev = pack(os.pullEvent())
if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then if ev[1] == "ticket_scanned" or ev[1] == "ic_card_scanned" then
processInspectionEvent(ev[1], ev) processInspectionEvent(ev[1], ev)
os.sleep(0.35) os.sleep(0.1)
drawReadyScreen() drawReadyScreen()
elseif ev[1] == "peripheral" or ev[1] == "peripheral_detach" or ev[1] == "monitor_resize" or ev[1] == "config_updated" then
pcall(drawReadyScreen)
elseif ev[1] == "timer" then
pcall(drawReadyScreen)
end end
end end
end end
local function startupRedrawTask()
local count = 0
while count < 3 do
sleep(0.5)
pcall(drawReadyScreen)
count = count + 1
end
while true do
sleep(600)
end
end
local function checkAndApplyUpdate() local function checkAndApplyUpdate()
local serverBase = guessBaseFromStatusURL(serverURL) local serverBase = guessBaseFromStatusURL(serverURL)
local txt = fetchHTTP(serverBase .. '/api/check-update?_t=' .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))) local txt = fetchHTTP(serverBase .. '/api/check-update?_t=' .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999)))
@@ -1521,6 +1844,7 @@ end
local function backgroundSyncTask() local function backgroundSyncTask()
pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end) pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end)
pcall(function() refreshStationNameMap(guessBaseFromStatusURL(serverURL)) end)
while true do while true do
sleep(VERSION_CHECK_INTERVAL) sleep(VERSION_CHECK_INTERVAL)
pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end) pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end)
@@ -1528,4 +1852,4 @@ local function backgroundSyncTask()
end end
end end
parallel.waitForAny(mainLoop, backgroundSyncTask) parallel.waitForAny(mainLoop, startupRedrawTask, backgroundSyncTask)
+1 -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_MACHINE_HTTP = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/ticketmachine.lua"
local URL_UPDATE_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_machine.lua" local URL_UPDATE_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_machine.lua"
+2 -1
View File
@@ -6,7 +6,8 @@ module.exports = {
cors: { cors: {
origin: "*", origin: "*",
methods: ["GET", "POST"] methods: ["GET", "POST"]
} },
transports: ['websocket']
}); });
io.engine.on('connection_error', (err) => { io.engine.on('connection_error', (err) => {
+23
View File
@@ -1191,6 +1191,29 @@ router.get('/export', (req, res) => {
res.json(DataService.buildExportPayload()); res.json(DataService.buildExportPayload());
}); });
// Issues
router.post('/report_issue', async (req, res) => {
const r = req.body || {};
const item = {
ts: Number(r.ts || Date.now()),
device: String(r.device || 'unknown'),
machine_id: String(r.machine_id || ''),
station_code: String(r.station_code || ''),
error_msg: String(r.error_msg || ''),
detail: String(r.detail || ''),
type: 'issue'
};
await DataService.appendIssue(item);
io.emit('issue:new', item);
appendReqLog(req, { category: 'device', type: 'report_issue', detail: item });
res.json({ ok: true });
});
router.get('/issues', (req, res) => {
const issues = DataService.getIssues();
res.json({ ok: true, issues });
});
// Ticket Operations // Ticket Operations
router.post('/tickets/sale', async (req, res) => { router.post('/tickets/sale', async (req, res) => {
const { start, terminal, train_type, cost, station_code, device, trips_total, trips_remaining } = req.body || {}; const { start, terminal, train_type, cost, station_code, device, trips_total, trips_remaining } = req.body || {};
+22 -2
View File
@@ -8,6 +8,7 @@ const router = express.Router();
const ASSETS_DIR = path.join(__dirname, '../../Assets'); const ASSETS_DIR = path.join(__dirname, '../../Assets');
const MANIFEST_PATH = path.join(ASSETS_DIR, 'manifest.json'); const MANIFEST_PATH = path.join(ASSETS_DIR, 'manifest.json');
const ROUTE_MAP_LICENSE = 'CC BY 4.0';
const getIp = (req) => (req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || req.connection?.remoteAddress || ''; const getIp = (req) => (req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || req.connection?.remoteAddress || '';
@@ -48,6 +49,8 @@ const writeManifest = (m) => {
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(out, null, 2), 'utf8'); fs.writeFileSync(MANIFEST_PATH, JSON.stringify(out, null, 2), 'utf8');
}; };
const normalizeCredit = (value) => String(value || '').trim().slice(0, 200);
const removeByPrefix = (prefix) => { const removeByPrefix = (prefix) => {
ensureDir(ASSETS_DIR); ensureDir(ASSETS_DIR);
const list = fs.readdirSync(ASSETS_DIR); const list = fs.readdirSync(ASSETS_DIR);
@@ -97,6 +100,8 @@ router.get('/manifest', (req, res) => {
res.json({ res.json({
ok: true, ok: true,
routeMap, routeMap,
routeMapCredit: normalizeCredit(m.routeMapCredit || ''),
routeMapLicense: ROUTE_MAP_LICENSE,
fareTable, fareTable,
updatedAt: m.updatedAt || null updatedAt: m.updatedAt || null
}); });
@@ -106,10 +111,24 @@ router.post('/route-map', uploadRouteMap.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ ok: false, error: '缺少文件' }); if (!req.file) return res.status(400).json({ ok: false, error: '缺少文件' });
const m = readManifest(); const m = readManifest();
m.routeMap = path.basename(req.file.filename); m.routeMap = path.basename(req.file.filename);
if (req.body && Object.prototype.hasOwnProperty.call(req.body, 'credit')) {
m.routeMapCredit = normalizeCredit(req.body.credit);
} else if (typeof m.routeMapCredit !== 'string') {
m.routeMapCredit = '';
}
m.updatedAt = Date.now(); m.updatedAt = Date.now();
writeManifest(m); writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap } }); appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap, credit: m.routeMapCredit || '' } });
res.json({ ok: true, routeMap: m.routeMap, updatedAt: m.updatedAt }); res.json({ ok: true, routeMap: m.routeMap, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
});
router.post('/route-map/credit', (req, res) => {
const m = readManifest();
m.routeMapCredit = normalizeCredit(req.body?.credit);
m.updatedAt = Date.now();
writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_update_route_map_credit', detail: { credit: m.routeMapCredit || '' } });
res.json({ ok: true, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
}); });
router.post('/fare-table', uploadFareTable.single('file'), (req, res) => { router.post('/fare-table', uploadFareTable.single('file'), (req, res) => {
@@ -129,6 +148,7 @@ router.delete('/route-map', (req, res) => {
} }
removeByPrefix('route-map'); removeByPrefix('route-map');
m.routeMap = null; m.routeMap = null;
m.routeMapCredit = '';
m.updatedAt = Date.now(); m.updatedAt = Date.now();
writeManifest(m); writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_delete_route_map', detail: { ok: true } }); appendReqLog(req, { category: 'admin', type: 'asset_delete_route_map', detail: { ok: true } });
+15 -1
View File
@@ -64,7 +64,8 @@ const cache = {
icCardIndex: {}, icCardIndex: {},
icCardEvents: [], icCardEvents: [],
statsTicket: [], statsTicket: [],
statsGate: [] statsGate: [],
issues: []
}; };
const DataService = { const DataService = {
@@ -88,6 +89,7 @@ const DataService = {
await conn.query(`CREATE TABLE IF NOT EXISTS ticket_events (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`); await conn.query(`CREATE TABLE IF NOT EXISTS ticket_events (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
await conn.query(`CREATE TABLE IF NOT EXISTS stats_ticket (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`); await conn.query(`CREATE TABLE IF NOT EXISTS stats_ticket (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
await conn.query(`CREATE TABLE IF NOT EXISTS stats_gate (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`); await conn.query(`CREATE TABLE IF NOT EXISTS stats_gate (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
await conn.query(`CREATE TABLE IF NOT EXISTS issues (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
// Load Cache // Load Cache
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']); const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
@@ -126,6 +128,9 @@ const DataService = {
const [statsG] = await conn.query('SELECT data FROM stats_gate'); const [statsG] = await conn.query('SELECT data FROM stats_gate');
cache.statsGate = statsG.map(r => r.data); cache.statsGate = statsG.map(r => r.data);
const [issues] = await conn.query('SELECT data FROM issues');
cache.issues = issues.map(r => r.data);
console.log('DataService initialized with MySQL'); console.log('DataService initialized with MySQL');
} finally { } finally {
conn.release(); conn.release();
@@ -511,6 +516,15 @@ const DataService = {
}, },
getStatsGate: () => cache.statsGate, getStatsGate: () => cache.statsGate,
// Issues
appendIssue: async (item) => {
cache.issues.push(item);
try {
await pool.query('INSERT INTO issues (data) VALUES (?)', [JSON.stringify(item)]);
} catch(e) { console.error('appendIssue error', e); }
},
getIssues: () => cache.issues,
// Export // Export
buildExportPayload: () => ({ buildExportPayload: () => ({
config: DataService.getConfig(), config: DataService.getConfig(),
+424 -122
View File
@@ -1,6 +1,6 @@
local CURRENT_STATION_CODE = 'Ticket-Machine' local CURRENT_STATION_CODE = 'Ticket-Machine'
local API_BASE = 'https://ticket.fse-media.group/api' local API_BASE = 'https://ticket.fse-media.group/api'
local VERSION = 'v1.5.22' local VERSION = 'v1.5.30'
local UPDATE_CHECK_INTERVAL = 5 local UPDATE_CHECK_INTERVAL = 5
-- ########################### -- ###########################
@@ -327,6 +327,19 @@ local function enqueueTicketUpload(ticketData)
return false return false
end end
local function reportIssue(errMsg, detail)
local url = API_BASE .. '/report_issue'
local payload = {
device = currentDeviceId(),
machine_id = currentDeviceId(),
station_code = state.stationCode or CURRENT_STATION_CODE,
error_msg = tostring(errMsg or ''),
detail = tostring(detail or ''),
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
}
postJSONResult(url, payload)
end
-- ########################### -- ###########################
-- Peripheral discovery -- Peripheral discovery
-- ########################### -- ###########################
@@ -562,10 +575,6 @@ local function issueTicketFromPeripheral(fromNameEnArg, toNameEnArg, apiType, ri
if type(dev) ~= 'table' then if type(dev) ~= 'table' then
return false, '', 'peripheral_unavailable' return false, '', 'peripheral_unavailable'
end end
local fn = dev.issueTicket
if type(fn) ~= 'function' then
return false, '', 'unsupported_method'
end
local function normalizeIssuedTicketId(id) local function normalizeIssuedTicketId(id)
if id == nil then return '' end if id == nil then return '' end
@@ -582,25 +591,107 @@ local function issueTicketFromPeripheral(fromNameEnArg, toNameEnArg, apiType, ri
return s return s
end end
local function tryIssue(...) local fallbackId = normalizeIssuedTicketId(fallbackTicketId)
local okCall, r1, r2, r3 = pcall(fn, ...) local typeCandidates = { tostring(apiType or 'local') }
if not (okCall and peripheralCallSucceeded(r1)) then if apiType == 'limited_express' then
return false, '', okCall and 'issue_failed' or 'issue_call_failed' table.insert(typeCandidates, 'Express')
end table.insert(typeCandidates, 'Limited Express')
local issuedId = extractPeripheralId(r2, r3, r1, fallbackTicketId) elseif apiType == 'single' then
local normalizedId = normalizeIssuedTicketId(issuedId) table.insert(typeCandidates, 'Single')
if #normalizedId == 0 then else
return false, '', 'invalid_ticket_id' table.insert(typeCandidates, 'Local')
end table.insert(typeCandidates, 'Ordinary')
return true, normalizedId, 'issueTicket'
end end
local okIssue, ticketId, issueErr = tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg) local function buildIssuePayload(typeValue)
if okIssue then return {
return true, ticketId, 'issueTicket' ticket_id = fallbackId,
id = fallbackId,
start_station = tostring(startStationArg or ''),
terminal_station = tostring(terminalStationArg or ''),
start_station_id = tostring(startStationArg or ''),
terminal_station_id = tostring(terminalStationArg or ''),
start_name_en = tostring(fromNameEnArg or ''),
terminal_name_en = tostring(toNameEnArg or ''),
start_name = tostring(fromNameCnUArg or ''),
terminal_name = tostring(toNameCnUArg or ''),
fromNameCnU = tostring(fromNameCnUArg or ''),
toNameCnU = tostring(toNameCnUArg or ''),
rides = tonumber(rides) or 1,
trips = tonumber(rides) or 1,
trips_total = tonumber(rides) or 1,
trips_remaining = tonumber(rides) or 1,
cost = tonumber(cost) or 0,
train_type = tostring(apiType or 'local'),
type = tostring(typeValue or apiType or 'local')
}
end end
return tryIssue(fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg) local variants = {}
local seenVariantKey = {}
local function addVariant(methodName, args)
local parts = { methodName }
for i = 1, #args do
parts[#parts + 1] = tostring(args[i])
end
local key = table.concat(parts, '\31')
if not seenVariantKey[key] then
seenVariantKey[key] = true
variants[#variants + 1] = { method = methodName, args = args }
end
end
for _, typeValue in ipairs(typeCandidates) do
local payload = buildIssuePayload(typeValue)
addVariant('issueTicket', {
tostring(fromNameEnArg or ''),
tostring(toNameEnArg or ''),
typeValue,
tonumber(rides) or 1,
tonumber(cost) or 0,
tostring(startStationArg or ''),
tostring(terminalStationArg or ''),
tostring(fromNameCnUArg or ''),
tostring(toNameCnUArg or '')
})
addVariant('issueTicket', {
tostring(fromNameEnArg or ''),
tostring(toNameEnArg or ''),
typeValue,
tonumber(rides) or 1,
tonumber(cost) or 0,
tostring(startStationArg or ''),
tostring(terminalStationArg or '')
})
addVariant('issueTicketData', { payload })
addVariant('writeTicketData', { payload })
end
local lastError = 'unsupported_method'
for _, variant in ipairs(variants) do
local fn = dev[variant.method]
if type(fn) == 'function' then
local okCall, r1, r2, r3 = pcall(fn, table.unpack(variant.args))
if okCall and peripheralCallSucceeded(r1) then
local payloadArg = type(variant.args[1]) == 'table' and variant.args[1] or nil
local issuedId = extractPeripheralId(r2, r3, r1, payloadArg and payloadArg.ticket_id, payloadArg and payloadArg.id, fallbackId)
local normalizedId = normalizeIssuedTicketId(issuedId)
if #normalizedId > 0 then
return true, normalizedId, variant.method
end
lastError = 'invalid_ticket_id'
else
local detail = extractPeripheralId(r1, r2, r3)
if okCall then
lastError = variant.method .. ':' .. tostring(detail ~= '' and detail or 'issue_failed')
else
lastError = variant.method .. ':' .. tostring(r1 or 'issue_call_failed')
end
end
end
end
return false, '', lastError
end end
-- ########################### -- ###########################
@@ -990,6 +1081,16 @@ local function nearestCCColor(val)
return colors.gray return colors.gray
end end
local function contrastTextColor(bg)
for _, c in ipairs(CC_PALETTE) do
if c.val == bg then
local yiq = (c.rgb[1] * 299 + c.rgb[2] * 587 + c.rgb[3] * 114) / 1000
return yiq >= 140 and colors.black or colors.white
end
end
return colors.white
end
local function clear() local function clear()
termDev.setBackgroundColor(colors.black) termDev.setBackgroundColor(colors.black)
termDev.clear() termDev.clear()
@@ -1024,6 +1125,31 @@ local function addButton(x, y, label, wBtn, hBtn, colorsPair, onClick)
termDev.write(label) termDev.write(label)
end end
local function drawVerticalScrollbar(sbX, startY, endY, offset, maxOffset, step, onUp, onDown)
addButton(sbX, startY - 1, '^', 3, 1, {colors.black, colors.white}, onUp)
addButton(sbX, endY + 1, 'v', 3, 1, {colors.black, colors.white}, onDown)
for py = startY, endY do
termDev.setBackgroundColor(colors.black)
termDev.setCursorPos(sbX, py)
termDev.write(' ')
termDev.setBackgroundColor(colors.gray)
termDev.setCursorPos(sbX, py)
termDev.write(' ')
end
if maxOffset <= 0 then
return
end
local railH = endY - startY + 1
local thumbH = math.max(2, math.floor((railH * railH) / (railH + maxOffset)))
if thumbH > railH then thumbH = railH end
local thumbY = startY + math.floor((railH - thumbH) * (offset / maxOffset))
for py = thumbY, thumbY + thumbH - 1 do
termDev.setBackgroundColor(colors.lightGray)
termDev.setCursorPos(sbX, py)
termDev.write(' ')
end
end
local function inRect(btn, px, py) local function inRect(btn, px, py)
return px >= btn.x and px <= (btn.x + btn.w - 1) and py >= btn.y and py <= (btn.y + btn.h - 1) return px >= btn.x and px <= (btn.x + btn.w - 1) and py >= btn.y and py <= (btn.y + btn.h - 1)
end end
@@ -1055,7 +1181,7 @@ state = {
departure = nil, terminal = nil, trainType = nil, trips = 1, cost = 0, paid = 0, doneAudioPlayed = false, departure = nil, terminal = nil, trainType = nil, trips = 1, cost = 0, paid = 0, doneAudioPlayed = false,
productMode = 'ticket', cardMode = nil, cardPaymentMode = 'local', productMode = 'ticket', cardMode = nil, cardPaymentMode = 'local',
holderName = '', cardDeposit = 0, cardTopup = 0, cardBalance = 0, cardOrderValue = 0, holderName = '', cardDeposit = 0, cardTopup = 0, cardBalance = 0, cardOrderValue = 0,
card_id = nil, card_server_data = nil, pendingBlankCardId = nil card_id = nil, card_server_data = nil, pendingBlankCardId = nil, cardIssueWarning = nil
} }
local function getCardConfig() local function getCardConfig()
@@ -1090,6 +1216,7 @@ local function resetTicketFlow()
state.card_id = nil state.card_id = nil
state.card_server_data = nil state.card_server_data = nil
state.pendingBlankCardId = nil state.pendingBlankCardId = nil
state.cardIssueWarning = nil
end end
local function resetCardFlow(mode) local function resetCardFlow(mode)
@@ -1109,6 +1236,7 @@ local function resetCardFlow(mode)
state.card_id = nil state.card_id = nil
state.card_server_data = nil state.card_server_data = nil
state.pendingBlankCardId = nil state.pendingBlankCardId = nil
state.cardIssueWarning = nil
end end
local function isCardOrderLike(data) local function isCardOrderLike(data)
@@ -1208,14 +1336,22 @@ local function renderConfirmCancel()
addButton(bx+4+bw, by+4, 'NO', bw, 1, {colors.green, colors.white}, function() ui_cancel_confirmed = false; ui_cancel_request = false end) addButton(bx+4+bw, by+4, 'NO', bw, 1, {colors.green, colors.white}, function() ui_cancel_confirmed = false; ui_cancel_request = false end)
end end
local function showAlert(msg) local function showAlert(msg, detail)
if detail then
reportIssue(msg, detail)
end
local boxW, boxH = math.max(24, math.min(32, w-4)), 6 local boxW, boxH = math.max(24, math.min(32, w-4)), 6
local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1 local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1
termDev.setBackgroundColor(colors.gray) termDev.setBackgroundColor(colors.gray)
for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end
msg = tostring(msg or '') local displayMsg = tostring(msg or '')
if #msg > boxW-4 then msg = msg:sub(1, boxW-4) end if #displayMsg > boxW-4 then displayMsg = displayMsg:sub(1, boxW-4) end
termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(msg) termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(displayMsg)
if detail then
local displayDetail = tostring(detail)
if #displayDetail > boxW-4 then displayDetail = displayDetail:sub(1, boxW-4) end
termDev.setCursorPos(bx+2, by+3); termDev.write(displayDetail)
end
Buttons = {} Buttons = {}
addButton(bx+math.floor((boxW-6)/2), by+4, 'OK', 6, 1, {colors.green, colors.white}, function() end) addButton(bx+math.floor((boxW-6)/2), by+4, 'OK', 6, 1, {colors.green, colors.white}, function() end)
waitButtons() waitButtons()
@@ -1282,8 +1418,12 @@ local function showSingleSegmentSelection()
local startIdx = singleSegmentScroll * itemsPerPage + 1 local startIdx = singleSegmentScroll * itemsPerPage + 1
local endIdx = math.min(startIdx + itemsPerPage - 1, #singleSegments) local endIdx = math.min(startIdx + itemsPerPage - 1, #singleSegments)
local bw = math.floor((w - 6) / 2) local sbX = w - 3
local bx1, bx2 = 2, w - bw - 1 local contentRight = sbX - 2
local colGap = 2
local bw = math.floor((contentRight - 1 - colGap) / 2)
if bw < 8 then bw = 8 end
local bx1, bx2 = 2, 2 + bw + colGap
local by = 5 local by = 5
for i = startIdx, endIdx do for i = startIdx, endIdx do
local seg = singleSegments[i] local seg = singleSegments[i]
@@ -1292,20 +1432,28 @@ local function showSingleSegmentSelection()
local x = colIdx == 0 and bx1 or bx2 local x = colIdx == 0 and bx1 or bx2
local y = by + rowIdx * 4 local y = by + rowIdx * 4
local label = seg.lineName .. ' cogs:' .. tostring(seg.cost) local lineColor = nearestCCColor(seg.color)
if #label > bw then label = label:sub(1, bw) end local textColor = contrastTextColor(lineColor)
local isSelected = (state.singleSegment == seg) local isSelected = (state.singleSegment == seg)
addButton(x, y, label, bw, 3, {isSelected and colors.green or colors.blue, colors.white}, function() local bg = isSelected and colors.green or lineColor
local fg = isSelected and colors.white or textColor
local label = seg.lineName .. ' ' .. tostring(seg.cost) .. 'c'
if #label > bw then label = label:sub(1, bw) end
addButton(x, y, label, bw, 3, {bg, fg}, function()
state.singleSegment = seg state.singleSegment = seg
end) end)
end end
if singleSegmentScroll > 0 then drawVerticalScrollbar(
addButton(w-8, 2, 'UP', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll - 1 end) sbX,
end by,
if singleSegmentScroll < maxScroll then h - 6,
addButton(w-8, h-5, 'DN', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll + 1 end) singleSegmentScroll * itemsPerPage,
end maxScroll * itemsPerPage,
itemsPerPage,
function() singleSegmentScroll = math.max(0, singleSegmentScroll - 1) end,
function() singleSegmentScroll = math.min(maxScroll, singleSegmentScroll + 1) end
)
addCancelButton() addCancelButton()
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end) addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
@@ -1341,8 +1489,8 @@ local function showHome()
local cx = math.floor((w - btnW) / 2) + 1 local cx = math.floor((w - btnW) / 2) + 1
addButton(cx, y-4, 'NEW', btnW, btnH, {colors.green, colors.white}, function() addButton(cx, y-4, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
resetTicketFlow() resetTicketFlow()
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete() stopAudio()
state.page = 'departure' state.page = 'new_menu'
end) end)
addButton(cx, y, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function() addButton(cx, y, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function()
resetTicketFlow() resetTicketFlow()
@@ -1353,34 +1501,48 @@ local function showHome()
resetCardFlow('open') resetCardFlow('open')
stopAudio(); state.page = 'card_home' stopAudio(); state.page = 'card_home'
end) end)
addButton(cx, y+8, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
resetTicketFlow()
stopAudio(); state.page = 'online'
end)
else else
local cx = math.floor((w - btnW) / 2) + 1 local cx = math.floor((w - btnW) / 2) + 1
addButton(x1, y-2, 'NEW', btnW, btnH, {colors.green, colors.white}, function() addButton(x1, y-2, 'NEW', btnW, btnH, {colors.green, colors.white}, function()
resetTicketFlow() resetTicketFlow()
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete() stopAudio()
state.page = 'departure' state.page = 'new_menu'
end) end)
addButton(x2, y-2, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function() addButton(x2, y-2, 'SINGLE', btnW, btnH, {colors.blue, colors.white}, function()
resetTicketFlow() resetTicketFlow()
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete() stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
state.page = 'single_segment' state.page = 'single_segment'
end) end)
addButton(x1, y+2, 'CARD', btnW, btnH, {colors.orange, colors.white}, function() addButton(cx, y+2, 'CARD', btnW, btnH, {colors.orange, colors.white}, function()
resetCardFlow('open') resetCardFlow('open')
stopAudio(); state.page = 'card_home' stopAudio(); state.page = 'card_home'
end) end)
addButton(x2, y+2, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function()
resetTicketFlow()
stopAudio(); state.page = 'online'
end)
end end
waitButtons() waitButtons()
end end
local function showNewMenu()
while state.page == 'new_menu' do
drawHeader('Ticket Service', 'Buy ticket or redeem voucher')
Buttons = {}
local btnW, btnH = 16, 3
local cx = math.floor((w - btnW) / 2) + 1
local y = math.floor(h / 2) - 3
addButton(cx, y, 'BUY TICKET', btnW, btnH, {colors.green, colors.white}, function()
resetTicketFlow()
stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete()
state.page = 'departure'
end)
addButton(cx, y + 4, 'REDEEM CODE', btnW, btnH, {colors.cyan, colors.white}, function()
resetTicketFlow()
stopAudio()
state.page = 'online'
end)
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
waitButtons()
end
end
local function showCardHome() local function showCardHome()
while state.page == 'card_home' do while state.page == 'card_home' do
drawHeader('IC Card Service', 'Open card or redeem online order') drawHeader('IC Card Service', 'Open card or redeem online order')
@@ -1418,6 +1580,25 @@ local function showCardNameInput()
{'+'} {'+'}
} }
local function clearLine(y)
termDev.setBackgroundColor(colors.black)
termDev.setCursorPos(1, y)
termDev.write(string.rep(' ', w))
end
local function renderInput()
local placeholder = name
if #placeholder == 0 then
placeholder = 'ENTER NAME'
elseif #placeholder > (w - 8) then
placeholder = '...' .. placeholder:sub(-(w - 11))
end
clearLine(4)
clearLine(5)
centerText(4, '[' .. placeholder .. ']', colors.yellow)
if msg and #msg > 0 then centerText(5, msg, msgCol) end
end
local function isAllowedHolderChar(ch) local function isAllowedHolderChar(ch)
if type(ch) ~= 'string' or #ch ~= 1 then return false end if type(ch) ~= 'string' or #ch ~= 1 then return false end
if ch:match('[A-Za-z ]') then return true end if ch:match('[A-Za-z ]') then return true end
@@ -1429,19 +1610,14 @@ local function showCardNameInput()
if ch:match('[A-Za-z]') then ch = ch:upper() end if ch:match('[A-Za-z]') then ch = ch:upper() end
if ch == ' ' and (#name == 0 or name:sub(-1) == ' ') then return end if ch == ' ' and (#name == 0 or name:sub(-1) == ' ') then return end
name = name .. ch name = name .. ch
msg = ''
renderInput()
end end
while state.page == 'card_name' do while state.page == 'card_name' do
local placeholder = name
if #placeholder == 0 then
placeholder = 'ENTER NAME'
elseif #placeholder > (w - 8) then
placeholder = '...' .. placeholder:sub(-(w - 11))
end
local rows = (keyboardMode == 'symbol') and symbolRows or alphaRows local rows = (keyboardMode == 'symbol') and symbolRows or alphaRows
drawHeader('Enter Holder Name', (keyboardMode == 'symbol') and 'Symbol keyboard' or 'Letters / symbols / space') drawHeader('Enter Holder Name', (keyboardMode == 'symbol') and 'Symbol keyboard' or 'Letters / symbols / space')
centerText(4, '[' .. placeholder .. ']', colors.yellow) renderInput()
if msg and #msg > 0 then centerText(5, msg, msgCol) end
Buttons = {} Buttons = {}
local keyW, keyH = (w < 44 and 2 or 3), 2 local keyW, keyH = (w < 44 and 2 or 3), 2
local sY = 7 local sY = 7
@@ -1460,36 +1636,54 @@ local function showCardNameInput()
appendHolderChar(' ') appendHolderChar(' ')
end) end)
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end) addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end)
local currentKeyboardMode = keyboardMode
addButton(12, h-3, (keyboardMode == 'symbol') and 'ABC' or 'SYM', 8, 3, {colors.black, colors.orange}, function() addButton(12, h-3, (keyboardMode == 'symbol') and 'ABC' or 'SYM', 8, 3, {colors.black, colors.orange}, function()
keyboardMode = (keyboardMode == 'symbol') and 'alpha' or 'symbol' keyboardMode = (keyboardMode == 'symbol') and 'alpha' or 'symbol'
end) end)
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() name = name:sub(1, -2) end)
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() name = '' end) addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function()
name = name:sub(1, -2)
msg = ''
renderInput()
end)
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function()
name = ''
msg = ''
renderInput()
end)
addButton(w-11, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function() addButton(w-11, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function()
local clean = firstString(name) local clean = firstString(name)
if #clean < 2 then if #clean < 2 then
msg, msgCol = 'Name too short', colors.red msg, msgCol = 'Name too short', colors.red
renderInput()
return return
end end
state.holderName = clean state.holderName = clean
state.page = 'card_topup' state.page = 'card_topup'
end) end)
local ev, p1, p2, p3 = os.pullEvent()
if ev == 'mouse_click' or ev == 'monitor_touch' then while state.page == 'card_name' and keyboardMode == currentKeyboardMode do
for _, b in ipairs(Buttons) do local ev, p1, p2, p3 = os.pullEvent()
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end if ev == 'mouse_click' or ev == 'monitor_touch' then
end for _, b in ipairs(Buttons) do
elseif ev == 'char' then if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
appendHolderChar(tostring(p1 or '')) end
elseif ev == 'key' and p1 == keys.backspace then elseif ev == 'char' then
name = name:sub(1, -2) appendHolderChar(tostring(p1 or ''))
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then elseif ev == 'key' and p1 == keys.backspace then
local clean = firstString(name) name = name:sub(1, -2)
if #clean >= 2 then msg = ''
state.holderName = clean renderInput()
state.page = 'card_topup' elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
else local clean = firstString(name)
msg, msgCol = 'Name too short', colors.red if #clean >= 2 then
state.holderName = clean
state.page = 'card_topup'
else
msg, msgCol = 'Name too short', colors.red
renderInput()
end
end end
end end
end end
@@ -1544,9 +1738,24 @@ local function showCardOnlineRedeem()
{'Z','X','C','V','B','N','M'} {'Z','X','C','V','B','N','M'}
} }
local function clearLine(y)
termDev.setBackgroundColor(colors.black)
termDev.setCursorPos(1, y)
termDev.write(string.rep(' ', w))
end
local function renderInput()
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
clearLine(4)
clearLine(5)
centerText(4, '[' .. placeholder .. ']', colors.yellow)
if msg and #msg > 0 then centerText(5, msg, msgCol) end
end
local function submitCode() local function submitCode()
if #code ~= 5 then if #code ~= 5 then
msg, msgCol = 'Need 5 chars', colors.red msg, msgCol = 'Need 5 chars', colors.red
renderInput()
return return
end end
local res = fetchJSON(API_BASE .. '/public/ic-cards/orders/' .. code) local res = fetchJSON(API_BASE .. '/public/ic-cards/orders/' .. code)
@@ -1555,17 +1764,20 @@ local function showCardOnlineRedeem()
end end
if not (res and res.ok) then if not (res and res.ok) then
msg, msgCol = 'Voucher Invalid', colors.red msg, msgCol = 'Voucher Invalid', colors.red
renderInput()
return return
end end
local d = res.data or res local d = res.data or res
local cardStatus = tostring(d.status or ''):lower() local cardStatus = tostring(d.status or ''):lower()
if d.consumed or (cardStatus ~= '' and cardStatus ~= 'pending_pickup' and isCardOrderLike(d)) then if d.consumed or (cardStatus ~= '' and cardStatus ~= 'pending_pickup' and isCardOrderLike(d)) then
msg, msgCol = 'Already Used!', colors.red msg, msgCol = 'Already Used!', colors.red
renderInput()
return return
end end
local cardOrder = buildCardOrderState(d, code) local cardOrder = buildCardOrderState(d, code)
if not cardOrder then if not cardOrder then
msg, msgCol = 'Not a card order', colors.red msg, msgCol = 'Not a card order', colors.red
renderInput()
return return
end end
resetCardFlow('redeem') resetCardFlow('redeem')
@@ -1583,10 +1795,7 @@ local function showCardOnlineRedeem()
end end
while state.page == 'card_online' do while state.page == 'card_online' do
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
drawHeader('Redeem IC Card', 'Type 5 chars then OK') drawHeader('Redeem IC Card', 'Type 5 chars then OK')
centerText(4, '[' .. placeholder .. ']', colors.yellow)
if msg and #msg > 0 then centerText(5, msg, msgCol) end
Buttons = {} Buttons = {}
local keyW, keyH = (w < 44 and 2 or 3), 2 local keyW, keyH = (w < 44 and 2 or 3), 2
local kbW = 10 * (keyW + 1) local kbW = 10 * (keyW + 1)
@@ -1595,26 +1804,46 @@ local function showCardOnlineRedeem()
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1) local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
for _, ch in ipairs(row) do for _, ch in ipairs(row) do
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function() addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function()
if #code < 5 then code = code .. ch end if #code < 5 then
code = code .. ch
msg = ''
renderInput()
end
end) end)
x = x + keyW + 1 x = x + keyW + 1
end end
end end
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end) addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end)
addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end) addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function()
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() code = '' end)
addButton(w-11, h-3, 'OK', 10, 3, {colors.black, colors.green}, submitCode)
local ev, p1, p2, p3 = os.pullEvent()
if ev == 'mouse_click' or ev == 'monitor_touch' then
for _, b in ipairs(Buttons) do
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
end
elseif ev == 'char' and #code < 5 then
code = code .. tostring(p1 or ''):upper()
elseif ev == 'key' and p1 == keys.backspace then
code = code:sub(1, -2) code = code:sub(1, -2)
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then msg = ''
submitCode() renderInput()
end)
addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function()
code = ''
msg = ''
renderInput()
end)
addButton(w-11, h-3, 'OK', 10, 3, {colors.black, colors.green}, submitCode)
renderInput()
while state.page == 'card_online' do
local ev, p1, p2, p3 = os.pullEvent()
if ev == 'mouse_click' or ev == 'monitor_touch' then
for _, b in ipairs(Buttons) do
if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
end
elseif ev == 'char' and #code < 5 then
code = code .. tostring(p1 or ''):upper()
msg = ''
renderInput()
elseif ev == 'key' and p1 == keys.backspace then
code = code:sub(1, -2)
msg = ''
renderInput()
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
submitCode()
elseif ev == 'config_updated' then
end
end end
end end
end end
@@ -1653,8 +1882,16 @@ local function renderLinesSelection(title, selectedCode, backPage, nextPage, inf
local ch = computeHeight(); local maxO = math.max(0, ch - (endY-startY+1)) local ch = computeHeight(); local maxO = math.max(0, ch - (endY-startY+1))
ui_scroll_offset = math.min(ui_scroll_offset, maxO) ui_scroll_offset = math.min(ui_scroll_offset, maxO)
addButton(sbX, startY-1, '^', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.max(0, ui_scroll_offset-4) end) drawVerticalScrollbar(
addButton(sbX, endY+1, 'v', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end) sbX,
startY,
endY,
ui_scroll_offset,
maxO,
4,
function() ui_scroll_offset = math.max(0, ui_scroll_offset-4) end,
function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end
)
local y = startY local y = startY
for _, l in ipairs(CFG.lines) do for _, l in ipairs(CFG.lines) do
@@ -1737,7 +1974,7 @@ local function showTerminal()
invalidUntil = os.epoch('utc') + 1500 invalidUntil = os.epoch('utc') + 1500
sel = nil sel = nil
state.terminal = nil state.terminal = nil
showAlert('Same station') showAlert('Same station', 'Dep and Term are identical')
else else
state.terminal = sel state.terminal = sel
end end
@@ -1995,7 +2232,7 @@ local function showOrderAndAudio()
else else
local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local') local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
if not unit then if not unit then
showAlert('No route') showAlert('No route', 'Cannot find fare path')
state.page = 'terminal' state.page = 'terminal'
return return
end end
@@ -2006,7 +2243,7 @@ local function showOrderAndAudio()
end end
if state.productMode ~= 'card' and state.trainType ~= 'single' and sameLogicalStation(state.departure, state.terminal) then if state.productMode ~= 'card' and state.trainType ~= 'single' and sameLogicalStation(state.departure, state.terminal) then
showAlert('Same station') showAlert('Same station', 'Cannot purchase to same station')
state.page = state.voucher_code and 'online' or 'terminal' state.page = state.voucher_code and 'online' or 'terminal'
return return
end end
@@ -2019,7 +2256,7 @@ local function showOrderAndAudio()
local function confirmAction() local function confirmAction()
if processing or confirmed then return end if processing or confirmed then return end
if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then
showAlert('Same station') showAlert('Same station', 'Dep and Term are identical on confirm')
if render then render() end if render then render() end
return return
end end
@@ -2054,6 +2291,7 @@ local function showOrderAndAudio()
end end
if okIssueBlank and #blankCardId > 0 then if okIssueBlank and #blankCardId > 0 then
payload.card_id = blankCardId payload.card_id = blankCardId
state.card_id = blankCardId
state.pendingBlankCardId = blankCardId state.pendingBlankCardId = blankCardId
local okReq, code, parsed, err = submitCardOpen(payload) local okReq, code, parsed, err = submitCardOpen(payload)
if okReq then if okReq then
@@ -2065,11 +2303,15 @@ local function showOrderAndAudio()
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
state.card_server_data = respData state.card_server_data = respData
state.pendingBlankCardId = nil state.pendingBlankCardId = nil
state.cardIssueWarning = nil
confirmed = true confirmed = true
statusMsg, statusCol = 'Card ready', colors.green statusMsg, statusCol = 'Card ready', colors.green
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
else else
statusMsg, statusCol = 'Write failed: ' .. tostring(writeMethod), colors.red state.card_server_data = respData
state.cardIssueWarning = 'Write pending: ' .. tostring(writeMethod)
confirmed = true
statusMsg, statusCol = 'Card issued, write pending', colors.red
end end
else else
local errorMsg = 'Card API Err' local errorMsg = 'Card API Err'
@@ -2080,9 +2322,14 @@ local function showOrderAndAudio()
elseif code and type(code) == 'number' then elseif code and type(code) == 'number' then
errorMsg = 'HTTP ' .. tostring(code) errorMsg = 'HTTP ' .. tostring(code)
end end
statusMsg, statusCol = 'Issued, retry sync: ' .. errorMsg, colors.red state.cardIssueWarning = 'Sync pending: ' .. errorMsg
confirmed = true
statusMsg, statusCol = 'Card issued, sync pending', colors.red
end end
elseif okIssueBlank then elseif okIssueBlank then
state.card_id = payload.card_id
state.cardIssueWarning = 'Issued without readable ID'
confirmed = true
statusMsg, statusCol = 'Card issued without ID', colors.red statusMsg, statusCol = 'Card issued without ID', colors.red
else else
local okReq, code, parsed, err = submitCardOpen(payload) local okReq, code, parsed, err = submitCardOpen(payload)
@@ -2095,6 +2342,7 @@ local function showOrderAndAudio()
state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance
state.card_server_data = respData state.card_server_data = respData
state.pendingBlankCardId = nil state.pendingBlankCardId = nil
state.cardIssueWarning = nil
confirmed = true confirmed = true
statusMsg, statusCol = 'Card ready', colors.green statusMsg, statusCol = 'Card ready', colors.green
if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end
@@ -2227,7 +2475,8 @@ end
local function showDone() local function showDone()
if state.productMode == 'card' then if state.productMode == 'card' then
drawHeader('Card Ready', 'Please take your IC card') local warning = firstString(state.cardIssueWarning)
drawHeader(warning ~= '' and 'Card Issued' or 'Card Ready', warning ~= '' and 'Please check issue status' or 'Please take your IC card')
local y = 5 local y = 5
local function line(label, value, col) local function line(label, value, col)
termDev.setTextColor(colors.white) termDev.setTextColor(colors.white)
@@ -2242,6 +2491,7 @@ local function showDone()
line('Balance', tonumber(state.cardBalance) or 0, colors.green) line('Balance', tonumber(state.cardBalance) or 0, colors.green)
line('Card ID', firstString(state.card_id, 'PENDING'), colors.cyan) line('Card ID', firstString(state.card_id, 'PENDING'), colors.cyan)
if state.voucher_code then line('Voucher', state.voucher_code, colors.cyan) end if state.voucher_code then line('Voucher', state.voucher_code, colors.cyan) end
if warning ~= '' then line('Status', warning, colors.red) end
saveCardIssueSnapshot({ saveCardIssueSnapshot({
card_id = state.card_id, card_id = state.card_id,
holder_name = state.holderName, holder_name = state.holderName,
@@ -2249,6 +2499,7 @@ local function showDone()
topup = state.cardTopup, topup = state.cardTopup,
balance = state.cardBalance, balance = state.cardBalance,
voucher_code = state.voucher_code, voucher_code = state.voucher_code,
issue_warning = warning,
station_code = state.stationCode or CURRENT_STATION_CODE, station_code = state.stationCode or CURRENT_STATION_CODE,
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000) ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
}) })
@@ -2333,7 +2584,7 @@ local function showDone()
local issueError = tostring(issueMethod or 'ticket_issue_failed') local issueError = tostring(issueMethod or 'ticket_issue_failed')
print('Ticket issue failed: ' .. issueError) print('Ticket issue failed: ' .. issueError)
_G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError _G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError
showAlert('Ticket issue failed') showAlert('Ticket issue failed', issueError)
resetTicketFlow() resetTicketFlow()
state.page = 'home' state.page = 'home'
return return
@@ -2432,6 +2683,20 @@ local function showOnlineVoucher()
{'Z','X','C','V','B','N','M'} {'Z','X','C','V','B','N','M'}
} }
local function clearLine(y)
termDev.setBackgroundColor(colors.black)
termDev.setCursorPos(1, y)
termDev.write(string.rep(' ', w))
end
local function renderInput()
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
clearLine(4)
clearLine(5)
centerText(4, '[' .. placeholder .. ']', colors.yellow)
if msg and #msg > 0 then centerText(5, msg, msgCol) end
end
local function submitCode() local function submitCode()
if #code == 5 then if #code == 5 then
local res = fetchJSON(API_BASE .. '/public/orders/' .. code) local res = fetchJSON(API_BASE .. '/public/orders/' .. code)
@@ -2441,6 +2706,7 @@ local function showOnlineVoucher()
-- Check if already consumed -- Check if already consumed
if d.consumed then if d.consumed then
msg, msgCol = 'Already Used!', colors.red msg, msgCol = 'Already Used!', colors.red
renderInput()
return return
end end
@@ -2454,15 +2720,16 @@ local function showOnlineVoucher()
state.page = 'order' state.page = 'order'
else else
msg, msgCol = 'Voucher Invalid', colors.red msg, msgCol = 'Voucher Invalid', colors.red
renderInput()
end end
else
msg, msgCol = 'Need 5 chars', colors.red
renderInput()
end end
end end
while state.page == 'online' do while state.page == 'online' do
local placeholder = code .. string.rep('_', math.max(0, 5-#code))
drawHeader('Enter Voucher', 'Type 5 chars then OK') drawHeader('Enter Voucher', 'Type 5 chars then OK')
centerText(4, '[' .. placeholder .. ']', colors.yellow)
if msg and #msg > 0 then centerText(5, msg, msgCol) end
Buttons = {} Buttons = {}
local keyW, keyH = (w < 44 and 2 or 3), 2 local keyW, keyH = (w < 44 and 2 or 3), 2
@@ -2472,7 +2739,13 @@ local function showOnlineVoucher()
for rIdx, row in ipairs(rows) do for rIdx, row in ipairs(rows) do
local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1) local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1)
for _, ch in ipairs(row) do for _, ch in ipairs(row) do
addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function() if #code < 5 then code = code .. ch end end) addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function()
if #code < 5 then
code = code .. ch
msg = ''
renderInput()
end
end)
x = x + keyW + 1 x = x + keyW + 1
end end
end end
@@ -2482,27 +2755,47 @@ local function showOnlineVoucher()
local gap = 2 local gap = 2
local total = bw + gap + cw + gap + ow local total = bw + gap + cw + gap + ow
local ax = math.floor((w - total) / 2) + 1 local ax = math.floor((w - total) / 2) + 1
local smallMode = false
if ax < 1 then if ax < 1 then
smallMode = true
bw, cw, ow = 6, 6, 6 bw, cw, ow = 6, 6, 6
total = bw + 1 + cw + 1 + ow gap = 1
total = bw + gap + cw + gap + ow
ax = math.floor((w - total) / 2) + 1 ax = math.floor((w - total) / 2) + 1
addButton(math.max(1, ax), actY, 'BKSP', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
addButton(math.max(1, ax) + bw + 1, actY, 'CLEAR', cw, 3, {colors.black, colors.red}, function() code = '' end)
addButton(math.max(1, ax) + bw + 1 + cw + 1, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
else
addButton(ax, actY, 'Backspace', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end)
addButton(ax + bw + gap, actY, 'Clear', cw, 3, {colors.black, colors.red}, function() code = '' end)
addButton(ax + bw + gap + cw + gap, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
end end
local bx = math.max(1, ax)
addButton(bx, actY, smallMode and 'BKSP' or 'Backspace', bw, 3, {colors.black, colors.red}, function()
code = code:sub(1, -2)
msg = ''
renderInput()
end)
addButton(bx + bw + gap, actY, smallMode and 'CLEAR' or 'Clear', cw, 3, {colors.black, colors.red}, function()
code = ''
msg = ''
renderInput()
end)
addButton(bx + bw + gap + cw + gap, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode)
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end) addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
local ev, p1, p2, p3 = os.pullEvent() renderInput()
if ev == 'mouse_click' or ev == 'monitor_touch' then while state.page == 'online' do
for _, b in ipairs(Buttons) do if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end end local ev, p1, p2, p3 = os.pullEvent()
elseif ev == 'char' and #code < 5 then code = code .. p1:upper() if ev == 'mouse_click' or ev == 'monitor_touch' then
elseif ev == 'key' and p1 == keys.backspace then code = code:sub(1, -2) for _, b in ipairs(Buttons) do
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode() if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end
elseif ev == 'config_updated' then end
elseif ev == 'char' and #code < 5 then
code = code .. tostring(p1 or ''):upper()
msg = ''
renderInput()
elseif ev == 'key' and p1 == keys.backspace then
code = code:sub(1, -2)
msg = ''
renderInput()
elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then
submitCode()
elseif ev == 'config_updated' then
end
end end
end end
end end
@@ -2513,6 +2806,7 @@ end
local function mainPageLoop() local function mainPageLoop()
while true do while true do
if state.page == 'home' then showHome() if state.page == 'home' then showHome()
elseif state.page == 'new_menu' then showNewMenu()
elseif state.page == 'single_segment' then showSingleSegmentSelection() elseif state.page == 'single_segment' then showSingleSegmentSelection()
elseif state.page == 'card_home' then showCardHome() elseif state.page == 'card_home' then showCardHome()
elseif state.page == 'card_name' then showCardNameInput() elseif state.page == 'card_name' then showCardNameInput()
@@ -2530,4 +2824,12 @@ local function mainPageLoop()
end end
end end
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask) local function safeMain()
local ok, err = pcall(parallel.waitForAny, mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)
if not ok then
reportIssue("Fatal Error", tostring(err))
error(err)
end
end
safeMain()
-1
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_REFILL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/refillmachine.lua"
local URL_UPDATE_REFILL = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_refill.lua" local URL_UPDATE_REFILL = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_refill.lua"
-73
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> <title>首页|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14"> <link rel="stylesheet" href="/style.css?v=22">
</head> </head>
<body class="public-search jr-public-page"> <body class="public-search jr-public-page">
<div class="jr-public-shell"> <div class="jr-public-shell">
@@ -16,11 +16,7 @@
<i class="fas fa-train"></i> <i class="fas fa-train"></i>
<span>FSE铁路运输票务系统</span> <span>FSE铁路运输票务系统</span>
</a> </a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div> </div>
</header> </header>
@@ -184,6 +180,26 @@
<script src="/custom-dialog.js?v=11"></script> <script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script> <script src="/public-status.js?v=14"></script>
<script> <script>
const createRouteMapCredit = (credit, license) => {
const wrap = document.createElement('div');
wrap.className = 'jr-asset-credit';
const creditText = document.createElement('span');
creditText.textContent = `线路图署名:${credit || '未填写'}`;
wrap.appendChild(creditText);
wrap.appendChild(document.createTextNode(' 许可协议:'));
const licenseLink = document.createElement('a');
licenseLink.href = 'https://creativecommons.org/licenses/by/4.0/';
licenseLink.target = '_blank';
licenseLink.rel = 'noopener noreferrer';
licenseLink.textContent = license || 'CC BY 4.0';
wrap.appendChild(licenseLink);
return wrap;
};
const loadPublicAssets = async () => { const loadPublicAssets = async () => {
const fareMapBox = document.getElementById('fareMapBox'); const fareMapBox = document.getElementById('fareMapBox');
const routeMapBox = document.getElementById('routeMapBox'); const routeMapBox = document.getElementById('routeMapBox');
@@ -209,6 +225,7 @@
img.src = `/assets/${encodeURIComponent(m.routeMap)}?v=${encodeURIComponent(mv)}`; img.src = `/assets/${encodeURIComponent(m.routeMap)}?v=${encodeURIComponent(mv)}`;
routeMapBox.innerHTML = '<div class="jr-asset-frame"></div>'; routeMapBox.innerHTML = '<div class="jr-asset-frame"></div>';
routeMapBox.firstElementChild.appendChild(img); routeMapBox.firstElementChild.appendChild(img);
routeMapBox.appendChild(createRouteMapCredit(m.routeMapCredit, m.routeMapLicense));
} else { } else {
routeMapBox.innerHTML = '<div class="text-muted">暂无线路图</div>'; routeMapBox.innerHTML = '<div class="text-muted">暂无线路图</div>';
} }
@@ -245,5 +262,3 @@
</html> </html>
+2 -6
View File
@@ -7,7 +7,7 @@
<title>IC卡管理|FSE铁路运输票务系统</title> <title>IC卡管理|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14"> <link rel="stylesheet" href="/style.css?v=22">
</head> </head>
<body class="jr-admin-page jr-admin-ic-page jr-public-page"> <body class="jr-admin-page jr-admin-ic-page jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-train"></i> <i class="fas fa-train"></i>
<span>FSE 铁路运输后台系统</span> <span>FSE 铁路运输后台系统</span>
</a> </a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div> </div>
</header> </header>
<div class="jr-brandbar"> <div class="jr-brandbar">
+2 -6
View File
@@ -7,7 +7,7 @@
<title>IC卡详情|FSE铁路运输票务系统</title> <title>IC卡详情|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14"> <link rel="stylesheet" href="/style.css?v=22">
</head> </head>
<body class="public-search jr-public-page"> <body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
<span>返回首页</span> <span>返回首页</span>
</a> </a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div> </div>
</header> </header>
<div class="jr-brandbar"> <div class="jr-brandbar">
+2 -6
View File
@@ -7,7 +7,7 @@
<title>线上购卡|FSE铁路运输票务系统</title> <title>线上购卡|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14"> <link rel="stylesheet" href="/style.css?v=22">
</head> </head>
<body class="public-search jr-public-page"> <body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
<span>返回首页</span> <span>返回首页</span>
</a> </a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div> </div>
</header> </header>
<div class="jr-brandbar"> <div class="jr-brandbar">
+2 -6
View File
@@ -7,7 +7,7 @@
<title>IC卡查询|FSE铁路运输票务系统</title> <title>IC卡查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14"> <link rel="stylesheet" href="/style.css?v=22">
</head> </head>
<body class="public-search jr-public-page"> <body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
<span>返回首页</span> <span>返回首页</span>
</a> </a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div> </div>
</header> </header>
<div class="jr-brandbar"> <div class="jr-brandbar">
+1034 -866
View File
File diff suppressed because it is too large Load Diff
+183 -12
View File
@@ -27,6 +27,7 @@ createApp({
tickets: '车票记录', tickets: '车票记录',
vouchers: '凭证管理', vouchers: '凭证管理',
iccards: 'IC 卡管理', iccards: 'IC 卡管理',
issues: '问题汇总',
assets: '线路图', assets: '线路图',
settings: '系统设置', settings: '系统设置',
logs: '日志' logs: '日志'
@@ -34,15 +35,15 @@ createApp({
return map[currentView.value] || '票价图'; return map[currentView.value] || '票价图';
}); });
// Prefer polling first so admin remains connected even when the proxy // 强制使用 WebSocket,避免轮询 (polling) 在负载均衡/反代下产生 400 Bad Request
// does not support WebSocket upgrades reliably. const socket = io({ transports: ['websocket'], timeout: 20000 });
const socket = io({ transports: ['polling', 'websocket'] });
// Data State // Data State
const stations = ref([]); const stations = ref([]);
const lines = ref([]); const lines = ref([]);
const fares = ref([]); const fares = ref([]);
const tickets = ref([]); const tickets = ref([]);
const issues = ref([]);
const stats = reactive({ sold_tickets: 0, revenue: 0 }); const stats = reactive({ sold_tickets: 0, revenue: 0 });
const config = reactive({ const config = reactive({
api_base: '', api_base: '',
@@ -56,10 +57,11 @@ createApp({
const logMax = ref(200); const logMax = ref(200);
const logLoading = ref(false); const logLoading = ref(false);
const orders = ref([]); const orders = ref([]);
const assetsManifest = reactive({ routeMap: null, fareTable: null, updatedAt: null }); const assetsManifest = reactive({ routeMap: null, routeMapCredit: '', routeMapLicense: 'CC BY 4.0', fareTable: null, updatedAt: null });
const assetsFarePreview = reactive({ headers: [], rows: [] }); const assetsFarePreview = reactive({ headers: [], rows: [] });
const assetsRouteMapUrl = ref(''); const assetsRouteMapUrl = ref('');
const assetsFareTableUrl = ref(''); const assetsFareTableUrl = ref('');
const assetsForm = reactive({ routeMapCredit: '' });
const icCards = ref([]); const icCards = ref([]);
const icCardSearch = ref(''); const icCardSearch = ref('');
const icSelectedId = ref(''); const icSelectedId = ref('');
@@ -76,6 +78,7 @@ createApp({
let ticketDataLoaded = false; let ticketDataLoaded = false;
let orderDataLoaded = false; let orderDataLoaded = false;
let logDataLoaded = false; let logDataLoaded = false;
let issueDataLoaded = false;
let assetsLoaded = false; let assetsLoaded = false;
let fareMapLoaded = false; let fareMapLoaded = false;
const loadingState = reactive({ const loadingState = reactive({
@@ -83,7 +86,8 @@ createApp({
tickets: false, tickets: false,
orders: false, orders: false,
logs: false, logs: false,
iccards: false iccards: false,
issues: false
}); });
const lastSyncAt = ref(0); const lastSyncAt = ref(0);
@@ -159,6 +163,132 @@ createApp({
try { return JSON.parse(t); } catch (e) { return null; } try { return JSON.parse(t); } catch (e) { return null; }
}; };
const safeJsonStringify = (value) => {
try {
return JSON.stringify(value, null, 2);
} catch (e) {
return String(value);
}
};
const formatIssueKey = (key) => {
const map = {
device: '设备',
device_id: '设备编号',
machine_id: '机器 ID',
station_code: '车站编号',
station_name: '车站名称',
station: '车站',
error_msg: '错误信息',
ticket_id: '车票 ID',
card_id: '卡号',
holder_name: '持卡人',
balance: '余额',
amount: '金额',
price: '价格',
order_code: '订单号',
voucher_code: '凭证码',
method: '方法',
source: '来源',
reason: '原因',
status: '状态',
action: '动作',
line: '线路',
route: '区间',
from: '起点',
to: '终点',
time: '时间',
ts: '时间戳'
};
const raw = String(key || '').trim();
if (!raw) return '字段';
if (map[raw]) return map[raw];
return raw
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^\w/, (ch) => ch.toUpperCase());
};
const formatIssueValue = (value) => {
if (value == null || value === '') return '---';
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '---';
if (typeof value === 'string') return value.trim() || '---';
if (Array.isArray(value)) {
if (!value.length) return '---';
if (value.every((item) => item == null || ['string', 'number', 'boolean'].includes(typeof item))) {
return value.map(formatIssueValue).join(' / ');
}
return safeJsonStringify(value);
}
if (typeof value === 'object') return safeJsonStringify(value);
return String(value);
};
const buildIssueDetailView = (detail) => {
const rawText = String(detail || '').trim();
if (!rawText) return { entries: [], rawText: '', rawExpanded: false };
const hiddenKeys = new Set(['device', 'device_id', 'machine_id', 'station_code', 'station_name', 'error_msg', 'detail', 'type']);
const parsed = parseJsonSafe(rawText);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const entries = Object.entries(parsed)
.filter(([key, value]) => !hiddenKeys.has(String(key || '').trim()) && value != null && value !== '')
.map(([key, value]) => ({
key: String(key),
label: formatIssueKey(key),
value: formatIssueValue(value)
}));
return {
entries,
rawText: entries.length ? rawText : '',
rawExpanded: false
};
}
const segments = rawText
.split(/\r?\n|;|;/)
.map((item) => item.trim())
.filter(Boolean);
const entries = [];
for (const segment of segments) {
const matched = /^([^:=:]{1,30})\s*[:=:]\s*(.+)$/.exec(segment);
if (!matched) continue;
const key = String(matched[1] || '').trim();
if (!key || hiddenKeys.has(key)) continue;
entries.push({
key,
label: formatIssueKey(key),
value: formatIssueValue(matched[2])
});
}
const hasPlainBody = segments.length <= 1 && entries.length <= 1;
return {
entries: hasPlainBody ? [] : entries,
rawText,
rawExpanded: hasPlainBody
};
};
const normalizeIssueRecord = (issue) => {
const item = issue && typeof issue === 'object' ? issue : {};
const device = String(item.device || item.machine_id || '').trim();
const stationCode = String(item.station_code || '').trim();
const stationName = stationCode ? getStationName(stationCode) : '';
const detailView = buildIssueDetailView(item.detail);
return {
...item,
displayDevice: device || '---',
displayStation: stationName && stationName !== stationCode ? `${stationName} (${stationCode})` : (stationCode || '---'),
displayError: String(item.error_msg || '').trim() || '未提供错误信息',
detailEntries: detailView.entries,
detailRaw: detailView.rawText,
detailRawExpanded: detailView.rawExpanded
};
};
const parseCsvSimple = (text) => { const parseCsvSimple = (text) => {
const out = { headers: [], rows: [] }; const out = { headers: [], rows: [] };
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
@@ -577,8 +707,11 @@ createApp({
const fetchAssetsManifest = async () => { const fetchAssetsManifest = async () => {
const data = await requestJson('/api/assets/manifest'); const data = await requestJson('/api/assets/manifest');
assetsManifest.routeMap = data ? (data.routeMap || null) : null; assetsManifest.routeMap = data ? (data.routeMap || null) : null;
assetsManifest.routeMapCredit = data ? String(data.routeMapCredit || '') : '';
assetsManifest.routeMapLicense = data ? String(data.routeMapLicense || 'CC BY 4.0') : 'CC BY 4.0';
assetsManifest.fareTable = data ? (data.fareTable || null) : null; assetsManifest.fareTable = data ? (data.fareTable || null) : null;
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null; assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
assetsForm.routeMapCredit = assetsManifest.routeMapCredit;
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap); assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable); assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
assetsLoaded = true; assetsLoaded = true;
@@ -622,9 +755,12 @@ createApp({
markSynced(); markSynced();
}; };
const uploadAssetFile = async (url, file) => { const uploadAssetFile = async (url, file, extraFields = {}) => {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
for (const [key, value] of Object.entries(extraFields || {})) {
fd.append(key, value == null ? '' : String(value));
}
await requestJson(url, { method: 'POST', body: fd }, { expectOk: true }); await requestJson(url, { method: 'POST', body: fd }, { expectOk: true });
await fetchAssetsManifest(); await fetchAssetsManifest();
}; };
@@ -633,7 +769,7 @@ createApp({
const f = ev && ev.target && ev.target.files && ev.target.files[0]; const f = ev && ev.target && ev.target.files && ev.target.files[0];
if (!f) return; if (!f) return;
ev.target.value = ''; ev.target.value = '';
await uploadAssetFile('/api/assets/route-map', f); await uploadAssetFile('/api/assets/route-map', f, { credit: assetsForm.routeMapCredit });
}; };
const uploadFareTable = async (ev) => { const uploadFareTable = async (ev) => {
@@ -648,6 +784,15 @@ createApp({
await fetchAssetsManifest(); await fetchAssetsManifest();
}; };
const saveRouteMapCredit = async () => {
await requestJson('/api/assets/route-map/credit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credit: assetsForm.routeMapCredit })
}, { expectOk: true });
await fetchAssetsManifest();
};
const deleteFareTable = async () => { const deleteFareTable = async () => {
await requestJson('/api/assets/fare-table', { method: 'DELETE' }, { expectOk: true }); await requestJson('/api/assets/fare-table', { method: 'DELETE' }, { expectOk: true });
await fetchAssetsManifest(); await fetchAssetsManifest();
@@ -845,6 +990,26 @@ createApp({
} }
}; };
// --- Issues Management ---
const fetchIssues = async () => {
if (loadingState.issues) return;
loadingState.issues = true;
try {
const res = await requestJson('/api/issues');
if (res && res.ok) {
issues.value = (res.issues || [])
.map(normalizeIssueRecord)
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
issueDataLoaded = true;
markSynced();
}
} catch (e) {
console.error(e);
} finally {
loadingState.issues = false;
}
};
const fetchIcCards = async (keepSelection = true) => { const fetchIcCards = async (keepSelection = true) => {
if (loadingState.iccards) return; if (loadingState.iccards) return;
loadingState.iccards = true; loadingState.iccards = true;
@@ -1131,6 +1296,7 @@ createApp({
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData(); if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders(); if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs(); if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
if (view === 'issues' && (force || !issueDataLoaded)) await fetchIssues();
if (view === 'iccards') { if (view === 'iccards') {
await fetchIcCards(true); await fetchIcCards(true);
if (icSelectedId.value) { if (icSelectedId.value) {
@@ -1600,6 +1766,12 @@ createApp({
stats.sold_tickets += item.sold_tickets; stats.sold_tickets += item.sold_tickets;
stats.revenue += item.revenue; stats.revenue += item.revenue;
}); });
socket.on('issue:new', (item) => {
issues.value = [normalizeIssueRecord(item), ...issues.value]
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
issueDataLoaded = true;
markSynced();
});
socket.on('ic-card:created', () => { fetchIcCards(false).catch(console.error); }); socket.on('ic-card:created', () => { fetchIcCards(false).catch(console.error); });
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); }); socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
socket.on('ic-card:check', () => { socket.on('ic-card:check', () => {
@@ -1843,9 +2015,6 @@ createApp({
} else { } else {
stopIcCardSync(); stopIcCardSync();
} }
if (v === 'assets') {
setTimeout(initTopologyLayout, 300);
}
ensureViewData(v).catch(console.error); ensureViewData(v).catch(console.error);
const sp = new URLSearchParams(location.search); const sp = new URLSearchParams(location.search);
if (v === 'dashboard') sp.delete('view'); if (v === 'dashboard') sp.delete('view');
@@ -1915,6 +2084,7 @@ createApp({
tickets: `已加载 ${ticketList.value.length} 条车票记录`, tickets: `已加载 ${ticketList.value.length} 条车票记录`,
vouchers: `已加载 ${orders.value.length} 条凭证记录`, vouchers: `已加载 ${orders.value.length} 条凭证记录`,
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`, iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
issues: `当前共有 ${issues.value.length} 条问题记录`,
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图', assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
settings: '可维护优惠活动与导出数据', settings: '可维护优惠活动与导出数据',
logs: `当前筛选结果 ${logs.value.length} 条日志` logs: `当前筛选结果 ${logs.value.length} 条日志`
@@ -2000,11 +2170,12 @@ createApp({
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats, stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs, logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
orders, orderList, fetchOrders, deleteOrder, orders, orderList, fetchOrders, deleteOrder,
issues, fetchIssues,
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats, icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney, createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch, showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
assetsManifest, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl, assetsManifest, assetsForm, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable, uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable, saveRouteMapCredit,
// Management // Management
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations, selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
-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> <title>票务查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14" /> <link rel="stylesheet" href="/style.css?v=22" />
</head> </head>
<body class="public-search jr-public-page"> <body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
<span>返回首页</span> <span>返回首页</span>
</a> </a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div> </div>
</header> </header>
+479 -5
View File
@@ -1,3 +1,7 @@
[v-cloak] {
display: none !important;
}
:root { :root {
--bg: #09090b; --bg: #09090b;
--surface: #18181b; --surface: #18181b;
@@ -251,7 +255,7 @@ button, .btn {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 8px 16px; padding: 8px 16px;
border-radius: 0; border-radius: 0 !important;
border: 1px solid var(--border); border: 1px solid var(--border);
background-color: transparent; background-color: transparent;
color: var(--text); color: var(--text);
@@ -477,6 +481,9 @@ tr:hover td {
.mobile-only { .mobile-only {
display: none; display: none;
} }
.mobile-only-inline {
display: none;
}
@media (max-width: 980px) { @media (max-width: 980px) {
:root { --sidebar-width: 260px; } :root { --sidebar-width: 260px; }
@@ -1780,6 +1787,7 @@ body.jr-order-page .site-footer .version {
background: #ffffff; background: #ffffff;
border: 1px solid #d7e0d3; border: 1px solid #d7e0d3;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06); box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
border-radius: 0;
} }
.jr-hero-copy { .jr-hero-copy {
@@ -2054,6 +2062,7 @@ body.jr-order-page .site-footer .version {
background: background:
linear-gradient(180deg, #f7faf7 0, #edf4ed 100%); linear-gradient(180deg, #f7faf7 0, #edf4ed 100%);
border: 1px solid #d7e0d3; border: 1px solid #d7e0d3;
border-radius: 0;
padding: 22px; padding: 22px;
} }
@@ -2441,11 +2450,14 @@ body.jr-order-page .station-map-container text {
.jr-selection-summary { .jr-selection-summary {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px;
margin-bottom: 16px;
} }
.jr-selection-arrow { .jr-selection-arrow {
min-height: 20px; min-height: 12px;
transform: rotate(90deg); transform: rotate(90deg);
font-size: 1rem;
} }
.jr-form-grid, .jr-form-grid,
@@ -2459,11 +2471,25 @@ body.jr-order-page .station-map-container text {
} }
.jr-station-map { .jr-station-map {
padding: 14px; padding: 10px;
}
.jr-selection-card {
padding: 12px 14px;
}
.jr-selection-tag {
margin-bottom: 4px;
font-size: 0.76rem;
}
.jr-selection-card strong {
font-size: 1rem;
line-height: 1.35;
} }
body.jr-order-page .station-map-container svg { body.jr-order-page .station-map-container svg {
min-width: 620px; min-width: 560px;
transform: none; transform: none;
} }
@@ -2725,6 +2751,25 @@ body.jr-public-page {
height: auto; height: auto;
} }
.jr-asset-credit {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
font-size: 0.82rem;
line-height: 1.5;
color: #6a786d;
}
.jr-asset-credit a {
color: #0b6b3a;
text-decoration: none;
}
.jr-asset-credit a:hover {
text-decoration: underline;
}
.jr-search-form { .jr-search-form {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -4199,6 +4244,123 @@ body.jr-admin-login-page {
gap: 16px; gap: 16px;
} }
.jr-issue-table {
width: 100%;
table-layout: fixed;
}
.jr-issue-table th:nth-child(1) {
width: 168px;
}
.jr-issue-table th:nth-child(2) {
width: 150px;
}
.jr-issue-table th:nth-child(3) {
width: 190px;
}
.jr-issue-table th:nth-child(4) {
width: 220px;
}
.jr-issue-table td {
vertical-align: top;
}
.jr-issue-primary {
font-weight: 700;
line-height: 1.45;
overflow-wrap: anywhere;
}
.jr-issue-sub {
margin-top: 4px;
font-size: 0.78rem;
color: #6d7f73;
overflow-wrap: anywhere;
}
.jr-issue-error {
color: #c43f3f;
font-weight: 700;
line-height: 1.6;
}
.jr-issue-detail-cell {
min-width: 0;
}
.jr-issue-detail-card,
.jr-issue-plain-detail {
border: 1px solid #dbe4d7;
background: #f8fbf7;
padding: 12px;
border-radius: 0;
}
.jr-issue-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
}
.jr-issue-detail-item {
border: 1px solid #dbe4d7;
background: #ffffff;
padding: 10px 12px;
min-width: 0;
}
.jr-issue-detail-item span {
display: block;
margin-bottom: 4px;
color: #6d7f73;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.jr-issue-detail-item strong {
display: block;
color: #183225;
font-size: 0.92rem;
line-height: 1.55;
font-weight: 600;
overflow-wrap: anywhere;
}
.jr-issue-raw-block {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #dbe4d7;
}
.jr-issue-raw-block summary {
cursor: pointer;
color: #607466;
font-size: 0.82rem;
font-weight: 700;
}
.jr-issue-raw-block pre,
.jr-issue-plain-detail pre {
margin: 8px 0 0;
padding: 12px;
border: 1px solid #1e293b;
background: #0f172a;
color: #e2e8f0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.82rem;
line-height: 1.5;
overflow: auto;
user-select: text;
-webkit-user-select: text;
}
.jr-admin-login-shell { .jr-admin-login-shell {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
@@ -4492,6 +4654,312 @@ body.jr-admin-login-page {
} }
} }
/* ========== Route 鬘オ髱「遘サ蜉ィ遶ッ荳馴。ケ騾る� ========== */
@media (max-width: 980px) {
body.jr-admin-route-page {
overflow: auto;
}
body.jr-admin-route-page #app {
min-height: auto;
height: auto;
}
.jr-admin-route-page .jr-admin-app {
min-height: auto;
border-radius: 0;
}
.jr-admin-route-page .jr-admin-main-shell {
width: min(100%, calc(100% - 24px));
padding: 12px 0 24px;
}
.jr-admin-route-page .jr-admin-app {
flex-direction: column;
}
.jr-admin-route-page .sidebar {
width: 86%;
max-width: 340px;
}
.jr-admin-route-page .jr-admin-header-side {
width: auto;
justify-content: flex-end;
}
}
@media (max-width: 768px) {
body.jr-admin-route-page .jr-topbar-inner {
min-height: 52px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 8px 0;
gap: 6px;
}
body.jr-admin-route-page .jr-top-status {
font-size: 0.8rem;
padding: 5px 10px;
}
body.jr-admin-route-page .jr-brandbar-inner {
min-height: auto;
padding: 14px 0;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
body.jr-admin-route-page .jr-nav {
width: 100%;
justify-content: flex-start;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
body.jr-admin-route-page .jr-nav a {
min-width: 88px;
padding: 8px 12px;
font-size: 0.85rem;
flex-shrink: 0;
}
.jr-admin-route-page .jr-admin-page .header,
.jr-admin-route-page .jr-admin-page .content {
padding-left: 14px;
padding-right: 14px;
}
.jr-admin-route-page .content {
padding: 14px;
}
.jr-admin-route-page .header {
padding: 12px 14px;
min-height: 60px;
flex-wrap: wrap;
gap: 10px;
}
.jr-admin-route-page .jr-page-intro {
padding: 18px 16px;
}
.jr-admin-route-page .jr-page-intro h1 {
font-size: clamp(1.4rem, 6vw, 2rem);
}
.jr-admin-route-page .jr-page-intro p {
font-size: 0.9rem;
line-height: 1.6;
}
.jr-admin-route-page .card {
padding: 14px;
margin-bottom: 14px;
}
.jr-admin-route-page .management-container {
flex-direction: column;
gap: 14px;
height: auto;
overflow: visible;
}
.jr-admin-route-page .management-sidebar {
width: 100%;
height: 380px;
max-height: 40vh;
}
.jr-admin-route-page .management-sidebar .card {
height: 100%;
overflow: hidden;
}
.jr-admin-route-page .management-main {
overflow: visible;
min-height: auto;
}
.jr-admin-route-page .management-main.is-empty {
display: none !important;
}
.desktop-only {
display: none !important;
}
.mobile-only-inline {
display: inline-block !important;
}
.jr-admin-route-page .editor-toolbar {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.jr-admin-route-page .editor-toolbar > .flex:first-child {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.jr-admin-route-page .switch-label {
width: 100%;
margin-left: 0 !important;
}
.jr-admin-route-page .hint-text {
margin-left: 0;
width: 100%;
word-break: break-word;
}
.jr-admin-route-page .editor-toolbar > .flex:last-child {
background: transparent !important;
padding: 0 !important;
flex-direction: column;
gap: 8px;
}
.jr-admin-route-page .editor-toolbar > .flex:last-child input,
.jr-admin-route-page .editor-toolbar > .flex:last-child button {
width: 100%;
}
.jr-admin-route-page .visual-line-container {
padding: 16px 10px;
min-height: 280px;
cursor: default;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
.jr-admin-route-page .visual-line-container svg {
min-width: 720px;
height: 220px;
}
.jr-admin-route-page .faremap-card .flex.between {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.jr-admin-route-page .faremap-card .flex.between > .flex:last-child {
width: 100%;
flex-wrap: wrap;
}
.jr-admin-route-page .faremap-card .flex.between > .flex:last-child button {
flex: 1;
min-width: 52px;
}
.jr-admin-route-page .faremap-viewport {
max-height: 60vh;
}
.jr-admin-route-page .faremap-canvas {
min-width: 100%;
}
.jr-admin-route-page .ticket-table,
.jr-admin-route-page table {
display: block;
overflow-x: auto;
white-space: nowrap;
font-size: 0.85rem;
}
.jr-admin-route-page .ticket-table th,
.jr-admin-route-page .ticket-table td,
.jr-admin-route-page table th,
.jr-admin-route-page table td {
padding: 10px 12px;
white-space: nowrap;
}
.jr-admin-route-page .modal-card {
width: 96%;
max-width: 96%;
padding: 18px 16px;
max-height: 92vh;
border-radius: 0;
}
.jr-admin-route-page .modal-title {
font-size: 1.05rem;
margin-bottom: 16px;
}
.jr-admin-route-page .modal-actions {
flex-direction: column-reverse;
margin-top: 20px;
gap: 8px;
}
.jr-admin-route-page .modal-actions button {
width: 100%;
min-height: 48px;
}
.jr-admin-route-page select[multiple] {
height: 160px;
font-size: 0.9rem;
}
.jr-admin-route-page button,
.jr-admin-route-page .btn {
min-height: 44px;
padding: 0 14px;
}
.jr-admin-route-page button.sm,
.jr-admin-route-page .btn.sm {
min-height: 36px;
min-width: 36px;
}
.jr-admin-route-page .icon-btn {
width: 44px;
height: 44px;
}
.jr-admin-route-page input,
.jr-admin-route-page select {
min-height: 44px;
font-size: 1rem;
}
.jr-admin-route-page .line-item {
padding: 14px 12px;
min-height: 56px;
}
.jr-admin-route-page .line-item .line-actions {
opacity: 1;
}
.jr-admin-route-page .line-item .line-actions button {
min-height: 36px;
min-width: 36px;
}
.jr-admin-route-page .site-footer {
margin-top: 20px;
padding-top: 12px;
font-size: 11px;
}
}
@media (max-width: 480px) {
.jr-admin-route-page .header h3 {
font-size: 1rem;
}
.jr-admin-route-page .jr-admin-header-pill {
font-size: 11px;
padding: 6px 10px;
}
.jr-admin-route-page .badge {
font-size: 0.7rem;
padding: 2px 5px;
}
.jr-admin-route-page h4 {
font-size: 0.95rem;
margin-bottom: 0.75rem;
}
.jr-admin-route-page h3 {
font-size: 1.1rem;
}
.jr-admin-route-page .jr-brand-copy strong {
font-size: 1rem;
}
.jr-admin-route-page .jr-brand-copy span {
font-size: 0.78rem;
}
.jr-admin-route-page .jr-brand-logo {
width: 40px;
height: 40px;
}
.jr-admin-route-page .faremap-viewport {
max-height: 50vh;
}
.jr-admin-route-page .visual-line-container svg {
min-width: 640px;
}
.jr-admin-route-page #app .ticket-header .flex.between:last-of-type {
flex-direction: column;
align-items: flex-start;
}
}
/* Static Map Overlays */ /* Static Map Overlays */
.map-station-overlay { .map-station-overlay {
z-index: 10; z-index: 10;
@@ -4527,5 +4995,11 @@ body.jr-admin-login-page {
background-color: var(--warning); background-color: var(--warning);
border-color: white; 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> <title>电子客票|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14"> <link rel="stylesheet" href="/style.css?v=22">
<style> <style>
[v-cloak] { [v-cloak] {
display: none; display: none;
@@ -51,11 +51,7 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
<span>返回查询</span> <span>返回查询</span>
</a> </a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div> </div>
</header> </header>
<div class="jr-brandbar"> <div class="jr-brandbar">
+2 -6
View File
@@ -7,7 +7,7 @@
<title>线上预定|FSE铁路运输票务系统</title> <title>线上预定|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14"> <link rel="stylesheet" href="/style.css?v=22">
</head> </head>
<body class="public-search jr-order-page"> <body class="public-search jr-order-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
<span>返回首页</span> <span>返回首页</span>
</a> </a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div> </div>
</header> </header>
<div class="jr-brandbar"> <div class="jr-brandbar">
+101 -60
View File
@@ -41,7 +41,10 @@
let lineColors = []; let lineColors = [];
let linesByStation = new Map(); let linesByStation = new Map();
let lineIdxsByCanonical = {}; let lineIdxsByCanonical = {};
let activeLineIdx = 0; let activeLineIdx = -1;
let routeMapUrl = '';
let routeMapCredit = '';
let routeMapLicense = 'CC BY 4.0';
const piePath = (cx, cy, r, a0, a1) => { const piePath = (cx, cy, r, a0, a1) => {
const x0 = cx + r * Math.cos(a0); const x0 = cx + r * Math.cos(a0);
@@ -77,6 +80,26 @@
return (yiq >= 140) ? '#0f172a' : '#ffffff'; return (yiq >= 140) ? '#0f172a' : '#ffffff';
}; };
const buildAssetUrl = (name, updatedAt) => {
if (!name) return '';
const version = updatedAt ? String(updatedAt) : String(Date.now());
return `/assets/${encodeURIComponent(name)}?v=${encodeURIComponent(version)}`;
};
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const buildCreditHtml = (credit, license, linkColor = '') => {
const safeCredit = escapeHtml(credit || '未填写');
const safeLicense = escapeHtml(license || 'CC BY 4.0');
const colorStyle = linkColor ? ` style="color: ${linkColor};"` : '';
return `<div class="jr-asset-credit"><span>线路图署名:${safeCredit}</span><span>许可协议:</span><a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer"${colorStyle}>${safeLicense}</a></div>`;
};
function renderStationNode(x, y, code, i, forceColor = null) { function renderStationNode(x, y, code, i, forceColor = null) {
const canonical = stationCanonicalByCode[code] || code; const canonical = stationCanonicalByCode[code] || code;
const codes = stationCodesByCanonical[canonical] || [canonical]; const codes = stationCodesByCanonical[canonical] || [canonical];
@@ -159,13 +182,13 @@
const bg = isActive ? li.color : '#f8fafc'; const bg = isActive ? li.color : '#f8fafc';
const fg = isActive ? getContrastColor(li.color) : '#334155'; const fg = isActive ? getContrastColor(li.color) : '#334155';
const border = isActive ? li.color : '#cbd5e1'; const border = isActive ? li.color : '#cbd5e1';
html += `<button class="tvm-line-btn" style="padding: 6px 12px; border: 1.5px solid ${border}; background: ${bg}; color: ${fg}; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px; transition: all 0.25s ease;" onclick="selectTvmLine(${i})">${li.name}</button>`; html += `<button class="tvm-line-btn" style="--btn-border:${border}; --btn-bg:${bg}; --btn-fg:${fg};" onclick="selectTvmLine(${i})">${li.name}</button>`;
} }
container.innerHTML = html; container.innerHTML = html;
} }
window.selectTvmLine = (idx) => { window.selectTvmLine = (idx) => {
activeLineIdx = idx; activeLineIdx = (activeLineIdx === idx) ? -1 : idx;
renderTvmLines(); renderTvmLines();
renderTvmSingleLine(); renderTvmSingleLine();
}; };
@@ -175,20 +198,40 @@
if (!container) return; if (!container) return;
const li = lineStops[activeLineIdx]; const li = lineStops[activeLineIdx];
if (!li) { if (!li) {
container.innerHTML = ''; if (routeMapUrl) {
container.innerHTML = `<div class="tvm-overview tvm-animate"><div class="tvm-overview-frame"><img src="${routeMapUrl}" alt="线路图总览">${buildCreditHtml(routeMapCredit, routeMapLicense)}</div></div>`;
} else {
container.innerHTML = '<div class="tvm-overview-empty">暂未选择线路,且暂无已上传线路图。</div>';
}
return; return;
} }
const startX = 40; const startX = 40;
const gapX = 65; const gapX = 65;
const breakGapX = 36;
const y = 60; const y = 60;
const pts = []; const pts = [];
const lineSegments = [];
let currentSegment = [];
let cursorX = startX;
for (let i = 0; i < li.stops.length; i++) { for (let i = 0; i < li.stops.length; i++) {
const code = li.stops[i]; const code = li.stops[i];
if (code === '-') continue; if (code === '-') {
const x = startX + pts.length * gapX; if (currentSegment.length > 0) {
pts.push({ x, y, code }); lineSegments.push(currentSegment);
currentSegment = [];
}
cursorX += breakGapX;
continue;
}
const point = { x: cursorX, y, code };
pts.push(point);
currentSegment.push(point);
cursorX += gapX;
}
if (currentSegment.length > 0) {
lineSegments.push(currentSegment);
} }
if (pts.length === 0) { if (pts.length === 0) {
@@ -198,17 +241,18 @@
let svgContent = ''; let svgContent = '';
if (pts.length >= 2) { for (const segment of lineSegments) {
let d = `M ${pts[0].x} ${y}`; if (segment.length < 2) continue;
for(let i=1; i<pts.length; i++) d += ` L ${pts[i].x} ${y}`; let d = `M ${segment[0].x} ${y}`;
svgContent += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />`; for (let i = 1; i < segment.length; i++) d += ` L ${segment[i].x} ${y}`;
svgContent += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="6" stroke-linecap="butt" stroke-linejoin="miter" />`;
} }
for (let i = 0; i < pts.length; i++) { for (let i = 0; i < pts.length; i++) {
svgContent += renderStationNode(pts[i].x, pts[i].y, pts[i].code, i, li.color); svgContent += renderStationNode(pts[i].x, pts[i].y, pts[i].code, i, li.color);
} }
const width = Math.max(container.clientWidth || 800, startX + pts.length * gapX + 40); const width = Math.max(container.clientWidth || 800, cursorX);
const height = 120; const height = 120;
// 添加 tvm-animate 类实现动画过渡 // 添加 tvm-animate 类实现动画过渡
container.innerHTML = `<svg class="tvm-animate" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="display:block;">${svgContent}</svg>`; container.innerHTML = `<svg class="tvm-animate" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="display:block;">${svgContent}</svg>`;
@@ -266,7 +310,7 @@
} }
for (const seg of segments) { for (const seg of segments) {
svgContent += `<line x1="${seg.x1}" y1="${y}" x2="${seg.x2}" y2="${y}" stroke="${seg.color}" stroke-width="6" stroke-linecap="round" />`; svgContent += `<line x1="${seg.x1}" y1="${y}" x2="${seg.x2}" y2="${y}" stroke="${seg.color}" stroke-width="6" stroke-linecap="butt" />`;
} }
const pathD = `M ${startX} ${y} L ${startX + (routeCodes.length - 1) * gapX} ${y}`; const pathD = `M ${startX} ${y} L ${startX + (routeCodes.length - 1) * gapX} ${y}`;
@@ -278,7 +322,7 @@
for (let i = 0; i < numArrows; i++) { for (let i = 0; i < numArrows; i++) {
svgContent += ` svgContent += `
<g> <g>
<path d="M -5 -4 L 3 0 L -5 4" fill="none" stroke="#854d0e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M -5 -4 L 3 0 L -5 4" fill="none" stroke="#854d0e" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter" />
<animateMotion dur="${duration}s" repeatCount="indefinite" path="${pathD}" rotate="auto" begin="-${i * interval}s" calcMode="linear" /> <animateMotion dur="${duration}s" repeatCount="indefinite" path="${pathD}" rotate="auto" begin="-${i * interval}s" calcMode="linear" />
</g> </g>
`; `;
@@ -296,9 +340,10 @@
async function renderSystemMap() { async function renderSystemMap() {
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>'; mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
try { try {
const [linesData, stationsData] = await Promise.all([ const [linesData, stationsData, assetsData] = await Promise.all([
fetch('/api/public/lines').then(r=>r.json()), fetch('/api/public/lines').then(r=>r.json()),
fetch('/api/public/stations').then(r=>r.json()) fetch('/api/public/stations').then(r=>r.json()),
fetch('/api/assets/manifest', { cache: 'no-store' }).then(r => r.json()).catch(() => null)
]); ]);
window.cachedStationsData = stationsData; window.cachedStationsData = stationsData;
@@ -333,45 +378,19 @@
const stopsRaw = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []); const stopsRaw = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []);
const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean); const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean);
if (stops.length === 0) continue; if (stops.length === 0) continue;
const stopCodes = stops.filter(stopCode => stopCode !== '-');
let currentSegment = []; if (stopCodes.length === 0) continue;
let addedAny = false; lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', enName: line.en_name || line.英文名称 || line.英文名 || '', color, stops });
for (const c of stops) { lineColors.push(color);
if (c === '-') { for (const stopCode of stopCodes) {
if (currentSegment.length > 0) { const arr = linesByStation.get(stopCode) || [];
lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', enName: line.en_name || line.英文名称 || line.英文名 || '', color, stops: currentSegment }); arr.push(currentLineIdx);
lineColors.push(color); linesByStation.set(stopCode, arr);
for (const stopCode of currentSegment) { const canonical = stationCanonicalByCode[stopCode] || stopCode;
const arr = linesByStation.get(stopCode) || []; if (!lineIdxsByCanonical[canonical]) lineIdxsByCanonical[canonical] = new Set();
arr.push(currentLineIdx); lineIdxsByCanonical[canonical].add(currentLineIdx);
linesByStation.set(stopCode, arr);
const canonical = stationCanonicalByCode[stopCode] || stopCode;
if (!lineIdxsByCanonical[canonical]) lineIdxsByCanonical[canonical] = new Set();
lineIdxsByCanonical[canonical].add(currentLineIdx);
}
currentSegment = [];
addedAny = true;
}
} else {
currentSegment.push(c);
}
}
if (currentSegment.length > 0) {
lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', enName: line.en_name || line.英文名称 || line.英文名 || '', color, stops: currentSegment });
lineColors.push(color);
for (const stopCode of currentSegment) {
const arr = linesByStation.get(stopCode) || [];
arr.push(currentLineIdx);
linesByStation.set(stopCode, arr);
const canonical = stationCanonicalByCode[stopCode] || stopCode;
if (!lineIdxsByCanonical[canonical]) lineIdxsByCanonical[canonical] = new Set();
lineIdxsByCanonical[canonical].add(currentLineIdx);
}
addedAny = true;
}
if (addedAny) {
currentLineIdx++;
} }
currentLineIdx++;
} }
if (lineStops.length === 0) { if (lineStops.length === 0) {
@@ -384,25 +403,47 @@
if (uniq.length >= 2) stationTransfer.add(c); if (uniq.length >= 2) stationTransfer.add(c);
} }
routeMapUrl = (assetsData && assetsData.routeMap) ? buildAssetUrl(assetsData.routeMap, assetsData.updatedAt) : '';
routeMapCredit = assetsData ? String(assetsData.routeMapCredit || '') : '';
routeMapLicense = assetsData ? String(assetsData.routeMapLicense || 'CC BY 4.0') : 'CC BY 4.0';
const fadeStyle = `<style> const fadeStyle = `<style>
@keyframes tvmFadeIn { @keyframes tvmFadeIn {
from { opacity: 0; transform: translateX(-10px); } from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); } to { opacity: 1; transform: translateX(0); }
} }
.tvm-animate { animation: tvmFadeIn 0.3s ease-out forwards; } .tvm-animate { animation: tvmFadeIn 0.3s ease-out forwards; }
.tvm-layout { display: flex; flex-direction: column; gap: 16px; width: 100%; min-width: 0; box-sizing: border-box; }
.tvm-lines { display: flex; flex-wrap: wrap; align-items: flex-start; align-content: flex-start; gap: 6px; width: 100%; min-width: 0; margin-top: -8px; margin-bottom: 2px; box-sizing: border-box; }
.tvm-line-btn { flex: 0 0 auto; min-height: 34px; padding: 0 12px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; white-space: nowrap; border: 1.5px solid var(--btn-border, #cbd5e1); background: var(--btn-bg, #f8fafc); color: var(--btn-fg, #334155); border-radius: 0; cursor: pointer; font-weight: 700; font-size: 13px; line-height: 1; transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease; }
.tvm-single-line, .tvm-route { width: 100%; min-width: 0; overflow-x: auto; padding: 10px 0; min-height: 120px; display: flex; align-items: center; justify-content: flex-start; box-sizing: border-box; }
.tvm-overview { width: 100%; min-height: 120px; display: flex; align-items: center; justify-content: center; background: #f8fbf8; border: 1px solid #d7e0d3; padding: 10px; box-sizing: border-box; }
.tvm-overview-frame { width: 100%; }
.tvm-overview img { display: block; max-width: 100%; height: auto; background: #ffffff; margin: 0 auto; }
.tvm-overview-empty { width: 100%; min-height: 120px; display: flex; align-items: center; justify-content: center; color: #64748b; background: #f8fbf8; border: 1px solid #d7e0d3; padding: 16px; text-align: center; }
.tvm-route-title { font-weight: bold; margin-bottom: -15px; color: #475569; display: none; font-size: 14px; }
.tvm-route { margin-top: -4px; }
.tvm-scroll-container { scroll-behavior: smooth; -webkit-overflow-scrolling: touch; } .tvm-scroll-container { scroll-behavior: smooth; -webkit-overflow-scrolling: touch; }
.tvm-scroll-container::-webkit-scrollbar { height: 4px; } .tvm-scroll-container::-webkit-scrollbar { height: 4px; }
.tvm-scroll-container::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 2px; } .tvm-scroll-container::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 0; }
.tvm-scroll-container::-webkit-scrollbar-track { background: transparent; } .tvm-scroll-container::-webkit-scrollbar-track { background: transparent; }
@media (max-width: 768px) {
.tvm-layout { gap: 12px; }
.tvm-lines { flex-wrap: nowrap; overflow-x: auto; gap: 5px; margin-top: -14px; margin-bottom: -2px; padding-bottom: 2px; }
.tvm-single-line, .tvm-route { min-height: 112px; padding: 6px 0 8px; }
.tvm-overview, .tvm-overview-empty { min-height: 112px; }
.tvm-route-title { margin-bottom: -12px; font-size: 13px; }
.tvm-route { margin-top: -6px; }
}
</style>`; </style>`;
mapContainer.innerHTML = ` mapContainer.innerHTML = `
${fadeStyle} ${fadeStyle}
<div class="tvm-layout" style="display: flex; flex-direction: column; gap: 16px; width: 100%;"> <div class="tvm-layout">
<div class="tvm-lines" id="tvmLines" style="display: flex; flex-wrap: wrap; gap: 6px;"></div> <div class="tvm-lines" id="tvmLines"></div>
<div class="tvm-single-line tvm-scroll-container" id="tvmSingleLine" style="overflow-x: auto; padding: 10px 0; min-height: 120px; display: flex; align-items: center; justify-content: flex-start;"></div> <div class="tvm-single-line tvm-scroll-container" id="tvmSingleLine"></div>
<div class="tvm-route-title" style="font-weight: bold; margin-bottom: -12px; color: #475569; display: none; font-size: 14px;" id="tvmRouteTitle">已选路径</div> <div class="tvm-route-title" id="tvmRouteTitle">已选路径</div>
<div class="tvm-route tvm-scroll-container" id="tvmRoute" style="overflow-x: auto; padding: 10px 0; border-top: 1.5px dashed #cbd5e1; min-height: 120px; display: none; align-items: center; justify-content: flex-start;"></div> <div class="tvm-route tvm-scroll-container" id="tvmRoute" style="border-top: 1.5px dashed #cbd5e1; display: none;"></div>
</div> </div>
`; `;
+188 -195
View File
@@ -8,7 +8,7 @@
<title>线路规划|FSE铁路运输票务系统</title> <title>线路规划|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14"> <link rel="stylesheet" href="/style.css?v=22">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/socket.io/socket.io.js"></script> <script src="/socket.io/socket.io.js"></script>
</head> </head>
@@ -21,11 +21,7 @@
<i class="fas fa-train"></i> <i class="fas fa-train"></i>
<span>FSE铁路运输票务系统</span> <span>FSE铁路运输票务系统</span>
</a> </a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div> </div>
</header> </header>
@@ -49,7 +45,7 @@
</div> </div>
<main class="jr-public-main jr-admin-main-shell"> <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="sidebar" :class="{ open: sidebarOpen }">
<div class="jr-admin-sidebar-head"> <div class="jr-admin-sidebar-head">
<span class="jr-kicker">ROUTE PLANNING</span> <span class="jr-kicker">ROUTE PLANNING</span>
@@ -64,6 +60,10 @@
@click="currentView = 'management'"> @click="currentView = 'management'">
<span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路规划 <span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路规划
</div> </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'"> <div class="nav-item" :class="{active: currentView === 'faremap'}" @click="currentView = 'faremap'">
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图 <span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
</div> </div>
@@ -100,10 +100,7 @@
</div> </div>
</div> </div>
<div class="jr-admin-header-side"> <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>
</div> </div>
@@ -159,7 +156,7 @@
</div> </div>
</div> </div>
<!--右侧面板--> <!--右侧面板-->
<div class="management-main"> <div class="management-main" :class="{'is-empty': !selectedLine}">
<div class="card mb-4"> <div class="card mb-4">
<div class="flex between"> <div class="flex between">
<div v-if="selectedLine"> <div v-if="selectedLine">
@@ -174,7 +171,8 @@
</div> </div>
</div> </div>
<div v-else> <div v-else>
<h4>选择左侧线路进行管理</h4> <h4 class="desktop-only">选择左侧线路进行管理</h4>
<h4 class="mobile-only-inline">请选择上方线路进行管理</h4>
</div> </div>
<div class="flex"> <div class="flex">
@@ -223,8 +221,13 @@
</div> </div>
<!-- 可视化线路编辑--> <!-- 可视化线路编辑-->
<div class="visual-line-container"> <div class="visual-line-container" ref="visualLineContainer"
<svg width="100%" height="200" :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"> v-if="selectedLine.stations && selectedLine.stations.length > 0">
<!--站点连接线--> <!--站点连接线-->
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100" <line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100"
@@ -239,8 +242,10 @@
<!--车站节点--> <!--车站节点-->
<g v-for="(sCode, index) in selectedLine.stations" :key="sCode" <g v-for="(sCode, index) in selectedLine.stations" :key="sCode"
@mousedown="onStationDragStart(index)" @mouseup="onStationDrop" @mousedown="onStationDragStart(index, $event)"
@mousemove="onStationDragOver(index)" @click="handleStationClick(sCode)" @mouseup="onStationDrop"
@click="handleStationClick(sCode)"
@touchstart.passive="onStationTouchStart(index, $event)"
class="station-node" :class="{ class="station-node" :class="{
'selected': isStationSelected(sCode), 'selected': isStationSelected(sCode),
'fare-source': fareSelection[0] === sCode, 'fare-source': fareSelection[0] === sCode,
@@ -284,7 +289,6 @@
</div> </div>
</div> </div>
<!-- 票价设置弹窗 -->
<div v-if="showFareModal" class="modal show"> <div v-if="showFareModal" class="modal show">
<div class="modal-card"> <div class="modal-card">
<h4 class="modal-title">设置票价</h4> <h4 class="modal-title">设置票价</h4>
@@ -384,9 +388,72 @@
</div> </div>
</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 v-if="currentView === 'faremap'" class="card faremap-card">
<div class="card faremap-card">
<div class="flex between mb-4"> <div class="flex between mb-4">
<h4>票价地图</h4> <h4>票价地图</h4>
<div class="flex" style="flex-wrap: wrap; gap: 8px;"> <div class="flex" style="flex-wrap: wrap; gap: 8px;">
@@ -404,187 +471,112 @@
v-html="fareMapSvg"> v-html="fareMapSvg">
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- 凭证管理 --> <!-- 提取出来的弹窗,避免被 currentView = 'management' 屏蔽 -->
<div v-if="currentView === 'vouchers'"> <div v-if="showFareModal" class="modal show">
<div class="card"> <div class="modal-card">
<div class="flex between mb-4"> <h4 class="modal-title">设置票价</h4>
<h4>凭证列表</h4> <div class="mb-4 text-center">
<div class="flex"> <div class="flex between"
<button @click="fetchOrders"><i class="fas fa-sync-alt"></i></button> 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>
</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"> <div class="mb-4">
<label style="display:block; margin-bottom:8px; font-weight:600;">优惠活动</label> <label>常规票价</label>
<div class="flex"> <input v-model.number="currentFare.cost_regular" type="number" class="w-100">
<input v-model="config.promotion.name" placeholder="活动名称"> </div>
<input v-model.number="config.promotion.discount" type="number" step="0.1" <div class="mb-4">
placeholder="折扣 (0.1-1.0)"> <label>特急票价</label>
<button @click="saveConfig"><i class="fas fa-save"></i> 保存</button> <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> </div>
<div class="modal-actions">
<button @click="saveLineSettings">保存</button>
<button class="danger" @click="closeLineModal">取消</button>
</div>
</div> </div>
</div> </div>
@@ -593,6 +585,7 @@
<span class="version">v1.0.12</span> <span class="version">v1.0.12</span>
</footer> </footer>
</div> </div>
</div> </div>
</div> </div>
@@ -600,7 +593,7 @@
</div> </div>
<script src="/custom-dialog.js?v=11"></script> <script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script> <script src="/public-status.js?v=14"></script>
<script src="ticket-route.js?v=3"></script> <script src="ticket-route.js?v=8"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const isDomain = location.hostname.includes('fse-media.group');
+191 -50
View File
@@ -15,17 +15,16 @@ createApp({
const sidebarOpen = ref(false); const sidebarOpen = ref(false);
const viewTitle = computed(() => { const viewTitle = computed(() => {
const map = { const map = {
dashboard: '仪表盘',
management: '线路与票价管理', management: '线路与票价管理',
tickets: '车票记录', stations: '站点管理',
settings: '系统设置' faremap: '票价地图'
}; };
return map[currentView.value] || '线路规划系统'; return map[currentView.value] || '线路规划系统';
}); });
const connected = ref(false); const connected = ref(false);
// Keep the legacy route console usable behind proxies that only allow polling. // 强制使用 WebSocket,避免轮询 (polling) 在负载均衡/反代下产生 400 Bad Request
const socket = io({ transports: ['polling', 'websocket'], timeout: 20000 }); const socket = io({ transports: ['websocket'], timeout: 20000 });
const stations = ref([]); const stations = ref([]);
@@ -400,24 +399,38 @@ createApp({
selectedTicket.value = null; selectedTicket.value = null;
}; };
/* 拖动调整站序 */ /* 拖动调整站序 - 统一实现(鼠标+触摸) */
const onStationDragStart = (index) => { const dragActiveSvg = ref(null);
if (fareMode.value) return;
draggingStationIndex.value = index; 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) => { const swapStationByIndex = (targetIndex) => {
if (draggingStationIndex.value === null) return; if (draggingStationIndex.value === null) return false;
if (draggingStationIndex.value === index) return; if (targetIndex < 0) return false;
if (draggingStationIndex.value === targetIndex) return false;
const list = selectedLine.value.stations; const list = selectedLine.value.stations;
const temp = list[draggingStationIndex.value]; if (!list) return false;
list.splice(draggingStationIndex.value, 1); const temp = list[draggingStationIndex.value];
list.splice(index, 0, temp); list.splice(draggingStationIndex.value, 1);
draggingStationIndex.value = index; list.splice(targetIndex, 0, temp);
draggingStationIndex.value = targetIndex;
return true;
}; };
const onStationDrop = async () => { const saveDragResult = async () => {
if (draggingStationIndex.value === null) return; if (draggingStationIndex.value === null) return;
try { try {
await updateLineStations(selectedLine.value.id, selectedLine.value.stations); await updateLineStations(selectedLine.value.id, selectedLine.value.stations);
@@ -426,6 +439,88 @@ createApp({
await fetchData(); await fetchData();
} }
draggingStationIndex.value = null; 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; })) return;
await runMutation(async () => { await runMutation(async () => {
await requestJson(`/api/stations/${encodeURIComponent(code)}`, { method: 'DELETE' }, { expectOk: true }); await requestJson(`/api/stations/${encodeURIComponent(code)}`, { method: 'DELETE' }, { expectOk: true });
await fetchData();
}); });
}; };
@@ -595,7 +691,6 @@ createApp({
}; };
const addStationToLine = async () => { const addStationToLine = async () => {
if (!selectedLine.value) return;
const { code, name, en_name } = newStation; const { code, name, en_name } = newStation;
if (!code || !name) return alert('请填写编号和名称'); if (!code || !name) return alert('请填写编号和名称');
@@ -610,12 +705,18 @@ createApp({
}, { expectOk: true }); }, { expectOk: true });
} }
if (isStationInLine(code)) throw new Error('该站点已在此线路中'); 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 });
}
const newStations = [...(selectedLine.value.stations || [])];
newStations.push(code);
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
Object.assign(newStation, { code: '', name: '', en_name: '' }); 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.en_name = s.en_name || s.enName || '';
stationForm.transfer_enabled = !!s.transfer_enabled; stationForm.transfer_enabled = !!s.transfer_enabled;
stationForm.transfer_to = Array.isArray(s.transfer_to) ? [...s.transfer_to] : []; 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; showStationModal.value = true;
}; };
@@ -676,8 +781,36 @@ createApp({
transfer_to: stationForm.transfer_enabled ? (Array.isArray(stationForm.transfer_to) ? stationForm.transfer_to : []) : [] transfer_to: stationForm.transfer_enabled ? (Array.isArray(stationForm.transfer_to) ? stationForm.transfer_to : []) : []
}; };
await runMutation(async () => { await runMutation(async () => {
// 1. 保存站点本身信息
await requestJson(`/api/stations/${encodeURIComponent(oldCode)}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true }); 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; showStationModal.value = false;
await fetchData();
}); });
}; };
@@ -890,41 +1023,46 @@ createApp({
watch(currentView, () => { sidebarOpen.value = false; }); watch(currentView, () => { sidebarOpen.value = false; });
/* 失败 */
onMounted(() => { onMounted(() => {
fetchData(); fetchData();
loadFareMap(); loadFareMap();
window.addEventListener('mouseup', async () => { window.addEventListener('mousemove', globalMouseMoveHandler);
if (draggingStationIndex.value !== null) { window.addEventListener('mouseup', globalMouseUpHandler);
if (selectedLine.value) { window.addEventListener('touchmove', globalTouchMoveHandler, { passive: false });
try { window.addEventListener('touchend', globalTouchEndHandler);
await requestJson(`/api/lines/${encodeURIComponent(selectedLine.value.id)}`, { window.addEventListener('touchcancel', globalTouchEndHandler);
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;
}
});
}); });
/* 计算 */ /* 计算 */
const recentLogs = computed(() => logs.value); const recentLogs = computed(() => logs.value);
const orderList = computed(() => orders.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(() => { const ticketList = computed(() => {
if (!ticketSearch.value) return tickets.value.slice(0, 50); return [];
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);
}); });
const exportFareMap = () => { const exportFareMap = () => {
@@ -976,9 +1114,12 @@ createApp({
isStationInLine, addStationToLine, removeStationFromLine, isStationInLine, addStationToLine, removeStationFromLine,
handleStationClick, isStationSelected, handleStationClick, isStationSelected,
onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex, onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex,
onStationTouchStart,
visualLineContainer, isPanning, onPanStart, onPanMove, onPanEnd,
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal, showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal, showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,
stationSearch, stationList, getStationLines, editStationGlobal, deleteStationGlobal,
/* 订单 */ /* 订单 */
fetchOrders, deleteOrder, fetchOrders, deleteOrder,
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventUnified, formatLogType, formatTrainType, showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventUnified, formatLogType, formatTrainType,
+2 -6
View File
@@ -7,7 +7,7 @@
<title>票务查询|FSE铁路运输票务系统</title> <title>票务查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14" /> <link rel="stylesheet" href="/style.css?v=22" />
</head> </head>
<body class="public-search jr-public-page"> <body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
<span>返回首页</span> <span>返回首页</span>
</a> </a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div> </div>
</header> </header>
+2 -6
View File
@@ -7,7 +7,7 @@
<title>凭证详情|FSE铁路运输票务系统</title> <title>凭证详情|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14" /> <link rel="stylesheet" href="/style.css?v=22" />
</head> </head>
<body class="public-search jr-public-page"> <body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
<span>返回首页</span> <span>返回首页</span>
</a> </a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div> </div>
</header> </header>
<div class="jr-brandbar"> <div class="jr-brandbar">