Compare commits

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

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

3. 静态资源版本号升级: style.css v16, ticket-route.js v5
2026-08-07 23:01:33 +08:00
Henry_Du b7cdcc570a feat(卡片): 新增卡片发行警告提示功能
将版本号更新至v1.5.29。新增cardIssueWarning状态变量以管理卡片发行过程中的警告信息,在重置票务和卡片流程时同步重置该状态。在卡片发行的各逻辑分支中设置对应警告文案,优化完成界面的展示逻辑以显示警告信息并调整提示语,同时在卡片发行快照中新增对应存储字段。
2026-07-31 13:29:23 +08:00
Henry_Du a59e9e60a4 feat(gate): 优化显示屏显示逻辑并更新版本
为 install_ticketmachine.lua 添加标准文件头注释
升级 gate.lua 版本至 v1.5.28
新增 splitDisplayLines 函数用于拆分多行显示文本
重构 draw 与 readyLine2 函数以支持多行状态文本的居中渲染
新增 displayStationCode 函数优化车站代码格式,移除 ST 前缀与多余空格
2026-07-31 12:43:58 +08:00
Henry_Du d685dee2a4 refactor(peripheral): 移除tm_gpu外设支持并清理冗余代码
删除gate.lua与ticketmachine.lua中所有与tm_gpu相关的代码逻辑,移除冗余的table.pack兼容函数与事件重写代码,简化外设发现与终端处理流程,同步更新两个文件的版本号。
2026-07-31 11:22:21 +08:00
Henry_Du 9c7e99633b fix(peripheral): 完善tm_gpu外设检测逻辑并升级版本号
新增scanForTmGpu函数用于遍历所有外设查找tm_gpu设备,在原有按类型匹配的查找失败时启用该后备逻辑,同时更新两个脚本文件的版本号。
2026-07-31 11:18:38 +08:00
Henry_Du 879445b9f6 feat(ticketmachine): 新增自定义TM GPU外设支持并更新版本至v1.5.26
- 新增table.pack兼容函数适配旧版Lua环境
- 实现TM GPU设备检测、终端仿真与坐标颜色转换逻辑
- 添加事件兼容层转换自定义触摸事件为标准事件并修正坐标
- 更新外设发现逻辑优先使用TM GPU终端
- 将版本从v1.5.25升级至v1.5.26
2026-07-31 11:15:31 +08:00
Henry_Du 200dbda9bd feat(display): 新增tm_gpu外设显示支持并升级版本至v1.5.28
实现tm_gpu外设的终端包装与适配逻辑,将其纳入显示外设选择流程。
2026-07-31 11:10:39 +08:00
Henry_Du 7e72bbeae5 feat: 新增错误问题追踪与管理功能
- 升级程序版本至 v1.5.25
- 新增对比度文本颜色计算与垂直滚动条绘制工具函数,优化选择界面滚动显示
- 网页端新增问题记录管理页面,配套完整的表格与详情展示样式
- 实现issue记录标准化处理、实时事件推送接收与按时间倒序排序展示
- 重构原有选择界面的滚动逻辑,替换为通用滚动条绘制函数
2026-07-29 12:29:54 +08:00
Henry_Du 0e8f86b478 1 2026-07-29 11:48:08 +08:00
Henry_Du 4db60ddc9e feat(ui): 新增购票兑换菜单,优化输入交互与界面布局
新增new_menu中间页面,拆分首页NEW按钮为购票和兑换码两个入口
抽离通用输入渲染函数,重构线上券码和IC卡兑换页面的代码逻辑
优化输入交互,按键、退格、清空时实时更新输入显示与提示
完善键盘快捷键支持,调整部分按钮布局与显示文本
统一音频播放逻辑,仅购票时播放欢迎音效
2026-07-29 11:34:47 +08:00
Henry_Du c840e2abfe feat: 新增客户端问题主动上报与控制台问题汇总功能
1. 客户端 (ticketmachine.lua): 修改 showAlert 支持详细错误并主动上报,使用 pcall 包装主循环捕获 Fatal Error 并上报。
2. 服务端 (data.js, api.js): 增加 issues 数据表,提供 /report_issue 和 /issues 接口。
3. 控制台 (index.html, index.js): 添加“问题汇总”菜单及界面状态管理。
2026-07-24 22:37:26 +08:00
Henry_Du be0077743c fix: 修复售票机付款成功后出票失败 2026-07-24 21:35:30 +08:00
Henry_Du 4ddc4cb4ad style: 稳定订票页线路按钮布局与尺寸 2026-07-19 03:41:50 +08:00
Henry_Du 04d0badce8 1 2026-07-19 00:38:18 +08:00
Henry_Du 847957be61 feat: 支持编辑线路图署名并固定 CC BY 4.0 2026-07-19 00:16:20 +08:00
Henry_Du 6195ebb6be chore: 为线路图展示补充 CC-BY 4.0 署名 2026-07-18 23:51:32 +08:00
Henry_Du ec31c12130 feat: 改造线路图上传后台并接入购票页总览图 2026-07-17 03:17:03 +08:00
Henry_Du 289ed65e79 style: 优化移动端线路按钮与区间卡片布局 2026-07-17 02:54:35 +08:00
Henry_Du 95bf4d3d8e style: 同步购票页移动端直角与线路列表布局 2026-07-17 01:19:35 +08:00
Henry_Du c6ab010974 style: 调整购票页外框直角样式并上移线路列表 2026-07-17 01:16:24 +08:00
Henry_Du 234e22468a style: 合并断点线路展示并改为直角线段 2026-07-17 01:13:10 +08:00
Henry_Du 77b487c326 feat(gate): support passing stationId to markEntered API 2026-07-17 00:44:37 +08:00
Henry_Du 1f5bc1d40d fix(gate): support java peripheral markEntered and bypass missing updateICCard 2026-07-17 00:33:35 +08:00
Henry_Du 2096dd8d89 fix(gate,installer): 修复进站刷卡字段兼容并清理安装器头注释 2026-07-17 00:19:35 +08:00
Henry_Du 5e8d4f7bbe fix: 统一不同线路的地图容器大小,无论站点多少,容器最小宽度都保持一致 2026-07-16 22:49:13 +08:00
Henry_Du 9d145c3d16 style: 优化 TVM 购票地图,缩小元素尺寸,增加切换动画,适配移动端 2026-07-16 22:16:10 +08:00
Henry_Du cfaa622dcd feat: 重构选择购票区间地图为 TVM 瀑布流样式 2026-07-16 06:05:33 +08:00
Henry_Du 7812d47f0c refactor & feat & fix: 重写SVG生成器,优化前端线路绘制与修复样式
重写服务器端SVG生成逻辑,将原铁路线路图生成功能替换为站点间票价矩阵生成。
优化前端换乘路径绘制:新增线路偏移与弯道处理,避免多条线路路径重叠。
修复站点标记的外层圆圈填充颜色,使其正确使用对应线路的配色。
2026-07-16 03:49:45 +08:00
Henry_Du d32d72a3da feat(web): 优化区间选择界面线路图布局
1. 实现正交布线与圆角拐角连接,减少斜线交叉

2. 动态调整线路间距与节点位置,提高整体紧凑性

3. 优化线路名称与换乘站标签的避让逻辑,避免遮挡

4. 删除不再使用的全局预览页(map-preview.html)
2026-07-16 00:26:18 +08:00
Henry_Du 86f3d478d6 feat(ticket-order): 重写线路地图可视化功能,替换原有线路UI
替换原有的标签式线路站点UI,新增加载渲染官方线路SVG地图、线路分段展示、换乘站标记、站点中英文标注、路线规划高亮预览等功能,优化响应式布局与交互逻辑。
2026-07-15 20:41:11 +08:00
Henry_Du 4ea58772d3 feat(route-map): 新增线路预览页面并优化八向布线布局
- 新增全网线路图预览前端页面 `/web/map-preview.html`
- 调整布局物理参数与迭代次数优化排布效果
- 新增正交八向布线强制对齐逻辑
- 实现节点网格对齐与优化线路路径转角处理
- 调整锚点固定力度提升布局稳定性
2026-07-15 02:54:11 +08:00
Henry_Du 620289e836 更新线路图绘制 2026-07-15 00:50:55 +08:00
Henry_Du e6eae25cf1 refactor(gate): streamline gate installers and support multi-station codes via splitCsv 2026-07-14 20:34:58 +08:00
Henry_Du 5bd2e5b882 feat(ticket-order): 优化单线线路展示与同线区间高亮 2026-07-14 05:47:47 +08:00
Henry_Du fefd919031 chore(ticket-order): 提升 ticket-order.js 版本号以破除浏览器缓存 2026-07-14 04:43:07 +08:00
Henry_Du 3311ee70ce feat(ticket-order): 添加单线地铁图选中高亮金线及箭头动画,取消鼠标悬浮动画 2026-07-14 01:43:24 +08:00
Henry_Du 63d710114d feat(ticket-order): 将 TVM UI 的网格站点改为与后台一致的单线地铁图样式 2026-07-14 01:17:49 +08:00
Henry_Du a79142660f fix(ticket-order): 修复无法正常切换路线的问题,优化移动端及主题适配 2026-07-14 00:50:19 +08:00
Henry_Du 139a65f24c refactor(ticket-order): 清理废弃的SVG渲染代码并优化TVM界面高亮反馈 2026-07-13 23:47:57 +08:00
Henry_Du ae11f9e34b feat(web): 新增后台线路拓扑图布局编辑器,允许手动拖拽调整所有站点坐标 2026-07-13 21:07:24 +08:00
Henry_Du 6a80db6d1d feat(web): 回退到以发散方式显示各条水平线路 2026-07-13 20:51:42 +08:00
Henry_Du ccb7ad5721 feat(web): 重构线路图渲染引擎,引入网格化对齐与拓扑深度推导算法,完美还原真实地铁图的整齐排版 2026-07-12 12:17:56 +08:00
Henry_Du 20cfdc5174 style(web): 调整线路名防遮挡偏移、缩小线路间距,并根据线路颜色动态匹配文字深浅色 2026-07-12 10:28:15 +08:00
Henry_Du e1637da9c6 style(web): 优化车站文本颜色层次并增加白色描边以解决与线条重叠的问题 2026-07-12 10:19:31 +08:00
Henry_Du 46f8a17157 feat(web): 优化车站名与线路名显示,增加车站编号与英文名称 2026-07-12 10:04:40 +08:00
Henry_Du 423a5972fc refactor(web): 优化线路图走线为45度和直角转折,并在起终点显示线路名 2026-07-12 09:50:36 +08:00
Henry_Du 9ef5d2099e refactor(web): 将订票线路图改为90度规整走线并保留圆角 2026-07-12 09:02:12 +08:00
Henry_Du a651fac095 refactor(web): 将换乘站合并为单点并重绘订票线路图 2026-07-12 08:35:07 +08:00
Henry_Du 83c7d25cbe refactor(web): 优化订票线路图布局与换乘站视觉表现 2026-07-11 23:37:35 +08:00
Henry_Du dc835f498d fix(web): 修复重构导致 allStations 变量未定义引发的渲染崩溃问题 2026-07-11 23:20:35 +08:00
Henry_Du d6f37d1415 fix(web): 重构线路图渲染算法,实现平行水平走线与胶囊式垂直换乘 2026-07-11 23:15:36 +08:00
Henry_Du 4052cd2f58 fix(web): 修复断点站点的X轴坐标偏移问题,并优化换乘站的视觉连接 2026-07-11 22:42:46 +08:00
Henry_Du db24b4da18 chore(assets): 移除静态SVG地图文件 2026-07-11 22:23:23 +08:00
Henry_Du 1ce7fd630b feat(web): 撤销静态SVG图片渲染,回退到自动线条生成模式,并保留断点支持 2026-07-11 22:23:12 +08:00
Henry_Du 0c1341e9c7 chore(assets): 添加Assets/manifest.json配置文件 2026-07-11 21:54:02 +08:00
Henry_Du 116b2af4ef feat(admin): 优化换乘设置与坐标绑定逻辑,自动识别同名换乘站 2026-07-11 21:46:12 +08:00
Henry_Du aede84f164 feat(web): 订票地图升级为静态SVG并叠加动态坐标交互;完善断点支持 2026-07-11 21:15:48 +08:00
Henry_Du 690173c522 3 2026-07-11 16:52:02 +08:00
Henry_Du 3040b294ef 2 2026-07-11 16:48:02 +08:00
Henry_Du 1265deca0f 1 2026-07-11 16:41:18 +08:00
Henry_Du ca9805f13a fix(gate): use unique cache busting file names again to break any potential loop 2026-07-11 16:36:50 +08:00
Henry_Du ef6af4bc62 fix(gate): fix attempt to call a nil value by moving checkAndApplyUpdate to the bottom 2026-07-11 16:32:48 +08:00
Henry_Du a228c24b33 fix(gate): use parallel background sync task for updates exactly like ticketmachine 2026-07-11 16:26:09 +08:00
Henry_Du c5cd4b6a95 fix(gate): correct update endpoint URL to use /api/check-update 2026-07-11 16:16:30 +08:00
Henry_Du 1d9f08d5c6 chore(backend,frontend): 移除设备位置管理全链路功能与相关代码
- 删除后端所有机器位置相关的API接口、缓存逻辑与后台同步任务
- 移除前端的机器位置导航入口、地图页面与管理代码
- 将原机器地图页面重构为车票查询页面
- 调整Lua程序的默认版本号
- 删除无用的.gitignore文件
- 优化前端页面的代码格式与导航结构
2026-07-11 16:05:20 +08:00
Henry_Du 4fe3a24eaf fix(ota): make update scripts self-updating and add cache-busting to installers 2026-07-11 16:00:37 +08:00
Henry_Du de187db646 fix: 即使无法获取GPS信号也上报机器在线状态,并升级版本至v1.5.18 2026-07-11 15:58:50 +08:00
Henry_Du f0b62eca49 版本号更新2 2026-07-11 15:49:53 +08:00
Henry_Du 37675c07a2 版本号更新 2026-07-11 15:49:38 +08:00
Henry_Du 84dbdf58c4 feat: 增加机器位置手动编辑功能及公开查看界面 2026-07-11 15:43:17 +08:00
Henry_Du becd8f0e20 chore: 清理无用的Assets/manifest.json并更新gitignore规则
删除空的Assets/manifest.json文件,更新.gitignore以忽略整个.trae目录
2026-07-11 15:30:00 +08:00
Henry_Du cbdb0d3ed3 chore: 添加 .gitignore 文件以排除本地依赖和日志文件 2026-07-11 15:03:57 +08:00
Henry_Du efc061e471 feat: 新增设备位置追踪与网页管理页面
- 更新服务端默认Lua版本与客户端程序版本至 v1.5.16
- 新增后端API接口用于提交和查询设备位置数据
- 为售票机和检票机添加后台GPS位置同步任务,定期上报设备坐标
- 服务端新增设备位置缓存,并自动清理超过5分钟未更新的过期数据
- 新增网页端设备位置地图页面,支持实时展示、点击查看详情与每分钟自动刷新
2026-07-11 14:58:30 +08:00
Henry_Du 2eddb8b84c style: update OTA version indicators to lime/yellow/red and output errors to CC terminal 2026-07-11 13:03:44 +08:00
Henry_Du d5b7a51dae 测试2 2026-07-11 12:31:36 +08:00
Henry_Du 79ae234232 feat: decouple OTA updates into dedicated fast endpoints to avoid db timeouts 2026-07-11 12:22:48 +08:00
Henry_Du bd6ef05722 fix: call checkAndApplyUpdate after config refresh to ensure fresh data 2026-07-11 12:13:14 +08:00
Henry_Du c9845ff15a test 2026-07-11 12:07:21 +08:00
Henry_Du 1f60c74ec2 feat: use simpler update_counter instead of timestamp for OTA updates 2026-07-11 12:00:20 +08:00
Henry_Du 61e98cd6d5 fix: add update_timestamp to /api/config response 2026-07-11 11:45:11 +08:00
Henry_Du de29ecac84 revert: remove _update_ts injection as it breaks ticket scanning 2026-07-11 11:29:26 +08:00
Henry_Du 74b9754b7d feat: inject _update_ts into all JSON responses for global OTA updates 2026-07-11 11:17:07 +08:00
Henry_Du 6d0596f614 测试更新 2026-07-11 11:07:34 +08:00
Henry_Du b528a3ae56 refactor: remove unused update functions and variables 2026-07-11 11:02:08 +08:00
Henry_Du 0186e14eb2 fix: add sleep to show update errors before reboot 2026-07-11 10:53:03 +08:00
Henry_Du b0361fc366 feat: simplify OTA updates with timestamp 2026-07-11 10:50:00 +08:00
Henry_Du bc5fe471ab Fix ticket machine update logic 2026-07-11 08:52:21 +08:00
Henry_Du 1049dfd79d 放弃在后台任务中去读取 state.page 2026-07-11 08:30:47 +08:00
Henry_Du 0d11292273 fix(ticketmachine): 修复更新触发条件并重构代码
移除未使用的 expectedMachineVersion 变量
调整后台同步任务的更新触发逻辑,仅在主页且状态有效时执行机器更新
移动版本指示器绘制函数到更早作用域,避免重复声明
2026-07-11 08:26:58 +08:00
Henry_Du 3781f63708 测试更新 2026-07-11 08:06:54 +08:00
Henry_Du 6c41c4c53d fix(backgroundSyncTask): 移除机器更新的首页触发限制
移除原有的state和页面home检查,仅保留pendingMachineUpdate判断,允许在任意页面触发版本指示器更新。
2026-07-11 01:07:19 +08:00
Henry_Du 2ffb449502 fix(update): 优化更新逻辑,添加错误提示并清理冗余代码
移除所有脚本文件的头部注释模板
删除installer.lua中未使用的splitCsv函数
重构静默更新逻辑,改用loadfile替代os.run以正确捕获执行错误
添加更新错误追踪机制,在界面版本指示器处显示失败提示
修复更新成功的判断条件
2026-07-11 00:20:11 +08:00
Henry_Du 331b1700b4 docs(update_gate.lua): 为该文件添加标准文件头注释 2026-07-11 00:12:24 +08:00
Henry_Du 446a8b0d09 feat(自动更新): 添加持久化版本追踪避免重复更新
新增读写本地版本记录文件的工具函数,调整自动更新触发逻辑仅在版本不一致时触发,更新成功后写入版本号;修复更新失败版本号的赋值问题,同时为安装脚本添加标准文件头注释
2026-07-11 00:09:43 +08:00
Henry_Du d848f1c438 gengx 2026-07-10 23:58:52 +08:00
Henry_Du f0b8db1662 fix: 修复更新逻辑,调整版本显示并同步版本号
- 新增失败更新版本跟踪变量,避免重复触发同一失败版本的更新
- 调整版本指示器的绘制逻辑,优化标记文本的显示效果
- 修复强制更新的触发校验条件,完善整体更新流程
- 初始化缺失的state变量并同步两个文件的程序版本号
2026-07-10 23:55:47 +08:00
Henry_Du 565f0008dd 测试更新2 2026-07-10 23:44:09 +08:00
Henry_Du 701cceee02 测试更新 2026-07-10 23:42:11 +08:00
Henry_Du 8a78034ad3 feat: 添加强制更新Lua脚本的配置并优化前端更新按钮
新增配置API的force_update字段返回与保存支持,更新终端Lua脚本逻辑以支持强制更新标记,同时简化前端更新按钮,移除模拟进度条并动态切换按钮状态与显示文本。
2026-07-10 23:38:03 +08:00
Henry_Du 78f302381a 版本号测试 2026-07-10 23:28:16 +08:00
Henry_Du fddffee348 fix(config): 增加cfg的类型检查避免运行时报错
修改gate.lua和ticketmachine.lua中的版本更新判断逻辑,新增对cfg变量的类型检查,避免当cfg为nil或非table类型时触发访问异常,提升代码健壮性。
2026-07-10 23:17:34 +08:00
Henry_Du 908fdc2af0 feat(web): 添加全局Lua脚本批量推送更新
替换原有的单个设备版本升级功能,添加推送前的确认弹窗,自动提升售票机和检票机的补丁版本并开启强制更新,添加推送进度展示,更新前端UI与说明文案,新增推送结果提示与异常处理
2026-07-10 23:08:32 +08:00
Henry_Du 0ef09239fd refactor(gate.lua): 重构票务设备状态更新逻辑,提取公共工具函数
将原handleScan中重复的票务设备状态更新代码封装为统一的处理函数,替换原有冗余的遍历更新逻辑,提升代码可读性与可维护性。
2026-07-10 21:39:01 +08:00
Henry_Du 33155ef2ed feat(更新逻辑): 调整更新触发逻辑,新增force_update配置控制
同步修改ticketmachine.lua和gate.lua,新增配置项判断逻辑替代原有的自动更新标记代码
2026-07-10 21:35:18 +08:00
Henry_Du f1cdfe3a84 fix: 修复票据详情设备ID缺失并优化自动更新逻辑
- 为票据详情接口添加设备字段兼容映射,支持从e.device/e.device_id/e.detail?.device获取值
- 更新设备ID缺失问题的调试文档,标记已验证的假设并补充最终结论
- 优化售票机和闸机的自动更新逻辑:缩短版本检查间隔至5秒,添加更新中状态标识,改为事件触发更新
2026-07-10 21:32:17 +08:00
Henry_Du 5f184bf184 功能(设备ID调试): 添加调试埋点与文档以定位设备ID缺失问题
创建调试环境配置文件与调试会话记录文档,在前端页面、票据面板以及后端公共接口的设备ID解析各阶段添加调试埋点,收集原始设备数据与处理后的结果,用于验证相关假设以定位设备ID不显示的根因。
2026-07-10 21:07:37 +08:00
Henry_Du 4daa0b6185 feat: 优化自动更新逻辑,移除单程票购票并升级版本至1.0.18
升级package.json版本至1.0.18;重构update_gate.lua和update_machine.lua,支持--silent静默运行参数,统一日志输出逻辑;优化ticketmachine.lua和gate.lua的自动更新触发逻辑,新增设备空闲检测,避免打断用户操作,仅在空闲时执行静默更新后重启设备;修复ticketmachine.lua中state变量重复声明的问题;移除Web端ticket-order相关的单程票购票UI与业务逻辑,简化购票流程;将gate.lua中的版本检查间隔从5秒调整为60秒,优化设备ID显示格式。
2026-07-10 20:51:34 +08:00
Henry_Du 1bdf0ca5f2 feat(购票页): 添加动态路线箭头并优化代码格式
新增沿路线循环移动的动态指示箭头,自动适配路线总长度计算动画参数;修复文件开头多余的零宽空格,统一renderRouteOverlay函数内的代码缩进。
2026-07-10 19:55:05 +08:00
Henry_Du 967c6740a3 style(web): 清理web目录下html文件开头的多余不可见字符 2026-07-10 19:06:42 +08:00
Henry_Du 230418079c 3 2026-07-10 14:55:42 +08:00
Henry_Du f65e043130 fix(web): 修复设备ID获取不全与空值处理不完善的问题
补充从ev.detail中提取设备ID的逻辑,同时新增对空字符串和'---'格式设备ID的统一处理
2026-07-10 13:37:56 +08:00
Henry_Du 8435717a58 fix(web, ticketmachine): 修复web设备ID匹配,清理无用代码并优化界面显示
调整web端设备ID匹配正则,支持带#前缀的数字ID
移除ticketmachine.lua中未使用的变量和工具函数
替换界面成本显示文本,将$改为cogs:
2026-07-10 13:25:54 +08:00
Henry_Du 2e113232d9 修复(API接口): 优化线路名称匹配逻辑并升级版本至1.0.17
移除线路名称中的所有多余空格,增强匹配鲁棒性,修复带有额外空格的线路无法正确匹配的问题
2026-07-10 13:15:45 +08:00
Henry_Du 8b10117487 feat: 优化设备标识识别与单程票检票逻辑
- 升级项目版本至v1.0.16
- 重构多文件的设备ID标准化逻辑,新增空格修剪、大小写不敏感处理,支持更多设备类型
- 基于事件类型智能区分闸机、充值机与售票机
- 新增站点线路校验工具,修复单程票检票的站点合法性校验
- 修正单程票票价计算使用实际进站站点而非初始起点
- 添加事件元数据格式化的空值安全检查
2026-07-10 12:02:50 +08:00
Henry_Du 29d66c43b0 update 2026-07-10 11:34:08 +08:00
Henry_Du d2809c4d6d 修改了一些东西 2026-07-10 11:33:37 +08:00
51 changed files with 5447 additions and 2311 deletions
+2
View File
@@ -0,0 +1,2 @@
DEBUG_SERVER_URL=http://127.0.0.1:7777/event
DEBUG_SESSION_ID=device-id-missing
BIN
View File
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
1. 完成功能制作后自行编辑git commit并推送
2. 提交时请添加功能描述
+2
View File
@@ -0,0 +1,2 @@
1. 服务器地址为192.140.163.241,服务端运行的端口为22222
2. 本地是开发,不是服务器!
+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 返回中提供)
-2
View File
@@ -1,2 +0,0 @@
{}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 473 KiB

-1
View File
@@ -1,4 +1,3 @@
# FTC 票务系统
[![支持 PR](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://makeapullrequest.com) [![使用 Lua](https://img.shields.io/badge/Made%20with-Lua-blue)]() [![CC:Tweaked](https://img.shields.io/badge/CC%3ATweaked-ComputerCraft-blue)](https://tweaked.cc/) [![Node.js 16+](https://img.shields.io/badge/Node.js-16%2B-green)](https://nodejs.org/) [![Open Source](https://img.shields.io/badge/Open%20Source-%E2%9D%A4-red)]() [![注释:英文](https://img.shields.io/badge/%E6%B3%A8%E9%87%8A-%E8%8B%B1%E6%96%87-blue)]()
+29
View File
@@ -0,0 +1,29 @@
# Debug Session: device-id-missing
- **Status**: [OPEN]
- **Issue**: 数据记录中的设备 ID 当前全部未显示,界面只看到“机器ID:”后没有具体值。
- **Debug Server**: http://127.0.0.1:7777/event
- **Log File**: .dbg/trae-debug-log-device-id-missing.ndjson
## Reproduction Steps
1. 使用售票机完成一次售票。
2. 使用闸机完成一次进站或出站。
3. 打开后台或票据时间线页面查看“机器ID”字段。
4. 观察“机器ID:”后方是否为空。
## Hypotheses & Verification
| ID | Hypothesis | Likelihood | Effort | Evidence |
|----|------------|------------|--------|----------|
| A | 后端接口返回的事件对象里根本没有 `device` / `detail.device` 字段 | High | Low | Rejected |
| B | 前端格式化函数读到了值,但后续字符串拼接或编码问题把值丢掉了 | High | Low | Rejected |
| C | 售票与检票两类事件的数据结构不同,当前前端只兼容了其中一类 | Med | Low | Confirmed |
| D | 设备端 Lua 确实上报了 ID,但服务端在写库或对外返回时被覆盖成空串 | Med | Med | Rejected |
| E | 页面实际运行的不是当前工作区这份前端代码,导致本地修改未生效 | Med | Med | Pending |
## Log Evidence
- 2026-07-10: 已启动 Debug Server `http://127.0.0.1:7777/event`
- 2026-07-10: 已在 `[public.js]` 的票据时间线组装处添加 A/C 埋点
- 2026-07-10: 已在 `[index.js]` 与 `[ticket-board.html]` 的设备 ID 格式化处添加 B 埋点
- 2026-07-10: 本地运行时复现结果:`saleRawDevice="#0"` 但 `saleMappedDevice=null`;同时 `checkRawDevice="#1"` 且 `checkMappedDevice="#1"`,证明 `sale` 映射阶段丢失了 `device`
## Verification Conclusion
- 已实施最小修复:在公开票据详情的 `sale` 事件映射中保留 `device`
+621 -139
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -1,4 +1,5 @@
local URL_MACHINE_HTTP = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/ticketmachine.lua"
local URL_UPDATE_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_machine.lua"
@@ -55,20 +56,25 @@ local function httpGet(url)
end
end
local function getCacheBustedUrl(url)
local sep = url:find("?") and "&" or "?"
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
end
term.clear()
term.setCursorPos(1, 1)
print("Ticket Machine Installer")
print("")
print("Downloading ticket machine program...")
local ok, code = httpGet(URL_MACHINE_HTTP)
local ok, code = httpGet(getCacheBustedUrl(URL_MACHINE_HTTP))
if not ok or type(code) ~= "string" or #code == 0 then
print("Download failed: " .. tostring(code or ""))
return
end
local okUpdate, updateCode = httpGet(URL_UPDATE_MACHINE)
local okUpdate, updateCode = httpGet(getCacheBustedUrl(URL_UPDATE_MACHINE))
if not okUpdate or type(updateCode) ~= "string" or #updateCode == 0 then
print("Download failed: " .. tostring(updateCode or ""))
return
+14 -5
View File
@@ -1,3 +1,4 @@
local URL_ERROR = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/error.dfpwm"
local URL_PASS = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/pass.dfpwm"
local URL_GATE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/gate.lua"
@@ -9,6 +10,9 @@ local function trim(s)
return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end
local function splitCsv(s)
local out = {}
s = trim(s)
@@ -20,7 +24,6 @@ local function splitCsv(s)
return out
end
local function writeFile(path, content, binary)
local mode = binary and "wb" or "w"
local f = fs.open(path, mode)
@@ -70,9 +73,14 @@ local function httpGet(url)
end
end
local function getCacheBustedUrl(url)
local sep = url:find("?") and "&" or "?"
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
end
local function download(url, path, binary)
print("Downloading: " .. path)
local ok, data = httpGet(url)
local ok, data = httpGet(getCacheBustedUrl(url))
if not ok then
print("Download failed: " .. tostring(data or ""))
return false
@@ -89,7 +97,7 @@ term.setCursorPos(1, 1)
print("Ticket Gate Installer")
print("")
local stationCode = trim(prompt("Station code: "))
local stationCode = trim(prompt("Station code(s) [comma or / separated]: "))
if #stationCode == 0 then
print("No station code provided.")
return
@@ -97,7 +105,8 @@ end
local modeRaw = prompt("Gate mode (entry/exit): ")
local mode = (trim(modeRaw):lower() == "exit") and "exit" or "entry"
local cfg = { mode = mode, station_codes = { stationCode }, station_code = stationCode }
local parsedCodes = splitCsv(stationCode)
local cfg = { mode = mode, station_codes = parsedCodes, station_code = parsedCodes[1] or stationCode }
local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg)
if not okCfg then
print("Config serialize failed.")
@@ -111,7 +120,7 @@ end
if not download(URL_PASS, "pass.dfpwm", true) then return end
if not download(URL_ERROR, "error.dfpwm", true) then return end
local okGate, gateCode = httpGet(URL_GATE)
local okGate, gateCode = httpGet(getCacheBustedUrl(URL_GATE))
if not okGate then
print("Download failed: startup")
return
+13 -7
View File
@@ -69,9 +69,14 @@ local function httpGet(url)
end
end
local function getCacheBustedUrl(url)
local sep = url:find("?") and "&" or "?"
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
end
local function download(url, path, binary)
print("Downloading: " .. path)
local ok, data = httpGet(url)
local ok, data = httpGet(getCacheBustedUrl(url))
if not ok then
print("Download failed: " .. tostring(data or ""))
return false
@@ -92,7 +97,7 @@ term.setCursorPos(1, 1)
print("Bidirectional Ticket Gate Installer")
print("")
local stationCode = trim(prompt("Station code: "))
local stationCode = trim(prompt("Station code(s) [comma or / separated]: "))
if #stationCode == 0 then
print("No station code provided.")
return
@@ -105,16 +110,17 @@ local backRaw = prompt("Back mode (entry/exit): ")
local frontMode = normalizeMode(frontRaw)
local backMode = normalizeMode(backRaw)
local parsedCodes = splitCsv(stationCode)
local cfg = {
station_codes = { stationCode },
station_code = stationCode,
station_codes = parsedCodes,
station_code = parsedCodes[1] or stationCode,
side_modes = {
front = frontMode,
back = backMode,
},
side_station_codes = {
front = stationCode,
back = stationCode,
front = parsedCodes[1] or stationCode,
back = parsedCodes[1] or stationCode,
}
}
local okCfg, cfgJson = pcall(textutils.serializeJSON, cfg)
@@ -130,7 +136,7 @@ end
if not download(URL_PASS, "pass.dfpwm", true) then return end
if not download(URL_ERROR, "error.dfpwm", true) then return end
local okGate, gateCode = httpGet(URL_GATE)
local okGate, gateCode = httpGet(getCacheBustedUrl(URL_GATE))
if not okGate then
print("Download failed: startup")
return
+7 -2
View File
@@ -54,19 +54,24 @@ local function httpGet(url)
end
end
local function getCacheBustedUrl(url)
local sep = url:find("?") and "&" or "?"
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
end
term.clear()
term.setCursorPos(1, 1)
print("Refill Machine Installer")
print("")
print("Downloading refill machine program...")
local ok, code = httpGet(URL_REFILL_MACHINE)
local ok, code = httpGet(getCacheBustedUrl(URL_REFILL_MACHINE))
if not ok or type(code) ~= "string" or #code == 0 then
print("Download failed: " .. tostring(code or ""))
return
end
local okUpdate, updateCode = httpGet(URL_UPDATE_REFILL)
local okUpdate, updateCode = httpGet(getCacheBustedUrl(URL_UPDATE_REFILL))
if not okUpdate or type(updateCode) ~= "string" or #updateCode == 0 then
print("Download failed: " .. tostring(updateCode or ""))
return
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ticketmachine",
"version": "1.0.14",
"version": "1.0.18",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
+10 -2
View File
@@ -1,4 +1,4 @@
local VERSION = "v1.5.7"
local VERSION = "v1.5.22"
local MACHINE_SIDE = "bottom"
local PAYMENT_SIDE = "right"
local PRESET_AMOUNTS = { 5, 10, 15, 20 }
@@ -115,13 +115,21 @@ local function postJSON(url, payload)
end
end
local function currentDeviceId()
local label = os.getComputerLabel()
if label and label ~= "" then
return label
end
return "充值机#" .. tostring(os.getComputerID())
end
local function syncRefillResult(cardInfo, amount, balance)
local cardId = trim(cardInfo and cardInfo.cardId or "")
if #cardId == 0 then return false, "missing_card_id" end
local url = API_BASE .. "/ic-cards/" .. urlEncodeComponent(cardId) .. "/sync"
return postJSON(url, {
type = "topup",
device = "refill_machine",
device = currentDeviceId(),
amount = tonumber(amount) or 0,
balance = tonumber(balance) or 0,
result = "pass",
+2 -1
View File
@@ -6,7 +6,8 @@ module.exports = {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
},
transports: ['websocket']
});
io.engine.on('connection_error', (err) => {
+77 -4
View File
@@ -151,6 +151,18 @@ const resolveCurrentStationCode = (body, resolveStation) => {
return codes[0] || direct || fallback || '';
};
// Dedicated lightweight OTA update endpoints
let globalUpdateCounter = Date.now();
router.post('/trigger-update', (req, res) => {
globalUpdateCounter++;
res.json({ ok: true, update_counter: globalUpdateCounter });
});
router.get('/check-update', (req, res) => {
res.json({ update_counter: globalUpdateCounter });
});
// Config
router.get('/config', (req, res) => {
const cfg = DataService.getConfig();
@@ -162,7 +174,9 @@ router.get('/config', (req, res) => {
fares: DataService.getFares(),
transfers: cfg.transfers || [],
promotion: cfg.promotion || { name: '', discount: 1 },
lua_versions: cfg.lua_versions || {}
lua_versions: cfg.lua_versions || {},
force_update: cfg.force_update === true,
update_counter: cfg.update_counter || 0
});
});
@@ -185,6 +199,10 @@ router.put('/config', async (req, res) => {
...(incoming.lua_versions || {})
};
}
if (typeof incoming.force_update === 'boolean') {
cfg.force_update = incoming.force_update;
}
// remove unused update_counter
await DataService.saveConfig(cfg);
appendReqLog(req, { category: 'admin', type: 'update_config_generic', detail: incoming });
io.emit('config:updated', cfg);
@@ -1173,6 +1191,29 @@ router.get('/export', (req, res) => {
res.json(DataService.buildExportPayload());
});
// Issues
router.post('/report_issue', async (req, res) => {
const r = req.body || {};
const item = {
ts: Number(r.ts || Date.now()),
device: String(r.device || 'unknown'),
machine_id: String(r.machine_id || ''),
station_code: String(r.station_code || ''),
error_msg: String(r.error_msg || ''),
detail: String(r.detail || ''),
type: 'issue'
};
await DataService.appendIssue(item);
io.emit('issue:new', item);
appendReqLog(req, { category: 'device', type: 'report_issue', detail: item });
res.json({ ok: true });
});
router.get('/issues', (req, res) => {
const issues = DataService.getIssues();
res.json({ ok: true, issues });
});
// Ticket Operations
router.post('/tickets/sale', async (req, res) => {
const { start, terminal, train_type, cost, station_code, device, trips_total, trips_remaining } = req.body || {};
@@ -1338,8 +1379,35 @@ router.post('/tickets/check', async (req, res) => {
const isSingleTicket = String(cur.train_type || '').toLowerCase() === 'single';
const checkStationOnLine = (lineName, codeRaw, codesSet, codeNorm) => {
if (!lineName) return false;
const target = String(lineName).trim().toLowerCase().replace(/\s+/g, '');
const lines = DataService.getLines() || [];
const line = lines.find(l => {
const ln = String(l.name || l['线路名称'] || '').trim().toLowerCase().replace(/\s+/g, '');
const le = String(l.en_name || l.en || l.enName || '').trim().toLowerCase().replace(/\s+/g, '');
return ln === target || le === target;
});
if (!line) return false; // Line not found
const stops = Array.isArray(line.stations) ? line.stations : (Array.isArray(line.stops) ? line.stops : (line['站点列表'] || line['站点'] || []));
const stopSet = new Set(stops.map(normStationKey).filter(Boolean));
if (codeNorm && stopSet.has(codeNorm)) return true;
if (codesSet.size > 0) {
for (const sc of codesSet) {
if (stopSet.has(sc)) return true;
}
}
return false;
};
if (action === 'entry') {
if (!isSingleTicket && !matchesStation(start)) return deny('wrong_station');
if (isSingleTicket) {
if (!checkStationOnLine(cur.start, station_code_raw, station_codes_norm, station_code_norm)) {
return deny('wrong_station');
}
} else {
if (!matchesStation(start)) return deny('wrong_station');
}
if (entered && !exited) return deny('already_entered');
const entryCode = isSingleTicket ? (station_code_raw || start) : start;
@@ -1352,7 +1420,8 @@ router.post('/tickets/check', async (req, res) => {
exited: false,
last_action: 'entry',
last_station_code: entryCode,
start: isSingleTicket ? entryCode : cur.start,
start: isSingleTicket ? cur.start : cur.start,
entered_station: entryCode,
last_result: 'pass',
last_reason: '',
last_event: 'check',
@@ -1367,7 +1436,11 @@ router.post('/tickets/check', async (req, res) => {
const exitCode = station_code_raw || terminal;
if (isSingleTicket) {
const fareInfo = LogicService.computeFareBoth(cur.start, exitCode);
if (!checkStationOnLine(cur.start, station_code_raw, station_codes_norm, station_code_norm)) {
return deny('wrong_station');
}
const entryCode = cur.entered_station || cur.last_station_code;
const fareInfo = LogicService.computeFareBoth(entryCode, exitCode);
if (!fareInfo || fareInfo.paid_segments !== 1) {
return deny('not_single_segment');
}
+22 -2
View File
@@ -8,6 +8,7 @@ const router = express.Router();
const ASSETS_DIR = path.join(__dirname, '../../Assets');
const MANIFEST_PATH = path.join(ASSETS_DIR, 'manifest.json');
const ROUTE_MAP_LICENSE = 'CC BY 4.0';
const getIp = (req) => (req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || req.connection?.remoteAddress || '';
@@ -48,6 +49,8 @@ const writeManifest = (m) => {
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(out, null, 2), 'utf8');
};
const normalizeCredit = (value) => String(value || '').trim().slice(0, 200);
const removeByPrefix = (prefix) => {
ensureDir(ASSETS_DIR);
const list = fs.readdirSync(ASSETS_DIR);
@@ -97,6 +100,8 @@ router.get('/manifest', (req, res) => {
res.json({
ok: true,
routeMap,
routeMapCredit: normalizeCredit(m.routeMapCredit || ''),
routeMapLicense: ROUTE_MAP_LICENSE,
fareTable,
updatedAt: m.updatedAt || null
});
@@ -106,10 +111,24 @@ router.post('/route-map', uploadRouteMap.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ ok: false, error: '缺少文件' });
const m = readManifest();
m.routeMap = path.basename(req.file.filename);
if (req.body && Object.prototype.hasOwnProperty.call(req.body, 'credit')) {
m.routeMapCredit = normalizeCredit(req.body.credit);
} else if (typeof m.routeMapCredit !== 'string') {
m.routeMapCredit = '';
}
m.updatedAt = Date.now();
writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap } });
res.json({ ok: true, routeMap: m.routeMap, updatedAt: m.updatedAt });
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap, credit: m.routeMapCredit || '' } });
res.json({ ok: true, routeMap: m.routeMap, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
});
router.post('/route-map/credit', (req, res) => {
const m = readManifest();
m.routeMapCredit = normalizeCredit(req.body?.credit);
m.updatedAt = Date.now();
writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_update_route_map_credit', detail: { credit: m.routeMapCredit || '' } });
res.json({ ok: true, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
});
router.post('/fare-table', uploadFareTable.single('file'), (req, res) => {
@@ -129,6 +148,7 @@ router.delete('/route-map', (req, res) => {
}
removeByPrefix('route-map');
m.routeMap = null;
m.routeMapCredit = '';
m.updatedAt = Date.now();
writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_delete_route_map', detail: { ok: true } });
+98 -3
View File
@@ -228,7 +228,9 @@ router.get('/config', (req, res) => {
const cfg = DataService.getConfig();
res.json({
promotion: { name: cfg.promotion?.name || '', discount: cfg.promotion?.discount ?? 1 },
lua_versions: cfg.lua_versions || {}
lua_versions: cfg.lua_versions || {},
force_update: cfg.force_update === true,
update_counter: cfg.update_counter || 0
});
});
@@ -694,20 +696,82 @@ router.get('/tickets/:id', async (req, res, next) => {
};
const formattedEvents = events.map(e => {
// #region debug-point A:public-ticket-event-raw
(() => {
const fs = require('fs');
let u = 'http://127.0.0.1:7777/event';
let s = 'device-id-missing';
try {
const env = fs.readFileSync('.dbg/device-id-missing.env', 'utf8');
u = env.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
s = env.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
} catch {}
fetch(u, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: s,
runId: 'pre-fix',
hypothesisId: 'A',
location: 'server/routes/public.js:696',
msg: '[DEBUG] public ticket event raw',
data: {
type: e?.type || '',
action: e?.action || '',
ticket_id: e?.ticket_id || '',
device: e?.device ?? null,
detail_device: e?.detail?.device ?? null
},
ts: Date.now()
})
}).catch(() => {});
})();
// #endregion
if (e.type === 'sale') {
return {
const mapped = {
type: 'sale',
ts: e.ts,
station_name: e.station_name || nameFor(e.station_code),
station_code: e.station_code || '',
station_en: e.station_code ? enNameFor(e.station_code) : (e.station_en || ''),
ticket_id: e.ticket_id,
device: e.device || e.device_id || e.detail?.device || '',
start_name: nameFor(e.start),
terminal_name: nameFor(e.terminal),
train_type: e.train_type || '',
trips_total: e.trips_total ?? 0,
amount: e.cost ?? 0
};
// #region debug-point A:public-ticket-event-sale-mapped
(() => {
const fs = require('fs');
let u = 'http://127.0.0.1:7777/event';
let s = 'device-id-missing';
try {
const env = fs.readFileSync('.dbg/device-id-missing.env', 'utf8');
u = env.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
s = env.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
} catch {}
fetch(u, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: s,
runId: 'pre-fix',
hypothesisId: 'A',
location: 'server/routes/public.js:701',
msg: '[DEBUG] public sale event mapped',
data: {
ticket_id: mapped.ticket_id || '',
device: mapped.device ?? null,
station_code: mapped.station_code || ''
},
ts: Date.now()
})
}).catch(() => {});
})();
// #endregion
return mapped;
} else if (e.type === 'status') {
return {
type: 'status',
@@ -720,11 +784,42 @@ router.get('/tickets/:id', async (req, res, next) => {
trips_remaining: e.trips_remaining ?? e.rides_remaining ?? null
};
} else if (e.type === 'check') {
return {
const mapped = {
...e,
station_name: e.station_name || nameFor(e.station_code),
station_en: e.station_code ? enNameFor(e.station_code) : (e.station_en || '')
};
// #region debug-point C:public-ticket-event-check-mapped
(() => {
const fs = require('fs');
let u = 'http://127.0.0.1:7777/event';
let s = 'device-id-missing';
try {
const env = fs.readFileSync('.dbg/device-id-missing.env', 'utf8');
u = env.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
s = env.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
} catch {}
fetch(u, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: s,
runId: 'pre-fix',
hypothesisId: 'C',
location: 'server/routes/public.js:718',
msg: '[DEBUG] public check event mapped',
data: {
ticket_id: mapped.ticket_id || '',
action: mapped.action || '',
device: mapped.device ?? null,
station_code: mapped.station_code || ''
},
ts: Date.now()
})
}).catch(() => {});
})();
// #endregion
return mapped;
}
return e;
});
+15 -1
View File
@@ -64,7 +64,8 @@ const cache = {
icCardIndex: {},
icCardEvents: [],
statsTicket: [],
statsGate: []
statsGate: [],
issues: []
};
const DataService = {
@@ -88,6 +89,7 @@ const DataService = {
await conn.query(`CREATE TABLE IF NOT EXISTS ticket_events (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
await conn.query(`CREATE TABLE IF NOT EXISTS stats_ticket (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
await conn.query(`CREATE TABLE IF NOT EXISTS stats_gate (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
await conn.query(`CREATE TABLE IF NOT EXISTS issues (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
// Load Cache
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
@@ -126,6 +128,9 @@ const DataService = {
const [statsG] = await conn.query('SELECT data FROM stats_gate');
cache.statsGate = statsG.map(r => r.data);
const [issues] = await conn.query('SELECT data FROM issues');
cache.issues = issues.map(r => r.data);
console.log('DataService initialized with MySQL');
} finally {
conn.release();
@@ -511,6 +516,15 @@ const DataService = {
},
getStatsGate: () => cache.statsGate,
// Issues
appendIssue: async (item) => {
cache.issues.push(item);
try {
await pool.query('INSERT INTO issues (data) VALUES (?)', [JSON.stringify(item)]);
} catch(e) { console.error('appendIssue error', e); }
},
getIssues: () => cache.issues,
// Export
buildExportPayload: () => ({
config: DataService.getConfig(),
+28 -1
View File
@@ -49,7 +49,7 @@ const buildRouteGraph = (stations, lines, fares, cfg) => {
for (let i = 0; i < stops.length - 1; i++) {
const a = String(stops[i] || '').trim();
const b = String(stops[i + 1] || '').trim();
if (!a || !b || a === b) continue;
if (!a || !b || a === b || a === '-' || b === '-') continue;
const fare = fareBySegment.get(segKey(a, b));
if (!fare) continue;
addEdge(a, b, {
@@ -86,6 +86,33 @@ const buildRouteGraph = (stations, lines, fares, cfg) => {
});
};
// Auto-link stations with the same name (Chinese or English)
const stationsByName = new Map();
const normKey = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, '');
for (const s of (stations || [])) {
const code = String(s?.code || '').trim();
if (!code) continue;
const cn = normKey(s?.name || s?.cn_name || s?.station_name);
const en = normKey(s?.en_name || s?.en || s?.enName);
if (cn) {
if (!stationsByName.has(cn)) stationsByName.set(cn, []);
stationsByName.get(cn).push(code);
}
if (en && en !== cn) {
if (!stationsByName.has(en)) stationsByName.set(en, []);
stationsByName.get(en).push(code);
}
}
for (const codes of stationsByName.values()) {
if (codes.length < 2) continue;
for (let i = 0; i < codes.length; i++) {
for (let j = i + 1; j < codes.length; j++) {
addTransferEdge(codes[i], codes[j]);
}
}
}
const transfers = Array.isArray(cfg?.transfers) ? cfg.transfers : [];
for (const p of transfers) {
const u = String(p?.[0] || '').trim();
+117 -101
View File
@@ -1,22 +1,29 @@
const fs = require('fs');
const escapeHTML = (s) => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
const escapeHTML = (s) => String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
function generate(stations, lines, fares, nameByCode, transfers) {
const enNameByCode = (() => {
try {
const map = {}; for (const s of stations) { map[String(s.code || '')] = s.en_name || s.enName || ''; }
const map = {};
for (const s of (stations || [])) {
map[String(s.code || '')] = s.en_name || s.enName || '';
}
return map;
} catch (_) { return {}; }
} catch (_) {
return {};
}
})();
// Build linesForStation for coloring (still needed for visualization)
const linesForStation = {};
(lines || []).forEach(li => {
(lines || []).forEach((li) => {
const id = li.id || '';
const name = li.name || li.cn_name || '';
const color = li.color || '#93a2b7';
const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x));
const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map((x) => String(x));
for (const code of arr) {
if (!linesForStation[code]) linesForStation[code] = [];
linesForStation[code].push({ id, name, color, stations: arr });
@@ -24,23 +31,23 @@ function generate(stations, lines, fares, nameByCode, transfers) {
});
const groupsMap = {};
// Optional: We can still use groups for coloring logic, but NOT for graph edges if we want strict mode.
// But let's keep groupsMap for color logic (lines 30-46) as it helps determine if lines connect visually.
stations.forEach(s => {
(stations || []).forEach((s) => {
const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim();
if (!k) return; (groupsMap[k] ||= []).push(String(s.code || ''));
if (!k) return;
if (!groupsMap[k]) groupsMap[k] = [];
groupsMap[k].push(String(s.code || ''));
});
const groupsForColors = Object.values(groupsMap).filter(arr => arr.length > 1);
const groupsForColors = Object.values(groupsMap).filter((arr) => arr.length > 1);
const colorsForRoute = (a, b) => {
const la = linesForStation[a] || [];
const lb = linesForStation[b] || [];
const same = la.find(x => lb.some(y => y.id === x.id));
const same = la.find((x) => lb.some((y) => y.id === x.id));
if (same) return [same.color || '#93a2b7'];
// Logic to find connecting colors via transfer groups
for (const g of groupsForColors) {
const startLine = la.find(li => (li.stations || []).some(code => g.includes(code)));
const endLine = lb.find(li => (li.stations || []).some(code => g.includes(code)));
const startLine = la.find((li) => (li.stations || []).some((code) => g.includes(code)));
const endLine = lb.find((li) => (li.stations || []).some((code) => g.includes(code)));
if (startLine && endLine) {
const c1 = startLine.color || '#93a2b7';
const c2 = endLine.color || '#93a2b7';
@@ -53,153 +60,162 @@ function generate(stations, lines, fares, nameByCode, transfers) {
const buildGraph = () => {
const adj = new Map();
const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); };
const addEdge = (u, v, w) => { addNode(u); addNode(v); adj.get(u).set(v, w); adj.get(v).set(u, w); };
// Create a set of valid station codes for fast lookup
const validStationCodes = new Set(stations.map(s => String(s.code || '')));
const addEdge = (u, v, w) => {
addNode(u);
addNode(v);
adj.get(u).set(v, w);
adj.get(v).set(u, w);
};
// 1. Edges from explicit FARES only
(fares || []).forEach(f => {
const a = String(f.from || ''), b = String(f.to || '');
const validStationCodes = new Set((stations || []).map((s) => String(s.code || '')));
(fares || []).forEach((f) => {
const a = String(f.from || '');
const b = String(f.to || '');
if (!a || !b) return;
if (!validStationCodes.has(a) || !validStationCodes.has(b)) return;
const w = Number(f.cost_regular ?? f.cost) || 0;
addEdge(a, b, w);
});
// 2. Edges from TRANSFERS (0 cost)
if (Array.isArray(transfers)) {
transfers.forEach(p => {
const a = String(p[1] || ''), b = String(p[2] || ''); // startup.lua used index 1,2? No, lua is 1-based. JS is 0-based.
// Wait, config transfers is [[a,b], [c,d]].
// Let's check how it's stored. usually JSON array of arrays.
// In startup.lua: p[1], p[2].
// In JS: p[0], p[1].
// But let's check standard.
// If I look at startup.lua: `local a, b = tostring(p[1] or ''), tostring(p[2] or '')` (Lua 1-based index)
// So in JSON it is `["A", "B"]`.
// In JS `p[0]` is "A", `p[1]` is "B".
// Let's be safe and check both or iterate.
const u = String(p[0] || '');
const v = String(p[1] || '');
if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) {
addEdge(u, v, 0);
}
});
transfers.forEach((p) => {
const u = String(p[0] || '');
const v = String(p[1] || '');
if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) {
addEdge(u, v, 0);
}
});
}
// REMOVED: Edges from Lines (implicit 0 cost)
// REMOVED: Edges from Groups (implicit 0 cost) - unless we want to keep it as fallback?
// User said "Strictly use database data". If transfers are in DB, we use them.
// If user relies on name grouping but didn't put it in DB transfers, they might be unhappy.
// But "Strictly use database data" implies we shouldn't automagically connect things.
// I will remove the auto-grouping for graph edges to be consistent with startup.lua changes.
stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); });
(stations || []).forEach((s) => {
const c = String(s.code || '');
if (c) addNode(c);
});
return adj;
};
const adj = buildGraph();
const codes = Array.from(adj.keys()).sort((a, b) => a.localeCompare(b));
const neighborsOf = (u) => adj.get(u) || new Map();
const dijkstra = (src) => {
const dist = {}; const visited = new Set();
for (const n of adj.keys()) dist[n] = Infinity; dist[src] = 0;
const dist = {};
const visited = new Set();
for (const n of adj.keys()) dist[n] = Infinity;
dist[src] = 0;
while (true) {
let u = null; let best = Infinity;
for (const n of adj.keys()) { if (!visited.has(n) && dist[n] < best) { best = dist[n]; u = n; } }
if (u === null) break; visited.add(u);
let u = null;
let best = Infinity;
for (const n of adj.keys()) {
if (!visited.has(n) && dist[n] < best) {
best = dist[n];
u = n;
}
}
if (u === null) break;
visited.add(u);
for (const [v, w] of neighborsOf(u)) {
const nd = dist[u] + Number(w || 0);
if (nd < dist[v]) dist[v] = nd;
}
}
return dist;
};
const distAll = {}; codes.forEach(c => { distAll[c] = dijkstra(c); });
const distAll = {};
codes.forEach((c) => {
distAll[c] = dijkstra(c);
});
let maxFare = 1;
for (const r of codes) {
for (const c of codes) { if (r === c) continue; const v = Number(distAll[r][c] || 0); if (v > maxFare) maxFare = v; }
for (const c of codes) {
if (r === c) continue;
const v = Number(distAll[r][c] || 0);
if (v > maxFare) maxFare = v;
}
}
const margin = 8;
const cellW = 56;
const cellH = 40;
const estimateWidth = (text, size) => Math.ceil(String(text || '').length * (size <= 10 ? 7 : 10));
const maxCnW = Math.max(1, ...codes.map(c => estimateWidth(nameByCode[c] || c, 14)));
const maxEnW = Math.max(1, ...codes.map(c => estimateWidth(enNameByCode[c] || '', 10)));
const maxCodeW = Math.max(1, ...codes.map(c => estimateWidth(c, 10)));
const maxCnW = Math.max(1, ...codes.map((c) => estimateWidth(nameByCode[c] || c, 14)));
const maxEnW = Math.max(1, ...codes.map((c) => estimateWidth(enNameByCode[c] || '', 10)));
const maxCodeW = Math.max(1, ...codes.map((c) => estimateWidth(c, 10)));
const leftW = Math.max(140, 24 + Math.max(maxCnW, maxEnW, maxCodeW));
const topH = 64;
const n = codes.length;
const width = margin + leftW + n * cellW + margin;
const height = margin + topH + n * cellH + margin;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:#ffffff;font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif">`;
// Column headers
for (let j = 0; j < n; j++) {
const code = codes[j]; const name = nameByCode[code] || code; const en = enNameByCode[code] || '';
const x = margin + leftW + j * cellW + cellW / 2; const y = margin + 20;
const code = codes[j];
const name = nameByCode[code] || code;
const en = enNameByCode[code] || '';
const x = margin + leftW + j * cellW + cellW / 2;
const y = margin + 20;
svg += `<text x="${x}" y="${y}" font-size="14" font-weight="900" fill="#000000" text-anchor="middle">${escapeHTML(name)}</text>`;
if (en) svg += `<text x="${x}" y="${y + 14}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(en)}</text>`;
svg += `<text x="${x}" y="${y + 26}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(code)}</text>`;
}
for (let i = 0; i < n; i++) {
const code = codes[i]; const name = nameByCode[code] || code; const en = enNameByCode[code] || '';
const xText = margin + 6; const baseY = margin + topH + i * cellH + 22;
const code = codes[i];
const name = nameByCode[code] || code;
const en = enNameByCode[code] || '';
const xText = margin + 6;
const baseY = margin + topH + i * cellH + 22;
svg += `<text x="${xText}" y="${baseY - 10}" font-size="14" font-weight="900" fill="#000000">${escapeHTML(name)}</text>`;
if (en) svg += `<text x="${xText}" y="${baseY + 4}" font-size="10" fill="#000000">${escapeHTML(en)}</text>`;
svg += `<text x="${xText}" y="${baseY + 16}" font-size="10" fill="#000000">${escapeHTML(code)}</text>`;
for (let j = 0; j < n; j++) {
const x0 = margin + leftW + j * cellW; const y0 = margin + topH + i * cellH;
const x0 = margin + leftW + j * cellW;
const y0 = margin + topH + i * cellH;
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#ffffff" stroke="#e5e7eb"/>`;
if (i === j) {
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" fill="#6b7280" text-anchor="middle">-</text>`;
} else {
const distVal = distAll[code][codes[j]];
if (distVal === Infinity || !Number.isFinite(distVal)) {
// Unreachable station
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="14" font-weight="800" fill="#ef4444" text-anchor="middle">×</text>`;
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="14" font-weight="800" fill="#ef4444" text-anchor="middle">×</text>`;
} else {
const val = Math.round(Number(distVal || 0));
const intensity = maxFare > 0 ? Math.min(1, val / maxFare) : 0;
const val = Math.round(Number(distVal || 0));
const routeColors = colorsForRoute(code, codes[j]);
// Get route colors
const routeColors = colorsForRoute(code, codes[j]);
// Draw background strips for multiple lines
if (routeColors.length > 0) {
const stripW = cellW / routeColors.length;
for (let k = 0; k < routeColors.length; k++) {
const c = routeColors[k] || '#93a2b7';
const stripX = x0 + k * stripW;
// Use fill-opacity for robustness against any color format
svg += `<rect x="${stripX}" y="${y0}" width="${stripW}" height="${cellH}" fill="${escapeHTML(c)}" fill-opacity="0.15" stroke="none"/>`;
}
} else {
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#000000" fill-opacity="0.03" stroke="none"/>`;
if (routeColors.length > 0) {
const stripW = cellW / routeColors.length;
for (let k = 0; k < routeColors.length; k++) {
const c = routeColors[k] || '#93a2b7';
const stripX = x0 + k * stripW;
svg += `<rect x="${stripX}" y="${y0}" width="${stripW}" height="${cellH}" fill="${escapeHTML(c)}" fill-opacity="0.15" stroke="none"/>`;
}
} else {
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#000000" fill-opacity="0.03" stroke="none"/>`;
}
// Add cell border
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>`;
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>`;
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" font-weight="800" fill="#000000" text-anchor="middle">${escapeHTML(String(val))}</text>`;
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" font-weight="800" fill="#000000" text-anchor="middle">${escapeHTML(String(val))}</text>`;
// Show small color indicators at top left
const dots = routeColors.slice(0, 4);
for (let k = 0; k < dots.length; k++) {
const cx = x0 + 8 + k * 10; const cy = y0 + 10;
const dotColor = dots[k] || '#93a2b7';
svg += `<circle cx="${cx}" cy="${cy}" r="3.5" fill="${escapeHTML(dotColor)}" stroke="white" stroke-width="1"/>`;
}
const dots = routeColors.slice(0, 4);
for (let k = 0; k < dots.length; k++) {
const cx = x0 + 8 + k * 10;
const cy = y0 + 10;
const dotColor = dots[k] || '#93a2b7';
svg += `<circle cx="${cx}" cy="${cy}" r="3.5" fill="${escapeHTML(dotColor)}" stroke="white" stroke-width="1"/>`;
}
}
}
}
}
svg += `</svg>`;
return svg;
}
+533 -195
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
const mysql = require('mysql2/promise');
async function run() {
const pool = mysql.createPool({
host: '127.0.0.1',
port: 3306,
user: 'cc-ticket-machine',
password: 'cc-ticket-machine',
database: 'cc-ticket-machine',
});
try {
const [rows] = await pool.query('SELECT data FROM config LIMIT 1');
if (rows.length > 0) {
let data = rows[0].data;
if (typeof data === 'string') data = JSON.parse(data);
data.lua_versions = data.lua_versions || {};
data.lua_versions.ticketmachine = 'v1.5.12';
data.lua_versions.gate = 'v1.5.9';
await pool.query('UPDATE config SET data = ?', [JSON.stringify(data)]);
console.log('Updated DB config to v1.5.12');
}
} catch(e) {
console.error(e);
} finally {
await pool.end();
}
}
run();
+37 -13
View File
@@ -1,5 +1,19 @@
local URL_GATE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/gate.lua"
local URL_UPDATE_GATE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_gate.lua"
local args = { ... }
local silent = AUTO_UPDATE_SILENT == true or args[1] == "--silent"
local function getCacheBustedUrl(url)
local sep = url:find("?") and "&" or "?"
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
end
local function log(msg)
if not silent then
print(msg)
end
end
local function writeFile(path, content, binary)
local mode = binary and "wb" or "w"
@@ -54,25 +68,35 @@ local function httpGet(url)
end
end
term.clear()
term.setCursorPos(1, 1)
print("Ticket Gate Updater")
print("")
print("Downloading gate program...")
if not silent then
term.clear()
term.setCursorPos(1, 1)
log("Ticket Gate Updater")
log("")
end
log("Downloading gate program...")
local ok, code = httpGet(URL_GATE)
local ok, code = httpGet(getCacheBustedUrl(URL_GATE))
if not ok or type(code) ~= "string" or #code == 0 then
print("Download failed: " .. tostring(code or ""))
return
error("Download failed: " .. tostring(code or ""))
end
if not atomicWrite("startup.lua", code, false) then
print("Write failed: startup.lua")
return
error("Write failed: startup.lua")
end
atomicWrite("startup", code, false)
if fs.exists("gate.lua") then atomicWrite("gate.lua", code, false) end
print("")
print("Done.")
print("Reboot the computer to apply the update.")
log("Downloading updater program...")
local okUp, codeUp = httpGet(getCacheBustedUrl(URL_UPDATE_GATE))
if okUp and type(codeUp) == "string" and #codeUp > 0 then
atomicWrite("update_gate.lua", codeUp, false)
end
if not silent then
log("")
log("Done.")
log("Reboot the computer to apply the update.")
end
return true
+37 -14
View File
@@ -1,5 +1,18 @@
local URL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/ticketmachine.lua"
local URL_UPDATE_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_machine.lua"
local args = { ... }
local silent = AUTO_UPDATE_SILENT == true or args[1] == "--silent"
local function getCacheBustedUrl(url)
local sep = url:find("?") and "&" or "?"
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
end
local function log(msg)
if not silent then
print(msg)
end
end
local function writeFile(path, content, binary)
local mode = binary and "wb" or "w"
@@ -54,25 +67,35 @@ local function httpGet(url)
end
end
term.clear()
term.setCursorPos(1, 1)
print("Ticket Machine Updater")
print("")
print("Downloading ticket machine program...")
if not silent then
term.clear()
term.setCursorPos(1, 1)
log("Ticket Machine Updater")
log("")
end
log("Downloading ticket machine program...")
local ok, code = httpGet(URL_MACHINE)
local ok, code = httpGet(getCacheBustedUrl(URL_MACHINE))
if not ok or type(code) ~= "string" or #code == 0 then
print("Download failed: " .. tostring(code or ""))
return
error("Download failed: " .. tostring(code or ""))
end
if not atomicWrite("startup.lua", code, false) then
print("Write failed: startup.lua")
return
error("Write failed: startup.lua")
end
atomicWrite("startup", code, false)
if fs.exists("ticketmachine.lua") then atomicWrite("ticketmachine.lua", code, false) end
print("")
print("Done.")
print("Reboot the computer to apply the update.")
log("Downloading updater program...")
local okUp, codeUp = httpGet(getCacheBustedUrl(URL_UPDATE_MACHINE))
if okUp and type(codeUp) == "string" and #codeUp > 0 then
atomicWrite("update_machine.lua", codeUp, false)
end
if not silent then
log("")
log("Done.")
log("Reboot the computer to apply the update.")
end
return true
+13 -1
View File
@@ -1,5 +1,11 @@
local URL_REFILL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/refillmachine.lua"
local URL_UPDATE_REFILL = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/raw/branch/main/update_refill.lua"
local function getCacheBustedUrl(url)
local sep = url:find("?") and "&" or "?"
return url .. sep .. "_t=" .. tostring(os.epoch and os.epoch("utc") or math.random(1, 999999))
end
local function writeFile(path, content, binary)
local mode = binary and "wb" or "w"
@@ -60,12 +66,18 @@ print("Refill Machine Updater")
print("")
print("Downloading refill machine program...")
local ok, code = httpGet(URL_REFILL_MACHINE)
local ok, code = httpGet(getCacheBustedUrl(URL_REFILL_MACHINE))
if not ok or type(code) ~= "string" or #code == 0 then
print("Download failed: " .. tostring(code or ""))
return
end
print("Downloading updater program...")
local okUp, codeUp = httpGet(getCacheBustedUrl(URL_UPDATE_REFILL))
if okUp and type(codeUp) == "string" and #codeUp > 0 then
atomicWrite("update_refill.lua", codeUp, false)
end
if not atomicWrite("startup.lua", code, false) then
print("Write failed: startup.lua")
return
+18 -18
View File
@@ -1,7 +1,7 @@
{
"style.css": {
"size": 77276,
"mtimeMs": 1781972131010.4126
"size": 86571,
"mtimeMs": 1783784204130.9187
},
"blog.css": {
"size": 994,
@@ -12,40 +12,40 @@
"mtimeMs": 1781967608378.205
},
"public-status.js": {
"size": 3814,
"mtimeMs": 1781967627973.2036
"size": 2782,
"mtimeMs": 1783602619602.658
},
"ticket-order.js": {
"size": 23614,
"mtimeMs": 1781974289008.8884
"size": 38975,
"mtimeMs": 1783947975169.5613
},
"ticket-search.js": {
"size": 11647,
"mtimeMs": 1781966517813.3787
"size": 17269,
"mtimeMs": 1783666241741.0564
},
"ticket-route.js": {
"size": 42284,
"mtimeMs": 1781967610560.6072
"size": 44004,
"mtimeMs": 1783666241687.1482
},
"index.js": {
"size": 63063,
"mtimeMs": 1781967609550.651
"size": 93210,
"mtimeMs": 1783947920227.9448
},
"ic-card-admin.js": {
"size": 15188,
"mtimeMs": 1781967608706.734
"size": 19095,
"mtimeMs": 1783666241303.3374
},
"ic-card-order.js": {
"size": 11528,
"mtimeMs": 1781967363353.6257
"size": 11488,
"mtimeMs": 1783234306158.3623
},
"ic-card-detail.js": {
"size": 5322,
"mtimeMs": 1781967346854.6172
},
"ic-card-search.js": {
"size": 5462,
"mtimeMs": 1781933937174.1592
"size": 14392,
"mtimeMs": 1783666241398.5178
},
"token.js": {
"size": 4064,
+9 -9
View File
@@ -1,16 +1,16 @@
{
"style.css": 12,
"style.css": 14,
"blog.css": 2,
"custom-dialog.js": 11,
"public-status.js": 13,
"ticket-order.js": 16,
"ticket-search.js": 11,
"ticket-route.js": 2,
"index.js": 2,
"ic-card-admin.js": 2,
"ic-card-order.js": 2,
"public-status.js": 14,
"ticket-order.js": 31,
"ticket-search.js": 12,
"ticket-route.js": 3,
"index.js": 6,
"ic-card-admin.js": 3,
"ic-card-order.js": 3,
"ic-card-detail.js": 2,
"ic-card-search.js": 2,
"ic-card-search.js": 3,
"token.js": 2,
"login.js": 2,
"blog.js": 2
+1 -74
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=13">
<link rel="stylesheet" href="blog.css?v=2">
</head>
<body class="public-search">
<div class="public-container">
<header class="search-header" style="text-align: left;">
<div style="margin-bottom: 10px; text-align: left;">
<a href="https://ticket.fse-media.group" id="homeLink"
style="color: var(--primary); text-decoration: none; font-weight: 500;">
<i class="fas fa-arrow-left"></i> 返回首页
</a>
</div>
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<img src="logo.png" alt="Logo" style="height: 40px; margin-right: 12px;">
<h1 style="font-weight: 800; letter-spacing: -1px; margin: 0; text-align: left; font-size: 26px;">
FMG
</h1>
</div>
</header>
<main>
<section class="tab-panel show">
<div class="portal-grid">
<a href="http://forum.fse-media.group" class="portal-card">
<div class="portal-icon">
<i class="fas fa-comments"></i>
</div>
<h3>论坛</h3>
<p>forum.fse-media.group</p>
</a>
<a href="http://b.igtm.ooooo.ink:11324" class="portal-card">
<div class="portal-icon">
<i class="fas fa-poll-h"></i>
</div>
<h3>问卷</h3>
<p>b.igtm.ooooo.ink</p>
</a>
</div>
<div class="card" style="margin-top: 20px;">
<div class="card-title"
style="font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; color: var(--text);">
<i class="fas fa-server text-primary"></i> 服务器状态
</div>
<div style="overflow-x: auto; width: 100%;">
<iframe id="mc-status-traintown-online-" frameborder="0" width="1000" height="500"
style="max-width:100%; border-radius: 8px;" scrolling="no"
src="https://motd.minebbs.com/iframe?ip=traintown.online&stype=auto&dark=true"></iframe>
</div>
</div>
</section>
</main>
<footer
style="margin-top: 60px; padding: 20px 0; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; text-align: center;">
<p>&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=12"></script>
<script src="blog.js?v=2"></script>
</body>
</html>
</html>
+26 -11
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
@@ -6,7 +6,7 @@
<title>首页|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=13">
<link rel="stylesheet" href="/style.css?v=22">
</head>
<body class="public-search jr-public-page">
<div class="jr-public-shell">
@@ -16,11 +16,7 @@
<i class="fas fa-train"></i>
<span>FSE铁路运输票务系统</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
@@ -181,9 +177,29 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script>
const createRouteMapCredit = (credit, license) => {
const wrap = document.createElement('div');
wrap.className = 'jr-asset-credit';
const creditText = document.createElement('span');
creditText.textContent = `线路图署名:${credit || '未填写'}`;
wrap.appendChild(creditText);
wrap.appendChild(document.createTextNode(' 许可协议:'));
const licenseLink = document.createElement('a');
licenseLink.href = 'https://creativecommons.org/licenses/by/4.0/';
licenseLink.target = '_blank';
licenseLink.rel = 'noopener noreferrer';
licenseLink.textContent = license || 'CC BY 4.0';
wrap.appendChild(licenseLink);
return wrap;
};
const loadPublicAssets = async () => {
const fareMapBox = document.getElementById('fareMapBox');
const routeMapBox = document.getElementById('routeMapBox');
@@ -209,6 +225,7 @@
img.src = `/assets/${encodeURIComponent(m.routeMap)}?v=${encodeURIComponent(mv)}`;
routeMapBox.innerHTML = '<div class="jr-asset-frame"></div>';
routeMapBox.firstElementChild.appendChild(img);
routeMapBox.appendChild(createRouteMapCredit(m.routeMapCredit, m.routeMapLicense));
} else {
routeMapBox.innerHTML = '<div class="text-muted">暂无线路图</div>';
}
@@ -245,5 +262,3 @@
</html>
+5 -9
View File
@@ -7,7 +7,7 @@
<title>IC卡管理|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=13">
<link rel="stylesheet" href="/style.css?v=22">
</head>
<body class="jr-admin-page jr-admin-ic-page jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-train"></i>
<span>FSE 铁路运输后台系统</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
@@ -172,9 +168,9 @@
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ic-card-admin.js?v=2"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script src="/ic-card-admin.js?v=3"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+18 -1
View File
@@ -191,7 +191,24 @@
const formatIcCardEventUnified = (ev) => {
const type = String(ev?.type || '').toLowerCase();
const action = String(ev?.action || '').toLowerCase();
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
let deviceId = String(ev?.device || ev?.device_id || ev?.detail?.device || ev?.detail?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---').trim();
const dStr = deviceId.toLowerCase();
if (dStr === 'gate') {
deviceId = '闸机';
} else if (dStr === 'refill_machine') {
deviceId = '充值机';
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
deviceId = '售票机';
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
deviceId = '闸机';
} else if (type === 'topup' || type === 'refill' || action === 'topup' || type === '充值') {
deviceId = '充值机';
} else {
deviceId = '售票机';
}
}
const timeStr = formatTime(ev?.ts);
const balance = ev?.balance ?? ev?.["余额"] ?? 0;
+5 -9
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
@@ -7,7 +7,7 @@
<title>IC卡详情|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=13">
<link rel="stylesheet" href="/style.css?v=22">
</head>
<body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
@@ -99,9 +95,9 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ic-card-detail.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script src="/public-status.js?v=14"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html', order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html', search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html', 'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html', 'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
+5 -9
View File
@@ -7,7 +7,7 @@
<title>线上购卡|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=13">
<link rel="stylesheet" href="/style.css?v=22">
</head>
<body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
@@ -105,9 +101,9 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ic-card-order.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ic-card-order.js?v=3"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+4 -8
View File
@@ -7,7 +7,7 @@
<title>IC卡查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14">
<link rel="stylesheet" href="/style.css?v=22">
</head>
<body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
@@ -109,9 +105,9 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ic-card-search.js?v=3"></script>
<script src="/public-status.js?v=13"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+18 -1
View File
@@ -90,7 +90,24 @@
const formatIcCardEventUnified = (ev) => {
const type = String(ev?.type || '').toLowerCase();
const action = String(ev?.action || '').toLowerCase();
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
let deviceId = String(ev?.device || ev?.device_id || ev?.detail?.device || ev?.detail?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---').trim();
const dStr = deviceId.toLowerCase();
if (dStr === 'gate') {
deviceId = '闸机';
} else if (dStr === 'refill_machine') {
deviceId = '充值机';
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
deviceId = '售票机';
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
deviceId = '闸机';
} else if (type === 'topup' || type === 'refill' || action === 'topup' || type === '充值') {
deviceId = '充值机';
} else {
deviceId = '售票机';
}
}
const timeStr = formatTime(ev?.ts);
const balance = ev?.balance ?? ev?.["余额"] ?? 0;
+1032 -805
View File
File diff suppressed because it is too large Load Diff
+560 -21
View File
@@ -27,6 +27,7 @@ createApp({
tickets: '车票记录',
vouchers: '凭证管理',
iccards: 'IC 卡管理',
issues: '问题汇总',
assets: '线路图',
settings: '系统设置',
logs: '日志'
@@ -34,15 +35,15 @@ createApp({
return map[currentView.value] || '票价图';
});
// Prefer polling first so admin remains connected even when the proxy
// does not support WebSocket upgrades reliably.
const socket = io({ transports: ['polling', 'websocket'] });
// 强制使用 WebSocket,避免轮询 (polling) 在负载均衡/反代下产生 400 Bad Request
const socket = io({ transports: ['websocket'], timeout: 20000 });
// Data State
const stations = ref([]);
const lines = ref([]);
const fares = ref([]);
const tickets = ref([]);
const issues = ref([]);
const stats = reactive({ sold_tickets: 0, revenue: 0 });
const config = reactive({
api_base: '',
@@ -56,10 +57,11 @@ createApp({
const logMax = ref(200);
const logLoading = ref(false);
const orders = ref([]);
const assetsManifest = reactive({ routeMap: null, fareTable: null, updatedAt: null });
const assetsManifest = reactive({ routeMap: null, routeMapCredit: '', routeMapLicense: 'CC BY 4.0', fareTable: null, updatedAt: null });
const assetsFarePreview = reactive({ headers: [], rows: [] });
const assetsRouteMapUrl = ref('');
const assetsFareTableUrl = ref('');
const assetsForm = reactive({ routeMapCredit: '' });
const icCards = ref([]);
const icCardSearch = ref('');
const icSelectedId = ref('');
@@ -76,6 +78,7 @@ createApp({
let ticketDataLoaded = false;
let orderDataLoaded = false;
let logDataLoaded = false;
let issueDataLoaded = false;
let assetsLoaded = false;
let fareMapLoaded = false;
const loadingState = reactive({
@@ -83,7 +86,8 @@ createApp({
tickets: false,
orders: false,
logs: false,
iccards: false
iccards: false,
issues: false
});
const lastSyncAt = ref(0);
@@ -121,6 +125,13 @@ createApp({
const lineFormOriginalId = ref('');
const lineForm = reactive({ id: '', name: '', en_name: '', color: '#3366cc', stations: [] });
// Topology Editor State
const topologyPan = reactive({ x: 0, y: 0, scale: 1, active: false, startX: 0, startY: 0, initialX: 0, initialY: 0 });
const topologyNodes = ref([]);
const topologyLines = ref([]);
const topologyDraggingNode = ref(null);
let topologyNodeDragStart = { x: 0, y: 0, nodeX: 0, nodeY: 0 };
// Legacy/Other State
const fareMapSvg = ref('');
const fareMapScale = ref(1);
@@ -152,6 +163,132 @@ createApp({
try { return JSON.parse(t); } catch (e) { return null; }
};
const safeJsonStringify = (value) => {
try {
return JSON.stringify(value, null, 2);
} catch (e) {
return String(value);
}
};
const formatIssueKey = (key) => {
const map = {
device: '设备',
device_id: '设备编号',
machine_id: '机器 ID',
station_code: '车站编号',
station_name: '车站名称',
station: '车站',
error_msg: '错误信息',
ticket_id: '车票 ID',
card_id: '卡号',
holder_name: '持卡人',
balance: '余额',
amount: '金额',
price: '价格',
order_code: '订单号',
voucher_code: '凭证码',
method: '方法',
source: '来源',
reason: '原因',
status: '状态',
action: '动作',
line: '线路',
route: '区间',
from: '起点',
to: '终点',
time: '时间',
ts: '时间戳'
};
const raw = String(key || '').trim();
if (!raw) return '字段';
if (map[raw]) return map[raw];
return raw
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^\w/, (ch) => ch.toUpperCase());
};
const formatIssueValue = (value) => {
if (value == null || value === '') return '---';
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '---';
if (typeof value === 'string') return value.trim() || '---';
if (Array.isArray(value)) {
if (!value.length) return '---';
if (value.every((item) => item == null || ['string', 'number', 'boolean'].includes(typeof item))) {
return value.map(formatIssueValue).join(' / ');
}
return safeJsonStringify(value);
}
if (typeof value === 'object') return safeJsonStringify(value);
return String(value);
};
const buildIssueDetailView = (detail) => {
const rawText = String(detail || '').trim();
if (!rawText) return { entries: [], rawText: '', rawExpanded: false };
const hiddenKeys = new Set(['device', 'device_id', 'machine_id', 'station_code', 'station_name', 'error_msg', 'detail', 'type']);
const parsed = parseJsonSafe(rawText);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const entries = Object.entries(parsed)
.filter(([key, value]) => !hiddenKeys.has(String(key || '').trim()) && value != null && value !== '')
.map(([key, value]) => ({
key: String(key),
label: formatIssueKey(key),
value: formatIssueValue(value)
}));
return {
entries,
rawText: entries.length ? rawText : '',
rawExpanded: false
};
}
const segments = rawText
.split(/\r?\n|;|;/)
.map((item) => item.trim())
.filter(Boolean);
const entries = [];
for (const segment of segments) {
const matched = /^([^:=:]{1,30})\s*[:=:]\s*(.+)$/.exec(segment);
if (!matched) continue;
const key = String(matched[1] || '').trim();
if (!key || hiddenKeys.has(key)) continue;
entries.push({
key,
label: formatIssueKey(key),
value: formatIssueValue(matched[2])
});
}
const hasPlainBody = segments.length <= 1 && entries.length <= 1;
return {
entries: hasPlainBody ? [] : entries,
rawText,
rawExpanded: hasPlainBody
};
};
const normalizeIssueRecord = (issue) => {
const item = issue && typeof issue === 'object' ? issue : {};
const device = String(item.device || item.machine_id || '').trim();
const stationCode = String(item.station_code || '').trim();
const stationName = stationCode ? getStationName(stationCode) : '';
const detailView = buildIssueDetailView(item.detail);
return {
...item,
displayDevice: device || '---',
displayStation: stationName && stationName !== stationCode ? `${stationName} (${stationCode})` : (stationCode || '---'),
displayError: String(item.error_msg || '').trim() || '未提供错误信息',
detailEntries: detailView.entries,
detailRaw: detailView.rawText,
detailRawExpanded: detailView.rawExpanded
};
};
const parseCsvSimple = (text) => {
const out = { headers: [], rows: [] };
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
@@ -348,7 +485,65 @@ createApp({
const formatTicketEventUnified = (ev) => {
const type = getTicketEventType(ev);
const action = getTicketEventAction(ev);
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
let deviceId = String(ev?.device || ev?.device_id || ev?.detail?.device || ev?.detail?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---').trim();
const dStr = deviceId.toLowerCase();
// #region debug-point B:index-ticket-device-before-map
fetch('http://127.0.0.1:7777/event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: 'device-id-missing',
runId: 'pre-fix',
hypothesisId: 'B',
location: 'web/index.js:351',
msg: '[DEBUG] admin ticket formatter raw device',
data: {
type,
action,
raw_device: ev?.device ?? null,
raw_device_id: ev?.device_id ?? null,
raw_detail_device: ev?.detail?.device ?? null,
resolved_before_map: deviceId
},
ts: Date.now()
})
}).catch(() => {});
// #endregion
if (dStr === 'gate') {
deviceId = '闸机';
} else if (dStr === 'refill_machine') {
deviceId = '充值机';
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
deviceId = '售票机';
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
deviceId = '闸机';
} else {
deviceId = '售票机';
}
}
// #region debug-point B:index-ticket-device-after-map
fetch('http://127.0.0.1:7777/event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: 'device-id-missing',
runId: 'pre-fix',
hypothesisId: 'B',
location: 'web/index.js:373',
msg: '[DEBUG] admin ticket formatter mapped device',
data: {
type,
action,
mapped_device: deviceId
},
ts: Date.now()
})
}).catch(() => {});
// #endregion
const timeStr = formatTime(ev?.ts || ev?.["时间戳"]);
if (type === 'sale' || type === '售票') {
@@ -451,7 +646,24 @@ createApp({
const formatIcEventDetail = (ev) => {
const type = String(ev?.type || '').toLowerCase();
const action = String(ev?.action || '').toLowerCase();
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
let deviceId = String(ev?.device || ev?.device_id || ev?.detail?.device || ev?.detail?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---').trim();
const dStr = deviceId.toLowerCase();
if (dStr === 'gate') {
deviceId = '闸机';
} else if (dStr === 'refill_machine') {
deviceId = '充值机';
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
deviceId = '售票机';
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
deviceId = '闸机';
} else if (type === 'topup' || type === 'refill' || action === 'topup' || type === '充值') {
deviceId = '充值机';
} else {
deviceId = '售票机';
}
}
const timeStr = formatTime(ev?.ts);
const balance = ev?.balance ?? ev?.["余额"] ?? 0;
@@ -495,8 +707,11 @@ createApp({
const fetchAssetsManifest = async () => {
const data = await requestJson('/api/assets/manifest');
assetsManifest.routeMap = data ? (data.routeMap || null) : null;
assetsManifest.routeMapCredit = data ? String(data.routeMapCredit || '') : '';
assetsManifest.routeMapLicense = data ? String(data.routeMapLicense || 'CC BY 4.0') : 'CC BY 4.0';
assetsManifest.fareTable = data ? (data.fareTable || null) : null;
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
assetsForm.routeMapCredit = assetsManifest.routeMapCredit;
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
assetsLoaded = true;
@@ -540,9 +755,12 @@ createApp({
markSynced();
};
const uploadAssetFile = async (url, file) => {
const uploadAssetFile = async (url, file, extraFields = {}) => {
const fd = new FormData();
fd.append('file', file);
for (const [key, value] of Object.entries(extraFields || {})) {
fd.append(key, value == null ? '' : String(value));
}
await requestJson(url, { method: 'POST', body: fd }, { expectOk: true });
await fetchAssetsManifest();
};
@@ -551,7 +769,7 @@ createApp({
const f = ev && ev.target && ev.target.files && ev.target.files[0];
if (!f) return;
ev.target.value = '';
await uploadAssetFile('/api/assets/route-map', f);
await uploadAssetFile('/api/assets/route-map', f, { credit: assetsForm.routeMapCredit });
};
const uploadFareTable = async (ev) => {
@@ -566,6 +784,15 @@ createApp({
await fetchAssetsManifest();
};
const saveRouteMapCredit = async () => {
await requestJson('/api/assets/route-map/credit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credit: assetsForm.routeMapCredit })
}, { expectOk: true });
await fetchAssetsManifest();
};
const deleteFareTable = async () => {
await requestJson('/api/assets/fare-table', { method: 'DELETE' }, { expectOk: true });
await fetchAssetsManifest();
@@ -763,6 +990,26 @@ createApp({
}
};
// --- Issues Management ---
const fetchIssues = async () => {
if (loadingState.issues) return;
loadingState.issues = true;
try {
const res = await requestJson('/api/issues');
if (res && res.ok) {
issues.value = (res.issues || [])
.map(normalizeIssueRecord)
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
issueDataLoaded = true;
markSynced();
}
} catch (e) {
console.error(e);
} finally {
loadingState.issues = false;
}
};
const fetchIcCards = async (keepSelection = true) => {
if (loadingState.iccards) return;
loadingState.iccards = true;
@@ -998,6 +1245,9 @@ createApp({
const found = lines.value.find((line) => line.id === selectedLine.value.id);
selectedLine.value = found || null;
}
if (currentView.value === 'assets') {
initTopologyLayout();
}
coreLoaded = true;
markSynced();
} catch (e) {
@@ -1046,6 +1296,7 @@ createApp({
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
if (view === 'issues' && (force || !issueDataLoaded)) await fetchIssues();
if (view === 'iccards') {
await fetchIcCards(true);
if (icSelectedId.value) {
@@ -1154,6 +1405,22 @@ createApp({
await updateLineStations(selectedLine.value.id, newStations);
};
const removeStationFromLineIndex = async (index) => {
if (!selectedLine.value) return;
const newStations = [...(selectedLine.value.stations || [])];
newStations.splice(index, 1);
await updateLineStations(selectedLine.value.id, newStations);
};
const addBreakpointToLine = async () => {
if (!selectedLine.value) return;
await runMutation(async () => {
const newStations = [...(selectedLine.value.stations || [])];
newStations.push('-');
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
});
};
const updateLineStations = async (lineId, stationsList, { skipFetchData } = {}) => {
// Need to update the whole line object usually, or a specific endpoint
// Assuming PUT /api/lines/:id updates fields provided
@@ -1214,9 +1481,24 @@ createApp({
const transferTargets = computed(() => {
const fromCode = stationForm.code;
return stations.value
.filter(s => s && s.code && s.code !== fromCode)
.map(s => ({ code: s.code, name: s.name || s.cn_name || s.code, en_name: s.en_name || s.enName || '' }));
const fromName = String(stationForm.name || '').trim().toLowerCase();
const sameName = [];
const others = [];
stations.value.forEach(s => {
if (!s || !s.code || s.code === fromCode) return;
const targetName = String(s.name || s.cn_name || s.code).trim().toLowerCase();
const opt = { code: s.code, name: s.name || s.cn_name || s.code, en_name: s.en_name || s.enName || '' };
if (fromName && targetName === fromName) {
sameName.push(opt);
} else {
others.push(opt);
}
});
return { sameName, others };
});
const openLineModal = () => {
@@ -1261,11 +1543,21 @@ createApp({
});
};
const handleStationClick = async (code) => {
const handleStationClick = async (code, index) => {
if (lineViewportPan.moved) {
lineViewportPan.moved = false;
return;
}
if (code === '-') {
if (await appDialog.confirm({
title: '移除断点',
message: `确定移除该断点?`,
confirmText: '确认移除'
})) {
await removeStationFromLineIndex(index);
}
return;
}
if (stationEditMode.value) {
openStationModal(code);
return;
@@ -1401,10 +1693,23 @@ createApp({
}, { successMessage: '保存成功' });
};
const bumpLuaVersion = async (device) => {
if (!config.lua_versions[device]) config.lua_versions[device] = 'v1.0.0';
config.lua_versions[device] = bumpPatchVersion(config.lua_versions[device]);
await saveConfig();
const isPushingUpdate = ref(false);
const pushLuaUpdate = async () => {
if (!confirm('确定要向所有机器推送更新指令吗?这将会使空闲机器静默更新并重启。')) return;
isPushingUpdate.value = true;
try {
// 直接请求专门的轻量级触发更新接口,避免和庞大的配置文件保存混在一起导致超时
await requestJson('/api/trigger-update', { method: 'POST' }, { expectOk: true });
alert('推送指令发送完毕,终端将陆续更新');
} catch (err) {
console.error(err);
alert('推送更新失败: ' + err.message);
} finally {
isPushingUpdate.value = false;
}
};
const exportData = () => {
@@ -1461,6 +1766,12 @@ createApp({
stats.sold_tickets += item.sold_tickets;
stats.revenue += item.revenue;
});
socket.on('issue:new', (item) => {
issues.value = [normalizeIssueRecord(item), ...issues.value]
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
issueDataLoaded = true;
markSynced();
});
socket.on('ic-card:created', () => { fetchIcCards(false).catch(console.error); });
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
socket.on('ic-card:check', () => {
@@ -1476,6 +1787,227 @@ createApp({
syncSelectedIcCard().catch(console.error);
});
// Topology Editor Logic
const initTopologyLayout = () => {
const nodesMap = new Map();
const linesArr = lines.value || [];
const stationsArr = stations.value || [];
// Helper to get transfer canonicals
const canonicals = {};
const isTransfer = {};
stationsArr.forEach(s => {
const code = String(s.code || '').trim();
canonicals[code] = code;
isTransfer[code] = false;
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
const canonical = group[0];
group.forEach(g => canonicals[g] = canonical);
}
});
// Count lines per canonical to identify transfer hubs
const lineCountByCanonical = {};
linesArr.forEach(line => {
const stops = Array.isArray(line.stations) ? line.stations : [];
stops.forEach(stop => {
if (stop === '-') return;
const canonical = canonicals[stop] || stop;
if (!lineCountByCanonical[canonical]) lineCountByCanonical[canonical] = new Set();
lineCountByCanonical[canonical].add(line.id);
});
});
Object.keys(lineCountByCanonical).forEach(c => {
if (lineCountByCanonical[c].size > 1) {
isTransfer[c] = true;
}
});
// Initialize Nodes
stationsArr.forEach((s, idx) => {
const code = String(s.code || '').trim();
const canonical = canonicals[code] || code;
if (nodesMap.has(canonical)) return;
nodesMap.set(canonical, {
canonical,
code: code,
name: s.name || code,
color: null,
isTransfer: isTransfer[canonical],
x: Number(s.svg_x) || (100 + (idx % 10) * 80),
y: Number(s.svg_y) || (100 + Math.floor(idx / 10) * 80),
originalX: Number(s.svg_x) || (100 + (idx % 10) * 80),
originalY: Number(s.svg_y) || (100 + Math.floor(idx / 10) * 80)
});
});
// Assign colors to nodes based on lines
linesArr.forEach(line => {
const stops = Array.isArray(line.stations) ? line.stations : [];
stops.forEach(stop => {
if (stop === '-') return;
const canonical = canonicals[stop] || stop;
const node = nodesMap.get(canonical);
if (node && !node.color) {
node.color = line.color || '#93a2b7';
}
});
});
topologyNodes.value = Array.from(nodesMap.values());
updateTopologyLines();
};
const updateTopologyLines = () => {
const canonicals = {};
stations.value.forEach(s => {
const code = String(s.code || '').trim();
canonicals[code] = code;
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
const canonical = group[0];
group.forEach(g => canonicals[g] = canonical);
}
});
const newLines = [];
(lines.value || []).forEach(line => {
const stops = Array.isArray(line.stations) ? line.stations : [];
let d = '';
let isFirst = true;
for (let i = 0; i < stops.length; i++) {
const stop = stops[i];
if (stop === '-') {
isFirst = true;
continue;
}
const canonical = canonicals[stop] || stop;
const node = topologyNodes.value.find(n => n.canonical === canonical);
if (!node) continue;
if (isFirst) {
d += `M ${node.x} ${node.y} `;
isFirst = false;
} else {
d += `L ${node.x} ${node.y} `;
}
}
if (d) {
newLines.push({ id: line.id, color: line.color || '#93a2b7', d });
}
});
topologyLines.value = newLines;
};
const onTopologyCanvasMouseDown = (e) => {
topologyPan.active = true;
topologyPan.startX = e.clientX;
topologyPan.startY = e.clientY;
topologyPan.initialX = topologyPan.x;
topologyPan.initialY = topologyPan.y;
};
const onTopologyCanvasMouseMove = (e) => {
if (topologyPan.active) {
topologyPan.x = topologyPan.initialX + (e.clientX - topologyPan.startX);
topologyPan.y = topologyPan.initialY + (e.clientY - topologyPan.startY);
} else if (topologyDraggingNode.value) {
const node = topologyNodes.value.find(n => n.canonical === topologyDraggingNode.value);
if (node) {
const dx = (e.clientX - topologyNodeDragStart.x) / topologyPan.scale;
const dy = (e.clientY - topologyNodeDragStart.y) / topologyPan.scale;
// Snap to grid of 20
node.x = Math.round((topologyNodeDragStart.nodeX + dx) / 20) * 20;
node.y = Math.round((topologyNodeDragStart.nodeY + dy) / 20) * 20;
updateTopologyLines();
}
}
};
const onTopologyCanvasMouseUp = () => {
topologyPan.active = false;
topologyDraggingNode.value = null;
};
const onTopologyCanvasWheel = (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
topologyPan.scale = Math.max(0.1, Math.min(3, topologyPan.scale * delta));
};
const onTopologyNodeMouseDown = (e, node) => {
topologyDraggingNode.value = node.canonical;
topologyNodeDragStart.x = e.clientX;
topologyNodeDragStart.y = e.clientY;
topologyNodeDragStart.nodeX = node.x;
topologyNodeDragStart.nodeY = node.y;
};
const resetTopologyLayout = () => {
if (!confirm('确定要放弃当前修改并重置吗?')) return;
topologyNodes.value.forEach(node => {
node.x = node.originalX;
node.y = node.originalY;
});
topologyPan.x = 0;
topologyPan.y = 0;
topologyPan.scale = 1;
updateTopologyLines();
};
const saveTopologyLayout = async () => {
if (!confirm('确定要保存当前站点坐标吗?这会覆盖所有站点的 svg_x 和 svg_y。')) return;
mutationBusy.value = true;
try {
// We need to update svg_x and svg_y for all stations that belong to each canonical group
const canonicals = {};
stations.value.forEach(s => {
const code = String(s.code || '').trim();
canonicals[code] = code;
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
const canonical = group[0];
group.forEach(g => canonicals[g] = canonical);
}
});
const updates = [];
for (const s of stations.value) {
const code = String(s.code || '').trim();
const canonical = canonicals[code] || code;
const node = topologyNodes.value.find(n => n.canonical === canonical);
if (node) {
updates.push({
...s,
svg_x: node.x,
svg_y: node.y
});
}
}
// Batch update one by one
for (const update of updates) {
await fetch(`/api/admin/stations/${encodeURIComponent(update.code)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(update)
});
}
alert('坐标保存成功!');
await fetchData();
initTopologyLayout(); // Re-initialize to update originalX/Y
} catch (e) {
alert('保存失败: ' + e.message);
} finally {
mutationBusy.value = false;
}
};
watch(currentView, (v) => {
sidebarOpen.value = false;
if (v === 'iccards') {
@@ -1552,6 +2084,7 @@ createApp({
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
issues: `当前共有 ${issues.value.length} 条问题记录`,
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
settings: '可维护优惠活动与导出数据',
logs: `当前筛选结果 ${logs.value.length} 条日志`
@@ -1637,17 +2170,18 @@ createApp({
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
orders, orderList, fetchOrders, deleteOrder,
issues, fetchIssues,
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
assetsManifest, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable,
assetsManifest, assetsForm, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable, saveRouteMapCredit,
// Management
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
visualLineViewport, lineViewportPan, lineEditorSvgWidth, startLineViewportPan, moveLineViewportPan,
selectLine, createLine, deleteLine, deleteStation, updateLineInfo, getFareText,
isStationInLine, addStationToLine, removeStationFromLine,
isStationInLine, addStationToLine, removeStationFromLine, removeStationFromLineIndex, addBreakpointToLine,
handleStationClick, isStationSelected,
onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex,
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
@@ -1658,7 +2192,12 @@ createApp({
saveCurrentFare, deleteCurrentFare, closeFareModal,
saveConfig, bumpLuaVersion, exportData, exportFareMap,
// Topology Editor
topologyPan, topologyNodes, topologyLines, topologyDraggingNode,
onTopologyCanvasMouseDown, onTopologyCanvasMouseMove, onTopologyCanvasMouseUp, onTopologyCanvasWheel, onTopologyNodeMouseDown,
resetTopologyLayout, saveTopologyLayout,
saveConfig, isPushingUpdate, pushLuaUpdate, exportData, exportFareMap,
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,
isTransferStation, getTransferTitleSuffix, getTransferLineBadges
-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=13" />
</head>
<body class="jr-admin-login-page">
<div class="jr-admin-login-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="/" class="jr-top-link">
<span>FSE铁路票务系统控制台</span>
</a>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="/" class="jr-brand">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE铁路票务系统控制台</strong>
<span>登录</span>
</div>
</a>
</div>
</div>
<main class="jr-admin-login-main">
<section class="jr-admin-login-panel">
<div class="jr-admin-login-copy">
<span class="jr-kicker">OPERATIONS ACCESS</span>
<h1>后台控制台</h1>
<p>线路维护、票务管理、日志查询与 IC 卡管理统一从·这里进入。</p>
<ul class="jr-admin-login-points">
<li>统一管理线路、票价和资源图文件</li>
<li>查看电子票、凭证与操作日志</li>
<li>维护 IC 卡发放、充值与状态记录</li>
</ul>
</div>
<section class="jr-admin-login-card">
<div class="jr-page-intro jr-page-intro-compact">
<span class="jr-kicker">SIGN IN</span>
<h2>控制台登录</h2>
<p>请输入管理员账号和密码。</p>
</div>
<div class="login-row"><input id="loginUser" type="text" placeholder="用户名" /></div>
<div class="login-row"><input id="loginPass" type="password" placeholder="密码" /></div>
<div class="login-actions">
<button id="loginBtn" class="btn primary">登录</button>
<span id="loginHint" class="hint"></span>
</div>
</section>
</section>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="login.js?v=2"></script>
</body>
</html>
+146
View File
@@ -0,0 +1,146 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>票务查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=22" />
</head>
<body class="public-search jr-public-page">
<div class="jr-public-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FarSight-T.N.E铁路运输</strong>
<span>票务查询</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
<a href="https://ticket.fse-media.group/order" data-link="order">线上预定</a>
<a href="https://ticket.fse-media.group/search" data-link="search" class="is-active">车票查询</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
</nav>
</div>
</div>
<main class="jr-public-main">
<section class="jr-page-intro">
<span class="jr-kicker">TICKET SEARCH</span>
<h1>按票号、站点或日期快速查询票据</h1>
<p>输入完整票号、起终点、日期或关键词,左侧浏览结果,右侧查看票据详情与最近流转记录。</p>
</section>
<section class="jr-panel-card" style="margin-bottom:24px;">
<div class="jr-panel-headline">
<h2>检索条件</h2>
<span class="jr-panel-note">Ticket ID / Station / Date</span>
</div>
<div class="jr-search-form">
<input id="q" class="jr-search-input" type="text"
placeholder="输入完整票号 / 起点 / 终点 / 日期 (YYYY-MM-DD)" />
<button id="searchBtn" class="btn primary jr-search-button"><i class="fas fa-search"></i>
立即搜索</button>
</div>
<p class="jr-search-helper">可直接输入完整票号,也可输入起点、终点或日期关键字进行模糊检索。</p>
</section>
<section class="jr-search-results">
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h3>结果列表</h3>
<span class="jr-panel-note">Search Results</span>
</div>
<div id="list" class="jr-scroll-box">
<div class="jr-center-empty">
<p>请输入关键词开始查询。</p>
</div>
</div>
</article>
<section id="detail-section" class="jr-detail-stack">
<article class="jr-panel-card" style="margin-bottom:20px;">
<div class="jr-panel-headline">
<h3>车票详情</h3>
<span class="jr-panel-note">Ticket Overview</span>
</div>
<div id="detail">
<div class="jr-center-empty">
<p>从左侧选择一张车票以查看详情。</p>
</div>
</div>
</article>
<div class="jr-grid-two">
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h3>热门站点</h3>
<span class="jr-panel-note">Popular Stations</span>
</div>
<div id="popularStations" class="jr-popular-list"></div>
</article>
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h3>热门路线</h3>
<span class="jr-panel-note">Popular Routes</span>
</div>
<div id="popularRoutes" class="jr-popular-list"></div>
</article>
</div>
</section>
</section>
<footer class="site-footer jr-footer-space">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-search.js?v=12"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html',
order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html',
search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html',
'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html',
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
};
const homeLink = document.getElementById('homeLink');
const brandLink = document.getElementById('brandLink');
if (homeLink) homeLink.href = links.home;
if (brandLink) brandLink.href = links.home;
document.querySelectorAll('[data-link]').forEach((el) => {
const key = el.getAttribute('data-link');
if (links[key]) el.href = links[key];
});
});
</script>
</body>
</html>
+528 -10
View File
@@ -1,3 +1,7 @@
[v-cloak] {
display: none !important;
}
:root {
--bg: #09090b;
--surface: #18181b;
@@ -251,7 +255,7 @@ button, .btn {
align-items: center;
justify-content: center;
padding: 8px 16px;
border-radius: 0;
border-radius: 0 !important;
border: 1px solid var(--border);
background-color: transparent;
color: var(--text);
@@ -477,6 +481,9 @@ tr:hover td {
.mobile-only {
display: none;
}
.mobile-only-inline {
display: none;
}
@media (max-width: 980px) {
:root { --sidebar-width: 260px; }
@@ -1432,22 +1439,24 @@ input[type="date"].input::-webkit-calendar-picker-indicator {
/* Station Map */
.station-map-container {
background: #00000033;
padding: 24px;
border-radius: 8px;
background: radial-gradient(circle at top left, rgba(255,255,255,0.78), rgba(248,250,252,0.95) 38%, rgba(241,245,249,0.98) 100%);
padding: 22px 24px 16px;
border-radius: 24px;
margin-bottom: 20px;
overflow-x: auto;
display: flex;
justify-content: center;
min-height: 380px;
align-items: center;
border: 1px solid rgba(148, 163, 184, 0.28);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.66);
}
.station-map-container svg {
min-width: 680px;
max-width: 100%;
height: auto;
transform: scale(1.06);
transform: scale(1.02);
transform-origin: center center;
}
@@ -1458,7 +1467,7 @@ input[type="date"].input::-webkit-calendar-picker-indicator {
.map-station.start .node-core { fill: var(--success) !important; r: 10; stroke: white; stroke-width: 3; }
.map-station.end .node-core { fill: var(--danger) !important; r: 10; stroke: white; stroke-width: 3; }
.map-station text { pointer-events: none; }
.map-station.transfer .node-core { stroke: #ffffffcc; stroke-width: 3; }
.map-station.transfer .node-core { stroke: #e2e8f0; stroke-width: 3; }
.map-station.route .node-core { stroke: var(--primary); stroke-width: 4; }
.map-station.route-transfer .node-core { stroke: #f59e0b; stroke-width: 4; }
@@ -1778,6 +1787,7 @@ body.jr-order-page .site-footer .version {
background: #ffffff;
border: 1px solid #d7e0d3;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
border-radius: 0;
}
.jr-hero-copy {
@@ -2052,6 +2062,7 @@ body.jr-order-page .site-footer .version {
background:
linear-gradient(180deg, #f7faf7 0, #edf4ed 100%);
border: 1px solid #d7e0d3;
border-radius: 0;
padding: 22px;
}
@@ -2350,7 +2361,7 @@ body.jr-order-page .map-station:hover .node-core {
}
body.jr-order-page .map-station .node-core {
stroke: #1d3326;
stroke: #334155;
}
body.jr-order-page .map-station.start .node-core {
@@ -2369,6 +2380,10 @@ body.jr-order-page .map-station.route-transfer .node-core {
stroke: #d69a0f;
}
body.jr-order-page .station-map-container text {
letter-spacing: 0.02em;
}
@media (max-width: 1080px) {
.jr-hero,
.jr-content-grid {
@@ -2435,11 +2450,14 @@ body.jr-order-page .map-station.route-transfer .node-core {
.jr-selection-summary {
grid-template-columns: 1fr;
gap: 8px;
margin-bottom: 16px;
}
.jr-selection-arrow {
min-height: 20px;
min-height: 12px;
transform: rotate(90deg);
font-size: 1rem;
}
.jr-form-grid,
@@ -2453,11 +2471,25 @@ body.jr-order-page .map-station.route-transfer .node-core {
}
.jr-station-map {
padding: 14px;
padding: 10px;
}
.jr-selection-card {
padding: 12px 14px;
}
.jr-selection-tag {
margin-bottom: 4px;
font-size: 0.76rem;
}
.jr-selection-card strong {
font-size: 1rem;
line-height: 1.35;
}
body.jr-order-page .station-map-container svg {
min-width: 620px;
min-width: 560px;
transform: none;
}
@@ -2719,6 +2751,25 @@ body.jr-public-page {
height: auto;
}
.jr-asset-credit {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
font-size: 0.82rem;
line-height: 1.5;
color: #6a786d;
}
.jr-asset-credit a {
color: #0b6b3a;
text-decoration: none;
}
.jr-asset-credit a:hover {
text-decoration: underline;
}
.jr-search-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -4193,6 +4244,123 @@ body.jr-admin-login-page {
gap: 16px;
}
.jr-issue-table {
width: 100%;
table-layout: fixed;
}
.jr-issue-table th:nth-child(1) {
width: 168px;
}
.jr-issue-table th:nth-child(2) {
width: 150px;
}
.jr-issue-table th:nth-child(3) {
width: 190px;
}
.jr-issue-table th:nth-child(4) {
width: 220px;
}
.jr-issue-table td {
vertical-align: top;
}
.jr-issue-primary {
font-weight: 700;
line-height: 1.45;
overflow-wrap: anywhere;
}
.jr-issue-sub {
margin-top: 4px;
font-size: 0.78rem;
color: #6d7f73;
overflow-wrap: anywhere;
}
.jr-issue-error {
color: #c43f3f;
font-weight: 700;
line-height: 1.6;
}
.jr-issue-detail-cell {
min-width: 0;
}
.jr-issue-detail-card,
.jr-issue-plain-detail {
border: 1px solid #dbe4d7;
background: #f8fbf7;
padding: 12px;
border-radius: 0;
}
.jr-issue-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
}
.jr-issue-detail-item {
border: 1px solid #dbe4d7;
background: #ffffff;
padding: 10px 12px;
min-width: 0;
}
.jr-issue-detail-item span {
display: block;
margin-bottom: 4px;
color: #6d7f73;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.jr-issue-detail-item strong {
display: block;
color: #183225;
font-size: 0.92rem;
line-height: 1.55;
font-weight: 600;
overflow-wrap: anywhere;
}
.jr-issue-raw-block {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #dbe4d7;
}
.jr-issue-raw-block summary {
cursor: pointer;
color: #607466;
font-size: 0.82rem;
font-weight: 700;
}
.jr-issue-raw-block pre,
.jr-issue-plain-detail pre {
margin: 8px 0 0;
padding: 12px;
border: 1px solid #1e293b;
background: #0f172a;
color: #e2e8f0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.82rem;
line-height: 1.5;
overflow: auto;
user-select: text;
-webkit-user-select: text;
}
.jr-admin-login-shell {
min-height: 100vh;
display: flex;
@@ -4485,3 +4653,353 @@ body.jr-admin-login-page {
width: 100%;
}
}
/* ========== Route 鬘オ髱「遘サ蜉ィ遶ッ荳馴。ケ騾る� ========== */
@media (max-width: 980px) {
body.jr-admin-route-page {
overflow: auto;
}
body.jr-admin-route-page #app {
min-height: auto;
height: auto;
}
.jr-admin-route-page .jr-admin-app {
min-height: auto;
border-radius: 0;
}
.jr-admin-route-page .jr-admin-main-shell {
width: min(100%, calc(100% - 24px));
padding: 12px 0 24px;
}
.jr-admin-route-page .jr-admin-app {
flex-direction: column;
}
.jr-admin-route-page .sidebar {
width: 86%;
max-width: 340px;
}
.jr-admin-route-page .jr-admin-header-side {
width: auto;
justify-content: flex-end;
}
}
@media (max-width: 768px) {
body.jr-admin-route-page .jr-topbar-inner {
min-height: 52px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 8px 0;
gap: 6px;
}
body.jr-admin-route-page .jr-top-status {
font-size: 0.8rem;
padding: 5px 10px;
}
body.jr-admin-route-page .jr-brandbar-inner {
min-height: auto;
padding: 14px 0;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
body.jr-admin-route-page .jr-nav {
width: 100%;
justify-content: flex-start;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
body.jr-admin-route-page .jr-nav a {
min-width: 88px;
padding: 8px 12px;
font-size: 0.85rem;
flex-shrink: 0;
}
.jr-admin-route-page .jr-admin-page .header,
.jr-admin-route-page .jr-admin-page .content {
padding-left: 14px;
padding-right: 14px;
}
.jr-admin-route-page .content {
padding: 14px;
}
.jr-admin-route-page .header {
padding: 12px 14px;
min-height: 60px;
flex-wrap: wrap;
gap: 10px;
}
.jr-admin-route-page .jr-page-intro {
padding: 18px 16px;
}
.jr-admin-route-page .jr-page-intro h1 {
font-size: clamp(1.4rem, 6vw, 2rem);
}
.jr-admin-route-page .jr-page-intro p {
font-size: 0.9rem;
line-height: 1.6;
}
.jr-admin-route-page .card {
padding: 14px;
margin-bottom: 14px;
}
.jr-admin-route-page .management-container {
flex-direction: column;
gap: 14px;
height: auto;
overflow: visible;
}
.jr-admin-route-page .management-sidebar {
width: 100%;
height: 380px;
max-height: 40vh;
}
.jr-admin-route-page .management-sidebar .card {
height: 100%;
overflow: hidden;
}
.jr-admin-route-page .management-main {
overflow: visible;
min-height: auto;
}
.jr-admin-route-page .management-main.is-empty {
display: none !important;
}
.desktop-only {
display: none !important;
}
.mobile-only-inline {
display: inline-block !important;
}
.jr-admin-route-page .editor-toolbar {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.jr-admin-route-page .editor-toolbar > .flex:first-child {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.jr-admin-route-page .switch-label {
width: 100%;
margin-left: 0 !important;
}
.jr-admin-route-page .hint-text {
margin-left: 0;
width: 100%;
word-break: break-word;
}
.jr-admin-route-page .editor-toolbar > .flex:last-child {
background: transparent !important;
padding: 0 !important;
flex-direction: column;
gap: 8px;
}
.jr-admin-route-page .editor-toolbar > .flex:last-child input,
.jr-admin-route-page .editor-toolbar > .flex:last-child button {
width: 100%;
}
.jr-admin-route-page .visual-line-container {
padding: 16px 10px;
min-height: 280px;
cursor: default;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
.jr-admin-route-page .visual-line-container svg {
min-width: 720px;
height: 220px;
}
.jr-admin-route-page .faremap-card .flex.between {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.jr-admin-route-page .faremap-card .flex.between > .flex:last-child {
width: 100%;
flex-wrap: wrap;
}
.jr-admin-route-page .faremap-card .flex.between > .flex:last-child button {
flex: 1;
min-width: 52px;
}
.jr-admin-route-page .faremap-viewport {
max-height: 60vh;
}
.jr-admin-route-page .faremap-canvas {
min-width: 100%;
}
.jr-admin-route-page .ticket-table,
.jr-admin-route-page table {
display: block;
overflow-x: auto;
white-space: nowrap;
font-size: 0.85rem;
}
.jr-admin-route-page .ticket-table th,
.jr-admin-route-page .ticket-table td,
.jr-admin-route-page table th,
.jr-admin-route-page table td {
padding: 10px 12px;
white-space: nowrap;
}
.jr-admin-route-page .modal-card {
width: 96%;
max-width: 96%;
padding: 18px 16px;
max-height: 92vh;
border-radius: 0;
}
.jr-admin-route-page .modal-title {
font-size: 1.05rem;
margin-bottom: 16px;
}
.jr-admin-route-page .modal-actions {
flex-direction: column-reverse;
margin-top: 20px;
gap: 8px;
}
.jr-admin-route-page .modal-actions button {
width: 100%;
min-height: 48px;
}
.jr-admin-route-page select[multiple] {
height: 160px;
font-size: 0.9rem;
}
.jr-admin-route-page button,
.jr-admin-route-page .btn {
min-height: 44px;
padding: 0 14px;
}
.jr-admin-route-page button.sm,
.jr-admin-route-page .btn.sm {
min-height: 36px;
min-width: 36px;
}
.jr-admin-route-page .icon-btn {
width: 44px;
height: 44px;
}
.jr-admin-route-page input,
.jr-admin-route-page select {
min-height: 44px;
font-size: 1rem;
}
.jr-admin-route-page .line-item {
padding: 14px 12px;
min-height: 56px;
}
.jr-admin-route-page .line-item .line-actions {
opacity: 1;
}
.jr-admin-route-page .line-item .line-actions button {
min-height: 36px;
min-width: 36px;
}
.jr-admin-route-page .site-footer {
margin-top: 20px;
padding-top: 12px;
font-size: 11px;
}
}
@media (max-width: 480px) {
.jr-admin-route-page .header h3 {
font-size: 1rem;
}
.jr-admin-route-page .jr-admin-header-pill {
font-size: 11px;
padding: 6px 10px;
}
.jr-admin-route-page .badge {
font-size: 0.7rem;
padding: 2px 5px;
}
.jr-admin-route-page h4 {
font-size: 0.95rem;
margin-bottom: 0.75rem;
}
.jr-admin-route-page h3 {
font-size: 1.1rem;
}
.jr-admin-route-page .jr-brand-copy strong {
font-size: 1rem;
}
.jr-admin-route-page .jr-brand-copy span {
font-size: 0.78rem;
}
.jr-admin-route-page .jr-brand-logo {
width: 40px;
height: 40px;
}
.jr-admin-route-page .faremap-viewport {
max-height: 50vh;
}
.jr-admin-route-page .visual-line-container svg {
min-width: 640px;
}
.jr-admin-route-page #app .ticket-header .flex.between:last-of-type {
flex-direction: column;
align-items: flex-start;
}
}
/* Static Map Overlays */
.map-station-overlay {
z-index: 10;
}
.map-station-overlay .station-hotspot {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: transparent;
border: 3px solid transparent;
transition: all 0.2s ease;
}
.map-station-overlay:hover .station-hotspot {
background-color: rgba(255, 255, 255, 0.5);
border-color: rgba(59, 130, 246, 0.8);
box-shadow: 0 0 8px rgba(0,0,0,0.3);
}
.map-station-overlay.start .station-hotspot {
background-color: var(--success);
border-color: white;
box-shadow: 0 0 0 2px var(--success);
}
.map-station-overlay.end .station-hotspot {
background-color: var(--danger);
border-color: white;
box-shadow: 0 0 0 2px var(--danger);
}
.map-station-overlay.route .station-hotspot {
background-color: var(--primary);
border-color: white;
}
.map-station-overlay.route-transfer .station-hotspot {
background-color: var(--warning);
border-color: white;
}
/* ヌソヨニヒ�ミーエナ・ネ・ウ�ヤイスヌ */
button, .btn, [class*="btn"], .jr-btn, .action-btn, input[type="button"], input[type="submit"], input[type="reset"] {
border-radius: 0 !important;
}
+91 -12
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
@@ -7,7 +7,7 @@
<title>电子客票|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=13">
<link rel="stylesheet" href="/style.css?v=22">
<style>
[v-cloak] {
display: none;
@@ -51,11 +51,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回查询</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
@@ -96,7 +92,15 @@
{{ statusInfo.text }}
</span>
</div>
<div class="jr-route-board">
<div v-if="String(ticket.overview.train_type || ticket.overview['车型'] || '').toLowerCase() === 'single'" class="jr-route-board" style="justify-content: center; text-align: center;">
<div class="jr-station-block">
<div class="jr-station-line" style="justify-content: center;">
<span class="jr-station-title">{{ ticket.overview.start_name }}</span>
</div>
<div class="jr-station-en" style="text-align: center; margin-top: 4px;">单程单区间</div>
</div>
</div>
<div v-else class="jr-route-board">
<div class="jr-station-block">
<div class="jr-station-line">
<span class="jr-station-title">{{ ticket.overview.start_name }}</span>
@@ -113,7 +117,18 @@
<div class="jr-station-en">{{ ticket.overview.terminal_en }}</div>
</div>
</div>
<div class="jr-meta-grid">
<div v-if="String(ticket.overview.train_type || ticket.overview['车型'] || '').toLowerCase() === 'single'" class="jr-meta-grid" style="grid-template-columns: repeat(2, 1fr);">
<div class="jr-meta-item">
<span>票价</span>
<strong>¥ {{ ticket.overview.amount || 0 }}</strong>
</div>
<div class="jr-meta-item">
<span>更新时间</span>
<strong>{{ formatTime(ticket.overview.last_update_ts) }}</strong>
</div>
</div>
<div v-else class="jr-meta-grid">
<div class="jr-meta-item">
<span>车型</span>
<strong>{{ formatTrainType(ticket.overview.train_type) }}</strong>
@@ -176,8 +191,8 @@
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
@@ -291,14 +306,78 @@
};
const formatEventMeta = (event) => {
if (!event) return '---';
const type = String(event.type || event['类型'] || '').toLowerCase();
const action = String(event.action || event['动作'] || '').toLowerCase();
if (type === 'sale' || type === '售票') {
const amount = event.amount ?? event['售票额'];
if (amount != null && amount !== '') return `票价:¥ ${amount}`;
}
const stationEn = event.station_en || event['站点英文'] || '';
const deviceId = event.device_id || event['设备编号'] || '';
let deviceId = String(event.device || event.device_id || event.detail?.device || event.detail?.device_id || event['设备编号'] || event['机器ID'] || '').trim();
const dStr = deviceId.toLowerCase();
// #region debug-point B:ticket-board-device-before-map
fetch('http://127.0.0.1:7777/event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: 'device-id-missing',
runId: 'pre-fix',
hypothesisId: 'B',
location: 'web/ticket-board.html:323',
msg: '[DEBUG] public ticket formatter raw device',
data: {
type,
action,
raw_device: event.device ?? null,
raw_device_id: event.device_id ?? null,
raw_detail_device: event.detail?.device ?? null,
resolved_before_map: deviceId
},
ts: Date.now()
})
}).catch(() => {});
// #endregion
if (dStr === 'gate') {
deviceId = '闸机';
} else if (dStr === 'refill_machine') {
deviceId = '充值机';
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
deviceId = '售票机';
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
deviceId = '闸机';
} else if (type === 'topup' || type === 'refill' || action === 'topup' || type === '充值') {
deviceId = '充值机';
} else {
deviceId = '售票机';
}
}
// #region debug-point B:ticket-board-device-after-map
fetch('http://127.0.0.1:7777/event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: 'device-id-missing',
runId: 'pre-fix',
hypothesisId: 'B',
location: 'web/ticket-board.html:345',
msg: '[DEBUG] public ticket formatter mapped device',
data: {
type,
action,
mapped_device: deviceId
},
ts: Date.now()
})
}).catch(() => {});
// #endregion
if (stationEn && deviceId) return `${stationEn} (${deviceId})`;
if (deviceId) return `设备:${deviceId}`;
return stationEn;
+6 -48
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
@@ -7,7 +7,7 @@
<title>线上预定|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=13">
<link rel="stylesheet" href="/style.css?v=22">
</head>
<body class="public-search jr-order-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
@@ -47,29 +43,6 @@
<section class="jr-content-grid">
<div class="jr-column-main">
<article class="jr-section-card" id="reservationPanel">
<div class="jr-form-block" style="margin-bottom: 24px;">
<div class="jr-field-head">
<span class="jr-field-label">票种</span>
<span class="jr-field-note">请选择购买的车票类型</span>
</div>
<div class="train-type-group jr-train-type-group">
<label>
<input type="radio" name="ticketClass" value="normal" checked>
<div class="type-card jr-type-card">
<div class="type-title">普通票</div>
<div class="type-desc">按起终点购票</div>
</div>
</label>
<label>
<input type="radio" name="ticketClass" value="single">
<div class="type-card jr-type-card">
<div class="type-title">单程票</div>
<div class="type-desc">限乘一个区间</div>
</div>
</label>
</div>
</div>
<div id="normalTicketView">
<div class="jr-section-head">
<div>
@@ -98,21 +71,6 @@
</div>
</div>
<div id="singleTicketView" style="display: none;">
<div class="jr-section-head">
<div>
<p class="jr-section-label">Single Trip</p>
<h2>选择单程区间</h2>
</div>
<p class="jr-section-note">根据线路和票价分类,购买后可乘坐该线路任意单一区间。</p>
</div>
<div id="singleSegmentList" class="list jr-price-list" style="max-height: 400px; overflow-y: auto;">
<div class="loading">加载区间数据...</div>
</div>
<input type="hidden" id="singleSegmentSelection" value="" />
<input type="hidden" id="singleSegmentPrice" value="" />
</div>
<div class="jr-form-grid" style="margin-top: 24px;">
<div class="jr-form-block">
<div class="jr-field-head">
@@ -195,9 +153,9 @@
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ticket-order.js?v=21"></script>
<script src="/public-status.js?v=13"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-order.js?v=31"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isRemote = location.hostname.includes('fse-media.group');
+359 -405
View File
@@ -1,4 +1,12 @@
(() => {
/*
* @Author: HenryDu8133 813367384@qq.com
* @Date: 2025-12-06 23:59:11
* @LastEditors: HenryDu8133 813367384@qq.com
* @LastEditTime: 2026-07-15 23:25:36
* @FilePath: \TicketMachine\web\ticket-order.js
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
(() => {
const $ = (sel) => document.querySelector(sel);
const fromEl = $('#from');
const toEl = $('#to');
@@ -19,7 +27,6 @@
}
};
// Fetch and Render Map
const mapContainer = $('#stationMap');
let selection = [null, null];
let currentRoute = [];
@@ -28,10 +35,17 @@
let stationEnByCode = {};
let stationCanonicalByCode = {};
let stationCodesByCanonical = {};
let stationXByCanonical = {};
let stationYByCanonical = {};
let stationTransfer = new Set();
let lineStops = [];
let lineColors = [];
let linesByStation = new Map();
let lineIdxsByCanonical = {};
let activeLineIdx = -1;
let routeMapUrl = '';
let routeMapCredit = '';
let routeMapLicense = 'CC BY 4.0';
const piePath = (cx, cy, r, a0, a1) => {
const x0 = cx + r * Math.cos(a0);
const y0 = cy + r * Math.sin(a0);
@@ -55,44 +69,281 @@
return out;
};
async function loadMap() {
try {
const res = await fetch('/api/public/fares/map/light');
const svg = await res.text();
mapContainer.innerHTML = svg;
const svgEl = mapContainer.querySelector('svg');
if(!svgEl) return;
const getContrastColor = (hexcolor) => {
if (!hexcolor) return '#ffffff';
let hex = hexcolor.replace('#', '');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
const r = parseInt(hex.substr(0,2),16) || 0;
const g = parseInt(hex.substr(2,2),16) || 0;
const b = parseInt(hex.substr(4,2),16) || 0;
const yiq = ((r*299)+(g*587)+(b*114))/1000;
return (yiq >= 140) ? '#0f172a' : '#ffffff';
};
renderLineMap();
} catch(e) {
mapContainer.innerHTML = '<div class="error">加载地图失败</div>';
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) {
const canonical = stationCanonicalByCode[code] || code;
const codes = stationCodesByCanonical[canonical] || [canonical];
const lineIdxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b);
const colors = lineIdxs.map(idx => lineColors[idx]).filter(Boolean);
const uniqueColors = Array.from(new Set(colors));
const primaryCode = codes[0];
const isTransfer = uniqueColors.length >= 2 || codes.length >= 2;
const name = getStationDisplayName(primaryCode);
const enName = stationEnByCode[primaryCode] || '';
const showEn = !!enName;
let markerSvg = '';
if (isTransfer) {
markerSvg += `<circle cx="${x}" cy="${y}" r="12" fill="#ffffff" stroke="#1f2937" stroke-width="2" />`;
markerSvg += pieSvg(x, y, 9, uniqueColors);
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="5" fill="#ffffff" stroke="#334155" stroke-width="2" />`;
} else {
const color = forceColor || uniqueColors[0] || '#93a2b7';
markerSvg += `<circle cx="${x}" cy="${y}" r="10" fill="${color}" stroke="${color}" stroke-width="2.5" />`;
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="5" fill="#ffffff" stroke="${color}" stroke-width="2" />`;
}
const preferTop = i % 2 === 0;
let labelSvg = '';
const codeFontSize = 8;
const nameFontSize = 11.5;
const enFontSize = 7.5;
const codeW = primaryCode.length * 5.5 + 8;
let textContent = '';
if (preferTop) {
const bottomY = y - 18;
const enY = bottomY;
const nameY = showEn ? bottomY - 10 : bottomY;
const codeY = nameY - 13;
textContent += `<rect x="${x - codeW / 2}" y="${codeY - 7}" width="${codeW}" height="10" rx="5" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" />`;
textContent += `<text x="${x}" y="${codeY + 1.5}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${primaryCode}</text>`;
textContent += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
if (showEn) {
textContent += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="2.5" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
}
} else {
const topY = y + 26;
const codeY = topY;
const nameY = topY + 13;
const enY = nameY + 10;
textContent += `<rect x="${x - codeW / 2}" y="${codeY - 7}" width="${codeW}" height="10" rx="5" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" />`;
textContent += `<text x="${x}" y="${codeY + 1.5}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${primaryCode}</text>`;
textContent += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
if (showEn) {
textContent += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="2.5" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
}
}
labelSvg += textContent;
let classList = 'map-station';
if (isTransfer) classList += ' transfer';
if (selection[0] && canonical === (stationCanonicalByCode[selection[0]] || selection[0])) classList += ' start';
if (selection[1] && canonical === (stationCanonicalByCode[selection[1]] || selection[1])) classList += ' end';
if (Array.isArray(currentRoute) && currentRoute.includes(primaryCode)) classList += ' route';
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.includes(primaryCode)) classList += ' route-transfer';
return `
<g class="${classList}" data-code="${primaryCode}" data-canonical="${canonical}" onclick="handleStationClick('${primaryCode}')" style="cursor:pointer">
<rect x="${x - 16}" y="${y - 16}" width="32" height="32" fill="transparent" />
${markerSvg}
${labelSvg}
</g>`;
}
async function renderLineMap() {
try {
const [lines, stations] = await Promise.all([
fetch('/api/public/lines').then(r=>r.json()),
fetch('/api/public/stations').then(r=>r.json())
]);
function renderTvmLines() {
const container = document.getElementById('tvmLines');
if (!container) return;
let html = '';
for (let i = 0; i < lineStops.length; i++) {
const li = lineStops[i];
const isActive = (i === activeLineIdx);
const bg = isActive ? li.color : '#f8fafc';
const fg = isActive ? getContrastColor(li.color) : '#334155';
const border = isActive ? li.color : '#cbd5e1';
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;
}
window.selectTvmLine = (idx) => {
activeLineIdx = (activeLineIdx === idx) ? -1 : idx;
renderTvmLines();
renderTvmSingleLine();
};
function renderTvmSingleLine() {
const container = document.getElementById('tvmSingleLine');
if (!container) return;
const li = lineStops[activeLineIdx];
if (!li) {
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;
}
const startX = 40;
const gapX = 65;
const breakGapX = 36;
const y = 60;
const pts = [];
const lineSegments = [];
let currentSegment = [];
let cursorX = startX;
for (let i = 0; i < li.stops.length; i++) {
const code = li.stops[i];
if (code === '-') {
if (currentSegment.length > 0) {
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) {
container.innerHTML = '<div style="color:#94a3b8; padding:20px;">该线路暂无站点</div>';
return;
}
let svgContent = '';
for (const segment of lineSegments) {
if (segment.length < 2) continue;
let d = `M ${segment[0].x} ${y}`;
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++) {
svgContent += renderStationNode(pts[i].x, pts[i].y, pts[i].code, i, li.color);
}
const width = Math.max(container.clientWidth || 800, cursorX);
const height = 120;
// 添加 tvm-animate 类实现动画过渡
container.innerHTML = `<svg class="tvm-animate" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="display:block;">${svgContent}</svg>`;
}
function renderTvmRoute() {
const container = document.getElementById('tvmRoute');
const title = document.getElementById('tvmRouteTitle');
if (!container) return;
if (!selection[0] || !selection[1] || !currentRoute || currentRoute.length < 2) {
container.style.display = 'none';
if (title) title.style.display = 'none';
container.innerHTML = '';
return;
}
const routeCodes = buildDisplayRouteCodes(currentRoute, selection[0], selection[1]);
if (routeCodes.length < 2) {
container.style.display = 'none';
if (title) title.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = 'flex';
if (title) title.style.display = 'block';
const startX = 40;
const gapX = 65;
const y = 60;
let svgContent = '';
const segments = [];
for (let i = 0; i < routeCodes.length - 1; i++) {
const c1 = routeCodes[i];
const c2 = routeCodes[i+1];
let html = '';
lines.forEach(line => {
const stList = line.站序 || [];
let color = '#93a2b7';
const can1 = stationCanonicalByCode[c1] || c1;
const can2 = stationCanonicalByCode[c2] || c2;
const canLines1 = Array.from(lineIdxsByCanonical[can1] || []);
const canLines2 = Array.from(lineIdxsByCanonical[can2] || []);
const canCommon = canLines1.filter(l => canLines2.includes(l));
if (canCommon.length > 0) {
color = lineColors[canCommon[0]];
}
segments.push({
x1: startX + i * gapX,
x2: startX + (i + 1) * gapX,
color
});
} catch(e) {}
}
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="butt" />`;
}
const pathD = `M ${startX} ${y} L ${startX + (routeCodes.length - 1) * gapX} ${y}`;
const totalDist = (routeCodes.length - 1) * gapX;
const duration = Math.max(1, totalDist / 150);
const numArrows = Math.max(1, Math.floor(duration / 0.8));
const interval = duration / numArrows;
for (let i = 0; i < numArrows; i++) {
svgContent += `
<g>
<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" />
</g>
`;
}
for (let i = 0; i < routeCodes.length; i++) {
svgContent += renderStationNode(startX + i * gapX, y, routeCodes[i], i);
}
const width = Math.max(container.clientWidth || 800, startX + routeCodes.length * gapX + 40);
const height = 120;
container.innerHTML = `<svg class="tvm-animate" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="display:block;">${svgContent}</svg>`;
}
async function renderSystemMap() {
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
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/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;
@@ -115,22 +366,31 @@
if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code));
}
const lineStops = [];
const lineColors = [];
const linesByStation = new Map();
lineStops = [];
lineColors = [];
linesByStation = new Map();
lineIdxsByCanonical = {};
let currentLineIdx = 0;
for (let i = 0; i < linesData.length; i++) {
const line = linesData[i] || {};
const color = line.color || line.颜色 || '#93a2b7';
const stopsRaw = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []);
const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean);
if (stops.length === 0) continue;
lineStops.push({ idx: lineStops.length, name: line.name || line.线路名称 || '', color, stops });
const stopCodes = stops.filter(stopCode => stopCode !== '-');
if (stopCodes.length === 0) continue;
lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', enName: line.en_name || line.英文名称 || line.英文名 || '', color, stops });
lineColors.push(color);
for (const c of stops) {
const arr = linesByStation.get(c) || [];
arr.push(lineStops.length - 1);
linesByStation.set(c, arr);
for (const stopCode of stopCodes) {
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);
}
currentLineIdx++;
}
if (lineStops.length === 0) {
@@ -142,182 +402,72 @@
const uniq = Array.from(new Set(arr));
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';
// Build SVG
const legendW = 210;
const legendX = 16;
const legendSwatchW = 22;
const legendTextX = legendX + legendSwatchW + 10;
const startX = legendW + 90;
const baseY = 44;
const lineGapY = 92;
const minGapX = 78;
const fadeStyle = `<style>
@keyframes tvmFadeIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
.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::-webkit-scrollbar { height: 4px; }
.tvm-scroll-container::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 0; }
.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>`;
const occurrences = {};
for (const li of lineStops) {
for (let i = 0; i < li.stops.length; i++) {
const code = li.stops[i];
const canonical = stationCanonicalByCode[code] || code;
if (!occurrences[canonical]) occurrences[canonical] = [];
occurrences[canonical].push(i * minGapX);
}
}
stationXByCanonical = {};
for (const canonical of Object.keys(occurrences)) {
const arr = occurrences[canonical];
const avg = arr.reduce((a,b)=>a+b,0) / Math.max(1, arr.length);
stationXByCanonical[canonical] = startX + Math.round(avg);
}
for (let pass = 0; pass < 3; pass++) {
for (const li of lineStops) {
let prevX = startX - minGapX;
for (const code of li.stops) {
const canonical = stationCanonicalByCode[code] || code;
const x = stationXByCanonical[canonical] ?? (prevX + minGapX);
const nx = Math.max(x, prevX + minGapX);
if (stationXByCanonical[canonical] == null || nx > stationXByCanonical[canonical]) stationXByCanonical[canonical] = nx;
prevX = stationXByCanonical[canonical];
}
}
}
const primaryLineByStation = {};
for (const [c, arr] of linesByStation.entries()) {
const uniq = Array.from(new Set(arr)).sort((a,b)=>a-b);
if (uniq.length === 0) continue;
primaryLineByStation[c] = uniq[0];
}
stationYByCanonical = {};
const ysByCanonical = {};
for (const c of Object.keys(primaryLineByStation)) {
const li = primaryLineByStation[c];
if (li == null) continue;
const canonical = stationCanonicalByCode[c] || c;
if (!ysByCanonical[canonical]) ysByCanonical[canonical] = [];
ysByCanonical[canonical].push(baseY + li * lineGapY);
}
mapContainer.innerHTML = `
${fadeStyle}
<div class="tvm-layout">
<div class="tvm-lines" id="tvmLines"></div>
<div class="tvm-single-line tvm-scroll-container" id="tvmSingleLine"></div>
<div class="tvm-route-title" id="tvmRouteTitle">已选路径</div>
<div class="tvm-route tvm-scroll-container" id="tvmRoute" style="border-top: 1.5px dashed #cbd5e1; display: none;"></div>
</div>
`;
const canonicalY = {};
for (const canonical of Object.keys(ysByCanonical)) {
const ys = ysByCanonical[canonical];
canonicalY[canonical] = ys.reduce((a,b) => a+b, 0) / ys.length;
}
for (const c of Object.keys(primaryLineByStation)) {
const canonical = stationCanonicalByCode[c] || c;
stationYByCanonical[c] = canonicalY[canonical];
}
const allStations = Object.keys(primaryLineByStation);
if (allStations.length === 0) {
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>线路数据为空,请稍后再试。</p></div>';
return;
}
const labelShownForCanonical = new Set();
const transferColorsByStation = {};
const primaryColorByStation = {};
for (const c of allStations) {
const lineIdxs = Array.from(new Set(linesByStation.get(c) || [])).sort((a,b)=>a-b);
const colors = lineIdxs.map(i => lineStops[i]?.color).filter(Boolean);
transferColorsByStation[c] = Array.from(new Set(colors));
primaryColorByStation[c] = colors[0] || '#93a2b7';
}
let svgContent = '';
for (const li of lineStops) {
const yLine = baseY + li.idx * lineGapY;
svgContent += `<rect x="${legendX}" y="${yLine-10}" width="${legendSwatchW}" height="10" rx="5" fill="${li.color}" />`;
svgContent += `<text x="${legendTextX}" y="${yLine-1}" fill="#e5e7eb" font-size="16" font-weight="800">${li.name}</text>`;
if (li.stops.length >= 2) {
const pts = li.stops.map(code => {
const canonical = stationCanonicalByCode[code] || code;
return {
x: stationXByCanonical[canonical] || 0,
y: stationYByCanonical[code] ?? yLine
};
});
let d = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 1; i < pts.length; i++) {
d += ` L ${pts[i].x} ${pts[i].y}`;
}
svgContent += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
}
}
for (const canonical of Object.keys(stationCodesByCanonical)) {
const codes = (stationCodesByCanonical[canonical] || []).filter(code => Number.isFinite(stationYByCanonical[code]));
if (codes.length < 2) continue;
const x = stationXByCanonical[canonical];
const y = stationYByCanonical[codes[0]];
const labelY = y + 34;
svgContent += `<circle cx="${x}" cy="${y}" r="7" fill="#ffffff" stroke="#111827" stroke-width="3" />`;
svgContent += `<rect x="${x - 42}" y="${labelY - 16}" width="84" height="24" rx="12" fill="rgba(241,245,249,0.92)" stroke="#cbd5e1" stroke-width="1.5" />`;
svgContent += `<text x="${x}" y="${labelY}" text-anchor="middle" fill="#334155" font-size="13" font-weight="800">${getStationDisplayName(canonical)}</text>`;
labelShownForCanonical.add(canonical);
}
for (const c of allStations) {
const canonical = stationCanonicalByCode[c] || c;
const x = stationXByCanonical[canonical];
const yNode = stationYByCanonical[c] ?? baseY;
const name = stationNameByCode[c] || c;
const isTransfer = stationTransfer.has(c);
const cls = isTransfer ? 'map-station transfer' : 'map-station';
const ringSvg = '';
const primaryColor = primaryColorByStation[c] || '#93a2b7';
const outer = isTransfer
? `<circle cx="${x}" cy="${yNode}" r="13" fill="#ffffff" stroke="${primaryColor}" stroke-width="5" />`
: `<circle cx="${x}" cy="${yNode}" r="12" fill="#ffffff" stroke="${primaryColor}" stroke-width="5" />`;
const transferFill = isTransfer ? `<circle cx="${x}" cy="${yNode}" r="7" fill="#ffffff" stroke="#111827" stroke-width="3" />` : '';
const coreFill = isTransfer ? '#ffffff' : '#ffffff';
const showLabel = !isTransfer || !labelShownForCanonical.has(canonical);
const textSvg = showLabel
? `<text x="${x}" y="${yNode+28}" text-anchor="middle" fill="#e5e7eb" font-size="14" font-weight="700">${name}</text>`
: '';
svgContent += `
<g class="${cls}" data-code="${c}" onclick="handleStationClick('${c}')" style="cursor:pointer">
${ringSvg}
${outer}
${transferFill}
<circle class="node-core" cx="${x}" cy="${yNode}" r="8" fill="${coreFill}" stroke="#111827" stroke-width="3" />
${textSvg}
</g>
`;
}
const width = Math.max(560, Math.max(...allStations.map(c => stationXByCanonical[stationCanonicalByCode[c] || c] || 0)) + 120);
const height = Math.max(260, baseY + lineStops.length * lineGapY + 60);
mapContainer.innerHTML = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${svgContent}</svg>`;
// Bind click events (standard onclick attribute in SVG string might not work in some contexts, but usually fine in innerHTML)
// Better to add event listeners via delegation
renderTvmLines();
renderTvmSingleLine();
} catch(e) {
console.error(e);
mapContainer.innerHTML = '<div class="error">地图加载失败</div>';
mapContainer.innerHTML = '<div class="error">数据加载失败</div>';
}
}
window.handleStationClick = (code) => {
code = String(code || '').trim();
// Toggle if already selected
if (selection[0] === code) {
selection[0] = null;
} else if (selection[1] === code) {
selection[1] = null;
} else {
// Add new selection
if (!selection[0]) {
selection[0] = code;
} else if (!selection[1]) {
selection[1] = code;
} else {
// When both ends are already selected, start a new selection flow.
selection[0] = code;
selection[1] = null;
currentRoute = [];
@@ -325,7 +475,6 @@
}
}
// Ensure no gaps (if start removed, move end to start)
if (!selection[0] && selection[1]) {
selection[0] = selection[1];
selection[1] = null;
@@ -333,7 +482,7 @@
updateSelectionUI();
};
function normalizeTrips() {
const raw = Number(tripsEl?.value || 1);
const trips = Number.isFinite(raw) ? Math.max(1, Math.floor(raw)) : 1;
@@ -427,69 +576,15 @@
return merged;
}
function getStationPoint(code) {
const normalized = String(code || '').trim();
if (!normalized) return null;
const canonical = stationCanonicalByCode[normalized] || normalized;
const x = stationXByCanonical[canonical];
const y = stationYByCanonical[normalized];
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
}
function renderRouteOverlay() {
const svgEl = mapContainer.querySelector('svg');
if (!svgEl) return;
const existing = svgEl.querySelector('.route-overlay-group');
if (existing) existing.remove();
if (!Array.isArray(currentRoute) || currentRoute.length < 2) return;
const points = currentRoute.map(getStationPoint).filter(Boolean);
if (points.length < 2) return;
const ns = 'http://www.w3.org/2000/svg';
const group = document.createElementNS(ns, 'g');
group.setAttribute('class', 'route-overlay-group');
const pathData = points.map((pt, idx) => `${idx === 0 ? 'M' : 'L'} ${pt.x} ${pt.y}`).join(' ');
const glow = document.createElementNS(ns, 'path');
glow.setAttribute('d', pathData);
glow.setAttribute('fill', 'none');
glow.setAttribute('stroke', 'rgba(250, 204, 21, 0.38)');
glow.setAttribute('stroke-width', '18');
glow.setAttribute('stroke-linecap', 'round');
glow.setAttribute('stroke-linejoin', 'round');
const main = document.createElementNS(ns, 'path');
main.setAttribute('d', pathData);
main.setAttribute('fill', 'none');
main.setAttribute('stroke', '#facc15');
main.setAttribute('stroke-width', '8');
main.setAttribute('stroke-linecap', 'round');
main.setAttribute('stroke-linejoin', 'round');
group.appendChild(glow);
group.appendChild(main);
const firstStation = svgEl.querySelector('.map-station');
if (firstStation) svgEl.insertBefore(group, firstStation);
else svgEl.appendChild(group);
}
function updateSelectionUI(skipPreview = false) {
if (!(selection[0] && selection[1])) {
currentRoute = [];
currentRouteTransfers = [];
}
// Update Inputs
fromEl.value = selection[0] || '';
toEl.value = selection[1] || '';
// Update Displays with Chinese names
const getName = (code) => {
return stationNameByCode[String(code || '').trim()] || code;
};
@@ -497,75 +592,29 @@
$('#fromDisplay').textContent = selection[0] ? getName(selection[0]) : '请在上方地图选择';
$('#toDisplay').textContent = selection[1] ? getName(selection[1]) : '请在上方地图选择';
// Update Map Styles
document.querySelectorAll('.map-station').forEach(el => {
const code = el.getAttribute('data-code');
const canonical = el.getAttribute('data-canonical');
el.classList.remove('start', 'end', 'selected', 'route', 'route-transfer');
if(code === selection[0]) el.classList.add('start');
if(code === selection[1]) el.classList.add('end');
if (selection[0] && canonical === (stationCanonicalByCode[selection[0]] || selection[0])) el.classList.add('start');
if (selection[1] && canonical === (stationCanonicalByCode[selection[1]] || selection[1])) el.classList.add('end');
if (Array.isArray(currentRoute) && currentRoute.includes(code)) el.classList.add('route');
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.includes(code)) el.classList.add('route-transfer');
});
if (Array.isArray(currentRoute) && currentRoute.length > 0) {
for (const c of currentRoute) {
const el = document.querySelector(`.map-station[data-code="${c}"]`);
if (el) el.classList.add('route');
}
}
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.length > 0) {
for (const c of currentRouteTransfers) {
const el = document.querySelector(`.map-station[data-code="${c}"]`);
if (el) el.classList.add('route-transfer');
}
}
renderTvmRoute();
renderRouteOverlay();
// Auto preview if both selected
if(!skipPreview && selection[0] && selection[1]) previewPrice();
}
// Load Map on Start
renderSystemMap();
async function previewPrice(force = false){
const seq = ++previewSeq;
const ticketClass = document.querySelector('input[name="ticketClass"]:checked').value;
const type = document.querySelector('input[name="trainType"]:checked').value;
const trips = normalizeTrips();
if (ticketClass === 'single') {
const lineName = document.getElementById('singleSegmentSelection').value;
const priceStr = document.getElementById('singleSegmentPrice').value;
if (!lineName || !priceStr) {
priceBox.innerHTML = '<div class="empty-state jr-empty-state"><p>请选择单程区间</p></div>';
return;
}
const cost = Number(priceStr) * trips;
priceBox.innerHTML = `
<div class="list-item jr-result-row">
<span class="k">线路</span>
<span class="v" style="font-weight:700; color:#0ea5e9;">${lineName}</span>
</div>
<div class="list-item jr-result-row">
<span class="k">区间</span>
<span class="v">任意单区间</span>
</div>
<div class="list-item jr-result-row">
<span class="k">车型</span>
<span class="v">${type}</span>
</div>
<div class="list-item jr-result-row">
<span class="k">单价</span>
<span class="v">$${priceStr}</span>
</div>
<div class="list-item jr-result-row">
<span class="k">总价</span>
<span class="v" style="font-size:18px; font-weight:800; color:#0ea5e9;">$${cost}</span>
</div>
`;
return;
}
const from = (fromEl.value||'').trim();
const to = (toEl.value||'').trim();
if(!from || !to){ priceBox.innerHTML = '<div class="empty-state jr-empty-state"><p>请选择起点与终点</p></div>'; return; }
@@ -641,37 +690,23 @@
}
}
// Create Order with auto-preview
async function createOrder(){
const ticketClass = document.querySelector('input[name="ticketClass"]:checked').value;
const type = document.querySelector('input[name="trainType"]:checked').value;
const trips = normalizeTrips();
const ride_date = new Date().toISOString().slice(0, 10);
let payload = null;
if (ticketClass === 'single') {
const lineName = document.getElementById('singleSegmentSelection').value;
const priceStr = document.getElementById('singleSegmentPrice').value;
if (!lineName || !priceStr) {
alert('请选择单程区间');
return;
}
payload = { start: lineName, terminal: 'SINGLE_SEGMENT', train_type: 'single', price: Number(priceStr), trips, ride_date };
} else {
const from = (fromEl.value||'').trim();
const to = (toEl.value||'').trim();
if(!from || !to){ alert('请完整填写信息'); return; }
// Auto-fetch price before creating
await previewPrice(true);
payload = { start: from, terminal: to, train_type: type, trips, ride_date };
}
const from = (fromEl.value||'').trim();
const to = (toEl.value||'').trim();
if(!from || !to){ alert('请完整填写信息'); return; }
await previewPrice(true);
payload = { start: from, terminal: to, train_type: type, trips, ride_date };
try{
const res = await fetch('/api/public/orders', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(payload) });
const r = await res.json();
if(r && r.ok){
// Link to external subdomain or local page
const buildTokenLink = (code) => {
if (location.hostname.includes('fse-media.group')) {
return `https://ticket.fse-media.group/token?code=${encodeURIComponent(code)}`;
@@ -700,93 +735,12 @@
const btnCreate = $('#createOrder');
if(btnCreate) btnCreate.onclick = createOrder;
// Listen for Type change to auto-update price
document.querySelectorAll('input[name="trainType"]').forEach(el => {
el.onchange = () => { if(fromEl.value && toEl.value) previewPrice(); };
});
document.querySelectorAll('input[name="ticketClass"]').forEach(r => r.addEventListener('change', toggleTicketClass));
if (tripsEl) {
tripsEl.oninput = () => { if(fromEl.value && toEl.value) previewPrice(); };
tripsEl.onchange = () => { normalizeTrips(); if(fromEl.value && toEl.value) previewPrice(); };
}
function toggleTicketClass() {
const isSingle = document.querySelector('input[name="ticketClass"]:checked').value === 'single';
if (isSingle) {
document.getElementById('normalTicketView').style.display = 'none';
document.getElementById('singleTicketView').style.display = 'block';
document.getElementById('tripsBlock').style.display = 'none';
if (tripsEl) tripsEl.value = 1;
renderSingleSegments();
} else {
document.getElementById('normalTicketView').style.display = 'block';
document.getElementById('singleTicketView').style.display = 'none';
document.getElementById('tripsBlock').style.display = 'block';
}
previewPrice(true);
}
async function renderSingleSegments() {
const listEl = document.getElementById('singleSegmentList');
if (listEl.innerHTML.includes('jr-result-row')) return; // Already rendered
listEl.innerHTML = '<div class="loading">加载区间数据...</div>';
try {
const [lines, fares] = await Promise.all([
fetch('/api/public/lines').then(r=>r.json()),
fetch('/api/public/fares').then(r=>r.json())
]);
const type = document.querySelector('input[name="trainType"]:checked').value;
const segments = [];
lines.forEach(line => {
const stops = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []);
const lineFares = new Set();
for (let i = 0; i < stops.length - 1; i++) {
const a = stops[i], b = stops[i+1];
const f = fares.find(x => (x.from === a && x.to === b) || (x.from === b && x.to === a));
if (f) {
const cost = type === 'Express' ? (f.cost_express ?? f.cost ?? 0) : (f.cost_regular ?? f.cost ?? 0);
lineFares.add(Number(cost));
}
}
lineFares.forEach(cost => {
segments.push({
lineName: line.name || line.线路名称 || '未知线路',
color: line.color || line.颜色 || '#93a2b7',
cost
});
});
});
if (segments.length === 0) {
listEl.innerHTML = '<div class="empty-state jr-empty-state"><p>暂无单程区间数据</p></div>';
return;
}
let html = '';
segments.forEach((seg, idx) => {
html += `
<label class="list-item jr-result-row" style="cursor: pointer; display: flex; align-items: center; gap: 12px; padding: 12px;">
<input type="radio" name="singleSegmentOption" value="${idx}" onchange="selectSingleSegment('${seg.lineName}', ${seg.cost})" style="margin: 0;">
<div style="width: 12px; height: 12px; border-radius: 50%; background-color: ${seg.color};"></div>
<div style="flex: 1;">
<div style="font-weight: bold; color: #1e293b;">${seg.lineName}</div>
<div style="font-size: 12px; color: #64748b;">单区间票</div>
</div>
<div style="font-weight: bold; color: #0ea5e9;">$${seg.cost}</div>
</label>
`;
});
listEl.innerHTML = html;
} catch (e) {
listEl.innerHTML = '<div class="error">加载区间数据失败</div>';
}
}
window.selectSingleSegment = (lineName, cost) => {
document.getElementById('singleSegmentSelection').value = lineName;
document.getElementById('singleSegmentPrice').value = cost;
previewPrice(true);
};
})();
+191 -198
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<!-- 充满未知和不稳定的票务系统! -->
@@ -8,7 +8,7 @@
<title>线路规划|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=13">
<link rel="stylesheet" href="/style.css?v=22">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
@@ -21,11 +21,7 @@
<i class="fas fa-train"></i>
<span>FSE铁路运输票务系统</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
@@ -49,7 +45,7 @@
</div>
<main class="jr-public-main jr-admin-main-shell">
<div id="app" class="jr-admin-app">
<div id="app" class="jr-admin-app" v-cloak>
<div class="sidebar" :class="{ open: sidebarOpen }">
<div class="jr-admin-sidebar-head">
<span class="jr-kicker">ROUTE PLANNING</span>
@@ -64,6 +60,10 @@
@click="currentView = 'management'">
<span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路规划
</div>
<div class="nav-item" :class="{active: currentView === 'stations'}"
@click="currentView = 'stations'">
<span class="nav-icon"><i class="fas fa-map-marker-alt"></i></span> 站点管理
</div>
<div class="nav-item" :class="{active: currentView === 'faremap'}" @click="currentView = 'faremap'">
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
</div>
@@ -100,10 +100,7 @@
</div>
</div>
<div class="jr-admin-header-side">
<span class="jr-admin-header-pill" :class="connected ? 'is-online' : 'is-offline'">
<i class="fas fa-circle"></i>
{{ connected ? '服务器在线' : '服务器离线' }}
</span>
</div>
</div>
@@ -159,7 +156,7 @@
</div>
</div>
<!--右侧面板-->
<div class="management-main">
<div class="management-main" :class="{'is-empty': !selectedLine}">
<div class="card mb-4">
<div class="flex between">
<div v-if="selectedLine">
@@ -174,7 +171,8 @@
</div>
</div>
<div v-else>
<h4>选择左侧线路进行管理</h4>
<h4 class="desktop-only">选择左侧线路进行管理</h4>
<h4 class="mobile-only-inline">请选择上方线路进行管理</h4>
</div>
<div class="flex">
@@ -223,8 +221,13 @@
</div>
<!-- 可视化线路编辑-->
<div class="visual-line-container">
<svg width="100%" height="200"
<div class="visual-line-container" ref="visualLineContainer"
:class="{ 'is-panning': isPanning }"
@mousedown="onPanStart"
@mousemove="onPanMove"
@mouseup="onPanEnd"
@mouseleave="onPanEnd">
<svg :style="{ minWidth: '100%', width: Math.max(100, 50 + selectedLine.stations.length * 120 + 50) + 'px' }" height="200"
v-if="selectedLine.stations && selectedLine.stations.length > 0">
<!--站点连接线-->
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100"
@@ -239,8 +242,10 @@
<!--车站节点-->
<g v-for="(sCode, index) in selectedLine.stations" :key="sCode"
@mousedown="onStationDragStart(index)" @mouseup="onStationDrop"
@mousemove="onStationDragOver(index)" @click="handleStationClick(sCode)"
@mousedown="onStationDragStart(index, $event)"
@mouseup="onStationDrop"
@click="handleStationClick(sCode)"
@touchstart.passive="onStationTouchStart(index, $event)"
class="station-node" :class="{
'selected': isStationSelected(sCode),
'fare-source': fareSelection[0] === sCode,
@@ -284,7 +289,6 @@
</div>
</div>
<!-- 票价设置弹窗 -->
<div v-if="showFareModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">设置票价</h4>
@@ -384,9 +388,72 @@
</div>
</div>
<div v-if="currentView === 'stations'" class="card">
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 12px;">
<h4 style="margin: 0;">站点总览 ({{ stations.length }})</h4>
<div class="flex" style="gap: 12px; flex-wrap: wrap;">
<div class="search-box">
<i class="fas fa-search"></i>
<input v-model="stationSearch" placeholder="搜索站点编号/名称..." style="width: 200px;">
</div>
<button @click="showAddStation = true"><i class="fas fa-plus"></i> 新增站点</button>
</div>
</div>
<div v-if="showAddStation" class="mb-4" style="background: rgba(255,255,255,0.05); padding: 15px; border-radius: 8px;">
<h5 style="margin-top:0; margin-bottom:12px;">创建新站点</h5>
<div class="flex" style="flex-wrap: wrap; gap: 12px;">
<input v-model="newStation.code" placeholder="站点编号 (必填)" style="flex: 1; min-width: 120px;">
<input v-model="newStation.name" placeholder="中文名称 (必填)" style="flex: 1; min-width: 120px;">
<input v-model="newStation.en_name" placeholder="英文名称" style="flex: 1; min-width: 120px;">
<button @click="addStationToLine" :disabled="!newStation.code || !newStation.name" style="min-width: 80px;">创建</button>
<button class="danger" @click="showAddStation = false" style="min-width: 80px;">取消</button>
</div>
<p class="text-muted mt-2" style="font-size: 0.85rem; margin-bottom: 0;"><i class="fas fa-info-circle"></i> 在此创建的站点将被保存到数据库中,可以在线路规划中添加到任意线路。</p>
</div>
<div class="table-responsive">
<table class="ticket-table" style="width: 100%;">
<thead>
<tr>
<th>站点编号</th>
<th>中文名称</th>
<th>英文名称</th>
<th>所属线路</th>
<th>换乘支持</th>
<th style="text-align: right;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="s in stationList" :key="s.code">
<td class="mono" style="font-weight:bold;">{{ s.code }}</td>
<td>{{ s.name || s.cn_name || '-' }}</td>
<td>{{ s.en_name || s.enName || '-' }}</td>
<td>
<div class="flex" style="gap: 4px; flex-wrap: wrap;">
<span v-for="l in getStationLines(s.code)" :key="l.id" class="badge" :style="{background: l.color, color: '#fff', border: 'none'}">{{ l.id }}</span>
<span v-if="getStationLines(s.code).length === 0" class="text-muted" style="font-size: 0.85em;">暂无线路</span>
</div>
</td>
<td>
<span v-if="s.transfer_enabled" class="badge" style="background: #10b981; color: white;">是</span>
<span v-else class="text-muted">-</span>
</td>
<td style="text-align: right;">
<button class="sm" @click="editStationGlobal(s.code)" title="编辑"><i class="fas fa-pen"></i></button>
<button class="danger sm" @click="deleteStationGlobal(s.code)" title="删除"><i class="fas fa-trash"></i></button>
</td>
</tr>
<tr v-if="stationList.length === 0">
<td colspan="6" class="text-center text-muted" style="padding: 30px;">无匹配站点</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 票价地图 -->
<div v-if="currentView === 'faremap'">
<div class="card faremap-card">
<div v-if="currentView === 'faremap'" class="card faremap-card">
<div class="flex between mb-4">
<h4>票价地图</h4>
<div class="flex" style="flex-wrap: wrap; gap: 8px;">
@@ -404,187 +471,112 @@
v-html="fareMapSvg">
</div>
</div>
</div>
</div>
<!-- 凭证管理 -->
<div v-if="currentView === 'vouchers'">
<div class="card">
<div class="flex between mb-4">
<h4>凭证列表</h4>
<div class="flex">
<button @click="fetchOrders"><i class="fas fa-sync-alt"></i></button>
<!-- 提取出来的弹窗,避免被 currentView = 'management' 屏蔽 -->
<div v-if="showFareModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">设置票价</h4>
<div class="mb-4 text-center">
<div class="flex between"
style="justify-content: center; gap: 20px; font-size: 1.1em; font-weight: bold;">
<span>{{ getStationName(fareSelection[0]) }}</span>
<i class="fas fa-arrow-right text-muted"></i>
<span>{{ getStationName(fareSelection[1]) }}</span>
</div>
</div>
<table class="ticket-table">
<thead>
<tr>
<th>凭证</th>
<th>线路</th>
<th>车型</th>
<th>票价</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orderList" :key="o.code">
<td class="mono" style="font-weight:bold; font-size:1.1em;">{{ o.code }}</td>
<td>{{ o.start_name }} <i class="fas fa-arrow-right text-muted"></i> {{
o.terminal_name }}</td>
<td>{{ formatTrainType(o.train_type) }}</td>
<td>{{ o.price }}</td>
<td><span class="badge" :class="formatTicketStatus(o.status).class">{{
formatTicketStatus(o.status).text }}</span></td>
<td>{{ formatTime(o.created_ts) }}</td>
<td>
<div class="flex" style="gap:4px;">
<a :href="'token.html?code='+o.code" target="_blank" class="btn sm"
title="查看"
style="height:28px; width:28px; padding:0; display:flex; align-items:center; justify-content:center;"><i
class="fas fa-eye"></i></a>
<button class="danger sm" @click="deleteOrder(o.code)"
style="height:28px; width:28px; padding:0; display:flex; align-items:center; justify-content:center;"><i
class="fas fa-trash"></i></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 车票记录 -->
<div v-if="currentView === 'tickets'">
<div class="card mb-4">
<div class="flex between mb-4">
<h4>车票记录</h4>
<div class="flex">
<input v-model="ticketSearch" placeholder="搜索 Ticket ID / 站点" style="width: 200px;">
<button @click="refreshData"><i class="fas fa-sync-alt"></i></button>
</div>
</div>
<table class="ticket-table">
<thead>
<tr>
<th>ID</th>
<th>起点</th>
<th>终点</th>
<th>类型</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<tr v-for="t in ticketList" :key="t.ticket_id" @click="viewTicketDetails(t)"
class="clickable-row">
<td><span class="mono">{{ t.ticket_id }}</span></td>
<td>
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ getStationInfo(t.start).name }}</span>
<span class="st-code">{{ t.start }}</span>
</div>
<div class="st-en">{{ getStationInfo(t.start).en_name }}</div>
</div>
</td>
<td>
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ getStationInfo(t.terminal).name }}</span>
<span class="st-code">{{ t.terminal }}</span>
</div>
<div class="st-en">{{ getStationInfo(t.terminal).en_name }}</div>
</div>
</td>
<td>{{ formatTrainType(t.type) }}</td>
<td><span class="badge" :class="formatTicketStatus(t.status).class">{{
formatTicketStatus(t.status).text }}</span></td>
<td>{{ formatTime(t.ts) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 车票详情弹窗 -->
<div v-if="showTicketModal" class="modal show" @click.self="closeTicketModal">
<div class="modal-card" style="width: 600px;">
<div class="flex between mb-4">
<h4 class="modal-title">车票详情</h4>
<button class="sm" @click="closeTicketModal" title="关闭"><i
class="fas fa-times"></i></button>
</div>
<div v-if="selectedTicket">
<div class="ticket-header mb-4"
style="background: rgba(255,255,255,0.05); padding: 16px; border-radius: 8px;">
<div class="flex between mb-2">
<span class="mono text-muted">{{ selectedTicket.ticket_id }}</span>
<span class="badge"
:class="formatTicketStatus(selectedTicket.index.status).class">
{{ formatTicketStatus(selectedTicket.index.status).text }}
</span>
</div>
<div class="flex between" style="font-size: 1.2rem; font-weight: bold;">
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ selectedTicket.index.start_name ||
selectedTicket.index.start }}</span>
<span class="st-code">{{ selectedTicket.index.start }}</span>
</div>
<div class="st-en">{{ selectedTicket.index.start_en || '' }}</div>
</div>
<i class="fas fa-arrow-right text-muted"></i>
<div class="st-container" style="align-items: flex-end;">
<div class="st-main-row">
<span class="st-name">{{ selectedTicket.index.terminal_name ||
selectedTicket.index.terminal }}</span>
<span class="st-code">{{ selectedTicket.index.terminal }}</span>
</div>
<div class="st-en">{{ selectedTicket.index.terminal_en || '' }}</div>
</div>
</div>
<div class="text-muted mt-2" style="font-size: 0.9rem;">
类型: {{ formatTrainType(selectedTicket.index.type ||
selectedTicket.index.train_type) }} | 票价: {{ selectedTicket.index.price ||
selectedTicket.index.cost }}
</div>
</div>
<h5>行程记录</h5>
<div class="timeline">
<div v-for="ev in selectedTicket.events" :key="ev.ts || ev['时间戳']" class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<div class="flex between">
<span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span>
</div>
<div class="text-muted" style="font-size: 0.9rem; margin-top: 4px;">
{{ formatTicketEventUnified(ev) }}
</div>
</div>
</div>
</div>
</div>
<div v-else class="loading">加载中...</div>
</div>
</div>
</div>
<!-- 设置 -->
<div v-if="currentView === 'settings'">
<div class="card mb-4">
<h4>优惠设置</h4>
<div class="mb-4">
<label style="display:block; margin-bottom:8px; font-weight:600;">优惠活动</label>
<div class="flex">
<input v-model="config.promotion.name" placeholder="活动名称">
<input v-model.number="config.promotion.discount" type="number" step="0.1"
placeholder="折扣 (0.1-1.0)">
<button @click="saveConfig"><i class="fas fa-save"></i> 保存</button>
<label>常规票价</label>
<input v-model.number="currentFare.cost_regular" type="number" class="w-100">
</div>
<div class="mb-4">
<label>特急票价</label>
<input v-model.number="currentFare.cost_express" type="number" class="w-100">
</div>
<div class="modal-actions">
<button class="danger" @click="deleteCurrentFare"
v-if="currentFare.exists">删除</button>
<button @click="saveCurrentFare">保存</button>
<button class="danger" @click="closeFareModal">取消</button>
</div>
</div>
</div>
<div v-if="showStationModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">站点编辑</h4>
<div class="mb-4">
<label>站点编号</label>
<input v-model="stationForm.code" class="w-100">
</div>
<div class="mb-4">
<label>中文名</label>
<input v-model="stationForm.name" class="w-100">
</div>
<div class="mb-4">
<label>英文名</label>
<input v-model="stationForm.en_name" class="w-100">
</div>
<div class="mb-4">
<label>所属线路</label>
<select v-model="stationForm.lines" multiple class="w-100" style="height: 100px;">
<option v-for="l in lines" :key="l.id" :value="l.id">
{{ l.name || l.id }} ({{ l.id }})
</option>
</select>
<p class="text-muted mt-1" style="font-size: 0.8em;">按住 Ctrl / Command 多选,保存后会自动同步到线路图中。</p>
</div>
<div class="mb-4">
<label class="switch-label">
<input type="checkbox" v-model="stationForm.transfer_enabled">
<span class="slider"></span>
<span class="label-text">可换乘</span>
</label>
</div>
<div class="mb-4">
<label>可换乘到的站点</label>
<select v-model="stationForm.transfer_to" multiple class="w-100"
:disabled="!stationForm.transfer_enabled" style="height: 180px;">
<option v-for="t in transferTargets" :key="t.code" :value="t.code">
{{ t.name }} ({{ t.en_name }}) - {{ t.code }}
</option>
</select>
</div>
<div class="modal-actions">
<button class="danger" @click="deleteStation(stationFormOriginalCode)">删除</button>
<button @click="saveStationSettings">保存</button>
<button class="danger" @click="closeStationModal">取消</button>
</div>
</div>
</div>
<div v-if="showLineModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">线路编辑</h4>
<div class="mb-4">
<label>线路编号</label>
<input v-model="lineForm.id" class="w-100">
</div>
<div class="mb-4">
<label>中文名</label>
<input v-model="lineForm.name" class="w-100">
</div>
<div class="mb-4">
<label>英文名</label>
<input v-model="lineForm.en_name" class="w-100">
</div>
<div class="mb-4">
<label>颜色</label>
<div class="flex" style="gap:8px;">
<input type="text" v-model="lineForm.color" class="w-100" placeholder="#3366cc">
<input type="color" v-model="lineForm.color" title="选择颜色"
style="width: 48px; padding: 0; border: none; height: 32px;">
</div>
</div>
<div class="modal-actions">
<button @click="saveLineSettings">保存</button>
<button class="danger" @click="closeLineModal">取消</button>
</div>
</div>
</div>
@@ -593,14 +585,15 @@
<span class="version">v1.0.12</span>
</footer>
</div>
</div>
</div>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="ticket-route.js?v=3"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script src="ticket-route.js?v=8"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+209 -53
View File
@@ -15,17 +15,16 @@ createApp({
const sidebarOpen = ref(false);
const viewTitle = computed(() => {
const map = {
dashboard: '仪表盘',
management: '线路与票价管理',
tickets: '车票记录',
settings: '系统设置'
stations: '站点管理',
faremap: '票价地图'
};
return map[currentView.value] || '线路规划系统';
});
const connected = ref(false);
// Keep the legacy route console usable behind proxies that only allow polling.
const socket = io({ transports: ['polling', 'websocket'], timeout: 20000 });
// 强制使用 WebSocket,避免轮询 (polling) 在负载均衡/反代下产生 400 Bad Request
const socket = io({ transports: ['websocket'], timeout: 20000 });
const stations = ref([]);
@@ -228,7 +227,22 @@ createApp({
const formatTicketEventUnified = (ev) => {
const type = getTicketEventType(ev);
const action = getTicketEventAction(ev);
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
let deviceId = String(ev?.device || ev?.device_id || ev?.detail?.device || ev?.detail?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---').trim();
const dStr = deviceId.toLowerCase();
if (dStr === 'gate') {
deviceId = '闸机';
} else if (dStr === 'refill_machine') {
deviceId = '充值机';
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
deviceId = '售票机';
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
deviceId = '闸机';
} else {
deviceId = '售票机';
}
}
const timeStr = formatTime(ev?.ts || ev?.["时间戳"]);
if (type === 'sale' || type === '售票') {
@@ -385,24 +399,38 @@ createApp({
selectedTicket.value = null;
};
/* 拖动调整站序 */
const onStationDragStart = (index) => {
if (fareMode.value) return;
draggingStationIndex.value = index;
/* 拖动调整站序 - 统一实现(鼠标+触摸) */
const dragActiveSvg = ref(null);
const computeStationIndexFromClientXY = (clientX, clientY) => {
const svgEl = dragActiveSvg.value;
if (!svgEl || !selectedLine.value) return -1;
const pt = svgEl.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const ctm = svgEl.getScreenCTM();
if (!ctm) return -1;
const loc = pt.matrixTransform(ctm.inverse());
return Math.max(0, Math.min(
(selectedLine.value.stations?.length || 1) - 1,
Math.round((loc.x - 50) / 120)
));
};
const onStationDragOver = (index) => {
if (draggingStationIndex.value === null) return;
if (draggingStationIndex.value === index) return;
const list = selectedLine.value.stations;
const temp = list[draggingStationIndex.value];
list.splice(draggingStationIndex.value, 1);
list.splice(index, 0, temp);
draggingStationIndex.value = index;
const swapStationByIndex = (targetIndex) => {
if (draggingStationIndex.value === null) return false;
if (targetIndex < 0) return false;
if (draggingStationIndex.value === targetIndex) return false;
const list = selectedLine.value.stations;
if (!list) return false;
const temp = list[draggingStationIndex.value];
list.splice(draggingStationIndex.value, 1);
list.splice(targetIndex, 0, temp);
draggingStationIndex.value = targetIndex;
return true;
};
const onStationDrop = async () => {
const saveDragResult = async () => {
if (draggingStationIndex.value === null) return;
try {
await updateLineStations(selectedLine.value.id, selectedLine.value.stations);
@@ -411,6 +439,88 @@ createApp({
await fetchData();
}
draggingStationIndex.value = null;
dragActiveSvg.value = null;
};
/* 鼠标事件 */
const onStationDragStart = (index, event) => {
if (fareMode.value) return;
draggingStationIndex.value = index;
const svgEl = (event.currentTarget && event.currentTarget.closest)
? event.currentTarget.closest('svg')
: null;
dragActiveSvg.value = svgEl || document.querySelector('.visual-line-container svg');
};
const onStationDragOver = (index) => {
swapStationByIndex(index);
};
const onStationDrop = async () => {
await saveDragResult();
};
const globalMouseMoveHandler = (e) => {
if (draggingStationIndex.value === null) return;
const targetIndex = computeStationIndexFromClientXY(e.clientX, e.clientY);
swapStationByIndex(targetIndex);
};
const globalMouseUpHandler = async () => {
if (draggingStationIndex.value === null) return;
await saveDragResult();
};
/* 触摸事件 - 移动端拖拽支持 */
const onStationTouchStart = (index, event) => {
if (fareMode.value) return;
if (!event.touches || event.touches.length !== 1) return;
draggingStationIndex.value = index;
const svgEl = (event.currentTarget && event.currentTarget.closest)
? event.currentTarget.closest('svg')
: null;
dragActiveSvg.value = svgEl || document.querySelector('.visual-line-container svg');
};
const globalTouchMoveHandler = (e) => {
if (draggingStationIndex.value === null) return;
if (!e.touches || e.touches.length !== 1) return;
if (e.cancelable) e.preventDefault();
const t = e.touches[0];
const targetIndex = computeStationIndexFromClientXY(t.clientX, t.clientY);
swapStationByIndex(targetIndex);
};
const globalTouchEndHandler = async () => {
if (draggingStationIndex.value === null) return;
await saveDragResult();
};
/* 画布平移*/
const visualLineContainer = ref(null);
const isPanning = ref(false);
const panStartX = ref(0);
const panScrollLeft = ref(0);
const onPanStart = (e) => {
if (e.button !== 0) return;
if (e.target.closest && e.target.closest('.station-node')) return;
isPanning.value = true;
panStartX.value = e.clientX;
if (visualLineContainer.value) {
panScrollLeft.value = visualLineContainer.value.scrollLeft;
}
};
const onPanMove = (e) => {
if (!isPanning.value || !visualLineContainer.value) return;
e.preventDefault();
const dx = e.clientX - panStartX.value;
visualLineContainer.value.scrollLeft = panScrollLeft.value - dx;
};
const onPanEnd = () => {
isPanning.value = false;
};
/* 订单管理 */
@@ -539,6 +649,7 @@ createApp({
})) return;
await runMutation(async () => {
await requestJson(`/api/stations/${encodeURIComponent(code)}`, { method: 'DELETE' }, { expectOk: true });
await fetchData();
});
};
@@ -580,7 +691,6 @@ createApp({
};
const addStationToLine = async () => {
if (!selectedLine.value) return;
const { code, name, en_name } = newStation;
if (!code || !name) return alert('请填写编号和名称');
@@ -595,12 +705,18 @@ createApp({
}, { expectOk: true });
}
if (isStationInLine(code)) throw new Error('该站点已在此线路中');
const newStations = [...(selectedLine.value.stations || [])];
newStations.push(code);
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
if (selectedLine.value) {
if (isStationInLine(code)) throw new Error('该站点已在此线路中');
const newStations = [...(selectedLine.value.stations || [])];
newStations.push(code);
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
}
Object.assign(newStation, { code: '', name: '', en_name: '' });
if (currentView.value === 'stations') {
showAddStation.value = false;
}
await fetchData();
});
};
@@ -633,6 +749,10 @@ createApp({
stationForm.en_name = s.en_name || s.enName || '';
stationForm.transfer_enabled = !!s.transfer_enabled;
stationForm.transfer_to = Array.isArray(s.transfer_to) ? [...s.transfer_to] : [];
// 找出该站点当前所属的线路
stationForm.lines = lines.value.filter(l => l.stations && l.stations.includes(s.code || code)).map(l => l.id);
showStationModal.value = true;
};
@@ -661,8 +781,36 @@ createApp({
transfer_to: stationForm.transfer_enabled ? (Array.isArray(stationForm.transfer_to) ? stationForm.transfer_to : []) : []
};
await runMutation(async () => {
// 1. 保存站点本身信息
await requestJson(`/api/stations/${encodeURIComponent(oldCode)}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true });
// 2. 更新所属线路
const newLinesIds = Array.isArray(stationForm.lines) ? stationForm.lines : [];
for (const line of lines.value) {
const wasInLine = line.stations && line.stations.includes(oldCode);
const shouldBeInLine = newLinesIds.includes(line.id);
let lineStations = [...(line.stations || [])];
let changed = false;
if (wasInLine && !shouldBeInLine) {
lineStations = lineStations.filter(c => c !== oldCode);
changed = true;
} else if (!wasInLine && shouldBeInLine) {
lineStations.push(newCode);
changed = true;
} else if (wasInLine && shouldBeInLine && oldCode !== newCode) {
lineStations = lineStations.map(c => c === oldCode ? newCode : c);
changed = true;
}
if (changed) {
await updateLineStations(line.id, lineStations, { skipFetchData: true });
}
}
showStationModal.value = false;
await fetchData();
});
};
@@ -874,42 +1022,47 @@ createApp({
});
watch(currentView, () => { sidebarOpen.value = false; });
/* 失败 */
onMounted(() => {
fetchData();
loadFareMap();
window.addEventListener('mouseup', async () => {
if (draggingStationIndex.value !== null) {
if (selectedLine.value) {
try {
await requestJson(`/api/lines/${encodeURIComponent(selectedLine.value.id)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ ...(lines.value.find(l => l.id === selectedLine.value.id) || selectedLine.value), stations: selectedLine.value.stations })
}, { expectOk: true });
await fetchData();
} catch (e) {
alert(`保存站序失败:${e?.message || String(e)}`);
await fetchData();
}
}
draggingStationIndex.value = null;
}
});
window.addEventListener('mousemove', globalMouseMoveHandler);
window.addEventListener('mouseup', globalMouseUpHandler);
window.addEventListener('touchmove', globalTouchMoveHandler, { passive: false });
window.addEventListener('touchend', globalTouchEndHandler);
window.addEventListener('touchcancel', globalTouchEndHandler);
});
/* 计算 */
const recentLogs = computed(() => logs.value);
const orderList = computed(() => orders.value);
const stationSearch = ref('');
const stationList = computed(() => {
if (!stationSearch.value) return stations.value;
const q = stationSearch.value.toLowerCase();
return stations.value.filter(s =>
(s.code && s.code.toLowerCase().includes(q)) ||
(s.name && s.name.toLowerCase().includes(q)) ||
(s.en_name && s.en_name.toLowerCase().includes(q))
);
});
const getStationLines = (code) => {
return lines.value.filter(l => l.stations && l.stations.includes(code));
};
const editStationGlobal = (code) => {
stationEditMode.value = true;
openStationModal(code);
// reset mode when modal closes is already handled? Wait, we need to ensure stationEditMode state isn't breaking anything, but openStationModal is generic.
};
const deleteStationGlobal = async (code) => {
await deleteStation(code);
};
const ticketList = computed(() => {
if (!ticketSearch.value) return tickets.value.slice(0, 50);
const q = ticketSearch.value.toLowerCase();
return tickets.value.filter(t =>
t.ticket_id.toLowerCase().includes(q) ||
(t.start && t.start.toLowerCase().includes(q)) ||
(t.terminal && t.terminal.toLowerCase().includes(q))
).slice(0, 50);
return [];
});
const exportFareMap = () => {
@@ -961,9 +1114,12 @@ createApp({
isStationInLine, addStationToLine, removeStationFromLine,
handleStationClick, isStationSelected,
onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex,
onStationTouchStart,
visualLineContainer, isPanning, onPanStart, onPanMove, onPanEnd,
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,
stationSearch, stationList, getStationLines, editStationGlobal, deleteStationGlobal,
/* 订单 */
fetchOrders, deleteOrder,
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventUnified, formatLogType, formatTrainType,
+5 -9
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
@@ -7,7 +7,7 @@
<title>票务查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14" />
<link rel="stylesheet" href="/style.css?v=22" />
</head>
<body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
@@ -120,9 +116,9 @@
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-search.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+83 -25
View File
@@ -110,7 +110,22 @@
const formatTicketEventUnified = (ev) => {
const type = getEventType(ev);
const action = String(ev.action || ev["动作"] || '').toLowerCase();
const deviceId = ev.device || ev.device_id || ev["设备编号"] || ev["机器ID"] || '---';
let deviceId = String(ev.device || ev.device_id || ev.detail?.device || ev.detail?.device_id || ev["设备编号"] || ev["机器ID"] || '---').trim();
const dStr = deviceId.toLowerCase();
if (dStr === 'gate') {
deviceId = '闸机';
} else if (dStr === 'refill_machine') {
deviceId = '充值机';
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
deviceId = '售票机';
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
deviceId = '闸机';
} else {
deviceId = '售票机';
}
}
const timeStr = formatTime(ev.ts || ev["时间戳"]);
if (type === 'sale' || type === '售票') {
@@ -151,6 +166,9 @@
const overview = it.overview || it["概览"] || null;
const startName = overview ? (overview.start_name || overview["起点"]) : (it.start_name || it["起点"] || '---');
const terminalName = overview ? (overview.terminal_name || overview["终点"]) : (it.terminal_name || it["终点"] || '---');
const trainType = overview ? (overview.train_type || overview["车型"]) : (it.train_type || it["车型"]);
const isSingle = String(trainType || '').toLowerCase() === 'single';
const updateTime = formatTime(
(overview && (overview.last_update_ts || overview["上次更新时间"])) ||
it.last_update_ts ||
@@ -158,13 +176,20 @@
''
);
let routeHtml = '';
if (isSingle) {
routeHtml = `${escapeHtml(startName)} <span style="font-size: 0.8em; margin-left: 8px; color: #666; font-weight: normal;">单程单区间</span>`;
} else {
routeHtml = `${escapeHtml(startName)} → ${escapeHtml(terminalName)}`;
}
row.innerHTML = `
<div class="jr-ticket-row-head">
<span class="jr-ticket-id mono">${escapeHtml(id)}</span>
<span class="jr-status-pill ${isValidStatus(statusText) ? 'jr-status-valid' : (statusText === '已使用' ? 'jr-status-used' : 'jr-status-expired')}">${escapeHtml(statusText)}</span>
</div>
<div class="jr-ticket-route">
${escapeHtml(startName)} → ${escapeHtml(terminalName)}
${routeHtml}
</div>
<div class="jr-list-meta">最近更新 ${escapeHtml(updateTime)}</div>
`;
@@ -192,6 +217,60 @@
const stRaw = ov.status || ov["状态"] || d.status || d["状态"] || '';
const statusText = formatStatusText(stRaw);
const statusClass = isValidStatus(stRaw) ? 'jr-status-valid' : (statusText === '已使用' ? 'jr-status-used' : 'jr-status-expired');
const isSingle = String(ov.train_type || ov["车型"] || '').toLowerCase() === 'single';
let routeBoardHtml = '';
if (isSingle) {
routeBoardHtml = `
<div class="jr-route-board" style="justify-content: center; text-align: center;">
<div class="jr-station-block">
<div class="jr-station-line" style="justify-content: center;">
<span class="jr-station-title">${escapeHtml(ov.start_name || ov["起点"] || '---')}</span>
</div>
<div class="jr-station-en" style="text-align: center; margin-top: 4px;">单程单区间</div>
</div>
</div>
`;
} else {
routeBoardHtml = `
<div class="jr-route-board">
<div class="jr-station-block">
<div class="jr-station-line">
<span class="jr-station-title">${escapeHtml(ov.start_name || ov["起点"] || '---')}</span>
<span class="jr-station-code">${escapeHtml(ov.start_code || ov["起点编号"] || '')}</span>
</div>
<div class="jr-station-en">${escapeHtml(ov.start_en || ov["起点英文"] || '')}</div>
</div>
<div class="jr-route-track"><i class="fas fa-train"></i></div>
<div class="jr-station-block is-end">
<div class="jr-station-line">
<span class="jr-station-title">${escapeHtml(ov.terminal_name || ov["终点"] || '---')}</span>
<span class="jr-station-code">${escapeHtml(ov.terminal_code || ov["终点编号"] || '')}</span>
</div>
<div class="jr-station-en">${escapeHtml(ov.terminal_en || ov["终点英文"] || '')}</div>
</div>
</div>
`;
}
let metaGridHtml = '';
if (isSingle) {
metaGridHtml = `
<div class="jr-meta-grid" style="grid-template-columns: repeat(2, 1fr);">
<div class="jr-meta-item"><span>票价</span><strong>${escapeHtml(ov.amount ?? ov["金额"] ?? 0)}</strong></div>
<div class="jr-meta-item"><span>更新</span><strong>${escapeHtml(formatTime(ov.last_update_ts ?? ov["上次更新时间"]))}</strong></div>
</div>
`;
} else {
metaGridHtml = `
<div class="jr-meta-grid">
<div class="jr-meta-item"><span>车型</span><strong>${escapeHtml(formatTrainType(ov.train_type || ov["车型"]))}</strong></div>
<div class="jr-meta-item"><span>乘次</span><strong>${escapeHtml(ov.trips_total ?? ov["总乘次"] ?? 0)}</strong></div>
<div class="jr-meta-item"><span>票价</span><strong>${escapeHtml(ov.amount ?? ov["金额"] ?? 0)}</strong></div>
<div class="jr-meta-item"><span>更新</span><strong>${escapeHtml(formatTime(ov.last_update_ts ?? ov["上次更新时间"]))}</strong></div>
</div>
`;
}
detailEl.innerHTML = `
<div class="jr-ticket-preview" onclick="openTicketDetail('${id}')" style="cursor:pointer;" title="点击查看电子票看板">
@@ -199,29 +278,8 @@
<span class="jr-ticket-id mono">${id} <i class="fas fa-external-link-alt" style="font-size:0.8em; margin-left:4px"></i></span>
<span class="jr-status-pill ${statusClass}">${statusText}</span>
</div>
<div class="jr-route-board">
<div class="jr-station-block">
<div class="jr-station-line">
<span class="jr-station-title">${ov.start_name || ov["起点"] || '---'}</span>
<span class="jr-station-code">${ov.start_code || ov["起点编号"] || ''}</span>
</div>
<div class="jr-station-en">${ov.start_en || ov["起点英文"] || ''}</div>
</div>
<div class="jr-route-track"><i class="fas fa-train"></i></div>
<div class="jr-station-block is-end">
<div class="jr-station-line">
<span class="jr-station-title">${ov.terminal_name || ov["终点"] || '---'}</span>
<span class="jr-station-code">${ov.terminal_code || ov["终点编号"] || ''}</span>
</div>
<div class="jr-station-en">${ov.terminal_en || ov["终点英文"] || ''}</div>
</div>
</div>
<div class="jr-meta-grid">
<div class="jr-meta-item"><span>车型</span><strong>${formatTrainType(ov.train_type || ov["车型"])}</strong></div>
<div class="jr-meta-item"><span>乘次</span><strong>${ov.trips_total ?? ov["总乘次"] ?? 0}</strong></div>
<div class="jr-meta-item"><span>票价</span><strong>${ov.amount ?? ov["金额"] ?? 0}</strong></div>
<div class="jr-meta-item"><span>更新</span><strong>${formatTime(ov.last_update_ts ?? ov["上次更新时间"])}</strong></div>
</div>
${routeBoardHtml}
${metaGridHtml}
</div>
<div class="jr-panel-headline" style="margin:20px 0 14px;">
+5 -9
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
@@ -7,7 +7,7 @@
<title>凭证详情|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=13" />
<link rel="stylesheet" href="/style.css?v=22" />
</head>
<body class="public-search jr-public-page">
@@ -18,11 +18,7 @@
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
@@ -125,9 +121,9 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/token.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script src="/public-status.js?v=14"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html', order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html', search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html', 'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html', 'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'