refactor(web): 重构全站界面与事件日志,优化功能与样式

1. 重构并统一票务、IC卡的事件日志格式化展示逻辑,替换原有零散的格式化函数,提升事件展示的一致性与可读性
2. 为AI助手面板新增折叠按钮与折叠逻辑,支持收起/展开面板,优化界面空间利用率
3. 移除多个页面的冗余介绍、提醒区块,精简页面结构
4. 修复IC卡购卡页面的充值选项按钮布局问题,将button元素改为div适配样式规范
5. 新增移动端卡片布局的横向滚动适配,优化小屏设备上的卡片展示与浏览体验
6. 更新后台管理页面的导航样式、文案与统计数据展示,新增车票、凭证的统计卡片
7. 统一全站品牌文案与页面格式,修复部分页面的DOCTYPE多余空格问题
8. 简化车票订单页面的地图加载逻辑,修复原有的冗余代码与加载异常问题
This commit is contained in:
2026-07-05 14:55:12 +08:00
parent ef9926dc58
commit 95893a5431
19 changed files with 483 additions and 793 deletions
+16
View File
@@ -250,6 +250,12 @@
box-shadow: 0 20px 38px rgba(36, 74, 50, 0.14);
border-color: #cadecd;
}
.tm-ai-toggle.is-folded .tm-ai-toggle-copy {
display: none;
}
.tm-ai-toggle.is-folded .tm-ai-fold-btn i {
transform: rotate(180deg);
}
.tm-ai-toggle-badge {
width: 28px;
height: 28px;
@@ -649,6 +655,9 @@
<strong>票务客服台</strong>
<span>咨询订票、票号与凭证</span>
</span>
<span class="tm-ai-fold-btn" title="折叠" style="margin-left: 8px; padding: 4px; color: var(--muted); border: 1px solid var(--border); border-radius: 0;">
<i class="fas fa-chevron-right"></i>
</span>
</button>
<div class="tm-ai-panel" aria-live="polite">
<div class="tm-ai-header">
@@ -872,6 +881,13 @@
}
};
const foldBtn = root.querySelector('.tm-ai-fold-btn');
foldBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleButton.classList.toggle('is-folded');
});
toggleButton.addEventListener('click', () => {
if (root.classList.contains('is-open')) closePanel();
else openPanel();
+1 -72
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
@@ -44,67 +44,6 @@
</div>
<main class="jr-public-main">
<section class="jr-home-hero">
<article class="jr-home-hero-main">
<span class="jr-kicker">FSE PUBLIC TICKET PORTAL</span>
<h1>FSE 铁路运输票务系统</h1>
<p class="jr-home-hero-text">
⌈票行千里,智通未来⌋
购票、查询、办卡
</p>
<div class="jr-home-hero-actions">
<a href="https://ticket.fse-media.group/order" data-link="order" class="jr-cta-primary">开始预定</a>
<a href="https://ticket.fse-media.group/search" data-link="search" class="jr-cta-secondary">查询票据</a>
</div>
<div class="jr-home-hero-stats">
<div class="jr-home-stat">
<strong>线上预定</strong>
<span>生成凭证码并在站内兑票</span>
</div>
<div class="jr-home-stat">
<strong>电子票查询</strong>
<span>支持票号、区间和日期检索</span>
</div>
<div class="jr-home-stat">
<strong>线路资源</strong>
<span>查看线路图与票价图</span>
</div>
</div>
</article>
<aside class="jr-home-hero-side">
<div class="jr-home-side-head">
<span class="jr-kicker">SERVICE GUIDE</span>
<h2>乘车流程</h2>
</div>
<ol class="jr-process-list jr-home-process">
<li>在线选择起点、终点、车型和乘次数量</li>
<li>确认票价与路径后生成订单凭证</li>
<li>前往游戏内售票机输入凭证码兑票</li>
</ol>
<div class="jr-home-side-strip">
<div>
<span>支持服务</span>
<strong>订票 / 查询 / 办卡</strong>
</div>
</div>
</aside>
</section>
<section class="jr-home-alert">
<div class="jr-alert-title">
<i class="fas fa-circle-info"></i>
<span>旅客提醒</span>
</div>
<p>线上预定生成的凭证码与IC卡订单号请及时保存;如需补查订单、车票状态或IC卡信息,可在对应查询页继续检索。</p>
</section>
<section class="jr-page-intro">
<span class="jr-kicker">SERVICE ENTRY</span>
<h1>从首页直接进入票务服务</h1>
<p>线上预定 / 车票查询 / 凭证核验 / IC卡</p>
</section>
<section class="jr-home-service-grid">
<a href="https://ticket.fse-media.group/order" data-link="order" class="jr-home-service-card jr-home-service-primary">
<span class="jr-feature-icon"><i class="fas fa-ticket-alt"></i></span>
@@ -141,16 +80,6 @@
<span>输入卡号或订单号,查看卡状态、余额与最近记录。</span>
</span>
</a>
<article class="jr-home-mini-card">
<span class="jr-kicker">POPULAR</span>
<h3>常用操作</h3>
<ul class="jr-guide-list">
<li>先订票,再截图保存凭证码。</li>
<li>线上购卡后请同时保存卡号与订单号。</li>
<li>如票据状态不明,优先进入查询页核验。</li>
<li>查看线路资源时可对照票价图与线路图。</li>
</ul>
</article>
</section>
<section class="jr-home-assets">
+1 -8
View File
@@ -88,15 +88,8 @@
<section class="jr-page-intro jr-admin-intro">
<span class="jr-kicker">IC MANAGEMENT</span>
<h1>IC 卡发行与状态管理</h1>
<p>延续公共页面的白底门户风格,让发卡、储值与事件记录在同一块工作区域中保持清晰易读。</p>
</section>
<section class="jr-home-alert jr-admin-alert">
<div class="jr-alert-title">
<i class="fas fa-circle-info"></i>
<span>业务范围</span>
</div>
<p>当前页面用于处理 IC 卡创建、余额管理、持卡人资料和事件流查看,适合作为后台卡务管理的单独入口。</p>
</section>
<div class="grid">
<div class="card">
<div class="stat-label">IC 卡总数</div>
+35 -4
View File
@@ -188,19 +188,50 @@
</div>
`;
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"] || '---';
const timeStr = formatTime(ev?.ts);
const balance = ev?.balance ?? ev?.["余额"] ?? 0;
if (type === 'create' || type === 'order_created' || type === 'activated') {
const cardType = ev?.detail?.card_type || ev?.card_type || ev?.["类型"] || '储值卡';
return `[${cardType}];余额: ${balance};时间: ${timeStr}`;
}
if (type === 'topup' || type === 'refill' || action === 'topup') {
const amount = ev?.amount ?? ev?.["充值数额"] ?? 0;
return `机器ID: ${deviceId};充值数额: ${amount};余额: ${balance};时间: ${timeStr}`;
}
if (action === 'entry' || type === 'entry') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};余额: ${balance};时间: ${timeStr}`;
}
if (action === 'exit' || type === 'exit') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};余额: ${balance};时间: ${timeStr}`;
}
// fallback
return `时间: ${timeStr};${typeof ev?.detail === 'string' ? ev.detail : JSON.stringify(ev?.detail || {}, null, 2)}`;
};
const events = state.selectedEvents;
eventEl.innerHTML = events.length ? events.map((event) => `
<div class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<div class="flex between">
<span style="font-weight:600;">${escapeHtml(eventTitle(event))}</span>
<span class="text-muted" style="font-size:0.8rem;">${escapeHtml(formatTime(event.ts))}</span>
<strong style="font-weight:600;">${escapeHtml(eventTitle(event))}</strong>
</div>
<div class="log-detail text-muted" style="font-size: 0.85rem; margin-top: 4px;">
${escapeHtml(formatIcCardEventUnified(event))}
</div>
<div class="log-detail" style="margin-top:8px;">${escapeHtml(typeof event.detail === 'string' ? event.detail : JSON.stringify(event.detail || {}, null, 2))}</div>
</div>
</div>
`).join('') : '<div class="loading">暂无事件记录。</div>';
`).join('') : '<div class="text-muted" style="padding: 20px 0; text-align: center;">暂无事件记录</div>';
};
const validateHolderName = (value) => {
-12
View File
@@ -44,18 +44,6 @@
</div>
</div>
<main class="jr-public-main">
<section class="jr-page-intro">
<span class="jr-kicker">IC CARD ORDER</span>
<h1>在线购买 IC 卡并生成领卡凭证</h1>
<p>提交持卡人姓名并选择首次充值金额后,系统会即时生成卡号和 5 位凭证码,旅客可凭凭证码到站内办理领卡。</p>
</section>
<section class="jr-home-alert">
<div class="jr-alert-title">
<i class="fas fa-circle-info"></i>
<span>购卡提醒</span>
</div>
<p>线上购卡创建后默认状态为“待领卡”;持卡人姓名仅支持英文与常用符号。如需补查凭证或卡片状态,可前往 IC 卡查询页输入卡号或凭证码检索。</p>
</section>
<section class="jr-grid-two">
<article class="jr-panel-card">
<div class="jr-panel-headline">
+4 -4
View File
@@ -69,15 +69,15 @@
? state.rechargeOptions
: [5, 10, 15, 20];
rechargeOptionListEl.innerHTML = options.map((amount) => `
<button type="button" class="jr-card-plan ${!state.customMode && Number(state.selectedAmount) === Number(amount) ? 'is-active' : ''}" data-amount="${escapeHtml(amount)}">
<div class="jr-card-plan ${!state.customMode && Number(state.selectedAmount) === Number(amount) ? 'is-active' : ''}" data-amount="${escapeHtml(amount)}">
<span class="jr-card-plan-title">${escapeHtml(amount)}</span>
<span class="jr-card-plan-desc">首次充值 ${escapeHtml(amount)}</span>
</button>
</div>
`).join('') + `
<button type="button" class="jr-card-plan jr-card-plan-compact ${state.customMode ? 'is-active' : ''}" data-custom="true">
<div class="jr-card-plan jr-card-plan-compact ${state.customMode ? 'is-active' : ''}" data-custom="true">
<span class="jr-card-plan-title">自定义</span>
<span class="jr-card-plan-desc">手动输入首次充值金额</span>
</button>
</div>
`;
if (customRechargeBoxEl) {
customRechargeBoxEl.classList.toggle('is-active', !!state.customMode);
+2 -59
View File
@@ -49,23 +49,7 @@
<h1>按卡号或凭证码查询 IC 卡状态</h1>
<p>支持检索 IC 卡当前状态、余额和最近操作记录;输入线上购卡生成的凭证码,也能反查对应卡片。</p>
</section>
<section class="jr-query-overview jr-grid-three" aria-label="IC 卡查询摘要">
<article class="jr-query-stat">
<span class="jr-query-stat-label">检索方式</span>
<strong>卡号 / 凭证码</strong>
<p>支持凭证码反查对应卡片,也支持直接输入卡号查看当前状态与余额。</p>
</article>
<article class="jr-query-stat">
<span class="jr-query-stat-label">结果浏览</span>
<strong>列表与详情并排</strong>
<p>左侧浏览卡片列表,右侧查看卡片详情、状态提示和最近操作记录。</p>
</article>
<article class="jr-query-stat">
<span class="jr-query-stat-label">移动端</span>
<strong>触达区更大</strong>
<p>手机端自动切换为单列阅读,卡片点击区域与按钮尺寸都更适合触屏操作。</p>
</article>
</section>
<section class="jr-panel-card" style="margin-bottom:24px;">
<div class="jr-panel-headline">
<h2>检索条件</h2>
@@ -116,48 +100,7 @@
</div>
</div>
</article>
<div class="jr-grid-two">
<article class="jr-panel-card jr-guide-card">
<div class="jr-panel-headline">
<h3>状态说明</h3>
<span class="jr-panel-note">Card Status</span>
</div>
<div class="jr-guide-list">
<div class="jr-guide-item">
<strong>正常</strong>
<span>卡片已启用,可在检票设备直接刷卡进出站。</span>
</div>
<div class="jr-guide-item">
<strong>待领卡</strong>
<span>请持购卡凭证码前往站内售票机完成领卡后再使用。</span>
</div>
<div class="jr-guide-item">
<strong>不可用</strong>
<span>卡片已停用、挂失或退款,建议联系站务进行处理。</span>
</div>
</div>
</article>
<article class="jr-panel-card jr-guide-card">
<div class="jr-panel-headline">
<h3>查询提示</h3>
<span class="jr-panel-note">Search Guide</span>
</div>
<div class="jr-guide-list">
<div class="jr-guide-item">
<strong>留空查询</strong>
<span>不输入关键字时,会按建卡时间倒序展示全部 IC 卡记录。</span>
</div>
<div class="jr-guide-item">
<strong>凭证反查</strong>
<span>购卡后若未领卡,可直接使用凭证码快速定位对应卡片。</span>
</div>
<div class="jr-guide-item">
<strong>手机查看</strong>
<span>移动端会把结果列表、详情和事件记录按顺序折叠为单列阅读。</span>
</div>
</div>
</article>
</div>
</section>
</section>
<footer class="site-footer jr-footer-space">
+31 -2
View File
@@ -87,6 +87,36 @@
`;
};
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"] || '---';
const timeStr = formatTime(ev?.ts);
const balance = ev?.balance ?? ev?.["余额"] ?? 0;
if (type === 'create' || type === 'order_created' || type === 'activated') {
const cardType = ev?.detail?.card_type || ev?.card_type || ev?.["类型"] || '储值卡';
return \`[${cardType}];余额: \${balance};时间: \${timeStr}\`;
}
if (type === 'topup' || type === 'refill' || action === 'topup') {
const amount = ev?.amount ?? ev?.["充值数额"] ?? 0;
return \`机器ID: \${deviceId};充值数额: \${amount};余额: \${balance};时间: \${timeStr}\`;
}
if (action === 'entry' || type === 'entry') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return \`机器ID: \${deviceId};站点编号: \${code};站点中文: \${name};余额: \${balance};时间: \${timeStr}\`;
}
if (action === 'exit' || type === 'exit') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return \`机器ID: \${deviceId};站点编号: \${code};站点中文: \${name};余额: \${balance};时间: \${timeStr}\`;
}
// fallback
return \`时间: \${timeStr};\${typeof ev?.detail === 'string' ? ev.detail : JSON.stringify(ev?.detail || {}, null, 2)}\`;
};
const buildEventsHtml = (events) => {
if (!events.length) {
return '<div class="jr-center-empty" style="min-height:180px;"><p>暂无相关事件记录。</p></div>';
@@ -95,9 +125,8 @@
<div class="jr-history-item">
<div class="jr-history-row">
<span class="jr-history-title">${escapeHtml(eventTitle(event))}</span>
<span class="jr-history-time">${escapeHtml(formatTime(event.ts))}</span>
</div>
<div class="jr-history-desc">${escapeHtml(typeof event.detail === 'string' ? event.detail : JSON.stringify(event.detail || {}, null, 2))}</div>
<div class="jr-history-desc">${escapeHtml(formatIcCardEventUnified(event))}</div>
</div>
`).join('');
};
+51 -80
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<!-- 充满未知和不稳定的票务系统! -->
@@ -20,7 +20,7 @@
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" class="jr-top-link" id="adminTopLink">
<i class="fas fa-train"></i>
<span>FSE 铁路运输后台系统</span>
<span>FSE铁路票务系统控制台</span>
</a>
</div>
</header>
@@ -30,8 +30,8 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="adminBrandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE 铁路运输</strong>
<span>后台控制台</span>
<strong>FarSight-T.N.E铁路运输</strong>
<span>控制台</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
@@ -50,7 +50,7 @@
<div class="jr-admin-sidebar-head">
<span class="jr-kicker">OPERATIONS CONSOLE</span>
<div class="brand">FSE铁路票务系统控制台</div>
<p class="jr-admin-sidebar-copy">后台管理页统一使用 JR 风格的门户视觉,集中处理票务、线路、资源和运营日志。</p>
<p class="jr-admin-sidebar-copy">集中处理票务、线路、资源和运营日志。</p>
</div>
<div class="nav">
<a href="https://ticket.fse-media.group" id="homeLink" class="nav-item" style="text-decoration: none;">
@@ -68,16 +68,16 @@
<span class="nav-icon"><i class="fas fa-ticket-alt"></i></span> 车票记录
</div>
<div class="nav-item" :class="{active: currentView === 'vouchers'}" @click="currentView = 'vouchers'">
<span class="nav-icon"><i class="fas fa-receipt"></i></span> 凭证管理
<span class="nav-icon"><i class="fas fa-receipt"></i></span> 凭证记录
</div>
<div class="nav-item" :class="{active: currentView === 'iccards'}" @click="currentView = 'iccards'">
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC 卡管理
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC卡管理
</div>
<div class="nav-item" :class="{active: currentView === 'assets'}" @click="currentView = 'assets'">
<span class="nav-icon"><i class="fas fa-route"></i></span> 线路图
</div>
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="currentView = 'settings'">
<span class="nav-icon"><i class="fas fa-cog"></i></span> 优惠设置
<span class="nav-icon"><i class="fas fa-cog"></i></span> 系统设置
</div>
<div class="nav-item" :class="{active: currentView === 'logs'}" @click="currentView = 'logs'">
<span class="nav-icon"><i class="fas fa-list"></i></span> 日志
@@ -92,7 +92,7 @@
<div class="flex" style="gap: 12px;">
<button class="icon-btn mobile-only" @click="sidebarOpen = !sidebarOpen" title="菜单"><i class="fas fa-bars"></i></button>
<div>
<span class="jr-kicker">JR STYLE ADMIN</span>
<span class="jr-kicker">OPERATIONS CONSOLE</span>
<h3 style="margin: 0;">{{ viewTitle }}</h3>
</div>
</div>
@@ -114,43 +114,10 @@
</div>
<div class="content">
<section class="jr-page-intro jr-admin-intro">
<span class="jr-kicker">CENTRAL MANAGEMENT</span>
<span class="jr-kicker">OPERATIONS CONSOLE</span>
<h1>铁路票务后台控制台</h1>
<p>参照公开页的信息层级组织后台入口,把线路、售票、资源和日志管理统一放入同一套铁路门户式界面。</p>
</section>
<section class="jr-home-alert jr-admin-alert">
<div class="jr-alert-title">
<i class="fas fa-circle-info"></i>
<span>控制台概览</span>
</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">
@@ -457,6 +424,24 @@
<!-- 凭证管理 -->
<div v-if="currentView === 'vouchers'">
<div class="grid">
<div class="card">
<div class="stat-label">凭证总数</div>
<div class="stat-value">{{ orderStats.total }}</div>
</div>
<div class="card">
<div class="stat-label">待使用</div>
<div class="stat-value">{{ orderStats.valid }}</div>
</div>
<div class="card">
<div class="stat-label">已使用</div>
<div class="stat-value">{{ orderStats.used }}</div>
</div>
<div class="card">
<div class="stat-label">已过期</div>
<div class="stat-value">{{ orderStats.expired }}</div>
</div>
</div>
<div class="card">
<div class="flex between mb-4">
<h4>凭证列表</h4>
@@ -467,11 +452,11 @@
<table class="ticket-table">
<thead>
<tr>
<th>凭证</th>
<th>凭证</th>
<th>线路</th>
<th>车型</th>
<th>票价</th>
<th>状态</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
@@ -503,17 +488,7 @@
</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>
@@ -535,18 +510,7 @@
<div class="management-container ic-admin-layout">
<div class="management-sidebar">
<div class="card jr-admin-note-card">
<div class="flex between mb-4">
<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>
@@ -588,11 +552,10 @@
<div class="flex between mb-4">
<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>
@@ -620,11 +583,7 @@
<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">
@@ -684,6 +643,20 @@
<!-- 车票记录 -->
<div v-if="currentView === 'tickets'">
<div class="grid">
<div class="card">
<div class="stat-label">车票总数</div>
<div class="stat-value">{{ ticketStats.total }}</div>
</div>
<div class="card">
<div class="stat-label">有效</div>
<div class="stat-value">{{ ticketStats.valid }}</div>
</div>
<div class="card">
<div class="stat-label">已使用</div>
<div class="stat-value">{{ ticketStats.used }}</div>
</div>
</div>
<div class="card mb-4">
<div class="flex between mb-4">
<h4>车票记录</h4>
@@ -786,11 +759,9 @@
<div class="timeline-content">
<div class="flex between">
<span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span>
<span class="text-muted" style="font-size: 0.8rem;">{{ formatTime(ev['时间戳'] || ev.ts) }}</span>
</div>
<div class="text-muted" style="font-size: 0.9rem;">
<div>{{ formatTicketEventLocation(ev) }}</div>
<div v-if="formatTicketEventExtra(ev)" style="margin-top: 4px;">{{ formatTicketEventExtra(ev) }}</div>
<div class="text-muted" style="font-size: 0.9rem; margin-top: 4px;">
{{ formatTicketEventUnified(ev) }}
</div>
</div>
</div>
+72 -25
View File
@@ -345,23 +345,31 @@ createApp({
return String(value);
};
const formatTicketEventExtra = (event) => {
const type = getTicketEventType(event);
const amount = event?.["售票额"] ?? event?.amount ?? event?.price ?? event?.cost;
const stationEn = event?.["站点英文"] || event?.station_en || '';
const deviceId = event?.["设备编号"] || event?.device_id || event?.device || '';
const attachment = formatTicketEventAttachment(
event?.["附加信息"] ?? event?.extra ?? event?.info ?? event?.meta ?? event?.detail
);
const parts = [];
if ((type === 'sale' || type === '售票') && amount != null && amount !== '') {
parts.push(`票价:¥ ${amount}`);
const formatTicketEventUnified = (ev) => {
const type = getTicketEventType(ev);
const action = getTicketEventAction(ev);
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
const timeStr = formatTime(ev?.ts || ev?.["时间戳"]);
if (type === 'sale' || type === '售票') {
const cost = ev?.cost ?? ev?.price ?? ev?.amount ?? ev?.["票价"] ?? ev?.["售票额"] ?? '0';
return `机器ID: ${deviceId};票价: ${cost};时间: ${timeStr}`;
}
if (stationEn && deviceId) parts.push(`${stationEn} (${deviceId})`);
else if (deviceId) parts.push(`设备:${deviceId}`);
else if (stationEn) parts.push(stationEn);
if (attachment) parts.push(attachment);
return parts.join(' | ');
if (type === 'entry' || action === 'entry') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
}
if (type === 'exit' || action === 'exit') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
}
// fallback
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
};
const formatLogDetail = (l) => {
@@ -440,14 +448,34 @@ createApp({
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
};
const formatIcEventDetail = (event) => {
const detail = event?.detail != null ? event.detail : event;
if (typeof detail === 'string') return detail;
try {
return JSON.stringify(detail || {}, null, 2);
} catch (_) {
return String(detail || '');
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"] || '---';
const timeStr = formatTime(ev?.ts);
const balance = ev?.balance ?? ev?.["余额"] ?? 0;
if (type === 'create' || type === 'order_created' || type === 'activated') {
const cardType = ev?.detail?.card_type || ev?.card_type || ev?.["类型"] || '储值卡';
return \`[${cardType}];余额: \${balance};时间: \${timeStr}\`;
}
if (type === 'topup' || type === 'refill' || action === 'topup') {
const amount = ev?.amount ?? ev?.["充值数额"] ?? 0;
return \`机器ID: \${deviceId};充值数额: \${amount};余额: \${balance};时间: \${timeStr}\`;
}
if (action === 'entry' || type === 'entry') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return \`机器ID: \${deviceId};站点编号: \${code};站点中文: \${name};余额: \${balance};时间: \${timeStr}\`;
}
if (action === 'exit' || type === 'exit') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return \`机器ID: \${deviceId};站点编号: \${code};站点中文: \${name};余额: \${balance};时间: \${timeStr}\`;
}
// fallback
return \`时间: \${timeStr};\${typeof ev?.detail === 'string' ? ev.detail : JSON.stringify(ev?.detail || {}, null, 2)}\`;
};
const getStationName = (code) => {
@@ -1540,6 +1568,25 @@ createApp({
(t.terminal && t.terminal.toLowerCase().includes(q))
).slice(0, 50);
});
const ticketStats = computed(() => {
const all = tickets.value;
return {
total: all.length,
valid: all.filter(t => t.status === 'valid').length,
used: all.filter(t => t.status === 'used').length
};
});
const orderStats = computed(() => {
const all = orders.value;
return {
total: all.length,
valid: all.filter(o => o.status === 'valid').length,
used: all.filter(o => o.status === 'used').length,
expired: all.filter(o => o.status === 'expired').length
};
});
const exportFareMap = () => {
const svgData = fareMapSvg.value;
@@ -1582,7 +1629,7 @@ createApp({
return {
currentView, viewTitle, sidebarOpen,
loadingState, isViewBusy, lastSyncText, currentViewSummary,
stations, lines, fares, stats, config, recentLogs, ticketList,
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
orders, orderList, fetchOrders, deleteOrder,
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
@@ -1602,7 +1649,7 @@ createApp({
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,
// Tickets
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventExtra, formatLogType, formatTrainType,
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventUnified, formatLogType, formatTrainType,
saveCurrentFare, deleteCurrentFare, closeFareModal,
+2 -2
View File
@@ -23,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>FarSight-T.N.E铁路运输</strong>
<span>控制台登录</span>
<strong>FSE铁路票务系统控制台</strong>
<span>登录</span>
</div>
</a>
</div>
+37 -6
View File
@@ -183,12 +183,12 @@ input, select, textarea, .voucher-code, .st-name, .st-code, .line-name {
.nav-item {
padding: 10px 16px;
border-radius: 6px;
border-radius: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
color: var(--muted);
color: var(--text);
font-weight: 500;
transition: all 0.2s ease;
}
@@ -251,7 +251,7 @@ button, .btn {
align-items: center;
justify-content: center;
padding: 8px 16px;
border-radius: 6px;
border-radius: 0;
border: 1px solid var(--border);
background-color: transparent;
color: var(--text);
@@ -1738,7 +1738,7 @@ body.jr-order-page .site-footer .version {
.jr-nav a {
min-width: 104px;
padding: 10px 16px;
border-radius: 999px;
border-radius: 0;
text-decoration: none;
color: #385042;
font-weight: 700;
@@ -3622,11 +3622,25 @@ body.jr-ticket-board-page .jr-board-card:last-child {
.jr-voucher-layout,
.jr-board-layout,
.jr-home-hero,
.jr-home-assets,
.jr-card-plan-grid {
.jr-home-assets {
grid-template-columns: 1fr;
}
.jr-card-plan-grid {
display: flex;
flex-direction: row;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
gap: 12px;
padding-bottom: 8px;
}
.jr-card-plan-grid > * {
flex: 0 0 75%;
scroll-snap-align: center;
}
.jr-grid-three,
.jr-home-service-grid {
grid-template-columns: 1fr;
@@ -3826,6 +3840,8 @@ body.jr-admin-login-page {
.jr-admin-page .nav-item.active {
background: #0b6b3a;
border-color: #0b6b3a;
color: #ffffff;
font-weight: 700;
box-shadow: none;
}
@@ -4289,6 +4305,21 @@ body.jr-admin-login-page {
overflow: auto;
}
.switch-label {
width: 100%;
margin-left: 0 !important;
margin-bottom: 8px;
}
.jr-admin-list-card {
min-height: auto !important;
}
.jr-admin-list-card .jr-scroll-box {
max-height: 400px;
-webkit-overflow-scrolling: touch;
}
.jr-admin-page .header,
.jr-admin-page .content {
padding-left: 16px;
+1 -79
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
@@ -44,70 +44,6 @@
</div>
</div>
<main class="jr-main">
<section class="jr-hero">
<div class="jr-hero-copy">
<p class="jr-eyebrow">FSE ONLINE RESERVATION</p>
<h1>完成一张车票预定</h1>
<p class="jr-hero-text">
先在线选择区间与车型,再生成凭证码,最后在游戏内售票机完成兑票。</p>
<div class="jr-hero-actions">
<a href="#reservationPanel" class="jr-cta-primary">开始预定</a>
<a href="https://ticket.fse-media.group/search" data-link="search" class="jr-cta-secondary">查询已有订单</a>
</div>
</div>
<aside class="jr-hero-panel">
<div class="jr-panel-head">
<span class="jr-panel-kicker">旅客须知</span>
<h2>预定流程</h2>
</div>
<ol class="jr-process-list">
<li>在线选择出发站、到达站与车型。</li>
<li>系统自动估算票价并显示预计路径。</li>
<li>生成凭证码后,在游戏内任意售票机兑票。</li>
</ol>
<div class="jr-service-strip">
<div>
<span class="jr-service-label">售票模式</span>
<strong>线上预订 / 站内兑票</strong>
</div>
<div>
<span class="jr-service-label">支持车型</span>
<strong>普通 / 特急</strong>
</div>
</div>
</aside>
</section>
<section class="jr-alert-band" aria-label="服务公告">
<div class="jr-alert-title">
<i class="fas fa-circle-info"></i>
<span>服务提醒</span>
</div>
<p>
票价将根据当前配置自动计算;生成的凭证码仅用于一次有效兑票,请妥善保存。</p>
</section>
<section class="jr-quick-links" aria-label="快捷入口">
<a class="jr-quick-link" href="https://ticket.fse-media.group/search" data-link="search">
<span class="jr-quick-icon"><i class="fas fa-magnifying-glass"></i></span>
<span class="jr-quick-text">
<strong>车票查询</strong>
<span>查询已有票据与乘车信息</span>
</span>
</a>
<a class="jr-quick-link" href="#reservationPanel">
<span class="jr-quick-icon"><i class="fas fa-route"></i></span>
<span class="jr-quick-text">
<strong>选择线路</strong>
<span>在系统线路图中选择起终点</span>
</span>
</a>
<a class="jr-quick-link" href="#voucherPanel">
<span class="jr-quick-icon"><i class="fas fa-receipt"></i></span>
<span class="jr-quick-text">
<strong>查看凭证</strong>
<span>预定成功后获取兑票代码</span>
</span>
</a>
</section>
<section class="jr-content-grid">
<div class="jr-column-main">
<article class="jr-section-card" id="reservationPanel">
@@ -208,20 +144,6 @@
</div>
</div>
</article>
<article class="jr-section-card jr-side-card">
<div class="jr-section-head">
<div>
<p class="jr-section-label">Guide</p>
<h2>使用说明</h2>
</div>
</div>
<ul class="jr-guide-list">
<li>若重新选择站点,票价与路径会自动重新计算。</li>
<li>凭证码生成后建议立即截图或复制保存。</li>
<li>如需核验订单,可前往“车票查询”页面查看详情。</li>
</ul>
</article>
</aside>
</section>
+15 -210
View File
@@ -56,49 +56,14 @@
};
async function loadMap() {
mapContainer.innerHTML = '<div class="loading">加载地图中...</div>';
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;
renderLineMap();
} catch(e) {
mapContainer.innerHTML = '<div class="error">加载地图失败</div>';
}
}
async function renderLineMap() {
try {
const [lines, stations] = await Promise.all([
fetch('/api/public/lines').then(r=>r.json()),
const [res, stationsData] = await Promise.all([
fetch('/api/public/fares/map/light?t=' + Date.now()),
fetch('/api/public/stations').then(r=>r.json())
]);
let html = '';
lines.forEach(line => {
const stList = line.站序 || [];
});
} catch(e) {}
}
async function renderSystemMap() {
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
try {
const [linesData, stationsData] = await Promise.all([
fetch('/api/public/lines').then(r=>r.json()),
fetch('/api/public/stations').then(r=>r.json())
]);
window.cachedStationsData = stationsData;
stationTransfer = new Set();
stationCanonicalByCode = {};
stationCodesByCanonical = {};
stationNameByCode = {};
stationEnByCode = {};
for (const s of stationsData) {
@@ -108,182 +73,22 @@
stationEnByCode[code] = String(s.en_name || s.英文名 || '');
}
const transferGroups = buildTransferGroups(stationsData);
stationCanonicalByCode = transferGroups.canonicalByCode;
stationCodesByCanonical = transferGroups.codesByCanonical;
for (const codes of Object.values(stationCodesByCanonical)) {
if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code));
}
const lineStops = [];
const lineColors = [];
const linesByStation = new Map();
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 });
lineColors.push(color);
for (const c of stops) {
const arr = linesByStation.get(c) || [];
arr.push(lineStops.length - 1);
linesByStation.set(c, arr);
}
}
if (lineStops.length === 0) {
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>暂无可显示的线路数据。</p></div>';
return;
}
for (const [c, arr] of linesByStation.entries()) {
const uniq = Array.from(new Set(arr));
if (uniq.length >= 2) stationTransfer.add(c);
}
const svg = await res.text();
mapContainer.innerHTML = svg;
// 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 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 = {};
for (const c of Object.keys(primaryLineByStation)) {
const li = primaryLineByStation[c];
if (li == null) continue;
stationYByCanonical[c] = baseY + li * lineGapY;
}
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 firstX = stationXByCanonical[stationCanonicalByCode[li.stops[0]] || li.stops[0]];
let d = `M ${firstX} ${yLine}`;
for (let i = 1; i < li.stops.length; i++) {
const x = stationXByCanonical[stationCanonicalByCode[li.stops[i]] || li.stops[i]];
d += ` L ${x} ${yLine}`;
}
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 ys = codes.map(code => stationYByCanonical[code]).sort((a, b) => a - b);
const x = stationXByCanonical[canonical];
const labelY = ys[ys.length - 1] + 34;
svgContent += `<line x1="${x}" y1="${ys[0]}" x2="${x}" y2="${ys[ys.length - 1]}" stroke="#cbd5e1" stroke-width="12" stroke-linecap="round" opacity="0.98" />`;
svgContent += `<circle cx="${x}" cy="${(ys[0] + ys[ys.length - 1]) / 2}" 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
const svgEl = mapContainer.querySelector('svg');
if(!svgEl) return;
svgEl.querySelectorAll('.map-station').forEach(el => {
el.addEventListener('click', () => {
window.handleStationClick(el.getAttribute('data-code'));
});
});
} 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
@@ -506,7 +311,7 @@
}
// Load Map on Start
renderSystemMap();
loadMap();
async function previewPrice(force = false){
const seq = ++previewSeq;
+4 -12
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<!-- 充满未知和不稳定的票务系统! -->
@@ -114,13 +114,7 @@
<h1>线路规划与票价维护</h1>
<p>线路结构、站点编辑、换乘关系和票价地图</p>
</section>
<section class="jr-home-alert jr-admin-alert">
<div class="jr-alert-title">
<i class="fas fa-circle-info"></i>
<span>线路维护提示</span>
</div>
<p>当前已加载 {{ lines.length }} 条线路,{{ selectedLine ? `正在编辑 ${selectedLine.name || selectedLine.id}` : '尚未选择线路' }}。编辑前可先在左侧确认线路列表和票价地图预览。</p>
</section>
<div v-if="currentView === 'management'" class="management-container">
<div class="management-sidebar">
<div class="card"
@@ -566,11 +560,9 @@
<div class="timeline-content">
<div class="flex between">
<span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span>
<span class="text-muted" style="font-size: 0.8rem;">{{ formatTime(ev['时间戳'] || ev.ts) }}</span>
</div>
<div class="text-muted" style="font-size: 0.9rem;">
<div>{{ formatTicketEventLocation(ev) }}</div>
<div v-if="formatTicketEventExtra(ev)" style="margin-top: 4px;">{{ formatTicketEventExtra(ev) }}</div>
<div class="text-muted" style="font-size: 0.9rem; margin-top: 4px;">
{{ formatTicketEventUnified(ev) }}
</div>
</div>
</div>
+25 -17
View File
@@ -225,23 +225,31 @@ createApp({
return String(value);
};
const formatTicketEventExtra = (event) => {
const type = getTicketEventType(event);
const amount = event?.["售票额"] ?? event?.amount ?? event?.price ?? event?.cost;
const stationEn = event?.["站点英文"] || event?.station_en || '';
const deviceId = event?.["设备编号"] || event?.device_id || event?.device || '';
const attachment = formatTicketEventAttachment(
event?.["附加信息"] ?? event?.extra ?? event?.info ?? event?.meta ?? event?.detail
);
const parts = [];
if ((type === 'sale' || type === '售票') && amount != null && amount !== '') {
parts.push(`票价:¥ ${amount}`);
const formatTicketEventUnified = (ev) => {
const type = getTicketEventType(ev);
const action = getTicketEventAction(ev);
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
const timeStr = formatTime(ev?.ts || ev?.["时间戳"]);
if (type === 'sale' || type === '售票') {
const cost = ev?.cost ?? ev?.price ?? ev?.amount ?? ev?.["票价"] ?? ev?.["售票额"] ?? '0';
return `机器ID: ${deviceId};票价: ${cost};时间: ${timeStr}`;
}
if (stationEn && deviceId) parts.push(`${stationEn} (${deviceId})`);
else if (deviceId) parts.push(`设备:${deviceId}`);
else if (stationEn) parts.push(stationEn);
if (attachment) parts.push(attachment);
return parts.join(' | ');
if (type === 'entry' || action === 'entry') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
}
if (type === 'exit' || action === 'exit') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
}
// fallback
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
};
const formatLogDetail = (l) => {
@@ -958,7 +966,7 @@ createApp({
/* 订单 */
fetchOrders, deleteOrder,
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventExtra, formatLogType, formatTrainType,
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventUnified, formatLogType, formatTrainType,
saveCurrentFare, deleteCurrentFare, closeFareModal,
+2 -18
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
@@ -52,23 +52,7 @@
<p>输入完整票号、起终点、日期或关键词,左侧浏览结果,右侧查看票据详情与最近流转记录。</p>
</section>
<section class="jr-query-overview jr-grid-three" aria-label="车票查询摘要">
<article class="jr-query-stat">
<span class="jr-query-stat-label">检索方式</span>
<strong>票号 / 站点 / 日期</strong>
<p>支持完整票号与站点关键词联合查询,适合快速反查近期票据。</p>
</article>
<article class="jr-query-stat">
<span class="jr-query-stat-label">结果浏览</span>
<strong>列表与详情并排</strong>
<p>左侧先筛选票据,右侧立即查看电子票概览与最近流转记录。</p>
</article>
<article class="jr-query-stat">
<span class="jr-query-stat-label">移动端</span>
<strong>单列阅读更顺手</strong>
<p>手机端自动切为单列,查询、结果与详情会按操作顺序依次展开。</p>
</article>
</section>
<section class="jr-panel-card" style="margin-bottom:24px;">
<div class="jr-panel-headline">
+25 -14
View File
@@ -107,18 +107,31 @@
return [stationName, stationName && stationCode ? stationCode : ''].filter(Boolean).join(' ');
};
const formatEventExtra = (event) => {
const type = getEventType(event);
const formatTicketEventUnified = (ev) => {
const type = getEventType(ev);
const action = String(ev.action || ev["动作"] || '').toLowerCase();
const deviceId = ev.device || ev.device_id || ev["设备编号"] || ev["机器ID"] || '---';
const timeStr = formatTime(ev.ts || ev["时间戳"]);
if (type === 'sale' || type === '售票') {
const amount = event.amount ?? event["售票额"];
if (amount != null && amount !== '') return `票价:¥ ${amount}`;
const cost = ev.cost ?? ev.price ?? ev.amount ?? ev["票价"] ?? ev["售票额"] ?? '0';
return \`机器ID: \${deviceId};票价: \${cost};时间: \${timeStr}\`;
}
const stationEn = event.station_en || event["站点英文"] || '';
const deviceId = event["设备编号"] || event.device_id || '';
if (stationEn && deviceId) return `${stationEn} (${deviceId})`;
if (deviceId) return `设备:${deviceId}`;
return stationEn;
if (type === 'entry' || action === 'entry') {
const code = ev.station_code || ev["站点编号"] || '---';
const name = ev.station_name || ev["站点中文"] || ev["发生站"] || '---';
return \`机器ID: \${deviceId};站点编号: \${code};站点中文: \${name};时间: \${timeStr}\`;
}
if (type === 'exit' || action === 'exit') {
const code = ev.station_code || ev["站点编号"] || '---';
const name = ev.station_name || ev["站点中文"] || ev["发生站"] || '---';
return \`机器ID: \${deviceId};站点编号: \${code};站点中文: \${name};时间: \${timeStr}\`;
}
// fallback
const code = ev.station_code || ev["站点编号"] || '---';
const name = ev.station_name || ev["站点中文"] || ev["发生站"] || '---';
return \`机器ID: \${deviceId};站点编号: \${code};站点中文: \${name};时间: \${timeStr}\`;
};
function renderList(items) {
@@ -219,12 +232,10 @@
${evs.map(e => `
<div class="jr-history-item">
<div class="jr-history-row">
<span class="jr-history-title">${formatEventTitle(e)}</span>
<span class="jr-history-time">${formatTime(e.ts || e["时间戳"])}</span>
<span class="jr-history-title">${escapeHtml(formatEventTitle(e))}</span>
</div>
<div class="jr-history-desc">
<div>${formatEventLocation(e)}</div>
<div style="margin-top:4px;">${formatEventExtra(e) || '---'}</div>
<div style="margin-top:4px;">${escapeHtml(formatTicketEventUnified(e))}</div>
</div>
</div>
`).join('')}