Compare commits

..

10 Commits

Author SHA1 Message Date
Henry_Du b614ff663c chore(web): 移除过时的socket调试与服务器状态监控代码
移除了登录页与后台管理页的服务器状态展示UI、public-status.js脚本引用,删除了index.js中的socket运行时日志上报逻辑与连接状态追踪代码,同时删除了用于排查socket polling 400问题的调试文档。
2026-06-21 16:11:54 +08:00
Henry_Du e78557f335 perf(web): 切换Vue CDN为生产优化版本
更新所有Web页面的Vue脚本引用为生产压缩版本以提升客户端加载性能,同时修复index.html中DOCTYPE行的微小格式问题
2026-06-21 15:44:42 +08:00
Henry_Du 2ddcd18e1e feat(socket): 添加 Socket 运行时调试日志及轮询 400 错误调试文档
- 更新 web/index.html 中 index.js 的资源版本为 v6 以清除旧缓存
- 在 web/index.js 中新增 Socket 运行时日志上报逻辑,捕获并上报连接、断开、错误及重连事件
- 新增调试文档记录生产环境 Socket.IO polling 400 错误的问题、现象与排查计划
2026-06-21 15:39:59 +08:00
Henry_Du b1cb84f736 feat(管理后台): 新增线路编辑器拖拽平移并修复代理下Socket连接问题
调整socket.io传输顺序优先使用轮询以适配代理服务器,新增可视化线路编辑器拖拽平移功能,修复多处CSS布局问题并更新静态资源缓存版本。
2026-06-21 11:21:09 +08:00
Henry_Du 7fea8807b8 feat(web,installer): 更新下载源、升级资源缓存版本、本地化界面并新增管理功能
- 更新 update_machine.lua 和 installer.lua 中的远程资源下载地址,从旧云存储链接切换为 Gitea 仓库提交镜像地址
- 新增双向闸机专用安装脚本 installer_bi.lua
- 为所有网页HTML文件更新静态资源的缓存版本号,避免浏览器加载过期的静态文件缓存
- 修复登录页面的乱码文本,替换为标准简体中文内容,修正ICP备案标识文本
- 新增管理后台概览板块、快捷操作按钮,优化IC卡管理界面与响应式布局样式
2026-06-21 10:37:25 +08:00
Henry_Du 108435e90d Merge branch 'main' of http://192.140.163.241:3000/Henry_Du/FSE-Ticket.sys 2026-06-21 10:19:34 +08:00
Henry_Du ea5c0a0d5a feat: 新增 error 和 pass 两个 DFPWM 二进制文件 2026-06-21 10:18:04 +08:00
Henry_Du db1562b830 删除 IC储蓄卡功能开发.md 2026-06-21 10:04:43 +08:00
Henry_Du d35ae5e75b 删除 gate.lua 2026-06-21 10:04:32 +08:00
Henry_Du 585e498235 删除 installer_bi.lua 2026-06-21 10:04:26 +08:00
43 changed files with 781 additions and 1736 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -1,5 +1,5 @@
local URL_ERROR = "http://cloud.fse-media.group/d/API/TicketMachine/error.dfpwm?sign=DvQ39wQDN6Cej4KPhAkM3JyXPM466koan6w9CckPxWU=:0"
local URL_PASS = "http://cloud.fse-media.group/d/API/TicketMachine/pass.dfpwm?sign=ZJfGDYnaxQU4JrGSh4NDzKZtjq3eMjYh9KHmMSAMKZA=:0"
local URL_ERROR = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/src/commit/108435e90d81c1c0a6e34486dee6cc6efd48ca53/error.dfpwm"
local URL_PASS = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/src/commit/108435e90d81c1c0a6e34486dee6cc6efd48ca53/pass.dfpwm"
local URL_GATE = "http://cloud.fse-media.group/d/API/TicketMachine/gate.lua?sign=OWy1wKkKhUhpnxXKeX6fRLePSg1XcaQgWOLvQbMuHRQ=:0"
local CONFIG_PATH = "gate_config.json"
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
local URL_MACHINE = "http://cloud.fse-media.group/d/API/TicketMachine/ticketmachine.lua?sign=UIiheDcpyzwKdovDZM3G-8IRZqApFgU2Kpnhe9k5ETQ=:0"
local URL_MACHINE = "http://gitea.fse-media.group/Henry_Du/FSE-Ticket.sys/src/commit/db1562b83045284bfdec9e4a3feb829193963943/ticketmachine.lua"
local function writeFile(path, content, binary)
local mode = binary and "wb" or "w"
+3 -2
View File
@@ -6,7 +6,7 @@
<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=12">
<link rel="stylesheet" href="style.css?v=13">
<link rel="stylesheet" href="blog.css?v=2">
</head>
<body class="public-search">
@@ -59,7 +59,8 @@ FMG
<span class="version">v1.0.12</span>
</footer>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="blog.js?v=2"></script>
</body>
</html>
+3 -2
View File
@@ -6,7 +6,7 @@
<title>FSE 铁路票务系统 - 首页</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="public-search jr-public-page">
<div class="jr-public-shell">
@@ -181,7 +181,7 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>
@@ -247,3 +247,4 @@
+3 -2
View File
@@ -7,7 +7,7 @@
<title>FSE 閾佽矾绁ㄥ姟绯荤粺 - IC 鍗$鐞?/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=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="jr-admin-page jr-admin-ic-page jr-public-page">
@@ -180,7 +180,7 @@
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<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>
@@ -206,3 +206,4 @@
</body>
</html>
+3 -2
View File
@@ -7,7 +7,7 @@
<title>IC 卡详情</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=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="public-search jr-public-page">
@@ -99,7 +99,7 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ic-card-detail.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
@@ -110,3 +110,4 @@
</body>
</html>
+3 -2
View File
@@ -7,7 +7,7 @@
<title>IC 鍗$嚎涓婅喘鍗?/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=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="public-search jr-public-page">
@@ -117,7 +117,7 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<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="/ai-assistant.js?v=6"></script>
@@ -129,3 +129,4 @@
</body>
</html>
+3 -2
View File
@@ -7,7 +7,7 @@
<title>IC 鍗℃煡璇?/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=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="public-search jr-public-page">
@@ -90,7 +90,7 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ic-card-search.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
@@ -101,3 +101,4 @@
</body>
</html>
+101 -29
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<!-- 充满未知和不稳定的票务系统! -->
@@ -7,8 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FSE铁路票务系统控制台</title>
<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=12">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<link rel="stylesheet" href="style.css?v=14">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
@@ -22,11 +22,6 @@
<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>
@@ -88,15 +83,6 @@
<span class="nav-icon"><i class="fas fa-list"></i></span> 日志
</div>
</div>
<!--连接状态显示-->
<div class="jr-admin-sidebar-status">
<div class="jr-admin-sidebar-status-label">Server: {{ connected ? 'Online' : 'Offline' }}</div>
<div class="flex" style="align-items: center; gap: 6px; margin-bottom: 15px;">
<i class="fas fa-circle"
:style="{ color: connected ? '#10b981' : '#ef4444', fontSize: '0.6rem' }"></i>
<span>Status: {{ connected ? 'Connected' : 'Disconnected' }}</span>
</div>
</div>
</div>
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false"></div>
@@ -112,10 +98,18 @@
</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 class="jr-admin-sync-meta">
<span class="jr-admin-sync-label">当前模块</span>
<strong>{{ viewTitle }}</strong>
</div>
<div class="jr-admin-sync-meta">
<span class="jr-admin-sync-label">最近同步</span>
<strong>{{ lastSyncText }}</strong>
</div>
<button class="btn primary" @click="refreshData" :disabled="isViewBusy">
<i class="fas" :class="isViewBusy ? 'fa-spinner fa-spin' : 'fa-rotate-right'"></i>
{{ isViewBusy ? '同步中' : '刷新视图' }}
</button>
</div>
</div>
<div class="content">
@@ -131,6 +125,32 @@
</div>
<p>当前模块:{{ viewTitle }},已加载 {{ lines.length }} 条线路、{{ stations.length }} 个站点。需要做批量调整时,可先在左侧切换模块再进入对应卡片操作区。</p>
</section>
<section class="jr-admin-overview-grid">
<article class="jr-admin-overview-card">
<span class="jr-admin-overview-label">当前模块</span>
<strong class="jr-admin-overview-value">{{ viewTitle }}</strong>
<p class="jr-admin-overview-note">{{ currentViewSummary }}</p>
</article>
<article class="jr-admin-overview-card">
<span class="jr-admin-overview-label">线路与站点</span>
<strong class="jr-admin-overview-value">{{ lines.length }} / {{ stations.length }}</strong>
<p class="jr-admin-overview-note">后台操作统一建立在线路、站点与票价的核心数据之上。</p>
</article>
<article class="jr-admin-overview-card">
<span class="jr-admin-overview-label">同步状态</span>
<strong class="jr-admin-overview-value">{{ isViewBusy ? '正在更新' : '数据已就绪' }}</strong>
<p class="jr-admin-overview-note">切换模块时只拉取当前视图需要的数据,减少等待与无效刷新。</p>
</article>
<article class="jr-admin-overview-card is-actions">
<span class="jr-admin-overview-label">快捷操作</span>
<div class="jr-admin-overview-actions">
<button class="btn" @click="currentView = 'management'"><i class="fas fa-network-wired"></i> 线路管理</button>
<button class="btn" @click="currentView = 'iccards'"><i class="fas fa-credit-card"></i> IC 卡务</button>
<button class="btn" @click="currentView = 'logs'"><i class="fas fa-list"></i> 查看日志</button>
<button class="btn primary" @click="refreshData" :disabled="isViewBusy"><i class="fas fa-rotate-right"></i> 立即同步</button>
</div>
</article>
</section>
<!-- 仪表盘-->
<div v-if="currentView === 'dashboard'">
<div class="grid">
@@ -255,8 +275,12 @@
</div>
<!-- 可视化线路编辑-->
<div class="visual-line-container">
<svg width="100%" height="200"
<div class="visual-line-container"
ref="visualLineViewport"
:class="{ 'is-panning': lineViewportPan.active }"
@mousedown="startLineViewportPan"
@mousemove="moveLineViewportPan">
<svg :width="lineEditorSvgWidth" height="200"
v-if="selectedLine.stations && selectedLine.stations.length > 0">
<!--站点连接线-->
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100"
@@ -479,6 +503,17 @@
</div>
<div v-if="currentView === 'iccards'">
<section class="jr-admin-section-toolbar">
<div class="jr-admin-section-toolbar-copy">
<span class="jr-admin-overview-label">IC CARD DESK</span>
<strong>{{ currentViewSummary }}</strong>
<p>把检索、充值、状态维护和事件核对集中在同一工作流里,减少在列表和详情之间来回跳转的成本。</p>
</div>
<div class="jr-admin-overview-actions">
<button class="btn" @click="fetchIcCards(false)" :disabled="isViewBusy"><i class="fas fa-list"></i> 刷新列表</button>
<button class="btn primary" @click="loadIcCard(icSelectedId)" :disabled="!icSelectedId || isViewBusy"><i class="fas fa-id-card"></i> 刷新详情</button>
</div>
</section>
<div class="grid">
<div class="card">
<div class="stat-label">IC 卡总数</div>
@@ -500,9 +535,24 @@
<div class="management-container ic-admin-layout">
<div class="management-sidebar">
<div class="card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
<div class="card jr-admin-note-card">
<div class="flex between mb-4">
<h4>卡片列表</h4>
<h4>操作说明</h4>
<span class="badge">只读入口</span>
</div>
<p class="jr-admin-card-note">本模块不提供后台快速建卡,卡片发放流程保持在线上购卡或既有开卡流程中完成,后台仅负责检索、维护、充值与记录核对。</p>
<div class="jr-admin-note-list">
<div>1. 先检索卡号、订单号或持卡人。</div>
<div>2. 在详情面板修改状态并保存。</div>
<div>3. 需要补款时直接使用充值入口。</div>
</div>
</div>
<div class="card jr-admin-list-card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
<div class="flex between mb-4">
<div>
<h4>卡片列表</h4>
<div class="jr-admin-list-meta">支持按卡号、订单号、凭证码和持卡人姓名检索。</div>
</div>
<span class="badge">{{ icCards.length }}</span>
</div>
<div class="flex mb-4" style="flex-wrap:wrap;">
@@ -536,8 +586,13 @@
<div class="management-main">
<div class="card">
<div class="flex between mb-4">
<h4>卡片详情</h4>
<div>
<h4>卡片详情</h4>
<div class="jr-admin-list-meta">在同一面板直接处理状态维护、充值和记录核对。</div>
</div>
<div class="flex" style="gap:8px;">
<button class="btn" @click="loadIcCard(icSelectedId)" :disabled="!icSelectedCard || isViewBusy"><i class="fas fa-rotate-right"></i> 刷新</button>
<button class="btn" @click="topupIcCard" :disabled="!icSelectedCard || isViewBusy"><i class="fas fa-wallet"></i> 充值</button>
<button class="btn danger" @click="deleteIcCard" :disabled="!icSelectedCard"><i class="fas fa-trash"></i> 删除卡</button>
<button class="btn primary" @click="saveIcCard" :disabled="!icSelectedCard"><i class="fas fa-save"></i> 保存</button>
</div>
@@ -554,6 +609,23 @@
</div>
<span class="badge" :class="icStatusInfo(icSelectedCard.status).className">{{ icStatusInfo(icSelectedCard.status).text }}</span>
</div>
<div class="jr-admin-summary-grid">
<div class="jr-admin-summary-item">
<span>当前余额</span>
<strong>{{ formatMoney(icSelectedCard.balance) }}</strong>
<small>支持直接发起充值。</small>
</div>
<div class="jr-admin-summary-item">
<span>事件记录</span>
<strong>{{ icSelectedEvents.length }}</strong>
<small>用于追踪开卡与状态变更。</small>
</div>
<div class="jr-admin-summary-item">
<span>订单来源</span>
<strong>{{ cardOrderCode(icSelectedCard) }}</strong>
<small>自动识别线上订单或现场办卡。</small>
</div>
</div>
<div class="ic-detail-grid">
<label class="ic-field">
<span>持卡人</span>
@@ -841,9 +913,8 @@
</div>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=13"></script>
<script src="index.js?v=2"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="index.js?v=6"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
@@ -873,3 +944,4 @@
+233 -66
View File
@@ -34,8 +34,9 @@ createApp({
return map[currentView.value] || '票价图';
});
const connected = ref(false);
const socket = io({ transports: ['websocket', 'polling'], upgrade: false });
// Prefer polling first so admin remains connected even when the proxy
// does not support WebSocket upgrades reliably.
const socket = io({ transports: ['polling', 'websocket'] });
// Data State
const stations = ref([]);
@@ -63,6 +64,24 @@ createApp({
const icCreateForm = reactive({ holder_name: '', balance: 50 });
const icDetailForm = reactive({ holder_name: '', status: 'active' });
let icCardSyncTimer = null;
let icCardSyncBusy = false;
let icListRequestSeq = 0;
let icDetailRequestSeq = 0;
let appMouseupHandler = null;
let coreLoaded = false;
let ticketDataLoaded = false;
let orderDataLoaded = false;
let logDataLoaded = false;
let assetsLoaded = false;
let fareMapLoaded = false;
const loadingState = reactive({
core: false,
tickets: false,
orders: false,
logs: false,
iccards: false
});
const lastSyncAt = ref(0);
// UI State
const showAddLine = ref(false);
@@ -82,6 +101,15 @@ createApp({
const showFareModal = ref(false);
const currentFare = reactive({ exists: false, cost_regular: 0, cost_express: 0 });
const draggingStationIndex = ref(null);
const visualLineViewport = ref(null);
const lineViewportPan = reactive({
active: false,
startX: 0,
startY: 0,
scrollLeft: 0,
scrollTop: 0,
moved: false
});
const showStationModal = ref(false);
const stationForm = reactive({ code: '', name: '', en_name: '', transfer_enabled: false, transfer_to: [] });
const stationFormOriginalCode = ref('');
@@ -103,6 +131,9 @@ createApp({
confirm: (message) => Promise.resolve(window.confirm(message)),
prompt: (message, defaultValue) => Promise.resolve(window.prompt(message, defaultValue))
};
const markSynced = () => {
lastSyncAt.value = Date.now();
};
const buildAssetUrl = (name) => {
if (!name) return '';
@@ -417,6 +448,7 @@ createApp({
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
assetsLoaded = true;
assetsFarePreview.headers = [];
assetsFarePreview.rows = [];
@@ -454,6 +486,7 @@ createApp({
}
} catch (e) {}
}
markSynced();
};
const uploadAssetFile = async (url, file) => {
@@ -631,36 +664,90 @@ createApp({
draggingStationIndex.value = null;
};
const startLineViewportPan = (event) => {
const viewport = visualLineViewport.value;
if (!viewport) return;
if (event.button !== 0) return;
if (event.target && event.target.closest('.station-node')) return;
lineViewportPan.active = true;
lineViewportPan.moved = false;
lineViewportPan.startX = event.clientX;
lineViewportPan.startY = event.clientY;
lineViewportPan.scrollLeft = viewport.scrollLeft;
lineViewportPan.scrollTop = viewport.scrollTop;
};
const moveLineViewportPan = (event) => {
if (!lineViewportPan.active) return;
const viewport = visualLineViewport.value;
if (!viewport) return;
const deltaX = event.clientX - lineViewportPan.startX;
const deltaY = event.clientY - lineViewportPan.startY;
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
lineViewportPan.moved = true;
}
viewport.scrollLeft = lineViewportPan.scrollLeft - deltaX;
viewport.scrollTop = lineViewportPan.scrollTop - deltaY;
};
const endLineViewportPan = () => {
lineViewportPan.active = false;
};
// --- Order Management ---
const fetchOrders = async () => {
if (loadingState.orders) return;
loadingState.orders = true;
try {
const res = await requestJson('/api/orders');
if (res && res.ok) orders.value = res.orders;
} catch (e) { console.error(e); }
if (res && res.ok) {
orders.value = res.orders || [];
orderDataLoaded = true;
markSynced();
}
} catch (e) {
console.error(e);
} finally {
loadingState.orders = false;
}
};
const fetchIcCards = async (keepSelection = true) => {
if (loadingState.iccards) return;
loadingState.iccards = true;
const requestSeq = ++icListRequestSeq;
const sp = new URLSearchParams();
if (icCardSearch.value.trim()) sp.set('q', icCardSearch.value.trim());
const res = await requestJson(`/api/ic-cards?${sp.toString()}`);
icCards.value = res?.cards || [];
if (keepSelection && icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
await loadIcCard(icSelectedId.value);
} else if (icSelectedId.value && !icCards.value.some((card) => card.card_id === icSelectedId.value)) {
icSelectedId.value = '';
icSelectedCard.value = null;
icSelectedEvents.value = [];
try {
const res = await requestJson(`/api/ic-cards?${sp.toString()}`);
if (requestSeq !== icListRequestSeq) return;
icCards.value = res?.cards || [];
if (keepSelection && icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
await loadIcCard(icSelectedId.value);
} else if (icSelectedId.value && !icCards.value.some((card) => card.card_id === icSelectedId.value)) {
icSelectedId.value = '';
icSelectedCard.value = null;
icSelectedEvents.value = [];
}
markSynced();
} finally {
if (requestSeq === icListRequestSeq) {
loadingState.iccards = false;
}
}
};
const loadIcCard = async (id) => {
const requestSeq = ++icDetailRequestSeq;
const res = await requestJson(`/api/ic-cards/${encodeURIComponent(id)}`);
if (requestSeq !== icDetailRequestSeq) return;
const card = res?.card || null;
icSelectedId.value = id;
icSelectedCard.value = card;
icSelectedEvents.value = res?.events || [];
icDetailForm.holder_name = card?.holder_name || '';
icDetailForm.status = card?.status || 'active';
markSynced();
};
const syncSelectedIcCard = async () => {
@@ -683,9 +770,15 @@ createApp({
stopIcCardSync();
if (currentView.value !== 'iccards') return;
icCardSyncTimer = setInterval(() => {
fetchIcCards(false).catch(console.error);
syncSelectedIcCard().catch(console.error);
}, 3000);
if (document.hidden || icCardSyncBusy) return;
icCardSyncBusy = true;
Promise.all([
fetchIcCards(false).catch(console.error),
icSelectedId.value ? syncSelectedIcCard().catch(console.error) : Promise.resolve()
]).finally(() => {
icCardSyncBusy = false;
});
}, 5000);
};
const createIcCard = async () => {
@@ -775,10 +868,15 @@ createApp({
};
const fetchLogs = async () => {
if (logLoading.value) return;
logLoading.value = true;
try {
const res = await requestJson(buildLogsUrl());
if (res && res.ok) logs.value = res.logs || [];
if (res && res.ok) {
logs.value = res.logs || [];
logDataLoaded = true;
markSynced();
}
} catch (e) {
console.error(e);
} finally {
@@ -823,66 +921,67 @@ createApp({
return `¥${reg} / ¥${exp}`;
};
const fetchData = async () => {
const fetchCoreData = async ({ force = false } = {}) => {
if (loadingState.core) return;
if (coreLoaded && !force) return;
loadingState.core = true;
try {
const safeFetch = (url, defaultVal) => requestJson(url).catch(e => {
const safeFetch = (url, defaultVal) => requestJson(url).catch((e) => {
console.error(`Fetch failed for ${url}`, e);
lastActionError.value = e?.message || String(e);
return defaultVal;
});
const safeFetchList = (url, key) => requestJson(url).then(d => d?.[key] || []).catch(e => {
console.error(`Fetch list failed for ${url}`, e);
lastActionError.value = e?.message || String(e);
return [];
});
const [s, l, f, c, t, lg, st, ord, cards] = await Promise.all([
const [s, l, f, c, st] = await Promise.all([
safeFetch('/api/stations', []),
safeFetch('/api/lines', []),
safeFetch('/api/fares', []),
safeFetch('/api/config', {}),
safeFetchList('/api/tickets', 'tickets'),
safeFetchList(buildLogsUrl(), 'logs'),
safeFetch('/api/stats/ticket/total', {}).then(d => d.total || {}),
safeFetchList('/api/orders', 'orders'),
safeFetchList('/api/ic-cards', 'cards')
safeFetch('/api/stats/ticket/total', {}).then((d) => d.total || {})
]);
stations.value = s;
lines.value = l;
fares.value = f;
Object.assign(config, c);
tickets.value = t;
logs.value = lg;
Object.assign(stats, st);
orders.value = ord;
icCards.value = cards;
// Refresh selected line if it exists
if (selectedLine.value) {
const found = lines.value.find(l => l.id === selectedLine.value.id);
if (found) selectedLine.value = found;
const found = lines.value.find((line) => line.id === selectedLine.value.id);
selectedLine.value = found || null;
}
if (icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
await loadIcCard(icSelectedId.value);
}
loadFareMap();
coreLoaded = true;
markSynced();
} catch (e) {
console.error("Failed to fetch data", e);
console.error('Failed to fetch core data', e);
} finally {
loadingState.core = false;
}
};
const loadFareMap = async () => {
const fetchTicketData = async () => {
if (loadingState.tickets) return;
loadingState.tickets = true;
try {
const res = await requestJson('/api/tickets');
tickets.value = res?.tickets || [];
ticketDataLoaded = true;
markSynced();
} catch (e) {
console.error('Failed to fetch tickets', e);
} finally {
loadingState.tickets = false;
}
};
const loadFareMap = async ({ force = false } = {}) => {
if (fareMapLoading.value) return;
if (fareMapLoaded && !force) return;
fareMapLoading.value = true;
fareMapError.value = '';
try {
// Change to fetch the SVG text directly from the public API
// Add timestamp to prevent caching
const r = await fetch(`/api/public/fares/map/light?t=${Date.now()}`);
const svg = await r.text();
fareMapSvg.value = svg;
fareMapLoaded = true;
markSynced();
} catch (e) {
console.error("Failed to load fare map", e);
fareMapError.value = '加载失败';
@@ -891,6 +990,25 @@ createApp({
}
};
const ensureViewData = async (view = currentView.value, { force = false } = {}) => {
await fetchCoreData({ force });
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
if (view === 'iccards') {
await fetchIcCards(true);
if (icSelectedId.value) {
await syncSelectedIcCard().catch(console.error);
}
}
if (view === 'faremap' && (force || !fareMapLoaded)) await loadFareMap({ force });
if (view === 'assets' && (!assetsLoaded || force)) await fetchAssetsManifest();
};
const fetchData = async () => {
await ensureViewData(currentView.value, { force: true });
};
const zoomFareMapIn = () => { fareMapScale.value = Math.min(3, Math.round((fareMapScale.value + 0.1) * 100) / 100); };
const zoomFareMapOut = () => { fareMapScale.value = Math.max(0.3, Math.round((fareMapScale.value - 0.1) * 100) / 100); };
const zoomFareMapReset = () => { fareMapScale.value = 1; };
@@ -1093,6 +1211,10 @@ createApp({
};
const handleStationClick = async (code) => {
if (lineViewportPan.moved) {
lineViewportPan.moved = false;
return;
}
if (stationEditMode.value) {
openStationModal(code);
return;
@@ -1231,17 +1353,18 @@ createApp({
};
// Socket Listeners
socket.on('connect', () => { connected.value = true; });
socket.on('disconnect', () => { connected.value = false; });
socket.on('stations:updated', (data) => {
stations.value = data;
// Refresh map when stations change
loadFareMap();
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
});
socket.on('lines:updated', (data) => {
lines.value = data;
coreLoaded = true;
// Update selectedLine reference if it exists
if (selectedLine.value) {
const updated = data.find(l => l.id === selectedLine.value.id);
@@ -1251,14 +1374,28 @@ createApp({
selectedLine.value = null; // Line was deleted
}
}
loadFareMap();
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
});
socket.on('fares:updated', (data) => {
fares.value = data;
loadFareMap();
coreLoaded = true;
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
});
socket.on('config:updated', (data) => {
Object.assign(config, data);
coreLoaded = true;
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
});
socket.on('config:updated', (data) => { Object.assign(config, data); loadFareMap(); });
socket.on('stats:ticket:updated', (item) => {
stats.sold_tickets += item.sold_tickets;
@@ -1281,15 +1418,12 @@ createApp({
watch(currentView, (v) => {
sidebarOpen.value = false;
if (v === 'assets') fetchAssetsManifest();
if (v === 'logs') fetchLogs();
if (v === 'iccards') {
fetchIcCards(true).catch(console.error);
syncSelectedIcCard().catch(() => {});
startIcCardSync();
} else {
stopIcCardSync();
}
ensureViewData(v).catch(console.error);
const sp = new URLSearchParams(location.search);
if (v === 'dashboard') sp.delete('view');
else sp.set('view', v);
@@ -1300,13 +1434,12 @@ createApp({
// Initial Load
onMounted(() => {
fetchData();
fetchAssetsManifest();
ensureViewData(currentView.value, { force: true }).catch(console.error);
if (currentView.value === 'iccards') {
fetchIcCards(true).catch(console.error);
startIcCardSync();
}
window.addEventListener('mouseup', async () => {
appMouseupHandler = async () => {
endLineViewportPan();
if (draggingStationIndex.value !== null) {
if (selectedLine.value) {
try {
@@ -1323,16 +1456,48 @@ createApp({
}
draggingStationIndex.value = null;
}
});
};
window.addEventListener('mouseup', appMouseupHandler);
});
onUnmounted(() => {
stopIcCardSync();
if (appMouseupHandler) {
window.removeEventListener('mouseup', appMouseupHandler);
}
});
// Computed
const recentLogs = computed(() => logs.value);
const orderList = computed(() => orders.value);
const lineEditorSvgWidth = computed(() => {
const count = Array.isArray(selectedLine.value?.stations) ? selectedLine.value.stations.length : 0;
return Math.max(960, 100 + Math.max(0, count - 1) * 120 + 120);
});
const lastSyncText = computed(() => lastSyncAt.value ? formatTime(lastSyncAt.value) : '尚未同步');
const isViewBusy = computed(() => {
if (loadingState.core) return true;
if (currentView.value === 'tickets') return loadingState.tickets;
if (currentView.value === 'vouchers') return loadingState.orders;
if (currentView.value === 'logs') return logLoading.value;
if (currentView.value === 'iccards') return loadingState.iccards;
if (currentView.value === 'faremap') return fareMapLoading.value;
return false;
});
const currentViewSummary = computed(() => {
const map = {
dashboard: `已同步 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
management: `当前可编辑 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
faremap: fareMapLoading.value ? '票价图正在生成中' : '可导出当前铁路票价图',
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
settings: '可维护优惠活动与导出数据',
logs: `当前筛选结果 ${logs.value.length} 条日志`
};
return map[currentView.value] || '后台模块已就绪';
});
const icCardStats = computed(() => ({
total: icCards.value.length,
pending: icCards.value.filter((card) => card.status === 'pending_pickup').length,
@@ -1388,7 +1553,8 @@ createApp({
};
return {
currentView, viewTitle, connected, sidebarOpen,
currentView, viewTitle, sidebarOpen,
loadingState, isViewBusy, lastSyncText, currentViewSummary,
stations, lines, fares, stats, config, recentLogs, ticketList,
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
orders, orderList, fetchOrders, deleteOrder,
@@ -1400,6 +1566,7 @@ createApp({
// Management
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
visualLineViewport, lineViewportPan, lineEditorSvgWidth, startLineViewportPan, moveLineViewportPan,
selectLine, createLine, deleteLine, deleteStation, updateLineInfo, getFareText,
isStationInLine, addStationToLine, removeStationFromLine,
handleStationClick, isStationSelected,
+17 -23
View File
@@ -4,22 +4,17 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>鎺у埗鍙扮櫥褰?/title>
<title>后台控制台登录</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="/style.css?v=12" />
<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>
<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>
@@ -28,8 +23,8 @@
<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>
<strong>FSE 铁路运输</strong>
<span>控制台登录</span>
</div>
</a>
</div>
@@ -39,38 +34,37 @@
<section class="jr-admin-login-panel">
<div class="jr-admin-login-copy">
<span class="jr-kicker">OPERATIONS ACCESS</span>
<h1>鍚庡彴鎺у埗鍙?/h1>
<p>绾胯矾缁存姢銆佺エ鎹鐞嗐€佹棩蹇楁煡璇笌 IC 鍗$鐞?/p>
<h1>后台控制台</h1>
<p>线路维护、票务管理、日志查询与 IC 卡管理统一从这里进入。</p>
<ul class="jr-admin-login-points">
<li>缁熶竴绠$悊绾胯矾銆佺エ浠峰拰璧勬簮鍥炬枃浠?/li>
<li>鏌ョ湅鐢靛瓙绁ㄣ€佸嚟璇佷笌鎿嶄綔鏃ュ織</li>
<li>缁存姢 IC 鍗″彂琛屻€佸厖鍊间笌鐘舵€佽褰?/li>
<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>
<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-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>
<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">绮CP澶?025450093鍙?/a>
<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="/public-status.js?v=13"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="login.js?v=2"></script>
</body>
</html>
+230 -9
View File
@@ -28,6 +28,8 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
body {
@@ -684,6 +686,7 @@ main {
display: flex;
flex-direction: column;
flex-shrink: 0;
min-height: 0;
}
.management-main {
@@ -700,6 +703,7 @@ main {
flex-direction: column;
gap: 8px;
padding-right: 4px;
min-height: 0;
}
.line-item {
@@ -769,18 +773,26 @@ main {
.visual-line-container {
flex: 1;
overflow-x: auto;
overflow-y: hidden;
overflow: auto;
background-color: #00000022;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 40px;
min-height: 0;
cursor: grab;
user-select: none;
scrollbar-width: thin;
}
.visual-line-container svg {
min-width: 100%;
flex-shrink: 0;
}
.visual-line-container.is-panning {
cursor: grabbing;
}
.station-node {
@@ -3381,6 +3393,12 @@ body.jr-ticket-board-page .jr-board-card:last-child {
align-items: start;
}
.ic-admin-layout .management-sidebar,
.ic-admin-layout .management-main,
.jr-admin-list-card {
min-height: 0;
}
.ic-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3727,6 +3745,31 @@ body.jr-admin-login-page {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.jr-admin-sync-meta {
min-width: 118px;
padding: 10px 12px;
border: 1px solid #d8e2d4;
background: #f8fbf7;
color: #385446;
}
.jr-admin-sync-meta strong {
display: block;
color: #143423;
font-size: 0.94rem;
}
.jr-admin-sync-label {
display: block;
margin-bottom: 4px;
color: #6a7d72;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
.jr-admin-header-pill {
@@ -3762,6 +3805,149 @@ body.jr-admin-login-page {
margin-bottom: 20px;
}
.jr-admin-overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.jr-admin-overview-card {
padding: 20px;
border: 1px solid #d7e0d3;
background: #ffffff;
box-shadow: 0 10px 24px rgba(18, 50, 33, 0.04);
}
.jr-admin-overview-card.is-actions {
background: linear-gradient(180deg, #f8fbf7 0, #ffffff 100%);
}
.jr-admin-overview-label {
display: inline-block;
margin-bottom: 8px;
color: #0b6b3a;
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.14em;
}
.jr-admin-overview-value {
display: block;
color: #143423;
font-size: 1.35rem;
line-height: 1.3;
}
.jr-admin-overview-note {
margin: 10px 0 0;
color: #627368;
line-height: 1.7;
font-size: 0.92rem;
}
.jr-admin-overview-actions,
.jr-admin-card-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.jr-admin-overview-actions {
margin-top: 14px;
}
.jr-admin-section-toolbar {
margin-bottom: 18px;
padding: 20px 22px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border: 1px solid #d7e0d3;
background: linear-gradient(135deg, rgba(11, 107, 58, 0.05) 0, rgba(11, 107, 58, 0.015) 28%, #ffffff 28%, #ffffff 100%);
}
.jr-admin-section-toolbar-copy {
min-width: 0;
}
.jr-admin-section-toolbar-copy strong {
display: block;
color: #143423;
font-size: 1.1rem;
}
.jr-admin-section-toolbar-copy p {
margin: 8px 0 0;
color: #627368;
line-height: 1.7;
}
.jr-admin-card-note,
.jr-admin-list-meta {
color: #6a7c70;
font-size: 0.88rem;
line-height: 1.7;
}
.jr-admin-note-list {
display: grid;
gap: 10px;
margin-top: 14px;
color: #3c594a;
font-size: 0.92rem;
line-height: 1.65;
}
.jr-admin-list-card .jr-scroll-box {
padding-right: 4px;
min-height: 320px;
max-height: 560px;
overflow-y: auto;
overscroll-behavior: contain;
}
.jr-admin-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.jr-admin-summary-item {
padding: 14px 16px;
border: 1px solid #dbe5d8;
background: #f8fbf7;
}
.jr-admin-summary-item span,
.jr-admin-summary-item small {
display: block;
color: #687a70;
}
.jr-admin-summary-item span {
margin-bottom: 6px;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
}
.jr-admin-summary-item strong {
display: block;
color: #143423;
font-size: 1.18rem;
line-height: 1.3;
word-break: break-word;
}
.jr-admin-summary-item small {
margin-top: 8px;
font-size: 0.84rem;
line-height: 1.6;
}
.jr-admin-page .card {
background: #ffffff;
border: 1px solid #d7e0d3;
@@ -3962,6 +4148,16 @@ body.jr-admin-login-page {
.jr-admin-login-panel {
grid-template-columns: 1fr;
}
.jr-admin-overview-grid,
.jr-admin-summary-grid {
grid-template-columns: 1fr 1fr;
}
.jr-admin-section-toolbar {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 768px) {
@@ -3980,6 +4176,38 @@ body.jr-admin-login-page {
padding-top: 20px;
}
.jr-admin-login-copy,
.jr-admin-login-card {
padding: 22px;
border-radius: 20px;
}
.jr-admin-header-side,
.jr-admin-overview-actions,
.jr-admin-card-actions {
width: 100%;
}
.jr-admin-sync-meta,
.jr-admin-overview-grid,
.jr-admin-summary-grid {
width: 100%;
}
.jr-admin-overview-grid,
.jr-admin-summary-grid {
grid-template-columns: 1fr;
}
.jr-admin-overview-card,
.jr-admin-section-toolbar {
padding: 18px;
}
.jr-admin-header-side .btn {
width: 100%;
}
}
/* --- Custom Dialog --- */
@@ -4102,10 +4330,3 @@ body.jr-admin-login-page {
width: 100%;
}
}
.jr-admin-login-copy,
.jr-admin-login-card {
padding: 22px;
border-radius: 20px;
}
}
+106 -88
View File
@@ -4,10 +4,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FSE閾佽矾鐢靛瓙瀹㈢エ</title>
<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=12">
<link rel="stylesheet" href="/style.css?v=13">
<style>
[v-cloak] {
display: none;
@@ -49,12 +49,12 @@
<div class="jr-topbar-inner">
<a href="javascript:void(0)" @click="goHome" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>杩斿洖鏌ヨ</span>
<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>
<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>
@@ -63,28 +63,28 @@
<a href="javascript:void(0)" @click="goHome" 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>
<strong>FSE铁路票务系统</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 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">ELECTRONIC TICKET</span>
<h1>鏌ョ湅杞︾エ鐘舵€佷笌鏈€杩戞祦杞褰?/h1>
<p>鐢ㄤ簬鏌ョ湅鍗曞紶鐢靛瓙瀹㈢エ鐨勪箻杞︿俊鎭€佺姸鎬佷笌杩涘嚭绔欒褰曪紝渚夸簬鏃呭鍜屽伐浣滀汉鍛樺揩閫熺‘璁ょエ鎹姸鎬併€?/p>
<h1>查看车票状态与最近流转记录</h1>
<p>用于查看单张电子客票的乘车信息、状态与进出站记录,便于旅客和工作人员快速确认票据状态。</p>
</section>
<div v-if="loading" class="jr-panel-card">
<div class="jr-center-empty">
<p>姝e湪璇诲彇绁ㄦ嵁鏁版嵁...</p>
<p>正在读取车票数据...</p>
</div>
</div>
<template v-if="!loading && hasTicket">
@@ -92,8 +92,9 @@
<article class="jr-board-card">
<div class="jr-panel-headline">
<h2 class="mono">{{ ticket.ticket_id }}</h2>
<span class="jr-status-pill" :class="statusInfo.class.replace('status-', 'jr-status-')">{{
statusInfo.text }}</span>
<span class="jr-status-pill" :class="statusInfo.class.replace('status-', 'jr-status-')">
{{ statusInfo.text }}
</span>
</div>
<div class="jr-route-board">
<div class="jr-station-block">
@@ -114,46 +115,44 @@
</div>
<div class="jr-meta-grid">
<div class="jr-meta-item">
<span>杞﹀瀷</span>
<span>车型</span>
<strong>{{ formatTrainType(ticket.overview.train_type) }}</strong>
</div>
<div class="jr-meta-item">
<span>绁ㄤ环</span>
<strong> {{ ticket.overview.amount || 0 }}</strong>
<span>票价</span>
<strong>¥ {{ ticket.overview.amount || 0 }}</strong>
</div>
<div class="jr-meta-item">
<span>涔樻</span>
<strong>{{ (ticket.overview.trips_remaining == null ? 1 :
ticket.overview.trips_remaining) }} / {{ (ticket.overview.trips_total == null ? 1 :
ticket.overview.trips_total) }}</strong>
<span>乘次</span>
<strong>{{ (ticket.overview.trips_remaining == null ? 1 : ticket.overview.trips_remaining) }} / {{ (ticket.overview.trips_total == null ? 1 : ticket.overview.trips_total) }}</strong>
</div>
<div class="jr-meta-item">
<span>鏇存柊鏃堕棿</span>
<span>更新时间</span>
<strong>{{ formatTime(ticket.overview.last_update_ts) }}</strong>
</div>
</div>
</article>
<aside class="jr-board-card">
<div class="jr-panel-headline">
<h3>娴佽浆璁板綍</h3>
<h3>流转记录</h3>
<span class="jr-panel-note">Recent Events</span>
</div>
<div class="jr-history-list" v-if="ticket.events && ticket.events.length > 0">
<div v-for="ev in ticket.events.slice().reverse().slice(0, 5)"
:key="ev.ts || ev.鏃堕棿鎴? class="jr-history-item">
:key="ev.ts || ev['时间戳'] || Math.random()"
class="jr-history-item">
<div class="jr-history-row">
<span class="jr-history-title">{{ formatEvent(ev) }}</span>
<span class="jr-history-time">{{ formatTime(ev.鏃堕棿鎴?|| ev.ts) }}</span>
<span class="jr-history-time">{{ formatTime(ev['时间戳'] || ev.ts) }}</span>
</div>
<div class="jr-history-desc">
<div>{{ formatEventLocation(ev) }}</div>
<div v-if="formatEventMeta(ev)" style="margin-top:4px;">{{ formatEventMeta(ev) }}
</div>
<div v-if="formatEventMeta(ev)" style="margin-top:4px;">{{ formatEventMeta(ev) }}</div>
</div>
</div>
</div>
<div v-else class="jr-center-empty">
<p>鏆傛棤娴佽浆璁板綍銆?/p>
<p>暂无流转记录。</p>
</div>
</aside>
</section>
@@ -161,23 +160,23 @@
<div v-if="!loading && !hasTicket" class="jr-panel-card">
<div class="jr-center-empty">
<h2 style="margin:0 0 10px;">鏃犳晥杞︾エ</h2>
<p>鏈壘鍒拌杞︾エ鐨勮缁嗕俊鎭€?/p>
<h2 style="margin:0 0 10px;">无效车票</h2>
<p>未找到该车票的详细信息。</p>
<div class="jr-action-row">
<button @click="goHome" class="btn primary jr-search-button">杩斿洖鏌ヨ</button>
<button @click="goHome" class="btn primary jr-search-button">返回查询</button>
</div>
</div>
</div>
<footer class="site-footer jr-footer-space">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
<span class="version">v1.0.12</span>
<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="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="/custom-dialog.js?v=11"></script>
<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="/ai-assistant.js?v=6"></script>
<script>
@@ -218,60 +217,74 @@
const statusInfo = computed(() => {
if (!hasTicket.value) return {};
let raw = '';
if (ticket.value && ticket.value.overview) {
if (ticket.value.overview.status != null) raw = ticket.value.overview.status;
if (ticket.value && ticket.value.overview && ticket.value.overview.status != null) {
raw = ticket.value.overview.status;
}
if (!raw && ticket.value) {
if (ticket.value.status != null) raw = ticket.value.status;
if (!raw && ticket.value && ticket.value.status != null) {
raw = ticket.value.status;
}
const status = String(raw).toLowerCase();
if (
status === '鏈夋晥' ||
status === '有效' ||
status === 'valid' ||
status === 'unused' ||
status === 'active' ||
status.includes('鏈夋晥') ||
status.includes('鏈娇鐢?) ||
status.includes('有效') ||
status.includes('未使用') ||
status.includes('unused')
) {
return { text: '鏈夋晥', class: 'status-valid' };
) {
return { text: '有效', class: 'status-valid' };
}
if (status === '宸蹭娇鐢? || status === 'used' || status.includes('宸蹭娇鐢?) || status.includes('used')) {
return { text: '宸蹭娇鐢?, class: 'status-used' };
if (
status === '已使用' ||
status === 'used' ||
status.includes('已使用') ||
status.includes('used')
) {
return { text: '已使用', class: 'status-used' };
}
return { text: '澶辨晥', class: 'status-expired' };
return { text: '失效', class: 'status-expired' };
});
const formatTime = (timestamp) => {
if (!timestamp) return '---';
let ts = Number(timestamp);
if (!Number.isFinite(ts)) return String(timestamp);
if (ts > 0 && ts < 1000000000000) ts = ts * 1000;
if (ts > 0 && ts < 1000000000000) ts *= 1000;
const date = new Date(ts);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit'});
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
const formatEvent = (event) => {
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
const action = String(event.action || event.鍔ㄤ綔 || '').toLowerCase();
const type = String(event.type || event['类型'] || '').toLowerCase();
const action = String(event.action || event['动作'] || '').toLowerCase();
if (type === '鐘舵€? || type === 'status') {
const actionMap = { 'entry': '杩涚珯鎴愬姛', 'exit': '鍑虹珯鎴愬姛' };
return actionMap[action] || '鐘舵佸彉鏇?;
if (type === '状态' || type === 'status') {
const actionMap = { entry: '进站成功', exit: '出站成功' };
return actionMap[action] || '状态变更';
}
const typeMap = { 'sale': '鍞エ鎴愬姛', '鍞エ': '鍞エ鎴愬姛', 'entry': '杩涚珯鎴愬姛', 'exit': '鍑虹珯鎴愬姛' };
return typeMap[type] || event.type || event.绫诲瀷 || '鐘舵€佸彉鏇?;
const typeMap = {
sale: '售票成功',
售票: '售票成功',
entry: '进站成功',
exit: '出站成功'
};
return typeMap[type] || event.type || event['类型'] || '状态变更';
};
const formatEventLocation = (event) => {
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
const stationName = event.station_name || event.鍞エ绔?|| event.鍙戠敓绔?|| '';
const stationCode = event.station_code || event.绔欑偣缂栧彿 || '';
const type = String(event.type || event['类型'] || '').toLowerCase();
const stationName = event.station_name || event['售票站'] || event['发生站'] || '';
const stationCode = event.station_code || event['站点编号'] || '';
if (type === 'sale' || type === '') {
return stationName || '绾夸笂鍞';
if (type === 'sale' || type === '售票') {
return stationName || '线上售票';
}
if (!stationName && !stationCode) return '---';
@@ -279,25 +292,25 @@
};
const formatEventMeta = (event) => {
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
if (type === 'sale' || type === '') {
const amount = event.amount ?? event.鍞エ棰?
if (amount != null && amount !== '') return `绁ㄤ环锛毬?${amount}`;
const type = String(event.type || 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.璁惧缂栧彿 || '';
const stationEn = event.station_en || event['站点英文'] || '';
const deviceId = event.device_id || event['设备编号'] || '';
if (stationEn && deviceId) return `${stationEn} (${deviceId})`;
if (deviceId) return `璁惧锛?{deviceId}`;
if (deviceId) return `设备:${deviceId}`;
return stationEn;
};
const formatTrainType = (type) => {
if (!type) return '?;
const t = type.toLowerCase();
if (t === 'local') return '鏅€?;
if (t === 'ltd.exp' || t === 'express' || t === 'exp' || t === 'express_train') return '鐗规?;
if (t.includes('鐗规€?)) return '鐗规?;
if (!type) return '普通';
const t = String(type).toLowerCase();
if (t === 'local') return '普通';
if (t === 'ltd.exp' || t === 'express' || t === 'exp' || t === 'express_train') return '特急';
if (t.includes('特急')) return '特急';
return String(type);
};
@@ -311,22 +324,22 @@
ticket.value = null;
} else {
const data = await response.json();
const id = (data && (data.ticket_id || data.エ缂栧彿 || data.id)) || ticketid;
const id = (data && (data.ticket_id || data['车票编号'] || data.id)) || ticketid;
let overview = null;
if (data) {
if (data.overview != null) overview = data.overview;
else if (data.姒傝 != null) overview = data.姒傝;
else if (data['概览'] != null) overview = data['概览'];
else if (data.summary != null) overview = data.summary;
}
let events = [];
if (data) {
if (Array.isArray(data.events)) events = data.events;
else if (data.浜嬩欢 != null) events = data.浜嬩欢;
else if (data['事件'] != null) events = data['事件'];
}
if (id && overview != null) {
const out = {};
if (data && typeof data === 'object') {
for (const k in data) out[k] = data[k];
for (const key in data) out[key] = data[key];
}
out.ticket_id = id;
out.overview = overview;
@@ -337,7 +350,7 @@
}
}
} catch (e) {
console.error('鑾峰彇杞︾エ鏁版嵁澶辫触:', e);
console.error('获取车票数据失败:', e);
ticket.value = null;
} finally {
loading.value = false;
@@ -357,15 +370,20 @@
});
return {
loading, ticket, hasTicket, statusInfo,
formatTime, formatEvent, formatEventLocation, formatEventMeta, formatTrainType, goHome
loading,
ticket,
hasTicket,
statusInfo,
formatTime,
formatEvent,
formatEventLocation,
formatEventMeta,
formatTrainType,
goHome
};
}
}).mount('#app');
</script>
</body>
</html>
+4 -3
View File
@@ -7,7 +7,7 @@
<title>线上预定</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=12">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="public-search jr-order-page">
@@ -232,8 +232,8 @@
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-order.js?v=20"></script>
<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="/ai-assistant.js?v=6"></script>
<script>
@@ -261,3 +261,4 @@
</body>
</html>
+54
View File
@@ -408,6 +408,58 @@
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 = [];
@@ -447,6 +499,8 @@
}
}
renderRouteOverlay();
// Auto preview if both selected
if(!skipPreview && selection[0] && selection[1]) previewPrice();
}
+5 -4
View File
@@ -8,8 +8,8 @@
<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=12">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<link rel="stylesheet" href="/style.css?v=13">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
@@ -607,9 +607,9 @@
</div>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="ticket-route.js?v=2"></script>
<script src="ticket-route.js?v=3"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
@@ -641,3 +641,4 @@
+2 -1
View File
@@ -24,7 +24,8 @@ createApp({
});
const connected = ref(false);
const socket = io({ transports: ['websocket'], upgrade: false, timeout: 20000 });
// Keep the legacy route console usable behind proxies that only allow polling.
const socket = io({ transports: ['polling', 'websocket'], timeout: 20000 });
const stations = ref([]);
+3 -2
View File
@@ -7,7 +7,7 @@
<title>票务查询</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=12" />
<link rel="stylesheet" href="/style.css?v=13" />
</head>
<body class="public-search jr-public-page">
@@ -117,7 +117,7 @@
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ticket-search.js?v=11"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
@@ -146,3 +146,4 @@
</body>
</html>
+3 -2
View File
@@ -7,7 +7,7 @@
<title>凭证详情</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=12" />
<link rel="stylesheet" href="/style.css?v=13" />
</head>
<body class="public-search jr-public-page">
@@ -125,7 +125,7 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/token.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
@@ -137,3 +137,4 @@
</body>
</html>