refactor(web): 重构全站界面与事件日志,优化功能与样式
1. 重构并统一票务、IC卡的事件日志格式化展示逻辑,替换原有零散的格式化函数,提升事件展示的一致性与可读性 2. 为AI助手面板新增折叠按钮与折叠逻辑,支持收起/展开面板,优化界面空间利用率 3. 移除多个页面的冗余介绍、提醒区块,精简页面结构 4. 修复IC卡购卡页面的充值选项按钮布局问题,将button元素改为div适配样式规范 5. 新增移动端卡片布局的横向滚动适配,优化小屏设备上的卡片展示与浏览体验 6. 更新后台管理页面的导航样式、文案与统计数据展示,新增车票、凭证的统计卡片 7. 统一全站品牌文案与页面格式,修复部分页面的DOCTYPE多余空格问题 8. 简化车票订单页面的地图加载逻辑,修复原有的冗余代码与加载异常问题
This commit is contained in:
+159
-169
@@ -3,197 +3,187 @@ const fs = require('fs');
|
||||
const escapeHTML = (s) => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
function generate(stations, lines, fares, nameByCode, transfers) {
|
||||
const enNameByCode = (() => {
|
||||
try {
|
||||
const map = {}; for (const s of stations) { map[String(s.code || '')] = s.en_name || s.enName || ''; }
|
||||
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 enNameByCode = {};
|
||||
stations.forEach(s => { enNameByCode[String(s.code || '')] = s.en_name || s.enName || ''; });
|
||||
|
||||
const groupsMap = {};
|
||||
// Optional: We can still use groups for coloring logic, but NOT for graph edges if we want strict mode.
|
||||
// But let's keep groupsMap for color logic (lines 30-46) as it helps determine if lines connect visually.
|
||||
stations.forEach(s => {
|
||||
const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim();
|
||||
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 [];
|
||||
};
|
||||
// 1. Build Graph
|
||||
const validStationCodes = new Set(stations.map(s => String(s.code || '')));
|
||||
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); };
|
||||
|
||||
const buildGraph = () => {
|
||||
const adj = new Map();
|
||||
const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); };
|
||||
const addEdge = (u, v, w) => { addNode(u); addNode(v); adj.get(u).set(v, w); adj.get(v).set(u, w); };
|
||||
|
||||
// Create a set of valid station codes for fast lookup
|
||||
const validStationCodes = new Set(stations.map(s => String(s.code || '')));
|
||||
stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); });
|
||||
|
||||
// 1. Edges from explicit FARES only
|
||||
(fares || []).forEach(f => {
|
||||
const a = String(f.from || ''), b = String(f.to || '');
|
||||
if (!a || !b) return;
|
||||
if (!validStationCodes.has(a) || !validStationCodes.has(b)) return;
|
||||
|
||||
const edgeCosts = new Map();
|
||||
(fares || []).forEach(f => {
|
||||
const a = String(f.from || ''), b = String(f.to || '');
|
||||
if (a && b && validStationCodes.has(a) && validStationCodes.has(b)) {
|
||||
const w = Number(f.cost_regular ?? f.cost) || 0;
|
||||
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)
|
||||
if (Array.isArray(transfers)) {
|
||||
transfers.forEach(p => {
|
||||
const a = String(p[1] || ''), b = String(p[2] || ''); // startup.lua used index 1,2? No, lua is 1-based. JS is 0-based.
|
||||
// Wait, config transfers is [[a,b], [c,d]].
|
||||
// Let's check how it's stored. usually JSON array of arrays.
|
||||
// In startup.lua: p[1], p[2].
|
||||
// In JS: p[0], p[1].
|
||||
// But let's check standard.
|
||||
// If I look at startup.lua: `local a, b = tostring(p[1] or ''), tostring(p[2] or '')` (Lua 1-based index)
|
||||
// So in JSON it is `["A", "B"]`.
|
||||
// In JS `p[0]` is "A", `p[1]` is "B".
|
||||
// Let's be safe and check both or iterate.
|
||||
|
||||
const u = String(p[0] || '');
|
||||
const v = String(p[1] || '');
|
||||
|
||||
if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) {
|
||||
addEdge(u, v, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 2. Force Directed Layout
|
||||
const nodes = Array.from(validStationCodes).map(code => ({
|
||||
code,
|
||||
x: Math.random() * 800,
|
||||
y: Math.random() * 600,
|
||||
vx: 0, vy: 0,
|
||||
name: nameByCode[code] || code
|
||||
}));
|
||||
const nodeMap = new Map(nodes.map(n => [n.code, n]));
|
||||
|
||||
// REMOVED: Edges from Lines (implicit 0 cost)
|
||||
// REMOVED: Edges from Groups (implicit 0 cost) - unless we want to keep it as fallback?
|
||||
// User said "Strictly use database data". If transfers are in DB, we use them.
|
||||
// If user relies on name grouping but didn't put it in DB transfers, they might be unhappy.
|
||||
// But "Strictly use database data" implies we shouldn't automagically connect things.
|
||||
// I will remove the auto-grouping for graph edges to be consistent with startup.lua changes.
|
||||
|
||||
stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); });
|
||||
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;
|
||||
// Add line connections to edges for layout purposes
|
||||
const layoutEdges = [];
|
||||
(lines || []).forEach(li => {
|
||||
const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x));
|
||||
for (let i = 0; i < arr.length - 1; i++) {
|
||||
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' });
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
};
|
||||
const distAll = {}; codes.forEach(c => { distAll[c] = dijkstra(c); });
|
||||
});
|
||||
|
||||
let maxFare = 1;
|
||||
for (const r of codes) {
|
||||
for (const c of codes) { if (r === c) continue; const v = Number(distAll[r][c] || 0); if (v > maxFare) maxFare = v; }
|
||||
for (const [u, edges] of adj.entries()) {
|
||||
for (const [v, w] of edges.entries()) {
|
||||
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 cellW = 56;
|
||||
const cellH = 40;
|
||||
const estimateWidth = (text, size) => Math.ceil(String(text || '').length * (size <= 10 ? 7 : 10));
|
||||
const maxCnW = Math.max(1, ...codes.map(c => estimateWidth(nameByCode[c] || c, 14)));
|
||||
const maxEnW = Math.max(1, ...codes.map(c => estimateWidth(enNameByCode[c] || '', 10)));
|
||||
const maxCodeW = Math.max(1, ...codes.map(c => estimateWidth(c, 10)));
|
||||
const leftW = Math.max(140, 24 + Math.max(maxCnW, maxEnW, maxCodeW));
|
||||
const topH = 64;
|
||||
const n = codes.length;
|
||||
const width = margin + leftW + n * cellW + margin;
|
||||
const height = margin + topH + n * cellH + margin;
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="background:#ffffff;font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif">`;
|
||||
// Column headers
|
||||
for (let j = 0; j < n; j++) {
|
||||
const code = codes[j]; const name = nameByCode[code] || code; const en = enNameByCode[code] || '';
|
||||
const x = margin + leftW + j * cellW + cellW / 2; const y = margin + 20;
|
||||
svg += `<text x="${x}" y="${y}" font-size="14" font-weight="900" fill="#000000" text-anchor="middle">${escapeHTML(name)}</text>`;
|
||||
if (en) svg += `<text x="${x}" y="${y + 14}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(en)}</text>`;
|
||||
svg += `<text x="${x}" y="${y + 26}" font-size="10" fill="#000000" text-anchor="middle">${escapeHTML(code)}</text>`;
|
||||
const iterations = 300;
|
||||
const k = Math.sqrt((1200 * 800) / nodes.length) || 100;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const t = 1 - i / iterations;
|
||||
|
||||
// Repulsion
|
||||
for (let a = 0; a < nodes.length; a++) {
|
||||
for (let b = a + 1; b < nodes.length; b++) {
|
||||
const dx = nodes[a].x - nodes[b].x;
|
||||
const dy = nodes[a].y - nodes[b].y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist === 0) { dist = 0.01; }
|
||||
if (dist < k * 2) {
|
||||
const force = (k * k) / dist;
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
nodes[a].vx += fx; nodes[a].vy += fy;
|
||||
nodes[b].vx -= fx; nodes[b].vy -= fy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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++) {
|
||||
const code = codes[i]; const name = nameByCode[code] || code; const en = enNameByCode[code] || '';
|
||||
const xText = margin + 6; const baseY = margin + topH + i * cellH + 22;
|
||||
svg += `<text x="${xText}" y="${baseY - 10}" font-size="14" font-weight="900" fill="#000000">${escapeHTML(name)}</text>`;
|
||||
if (en) svg += `<text x="${xText}" y="${baseY + 4}" font-size="10" fill="#000000">${escapeHTML(en)}</text>`;
|
||||
svg += `<text x="${xText}" y="${baseY + 16}" font-size="10" fill="#000000">${escapeHTML(code)}</text>`;
|
||||
for (let j = 0; j < n; j++) {
|
||||
const x0 = margin + leftW + j * cellW; const y0 = margin + topH + i * cellH;
|
||||
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#ffffff" stroke="#e5e7eb"/>`;
|
||||
if (i === j) {
|
||||
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" fill="#6b7280" text-anchor="middle">-</text>`;
|
||||
} else {
|
||||
const val = Math.round(Number(distAll[code][codes[j]] || 0));
|
||||
const intensity = maxFare > 0 ? Math.min(1, val / maxFare) : 0;
|
||||
// Normalize coordinates
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
nodes.forEach(n => {
|
||||
minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
|
||||
maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y);
|
||||
});
|
||||
|
||||
const margin = 100;
|
||||
nodes.forEach(n => {
|
||||
n.x = n.x - minX + margin;
|
||||
n.y = n.y - minY + margin;
|
||||
});
|
||||
|
||||
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
|
||||
const routeColors = colorsForRoute(code, codes[j]);
|
||||
|
||||
// Draw background strips for multiple lines
|
||||
if (routeColors.length > 0) {
|
||||
const stripW = cellW / routeColors.length;
|
||||
for (let k = 0; k < routeColors.length; k++) {
|
||||
const c = routeColors[k] || '#93a2b7';
|
||||
const stripX = x0 + k * stripW;
|
||||
// Use fill-opacity for robustness against any color format
|
||||
svg += `<rect x="${stripX}" y="${y0}" width="${stripW}" height="${cellH}" fill="${escapeHTML(c)}" fill-opacity="0.15" stroke="none"/>`;
|
||||
}
|
||||
} else {
|
||||
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="#000000" fill-opacity="0.03" stroke="none"/>`;
|
||||
if (isTransfer) {
|
||||
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" />`;
|
||||
}
|
||||
|
||||
// Add cell border
|
||||
svg += `<rect x="${x0}" y="${y0}" width="${cellW}" height="${cellH}" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>`;
|
||||
|
||||
svg += `<text x="${x0 + cellW / 2}" y="${y0 + 22}" font-size="12" font-weight="800" fill="#000000" text-anchor="middle">${escapeHTML(String(val))}</text>`;
|
||||
|
||||
// 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"/>`;
|
||||
if (w > 0) {
|
||||
const cx = (nu.x + nv.x) / 2;
|
||||
const cy = (nu.y + nv.y) / 2;
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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>`;
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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('')}
|
||||
|
||||
Reference in New Issue
Block a user