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
+159 -169
View File
@@ -3,197 +3,187 @@ const fs = require('fs');
const escapeHTML = (s) => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); const escapeHTML = (s) => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
function generate(stations, lines, fares, nameByCode, transfers) { function generate(stations, lines, fares, nameByCode, transfers) {
const enNameByCode = (() => { const enNameByCode = {};
try { stations.forEach(s => { enNameByCode[String(s.code || '')] = s.en_name || s.enName || ''; });
const map = {}; for (const s of stations) { map[String(s.code || '')] = s.en_name || s.enName || ''; }
return map;
} catch (_) { return {}; }
})();
// Build linesForStation for coloring (still needed for visualization)
const linesForStation = {};
(lines || []).forEach(li => {
const id = li.id || '';
const name = li.name || li.cn_name || '';
const color = li.color || '#93a2b7';
const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x));
for (const code of arr) {
if (!linesForStation[code]) linesForStation[code] = [];
linesForStation[code].push({ id, name, color, stations: arr });
}
});
const groupsMap = {}; // 1. Build Graph
// Optional: We can still use groups for coloring logic, but NOT for graph edges if we want strict mode. const validStationCodes = new Set(stations.map(s => String(s.code || '')));
// But let's keep groupsMap for color logic (lines 30-46) as it helps determine if lines connect visually. const adj = new Map();
stations.forEach(s => { const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); };
const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim(); const addEdge = (u, v, w) => { addNode(u); addNode(v); adj.get(u).set(v, w); adj.get(v).set(u, w); };
if (!k) return; (groupsMap[k] ||= []).push(String(s.code || ''));
});
const groupsForColors = Object.values(groupsMap).filter(arr => arr.length > 1);
const colorsForRoute = (a, b) => {
const la = linesForStation[a] || [];
const lb = linesForStation[b] || [];
const same = la.find(x => lb.some(y => y.id === x.id));
if (same) return [same.color || '#93a2b7'];
// Logic to find connecting colors via transfer groups
for (const g of groupsForColors) {
const startLine = la.find(li => (li.stations || []).some(code => g.includes(code)));
const endLine = lb.find(li => (li.stations || []).some(code => g.includes(code)));
if (startLine && endLine) {
const c1 = startLine.color || '#93a2b7';
const c2 = endLine.color || '#93a2b7';
return (startLine.id === endLine.id) ? [c1] : [c1, c2];
}
}
return [];
};
const buildGraph = () => { stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); });
const adj = new Map();
const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); };
const addEdge = (u, v, w) => { addNode(u); addNode(v); adj.get(u).set(v, w); adj.get(v).set(u, w); };
// Create a set of valid station codes for fast lookup
const validStationCodes = new Set(stations.map(s => String(s.code || '')));
// 1. Edges from explicit FARES only const edgeCosts = new Map();
(fares || []).forEach(f => { (fares || []).forEach(f => {
const a = String(f.from || ''), b = String(f.to || ''); const a = String(f.from || ''), b = String(f.to || '');
if (!a || !b) return; if (a && b && validStationCodes.has(a) && validStationCodes.has(b)) {
if (!validStationCodes.has(a) || !validStationCodes.has(b)) return;
const w = Number(f.cost_regular ?? f.cost) || 0; const w = Number(f.cost_regular ?? f.cost) || 0;
addEdge(a, b, w); addEdge(a, b, w);
edgeCosts.set(`${a}|${b}`, w);
edgeCosts.set(`${b}|${a}`, w);
}
});
const transferSet = new Set();
if (Array.isArray(transfers)) {
transfers.forEach(p => {
const u = String(p[0] || ''), v = String(p[1] || '');
if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) {
addEdge(u, v, 0);
transferSet.add(`${u}|${v}`);
transferSet.add(`${v}|${u}`);
}
}); });
}
// 2. Edges from TRANSFERS (0 cost) // 2. Force Directed Layout
if (Array.isArray(transfers)) { const nodes = Array.from(validStationCodes).map(code => ({
transfers.forEach(p => { code,
const a = String(p[1] || ''), b = String(p[2] || ''); // startup.lua used index 1,2? No, lua is 1-based. JS is 0-based. x: Math.random() * 800,
// Wait, config transfers is [[a,b], [c,d]]. y: Math.random() * 600,
// Let's check how it's stored. usually JSON array of arrays. vx: 0, vy: 0,
// In startup.lua: p[1], p[2]. name: nameByCode[code] || code
// In JS: p[0], p[1]. }));
// But let's check standard. const nodeMap = new Map(nodes.map(n => [n.code, n]));
// If I look at startup.lua: `local a, b = tostring(p[1] or ''), tostring(p[2] or '')` (Lua 1-based index)
// So in JSON it is `["A", "B"]`.
// In JS `p[0]` is "A", `p[1]` is "B".
// Let's be safe and check both or iterate.
const u = String(p[0] || '');
const v = String(p[1] || '');
if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) {
addEdge(u, v, 0);
}
});
}
// REMOVED: Edges from Lines (implicit 0 cost) // Add line connections to edges for layout purposes
// REMOVED: Edges from Groups (implicit 0 cost) - unless we want to keep it as fallback? const layoutEdges = [];
// User said "Strictly use database data". If transfers are in DB, we use them. (lines || []).forEach(li => {
// If user relies on name grouping but didn't put it in DB transfers, they might be unhappy. const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x));
// But "Strictly use database data" implies we shouldn't automagically connect things. for (let i = 0; i < arr.length - 1; i++) {
// I will remove the auto-grouping for graph edges to be consistent with startup.lua changes. if (nodeMap.has(arr[i]) && nodeMap.has(arr[i+1])) {
layoutEdges.push({ source: nodeMap.get(arr[i]), target: nodeMap.get(arr[i+1]), type: 'line' });
stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); });
return adj;
};
const adj = buildGraph();
const codes = Array.from(adj.keys()).sort((a, b) => a.localeCompare(b));
const neighborsOf = (u) => adj.get(u) || new Map();
const dijkstra = (src) => {
const dist = {}; const visited = new Set();
for (const n of adj.keys()) dist[n] = Infinity; dist[src] = 0;
while (true) {
let u = null; let best = Infinity;
for (const n of adj.keys()) { if (!visited.has(n) && dist[n] < best) { best = dist[n]; u = n; } }
if (u === null) break; visited.add(u);
for (const [v, w] of neighborsOf(u)) {
const nd = dist[u] + Number(w || 0);
if (nd < dist[v]) dist[v] = nd;
} }
} }
return dist; });
};
const distAll = {}; codes.forEach(c => { distAll[c] = dijkstra(c); });
let maxFare = 1; for (const [u, edges] of adj.entries()) {
for (const r of codes) { for (const [v, w] of edges.entries()) {
for (const c of codes) { if (r === c) continue; const v = Number(distAll[r][c] || 0); if (v > maxFare) maxFare = v; } if (u < v && nodeMap.has(u) && nodeMap.has(v)) {
layoutEdges.push({ source: nodeMap.get(u), target: nodeMap.get(v), type: 'fare' });
}
}
} }
const margin = 8; const iterations = 300;
const cellW = 56; const k = Math.sqrt((1200 * 800) / nodes.length) || 100;
const cellH = 40; for (let i = 0; i < iterations; i++) {
const estimateWidth = (text, size) => Math.ceil(String(text || '').length * (size <= 10 ? 7 : 10)); const t = 1 - i / iterations;
const maxCnW = Math.max(1, ...codes.map(c => estimateWidth(nameByCode[c] || c, 14)));
const maxEnW = Math.max(1, ...codes.map(c => estimateWidth(enNameByCode[c] || '', 10))); // Repulsion
const maxCodeW = Math.max(1, ...codes.map(c => estimateWidth(c, 10))); for (let a = 0; a < nodes.length; a++) {
const leftW = Math.max(140, 24 + Math.max(maxCnW, maxEnW, maxCodeW)); for (let b = a + 1; b < nodes.length; b++) {
const topH = 64; const dx = nodes[a].x - nodes[b].x;
const n = codes.length; const dy = nodes[a].y - nodes[b].y;
const width = margin + leftW + n * cellW + margin; let dist = Math.sqrt(dx * dx + dy * dy);
const height = margin + topH + n * cellH + margin; if (dist === 0) { dist = 0.01; }
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:#ffffff;font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif">`; if (dist < k * 2) {
// Column headers const force = (k * k) / dist;
for (let j = 0; j < n; j++) { const fx = (dx / dist) * force;
const code = codes[j]; const name = nameByCode[code] || code; const en = enNameByCode[code] || ''; const fy = (dy / dist) * force;
const x = margin + leftW + j * cellW + cellW / 2; const y = margin + 20; nodes[a].vx += fx; nodes[a].vy += fy;
svg += `<text x="${x}" y="${y}" font-size="14" font-weight="900" fill="#000000" text-anchor="middle">${escapeHTML(name)}</text>`; nodes[b].vx -= fx; nodes[b].vy -= fy;
if (en) svg += `<text x="${x}" y="${y + 14}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(en)}</text>`; }
svg += `<text x="${x}" y="${y + 26}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(code)}</text>`; }
}
// Attraction
for (const e of layoutEdges) {
const dx = e.target.x - e.source.x;
const dy = e.target.y - e.source.y;
let dist = Math.sqrt(dx * dx + dy * dy);
if (dist === 0) dist = 0.01;
const idealDist = e.type === 'line' ? k * 0.8 : k * 1.5;
const force = (dist * dist) / idealDist;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
e.source.vx += fx; e.source.vy += fy;
e.target.vx -= fx; e.target.vy -= fy;
}
// Gravity
for (const n of nodes) {
const dx = 600 - n.x;
const dy = 400 - n.y;
n.vx += dx * 0.05;
n.vy += dy * 0.05;
}
for (const n of nodes) {
n.x += n.vx * t * 0.1;
n.y += n.vy * t * 0.1;
n.vx *= 0.8;
n.vy *= 0.8;
}
} }
for (let i = 0; i < n; i++) { // Normalize coordinates
const code = codes[i]; const name = nameByCode[code] || code; const en = enNameByCode[code] || ''; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const xText = margin + 6; const baseY = margin + topH + i * cellH + 22; nodes.forEach(n => {
svg += `<text x="${xText}" y="${baseY - 10}" font-size="14" font-weight="900" fill="#000000">${escapeHTML(name)}</text>`; minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
if (en) svg += `<text x="${xText}" y="${baseY + 4}" font-size="10" fill="#000000">${escapeHTML(en)}</text>`; maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y);
svg += `<text x="${xText}" y="${baseY + 16}" font-size="10" fill="#000000">${escapeHTML(code)}</text>`; });
for (let j = 0; j < n; j++) {
const x0 = margin + leftW + j * cellW; const y0 = margin + topH + i * cellH; const margin = 100;
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#ffffff" stroke="#e5e7eb"/>`; nodes.forEach(n => {
if (i === j) { n.x = n.x - minX + margin;
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" fill="#6b7280" text-anchor="middle">-</text>`; n.y = n.y - minY + margin;
} else { });
const val = Math.round(Number(distAll[code][codes[j]] || 0));
const intensity = maxFare > 0 ? Math.min(1, val / maxFare) : 0; const width = maxX - minX + margin * 2;
const height = maxY - minY + margin * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:#ffffff;font-family:Inter, system-ui, -apple-system, sans-serif">`;
// Draw lines
(lines || []).forEach(li => {
const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x));
const color = li.color || '#93a2b7';
if (arr.length < 2) return;
let d = '';
for (let i = 0; i < arr.length; i++) {
const n = nodeMap.get(arr[i]);
if (n) {
d += (i === 0 ? 'M' : 'L') + ` ${n.x} ${n.y}`;
}
}
if (d) {
svg += `<path d="${d}" fill="none" stroke="${escapeHTML(color)}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" opacity="0.7"/>`;
}
});
// Draw edges (fares and transfers)
const drawnEdges = new Set();
for (const [u, edges] of adj.entries()) {
for (const [v, w] of edges.entries()) {
if (u < v && nodeMap.has(u) && nodeMap.has(v)) {
const nu = nodeMap.get(u), nv = nodeMap.get(v);
const isTransfer = transferSet.has(`${u}|${v}`);
// Get route colors if (isTransfer) {
const routeColors = colorsForRoute(code, codes[j]); svg += `<line x1="${nu.x}" y1="${nu.y}" x2="${nv.x}" y2="${nv.y}" stroke="#94a3b8" stroke-width="6" stroke-dasharray="4 4" stroke-linecap="round" />`;
// Draw background strips for multiple lines
if (routeColors.length > 0) {
const stripW = cellW / routeColors.length;
for (let k = 0; k < routeColors.length; k++) {
const c = routeColors[k] || '#93a2b7';
const stripX = x0 + k * stripW;
// Use fill-opacity for robustness against any color format
svg += `<rect x="${stripX}" y="${y0}" width="${stripW}" height="${cellH}" fill="${escapeHTML(c)}" fill-opacity="0.15" stroke="none"/>`;
}
} else {
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#000000" fill-opacity="0.03" stroke="none"/>`;
} }
// Add cell border if (w > 0) {
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>`; const cx = (nu.x + nv.x) / 2;
const cy = (nu.y + nv.y) / 2;
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" font-weight="800" fill="#000000" text-anchor="middle">${escapeHTML(String(val))}</text>`; svg += `<rect x="${cx - 12}" y="${cy - 10}" width="24" height="20" rx="4" fill="#fef08a" stroke="#ca8a04" stroke-width="1"/>`;
svg += `<text x="${cx}" y="${cy + 4}" font-size="12" font-weight="bold" fill="#ca8a04" text-anchor="middle">${w}</text>`;
// Show small color indicators at top left
const dots = routeColors.slice(0, 4);
for (let k = 0; k < dots.length; k++) {
const cx = x0 + 8 + k * 10; const cy = y0 + 10;
const dotColor = dots[k] || '#93a2b7';
svg += `<circle cx="${cx}" cy="${cy}" r="3.5" fill="${escapeHTML(dotColor)}" stroke="white" stroke-width="1"/>`;
} }
} }
} }
} }
// Draw nodes
nodes.forEach(n => {
svg += `<g class="map-station" data-code="${escapeHTML(n.code)}" style="cursor:pointer">`;
svg += `<circle cx="${n.x}" cy="${n.y}" r="8" fill="#ffffff" stroke="#1e293b" stroke-width="3"/>`;
svg += `<text x="${n.x}" y="${n.y - 15}" font-size="14" font-weight="bold" fill="#0f172a" text-anchor="middle" style="pointer-events:none">${escapeHTML(n.name)}</text>`;
svg += `<text x="${n.x}" y="${n.y + 20}" font-size="10" fill="#64748b" text-anchor="middle" style="pointer-events:none">${escapeHTML(n.code)}</text>`;
svg += `</g>`;
});
svg += `</svg>`; svg += `</svg>`;
return svg; return svg;
} }
+16
View File
@@ -250,6 +250,12 @@
box-shadow: 0 20px 38px rgba(36, 74, 50, 0.14); box-shadow: 0 20px 38px rgba(36, 74, 50, 0.14);
border-color: #cadecd; 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 { .tm-ai-toggle-badge {
width: 28px; width: 28px;
height: 28px; height: 28px;
@@ -649,6 +655,9 @@
<strong>票务客服台</strong> <strong>票务客服台</strong>
<span>咨询订票、票号与凭证</span> <span>咨询订票、票号与凭证</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> </button>
<div class="tm-ai-panel" aria-live="polite"> <div class="tm-ai-panel" aria-live="polite">
<div class="tm-ai-header"> <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', () => { toggleButton.addEventListener('click', () => {
if (root.classList.contains('is-open')) closePanel(); if (root.classList.contains('is-open')) closePanel();
else openPanel(); else openPanel();
+1 -72
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -44,67 +44,6 @@
</div> </div>
<main class="jr-public-main"> <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"> <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"> <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> <span class="jr-feature-icon"><i class="fas fa-ticket-alt"></i></span>
@@ -141,16 +80,6 @@
<span>输入卡号或订单号,查看卡状态、余额与最近记录。</span> <span>输入卡号或订单号,查看卡状态、余额与最近记录。</span>
</span> </span>
</a> </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>
<section class="jr-home-assets"> <section class="jr-home-assets">
+1 -8
View File
@@ -88,15 +88,8 @@
<section class="jr-page-intro jr-admin-intro"> <section class="jr-page-intro jr-admin-intro">
<span class="jr-kicker">IC MANAGEMENT</span> <span class="jr-kicker">IC MANAGEMENT</span>
<h1>IC 卡发行与状态管理</h1> <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> </section>
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
<div class="stat-label">IC 卡总数</div> <div class="stat-label">IC 卡总数</div>
+35 -4
View File
@@ -188,19 +188,50 @@
</div> </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; const events = state.selectedEvents;
eventEl.innerHTML = events.length ? events.map((event) => ` eventEl.innerHTML = events.length ? events.map((event) => `
<div class="timeline-item"> <div class="timeline-item">
<div class="timeline-dot"></div> <div class="timeline-dot"></div>
<div class="timeline-content"> <div class="timeline-content">
<div class="flex between"> <div class="flex between">
<span style="font-weight:600;">${escapeHtml(eventTitle(event))}</span> <strong style="font-weight:600;">${escapeHtml(eventTitle(event))}</strong>
<span class="text-muted" style="font-size:0.8rem;">${escapeHtml(formatTime(event.ts))}</span> </div>
<div class="log-detail text-muted" style="font-size: 0.85rem; margin-top: 4px;">
${escapeHtml(formatIcCardEventUnified(event))}
</div> </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>
</div> </div>
`).join('') : '<div class="loading">暂无事件记录。</div>'; `).join('') : '<div class="text-muted" style="padding: 20px 0; text-align: center;">暂无事件记录</div>';
}; };
const validateHolderName = (value) => { const validateHolderName = (value) => {
-12
View File
@@ -44,18 +44,6 @@
</div> </div>
</div> </div>
<main class="jr-public-main"> <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"> <section class="jr-grid-two">
<article class="jr-panel-card"> <article class="jr-panel-card">
<div class="jr-panel-headline"> <div class="jr-panel-headline">
+4 -4
View File
@@ -69,15 +69,15 @@
? state.rechargeOptions ? state.rechargeOptions
: [5, 10, 15, 20]; : [5, 10, 15, 20];
rechargeOptionListEl.innerHTML = options.map((amount) => ` 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-title">${escapeHtml(amount)}</span>
<span class="jr-card-plan-desc">首次充值 ${escapeHtml(amount)}</span> <span class="jr-card-plan-desc">首次充值 ${escapeHtml(amount)}</span>
</button> </div>
`).join('') + ` `).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-title">自定义</span>
<span class="jr-card-plan-desc">手动输入首次充值金额</span> <span class="jr-card-plan-desc">手动输入首次充值金额</span>
</button> </div>
`; `;
if (customRechargeBoxEl) { if (customRechargeBoxEl) {
customRechargeBoxEl.classList.toggle('is-active', !!state.customMode); customRechargeBoxEl.classList.toggle('is-active', !!state.customMode);
+2 -59
View File
@@ -49,23 +49,7 @@
<h1>按卡号或凭证码查询 IC 卡状态</h1> <h1>按卡号或凭证码查询 IC 卡状态</h1>
<p>支持检索 IC 卡当前状态、余额和最近操作记录;输入线上购卡生成的凭证码,也能反查对应卡片。</p> <p>支持检索 IC 卡当前状态、余额和最近操作记录;输入线上购卡生成的凭证码,也能反查对应卡片。</p>
</section> </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;"> <section class="jr-panel-card" style="margin-bottom:24px;">
<div class="jr-panel-headline"> <div class="jr-panel-headline">
<h2>检索条件</h2> <h2>检索条件</h2>
@@ -116,48 +100,7 @@
</div> </div>
</div> </div>
</article> </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>
</section> </section>
<footer class="site-footer jr-footer-space"> <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) => { const buildEventsHtml = (events) => {
if (!events.length) { if (!events.length) {
return '<div class="jr-center-empty" style="min-height:180px;"><p>暂无相关事件记录。</p></div>'; 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-item">
<div class="jr-history-row"> <div class="jr-history-row">
<span class="jr-history-title">${escapeHtml(eventTitle(event))}</span> <span class="jr-history-title">${escapeHtml(eventTitle(event))}</span>
<span class="jr-history-time">${escapeHtml(formatTime(event.ts))}</span>
</div> </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> </div>
`).join(''); `).join('');
}; };
+51 -80
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<!-- 充满未知和不稳定的票务系统! --> <!-- 充满未知和不稳定的票务系统! -->
@@ -20,7 +20,7 @@
<div class="jr-topbar-inner"> <div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" class="jr-top-link" id="adminTopLink"> <a href="https://ticket.fse-media.group" class="jr-top-link" id="adminTopLink">
<i class="fas fa-train"></i> <i class="fas fa-train"></i>
<span>FSE 铁路运输后台系统</span> <span>FSE铁路票务系统控制台</span>
</a> </a>
</div> </div>
</header> </header>
@@ -30,8 +30,8 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="adminBrandLink"> <a href="https://ticket.fse-media.group" class="jr-brand" id="adminBrandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" /> <img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy"> <div class="jr-brand-copy">
<strong>FSE 铁路运输</strong> <strong>FarSight-T.N.E铁路运输</strong>
<span>后台控制台</span> <span>控制台</span>
</div> </div>
</a> </a>
<nav class="jr-nav" aria-label="站点导航"> <nav class="jr-nav" aria-label="站点导航">
@@ -50,7 +50,7 @@
<div class="jr-admin-sidebar-head"> <div class="jr-admin-sidebar-head">
<span class="jr-kicker">OPERATIONS CONSOLE</span> <span class="jr-kicker">OPERATIONS CONSOLE</span>
<div class="brand">FSE铁路票务系统控制台</div> <div class="brand">FSE铁路票务系统控制台</div>
<p class="jr-admin-sidebar-copy">后台管理页统一使用 JR 风格的门户视觉,集中处理票务、线路、资源和运营日志。</p> <p class="jr-admin-sidebar-copy">集中处理票务、线路、资源和运营日志。</p>
</div> </div>
<div class="nav"> <div class="nav">
<a href="https://ticket.fse-media.group" id="homeLink" class="nav-item" style="text-decoration: none;"> <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> 车票记录 <span class="nav-icon"><i class="fas fa-ticket-alt"></i></span> 车票记录
</div> </div>
<div class="nav-item" :class="{active: currentView === 'vouchers'}" @click="currentView = 'vouchers'"> <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>
<div class="nav-item" :class="{active: currentView === 'iccards'}" @click="currentView = 'iccards'"> <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>
<div class="nav-item" :class="{active: currentView === 'assets'}" @click="currentView = 'assets'"> <div class="nav-item" :class="{active: currentView === 'assets'}" @click="currentView = 'assets'">
<span class="nav-icon"><i class="fas fa-route"></i></span> 线路图 <span class="nav-icon"><i class="fas fa-route"></i></span> 线路图
</div> </div>
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="currentView = 'settings'"> <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>
<div class="nav-item" :class="{active: currentView === 'logs'}" @click="currentView = 'logs'"> <div class="nav-item" :class="{active: currentView === 'logs'}" @click="currentView = 'logs'">
<span class="nav-icon"><i class="fas fa-list"></i></span> 日志 <span class="nav-icon"><i class="fas fa-list"></i></span> 日志
@@ -92,7 +92,7 @@
<div class="flex" style="gap: 12px;"> <div class="flex" style="gap: 12px;">
<button class="icon-btn mobile-only" @click="sidebarOpen = !sidebarOpen" title="菜单"><i class="fas fa-bars"></i></button> <button class="icon-btn mobile-only" @click="sidebarOpen = !sidebarOpen" title="菜单"><i class="fas fa-bars"></i></button>
<div> <div>
<span class="jr-kicker">JR STYLE ADMIN</span> <span class="jr-kicker">OPERATIONS CONSOLE</span>
<h3 style="margin: 0;">{{ viewTitle }}</h3> <h3 style="margin: 0;">{{ viewTitle }}</h3>
</div> </div>
</div> </div>
@@ -114,43 +114,10 @@
</div> </div>
<div class="content"> <div class="content">
<section class="jr-page-intro jr-admin-intro"> <section class="jr-page-intro jr-admin-intro">
<span class="jr-kicker">CENTRAL MANAGEMENT</span> <span class="jr-kicker">OPERATIONS CONSOLE</span>
<h1>铁路票务后台控制台</h1> <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> </section>
<!-- 仪表盘--> <!-- 仪表盘-->
<div v-if="currentView === 'dashboard'"> <div v-if="currentView === 'dashboard'">
<div class="grid"> <div class="grid">
@@ -457,6 +424,24 @@
<!-- 凭证管理 --> <!-- 凭证管理 -->
<div v-if="currentView === 'vouchers'"> <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="card">
<div class="flex between mb-4"> <div class="flex between mb-4">
<h4>凭证列表</h4> <h4>凭证列表</h4>
@@ -467,11 +452,11 @@
<table class="ticket-table"> <table class="ticket-table">
<thead> <thead>
<tr> <tr>
<th>凭证</th> <th>凭证</th>
<th>线路</th> <th>线路</th>
<th>车型</th> <th>车型</th>
<th>票价</th> <th>票价</th>
<th>状态</th> <th>状态</th>
<th>创建时间</th> <th>创建时间</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
@@ -503,17 +488,7 @@
</div> </div>
<div v-if="currentView === 'iccards'"> <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="grid">
<div class="card"> <div class="card">
<div class="stat-label">IC 卡总数</div> <div class="stat-label">IC 卡总数</div>
@@ -535,18 +510,7 @@
<div class="management-container ic-admin-layout"> <div class="management-container ic-admin-layout">
<div class="management-sidebar"> <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="card jr-admin-list-card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
<div class="flex between mb-4"> <div class="flex between mb-4">
<div> <div>
@@ -588,11 +552,10 @@
<div class="flex between mb-4"> <div class="flex between mb-4">
<div> <div>
<h4>卡片详情</h4> <h4>卡片详情</h4>
<div class="jr-admin-list-meta">在同一面板直接处理状态维护、充值和记录核对。</div>
</div> </div>
<div class="flex" style="gap:8px;"> <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="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 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> <button class="btn primary" @click="saveIcCard" :disabled="!icSelectedCard"><i class="fas fa-save"></i> 保存</button>
</div> </div>
@@ -620,11 +583,7 @@
<strong>{{ icSelectedEvents.length }}</strong> <strong>{{ icSelectedEvents.length }}</strong>
<small>用于追踪开卡与状态变更。</small> <small>用于追踪开卡与状态变更。</small>
</div> </div>
<div class="jr-admin-summary-item">
<span>订单来源</span>
<strong>{{ cardOrderCode(icSelectedCard) }}</strong>
<small>自动识别线上订单或现场办卡。</small>
</div>
</div> </div>
<div class="ic-detail-grid"> <div class="ic-detail-grid">
<label class="ic-field"> <label class="ic-field">
@@ -684,6 +643,20 @@
<!-- 车票记录 --> <!-- 车票记录 -->
<div v-if="currentView === 'tickets'"> <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="card mb-4">
<div class="flex between mb-4"> <div class="flex between mb-4">
<h4>车票记录</h4> <h4>车票记录</h4>
@@ -786,11 +759,9 @@
<div class="timeline-content"> <div class="timeline-content">
<div class="flex between"> <div class="flex between">
<span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span> <span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span>
<span class="text-muted" style="font-size: 0.8rem;">{{ formatTime(ev['时间戳'] || ev.ts) }}</span>
</div> </div>
<div class="text-muted" style="font-size: 0.9rem;"> <div class="text-muted" style="font-size: 0.9rem; margin-top: 4px;">
<div>{{ formatTicketEventLocation(ev) }}</div> {{ formatTicketEventUnified(ev) }}
<div v-if="formatTicketEventExtra(ev)" style="margin-top: 4px;">{{ formatTicketEventExtra(ev) }}</div>
</div> </div>
</div> </div>
</div> </div>
+72 -25
View File
@@ -345,23 +345,31 @@ createApp({
return String(value); return String(value);
}; };
const formatTicketEventExtra = (event) => { const formatTicketEventUnified = (ev) => {
const type = getTicketEventType(event); const type = getTicketEventType(ev);
const amount = event?.["售票额"] ?? event?.amount ?? event?.price ?? event?.cost; const action = getTicketEventAction(ev);
const stationEn = event?.["站点英文"] || event?.station_en || ''; const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
const deviceId = event?.["设备编号"] || event?.device_id || event?.device || ''; const timeStr = formatTime(ev?.ts || ev?.["时间戳"]);
const attachment = formatTicketEventAttachment(
event?.["附加信息"] ?? event?.extra ?? event?.info ?? event?.meta ?? event?.detail if (type === 'sale' || type === '售票') {
); const cost = ev?.cost ?? ev?.price ?? ev?.amount ?? ev?.["票价"] ?? ev?.["售票额"] ?? '0';
const parts = []; return `机器ID: ${deviceId};票价: ${cost};时间: ${timeStr}`;
if ((type === 'sale' || type === '售票') && amount != null && amount !== '') {
parts.push(`票价:¥ ${amount}`);
} }
if (stationEn && deviceId) parts.push(`${stationEn} (${deviceId})`); if (type === 'entry' || action === 'entry') {
else if (deviceId) parts.push(`设备:${deviceId}`); const code = ev?.station_code || ev?.["站点编号"] || '---';
else if (stationEn) parts.push(stationEn); const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
if (attachment) parts.push(attachment); return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
return parts.join(' | '); }
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) => { const formatLogDetail = (l) => {
@@ -440,14 +448,34 @@ createApp({
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件'); return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
}; };
const formatIcEventDetail = (event) => { const formatIcEventDetail = (ev) => {
const detail = event?.detail != null ? event.detail : event; const type = String(ev?.type || '').toLowerCase();
if (typeof detail === 'string') return detail; const action = String(ev?.action || '').toLowerCase();
try { const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
return JSON.stringify(detail || {}, null, 2); const timeStr = formatTime(ev?.ts);
} catch (_) { const balance = ev?.balance ?? ev?.["余额"] ?? 0;
return String(detail || '');
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) => { const getStationName = (code) => {
@@ -1540,6 +1568,25 @@ createApp({
(t.terminal && t.terminal.toLowerCase().includes(q)) (t.terminal && t.terminal.toLowerCase().includes(q))
).slice(0, 50); ).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 exportFareMap = () => {
const svgData = fareMapSvg.value; const svgData = fareMapSvg.value;
@@ -1582,7 +1629,7 @@ createApp({
return { return {
currentView, viewTitle, sidebarOpen, currentView, viewTitle, sidebarOpen,
loadingState, isViewBusy, lastSyncText, currentViewSummary, 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, logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
orders, orderList, fetchOrders, deleteOrder, orders, orderList, fetchOrders, deleteOrder,
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats, icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
@@ -1602,7 +1649,7 @@ createApp({
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal, showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,
// Tickets // Tickets
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventExtra, formatLogType, formatTrainType, showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventUnified, formatLogType, formatTrainType,
saveCurrentFare, deleteCurrentFare, closeFareModal, saveCurrentFare, deleteCurrentFare, closeFareModal,
+2 -2
View File
@@ -23,8 +23,8 @@
<a href="/" class="jr-brand"> <a href="/" class="jr-brand">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" /> <img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy"> <div class="jr-brand-copy">
<strong>FarSight-T.N.E铁路运输</strong> <strong>FSE铁路票务系统控制台</strong>
<span>控制台登录</span> <span>登录</span>
</div> </div>
</a> </a>
</div> </div>
+37 -6
View File
@@ -183,12 +183,12 @@ input, select, textarea, .voucher-code, .st-name, .st-code, .line-name {
.nav-item { .nav-item {
padding: 10px 16px; padding: 10px 16px;
border-radius: 6px; border-radius: 0;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
color: var(--muted); color: var(--text);
font-weight: 500; font-weight: 500;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@@ -251,7 +251,7 @@ button, .btn {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 8px 16px; padding: 8px 16px;
border-radius: 6px; border-radius: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
background-color: transparent; background-color: transparent;
color: var(--text); color: var(--text);
@@ -1738,7 +1738,7 @@ body.jr-order-page .site-footer .version {
.jr-nav a { .jr-nav a {
min-width: 104px; min-width: 104px;
padding: 10px 16px; padding: 10px 16px;
border-radius: 999px; border-radius: 0;
text-decoration: none; text-decoration: none;
color: #385042; color: #385042;
font-weight: 700; font-weight: 700;
@@ -3622,11 +3622,25 @@ body.jr-ticket-board-page .jr-board-card:last-child {
.jr-voucher-layout, .jr-voucher-layout,
.jr-board-layout, .jr-board-layout,
.jr-home-hero, .jr-home-hero,
.jr-home-assets, .jr-home-assets {
.jr-card-plan-grid {
grid-template-columns: 1fr; 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-grid-three,
.jr-home-service-grid { .jr-home-service-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -3826,6 +3840,8 @@ body.jr-admin-login-page {
.jr-admin-page .nav-item.active { .jr-admin-page .nav-item.active {
background: #0b6b3a; background: #0b6b3a;
border-color: #0b6b3a; border-color: #0b6b3a;
color: #ffffff;
font-weight: 700;
box-shadow: none; box-shadow: none;
} }
@@ -4289,6 +4305,21 @@ body.jr-admin-login-page {
overflow: auto; 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 .header,
.jr-admin-page .content { .jr-admin-page .content {
padding-left: 16px; padding-left: 16px;
+1 -79
View File
@@ -1,4 +1,4 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
@@ -44,70 +44,6 @@
</div> </div>
</div> </div>
<main class="jr-main"> <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"> <section class="jr-content-grid">
<div class="jr-column-main"> <div class="jr-column-main">
<article class="jr-section-card" id="reservationPanel"> <article class="jr-section-card" id="reservationPanel">
@@ -208,20 +144,6 @@
</div> </div>
</div> </div>
</article> </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> </aside>
</section> </section>
+15 -210
View File
@@ -56,49 +56,14 @@
}; };
async function loadMap() { async function loadMap() {
mapContainer.innerHTML = '<div class="loading">加载地图中...</div>';
try { try {
const res = await fetch('/api/public/fares/map/light'); const [res, stationsData] = await Promise.all([
const svg = await res.text(); fetch('/api/public/fares/map/light?t=' + Date.now()),
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()),
fetch('/api/public/stations').then(r=>r.json()) 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; window.cachedStationsData = stationsData;
stationTransfer = new Set();
stationCanonicalByCode = {};
stationCodesByCanonical = {};
stationNameByCode = {}; stationNameByCode = {};
stationEnByCode = {}; stationEnByCode = {};
for (const s of stationsData) { for (const s of stationsData) {
@@ -108,182 +73,22 @@
stationEnByCode[code] = String(s.en_name || s.英文名 || ''); stationEnByCode[code] = String(s.en_name || s.英文名 || '');
} }
const transferGroups = buildTransferGroups(stationsData); const svg = await res.text();
stationCanonicalByCode = transferGroups.canonicalByCode; mapContainer.innerHTML = svg;
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);
}
// Build SVG const svgEl = mapContainer.querySelector('svg');
const legendW = 210; if(!svgEl) return;
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
svgEl.querySelectorAll('.map-station').forEach(el => {
el.addEventListener('click', () => {
window.handleStationClick(el.getAttribute('data-code'));
});
});
} catch(e) { } catch(e) {
console.error(e); mapContainer.innerHTML = '<div class="error">加载地图失败</div>';
mapContainer.innerHTML = '<div class="error">地图加载失败</div>';
} }
} }
window.handleStationClick = (code) => { window.handleStationClick = (code) => {
code = String(code || '').trim(); code = String(code || '').trim();
// Toggle if already selected // Toggle if already selected
@@ -506,7 +311,7 @@
} }
// Load Map on Start // Load Map on Start
renderSystemMap(); loadMap();
async function previewPrice(force = false){ async function previewPrice(force = false){
const seq = ++previewSeq; const seq = ++previewSeq;
+4 -12
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<!-- 充满未知和不稳定的票务系统! --> <!-- 充满未知和不稳定的票务系统! -->
@@ -114,13 +114,7 @@
<h1>线路规划与票价维护</h1> <h1>线路规划与票价维护</h1>
<p>线路结构、站点编辑、换乘关系和票价地图</p> <p>线路结构、站点编辑、换乘关系和票价地图</p>
</section> </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 v-if="currentView === 'management'" class="management-container">
<div class="management-sidebar"> <div class="management-sidebar">
<div class="card" <div class="card"
@@ -566,11 +560,9 @@
<div class="timeline-content"> <div class="timeline-content">
<div class="flex between"> <div class="flex between">
<span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span> <span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span>
<span class="text-muted" style="font-size: 0.8rem;">{{ formatTime(ev['时间戳'] || ev.ts) }}</span>
</div> </div>
<div class="text-muted" style="font-size: 0.9rem;"> <div class="text-muted" style="font-size: 0.9rem; margin-top: 4px;">
<div>{{ formatTicketEventLocation(ev) }}</div> {{ formatTicketEventUnified(ev) }}
<div v-if="formatTicketEventExtra(ev)" style="margin-top: 4px;">{{ formatTicketEventExtra(ev) }}</div>
</div> </div>
</div> </div>
</div> </div>
+25 -17
View File
@@ -225,23 +225,31 @@ createApp({
return String(value); return String(value);
}; };
const formatTicketEventExtra = (event) => { const formatTicketEventUnified = (ev) => {
const type = getTicketEventType(event); const type = getTicketEventType(ev);
const amount = event?.["售票额"] ?? event?.amount ?? event?.price ?? event?.cost; const action = getTicketEventAction(ev);
const stationEn = event?.["站点英文"] || event?.station_en || ''; const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
const deviceId = event?.["设备编号"] || event?.device_id || event?.device || ''; const timeStr = formatTime(ev?.ts || ev?.["时间戳"]);
const attachment = formatTicketEventAttachment(
event?.["附加信息"] ?? event?.extra ?? event?.info ?? event?.meta ?? event?.detail if (type === 'sale' || type === '售票') {
); const cost = ev?.cost ?? ev?.price ?? ev?.amount ?? ev?.["票价"] ?? ev?.["售票额"] ?? '0';
const parts = []; return `机器ID: ${deviceId};票价: ${cost};时间: ${timeStr}`;
if ((type === 'sale' || type === '售票') && amount != null && amount !== '') {
parts.push(`票价:¥ ${amount}`);
} }
if (stationEn && deviceId) parts.push(`${stationEn} (${deviceId})`); if (type === 'entry' || action === 'entry') {
else if (deviceId) parts.push(`设备:${deviceId}`); const code = ev?.station_code || ev?.["站点编号"] || '---';
else if (stationEn) parts.push(stationEn); const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
if (attachment) parts.push(attachment); return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
return parts.join(' | '); }
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) => { const formatLogDetail = (l) => {
@@ -958,7 +966,7 @@ createApp({
/* 订单 */ /* 订单 */
fetchOrders, deleteOrder, 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, saveCurrentFare, deleteCurrentFare, closeFareModal,
+2 -18
View File
@@ -1,4 +1,4 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
@@ -52,23 +52,7 @@
<p>输入完整票号、起终点、日期或关键词,左侧浏览结果,右侧查看票据详情与最近流转记录。</p> <p>输入完整票号、起终点、日期或关键词,左侧浏览结果,右侧查看票据详情与最近流转记录。</p>
</section> </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;"> <section class="jr-panel-card" style="margin-bottom:24px;">
<div class="jr-panel-headline"> <div class="jr-panel-headline">
+25 -14
View File
@@ -107,18 +107,31 @@
return [stationName, stationName && stationCode ? stationCode : ''].filter(Boolean).join(' '); return [stationName, stationName && stationCode ? stationCode : ''].filter(Boolean).join(' ');
}; };
const formatEventExtra = (event) => { const formatTicketEventUnified = (ev) => {
const type = getEventType(event); 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 === '售票') { if (type === 'sale' || type === '售票') {
const amount = event.amount ?? event["售票额"]; const cost = ev.cost ?? ev.price ?? ev.amount ?? ev["票价"] ?? ev["售票额"] ?? '0';
if (amount != null && amount !== '') return `票价:¥ ${amount}`; return \`机器ID: \${deviceId};票价: \${cost};时间: \${timeStr}\`;
} }
if (type === 'entry' || action === 'entry') {
const stationEn = event.station_en || event["站点英文"] || ''; const code = ev.station_code || ev["站点编号"] || '---';
const deviceId = event["设备编号"] || event.device_id || ''; const name = ev.station_name || ev["站点中文"] || ev["发生站"] || '---';
if (stationEn && deviceId) return `${stationEn} (${deviceId})`; return \`机器ID: \${deviceId};站点编号: \${code};站点中文: \${name};时间: \${timeStr}\`;
if (deviceId) return `设备:${deviceId}`; }
return stationEn; 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) { function renderList(items) {
@@ -219,12 +232,10 @@
${evs.map(e => ` ${evs.map(e => `
<div class="jr-history-item"> <div class="jr-history-item">
<div class="jr-history-row"> <div class="jr-history-row">
<span class="jr-history-title">${formatEventTitle(e)}</span> <span class="jr-history-title">${escapeHtml(formatEventTitle(e))}</span>
<span class="jr-history-time">${formatTime(e.ts || e["时间戳"])}</span>
</div> </div>
<div class="jr-history-desc"> <div class="jr-history-desc">
<div>${formatEventLocation(e)}</div> <div style="margin-top:4px;">${escapeHtml(formatTicketEventUnified(e))}</div>
<div style="margin-top:4px;">${formatEventExtra(e) || '---'}</div>
</div> </div>
</div> </div>
`).join('')} `).join('')}