初始提交

This commit is contained in:
2026-06-21 10:00:13 +08:00
commit 7a5dc32672
1441 changed files with 266348 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
{
"style.css": {
"size": 77276,
"mtimeMs": 1781972131010.4126
},
"blog.css": {
"size": 994,
"mtimeMs": 1779588998553.1523
},
"custom-dialog.js": {
"size": 7683,
"mtimeMs": 1781967608378.205
},
"ai-assistant.js": {
"size": 30590,
"mtimeMs": 1782004060434.665
},
"public-status.js": {
"size": 3814,
"mtimeMs": 1781967627973.2036
},
"ticket-order.js": {
"size": 23614,
"mtimeMs": 1781974289008.8884
},
"ticket-search.js": {
"size": 11647,
"mtimeMs": 1781966517813.3787
},
"ticket-route.js": {
"size": 42284,
"mtimeMs": 1781967610560.6072
},
"index.js": {
"size": 63063,
"mtimeMs": 1781967609550.651
},
"ic-card-admin.js": {
"size": 15188,
"mtimeMs": 1781967608706.734
},
"ic-card-order.js": {
"size": 11528,
"mtimeMs": 1781967363353.6257
},
"ic-card-detail.js": {
"size": 5322,
"mtimeMs": 1781967346854.6172
},
"ic-card-search.js": {
"size": 5462,
"mtimeMs": 1781933937174.1592
},
"token.js": {
"size": 4064,
"mtimeMs": 1781863551516.1868
},
"login.js": {
"size": 4976,
"mtimeMs": 1779592008691.236
},
"blog.js": {
"size": 236,
"mtimeMs": 1778819183400.289
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+900
View File
@@ -0,0 +1,900 @@
(() => {
if (window.__tmAiAssistantLoaded) return;
window.__tmAiAssistantLoaded = true;
const pathname = window.location.pathname || '/';
const body = document.body;
const isAssistantEligiblePage = (targetBody) => {
if (!targetBody) return false;
if (targetBody.classList.contains('jr-admin-page') || targetBody.classList.contains('jr-admin-login-page')) return false;
return targetBody.classList.contains('jr-public-page')
|| targetBody.classList.contains('public-search')
|| targetBody.classList.contains('jr-order-page')
|| targetBody.classList.contains('jr-ticket-board-page');
};
if (!isAssistantEligiblePage(body)) return;
const pageMap = {
'/': '首页',
'/home.html': '首页',
'/order': '线上预定',
'/ticket-order.html': '线上预定',
'/search': '车票查询',
'/ticket-search.html': '车票查询',
'/ticket-board.html': '车票详情',
'/token': '订单凭证',
'/token.html': '订单凭证',
'/ic-card/order': 'IC 卡线上购卡',
'/ic-card-order.html': 'IC 卡线上购卡',
'/ic-card/search': 'IC 卡查询',
'/ic-card-search.html': 'IC 卡查询'
};
const suggestionMap = {
'首页': ['这个网站可以做什么?', '如何在线订票?', '凭证码有什么用?'],
'线上预定': ['如何在线订票?', '订票后怎么兑票?', '车型和乘次数量怎么选?'],
'车票查询': ['怎么查我的车票?', '票据状态怎么看?', '查不到票据怎么办?'],
'车票详情': ['帮我解释当前票号。', '这张票现在还能使用吗?', '这些状态字段分别代表什么?'],
'订单凭证': ['凭证码怎么使用?', '这个凭证现在能兑票吗?', '怎么确认是否已经兑票?'],
'IC 卡线上购卡': ['怎么在线购买 IC 卡?', '购卡后怎么领卡?', '持卡人姓名有什么要求?'],
'IC 卡查询': ['怎么查询 IC 卡余额?', '输入什么可以查到 IC 卡?', '卡片状态代表什么?']
};
const resolvePageName = () => {
if (pageMap[pathname]) return pageMap[pathname];
if (body.classList.contains('jr-ticket-board-page')) return '车票详情';
if (body.classList.contains('jr-order-page')) return '线上预定';
if (document.querySelector('#vCode, #vCodeTop, .jr-redeem-code-value')) return '订单凭证';
if (document.querySelector('.jr-ticket-id, .jr-route-board')) return '车票详情';
if (document.querySelector('#q, #searchBtn')) return '车票查询';
return document.title || '当前页面';
};
const pageName = resolvePageName();
const history = [];
let sending = false;
let lastContextSignature = '';
const textOf = (selector) => {
const el = document.querySelector(selector);
return el ? String(el.textContent || '').trim() : '';
};
const valueOf = (selector) => {
const el = document.querySelector(selector);
return el ? String(el.value || '').trim() : '';
};
const compact = (value, maxLength = 120) => {
const text = String(value || '').trim().replace(/\s+/g, ' ');
if (!text) return '';
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
};
const toContextSignature = (context) => JSON.stringify(context || {});
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const renderInlineMarkdown = (text) => escapeHtml(text)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
const markdownToHtml = (source) => {
const normalized = String(source || '').replace(/\r\n/g, '\n').trim();
if (!normalized) return '';
const codeBlocks = [];
const withPlaceholders = normalized.replace(/```([\w-]*)\n([\s\S]*?)```/g, (_, lang, code) => {
const token = `__TM_CODE_BLOCK_${codeBlocks.length}__`;
codeBlocks.push(
`<pre class="tm-ai-pre"><code${lang ? ` class="language-${escapeHtml(lang)}"` : ''}>${escapeHtml(code.trim())}</code></pre>`
);
return token;
});
const blocks = withPlaceholders.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
const html = blocks.map((block) => {
if (/^__TM_CODE_BLOCK_\d+__$/.test(block)) {
const index = Number(block.replace(/\D/g, ''));
return codeBlocks[index] || '';
}
const lines = block.split('\n').map((line) => line.trimEnd());
if (lines.every((line) => /^[-*]\s+/.test(line))) {
return `<ul>${lines.map((line) => `<li>${renderInlineMarkdown(line.replace(/^[-*]\s+/, ''))}</li>`).join('')}</ul>`;
}
if (lines.every((line) => /^\d+\.\s+/.test(line))) {
return `<ol>${lines.map((line) => `<li>${renderInlineMarkdown(line.replace(/^\d+\.\s+/, ''))}</li>`).join('')}</ol>`;
}
if (lines.length === 1 && /^###\s+/.test(lines[0])) return `<h3>${renderInlineMarkdown(lines[0].replace(/^###\s+/, ''))}</h3>`;
if (lines.length === 1 && /^##\s+/.test(lines[0])) return `<h2>${renderInlineMarkdown(lines[0].replace(/^##\s+/, ''))}</h2>`;
if (lines.length === 1 && /^#\s+/.test(lines[0])) return `<h1>${renderInlineMarkdown(lines[0].replace(/^#\s+/, ''))}</h1>`;
return `<p>${lines.map((line) => renderInlineMarkdown(line)).join('<br />')}</p>`;
}).join('');
return html.replace(/__TM_CODE_BLOCK_(\d+)__/g, (_, index) => codeBlocks[Number(index)] || '');
};
const buildContextActions = (context) => {
const actions = [];
if (context.voucher_code) {
actions.push({
label: '解释当前凭证',
prompt: '请结合当前页面识别到的凭证信息,解释这个凭证现在的状态、如何使用,以及我接下来应该做什么。'
});
}
if (context.ticket_id) {
actions.push({
label: '解释当前票号',
prompt: '请结合当前页面识别到的票号与车票信息,解释这张票当前状态、还能不能使用,以及各字段分别代表什么。'
});
}
if (context.query_keyword && !context.ticket_id && !context.voucher_code) {
actions.push({
label: '解释当前检索',
prompt: `我当前检索的是“${context.query_keyword}”,请告诉我应该如何判断它是票号、凭证码还是其他查询关键词。`
});
}
return actions;
};
const collectPageContext = () => {
const params = new URLSearchParams(window.location.search);
const pathSegments = pathname.split('/').filter(Boolean);
const trailingSegment = decodeURIComponent(pathSegments[pathSegments.length - 1] || '');
const context = {
page_name: pageName
};
const fromInput = valueOf('#from');
const toInput = valueOf('#to');
const trips = valueOf('#trips');
const voucherCode = textOf('#vCode') || textOf('#vCodeTop') || textOf('.voucher-code');
const ticketId = compact(textOf('.jr-ticket-id')) || compact(textOf('.jr-panel-headline .mono')) || compact(params.get('id'));
const searchKeyword = valueOf('#q') || valueOf('#queryInput') || compact(params.get('q'));
const status = textOf('#vStatusTop') || textOf('#vStatusTag') || textOf('.jr-status-pill');
const startName = textOf('.vStartName') || textOf('.jr-route-board .jr-station-block:first-child .jr-station-title');
const terminalName = textOf('.vTermName') || textOf('.jr-route-board .jr-station-block.is-end .jr-station-title');
if (voucherCode) context.voucher_code = voucherCode;
if (ticketId) context.ticket_id = ticketId.replace(/\s+<.*$/, '').trim();
if (!context.ticket_id && body.classList.contains('jr-ticket-board-page') && trailingSegment && trailingSegment !== 'search' && trailingSegment !== 'ticket-board.html') {
context.ticket_id = trailingSegment;
}
if (searchKeyword) context.query_keyword = searchKeyword;
if (status) context.status = status;
if (startName) context.start = startName;
if (terminalName) context.terminal = terminalName;
if (fromInput) context.selected_start = fromInput;
if (toInput) context.selected_terminal = toInput;
if (trips) context.trips = trips;
if (pathname === '/order' || pathname === '/ticket-order.html') {
const typeEl = document.querySelector('input[name="trainType"]:checked');
const typeCardTitle = typeEl ? compact(typeEl.nextElementSibling?.querySelector('.type-title')?.textContent || typeEl.value) : '';
if (typeCardTitle) context.train_type = typeCardTitle;
const totalPrice = compact(textOf('.jr-total-amount'));
if (totalPrice) context.price = totalPrice;
}
if (pathname === '/token' || pathname === '/token.html') {
const rideDate = textOf('#vDateTop');
const type = textOf('#vTypeTop');
const price = textOf('#vPriceTop');
const voucherHint = textOf('.jr-redeem-copy');
if (rideDate) context.ride_date = rideDate;
if (type) context.train_type = type;
if (price) context.price = price;
if (voucherHint) context.redeem_tip = compact(voucherHint, 180);
}
if (pathname === '/search' || pathname === '/ticket-search.html' || body.classList.contains('jr-ticket-board-page')) {
const metaItems = Array.from(document.querySelectorAll('.jr-meta-item')).slice(0, 6);
metaItems.forEach((item) => {
const key = compact(item.querySelector('span')?.textContent || '', 40);
const value = compact(item.querySelector('strong')?.textContent || '', 80);
if (!key || !value) return;
if (key.includes('车型')) context.train_type = value;
if (key.includes('票价')) context.price = value;
if (key.includes('乘次')) context.trips_summary = value;
if (key.includes('更新')) context.last_update = value;
});
const metaValues = metaItems.map((item) => compact(item.querySelector('strong')?.textContent || '', 80));
if (!context.train_type && metaValues[0]) context.train_type = metaValues[0];
if (!context.price && metaValues[1]) context.price = metaValues[1];
if (!context.trips_summary && metaValues[2]) context.trips_summary = metaValues[2];
if (!context.last_update && metaValues[3]) context.last_update = metaValues[3];
}
const availableKeys = Object.keys(context).filter((key) => compact(context[key]));
return availableKeys.length > 1 ? context : { page_name: pageName };
};
const ensureStyle = () => {
if (document.getElementById('tm-ai-assistant-style')) return;
const style = document.createElement('style');
style.id = 'tm-ai-assistant-style';
style.textContent = `
.tm-ai-assistant {
position: fixed;
right: 22px;
bottom: 22px;
z-index: 4200;
font-family: "Segoe UI Variable Text", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.tm-ai-toggle {
min-width: 138px;
min-height: 50px;
padding: 9px 12px;
border: 1px solid #d7e0d3;
border-top: 3px solid #2b8a57;
border-radius: 0;
background: #fbfdf9;
color: #183525;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 10px;
justify-content: flex-start;
box-shadow: 0 12px 28px rgba(36, 74, 50, 0.1);
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.tm-ai-toggle:hover {
transform: translateY(-2px);
box-shadow: 0 20px 38px rgba(36, 74, 50, 0.14);
border-color: #cadecd;
}
.tm-ai-toggle-badge {
width: 28px;
height: 28px;
border-radius: 0;
background: #2b8a57;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
color: #ffffff;
letter-spacing: 0.08em;
}
.tm-ai-toggle-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.tm-ai-toggle-copy strong {
font-size: 13px;
line-height: 1;
color: #183525;
}
.tm-ai-toggle-copy span {
font-size: 10px;
color: #607064;
}
.tm-ai-panel {
width: min(520px, calc(100vw - 24px));
height: min(780px, calc(100vh - 72px));
display: none;
flex-direction: column;
margin-top: 10px;
border: 1px solid #d7e0d3;
border-top: 3px solid #2b8a57;
border-radius: 0;
overflow: hidden;
background: #f9fcf7;
box-shadow: 0 24px 56px rgba(36, 74, 50, 0.12);
}
.tm-ai-assistant.is-open .tm-ai-panel {
display: flex;
}
.tm-ai-header {
padding: 12px 14px 8px;
color: #173321;
background: #fbfdf9;
border-bottom: 1px solid #dfe8dd;
}
.tm-ai-header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.tm-ai-agent {
display: flex;
gap: 10px;
align-items: center;
}
.tm-ai-agent-avatar {
width: 26px;
height: 26px;
border-radius: 0;
background: #2b8a57;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 11px;
color: #ffffff;
}
.tm-ai-header-title {
display: flex;
flex-direction: column;
gap: 1px;
}
.tm-ai-kicker {
font-size: 9px;
font-weight: 800;
letter-spacing: 0.14em;
color: #2b8a57;
}
.tm-ai-title {
font-size: 15px;
font-weight: 800;
color: #183525;
}
.tm-ai-subtitle,
.tm-ai-header-meta {
font-size: 11px;
color: #607064;
}
.tm-ai-header-meta {
display: none;
}
.tm-ai-close {
width: 28px;
height: 28px;
border: 1px solid #d7e0d3;
border-radius: 0;
background: #ffffff;
color: #385042;
cursor: pointer;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.tm-ai-close:hover {
background: #f3f8f1;
border-color: #c6d7c1;
color: #183525;
}
.tm-ai-context {
margin-top: 8px;
padding: 8px;
border-radius: 0;
background: #f7fbf5;
border: 1px solid #dbe7d8;
}
.tm-ai-context-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.tm-ai-context-title {
font-size: 11px;
font-weight: 700;
}
.tm-ai-context-tag {
padding: 2px 6px;
border-radius: 0;
background: #edf6ee;
color: #2b8a57;
font-size: 10px;
}
.tm-ai-context-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.tm-ai-context-item {
padding: 6px 8px;
border-radius: 0;
background: #ffffff;
border: 1px solid #e0e9dd;
}
.tm-ai-context-item span {
display: block;
font-size: 9px;
letter-spacing: 0.06em;
color: #6b7c6e;
}
.tm-ai-context-item strong {
display: block;
margin-top: 2px;
font-size: 12px;
line-height: 1.35;
word-break: break-word;
color: #173321;
}
.tm-ai-context-actions,
.tm-ai-suggestions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tm-ai-context-btn,
.tm-ai-suggestion {
padding: 6px 10px;
border-radius: 0;
border: 1px solid #d5e1d2;
background: #ffffff;
color: #355040;
font-size: 11px;
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
}
.tm-ai-context-btn:hover,
.tm-ai-suggestion:hover {
background: #f2f8f1;
border-color: #bfd3ba;
color: #2b8a57;
transform: translateY(-1px);
}
.tm-ai-suggestions {
display: none;
}
.tm-ai-body {
flex: 1;
overflow: auto;
padding: 14px;
background: #fcfefb;
}
.tm-ai-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.tm-ai-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
.tm-ai-row-user {
justify-content: flex-end;
}
.tm-ai-bubble-avatar {
width: 22px;
height: 22px;
border-radius: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 800;
flex-shrink: 0;
}
.tm-ai-row-assistant .tm-ai-bubble-avatar {
background: #edf6ee;
color: #2b8a57;
}
.tm-ai-row-user .tm-ai-bubble-avatar {
background: #7ca986;
color: #ffffff;
order: 2;
}
.tm-ai-message {
max-width: 88%;
border-radius: 0;
padding: 10px 12px;
line-height: 1.68;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
}
.tm-ai-message-assistant {
background: #ffffff;
color: #223428;
border: 1px solid #dbe6d8;
}
.tm-ai-message-user {
background: #eef6ee;
color: #284233;
border: 1px solid #dbe7d8;
}
.tm-ai-message h1,
.tm-ai-message h2,
.tm-ai-message h3 {
margin: 0 0 8px;
color: inherit;
line-height: 1.45;
}
.tm-ai-message h1 { font-size: 16px; }
.tm-ai-message h2 { font-size: 15px; }
.tm-ai-message h3 { font-size: 14px; }
.tm-ai-message p {
margin: 0 0 8px;
}
.tm-ai-message p:last-child,
.tm-ai-message ul:last-child,
.tm-ai-message ol:last-child,
.tm-ai-message pre:last-child {
margin-bottom: 0;
}
.tm-ai-message ul,
.tm-ai-message ol {
margin: 0 0 8px;
padding-left: 18px;
}
.tm-ai-message li + li {
margin-top: 4px;
}
.tm-ai-message a {
color: #2f7d55;
text-decoration: underline;
}
.tm-ai-message strong {
font-weight: 700;
}
.tm-ai-message code {
padding: 1px 5px;
background: #f1f5ef;
border: 1px solid #dde7da;
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
}
.tm-ai-pre {
margin: 0 0 8px;
padding: 10px 12px;
overflow: auto;
background: #f4f8f2;
border: 1px solid #dbe6d8;
}
.tm-ai-pre code {
padding: 0;
border: none;
background: transparent;
display: block;
line-height: 1.55;
}
.tm-ai-footer {
padding: 10px 12px 12px;
border-top: 1px solid #dfe8dd;
background: #fbfdf9;
}
.tm-ai-status {
min-height: 14px;
margin-bottom: 6px;
color: #647266;
font-size: 11px;
}
.tm-ai-composer {
border: 1px solid #d6e1d3;
border-radius: 0;
background: #ffffff;
overflow: hidden;
}
.tm-ai-input {
width: 100%;
min-height: 72px;
resize: none;
padding: 10px 12px;
border: none;
background: transparent;
color: #173324;
outline: none;
font-size: 13px;
line-height: 1.6;
}
.tm-ai-composer:focus-within {
border-color: #2b8a57;
box-shadow: 0 0 0 3px rgba(43, 138, 87, 0.1);
}
.tm-ai-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 0 10px 10px;
}
.tm-ai-hint {
display: none;
}
.tm-ai-send {
min-width: 86px;
height: 34px;
border: none;
border-radius: 0;
background: #4a9969;
color: #fff;
cursor: pointer;
font-weight: 700;
}
.tm-ai-send[disabled],
.tm-ai-input[disabled] {
opacity: 0.65;
cursor: not-allowed;
}
@media (max-width: 768px) {
.tm-ai-assistant {
right: 12px;
left: 12px;
bottom: 12px;
}
.tm-ai-toggle {
width: 100%;
justify-content: center;
}
.tm-ai-panel {
width: 100%;
height: min(76vh, 720px);
}
.tm-ai-context-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tm-ai-actions {
flex-direction: row;
justify-content: flex-end;
}
.tm-ai-send {
width: auto;
}
}
`;
document.head.appendChild(style);
};
const root = document.createElement('section');
root.className = 'tm-ai-assistant';
root.innerHTML = `
<button type="button" class="tm-ai-toggle" aria-label="打开在线客服">
<span class="tm-ai-toggle-badge">客</span>
<span class="tm-ai-toggle-copy">
<strong>票务客服台</strong>
<span>咨询订票、票号与凭证</span>
</span>
</button>
<div class="tm-ai-panel" aria-live="polite">
<div class="tm-ai-header">
<div class="tm-ai-header-row">
<div class="tm-ai-agent">
<div class="tm-ai-agent-avatar">客服</div>
<div class="tm-ai-header-title">
<span class="tm-ai-kicker">FSE TICKET SERVICE DESK</span>
<strong class="tm-ai-title">票务服务台</strong>
<span class="tm-ai-subtitle">当前页面:${pageName}</span>
</div>
</div>
<button type="button" class="tm-ai-close" aria-label="关闭在线客服">×</button>
</div>
<div class="tm-ai-context"></div>
<div class="tm-ai-suggestions"></div>
</div>
<div class="tm-ai-body">
<div class="tm-ai-list"></div>
</div>
<div class="tm-ai-footer">
<div class="tm-ai-status"></div>
<div class="tm-ai-composer">
<textarea class="tm-ai-input" placeholder="例如:帮我解释当前凭证为什么显示可使用?"></textarea>
<div class="tm-ai-actions">
<div class="tm-ai-hint">客服会自动读取当前页面中的票号、凭证码和主要票务信息。</div>
<button type="button" class="tm-ai-send">发送</button>
</div>
</div>
</div>
</div>
`;
ensureStyle();
body.appendChild(root);
const toggleButton = root.querySelector('.tm-ai-toggle');
const closeButton = root.querySelector('.tm-ai-close');
const list = root.querySelector('.tm-ai-list');
const suggestions = root.querySelector('.tm-ai-suggestions');
const contextBox = root.querySelector('.tm-ai-context');
const input = root.querySelector('.tm-ai-input');
const sendButton = root.querySelector('.tm-ai-send');
const statusText = root.querySelector('.tm-ai-status');
const bodyBox = root.querySelector('.tm-ai-body');
const renderMessage = (role, text) => {
const row = document.createElement('div');
row.className = `tm-ai-row tm-ai-row-${role}`;
row.innerHTML = `
<span class="tm-ai-bubble-avatar">${role === 'assistant' ? '服' : '我'}</span>
<div class="tm-ai-message tm-ai-message-${role}"></div>
`;
const messageBox = row.querySelector('.tm-ai-message');
if (role === 'assistant') messageBox.innerHTML = markdownToHtml(text);
else messageBox.textContent = text;
list.appendChild(row);
bodyBox.scrollTop = bodyBox.scrollHeight;
};
const setStatus = (text) => {
statusText.textContent = text || '';
};
const pushHistory = (role, text) => {
history.push({ role, content: text });
if (history.length > 12) history.splice(0, history.length - 12);
};
const openPanel = () => {
root.classList.add('is-open');
refreshContextUI();
window.setTimeout(() => input.focus(), 20);
};
const closePanel = () => {
root.classList.remove('is-open');
};
const setSending = (value) => {
sending = value;
input.disabled = value;
sendButton.disabled = value;
setStatus(value ? '客服正在整理当前页面信息并生成答复...' : '');
};
const sendPreset = (prompt) => {
input.value = prompt;
openPanel();
input.focus();
};
const renderSuggestions = () => {
const items = suggestionMap[pageName] || suggestionMap['首页'];
suggestions.innerHTML = '';
items.forEach((text) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'tm-ai-suggestion';
button.textContent = text;
button.addEventListener('click', () => sendPreset(text));
suggestions.appendChild(button);
});
};
const renderContext = (context) => {
const entries = Object.entries(context || {})
.filter(([key, value]) => key !== 'page_name' && compact(value))
.slice(0, 6);
const actionItems = buildContextActions(context);
if (!entries.length) {
contextBox.innerHTML = `
<div class="tm-ai-context-head">
<span class="tm-ai-context-title">当前页面识别</span>
<span class="tm-ai-context-tag">未识别到票据</span>
</div>
<div class="tm-ai-context-item">
<span>提示</span>
<strong>客服会在你打开凭证详情、票据详情或输入检索内容后自动读取并辅助解释。</strong>
</div>
`;
return;
}
const labelMap = {
voucher_code: '凭证码',
ticket_id: '票号',
query_keyword: '检索内容',
status: '当前状态',
start: '起点',
terminal: '终点',
selected_start: '所选起点',
selected_terminal: '所选终点',
trips: '乘次数量',
train_type: '车型',
price: '票价',
ride_date: '乘车日期',
trips_summary: '乘次信息',
last_update: '最近更新',
redeem_tip: '兑票说明'
};
contextBox.innerHTML = `
<div class="tm-ai-context-head">
<span class="tm-ai-context-title">当前页面识别</span>
<span class="tm-ai-context-tag">已同步票务上下文</span>
</div>
<div class="tm-ai-context-grid">
${entries.map(([key, value]) => `
<div class="tm-ai-context-item">
<span>${labelMap[key] || key}</span>
<strong>${compact(value, 80)}</strong>
</div>
`).join('')}
</div>
${actionItems.length ? `
<div class="tm-ai-context-actions">
${actionItems.map((item, index) => `<button type="button" class="tm-ai-context-btn" data-context-action="${index}">${item.label}</button>`).join('')}
</div>
` : ''}
`;
Array.from(contextBox.querySelectorAll('[data-context-action]')).forEach((button) => {
button.addEventListener('click', () => {
const idx = Number(button.getAttribute('data-context-action'));
const item = actionItems[idx];
if (item) sendPreset(item.prompt);
});
});
};
const refreshContextUI = () => {
const context = collectPageContext();
const signature = toContextSignature(context);
if (signature === lastContextSignature && contextBox.innerHTML) return;
lastContextSignature = signature;
renderContext(context);
};
const sendMessage = async () => {
const message = String(input.value || '').trim();
if (!message || sending) return;
const requestHistory = history.slice();
const context = collectPageContext();
renderMessage('user', message);
pushHistory('user', message);
input.value = '';
setSending(true);
try {
const response = await fetch('/api/ai-assistant', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
message,
history: requestHistory,
page: pageName,
context
})
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data.ok || !data.reply) {
throw new Error(data.error || '在线客服暂时无法回答,请稍后重试。');
}
renderMessage('assistant', data.reply);
pushHistory('assistant', data.reply);
} catch (error) {
const fallback = error?.message || '在线客服暂时不可用,请稍后重试。';
renderMessage('assistant', fallback);
pushHistory('assistant', fallback);
} finally {
setSending(false);
refreshContextUI();
input.focus();
}
};
toggleButton.addEventListener('click', () => {
if (root.classList.contains('is-open')) closePanel();
else openPanel();
});
closeButton.addEventListener('click', closePanel);
sendButton.addEventListener('click', sendMessage);
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
} else if (event.key === 'Escape') {
closePanel();
}
});
const observer = new MutationObserver(() => {
window.clearTimeout(observer.__tmTimer);
observer.__tmTimer = window.setTimeout(refreshContextUI, 180);
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
renderMessage('assistant', `你好,这里是 FSE 票务在线客服。你现在位于“${pageName}”页面,我会自动读取当前票号、凭证码或检索内容,帮你解释状态、使用方法和下一步操作。`);
pushHistory('assistant', `你好,这里是 FSE 票务在线客服。你现在位于“${pageName}”页面,我会自动读取当前票号、凭证码或检索内容,帮你解释状态、使用方法和下一步操作。`);
renderSuggestions();
refreshContextUI();
})();
+18
View File
@@ -0,0 +1,18 @@
{
"style.css": 12,
"blog.css": 2,
"custom-dialog.js": 11,
"ai-assistant.js": 6,
"public-status.js": 13,
"ticket-order.js": 16,
"ticket-search.js": 11,
"ticket-route.js": 2,
"index.js": 2,
"ic-card-admin.js": 2,
"ic-card-order.js": 2,
"ic-card-detail.js": 2,
"ic-card-search.js": 2,
"token.js": 2,
"login.js": 2,
"blog.js": 2
}
+53
View File
@@ -0,0 +1,53 @@
.portal-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.portal-card {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
transition: all 0.3s ease;
text-decoration: none;
display: flex;
flex-direction: column;
align-items: flex-start;
height: 100%;
text-align: left;
}
.portal-card:hover {
transform: translateY(-5px);
border-color: #52525b;
box-shadow: 0 10px 30px -10px rgba(255, 255, 255, 0.05);
}
.portal-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background-color: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
font-size: 1.5rem;
color: #e4e4e7;
}
.portal-card h3 {
font-size: 1.25rem;
margin-bottom: 8px;
color: var(--text);
}
.portal-card p {
color: var(--muted);
font-size: 0.9rem;
line-height: 1.5;
margin: 0;
}
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FMG</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="style.css?v=12">
<link rel="stylesheet" href="blog.css?v=2">
</head>
<body class="public-search">
<div class="public-container">
<header class="search-header" style="text-align: left;">
<div style="margin-bottom: 10px; text-align: left;">
<a href="https://ticket.fse-media.group" id="homeLink" style="color: var(--primary); text-decoration: none; font-weight: 500;">
<i class="fas fa-arrow-left"></i> 返回首页
</a>
</div>
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<img src="logo.png" alt="Logo" style="height: 40px; margin-right: 12px;">
<h1 style="font-weight: 800; letter-spacing: -1px; margin: 0; text-align: left; font-size: 26px;">
FMG
</h1>
</div>
</header>
<main>
<section class="tab-panel show">
<div class="portal-grid">
<a href="http://forum.fse-media.group" class="portal-card">
<div class="portal-icon">
<i class="fas fa-comments"></i>
</div>
<h3>论坛</h3>
<p>forum.fse-media.group</p>
</a>
<a href="http://b.igtm.ooooo.ink:11324" class="portal-card">
<div class="portal-icon">
<i class="fas fa-poll-h"></i>
</div>
<h3>问卷</h3>
<p>b.igtm.ooooo.ink</p>
</a>
</div>
<div class="card" style="margin-top: 20px;">
<div class="card-title" style="font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; color: var(--text);">
<i class="fas fa-server text-primary"></i> 服务器状态</div>
<div style="overflow-x: auto; width: 100%;">
<iframe id="mc-status-traintown-online-" frameborder="0" width="1000" height="500" style="max-width:100%; border-radius: 8px;" scrolling="no" src="https://motd.minebbs.com/iframe?ip=traintown.online&stype=auto&dark=true"></iframe>
</div>
</div>
</section>
</main>
<footer style="margin-top: 60px; padding: 20px 0; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; text-align: center;">
<p>&copy; 2026 FSE Media Group. All rights reserved.</p>
</footer>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="blog.js?v=2"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
document.addEventListener('DOMContentLoaded', () => {
if (!location.hostname.includes('fse-media.group')) {
const homeLink = document.getElementById('homeLink');
if (homeLink) homeLink.href = '/home.html';
}
});
+200
View File
@@ -0,0 +1,200 @@
(function () {
if (window.appDialog) return;
const state = {
root: null,
panel: null,
title: null,
message: null,
field: null,
input: null,
cancel: null,
confirm: null,
lastFocused: null,
queue: Promise.resolve()
};
function ensureRoot() {
if (state.root) return;
const root = document.createElement('div');
root.className = 'tm-dialog-root';
root.hidden = true;
root.innerHTML = [
'<div class="tm-dialog-backdrop" data-dialog-close="cancel"></div>',
'<div class="tm-dialog-panel" role="dialog" aria-modal="true" aria-labelledby="tmDialogTitle">',
' <div class="tm-dialog-kicker">FSE RAILWAY</div>',
' <h3 class="tm-dialog-title" id="tmDialogTitle">系统提示</h3>',
' <div class="tm-dialog-message"></div>',
' <label class="tm-dialog-field" hidden>',
' <span class="tm-dialog-field-label">输入内容</span>',
' <input class="tm-dialog-input" type="text" />',
' </label>',
' <div class="tm-dialog-actions">',
' <button type="button" class="btn tm-dialog-cancel">取消</button>',
' <button type="button" class="btn primary tm-dialog-confirm">确定</button>',
' </div>',
'</div>'
].join('');
document.body.appendChild(root);
state.root = root;
state.panel = root.querySelector('.tm-dialog-panel');
state.title = root.querySelector('.tm-dialog-title');
state.message = root.querySelector('.tm-dialog-message');
state.field = root.querySelector('.tm-dialog-field');
state.input = root.querySelector('.tm-dialog-input');
state.cancel = root.querySelector('.tm-dialog-cancel');
state.confirm = root.querySelector('.tm-dialog-confirm');
}
function whenReady() {
if (document.body) return Promise.resolve();
return new Promise((resolve) => {
document.addEventListener('DOMContentLoaded', resolve, { once: true });
});
}
function normalizeOptions(type, value, fallbackValue) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return {
type,
title: value.title || (type === 'confirm' ? '请确认' : type === 'prompt' ? '请输入内容' : '系统提示'),
message: value.message || '',
confirmText: value.confirmText || (type === 'alert' ? '知道了' : '确定'),
cancelText: value.cancelText || '取消',
defaultValue: value.defaultValue == null ? '' : String(value.defaultValue),
placeholder: value.placeholder || ''
};
}
return {
type,
title: type === 'confirm' ? '请确认' : type === 'prompt' ? '请输入内容' : '系统提示',
message: value == null ? '' : String(value),
confirmText: type === 'alert' ? '知道了' : '确定',
cancelText: '取消',
defaultValue: fallbackValue == null ? '' : String(fallbackValue),
placeholder: ''
};
}
function setOpen(open) {
ensureRoot();
state.root.hidden = !open;
state.root.classList.toggle('is-open', open);
}
async function showDialog(options) {
await whenReady();
ensureRoot();
return new Promise((resolve) => {
setOpen(true);
state.lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
state.title.textContent = options.title;
state.message.textContent = options.message;
state.confirm.textContent = options.confirmText;
state.cancel.textContent = options.cancelText;
const isPrompt = options.type === 'prompt';
const showCancel = options.type !== 'alert';
state.field.hidden = !isPrompt;
state.input.value = options.defaultValue || '';
state.input.placeholder = options.placeholder || '';
state.cancel.hidden = !showCancel;
let settled = false;
const close = (result, shouldRestoreFocus = true) => {
if (settled) return;
settled = true;
document.removeEventListener('keydown', onKeydown, true);
state.root.removeEventListener('click', onRootClick, true);
setOpen(false);
if (shouldRestoreFocus && state.lastFocused && typeof state.lastFocused.focus === 'function') {
window.setTimeout(() => state.lastFocused.focus(), 0);
}
resolve(result);
};
const onKeydown = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
if (options.type === 'alert') close(undefined);
else close(null);
return;
}
if (event.key === 'Enter') {
const target = event.target;
if (target === state.cancel) return;
event.preventDefault();
if (isPrompt) close(state.input.value);
else if (options.type === 'confirm') close(true);
else close(undefined);
}
};
const onRootClick = (event) => {
const action = event.target && event.target.getAttribute && event.target.getAttribute('data-dialog-close');
if (action === 'cancel') {
if (options.type === 'alert') close(undefined);
else close(null);
return;
}
if (event.target === state.cancel) {
close(null);
return;
}
if (event.target === state.confirm) {
if (isPrompt) close(state.input.value);
else if (options.type === 'confirm') close(true);
else close(undefined);
}
};
document.addEventListener('keydown', onKeydown, true);
state.root.addEventListener('click', onRootClick, true);
window.setTimeout(() => {
if (isPrompt) state.input.focus();
else state.confirm.focus();
}, 0);
}).then((result) => {
if (options.type === 'confirm') return result === true;
if (options.type === 'prompt') return result == null ? null : String(result);
return undefined;
});
}
function enqueue(task) {
state.queue = state.queue.then(task, task);
return state.queue;
}
const api = {
alert(message) {
return enqueue(() => showDialog(normalizeOptions('alert', message)));
},
confirm(message) {
return enqueue(() => showDialog(normalizeOptions('confirm', message)));
},
prompt(message, defaultValue) {
return enqueue(() => showDialog(normalizeOptions('prompt', message, defaultValue)));
}
};
window.appDialog = api;
window.alert = function (message) {
return api.alert(message);
};
window.confirm = function (message) {
return api.confirm(message);
};
window.prompt = function (message, defaultValue) {
return api.prompt(message, defaultValue);
};
})();
+249
View File
@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FSE 铁路票务系统 - 首页</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
</head>
<body class="public-search jr-public-page">
<div class="jr-public-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" class="jr-top-link" id="homeTopLink">
<i class="fas fa-train"></i>
<span>FSE铁路运输票务系统</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FarSight-T.N.E铁路运输</strong>
<span>票务服务</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
<a href="https://ticket.fse-media.group/home.html" data-link="home" class="is-active">首页</a>
<a href="https://ticket.fse-media.group/order" data-link="order">线上预定</a>
<a href="https://ticket.fse-media.group/search" data-link="search">车票查询</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
</nav>
</div>
</div>
<main class="jr-public-main">
<section class="jr-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>
<span class="jr-feature-copy">
<strong>线上预定</strong>
<span>在线选择区间、车型与乘次,并生成兑票凭证。</span>
</span>
</a>
<a href="https://ticket.fse-media.group/search" data-link="search" class="jr-home-service-card">
<span class="jr-feature-icon"><i class="fas fa-search"></i></span>
<span class="jr-feature-copy">
<strong>车票查询</strong>
<span>输入票号、站点或日期,快速查看票据详情和流转记录。</span>
</span>
</a>
<a href="https://ticket.fse-media.group/search" data-link="search" class="jr-home-service-card">
<span class="jr-feature-icon"><i class="fas fa-receipt"></i></span>
<span class="jr-feature-copy">
<strong>凭证核验</strong>
<span>查询订单凭证状态,确认是否已被使用或仍可兑票。</span>
</span>
</a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="ic-card-order" class="jr-home-service-card">
<span class="jr-feature-icon"><i class="fas fa-credit-card"></i></span>
<span class="jr-feature-copy">
<strong>线上购卡</strong>
<span>在线填写购卡信息,生成IC卡订单号与领卡卡号。</span>
</span>
</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="ic-card-search" class="jr-home-service-card">
<span class="jr-feature-icon"><i class="fas fa-wallet"></i></span>
<span class="jr-feature-copy">
<strong>IC 卡查询</strong>
<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">
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h3>票价图</h3>
<span class="jr-panel-note">Fare Map</span>
</div>
<div id="fareMapBox" class="jr-asset-frame">
<div class="text-muted">加载中...</div>
</div>
</article>
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h3>线路图</h3>
<span class="jr-panel-note">Route Map</span>
</div>
<div id="routeMapBox" class="jr-asset-frame">
<div class="text-muted">加载中...</div>
</div>
</article>
</section>
<footer class="site-footer jr-footer-space">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>
const loadPublicAssets = async () => {
const fareMapBox = document.getElementById('fareMapBox');
const routeMapBox = document.getElementById('routeMapBox');
try {
const v = String(Date.now());
try {
const r2 = await fetch(`/api/public/fares/map/light?t=${encodeURIComponent(v)}`, { cache: 'no-store' });
const svg = await r2.text();
fareMapBox.innerHTML = `<div class="jr-asset-frame">${svg}</div>`;
} catch (e) {
fareMapBox.innerHTML = '<div class="text-muted">票价图加载失败</div>';
}
const r = await fetch('/api/assets/manifest', { cache: 'no-store' });
const m = await r.json();
const mv = (m && m.updatedAt) ? String(m.updatedAt) : v;
if (m && m.routeMap) {
const img = document.createElement('img');
img.alt = '线路图';
img.src = `/assets/${encodeURIComponent(m.routeMap)}?v=${encodeURIComponent(mv)}`;
routeMapBox.innerHTML = '<div class="jr-asset-frame"></div>';
routeMapBox.firstElementChild.appendChild(img);
} else {
routeMapBox.innerHTML = '<div class="text-muted">暂无线路图</div>';
}
} catch (e) {
fareMapBox.innerHTML = '<div class="text-muted">票价图加载失败</div>';
routeMapBox.innerHTML = '<div class="text-muted">线路图加载失败</div>';
}
};
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html',
order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html',
search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html',
'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html',
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
};
const homeTopLink = document.getElementById('homeTopLink');
const brandLink = document.getElementById('brandLink');
if (homeTopLink) homeTopLink.href = links.home;
if (brandLink) brandLink.href = links.home;
document.querySelectorAll('[data-link]').forEach((el) => {
const key = el.getAttribute('data-link');
if (links[key]) el.href = links[key];
});
loadPublicAssets();
});
</script>
</body>
</html>
+208
View File
@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FSE 閾佽矾绁ㄥ姟绯荤粺 - IC 鍗$鐞?/title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
</head>
<body class="jr-admin-page jr-admin-ic-page jr-public-page">
<div class="jr-public-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="/" class="jr-top-link" id="icTopLink">
<i class="fas fa-train"></i>
<span>FSE 閾佽矾杩愯緭鍚庡彴绯荤粺</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">鏈嶅姟鍣ㄧ姸鎬?/span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>妫€娴嬩腑</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="/" class="jr-brand" id="icBrandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE 閾佽矾杩愯緭</strong>
<span>IC 鍗$鐞嗗悗鍙?/span>
</div>
</a>
<nav class="jr-nav" aria-label="绔欑偣瀵艰埅">
<a href="https://ticket.fse-media.group/home.html" data-link="home">棣栭〉</a>
<a href="https://ticket.fse-media.group/order" data-link="order">绾夸笂棰勫畾</a>
<a href="https://ticket.fse-media.group/search" data-link="search">杞︾エ鏌ヨ</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 鍗℃煡璇?/a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">绾夸笂璐崱</a>
</nav>
</div>
</div>
<main class="jr-public-main jr-admin-main-shell">
<div id="app" class="jr-admin-app">
<div class="sidebar">
<div class="jr-admin-sidebar-head">
<span class="jr-kicker">IC CARD CONSOLE</span>
<div class="brand">FSE 閾佽矾绁ㄥ姟绯荤粺鎺у埗鍙?/div>
<p class="jr-admin-sidebar-copy">缁熶竴绠$悊 IC 鍗″彂琛屻€佸厖鍊笺€佹寔鍗′汉淇℃伅鍜屽巻鍙叉搷浣滆褰曘€?/p>
</div>
<div class="nav">
<a href="/" class="nav-item" style="text-decoration:none;">
<span class="nav-icon"><i class="fas fa-home"></i></span> 杩斿洖棣栭〉
</a>
<a href="/admin" class="nav-item" style="text-decoration:none;">
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> 涓绘帶鍒跺彴
</a>
<a href="/admin/ic-card" class="nav-item active" style="text-decoration:none;">
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC 鍗$鐞?</a>
</div>
<div class="sidebar-footer jr-admin-sidebar-status">
<div>IC Card Console</div>
<div id="serverStatusText" style="margin-top:6px;">姝e湪妫€娴嬫湇鍔$姸鎬?..</div>
</div>
</div>
<div class="main">
<div class="header">
<div class="jr-admin-header-copy">
<div class="flex" style="gap: 12px;">
<div>
<span class="jr-kicker">JR STYLE ADMIN</span>
<h3 style="margin: 0;">IC 鍗$鐞?/h3>
</div>
</div>
</div>
<div class="flex">
<button id="refreshBtn"><i class="fas fa-sync-alt"></i> 鍒锋柊</button>
</div>
</div>
<div class="content">
<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 鍗″垱寤恒€佷綑棰濈鐞嗐€佹寔鍗′汉璧勬枡鍜屼簨浠舵祦鏌ョ湅锛岄€傚悎浣滀负鍚庡彴鍗″姟绠$悊鐨勫崟鐙叆鍙c€?/p>
</section>
<div class="grid">
<div class="card">
<div class="stat-label">IC 鍗℃€绘暟</div>
<div class="stat-value" id="statTotal">0</div>
</div>
<div class="card">
<div class="stat-label">寰呴鍗?/div>
<div class="stat-value" id="statPending">0</div>
</div>
<div class="card">
<div class="stat-label">姝e父鍚敤</div>
<div class="stat-value" id="statActive">0</div>
</div>
<div class="card">
<div class="stat-label">鍌ㄥ€兼€婚</div>
<div class="stat-value" id="statBalance">0</div>
</div>
</div>
<div class="management-container ic-admin-layout">
<div class="management-sidebar">
<div class="card">
<div class="flex between mb-4">
<h4>蹇€熷缓鍗?/h4>
</div>
<div class="ic-form-grid">
<input id="createHolder" placeholder="鎸佸崱浜哄鍚嶏紙浠呰嫳鏂囦笌绗﹀彿锛?>
<input id="createBalance" type="number" min="0" step="1" value="50"
placeholder="鍒濆浣欓">
</div>
<div class="text-muted" style="margin-top:12px;">鍚庡彴寤哄崱涔熺粺涓€涓?IC 鍌ㄥ€煎崱锛屾寔鍗′汉濮撳悕浠呮敮鎸佽嫳鏂囦笌甯哥敤绗﹀彿銆? </div>
<div class="toolbar" style="margin-top: 14px;">
<button id="createBtn" class="btn primary"><i class="fas fa-plus"></i> 鍒涘缓 IC
鍗?/button>
</div>
</div>
<div class="card"
style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
<div class="flex between mb-4">
<h4>鍗$墖鍒楄〃</h4>
<span class="badge" id="listCountBadge">0</span>
</div>
<div class="flex mb-4" style="flex-wrap:wrap;">
<input id="searchInput" placeholder="鎼滅储鍗″彿 / 璁㈠崟鍙?/ 濮撳悕" style="flex:1;">
</div>
<div id="cardList" class="list-lines" style="flex:1; overflow-y:auto;"></div>
</div>
</div>
<div class="management-main">
<div class="card">
<div class="flex between mb-4">
<h4>鍗$墖璇︽儏</h4>
<div class="flex" style="gap:8px;">
<button id="topupBtn" class="btn"><i class="fas fa-wallet"></i> 鍏呭€?/button>
<button id="saveBtn" class="btn primary"><i class="fas fa-save"></i>
淇濆瓨</button>
</div>
</div>
<div id="detailPanel" class="empty-state">
<i class="fas fa-credit-card" style="font-size:48px; opacity:0.3;"></i>
<p>浠庡乏渚ч€夋嫨涓€寮?IC 鍗′互鏌ョ湅璇︽儏銆?/p>
</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="flex between mb-4">
<h4>鎿嶄綔璁板綍</h4>
</div>
<div id="eventList" class="timeline">
<div class="loading">閫夋嫨鍗$墖鍚庢樉绀轰簨浠舵祦銆?/div>
</div>
</div>
</div>
</div>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank"
rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
<span class="version">v1.0.12</span>
</footer>
</div>
</div>
</div>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ic-card-admin.js?v=2"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html',
order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html',
search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html',
'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html',
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
};
document.getElementById('icTopLink').href = links.home;
document.getElementById('icBrandLink').href = links.home;
document.querySelectorAll('[data-link]').forEach((el) => {
const key = el.getAttribute('data-link');
if (links[key]) el.href = links[key];
});
});
</script>
</body>
</html>
+335
View File
@@ -0,0 +1,335 @@
(() => {
const $ = (sel) => document.querySelector(sel);
const state = {
cards: [],
selectedId: '',
selectedCard: null,
selectedEvents: []
};
const appDialog = window.appDialog || {
alert: (message) => Promise.resolve(window.alert(message)),
confirm: (message) => Promise.resolve(window.confirm(message)),
prompt: (message, defaultValue) => Promise.resolve(window.prompt(message, defaultValue))
};
const HOLDER_NAME_PATTERN = /^[A-Za-z][A-Za-z .,'()&@/_\-+]*$/;
const displayCardId = (card) => String(card?.display_card_id || card?.card_id || '---');
const statusTextEl = $('#serverStatusText');
const listEl = $('#cardList');
const detailEl = $('#detailPanel');
const eventEl = $('#eventList');
const searchEl = $('#searchInput');
const api = {
async request(url, opts = {}) {
const res = await fetch(url, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data.error || res.statusText || '请求失败');
}
return data;
},
fetchCards(q = '') {
return api.request(`/api/ic-cards?q=${encodeURIComponent(q)}`);
},
fetchCardDetail(id) {
return api.request(`/api/ic-cards/${encodeURIComponent(id)}`);
},
createCard(payload) {
return api.request('/api/ic-cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
},
updateCard(id, payload) {
return api.request(`/api/ic-cards/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
},
topup(id, amount) {
return api.request(`/api/ic-cards/${encodeURIComponent(id)}/topup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount })
});
}
};
const escapeHtml = (value) => String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const formatMoney = (value) => {
const n = Number(value || 0);
return Number.isFinite(n) ? n.toFixed(0) : '0';
};
const formatTime = (value) => {
if (value == null || value === '') return '---';
const ts = Number(value);
const date = Number.isFinite(ts) ? new Date(ts) : new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false });
};
const statusInfo = (status) => {
const map = {
pending_pickup: { text: '待领卡', className: 'badge-warning' },
active: { text: '正常', className: 'badge-success' },
disabled: { text: '停用', className: 'badge-danger' },
lost: { text: '挂失', className: 'badge-danger' },
refunded: { text: '已退卡', className: 'badge-secondary' }
};
return map[String(status || '').toLowerCase()] || { text: status || '未知', className: 'badge-secondary' };
};
const eventTitle = (event) => {
const map = {
create: '后台建卡',
update: '信息更新',
topup: '余额充值',
order_created: '线上购卡',
activated: '正式启用'
};
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
};
const renderStats = () => {
const cards = state.cards;
$('#statTotal').textContent = String(cards.length);
$('#statPending').textContent = String(cards.filter((card) => card.status === 'pending_pickup').length);
$('#statActive').textContent = String(cards.filter((card) => card.status === 'active').length);
$('#statBalance').textContent = formatMoney(cards.reduce((sum, card) => sum + (Number(card.balance || 0) || 0), 0));
$('#listCountBadge').textContent = String(cards.length);
};
const renderList = () => {
if (!state.cards.length) {
listEl.innerHTML = '<div class="empty-state" style="padding:24px 0;"><p>暂无 IC 卡记录。</p></div>';
return;
}
listEl.innerHTML = state.cards.map((card) => {
const info = statusInfo(card.status);
return `
<div class="line-item ic-card-item ${state.selectedId === card.card_id ? 'active' : ''}" data-id="${escapeHtml(card.card_id)}">
<div class="line-color-dot" style="background:${card.status === 'active' ? 'var(--success)' : (card.status === 'pending_pickup' ? 'var(--warning)' : 'var(--danger)')}"></div>
<div class="line-info">
<div class="line-name">${escapeHtml(displayCardId(card))}</div>
<div class="line-meta">${escapeHtml(card.holder_name || '未登记持卡人')} · IC 储值卡</div>
<div class="line-meta">订单 ${escapeHtml(card.order_code || '---')} · 余额 ${escapeHtml(formatMoney(card.balance))}</div>
</div>
<div class="line-actions" style="opacity:1;">
<span class="badge ${info.className}">${escapeHtml(info.text)}</span>
</div>
</div>
`;
}).join('');
listEl.querySelectorAll('[data-id]').forEach((item) => {
item.addEventListener('click', () => {
const id = item.getAttribute('data-id');
if (id) loadCard(id);
});
});
};
const renderDetail = () => {
if (!state.selectedCard) {
detailEl.className = 'empty-state';
detailEl.innerHTML = '<i class="fas fa-credit-card" style="font-size:48px; opacity:0.3;"></i><p>从左侧选择一张 IC 卡以查看详情。</p>';
eventEl.innerHTML = '<div class="loading">选择卡片后显示事件流。</div>';
return;
}
const card = state.selectedCard;
const info = statusInfo(card.status);
detailEl.className = '';
detailEl.innerHTML = `
<div class="flex between mb-4" style="align-items:flex-start;">
<div>
<div class="mono" style="font-size:1.4rem; font-weight:700;">${escapeHtml(displayCardId(card))}</div>
<div class="text-muted" style="margin-top:6px;">订单号 ${escapeHtml(card.order_code || '---')} · 来源 ${escapeHtml(card.source || '---')}</div>
</div>
<span class="badge ${info.className}">${escapeHtml(info.text)}</span>
</div>
<div class="ic-detail-grid">
<label class="ic-field">
<span>持卡人</span>
<input id="detailHolder" value="${escapeHtml(card.holder_name || '')}">
</label>
<label class="ic-field">
<span>卡片类型</span>
<input value="IC 储值卡" disabled>
</label>
<label class="ic-field">
<span>状态</span>
<select id="detailStatus">
<option value="pending_pickup" ${card.status === 'pending_pickup' ? 'selected' : ''}>待领卡</option>
<option value="active" ${card.status === 'active' ? 'selected' : ''}>正常</option>
<option value="disabled" ${card.status === 'disabled' ? 'selected' : ''}>停用</option>
<option value="lost" ${card.status === 'lost' ? 'selected' : ''}>挂失</option>
<option value="refunded" ${card.status === 'refunded' ? 'selected' : ''}>已退卡</option>
</select>
</label>
<label class="ic-field">
<span>余额</span>
<input id="detailBalance" type="number" min="0" step="1" value="${escapeHtml(Number(card.balance || 0))}">
</label>
</div>
<div class="ic-inline-meta">
<div class="list-item"><span class="k">创建时间</span><span class="v">${escapeHtml(formatTime(card.created_ts))}</span></div>
<div class="list-item"><span class="k">最后更新</span><span class="v">${escapeHtml(formatTime(card.last_update_ts))}</span></div>
<div class="list-item"><span class="k">首次充值</span><span class="v">${escapeHtml(formatMoney(card.purchase_amount ?? card.balance))}</span></div>
<div class="list-item"><span class="k">购卡金额</span><span class="v">${escapeHtml(formatMoney(card.purchase_amount))}</span></div>
</div>
`;
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>
</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>';
};
const validateHolderName = (value) => {
const holderName = String(value || '').trim();
if (!holderName) return '请输入持卡人姓名';
if (holderName.length > 24) return '持卡人姓名不能超过 24 个字符';
if (!HOLDER_NAME_PATTERN.test(holderName)) return '持卡人姓名仅支持英文与常用符号';
return '';
};
const currentDetailPayload = () => ({
holder_name: $('#detailHolder')?.value.trim() || '',
status: $('#detailStatus')?.value || 'active',
balance: Number($('#detailBalance')?.value || 0) || 0
});
async function refreshList(keepSelection = true) {
const query = searchEl.value.trim();
const data = await api.fetchCards(query);
state.cards = data.cards || [];
renderStats();
renderList();
if (keepSelection && state.selectedId && state.cards.some((card) => card.card_id === state.selectedId)) {
await loadCard(state.selectedId, false);
} else if (state.selectedId && !state.cards.some((card) => card.card_id === state.selectedId)) {
state.selectedId = '';
state.selectedCard = null;
state.selectedEvents = [];
renderDetail();
}
}
async function loadCard(id, rerenderList = true) {
const data = await api.fetchCardDetail(id);
state.selectedId = id;
state.selectedCard = data.card || null;
state.selectedEvents = data.events || [];
if (rerenderList) renderList();
renderDetail();
}
async function createCard() {
const holder_name = $('#createHolder').value.trim();
const balance = Number($('#createBalance').value || 0) || 0;
const holderNameError = validateHolderName(holder_name);
if (holderNameError) {
alert(holderNameError);
return;
}
const data = await api.createCard({ holder_name, balance });
$('#createHolder').value = '';
$('#createBalance').value = '50';
await refreshList(false);
if (data.card_id) await loadCard(data.card_id);
alert(`IC 卡已创建:${displayCardId(data.card || data)}`);
}
async function saveCard() {
if (!state.selectedId) {
alert('请先选择一张 IC 卡');
return;
}
const payload = currentDetailPayload();
const holderNameError = validateHolderName(payload.holder_name);
if (holderNameError) {
alert(holderNameError);
return;
}
await api.updateCard(state.selectedId, payload);
await refreshList(false);
await loadCard(state.selectedId);
alert('已保存 IC 卡信息');
}
async function topupCard() {
if (!state.selectedId) {
alert('请先选择一张 IC 卡');
return;
}
const raw = await appDialog.prompt({
title: 'IC 卡充值',
message: `请输入给 ${displayCardId(state.selectedCard || { card_id: state.selectedId })} 充值的金额`,
defaultValue: '50',
placeholder: '请输入充值金额',
confirmText: '确认充值'
});
if (raw == null) return;
const amount = Number(raw);
if (!(amount > 0)) {
alert('充值金额必须大于 0');
return;
}
await api.topup(state.selectedId, amount);
await refreshList(false);
await loadCard(state.selectedId);
alert('充值成功');
}
async function pingServer() {
try {
await fetch('/api/public/health', { cache: 'no-store' });
statusTextEl.textContent = '服务状态:在线';
} catch (_) {
statusTextEl.textContent = '服务状态:离线';
}
}
$('#refreshBtn').addEventListener('click', () => refreshList(false).catch((error) => alert(error.message || String(error))));
$('#createBtn').addEventListener('click', () => createCard().catch((error) => alert(error.message || String(error))));
$('#saveBtn').addEventListener('click', () => saveCard().catch((error) => alert(error.message || String(error))));
$('#topupBtn').addEventListener('click', () => topupCard().catch((error) => alert(error.message || String(error))));
searchEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
refreshList(false).catch((error) => alert(error.message || String(error)));
}
});
searchEl.addEventListener('input', () => {
window.clearTimeout(searchEl._timer);
searchEl._timer = window.setTimeout(() => {
refreshList(false).catch((error) => alert(error.message || String(error)));
}, 240);
});
(async () => {
await pingServer();
await refreshList(false);
if (state.cards[0]) await loadCard(state.cards[0].card_id);
})().catch((error) => {
statusTextEl.textContent = '服务状态:请求失败';
listEl.innerHTML = `<div class="loading">${escapeHtml(error.message || String(error))}</div>`;
});
})();
+112
View File
@@ -0,0 +1,112 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IC 卡详情</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
</head>
<body class="public-search jr-public-page">
<div class="jr-public-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE Railway</strong>
<span>IC Card Detail</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
<a href="https://ticket.fse-media.group/order" data-link="order">线上预定</a>
<a href="https://ticket.fse-media.group/search" data-link="search">车票查询</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search" class="is-active">IC
卡查询</a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
</nav>
</div>
</div>
<main class="jr-public-main">
<section class="jr-page-intro">
<span class="jr-kicker">IC CARD PASS</span>
<h1>IC 卡电子信息</h1>
<p>IC交通卡电子信息</p>
</section>
<div id="loading" class="jr-panel-card">
<div class="jr-center-empty">
<p>正在加载卡片信息...</p>
</div>
</div>
<div id="error" class="jr-panel-card" style="display:none;">
<div class="jr-center-empty">
<h2 style="margin:0 0 10px;">卡片不存在</h2>
<p id="errorMsg">系统未找到该 IC 卡信息。</p>
</div>
</div>
<div id="content" class="jr-voucher-layout" style="display:none;">
<section class="jr-voucher-card">
<div class="jr-panel-headline">
<h2>IC 卡电子信息</h2>
<span class="jr-panel-note" id="cardStatusTop"></span>
</div>
<div class="jr-voucher-band jr-card-pass-band">
<span class="jr-kicker">CARD ID</span>
<div class="jr-voucher-code" id="cardIdTop"></div>
</div>
<div class="jr-meta-grid jr-card-info-grid">
<div class="jr-meta-item"><span>持卡人</span><strong id="holderName"></strong></div>
<div class="jr-meta-item"><span>当前余额</span><strong class="jr-code-accent-status"
id="cardBalance"></strong></div>
<div class="jr-meta-item"><span>凭证码</span><strong class="mono" id="cardVoucher"></strong></div>
<div class="jr-meta-item"><span>开卡时间</span><strong id="cardCreatedTs"></strong></div>
</div>
<div class="jr-redeem-summary">
<span class="jr-kicker">CARD GUIDE</span>
<div class="jr-redeem-code-row">
<span class="jr-redeem-code-label">当前状态</span>
<strong class="jr-redeem-code-value" id="cardStatusTag"></strong>
</div>
<p class="jr-redeem-copy" id="cardUsageHint">如卡片仍为“待领卡”,请持凭证码前往站内售票机完成领卡;如已启用,可在检票机直接刷卡进出站。</p>
</div>
<div class="jr-action-row">
<a id="searchLink" href="#" class="btn jr-secondary-btn"><i class="fas fa-search"></i> 查询记录</a>
<button class="btn primary jr-search-button" id="copyCardIdBtn"><i class="fas fa-copy"></i>
复制卡号</button>
</div>
</section>
</div>
<footer class="site-footer jr-footer-space">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ic-card-detail.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html', order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html', search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html', 'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html', 'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
}; const homeLink = document.getElementById('homeLink'); const brandLink = document.getElementById('brandLink'); if (homeLink) homeLink.href = links.home; if (brandLink) brandLink.href = links.home; document.querySelectorAll('[data-link]').forEach((el) => { const key = el.getAttribute('data-link'); if (links[key]) el.href = links[key]; });
});</script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
(() => {
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const errorMsg = document.getElementById('errorMsg');
const content = document.getElementById('content');
const copyCardIdBtn = document.getElementById('copyCardIdBtn');
const formatTime = (value) => {
if (value == null || value === '') return '---';
const ts = Number(value);
const date = Number.isFinite(ts) ? new Date(ts) : new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false });
};
const escapeHtml = (value) => String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const applyStatus = (el, text, cls) => {
if (!el) return;
el.textContent = text;
el.className = cls;
};
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value ?? '';
};
const normalizeStatus = (status) => {
const s = String(status || '').toLowerCase();
if (s === 'active') return { text: '正常', cls: 'jr-status-pill jr-status-valid' };
if (s === 'pending_pickup') return { text: '待领卡', cls: 'jr-status-pill jr-status-used' };
if (s === 'disabled' || s === 'lost' || s === 'refunded') return { text: '不可用', cls: 'jr-status-pill jr-status-expired' };
return { text: status || '未知', cls: 'jr-status-pill jr-status-expired' };
};
const normalizeMode = (card) => String(card?.status || '').trim().toLowerCase() === 'pending_pickup' ? 'voucher' : 'card';
const getLookupKey = (card, fallbackCardId) => {
const voucher = String(card?.voucher_code || card?.code || card?.order_code || '').trim();
const rawCardId = String(card?.card_id || fallbackCardId || '').trim();
return normalizeMode(card) === 'voucher' ? (voucher || rawCardId) : (rawCardId || voucher);
};
const getUsageHint = (card) => {
const status = String(card?.status || '').trim().toLowerCase();
if (status === 'pending_pickup') {
return '当前仍为待领卡状态,请持凭证码前往站内售票机完成领卡。';
}
return '卡片已启用,可在检票机直接刷卡进出站。';
};
const pathParts = location.pathname.split('/').filter(Boolean);
const cardId = decodeURIComponent(pathParts[pathParts.length - 1] || '');
if (!cardId) {
loading.style.display = 'none';
error.style.display = 'block';
errorMsg.textContent = '无效的卡号';
return;
}
fetch(`/api/public/ic-cards/query?q=${encodeURIComponent(cardId)}`)
.then((res) => res.json())
.then((res) => {
if (!res.ok || !res.card) {
throw new Error(res.error || '未找到 IC 卡信息');
}
const card = res.card;
const voucher = card.voucher_code || card.code || card.order_code || '---';
const status = normalizeStatus(card.status_label || card.status);
const shownCardId = card.display_card_id || card.card_id || cardId;
const lookupKey = getLookupKey(card, cardId);
const copyMode = normalizeMode(card);
loading.style.display = 'none';
error.style.display = 'none';
content.style.display = 'block';
setText('cardIdTop', shownCardId);
setText('holderName', card.holder_name || '未登记');
setText('cardBalance', card.balance ?? 0);
setText('cardVoucher', voucher);
setText('cardCreatedTs', formatTime(card.created_ts));
setText('cardUsageHint', getUsageHint(card));
applyStatus(document.getElementById('cardStatusTop'), status.text, status.cls);
applyStatus(document.getElementById('cardStatusTag'), status.text, status.cls);
const searchLink = document.getElementById('searchLink');
if (searchLink) {
const href = location.hostname.includes('fse-media.group')
? `https://ticket.fse-media.group/ic-card/search?q=${encodeURIComponent(lookupKey)}`
: `/ic-card-search.html?q=${encodeURIComponent(lookupKey)}`;
searchLink.href = href;
}
if (copyCardIdBtn) {
copyCardIdBtn.innerHTML = copyMode === 'voucher'
? '<i class="fas fa-copy"></i> 复制凭证码'
: '<i class="fas fa-copy"></i> 复制卡号';
copyCardIdBtn.onclick = () => {
const copyValue = copyMode === 'voucher' ? voucher : (card.card_id || cardId);
navigator.clipboard.writeText(copyValue).then(() => {
alert(copyMode === 'voucher' ? '已复制凭证码' : '已复制卡号');
});
};
}
})
.catch((err) => {
loading.style.display = 'none';
error.style.display = 'block';
errorMsg.textContent = err.message || String(err);
});
})();
+131
View File
@@ -0,0 +1,131 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IC 鍗$嚎涓婅喘鍗?/title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
</head>
<body class="public-search jr-public-page">
<div class="jr-public-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>杩斿洖棣栭〉</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">鏈嶅姟鍣ㄧ姸鎬?/span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>妫€娴嬩腑</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE Railway</strong>
<span>IC Card Online Order</span>
</div>
</a>
<nav class="jr-nav" aria-label="绔欑偣瀵艰埅">
<a href="https://ticket.fse-media.group/home.html" data-link="home">棣栭〉</a>
<a href="https://ticket.fse-media.group/order" data-link="order">绾夸笂棰勫畾</a>
<a href="https://ticket.fse-media.group/search" data-link="search">杞︾エ鏌ヨ</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 鍗℃煡璇?/a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order"
class="is-active">绾夸笂璐崱</a>
</nav>
</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">
<h2>棣栨鍏呭€?/h2>
<span class="jr-panel-note">First Top-up</span>
</div>
<div id="rechargeOptionList" class="jr-card-plan-grid">
<div class="jr-center-empty">
<p>姝e湪鍔犺浇鍏呭€奸厤缃?..</p>
</div>
</div>
<div id="customRechargeBox" class="jr-card-plan-custom-box">
<input id="customInitialBalance" class="jr-search-input" type="number" min="1" step="1"
placeholder="鑷畾涔夐娆″厖鍊奸噾棰濓紙閫夋嫨鈥滆嚜瀹氫箟鈥濆悗鍚敤锛? disabled>
</div>
<div class="jr-panel-headline" style="margin-top:24px;">
<h3>鎸佸崱浜轰俊鎭?/h3>
<span class="jr-panel-note">Order Form</span>
</div>
<div class="ic-form-grid">
<input id="holderName" class="jr-search-input" type="text" maxlength="24"
placeholder="鎸佸崱浜哄鍚嶏紙浠呰嫳鏂囦笌绗﹀彿锛?>
</div>
<p id="holderNameHint" class="text-muted" style="margin-top:12px;">浠呮敮鎸佽嫳鏂囦笌甯哥敤绗﹀彿锛屼緥濡?`Alex
Smith`銆乣A.Brown`銆乣Chris-O'Neil`銆?/p>
<div class="jr-action-row">
<button id="submitOrderBtn" class="btn primary jr-search-button"><i
class="fas fa-credit-card"></i> 鎻愪氦璐崱</button>
</div>
</article>
<div>
<article class="jr-panel-card" style="margin-bottom:20px;">
<div class="jr-panel-headline">
<h2>璐圭敤棰勪及</h2>
<span class="jr-panel-note">Estimate</span>
</div>
<div id="estimateBox" class="ic-inline-meta">
<div class="jr-center-empty">
<p>璇烽€夋嫨棣栨鍏呭€奸噾棰濆悗鏌ョ湅璐圭敤鏋勬垚銆?/p>
</div>
</div>
</article>
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h2>璐崱缁撴灉</h2>
<span class="jr-panel-note">Card Result</span>
</div>
<div id="orderResultBox" class="jr-center-empty">
<p>鎻愪氦鍚庡皢鍦ㄦ鏄剧ず鍗″彿銆佸嚟璇佺爜鍜岄鍗℃彁绀恒€?/p>
</div>
</article>
</div>
</section>
<footer class="site-footer jr-footer-space">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ic-card-order.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html', order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html', search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html', 'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html', 'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
}; const homeLink = document.getElementById('homeLink'); const brandLink = document.getElementById('brandLink'); if (homeLink) homeLink.href = links.home; if (brandLink) brandLink.href = links.home; document.querySelectorAll('[data-link]').forEach((el) => { const key = el.getAttribute('data-link'); if (links[key]) el.href = links[key]; });
});</script>
</body>
</html>
+255
View File
@@ -0,0 +1,255 @@
(() => {
const $ = (sel) => document.querySelector(sel);
const rechargeOptionListEl = $('#rechargeOptionList') || $('#planList');
const estimateBoxEl = $('#estimateBox');
const resultBoxEl = $('#orderResultBox');
const holderNameEl = $('#holderName');
const customInitialBalanceEl = $('#customInitialBalance') || $('#initialBalance');
const customRechargeBoxEl = $('#customRechargeBox');
const submitBtn = $('#submitOrderBtn');
const HOLDER_NAME_PATTERN = /^[A-Za-z][A-Za-z .,'()&@/_\-+]*$/;
const state = {
rechargeOptions: [5, 10, 15, 20],
selectedAmount: 5,
customMode: false
};
if (!rechargeOptionListEl || !estimateBoxEl || !resultBoxEl || !holderNameEl || !customInitialBalanceEl || !submitBtn) {
console.error('[ic-card-order] Missing required DOM nodes', {
rechargeOptionListEl: !!rechargeOptionListEl,
estimateBoxEl: !!estimateBoxEl,
resultBoxEl: !!resultBoxEl,
holderNameEl: !!holderNameEl,
customInitialBalanceEl: !!customInitialBalanceEl,
customRechargeBoxEl: !!customRechargeBoxEl,
submitBtn: !!submitBtn
});
return;
}
const api = {
async request(url, opts = {}) {
const res = await fetch(url, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data.error || res.statusText || '请求失败');
}
return data;
},
fetchConfig() {
return api.request('/api/public/ic-cards/config');
},
createOrder(payload) {
return api.request('/api/public/ic-cards/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
};
const escapeHtml = (value) => String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const getInitialBalance = () => {
if (state.customMode) {
return Math.max(1, Number(customInitialBalanceEl.value || 0) || 0);
}
return Math.max(1, Number(state.selectedAmount || 0) || 0);
};
const renderRechargeOptions = () => {
const options = Array.isArray(state.rechargeOptions) && state.rechargeOptions.length
? 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)}">
<span class="jr-card-plan-title">${escapeHtml(amount)}</span>
<span class="jr-card-plan-desc">首次充值 ${escapeHtml(amount)}</span>
</button>
`).join('') + `
<button type="button" 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>
`;
if (customRechargeBoxEl) {
customRechargeBoxEl.classList.toggle('is-active', !!state.customMode);
}
rechargeOptionListEl.querySelectorAll('[data-amount]').forEach((button) => {
button.addEventListener('click', () => {
state.customMode = false;
state.selectedAmount = Number(button.getAttribute('data-amount')) || 5;
customInitialBalanceEl.disabled = true;
renderRechargeOptions();
renderEstimate();
});
});
const customBtn = rechargeOptionListEl.querySelector('[data-custom="true"]');
if (customBtn) {
customBtn.addEventListener('click', () => {
state.customMode = true;
customInitialBalanceEl.disabled = false;
customInitialBalanceEl.focus();
if (!customInitialBalanceEl.value) customInitialBalanceEl.value = '25';
renderRechargeOptions();
renderEstimate();
});
}
};
const syncLegacyDom = () => {
const holderPhoneEl = $('#holderPhone');
const orderNoteEl = $('#orderNote');
if (holderPhoneEl) {
holderPhoneEl.disabled = true;
holderPhoneEl.value = '';
holderPhoneEl.placeholder = '已停用';
holderPhoneEl.style.display = 'none';
}
if (orderNoteEl) {
orderNoteEl.disabled = true;
orderNoteEl.value = '';
orderNoteEl.placeholder = '已停用';
orderNoteEl.style.display = 'none';
}
const createTypeSelect = $('#createType');
if (createTypeSelect) {
createTypeSelect.disabled = true;
createTypeSelect.style.display = 'none';
}
};
const validateHolderName = (value) => {
const holderName = String(value || '').trim();
if (!holderName) return '请输入持卡人姓名';
if (holderName.length > 24) return '持卡人姓名不能超过 24 个字符';
if (!HOLDER_NAME_PATTERN.test(holderName)) return '持卡人姓名仅支持英文与常用符号';
return '';
};
const validateInitialBalance = () => {
const initialBalance = getInitialBalance();
if (!Number.isFinite(initialBalance) || initialBalance < 1) {
return '首次充值金额必须大于 0';
}
return '';
};
const renderEstimate = () => {
const initialBalance = getInitialBalance();
if (!Number.isFinite(initialBalance) || initialBalance < 1) {
estimateBoxEl.innerHTML = '<div class="jr-center-empty"><p>请选择有效的首次充值金额。</p></div>';
return;
}
estimateBoxEl.innerHTML = `
<div class="list-item"><span class="k">卡片类型</span><span class="v">IC 储值卡</span></div>
<div class="list-item"><span class="k">首次充值</span><span class="v">${escapeHtml(initialBalance)}</span></div>
<div class="list-item"><span class="k">持卡人限制</span><span class="v">仅英文与常用符号</span></div>
<div class="list-item jr-total-row"><span class="k">预计金额</span><span class="v jr-total-amount">${escapeHtml(initialBalance)}</span></div>
`;
};
const renderResult = (data) => {
if (!data) {
resultBoxEl.className = 'jr-center-empty';
resultBoxEl.innerHTML = '<p>提交后将在此显示卡号、凭证码和领卡提示。</p>';
return;
}
const isDomain = location.hostname.includes('fse-media.group');
const voucherCode = data.code || data.voucher_code || data.order_code || '---';
const cardStatus = String(data.card?.status || data.status || '').trim().toLowerCase();
const lookupKey = (cardStatus === 'pending_pickup')
? voucherCode
: (data.card?.card_id || data.card_id || voucherCode);
const searchHref = isDomain
? `https://ticket.fse-media.group/ic-card/search?q=${encodeURIComponent(lookupKey)}`
: `/ic-card-search.html?q=${encodeURIComponent(lookupKey)}`;
const detailHref = isDomain
? `https://ticket.fse-media.group/ic/${encodeURIComponent(lookupKey)}`
: `/ic/${encodeURIComponent(lookupKey)}`;
const shownCardId = data.display_card_id || data.card?.display_card_id || data.card_id || '---';
const amount = Number(data.amount ?? data.card?.purchase_amount ?? data.card?.balance ?? getInitialBalance()) || getInitialBalance();
resultBoxEl.className = '';
resultBoxEl.innerHTML = `
<div class="jr-redeem-summary jr-card-order-result">
<div class="jr-kicker">ORDER CREATED</div>
<div class="jr-redeem-code-row">
<span class="jr-redeem-code-label">凭证码</span>
<strong class="jr-redeem-code-value">${escapeHtml(voucherCode)}</strong>
</div>
<div class="jr-redeem-code-row" style="margin-top:12px;">
<span class="jr-redeem-code-label">卡号</span>
<strong class="jr-redeem-code-value jr-code-accent-secondary">${escapeHtml(shownCardId)}</strong>
</div>
<div class="jr-order-info-grid">
<div class="jr-order-info-item">
<span>首次充值</span>
<strong>${escapeHtml(amount)}</strong>
</div>
<div class="jr-order-info-item">
<span>当前状态</span>
<strong class="jr-code-accent-status">待领卡</strong>
</div>
</div>
<p class="jr-redeem-copy">购卡信息已生成。请保存卡号与凭证码,前往站内办理领卡或后续状态查询。</p>
<div class="jr-action-row">
<a class="btn jr-secondary-btn" href="${detailHref}"><i class="fas fa-id-card"></i> 卡片详情</a>
<a class="btn jr-secondary-btn" href="${searchHref}"><i class="fas fa-search"></i> 查询此卡</a>
</div>
</div>
`;
};
const submitOrder = async () => {
const holderNameError = validateHolderName(holderNameEl.value);
if (holderNameError) {
alert(holderNameError);
return;
}
const balanceError = validateInitialBalance();
if (balanceError) {
alert(balanceError);
return;
}
const payload = {
holder_name: holderNameEl.value.trim(),
initial_balance: getInitialBalance()
};
submitBtn.disabled = true;
try {
const data = await api.createOrder(payload);
renderResult(data);
alert(`购卡成功,凭证码:${data.code}`);
} finally {
submitBtn.disabled = false;
}
};
customInitialBalanceEl.addEventListener('input', renderEstimate);
submitBtn.addEventListener('click', () => submitOrder().catch((error) => alert(error.message || String(error))));
(async () => {
syncLegacyDom();
const data = await api.fetchConfig();
state.rechargeOptions = (data.recharge_options || data.initial_balance_options || [5, 10, 15, 20])
.map((value) => Number(value) || 0)
.filter((value) => value > 0);
state.selectedAmount = state.rechargeOptions[0] || 5;
customInitialBalanceEl.disabled = true;
if (customRechargeBoxEl) customRechargeBoxEl.classList.remove('is-active');
renderRechargeOptions();
renderEstimate();
renderResult(null);
})().catch((error) => {
rechargeOptionListEl.innerHTML = `<div class="jr-center-empty"><p>${escapeHtml(error.message || String(error))}</p></div>`;
estimateBoxEl.innerHTML = '<div class="jr-center-empty"><p>配置加载失败。</p></div>';
});
})();
+103
View File
@@ -0,0 +1,103 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IC 鍗℃煡璇?/title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
</head>
<body class="public-search jr-public-page">
<div class="jr-public-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>杩斿洖棣栭〉</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">鏈嶅姟鍣ㄧ姸鎬?/span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>妫€娴嬩腑</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE閾佽矾绁ㄥ姟绯荤粺</strong>
<span>IC 鍗℃煡璇㈡湇鍔?/span>
</div>
</a>
<nav class="jr-nav" aria-label="绔欑偣瀵艰埅">
<a href="https://ticket.fse-media.group/home.html" data-link="home">棣栭〉</a>
<a href="https://ticket.fse-media.group/order" data-link="order">绾夸笂棰勫畾</a>
<a href="https://ticket.fse-media.group/search" data-link="search">杞︾エ鏌ヨ</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search" class="is-active">IC
鍗℃煡璇?/a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">绾夸笂璐崱</a>
</nav>
</div>
</div>
<main class="jr-public-main">
<section class="jr-page-intro">
<span class="jr-kicker">IC CARD SEARCH</span>
<h1>鎸夊崱鍙锋垨鍑瘉鐮佹煡璇?IC 鍗$姸鎬?/h1>
<p>鍙煡璇?IC 鍗$殑褰撳墠鐘舵€併€佷綑棰濆拰鏈€杩戞搷浣滆褰曘€傝緭鍏ョ嚎涓婅喘鍗$敓鎴愮殑鍑瘉鐮佷篃鍙弽鏌ュ搴斿崱鐗囥€?/p>
</section>
<section class="jr-panel-card" style="margin-bottom:24px;">
<div class="jr-panel-headline">
<h2>妫€绱㈡潯浠?/h2>
<span class="jr-panel-note">Card ID / Voucher Code</span>
</div>
<div class="jr-search-form">
<input id="queryInput" class="jr-search-input" type="text"
placeholder="杈撳叆鍗″彿鎴栧嚟璇佺爜锛屽 IC-348215 / M1SKP" />
<button id="queryBtn" class="btn primary jr-search-button"><i class="fas fa-search"></i> 鏌ヨ IC
鍗?/button>
</div>
</section>
<section class="jr-grid-two">
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h3>鍗$墖姒傝</h3>
<span class="jr-panel-note">Card Overview</span>
</div>
<div id="summaryBox" class="jr-center-empty">
<p>璇疯緭鍏ュ崱鍙锋垨鍑瘉鐮佸紑濮嬫煡璇€?/p>
</div>
</article>
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h3>浜嬩欢璁板綍</h3>
<span class="jr-panel-note">Recent Events</span>
</div>
<div id="eventBox" class="jr-history-list">
<div class="jr-center-empty" style="min-height:180px;">
<p>鏌ヨ鎴愬姛鍚庢樉绀哄缓鍗°€佽喘鍗°€佸厖鍊肩瓑鎿嶄綔璁板綍銆?/p>
</div>
</div>
</article>
</section>
<footer class="site-footer jr-footer-space">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ic-card-search.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html', order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html', search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html', 'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html', 'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
}; const homeLink = document.getElementById('homeLink'); const brandLink = document.getElementById('brandLink'); if (homeLink) homeLink.href = links.home; if (brandLink) brandLink.href = links.home; document.querySelectorAll('[data-link]').forEach((el) => { const key = el.getAttribute('data-link'); if (links[key]) el.href = links[key]; });
});</script>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
(() => {
const $ = (sel) => document.querySelector(sel);
const inputEl = $('#queryInput');
const queryBtn = $('#queryBtn');
const summaryBoxEl = $('#summaryBox');
const eventBoxEl = $('#eventBox');
const api = {
async request(url) {
const res = await fetch(url);
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data.error || res.statusText || '请求失败');
}
return data;
},
query(q) {
return api.request(`/api/public/ic-cards/query?q=${encodeURIComponent(q)}`);
}
};
const escapeHtml = (value) => String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const formatTime = (value) => {
if (value == null || value === '') return '---';
const ts = Number(value);
const date = Number.isFinite(ts) ? new Date(ts) : new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false });
};
const eventTitle = (event) => {
const map = {
create: '后台建卡',
update: '信息更新',
topup: '余额充值',
order_created: '线上购卡',
activated: '正式启用'
};
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
};
const renderSummary = (card) => {
const shownCardId = card.display_card_id || card.card_id || '---';
summaryBoxEl.className = '';
summaryBoxEl.innerHTML = `
<div class="jr-ticket-preview">
<div class="jr-ticket-row-head">
<span class="jr-ticket-id mono">${escapeHtml(shownCardId)}</span>
<span class="jr-status-pill ${card.status === 'active' ? 'jr-status-valid' : (card.status === 'pending_pickup' ? 'jr-status-used' : 'jr-status-expired')}">${escapeHtml(card.status_label || card.status || '未知')}</span>
</div>
<div class="jr-meta-grid">
<div class="jr-meta-item"><span>持卡人</span><strong>${escapeHtml(card.holder_name || '未登记')}</strong></div>
<div class="jr-meta-item"><span>卡片类型</span><strong>IC 储值卡</strong></div>
<div class="jr-meta-item"><span>余额</span><strong>${escapeHtml(card.balance ?? 0)}</strong></div>
<div class="jr-meta-item"><span>首次充值</span><strong>${escapeHtml(card.purchase_amount ?? card.balance ?? 0)}</strong></div>
<div class="jr-meta-item"><span>凭证码</span><strong>${escapeHtml(card.voucher_code || card.code || card.order_code || '---')}</strong></div>
<div class="jr-meta-item"><span>购卡时间</span><strong>${escapeHtml(formatTime(card.created_ts))}</strong></div>
</div>
</div>
`;
};
const renderEvents = (events) => {
if (!events.length) {
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>暂无相关事件记录。</p></div>';
return;
}
eventBoxEl.innerHTML = events.map((event) => `
<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>
`).join('');
};
const renderError = (message) => {
summaryBoxEl.className = 'jr-center-empty';
summaryBoxEl.innerHTML = `<p>${escapeHtml(message)}</p>`;
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>暂无可显示的事件记录。</p></div>';
};
const doQuery = async () => {
const q = inputEl.value.trim();
if (!q) {
renderError('请输入卡号或凭证码');
return;
}
summaryBoxEl.className = 'jr-center-empty';
summaryBoxEl.innerHTML = '<p>正在查询 IC 卡...</p>';
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>正在加载事件记录...</p></div>';
const data = await api.query(q);
renderSummary(data.card || {});
renderEvents(data.events || []);
const newUrl = `${window.location.origin}${window.location.pathname}?q=${encodeURIComponent(q)}`;
window.history.replaceState({ path: newUrl }, '', newUrl);
};
queryBtn.addEventListener('click', () => doQuery().catch((error) => renderError(error.message || String(error))));
inputEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') doQuery().catch((error) => renderError(error.message || String(error)));
});
const params = new URLSearchParams(location.search);
const q = params.get('q');
if (q) {
inputEl.value = q;
doQuery().catch((error) => renderError(error.message || String(error)));
}
})();
+875
View File
@@ -0,0 +1,875 @@
<!DOCTYPE html>
<html lang="zh-CN">
<!-- 充满未知和不稳定的票务系统! -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FSE铁路票务系统控制台</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="style.css?v=12">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
<!--侧边栏-->
<body class="jr-admin-page jr-public-page">
<div class="jr-public-shell">
<header class="jr-topbar">
<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>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<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>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
<a href="https://ticket.fse-media.group/order" data-link="order">线上预定</a>
<a href="https://ticket.fse-media.group/search" data-link="search">车票查询</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
</nav>
</div>
</div>
<main class="jr-public-main jr-admin-main-shell">
<div id="app" class="jr-admin-app">
<div class="sidebar" :class="{ open: sidebarOpen }">
<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>
</div>
<div class="nav">
<a href="https://ticket.fse-media.group" id="homeLink" class="nav-item" style="text-decoration: none;">
<span class="nav-icon"><i class="fas fa-home"></i></span> 返回首页
</a>
<div class="nav-item" :class="{active: currentView === 'dashboard'}" @click="currentView = 'dashboard'">
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> 仪表盘 </div>
<div class="nav-item" :class="{active: currentView === 'management'}"
@click="currentView = 'management'">
<span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路与票价 </div>
<div class="nav-item" :class="{active: currentView === 'faremap'}" @click="currentView = 'faremap'">
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
</div>
<div class="nav-item" :class="{active: currentView === 'tickets'}" @click="currentView = 'tickets'">
<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> 凭证管理
</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 卡管理
</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> 优惠设置
</div>
<div class="nav-item" :class="{active: currentView === 'logs'}" @click="currentView = 'logs'">
<span class="nav-icon"><i class="fas fa-list"></i></span> 日志
</div>
</div>
<!--连接状态显示-->
<div class="jr-admin-sidebar-status">
<div class="jr-admin-sidebar-status-label">Server: {{ connected ? 'Online' : 'Offline' }}</div>
<div class="flex" style="align-items: center; gap: 6px; margin-bottom: 15px;">
<i class="fas fa-circle"
:style="{ color: connected ? '#10b981' : '#ef4444', fontSize: '0.6rem' }"></i>
<span>Status: {{ connected ? 'Connected' : 'Disconnected' }}</span>
</div>
</div>
</div>
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false"></div>
<div class="main">
<div class="header">
<div class="jr-admin-header-copy">
<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>
<h3 style="margin: 0;">{{ viewTitle }}</h3>
</div>
</div>
</div>
<div class="jr-admin-header-side">
<span class="jr-admin-header-pill" :class="connected ? 'is-online' : 'is-offline'">
<i class="fas fa-circle"></i>
{{ connected ? '服务器在线' : '服务器离线' }}
</span>
</div>
</div>
<div class="content">
<section class="jr-page-intro jr-admin-intro">
<span class="jr-kicker">CENTRAL MANAGEMENT</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>
<!-- 仪表盘-->
<div v-if="currentView === 'dashboard'">
<div class="grid">
<div class="card">
<div class="stat-label">今日售票额</div>
<div class="stat-value">{{ stats.sold_tickets || 0 }}</div>
</div>
<div class="card">
<div class="stat-label">站点数</div>
<div class="stat-value">{{ stations.length }}</div>
</div>
<div class="card">
<div class="stat-label">运营线路</div>
<div class="stat-value">{{ lines.length }}</div>
</div>
</div>
</div>
<!-- 线路与票价管理 -->
<div v-if="currentView === 'management'" class="management-container">
<div class="management-sidebar">
<div class="card"
style="height: 100%; display: flex; flex-direction: column; margin-bottom: 0;">
<div class="flex between mb-4">
<h4>线路列表</h4>
<button @click="showAddLine = true" title="新建线路"><i class="fas fa-plus"></i></button>
</div>
<!--添加车站-->
<div v-if="showAddLine" class="mb-4"
style="background: rgba(255,255,255,0.05); padding: 10px; border-radius: 8px;">
<input v-model="newLine.id" placeholder="线路编号 (如 L1)"
style="margin-bottom: 8px; width: 100%;">
<input v-model="newLine.name" placeholder="中文名称"
style="margin-bottom: 8px; width: 100%;">
<input v-model="newLine.en_name" placeholder="英文名称"
style="margin-bottom: 8px; width: 100%;">
<div class="flex">
<input type="text" v-model="newLine.color" placeholder="#HEX颜色" style="flex: 1;">
<input type="color" v-model="newLine.color" title="选择颜色"
style="width: 40px; padding: 0; border: none; height: 32px;">
<button @click="createLine" style="padding: 0 12px;" title="确认创建线路"><i
class="fas fa-check"></i></button>
<button class="danger" @click="showAddLine = false" title="取消"
style="padding: 0 12px;"><i class="fas fa-times"></i></button>
</div>
</div>
<div class="list-lines" style="flex: 1; overflow-y: auto;">
<div v-for="l in lines" :key="l.id" class="line-item"
:class="{active: selectedLine && selectedLine.id === l.id}" @click="selectLine(l)">
<div class="line-color-dot" :style="{background: l.color}"></div>
<div class="line-info">
<div class="line-name">{{ l.name || l.id }}</div>
<div class="line-meta">{{ (l.stations || []).length }} 站</div>
</div>
<div class="line-actions" v-if="selectedLine && selectedLine.id === l.id">
<button class="danger sm" @click.stop="deleteLine(l.id)"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
</div>
</div>
<!--右侧面板-->
<div class="management-main">
<div class="card mb-4">
<div class="flex between">
<div v-if="selectedLine">
<div class="flex">
<h3 :style="{color: selectedLine.color}">{{ selectedLine.name || selectedLine.id
}}</h3>
<span class="badge">{{ selectedLine.id }}</span>
</div>
<div class="flex mt-2" style="align-items:center; gap:8px;">
<label style="font-size:0.8em; color:var(--muted);">EN:</label>
<span style="font-size:0.9em;">{{ selectedLine.en_name || 'N/A' }}</span>
</div>
</div>
<div v-else>
<h4>选择左侧线路进行管理</h4>
</div>
<div class="flex">
<button v-if="selectedLine" @click="openLineModal" title="编辑线路"><i class="fas fa-pen"></i></button>
<button @click="refreshData" title="刷新"><i class="fas fa-sync-alt"></i></button>
</div>
</div>
</div>
<!-- 可视化线路编辑-->
<div class="card visual-editor" v-if="selectedLine">
<div class="editor-toolbar flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
<div class="flex">
<label class="switch-label">
<input type="checkbox" v-model="fareMode">
<span class="slider"></span>
<span class="label-text"><i class="fas fa-coins"></i> 票价设置/车站编辑模式</span>
</label>
<label class="switch-label" style="margin-left: 10px;">
<input type="checkbox" v-model="stationEditMode">
<span class="slider"></span>
<span class="label-text"><i class="fas fa-exchange-alt"></i> 换乘设置模式</span>
</label>
<div v-if="fareMode" class="hint-text text-warning">
<i class="fas fa-info-circle"></i> 点击两个站点以设置票价 </div>
<div v-else-if="stationEditMode" class="hint-text text-info">
<i class="fas fa-info-circle"></i> 点击站点以设置换乘 </div>
<div v-else class="hint-text text-muted">
<i class="fas fa-info-circle"></i> 点击站点删除
</div>
</div>
<div class="flex"
style="background: rgba(255,255,255,0.05); padding: 8px; border-radius: 6px;">
<div style="font-weight: bold; margin-right: 8px;">添加站点:</div>
<input v-model="newStation.code" placeholder="编号 (01-01)" style="width: 100px;">
<input v-model="newStation.name" placeholder="中文名" style="width: 120px;">
<input v-model="newStation.en_name" placeholder="英文名" style="width: 120px;">
<button @click="addStationToLine"
:disabled="!newStation.code || !newStation.name"><i class="fas fa-plus"></i>
添加</button>
</div>
</div>
<!-- 可视化线路编辑-->
<div class="visual-line-container">
<svg width="100%" height="200"
v-if="selectedLine.stations && selectedLine.stations.length > 0">
<!--站点连接线-->
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100"
:stroke="selectedLine.color" stroke-width="4" stroke-linecap="round" />
<!--票价显示-->
<g v-for="(s, i) in selectedLine.stations.slice(0, selectedLine.stations.length-1)"
:key="'fare-'+i">
<text :x="50 + i * 120 + 60" y="90" text-anchor="middle" fill="#f59e0b"
font-size="10" font-weight="bold">{{ getFareText(i) }}</text>
</g>
<!--车站节点-->
<g v-for="(sCode, index) in selectedLine.stations" :key="sCode"
@mousedown="onStationDragStart(index)" @mouseup="onStationDrop"
@mousemove="onStationDragOver(index)" @click="handleStationClick(sCode)"
class="station-node" :class="{
'selected': isStationSelected(sCode),
'fare-source': fareSelection[0] === sCode,
'fare-target': fareSelection[1] === sCode
}">
<!--车站节点图形-->
<circle :cx="50 + index * 120" cy="100" r="14" fill="var(--bg)"
:stroke="selectedLine.color" stroke-width="3" />
<circle v-if="isStationSelected(sCode)" :cx="50 + index * 120" cy="100" r="8"
:fill="selectedLine.color" />
<!--节点标签-->
<text :x="50 + index * 120" y="70" text-anchor="middle" fill="var(--text)"
font-weight="bold" font-size="12" style="pointer-events: none;">{{
getStationName(sCode) }}</text>
<text :x="50 + index * 120" y="135" text-anchor="middle" fill="var(--muted)"
font-size="10" style="pointer-events: none;">{{ sCode }}</text>
<g v-if="getTransferLineBadges(sCode).length > 0">
<g v-for="(li, liIdx) in getTransferLineBadges(sCode)" :key="`${sCode}-xfer-${li.id}`">
<circle :cx="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" cy="150" r="5"
:fill="li.color" stroke="#ffffff" stroke-width="1" />
<text :x="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" y="165" text-anchor="middle"
fill="var(--muted)" font-size="7" style="pointer-events: none;">{{ li.id }}</text>
</g>
</g>
<!--删除-->
<title>{{ getStationName(sCode) }} ({{ sCode }}){{ getTransferTitleSuffix(sCode) }}</title>
</g>
</svg>
<div v-else class="empty-state">
<i class="fas fa-subway"
style="font-size: 48px; margin-bottom: 16px; opacity: 0.3;"></i>
<p>此线路暂无站点,请从上方添加</p>
</div>
</div>
</div>
<!-- 票价设置弹窗 -->
<div v-if="showFareModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">设置票价</h4>
<div class="mb-4 text-center">
<div class="flex between"
style="justify-content: center; gap: 20px; font-size: 1.1em; font-weight: bold;">
<span>{{ getStationName(fareSelection[0]) }}</span>
<i class="fas fa-arrow-right text-muted"></i>
<span>{{ getStationName(fareSelection[1]) }}</span>
</div>
</div>
<div class="mb-4">
<label>常规票价</label>
<input v-model.number="currentFare.cost_regular" type="number" class="w-100">
</div>
<div class="mb-4">
<label>特急票价</label>
<input v-model.number="currentFare.cost_express" type="number" class="w-100">
</div>
<div class="modal-actions">
<button class="danger" @click="deleteCurrentFare"
v-if="currentFare.exists">删除</button>
<button @click="saveCurrentFare">保存</button>
<button class="danger" @click="closeFareModal">取消</button>
</div>
</div>
</div>
<div v-if="showStationModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">站点编辑</h4>
<div class="mb-4">
<label>站点编号</label>
<input v-model="stationForm.code" class="w-100">
</div>
<div class="mb-4">
<label>中文名</label>
<input v-model="stationForm.name" class="w-100">
</div>
<div class="mb-4">
<label>英文名</label>
<input v-model="stationForm.en_name" class="w-100">
</div>
<div class="mb-4">
<label class="switch-label">
<input type="checkbox" v-model="stationForm.transfer_enabled">
<span class="slider"></span>
<span class="label-text">可换乘</span>
</label>
</div>
<div class="mb-4">
<label>可换乘到的站点</label>
<select v-model="stationForm.transfer_to" multiple class="w-100" :disabled="!stationForm.transfer_enabled" style="height: 180px;">
<option v-for="t in transferTargets" :key="t.code" :value="t.code">
{{ t.name }} ({{ t.en_name }}) - {{ t.code }}
</option>
</select>
</div>
<div class="modal-actions">
<button class="danger" @click="deleteStation(stationFormOriginalCode)">删除</button>
<button @click="saveStationSettings">保存</button>
<button class="danger" @click="closeStationModal">取消</button>
</div>
</div>
</div>
<div v-if="showLineModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">线路编辑</h4>
<div class="mb-4">
<label>线路编号</label>
<input v-model="lineForm.id" class="w-100">
</div>
<div class="mb-4">
<label>中文名</label>
<input v-model="lineForm.name" class="w-100">
</div>
<div class="mb-4">
<label>英文名</label>
<input v-model="lineForm.en_name" class="w-100">
</div>
<div class="mb-4">
<label>颜色</label>
<div class="flex" style="gap:8px;">
<input type="text" v-model="lineForm.color" class="w-100" placeholder="#3366cc">
<input type="color" v-model="lineForm.color" title="选择颜色"
style="width: 48px; padding: 0; border: none; height: 32px;">
</div>
</div>
<div class="modal-actions">
<button @click="saveLineSettings">保存</button>
<button class="danger" @click="closeLineModal">取消</button>
</div>
</div>
</div>
</div>
</div>
<!-- 票价地图 -->
<div v-if="currentView === 'faremap'">
<div class="card faremap-card">
<div class="flex between mb-4">
<h4>票价地图</h4>
<div class="flex" style="flex-wrap: wrap; gap: 8px;">
<button @click="loadFareMap" title="刷新"><i class="fas fa-sync-alt"></i></button>
<button @click="zoomFareMapOut" title="缩小"><i class="fas fa-minus"></i></button>
<button @click="zoomFareMapIn" title="放大"><i class="fas fa-plus"></i></button>
<button @click="zoomFareMapReset" title="重置"><i class="fas fa-crosshairs"></i></button>
<button @click="exportFareMap" title="导出图像"><i class="fas fa-download"></i></button>
</div>
</div>
<div v-if="fareMapLoading" class="loading">加载中...</div>
<div v-else-if="fareMapError" class="loading">{{ fareMapError }}</div>
<div v-else class="faremap-viewport">
<div class="faremap-canvas" :style="{ transform: `scale(${fareMapScale})` }" v-html="fareMapSvg"></div>
</div>
</div>
</div>
<!-- 凭证管理 -->
<div v-if="currentView === 'vouchers'">
<div class="card">
<div class="flex between mb-4">
<h4>凭证列表</h4>
<div class="flex">
<button @click="fetchOrders"><i class="fas fa-sync-alt"></i></button>
</div>
</div>
<table class="ticket-table">
<thead>
<tr>
<th>凭证</th>
<th>线路</th>
<th>车型</th>
<th>票价</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orderList" :key="o.code">
<td class="mono" style="font-weight:bold; font-size:1.1em;">{{ o.code }}</td>
<td>{{ o.start_name }} <i class="fas fa-arrow-right text-muted"></i> {{
o.terminal_name }}</td>
<td>{{ formatTrainType(o.train_type) }}</td>
<td>{{ o.price }}</td>
<td><span class="badge" :class="formatTicketStatus(o.status).class">{{ formatTicketStatus(o.status).text }}</span></td>
<td>{{ formatTime(o.created_ts) }}</td>
<td>
<div class="flex" style="gap:4px;">
<a :href="'token.html?code='+o.code" target="_blank" class="btn sm"
title="查看"
style="height:28px; width:28px; padding:0; display:flex; align-items:center; justify-content:center;"><i
class="fas fa-eye"></i></a>
<button class="danger sm" @click="deleteOrder(o.code)"
style="height:28px; width:28px; padding:0; display:flex; align-items:center; justify-content:center;"><i
class="fas fa-trash"></i></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="currentView === 'iccards'">
<div class="grid">
<div class="card">
<div class="stat-label">IC 卡总数</div>
<div class="stat-value">{{ icCardStats.total }}</div>
</div>
<div class="card">
<div class="stat-label">待领卡</div>
<div class="stat-value">{{ icCardStats.pending }}</div>
</div>
<div class="card">
<div class="stat-label">正常启用</div>
<div class="stat-value">{{ icCardStats.active }}</div>
</div>
<div class="card">
<div class="stat-label">储值总额</div>
<div class="stat-value">{{ formatMoney(icCardStats.balance) }}</div>
</div>
</div>
<div class="management-container ic-admin-layout">
<div class="management-sidebar">
<div class="card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
<div class="flex between mb-4">
<h4>卡片列表</h4>
<span class="badge">{{ icCards.length }}</span>
</div>
<div class="flex mb-4" style="flex-wrap:wrap;">
<input v-model="icCardSearch" placeholder="搜索卡号 / 订单号 / 凭证码 / 姓名" style="flex:1;">
</div>
<div class="list-lines jr-scroll-box" style="flex:1; min-height:320px;">
<div v-if="!icCards.length" class="empty-state" style="padding:24px 0;">
<p>暂无 IC 卡记录。</p>
</div>
<div
v-for="card in icCards"
:key="card.card_id"
class="line-item ic-card-item"
:class="{ active: icSelectedId === card.card_id }"
@click="loadIcCard(card.card_id)"
>
<div class="line-color-dot" :style="{ background: icStatusColor(card.status) }"></div>
<div class="line-info">
<div class="line-name">{{ displayIcCardId(card) }}</div>
<div class="line-meta">{{ card.holder_name || '未登记持卡人' }} · IC 储值卡</div>
<div class="line-meta">订单 {{ cardOrderCode(card) }} · 余额 {{ formatMoney(card.balance) }}</div>
</div>
<div class="line-actions" style="opacity:1;">
<span class="badge" :class="icStatusInfo(card.status).className">{{ icStatusInfo(card.status).text }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="management-main">
<div class="card">
<div class="flex between mb-4">
<h4>卡片详情</h4>
<div class="flex" style="gap:8px;">
<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>
</div>
<div v-if="!icSelectedCard" class="empty-state">
<i class="fas fa-credit-card" style="font-size:48px; opacity:0.3;"></i>
<p>从左侧选择一张 IC 卡以查看详情。</p>
</div>
<div v-else>
<div class="flex between mb-4" style="align-items:flex-start;">
<div>
<div class="mono" style="font-size:1.4rem; font-weight:700;">{{ displayIcCardId(icSelectedCard) }}</div>
<div class="text-muted" style="margin-top:6px;">订单号 {{ cardOrderCode(icSelectedCard) }} · 来源 {{ icSelectedCard.source || '---' }}</div>
</div>
<span class="badge" :class="icStatusInfo(icSelectedCard.status).className">{{ icStatusInfo(icSelectedCard.status).text }}</span>
</div>
<div class="ic-detail-grid">
<label class="ic-field">
<span>持卡人</span>
<input v-model="icDetailForm.holder_name">
</label>
<label class="ic-field">
<span>卡片类型</span>
<input value="IC 储值卡" disabled>
</label>
<label class="ic-field">
<span>状态</span>
<select v-model="icDetailForm.status">
<option value="pending_pickup">待领卡</option>
<option value="active">正常</option>
<option value="disabled">停用</option>
<option value="lost">挂失</option>
<option value="refunded">已退卡</option>
</select>
</label>
<label class="ic-field">
<span>余额</span>
<input :value="formatMoney(icSelectedCard?.balance || 0)" disabled>
</label>
</div>
<div class="ic-inline-meta">
<div class="list-item"><span class="k">创建时间</span><span class="v">{{ formatTime(icSelectedCard.created_ts) }}</span></div>
<div class="list-item"><span class="k">最后更新</span><span class="v">{{ formatTime(icSelectedCard.last_update_ts) }}</span></div>
<div class="list-item"><span class="k">首次充值</span><span class="v">{{ formatMoney(icSelectedCard.purchase_amount ?? icSelectedCard.balance) }}</span></div>
<div class="list-item"><span class="k">购卡金额</span><span class="v">{{ formatMoney(icSelectedCard.purchase_amount) }}</span></div>
</div>
</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="flex between mb-4">
<h4>操作记录</h4>
</div>
<div class="timeline">
<div v-if="!icSelectedCard" class="loading">选择卡片后显示事件流。</div>
<div v-else-if="!icSelectedEvents.length" class="loading">暂无事件记录。</div>
<div v-for="(event, idx) in icSelectedEvents" :key="`${event.ts || 0}-${event.type || 'event'}-${idx}`" class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<div class="flex between">
<span style="font-weight:600;">{{ icEventTitle(event) }}</span>
<span class="text-muted" style="font-size:0.8rem;">{{ formatTime(event.ts) }}</span>
</div>
<div class="log-detail" style="margin-top:8px;">{{ formatIcEventDetail(event) }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 车票记录 -->
<div v-if="currentView === 'tickets'">
<div class="card mb-4">
<div class="flex between mb-4">
<h4>车票记录</h4>
<div class="flex">
<input v-model="ticketSearch" placeholder="搜索 Ticket ID / 站点" style="width: 200px;">
<button @click="refreshData"><i class="fas fa-sync-alt"></i></button>
</div>
</div>
<table class="ticket-table">
<thead>
<tr>
<th>ID</th>
<th>起点</th>
<th>终点</th>
<th>类型</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<tr v-for="t in ticketList" :key="t.ticket_id" @click="viewTicketDetails(t)"
class="clickable-row">
<td><span class="mono">{{ t.ticket_id }}</span></td>
<td>
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ getStationInfo(t.start).name }}</span>
<span class="st-code">{{ t.start }}</span>
</div>
<div class="st-en">{{ getStationInfo(t.start).en_name }}</div>
</div>
</td>
<td>
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ getStationInfo(t.terminal).name }}</span>
<span class="st-code">{{ t.terminal }}</span>
</div>
<div class="st-en">{{ getStationInfo(t.terminal).en_name }}</div>
</div>
</td>
<td>{{ formatTrainType(t.type) }}</td>
<td><span class="badge" :class="formatTicketStatus(t.status).class">{{
formatTicketStatus(t.status).text }}</span></td>
<td>{{ formatTime(t.ts) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 车票详情弹窗 -->
<div v-if="showTicketModal" class="modal show" @click.self="closeTicketModal">
<div class="modal-card" style="width: 600px;">
<div class="flex between mb-4">
<h4 class="modal-title">车票详情</h4>
<button class="sm" @click="closeTicketModal" title="关闭"><i
class="fas fa-times"></i></button>
</div>
<div v-if="selectedTicket">
<div class="ticket-header mb-4"
style="background: rgba(255,255,255,0.05); padding: 16px; border-radius: 8px;">
<div class="flex between mb-2">
<span class="mono text-muted">{{ selectedTicket.ticket_id }}</span>
<span class="badge"
:class="formatTicketStatus(selectedTicket.index.status).class">
{{ formatTicketStatus(selectedTicket.index.status).text }}
</span>
</div>
<div class="flex between" style="font-size: 1.2rem; font-weight: bold;">
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ selectedTicket.index.start_name ||
selectedTicket.index.start }}</span>
<span class="st-code">{{ selectedTicket.index.start }}</span>
</div>
<div class="st-en">{{ selectedTicket.index.start_en || '' }}</div>
</div>
<i class="fas fa-arrow-right text-muted"></i>
<div class="st-container" style="align-items: flex-end;">
<div class="st-main-row">
<span class="st-name">{{ selectedTicket.index.terminal_name ||
selectedTicket.index.terminal }}</span>
<span class="st-code">{{ selectedTicket.index.terminal }}</span>
</div>
<div class="st-en">{{ selectedTicket.index.terminal_en || '' }}</div>
</div>
</div>
<div class="text-muted mt-2" style="font-size: 0.9rem;">
类型: {{ formatTrainType(selectedTicket.index.type ||
selectedTicket.index.train_type) }} | 票价: {{ selectedTicket.index.price ||
selectedTicket.index.cost }}
</div>
</div>
<h5>行程记录</h5>
<div class="timeline">
<div v-for="ev in selectedTicket.events" :key="ev.ts || ev['时间戳']" class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<div class="flex between">
<span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span>
<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>
</div>
</div>
</div>
</div>
<div v-else class="loading">加载中...</div>
</div>
</div>
</div>
<div v-if="currentView === 'assets'">
<div class="card mb-4">
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
<div class="flex" style="align-items: center; gap: 10px;">
<h4>线路图</h4>
<span class="badge" v-if="assetsManifest.routeMap" style="font-family: monospace;">{{ assetsManifest.routeMap }}</span>
</div>
<div class="flex" style="gap: 8px;">
<label class="btn" style="cursor: pointer;">
<i class="fas fa-upload"></i> 上传线路图
<input type="file" hidden accept=".png,.jpg,.jpeg,.webp,.svg" @change="uploadRouteMap">
</label>
<a v-if="assetsRouteMapUrl" :href="assetsRouteMapUrl" target="_blank" class="btn">
<i class="fas fa-external-link-alt"></i> 打开
</a>
<button v-if="assetsManifest.routeMap" class="danger" @click="deleteRouteMap">
<i class="fas fa-trash"></i> 删除
</button>
</div>
</div>
<div v-if="assetsRouteMapUrl" style="border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #0b0b0f;">
<img :src="assetsRouteMapUrl" alt="线路图" style="display:block; width: 100%; height: auto;">
</div>
<div v-else class="loading">未上传线路图</div>
</div>
</div>
<!-- 设置 -->
<div v-if="currentView === 'settings'">
<div class="card mb-4">
<div class="mb-4">
<label style="display:block; margin-bottom:8px; font-weight:600;">优惠活动</label>
<div class="flex">
<input v-model="config.promotion.name" placeholder="活动名称">
<input v-model.number="config.promotion.discount" type="number" step="0.1"
placeholder="折扣 (0.1-1.0)">
<button @click="saveConfig"><i class="fas fa-save"></i> 保存</button>
</div>
</div>
</div>
<div class="card">
<h4>数据管理</h4>
<div class="flex">
<button @click="exportData"><i class="fas fa-file-export"></i> 导出数据 JSON</button>
</div>
</div>
</div>
<div v-if="currentView === 'logs'">
<div class="card">
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
<h4>日志</h4>
<div class="flex" style="gap: 8px;">
<button @click="fetchLogs" title="刷新"><i class="fas fa-sync-alt"></i></button>
</div>
</div>
<div class="flex mb-4" style="flex-wrap: wrap; gap: 10px; align-items: center;">
<select v-model="logCategory" style="width: 150px;">
<option value="">全部来源</option>
<option value="admin">admin</option>
<option value="public">public</option>
<option value="device">device</option>
<option value="system">system</option>
</select>
<input v-model="logTypeFilter" placeholder="type (可逗号分隔)" style="width: 260px;">
<input v-model="logQuery" placeholder="关键字" style="width: 220px;">
<input v-model.number="logMax" type="number" min="10" max="5000" step="10" style="width: 120px;">
<button @click="fetchLogs" :disabled="logLoading">
<i class="fas" :class="logLoading ? 'fa-spinner fa-spin' : 'fa-filter'"></i> 筛选
</button>
</div>
<div v-if="logLoading" class="loading">加载中...</div>
<table v-else>
<thead>
<tr>
<th>时间</th>
<th>来源</th>
<th>类型</th>
<th>IP</th>
<th>详情</th>
</tr>
</thead>
<tbody>
<tr v-for="(l, idx) in logs" :key="idx">
<td style="white-space: nowrap;">{{ formatTime(l.ts) }}</td>
<td><span class="badge">{{ l.category || 'legacy' }}</span></td>
<td class="mono">{{ l.type || 'event' }}</td>
<td class="mono">{{ l.ip || '' }}</td>
<td>
<details>
<summary class="text-muted" style="cursor: pointer;">查看</summary>
<pre style="white-space: pre-wrap; margin: 8px 0 0; font-size: 0.85rem; line-height: 1.35;">{{ formatLogDetail(l) }}</pre>
</details>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</div>
</div>
</div>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=13"></script>
<script src="index.js?v=2"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html',
order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html',
search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html',
'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html',
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
};
document.getElementById('homeLink').href = links.home;
document.getElementById('adminTopLink').href = links.home;
document.getElementById('adminBrandLink').href = links.home;
document.querySelectorAll('[data-link]').forEach((el) => {
const key = el.getAttribute('data-link');
if (links[key]) el.href = links[key];
});
});
</script>
</body>
</html>
+1421
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>鎺у埗鍙扮櫥褰?/title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="/style.css?v=12" />
</head>
<body class="jr-admin-login-page">
<div class="jr-admin-login-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="/" class="jr-top-link">
<span>FSE閾佽矾绁ㄥ姟绯荤粺鎺у埗鍙?/span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">鏈嶅姟鍣ㄧ姸鎬?/span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>妫€娴嬩腑</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="/" class="jr-brand">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE 閾佽矾杩愯緭</strong>
<span>鎺у埗鍙扮櫥褰?/span>
</div>
</a>
</div>
</div>
<main class="jr-admin-login-main">
<section class="jr-admin-login-panel">
<div class="jr-admin-login-copy">
<span class="jr-kicker">OPERATIONS ACCESS</span>
<h1>鍚庡彴鎺у埗鍙?/h1>
<p>绾胯矾缁存姢銆佺エ鎹鐞嗐€佹棩蹇楁煡璇笌 IC 鍗$鐞?/p>
<ul class="jr-admin-login-points">
<li>缁熶竴绠$悊绾胯矾銆佺エ浠峰拰璧勬簮鍥炬枃浠?/li>
<li>鏌ョ湅鐢靛瓙绁ㄣ€佸嚟璇佷笌鎿嶄綔鏃ュ織</li>
<li>缁存姢 IC 鍗″彂琛屻€佸厖鍊间笌鐘舵€佽褰?/li>
</ul>
</div>
<section class="jr-admin-login-card">
<div class="jr-page-intro jr-page-intro-compact">
<span class="jr-kicker">SIGN IN</span>
<h2>鎺у埗鍙扮櫥褰?/h2>
<p>璇疯緭鍏ョ鐞嗗憳璐﹀彿鍜屽瘑鐮併€?/p>
</div>
<div class="login-row"><input id="loginUser" type="text" placeholder="鐢ㄦ埛鍚? /></div>
<div class="login-row"><input id="loginPass" type="password" placeholder="瀵嗙爜" /></div>
<div class="login-actions">
<button id="loginBtn" class="btn primary">鐧诲綍</button>
<span id="loginHint" class="hint"></span>
</div>
</section>
</section>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=13"></script>
<script src="login.js?v=2"></script>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
(function(){
const userEl = document.getElementById('loginUser');
const passEl = document.getElementById('loginPass');
const btn = document.getElementById('loginBtn');
const hintEl = document.getElementById('loginHint');
function sha256HexJS(str){
function utf8ToBytes(s){
return new TextEncoder().encode(s);
}
const K = new Uint32Array([
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
]);
const H = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
const bytes = utf8ToBytes(str);
const l = bytes.length;
const withPad = new Uint8Array(((l + 9 + 63) >> 6) << 6);
withPad.set(bytes);
withPad[l] = 0x80;
const bitLen = l * 8;
withPad[withPad.length-4] = (bitLen >>> 24) & 0xff;
withPad[withPad.length-3] = (bitLen >>> 16) & 0xff;
withPad[withPad.length-2] = (bitLen >>> 8) & 0xff;
withPad[withPad.length-1] = (bitLen) & 0xff;
const W = new Uint32Array(64);
function rotr(x,n){ return (x>>>n) | (x<<(32-n)); }
for(let i=0;i<withPad.length;i+=64){
for(let t=0;t<16;t++){
const j = i + t*4;
W[t] = (withPad[j]<<24)|(withPad[j+1]<<16)|(withPad[j+2]<<8)|(withPad[j+3]);
}
for(let t=16;t<64;t++){
const s0 = rotr(W[t-15],7) ^ rotr(W[t-15],18) ^ (W[t-15]>>>3);
const s1 = rotr(W[t-2],17) ^ rotr(W[t-2],19) ^ (W[t-2]>>>10);
W[t] = (W[t-16] + s0 + W[t-7] + s1) >>> 0;
}
let a=H[0],b=H[1],c=H[2],d=H[3],e=H[4],f=H[5],g=H[6],h=H[7];
for(let t=0;t<64;t++){
const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25);
const ch = (e & f) ^ (~e & g);
const temp1 = (h + S1 + ch + K[t] + W[t]) >>> 0;
const S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + maj) >>> 0;
h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;
}
H[0]=(H[0]+a)>>>0; H[1]=(H[1]+b)>>>0; H[2]=(H[2]+c)>>>0; H[3]=(H[3]+d)>>>0;
H[4]=(H[4]+e)>>>0; H[5]=(H[5]+f)>>>0; H[6]=(H[6]+g)>>>0; H[7]=(H[7]+h)>>>0;
}
const out = new Uint8Array(32);
for(let i=0;i<8;i++){
out[i*4] = (H[i]>>>24)&0xff; out[i*4+1]=(H[i]>>>16)&0xff; out[i*4+2]=(H[i]>>>8)&0xff; out[i*4+3]=H[i]&0xff;
}
return Array.from(out).map(b=>b.toString(16).padStart(2,'0')).join('');
}
async function sha256Hex(str){
try{
if(window.crypto && window.crypto.subtle){
const buf = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('');
}
}catch(e){}
return sha256HexJS(str);
}
async function getConfig(){
try{
const r = await fetch('/api/config');
return await r.json();
}catch(e){ return {}; }
}
async function init(){
const sp = new URLSearchParams(location.search);
const nextRaw = sp.get('next') || 'index.html';
const next = (/^https?:\/\//i.test(nextRaw) || nextRaw.includes('://')) ? 'index.html' : nextRaw;
if(localStorage.getItem('tm_session')==='ok'){ location.href = next; return; }
const cfg = await getConfig();
const defaultHash = await sha256Hex('admin:fseticket');
const allowHash = cfg.admin_hash_sha256 || cfg.admin_hash || defaultHash;
btn.addEventListener('click', async ()=>{
const u = (userEl.value||'').trim();
const p = passEl.value||'';
const h = await sha256Hex(u+':'+p);
if(h === allowHash){
localStorage.setItem('tm_session','ok');
try{
fetch('/api/log', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ category:'admin', source:'web', type:'admin_login', detail:{ user:u, ok:true, next } }) }).catch(()=>{});
}catch(_){ }
location.href = next;
} else {
hintEl.textContent = '账号或密码错误';
try{
fetch('/api/log', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ category:'admin', source:'web', type:'admin_login', detail:{ user:u, ok:false, next } }) }).catch(()=>{});
}catch(_){ }
}
});
}
init();
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+111
View File
@@ -0,0 +1,111 @@
/*
* @Author: HenryDu8133 813367384@qq.com
* @Date: 2026-06-19 17:30:23
* @LastEditors: HenryDu8133 813367384@qq.com
* @LastEditTime: 2026-06-19 17:35:05
* @FilePath: \TicketMachine\web\public-status.js
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
(() => {
const STATUS_CLASSES = ['is-checking', 'is-online', 'is-offline'];
const ENDPOINTS = [
'/api/public/config',
'/api/public/popular',
'/api/assets/manifest'
];
const ASSISTANT_SCRIPT_ID = 'tm-ai-assistant-script';
const ASSISTANT_ASSET_VERSION = '2';
const roots = () => Array.from(document.querySelectorAll('[data-server-status-root]'));
const setStatus = (state, text) => {
roots().forEach((root) => {
root.classList.remove(...STATUS_CLASSES);
root.classList.add(`is-${state}`);
const value = root.querySelector('[data-server-status-value]');
if (value) value.textContent = text;
});
};
const probe = async () => {
for (const endpoint of ENDPOINTS) {
const separator = endpoint.includes('?') ? '&' : '?';
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 3500);
try {
const response = await fetch(`${endpoint}${separator}_=${Date.now()}`, {
cache: 'no-store',
signal: controller.signal
});
if (response.ok) {
window.clearTimeout(timeoutId);
return true;
}
} catch (error) {
void error;
}
window.clearTimeout(timeoutId);
}
return false;
};
const refresh = async () => {
if (!roots().length) return;
if (navigator.onLine === false) {
setStatus('offline', '网络离线');
return;
}
setStatus('checking', '检测中');
const online = await probe();
setStatus(online ? 'online' : 'offline', online ? '连接正常' : '连接异常');
};
let timerId = null;
const isAssistantEligiblePage = (body) => {
if (!body) return false;
if (body.classList.contains('jr-admin-page') || body.classList.contains('jr-admin-login-page')) return false;
return body.classList.contains('jr-public-page')
|| body.classList.contains('public-search')
|| body.classList.contains('jr-order-page')
|| body.classList.contains('jr-ticket-board-page');
};
const loadAssistant = () => {
const body = document.body;
if (!isAssistantEligiblePage(body)) return;
if (document.getElementById(ASSISTANT_SCRIPT_ID) || window.__tmAiAssistantLoaded) return;
const script = document.createElement('script');
script.id = ASSISTANT_SCRIPT_ID;
script.src = `/ai-assistant.js?v=${ASSISTANT_ASSET_VERSION}`;
script.defer = true;
document.head.appendChild(script);
};
const init = () => {
loadAssistant();
if (!roots().length || timerId !== null) return;
refresh();
timerId = window.setInterval(refresh, 30000);
window.addEventListener('online', refresh);
window.addEventListener('offline', () => setStatus('offline', '网络离线'));
document.addEventListener('visibilitychange', () => {
if (!document.hidden) refresh();
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();
+4111
View File
File diff suppressed because it is too large Load Diff
+372
View File
@@ -0,0 +1,372 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FSE閾佽矾鐢靛瓙瀹㈢エ</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
<style>
[v-cloak] {
display: none;
}
body.jr-ticket-board-page {
display: block !important;
overflow-y: auto !important;
min-height: 100vh !important;
width: 100% !important;
background: linear-gradient(180deg, #eef4ee 0, #eef4ee 146px, #f7f8f4 146px, #f7f8f4 100%) !important;
}
body.jr-ticket-board-page #app,
body.jr-ticket-board-page .jr-public-shell,
body.jr-ticket-board-page .jr-topbar,
body.jr-ticket-board-page .jr-brandbar,
body.jr-ticket-board-page .jr-public-main {
display: block !important;
width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
float: none !important;
}
body.jr-ticket-board-page .jr-topbar-inner,
body.jr-ticket-board-page .jr-brandbar-inner,
body.jr-ticket-board-page .jr-public-main {
width: min(1280px, calc(100% - 40px)) !important;
margin: 0 auto !important;
}
</style>
</head>
<body class="public-search jr-public-page jr-ticket-board-page">
<div id="app" v-cloak class="jr-public-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="javascript:void(0)" @click="goHome" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>杩斿洖鏌ヨ</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">鏈嶅姟鍣ㄧ姸鎬?/span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>妫€娴嬩腑</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="javascript:void(0)" @click="goHome" class="jr-brand">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE閾佽矾绁ㄥ姟绯荤粺</strong>
<span>鐢靛瓙瀹㈢エ淇℃伅</span>
</div>
</a>
<nav class="jr-nav" aria-label="绔欑偣瀵艰埅">
<a href="https://ticket.fse-media.group/home.html" data-link="home">棣栭〉</a>
<a href="https://ticket.fse-media.group/order" data-link="order">绾夸笂棰勫畾</a>
<a href="https://ticket.fse-media.group/search" data-link="search" class="is-active">杞︾エ鏌ヨ</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 鍗℃煡璇?/a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">绾夸笂璐崱</a>
</nav>
</div>
</div>
<main class="jr-public-main">
<section class="jr-page-intro">
<span class="jr-kicker">ELECTRONIC TICKET</span>
<h1>鏌ョ湅杞︾エ鐘舵€佷笌鏈€杩戞祦杞褰?/h1>
<p>鐢ㄤ簬鏌ョ湅鍗曞紶鐢靛瓙瀹㈢エ鐨勪箻杞︿俊鎭€佺姸鎬佷笌杩涘嚭绔欒褰曪紝渚夸簬鏃呭鍜屽伐浣滀汉鍛樺揩閫熺‘璁ょエ鎹姸鎬併€?/p>
</section>
<div v-if="loading" class="jr-panel-card">
<div class="jr-center-empty">
<p>姝e湪璇诲彇绁ㄦ嵁鏁版嵁...</p>
</div>
</div>
<template v-if="!loading && hasTicket">
<section class="jr-board-layout">
<article class="jr-board-card">
<div class="jr-panel-headline">
<h2 class="mono">{{ ticket.ticket_id }}</h2>
<span class="jr-status-pill" :class="statusInfo.class.replace('status-', 'jr-status-')">{{
statusInfo.text }}</span>
</div>
<div class="jr-route-board">
<div class="jr-station-block">
<div class="jr-station-line">
<span class="jr-station-title">{{ ticket.overview.start_name }}</span>
<span class="jr-station-code">{{ ticket.overview.start_code }}</span>
</div>
<div class="jr-station-en">{{ ticket.overview.start_en }}</div>
</div>
<div class="jr-route-track"><i class="fas fa-train"></i></div>
<div class="jr-station-block is-end">
<div class="jr-station-line">
<span class="jr-station-title">{{ ticket.overview.terminal_name }}</span>
<span class="jr-station-code">{{ ticket.overview.terminal_code }}</span>
</div>
<div class="jr-station-en">{{ ticket.overview.terminal_en }}</div>
</div>
</div>
<div class="jr-meta-grid">
<div class="jr-meta-item">
<span>杞﹀瀷</span>
<strong>{{ formatTrainType(ticket.overview.train_type) }}</strong>
</div>
<div class="jr-meta-item">
<span>绁ㄤ环</span>
<strong>楼 {{ ticket.overview.amount || 0 }}</strong>
</div>
<div class="jr-meta-item">
<span>涔樻</span>
<strong>{{ (ticket.overview.trips_remaining == null ? 1 :
ticket.overview.trips_remaining) }} / {{ (ticket.overview.trips_total == null ? 1 :
ticket.overview.trips_total) }}</strong>
</div>
<div class="jr-meta-item">
<span>鏇存柊鏃堕棿</span>
<strong>{{ formatTime(ticket.overview.last_update_ts) }}</strong>
</div>
</div>
</article>
<aside class="jr-board-card">
<div class="jr-panel-headline">
<h3>娴佽浆璁板綍</h3>
<span class="jr-panel-note">Recent Events</span>
</div>
<div class="jr-history-list" v-if="ticket.events && ticket.events.length > 0">
<div v-for="ev in ticket.events.slice().reverse().slice(0, 5)"
:key="ev.ts || ev.鏃堕棿鎴? class="jr-history-item">
<div class="jr-history-row">
<span class="jr-history-title">{{ formatEvent(ev) }}</span>
<span class="jr-history-time">{{ formatTime(ev.鏃堕棿鎴?|| ev.ts) }}</span>
</div>
<div class="jr-history-desc">
<div>{{ formatEventLocation(ev) }}</div>
<div v-if="formatEventMeta(ev)" style="margin-top:4px;">{{ formatEventMeta(ev) }}
</div>
</div>
</div>
</div>
<div v-else class="jr-center-empty">
<p>鏆傛棤娴佽浆璁板綍銆?/p>
</div>
</aside>
</section>
</template>
<div v-if="!loading && !hasTicket" class="jr-panel-card">
<div class="jr-center-empty">
<h2 style="margin:0 0 10px;">鏃犳晥杞︾エ</h2>
<p>鏈壘鍒拌杞︾エ鐨勮缁嗕俊鎭€?/p>
<div class="jr-action-row">
<button @click="goHome" class="btn primary jr-search-button">杩斿洖鏌ヨ</button>
</div>
</div>
</div>
<footer class="site-footer jr-footer-space">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">绮CP澶?025450093鍙?/a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html',
order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html',
search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html',
'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html',
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
};
document.querySelectorAll('[data-link]').forEach((el) => {
const key = el.getAttribute('data-link');
if (links[key]) el.href = links[key];
});
});
</script>
<script>
const { createApp, ref, onMounted, computed } = Vue;
createApp({
setup() {
const loading = ref(true);
const ticket = ref(null);
const sp = new URLSearchParams(window.location.search);
const idFromQuery = sp.get('id') || '';
const pathParts = window.location.pathname.split('/').filter(Boolean);
const idFromPath = pathParts.length ? pathParts[pathParts.length - 1] : '';
const ticketid = decodeURIComponent(idFromPath || idFromQuery || '');
const hasTicket = computed(() => {
return ticket.value && (ticket.value.ticket_id || ticket.value.id) && ticket.value.overview != null;
});
const statusInfo = computed(() => {
if (!hasTicket.value) return {};
let raw = '';
if (ticket.value && ticket.value.overview) {
if (ticket.value.overview.status != null) raw = ticket.value.overview.status;
}
if (!raw && ticket.value) {
if (ticket.value.status != null) raw = ticket.value.status;
}
const status = String(raw).toLowerCase();
if (
status === '鏈夋晥' ||
status === 'valid' ||
status === 'unused' ||
status === 'active' ||
status.includes('鏈夋晥') ||
status.includes('鏈娇鐢?) ||
status.includes('unused')
) {
return { text: '鏈夋晥', class: 'status-valid' };
}
if (status === '宸蹭娇鐢? || status === 'used' || status.includes('宸蹭娇鐢?) || status.includes('used')) {
return { text: '宸蹭娇鐢?, class: 'status-used' };
}
return { text: '澶辨晥', class: 'status-expired' };
});
const formatTime = (timestamp) => {
if (!timestamp) return '---';
let ts = Number(timestamp);
if (!Number.isFinite(ts)) return String(timestamp);
if (ts > 0 && ts < 1000000000000) ts = ts * 1000;
const date = new Date(ts);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit'});
};
const formatEvent = (event) => {
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
const action = String(event.action || event.鍔ㄤ綔 || '').toLowerCase();
if (type === '鐘舵€? || type === 'status') {
const actionMap = { 'entry': '杩涚珯鎴愬姛', 'exit': '鍑虹珯鎴愬姛' };
return actionMap[action] || '鐘舵佸彉鏇?;
}
const typeMap = { 'sale': '鍞エ鎴愬姛', '鍞エ': '鍞エ鎴愬姛', 'entry': '杩涚珯鎴愬姛', 'exit': '鍑虹珯鎴愬姛' };
return typeMap[type] || event.type || event.绫诲瀷 || '鐘舵€佸彉鏇?;
};
const formatEventLocation = (event) => {
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
const stationName = event.station_name || event.鍞エ绔?|| event.鍙戠敓绔?|| '';
const stationCode = event.station_code || event.绔欑偣缂栧彿 || '';
if (type === 'sale' || type === '') {
return stationName || '绾夸笂鍞';
}
if (!stationName && !stationCode) return '---';
return [stationName, stationName && stationCode ? stationCode : ''].filter(Boolean).join(' ');
};
const formatEventMeta = (event) => {
const type = String(event.type || event.绫诲瀷 || '').toLowerCase();
if (type === 'sale' || type === '') {
const amount = event.amount ?? event.鍞エ棰?
if (amount != null && amount !== '') return `绁ㄤ环锛毬?${amount}`;
}
const stationEn = event.station_en || event.绔欑偣鑻辨枃 || '';
const deviceId = event.device_id || event.璁惧缂栧彿 || '';
if (stationEn && deviceId) return `${stationEn} (${deviceId})`;
if (deviceId) return `璁惧锛?{deviceId}`;
return stationEn;
};
const formatTrainType = (type) => {
if (!type) return '?;
const t = type.toLowerCase();
if (t === 'local') return '鏅€?;
if (t === 'ltd.exp' || t === 'express' || t === 'exp' || t === 'express_train') return '鐗规?;
if (t.includes('鐗规€?)) return '鐗规?;
return String(type);
};
const fetchData = async () => {
loading.value = true;
ticket.value = null;
try {
const response = await fetch(`/api/public/tickets/${ticketid}`);
if (response.status === 404) {
ticket.value = null;
} else {
const data = await response.json();
const id = (data && (data.ticket_id || data.エ缂栧彿 || data.id)) || ticketid;
let overview = null;
if (data) {
if (data.overview != null) overview = data.overview;
else if (data.姒傝 != null) overview = data.姒傝;
else if (data.summary != null) overview = data.summary;
}
let events = [];
if (data) {
if (Array.isArray(data.events)) events = data.events;
else if (data.浜嬩欢 != null) events = data.浜嬩欢;
}
if (id && overview != null) {
const out = {};
if (data && typeof data === 'object') {
for (const k in data) out[k] = data[k];
}
out.ticket_id = id;
out.overview = overview;
out.events = events;
ticket.value = out;
} else {
ticket.value = null;
}
}
} catch (e) {
console.error('鑾峰彇杞︾エ鏁版嵁澶辫触:', e);
ticket.value = null;
} finally {
loading.value = false;
}
};
const goHome = () => {
if (window.location.hostname.includes('fse-media.group')) {
window.location.href = 'https://ticket.fse-media.group/search';
} else {
window.location.href = '/ticket-search.html';
}
};
onMounted(() => {
fetchData();
});
return {
loading, ticket, hasTicket, statusInfo,
formatTime, formatEvent, formatEventLocation, formatEventMeta, formatTrainType, goHome
};
}
}).mount('#app');
</script>
</body>
</html>
+263
View File
@@ -0,0 +1,263 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>线上预定</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
</head>
<body class="public-search jr-order-page">
<div class="jr-order-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE铁路票务系统</strong>
<span>线上预定</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
<a href="https://ticket.fse-media.group/order" data-link="order" class="is-active">线上预定</a>
<a href="https://ticket.fse-media.group/search" data-link="search">车票查询</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
</nav>
</div>
</div>
<main class="jr-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">
<div class="jr-section-head">
<div>
<p class="jr-section-label">Reservation</p>
<h2>选择乘车区间</h2>
</div>
<p class="jr-section-note">点击下方线路图中的站点,依次选择起点与终点。</p>
</div>
<div id="stationMap" class="station-map-container jr-station-map">
<div class="loading">加载线路图中...</div>
</div>
<div class="jr-selection-summary">
<div class="jr-selection-card jr-selection-start">
<span class="jr-selection-tag">出发站</span>
<strong id="fromDisplay">请在上方地图选择</strong>
<input id="from" type="hidden" />
</div>
<div class="jr-selection-arrow">
<i class="fas fa-arrow-right"></i>
</div>
<div class="jr-selection-card jr-selection-end">
<span class="jr-selection-tag">到达站</span>
<strong id="toDisplay">请在上方地图选择</strong>
<input id="to" type="hidden" />
</div>
</div>
<div class="jr-form-grid">
<div class="jr-form-block">
<div class="jr-field-head">
<span class="jr-field-label">车型</span>
<span class="jr-field-note">根据停靠站不同计算票价</span>
</div>
<div class="train-type-group jr-train-type-group" id="typeGroup">
<label>
<input type="radio" name="trainType" value="Local" checked>
<div class="type-card jr-type-card">
<div class="type-title">普通 Local</div>
<div class="type-desc">每站停靠,适合常规出行</div>
</div>
</label>
<label>
<input type="radio" name="trainType" value="Express">
<div class="type-card jr-type-card">
<div class="type-title">特急 Express</div>
<div class="type-desc">仅停主要车站,票价更高</div>
</div>
</label>
</div>
</div>
<div class="jr-form-block jr-trip-block">
<div class="jr-field-head">
<label class="jr-field-label" for="trips">乘次数量</label>
<span class="jr-field-note">至少 1 次</span>
</div>
<input id="trips" type="number" min="1" value="1" class="input jr-quantity-input" placeholder="请输入乘次数量"
title="乘次数量" />
</div>
</div>
<div class="toolbar jr-toolbar">
<button id="createOrder" class="btn primary jr-submit-btn">
<i class="fas fa-check-circle"></i>
<span>生成凭证码</span>
</button>
</div>
</article>
</div>
<aside class="jr-column-side">
<article class="jr-section-card jr-side-card">
<div class="jr-section-head">
<div>
<p class="jr-section-label">Estimate</p>
<h2>票价与路径</h2>
</div>
<p class="jr-section-note">选择完整区间后自动刷新。</p>
</div>
<div id="priceBox" class="list jr-price-list">
<div class="empty-state jr-empty-state">
<p>请选择起点与终点后查看票价估算。</p>
</div>
</div>
</article>
<article class="jr-section-card jr-side-card" id="voucherPanel">
<div class="jr-section-head">
<div>
<p class="jr-section-label">Voucher</p>
<h2>凭证与兑票</h2>
</div>
<p class="jr-section-note">生成后即可前往游戏内售票机兑票。</p>
</div>
<div id="voucherBox" class="list jr-voucher-list">
<div class="empty-state jr-empty-state jr-voucher-empty">
<p>尚未生成凭证码。</p>
</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>
<footer class="site-footer jr-site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-order.js?v=20"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isRemote = location.hostname.includes('fse-media.group');
const links = {
home: isRemote ? 'https://ticket.fse-media.group' : '/home.html',
order: isRemote ? 'https://ticket.fse-media.group/order' : '/ticket-order.html',
search: isRemote ? 'https://ticket.fse-media.group/search' : '/ticket-search.html',
'card-search': isRemote ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html',
'card-order': isRemote ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
};
const homeLink = document.getElementById('homeLink');
const brandLink = document.getElementById('brandLink');
if (homeLink) homeLink.href = links.home;
if (brandLink) brandLink.href = links.home;
document.querySelectorAll('[data-link]').forEach((el) => {
const key = el.getAttribute('data-link');
if (links[key]) el.href = links[key];
});
});
</script>
</body>
</html>
+590
View File
@@ -0,0 +1,590 @@
(() => {
const $ = (sel) => document.querySelector(sel);
const fromEl = $('#from');
const toEl = $('#to');
const tripsEl = $('#trips');
const priceBox = $('#priceBox');
const voucherBox = $('#voucherBox');
let previewSeq = 0;
let lastPreviewKey = '';
const api = {
fareQuery: async (from, to) => {
const r = await fetch(`/api/public/fares/query?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
return r.json();
},
createOrder: async (payload) => {
const r = await fetch('/api/public/orders', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(payload) });
return r.json();
}
};
// Fetch and Render Map
const mapContainer = $('#stationMap');
let selection = [null, null];
let currentRoute = [];
let currentRouteTransfers = [];
let stationNameByCode = {};
let stationEnByCode = {};
let stationCanonicalByCode = {};
let stationCodesByCanonical = {};
let stationXByCanonical = {};
let stationYByCanonical = {};
let stationTransfer = new Set();
const piePath = (cx, cy, r, a0, a1) => {
const x0 = cx + r * Math.cos(a0);
const y0 = cy + r * Math.sin(a0);
const x1 = cx + r * Math.cos(a1);
const y1 = cy + r * Math.sin(a1);
const large = (a1 - a0) > Math.PI ? 1 : 0;
return `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1} Z`;
};
const pieSvg = (cx, cy, r, colors) => {
const cols = (Array.isArray(colors) ? colors.filter(Boolean) : []).slice(0, 4);
if (cols.length === 0) return '';
if (cols.length === 1) return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${cols[0]}" />`;
const step = (Math.PI * 2) / cols.length;
let out = '';
for (let i = 0; i < cols.length; i++) {
const a0 = -Math.PI / 2 + i * step;
const a1 = a0 + step;
out += `<path d="${piePath(cx, cy, r, a0, a1)}" fill="${cols[i]}" />`;
}
return out;
};
async function loadMap() {
try {
const res = await fetch('/api/public/fares/map/light');
const svg = await res.text();
mapContainer.innerHTML = svg;
const svgEl = mapContainer.querySelector('svg');
if(!svgEl) return;
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())
]);
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) {
const code = String(s.code || s.编号 || '').trim();
if (!code) continue;
stationNameByCode[code] = String(s.name || s.名称 || code);
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);
}
// 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
} catch(e) {
console.error(e);
mapContainer.innerHTML = '<div class="error">地图加载失败</div>';
}
}
window.handleStationClick = (code) => {
code = String(code || '').trim();
// Toggle if already selected
if (selection[0] === code) {
selection[0] = null;
} else if (selection[1] === code) {
selection[1] = null;
} else {
// Add new selection
if (!selection[0]) {
selection[0] = code;
} else if (!selection[1]) {
selection[1] = code;
} else {
// When both ends are already selected, start a new selection flow.
selection[0] = code;
selection[1] = null;
currentRoute = [];
currentRouteTransfers = [];
}
}
// Ensure no gaps (if start removed, move end to start)
if (!selection[0] && selection[1]) {
selection[0] = selection[1];
selection[1] = null;
}
updateSelectionUI();
};
function normalizeTrips() {
const raw = Number(tripsEl?.value || 1);
const trips = Number.isFinite(raw) ? Math.max(1, Math.floor(raw)) : 1;
if (tripsEl) tripsEl.value = String(trips);
return trips;
}
function getStationDisplayName(code) {
return stationNameByCode[String(code || '').trim()] || String(code || '').trim();
}
function getStationNameKey(code) {
const cn = getStationDisplayName(code).replace(/\s+/g, '');
const en = String(stationEnByCode[String(code || '').trim()] || '').toLowerCase().replace(/\s+/g, '');
return `${cn}|${en}`;
}
function buildTransferGroups(stationsData) {
const parent = {};
const codesByName = {};
const find = (code) => {
if (!parent[code]) parent[code] = code;
if (parent[code] !== code) parent[code] = find(parent[code]);
return parent[code];
};
const union = (a, b) => {
if (!a || !b) return;
const ra = find(a);
const rb = find(b);
if (ra !== rb) parent[rb] = ra;
};
for (const s of stationsData) {
const code = String(s?.code || s?.编号 || '').trim();
if (!code) continue;
parent[code] = code;
const cn = String(s?.name || s?.名称 || '').replace(/\s+/g, '');
const en = String(s?.en_name || s?.英文名 || '').toLowerCase().replace(/\s+/g, '');
const nameKey = `${cn}|${en}`;
if (cn || en) {
if (!codesByName[nameKey]) codesByName[nameKey] = [];
codesByName[nameKey].push(code);
}
}
for (const s of stationsData) {
const code = String(s?.code || s?.编号 || '').trim();
if (!code) continue;
const list = Array.isArray(s?.transfer_to) ? s.transfer_to : [];
for (const item of list) {
const to = String((typeof item === 'string') ? item : (item?.code || item?.station || item?.id || item?.[0] || '')).trim();
if (to) union(code, to);
}
}
for (const codes of Object.values(codesByName)) {
if (!Array.isArray(codes) || codes.length < 2) continue;
for (let i = 1; i < codes.length; i++) {
union(codes[0], codes[i]);
}
}
const byRoot = {};
for (const code of Object.keys(parent)) {
const root = find(code);
if (!byRoot[root]) byRoot[root] = [];
byRoot[root].push(code);
}
const canonicalByCode = {};
const codesByCanonical = {};
for (const codes of Object.values(byRoot)) {
codes.sort((a, b) => a.localeCompare(b));
const canonical = codes[0];
codesByCanonical[canonical] = codes;
for (const code of codes) canonicalByCode[code] = canonical;
}
return { canonicalByCode, codesByCanonical };
}
function buildDisplayRouteCodes(route, from, to) {
const middle = Array.isArray(route) ? route.filter(c => c && c !== from && c !== to) : [];
const merged = [];
for (const code of [from, ...middle, to].filter(Boolean)) {
const prev = merged[merged.length - 1];
const prevName = prev ? getStationDisplayName(prev).replace(/\s+/g, '') : '';
const nextName = getStationDisplayName(code).replace(/\s+/g, '');
if (prev && prevName && prevName === nextName) continue;
merged.push(code);
}
return merged;
}
function updateSelectionUI(skipPreview = false) {
if (!(selection[0] && selection[1])) {
currentRoute = [];
currentRouteTransfers = [];
}
// Update Inputs
fromEl.value = selection[0] || '';
toEl.value = selection[1] || '';
// Update Displays with Chinese names
const getName = (code) => {
return stationNameByCode[String(code || '').trim()] || code;
};
$('#fromDisplay').textContent = selection[0] ? getName(selection[0]) : '请在上方地图选择';
$('#toDisplay').textContent = selection[1] ? getName(selection[1]) : '请在上方地图选择';
// Update Map Styles
document.querySelectorAll('.map-station').forEach(el => {
const code = el.getAttribute('data-code');
el.classList.remove('start', 'end', 'selected', 'route', 'route-transfer');
if(code === selection[0]) el.classList.add('start');
if(code === selection[1]) el.classList.add('end');
});
if (Array.isArray(currentRoute) && currentRoute.length > 0) {
for (const c of currentRoute) {
const el = document.querySelector(`.map-station[data-code="${c}"]`);
if (el) el.classList.add('route');
}
}
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.length > 0) {
for (const c of currentRouteTransfers) {
const el = document.querySelector(`.map-station[data-code="${c}"]`);
if (el) el.classList.add('route-transfer');
}
}
// Auto preview if both selected
if(!skipPreview && selection[0] && selection[1]) previewPrice();
}
// Load Map on Start
renderSystemMap();
async function previewPrice(force = false){
const seq = ++previewSeq;
const from = (fromEl.value||'').trim();
const to = (toEl.value||'').trim();
const type = document.querySelector('input[name="trainType"]:checked').value;
const trips = normalizeTrips();
if(!from || !to){ priceBox.innerHTML = '<div class="empty-state jr-empty-state"><p>请选择起点与终点</p></div>'; return; }
const previewKey = `${from}|${to}|${type}|${trips}`;
if (!force && previewKey === lastPreviewKey) return;
lastPreviewKey = previewKey;
try{
const fare = await api.fareQuery(from, to);
if (seq !== previewSeq) return;
if(fare && (fare.error || fare['错误'])){ priceBox.innerHTML = '<div class="list-item jr-result-row"><span class="k">提示</span><span class="v">未找到对应票价</span></div>'; return; }
const resolvedFrom = String(fare?.from_code || '').trim();
const resolvedTo = String(fare?.to_code || '').trim();
if ((resolvedFrom && resolvedFrom !== from) || (resolvedTo && resolvedTo !== to)) {
lastPreviewKey = '';
priceBox.innerHTML = `
<div class="empty-state jr-empty-state">
<p>站点解析异常,当前后端返回的站码与页面选择不一致。</p>
<p>已选:${getStationDisplayName(from)}(${from}) -> ${getStationDisplayName(to)}(${to})</p>
<p>返回:${getStationDisplayName(resolvedFrom)}(${resolvedFrom || '-'}) -> ${getStationDisplayName(resolvedTo)}(${resolvedTo || '-'})</p>
<p>请先部署最新后端并清理 CDN 缓存后再试。</p>
</div>
`;
currentRoute = [];
currentRouteTransfers = [];
updateSelectionUI(true);
return;
}
const discRaw = Number(fare?.discount ?? fare?.['折扣'] ?? 1);
const disc = Number.isFinite(discRaw) && discRaw > 0 ? discRaw : 1;
const base = (type==='Express')
? Number(fare?.express_fare ?? fare?.['特快票价'] ?? 0)
: Number(fare?.regular_fare ?? fare?.['常规票价'] ?? 0);
const discountedRaw = (type==='Express')
? (fare?.discounted_express_fare ?? fare?.['优惠后特快票价'])
: (fare?.discounted_regular_fare ?? fare?.['优惠后常规票价']);
const discountedSingle = Number(discountedRaw ?? Math.floor(base * disc) ?? 0);
const price = discountedSingle * trips;
const routeKey = (type === 'Express') ? 'express_path' : 'regular_path';
const transferKey = (type === 'Express') ? 'express_transfers' : 'regular_transfers';
currentRoute = Array.isArray(fare?.[routeKey]) ? fare[routeKey] : [];
currentRouteTransfers = Array.isArray(fare?.[transferKey]) ? fare[transferKey] : [];
const displayRoute = buildDisplayRouteCodes(currentRoute, from, to);
const routeText = displayRoute.length > 0 ? displayRoute.map(getStationDisplayName).join(' → ') : '';
const transferStops = [];
const seenTransferNames = new Set();
for (const code of currentRouteTransfers) {
if (code === from || code === to) continue;
const nameKey = getStationDisplayName(code).replace(/\s+/g, '');
if (!nameKey || seenTransferNames.has(nameKey)) continue;
seenTransferNames.add(nameKey);
transferStops.push(code);
}
const transferHtml = transferStops.length
? `<div class="jr-transfer-chips">${transferStops.map(c => `<span class="badge badge-secondary">${getStationDisplayName(c)}</span>`).join('')}</div>`
: `<span class="text-muted">无</span>`;
priceBox.innerHTML = `
<div class="list-item jr-result-row"><span class="k">原始票价</span><span class="v">${base}</span></div>
<div class="list-item jr-result-row"><span class="k">优惠后票价</span><span class="v">${discountedSingle}</span></div>
<div class="list-item jr-result-row"><span class="k">折扣</span><span class="v">-${Math.round((1-disc)*100)}%</span></div>
${routeText ? `<div class="list-item jr-result-row jr-result-multiline"><span class="k">路径</span><span class="v jr-route-text">${routeText}</span></div>` : ``}
<div class="list-item jr-result-row jr-result-multiline"><span class="k">换乘</span><span class="v jr-route-text">${transferHtml}</span></div>
<div class="list-item jr-result-row jr-total-row">
<span class="k">总价</span><span class="v jr-total-amount">${price}</span>
</div>
`;
updateSelectionUI(true);
}catch(e){
lastPreviewKey = '';
priceBox.innerHTML = '<div class="empty-state jr-empty-state"><p>票价预估失败,请稍后再试。</p></div>';
}
}
// Create Order with auto-preview
async function createOrder(){
const from = (fromEl.value||'').trim();
const to = (toEl.value||'').trim();
const type = document.querySelector('input[name="trainType"]:checked').value;
const trips = normalizeTrips();
const ride_date = new Date().toISOString().slice(0, 10);
if(!from || !to){ alert('请完整填写信息'); return; }
// Auto-fetch price before creating
await previewPrice(true);
try{
const payload = { start: from, terminal: to, train_type: type, trips, ride_date };
const res = await fetch('/api/public/orders', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(payload) });
const r = await res.json();
if(r && r.ok){
// Link to external subdomain or local page
const buildTokenLink = (code) => {
if (location.hostname.includes('fse-media.group')) {
return `https://ticket.fse-media.group/token?code=${encodeURIComponent(code)}`;
}
return `/token.html?code=${encodeURIComponent(code)}`;
};
voucherBox.innerHTML = `
<div class="voucher-container jr-voucher-panel">
<div class="jr-voucher-meta">凭证码</div>
<div class="voucher-code">${r.code}</div>
<div class="voucher-hint">请在游戏内任意售票机选择线上订票并输入该凭证码兑票。</div>
<div class="toolbar jr-voucher-actions">
<a class="btn jr-detail-btn" href="${buildTokenLink(r.code)}" target="_self"><i class="fas fa-eye"></i> 详情</a>
</div>
</div>
`;
}else{
alert('创建失败: ' + (r.error || '未知错误'));
}
}catch(e){ alert('创建失败'); }
}
const btnPreview = $('#previewPrice');
if(btnPreview) btnPreview.onclick = previewPrice;
const btnCreate = $('#createOrder');
if(btnCreate) btnCreate.onclick = createOrder;
// Listen for Type change to auto-update price
document.querySelectorAll('input[name="trainType"]').forEach(el => {
el.onchange = () => { if(fromEl.value && toEl.value) previewPrice(); };
});
if (tripsEl) {
tripsEl.oninput = () => { if(fromEl.value && toEl.value) previewPrice(); };
tripsEl.onchange = () => { normalizeTrips(); if(fromEl.value && toEl.value) previewPrice(); };
}
})();
+643
View File
@@ -0,0 +1,643 @@
<!DOCTYPE html>
<html lang="zh-CN">
<!-- 充满未知和不稳定的票务系统! -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FSE铁路票务系统 - 线路规划</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
<body class="jr-admin-page jr-admin-route-page jr-public-page">
<div class="jr-public-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" class="jr-top-link" id="routeTopLink">
<i class="fas fa-train"></i>
<span>FSE 铁路运输后台系统</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="https://ticket.fse-media.group" class="jr-brand" id="routeBrandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE 铁路运输</strong>
<span>线路规划后台</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
<a href="https://ticket.fse-media.group/order" data-link="order">线上预定</a>
<a href="https://ticket.fse-media.group/search" data-link="search">车票查询</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
</nav>
</div>
</div>
<main class="jr-public-main jr-admin-main-shell">
<div id="app" class="jr-admin-app">
<div class="sidebar" :class="{ open: sidebarOpen }">
<div class="jr-admin-sidebar-head">
<span class="jr-kicker">ROUTE PLANNING</span>
<div class="brand">FSE铁路售票线路规划系统</div>
<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;">
<span class="nav-icon"><i class="fas fa-home"></i></span> 返回首页
</a>
<div class="nav-item" :class="{active: currentView === 'management'}"
@click="currentView = 'management'">
<span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路规划
</div>
<div class="nav-item" :class="{active: currentView === 'faremap'}" @click="currentView = 'faremap'">
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
</div>
</div>
<div class="jr-admin-sidebar-status">
<div class="jr-admin-sidebar-status-label">Server: {{ connected ? 'Online' : 'Offline' }}</div>
<div class="flex" style="align-items: center; gap: 6px;">
<i class="fas fa-circle"
:style="{ color: connected ? '#10b981' : '#ef4444', fontSize: '0.6rem' }"></i>
<span>Status: {{ connected ? 'Connected' : 'Disconnected' }}</span>
</div>
</div>
<div class="sidebar-faremap">
<div class="sidebar-faremap-title">票价地图预览</div>
<div class="sidebar-faremap-box" @click="currentView = 'faremap'">
<div v-if="fareMapLoading" class="text-muted" style="padding: 10px;">加载中...</div>
<div v-else-if="fareMapError" class="text-muted" style="padding: 10px;">{{ fareMapError }}</div>
<div v-else class="sidebar-faremap-canvas" v-html="fareMapSvg"></div>
</div>
</div>
</div>
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false"></div>
<div class="main">
<div class="header">
<div class="jr-admin-header-copy">
<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>
<h3 style="margin: 0;">{{ viewTitle }}</h3>
</div>
</div>
</div>
<div class="jr-admin-header-side">
<span class="jr-admin-header-pill" :class="connected ? 'is-online' : 'is-offline'">
<i class="fas fa-circle"></i>
{{ connected ? '服务器在线' : '服务器离线' }}
</span>
</div>
</div>
<div class="content">
<section class="jr-page-intro jr-admin-intro">
<span class="jr-kicker">LINE CONTROL</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>当前已加载 {{ lines.length }} 条线路,{{ selectedLine ? `正在编辑 ${selectedLine.name || selectedLine.id}` : '尚未选择线路' }}。编辑前可先在左侧确认线路列表和票价地图预览。</p>
</section>
<div v-if="currentView === 'management'" class="management-container">
<div class="management-sidebar">
<div class="card"
style="height: 100%; display: flex; flex-direction: column; margin-bottom: 0;">
<div class="flex between mb-4">
<h4>线路列表</h4>
<button @click="showAddLine = true" title="新建线路"><i class="fas fa-plus"></i></button>
</div>
<!--添加车站-->
<div v-if="showAddLine" class="mb-4"
style="background: rgba(255,255,255,0.05); padding: 10px; border-radius: 8px;">
<input v-model="newLine.id" placeholder="线路编号 (如 L1)"
style="margin-bottom: 8px; width: 100%;">
<input v-model="newLine.name" placeholder="中文名称"
style="margin-bottom: 8px; width: 100%;">
<input v-model="newLine.en_name" placeholder="英文名称"
style="margin-bottom: 8px; width: 100%;">
<div class="flex">
<input type="text" v-model="newLine.color" placeholder="#HEX颜色" style="flex: 1;">
<input type="color" v-model="newLine.color" title="选择颜色"
style="width: 40px; padding: 0; border: none; height: 32px;">
<button @click="createLine" style="padding: 0 12px;" title="确认创建线路"><i
class="fas fa-check"></i></button>
<button class="danger" @click="showAddLine = false" title="取消"
style="padding: 0 12px;"><i class="fas fa-times"></i></button>
</div>
</div>
<div class="list-lines" style="flex: 1; overflow-y: auto;">
<div v-for="l in lines" :key="l.id" class="line-item"
:class="{active: selectedLine && selectedLine.id === l.id}" @click="selectLine(l)">
<div class="line-color-dot" :style="{background: l.color}"></div>
<div class="line-info">
<div class="line-name">{{ l.name || l.id }}</div>
<div class="line-meta">{{ (l.stations || []).length }} 站</div>
</div>
<div class="line-actions" v-if="selectedLine && selectedLine.id === l.id">
<button class="danger sm" @click.stop="deleteLine(l.id)"><i
class="fas fa-trash"></i></button>
</div>
</div>
</div>
</div>
</div>
<!--右侧面板-->
<div class="management-main">
<div class="card mb-4">
<div class="flex between">
<div v-if="selectedLine">
<div class="flex">
<h3 :style="{color: selectedLine.color}">{{ selectedLine.name || selectedLine.id
}}</h3>
<span class="badge">{{ selectedLine.id }}</span>
</div>
<div class="flex mt-2" style="align-items:center; gap:8px;">
<label style="font-size:0.8em; color:var(--muted);">EN:</label>
<span style="font-size:0.9em;">{{ selectedLine.en_name || 'N/A' }}</span>
</div>
</div>
<div v-else>
<h4>选择左侧线路进行管理</h4>
</div>
<div class="flex">
<button v-if="selectedLine" @click="openLineModal" title="编辑线路"><i
class="fas fa-pen"></i></button>
<button @click="refreshData" title="刷新"><i class="fas fa-sync-alt"></i></button>
</div>
</div>
</div>
<!-- 可视化线路编辑-->
<div class="card visual-editor" v-if="selectedLine">
<div class="editor-toolbar flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
<div class="flex">
<label class="switch-label">
<input type="checkbox" v-model="fareMode">
<span class="slider"></span>
<span class="label-text"><i class="fas fa-coins"></i> 票价设置/车站编辑模式</span>
</label>
<label class="switch-label" style="margin-left: 10px;">
<input type="checkbox" v-model="stationEditMode">
<span class="slider"></span>
<span class="label-text"><i class="fas fa-exchange-alt"></i> 换乘设置模式</span>
</label>
<div v-if="fareMode" class="hint-text text-warning">
<i class="fas fa-info-circle"></i> 点击两个站点以设置票价
</div>
<div v-else-if="stationEditMode" class="hint-text text-info">
<i class="fas fa-info-circle"></i> 点击站点以设置换乘
</div>
<div v-else class="hint-text text-muted">
<i class="fas fa-info-circle"></i> 点击站点删除
</div>
</div>
<div class="flex"
style="background: rgba(255,255,255,0.05); padding: 8px; border-radius: 6px;">
<div style="font-weight: bold; margin-right: 8px;">添加站点:</div>
<input v-model="newStation.code" placeholder="编号 (01-01)" style="width: 100px;">
<input v-model="newStation.name" placeholder="中文名" style=" width: 120px;">
<input v-model="newStation.en_name" placeholder="英文名" style=" width: 120px;">
<button @click="addStationToLine"
:disabled="!newStation.code || !newStation.name"><i class="fas fa-plus"></i>
添加</button>
</div>
</div>
<!-- 可视化线路编辑-->
<div class="visual-line-container">
<svg width="100%" height="200"
v-if="selectedLine.stations && selectedLine.stations.length > 0">
<!--站点连接线-->
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100"
:stroke="selectedLine.color" stroke-width="4" stroke-linecap="round" />
<!--票价显示-->
<g v-for="(s, i) in selectedLine.stations.slice(0, selectedLine.stations.length-1)"
:key="'fare-'+i">
<text :x="50 + i * 120 + 60" y="90" text-anchor="middle" fill="#f59e0b"
font-size="10" font-weight="bold">{{ getFareText(i) }}</text>
</g>
<!--车站节点-->
<g v-for="(sCode, index) in selectedLine.stations" :key="sCode"
@mousedown="onStationDragStart(index)" @mouseup="onStationDrop"
@mousemove="onStationDragOver(index)" @click="handleStationClick(sCode)"
class="station-node" :class="{
'selected': isStationSelected(sCode),
'fare-source': fareSelection[0] === sCode,
'fare-target': fareSelection[1] === sCode
}">
<!--车站节点图形-->
<circle :cx="50 + index * 120" cy="100" r="14" fill="var(--bg)"
:stroke="selectedLine.color" stroke-width="3" />
<circle v-if="isStationSelected(sCode)" :cx="50 + index * 120" cy="100" r="8"
:fill="selectedLine.color" />
<!--节点标签-->
<text :x="50 + index * 120" y="70" text-anchor="middle" fill="var(--text)"
font-weight="bold" font-size="12" style="pointer-events: none;">{{
getStationName(sCode) }}</text>
<text :x="50 + index * 120" y="135" text-anchor="middle" fill="var(--muted)"
font-size="10" style="pointer-events: none;">{{ sCode }}</text>
<g v-if="getTransferLineBadges(sCode).length > 0">
<g v-for="(li, liIdx) in getTransferLineBadges(sCode)"
:key="`${sCode}-xfer-${li.id}`">
<circle
:cx="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14"
cy="150" r="5" :fill="li.color" stroke="#ffffff" stroke-width="1" />
<text
:x="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14"
y="165" text-anchor="middle" fill="var(--muted)" font-size="7"
style="pointer-events: none;">{{ li.id }}</text>
</g>
</g>
<!--删除-->
<title>{{ getStationName(sCode) }} ({{ sCode }}){{ getTransferTitleSuffix(sCode)
}}</title>
</g>
</svg>
<div v-else class="empty-state">
<i class="fas fa-subway"
style="font-size: 48px; margin-bottom: 16px; opacity: 0.3;"></i>
<p>此线路暂无站点,请从上方添加</p>
</div>
</div>
</div>
<!-- 票价设置弹窗 -->
<div v-if="showFareModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">设置票价</h4>
<div class="mb-4 text-center">
<div class="flex between"
style="justify-content: center; gap: 20px; font-size: 1.1em; font-weight: bold;">
<span>{{ getStationName(fareSelection[0]) }}</span>
<i class="fas fa-arrow-right text-muted"></i>
<span>{{ getStationName(fareSelection[1]) }}</span>
</div>
</div>
<div class="mb-4">
<label>常规票价</label>
<input v-model.number="currentFare.cost_regular" type="number" class="w-100">
</div>
<div class="mb-4">
<label>特急票价</label>
<input v-model.number="currentFare.cost_express" type="number" class="w-100">
</div>
<div class="modal-actions">
<button class="danger" @click="deleteCurrentFare"
v-if="currentFare.exists">删除</button>
<button @click="saveCurrentFare">保存</button>
<button class="danger" @click="closeFareModal">取消</button>
</div>
</div>
</div>
<div v-if="showStationModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">站点编辑</h4>
<div class="mb-4">
<label>站点编号</label>
<input v-model="stationForm.code" class="w-100">
</div>
<div class="mb-4">
<label>中文名</label>
<input v-model="stationForm.name" class="w-100">
</div>
<div class="mb-4">
<label>英文名</label>
<input v-model="stationForm.en_name" class="w-100">
</div>
<div class="mb-4">
<label class="switch-label">
<input type="checkbox" v-model="stationForm.transfer_enabled">
<span class="slider"></span>
<span class="label-text">可换乘</span>
</label>
</div>
<div class="mb-4">
<label>可换乘到的站点</label>
<select v-model="stationForm.transfer_to" multiple class="w-100"
:disabled="!stationForm.transfer_enabled" style="height: 180px;">
<option v-for="t in transferTargets" :key="t.code" :value="t.code">
{{ t.name }} ({{ t.en_name }}) - {{ t.code }}
</option>
</select>
</div>
<div class="modal-actions">
<button class="danger" @click="deleteStation(stationFormOriginalCode)">删除</button>
<button @click="saveStationSettings">保存</button>
<button class="danger" @click="closeStationModal">取消</button>
</div>
</div>
</div>
<div v-if="showLineModal" class="modal show">
<div class="modal-card">
<h4 class="modal-title">线路编辑</h4>
<div class="mb-4">
<label>线路编号</label>
<input v-model="lineForm.id" class="w-100">
</div>
<div class="mb-4">
<label>中文名</label>
<input v-model="lineForm.name" class="w-100">
</div>
<div class="mb-4">
<label>英文名</label>
<input v-model="lineForm.en_name" class="w-100">
</div>
<div class="mb-4">
<label>颜色</label>
<div class="flex" style="gap:8px;">
<input type="text" v-model="lineForm.color" class="w-100" placeholder="#3366cc">
<input type="color" v-model="lineForm.color" title="选择颜色"
style="width: 48px; padding: 0; border: none; height: 32px;">
</div>
</div>
<div class="modal-actions">
<button @click="saveLineSettings">保存</button>
<button class="danger" @click="closeLineModal">取消</button>
</div>
</div>
</div>
</div>
</div>
<!-- 票价地图 -->
<div v-if="currentView === 'faremap'">
<div class="card faremap-card">
<div class="flex between mb-4">
<h4>票价地图</h4>
<div class="flex" style="flex-wrap: wrap; gap: 8px;">
<button @click="loadFareMap" title="刷新"><i class="fas fa-sync-alt"></i></button>
<button @click="zoomFareMapOut" title="缩小"><i class="fas fa-minus"></i></button>
<button @click="zoomFareMapIn" title="放大"><i class="fas fa-plus"></i></button>
<button @click="zoomFareMapReset" title="重置"><i class="fas fa-crosshairs"></i></button>
<button @click="exportFareMap" title="导出图像"><i class="fas fa-download"></i></button>
</div>
</div>
<div v-if="fareMapLoading" class="loading">加载中...</div>
<div v-else-if="fareMapError" class="loading">{{ fareMapError }}</div>
<div v-else class="faremap-viewport">
<div class="faremap-canvas" :style="{ transform: `scale(${fareMapScale})` }"
v-html="fareMapSvg">
</div>
</div>
</div>
</div>
<!-- 凭证管理 -->
<div v-if="currentView === 'vouchers'">
<div class="card">
<div class="flex between mb-4">
<h4>凭证列表</h4>
<div class="flex">
<button @click="fetchOrders"><i class="fas fa-sync-alt"></i></button>
</div>
</div>
<table class="ticket-table">
<thead>
<tr>
<th>凭证</th>
<th>线路</th>
<th>车型</th>
<th>票价</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orderList" :key="o.code">
<td class="mono" style="font-weight:bold; font-size:1.1em;">{{ o.code }}</td>
<td>{{ o.start_name }} <i class="fas fa-arrow-right text-muted"></i> {{
o.terminal_name }}</td>
<td>{{ formatTrainType(o.train_type) }}</td>
<td>{{ o.price }}</td>
<td><span class="badge" :class="formatTicketStatus(o.status).class">{{
formatTicketStatus(o.status).text }}</span></td>
<td>{{ formatTime(o.created_ts) }}</td>
<td>
<div class="flex" style="gap:4px;">
<a :href="'token.html?code='+o.code" target="_blank" class="btn sm"
title="查看"
style="height:28px; width:28px; padding:0; display:flex; align-items:center; justify-content:center;"><i
class="fas fa-eye"></i></a>
<button class="danger sm" @click="deleteOrder(o.code)"
style="height:28px; width:28px; padding:0; display:flex; align-items:center; justify-content:center;"><i
class="fas fa-trash"></i></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 车票记录 -->
<div v-if="currentView === 'tickets'">
<div class="card mb-4">
<div class="flex between mb-4">
<h4>车票记录</h4>
<div class="flex">
<input v-model="ticketSearch" placeholder="搜索 Ticket ID / 站点" style="width: 200px;">
<button @click="refreshData"><i class="fas fa-sync-alt"></i></button>
</div>
</div>
<table class="ticket-table">
<thead>
<tr>
<th>ID</th>
<th>起点</th>
<th>终点</th>
<th>类型</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<tr v-for="t in ticketList" :key="t.ticket_id" @click="viewTicketDetails(t)"
class="clickable-row">
<td><span class="mono">{{ t.ticket_id }}</span></td>
<td>
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ getStationInfo(t.start).name }}</span>
<span class="st-code">{{ t.start }}</span>
</div>
<div class="st-en">{{ getStationInfo(t.start).en_name }}</div>
</div>
</td>
<td>
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ getStationInfo(t.terminal).name }}</span>
<span class="st-code">{{ t.terminal }}</span>
</div>
<div class="st-en">{{ getStationInfo(t.terminal).en_name }}</div>
</div>
</td>
<td>{{ formatTrainType(t.type) }}</td>
<td><span class="badge" :class="formatTicketStatus(t.status).class">{{
formatTicketStatus(t.status).text }}</span></td>
<td>{{ formatTime(t.ts) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 车票详情弹窗 -->
<div v-if="showTicketModal" class="modal show" @click.self="closeTicketModal">
<div class="modal-card" style="width: 600px;">
<div class="flex between mb-4">
<h4 class="modal-title">车票详情</h4>
<button class="sm" @click="closeTicketModal" title="关闭"><i
class="fas fa-times"></i></button>
</div>
<div v-if="selectedTicket">
<div class="ticket-header mb-4"
style="background: rgba(255,255,255,0.05); padding: 16px; border-radius: 8px;">
<div class="flex between mb-2">
<span class="mono text-muted">{{ selectedTicket.ticket_id }}</span>
<span class="badge"
:class="formatTicketStatus(selectedTicket.index.status).class">
{{ formatTicketStatus(selectedTicket.index.status).text }}
</span>
</div>
<div class="flex between" style="font-size: 1.2rem; font-weight: bold;">
<div class="st-container">
<div class="st-main-row">
<span class="st-name">{{ selectedTicket.index.start_name ||
selectedTicket.index.start }}</span>
<span class="st-code">{{ selectedTicket.index.start }}</span>
</div>
<div class="st-en">{{ selectedTicket.index.start_en || '' }}</div>
</div>
<i class="fas fa-arrow-right text-muted"></i>
<div class="st-container" style="align-items: flex-end;">
<div class="st-main-row">
<span class="st-name">{{ selectedTicket.index.terminal_name ||
selectedTicket.index.terminal }}</span>
<span class="st-code">{{ selectedTicket.index.terminal }}</span>
</div>
<div class="st-en">{{ selectedTicket.index.terminal_en || '' }}</div>
</div>
</div>
<div class="text-muted mt-2" style="font-size: 0.9rem;">
类型: {{ formatTrainType(selectedTicket.index.type ||
selectedTicket.index.train_type) }} | 票价: {{ selectedTicket.index.price ||
selectedTicket.index.cost }}
</div>
</div>
<h5>行程记录</h5>
<div class="timeline">
<div v-for="ev in selectedTicket.events" :key="ev.ts || ev['时间戳']" class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<div class="flex between">
<span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span>
<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>
</div>
</div>
</div>
</div>
<div v-else class="loading">加载中...</div>
</div>
</div>
</div>
<!-- 设置 -->
<div v-if="currentView === 'settings'">
<div class="card mb-4">
<h4>优惠设置</h4>
<div class="mb-4">
<label style="display:block; margin-bottom:8px; font-weight:600;">优惠活动</label>
<div class="flex">
<input v-model="config.promotion.name" placeholder="活动名称">
<input v-model.number="config.promotion.discount" type="number" step="0.1"
placeholder="折扣 (0.1-1.0)">
<button @click="saveConfig"><i class="fas fa-save"></i> 保存</button>
</div>
</div>
</div>
</div>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</div>
</div>
</div>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=13"></script>
<script src="ticket-route.js?v=2"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html',
order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html',
search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html',
'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html',
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
};
document.getElementById('homeLink').href = links.home;
document.getElementById('routeTopLink').href = links.home;
document.getElementById('routeBrandLink').href = links.home;
document.querySelectorAll('[data-link]').forEach((el) => {
const key = el.getAttribute('data-link');
if (links[key]) el.href = links[key];
});
});
</script>
</body>
</html>
+970
View File
@@ -0,0 +1,970 @@
(() => {
try {
if (localStorage.getItem('tm_session') !== 'ok') {
const next = encodeURIComponent(location.pathname + location.search);
location.href = `/login.html?next=${next}`;
}
} catch (_) { }
})();
const { createApp, ref, onMounted, computed, reactive, watch } = Vue;
createApp({
setup() {
const currentView = ref('management');
const sidebarOpen = ref(false);
const viewTitle = computed(() => {
const map = {
dashboard: '仪表盘',
management: '线路与票价管理',
tickets: '车票记录',
settings: '系统设置'
};
return map[currentView.value] || '线路规划系统';
});
const connected = ref(false);
const socket = io({ transports: ['websocket'], upgrade: false, timeout: 20000 });
const stations = ref([]);
const lines = ref([]);
const fares = ref([]);
const tickets = ref([]);
const stats = reactive({ sold_tickets: 0, revenue: 0 });
const config = reactive({ api_base: '', promotion: { name: '', discount: 1 } });
const logs = ref([]);
const orders = ref([]);
const showAddLine = ref(false);
const showAddStation = ref(false);
const newLine = reactive({ id: '', name: '', en_name: '', color: '#3366cc' });
const newStation = reactive({ code: '', name: '', en_name: '' });
const showTicketModal = ref(false);
const selectedTicket = ref(null);
const selectedLine = ref(null);
const fareMode = ref(false);
const stationEditMode = ref(false);
const fareSelection = ref([]);
const showFareModal = ref(false);
const currentFare = reactive({ exists: false, cost_regular: 0, cost_express: 0 });
const draggingStationIndex = ref(null);
const showStationModal = ref(false);
const stationForm = reactive({ code: '', name: '', en_name: '', transfer_enabled: false, transfer_to: [] });
const stationFormOriginalCode = ref('');
const showLineModal = ref(false);
const lineFormOriginalId = ref('');
const lineForm = reactive({ id: '', name: '', en_name: '', color: '#3366cc', stations: [] });
const fareMapSvg = ref('');
const fareMapScale = ref(1);
const fareMapLoading = ref(false);
const fareMapError = ref('');
const ticketSearch = ref('');
const lastActionError = ref('');
const lastActionOkTs = ref(0);
const mutationBusy = ref(false);
const appDialog = window.appDialog || {
alert: (message) => Promise.resolve(window.alert(message)),
confirm: (message) => Promise.resolve(window.confirm(message)),
prompt: (message, defaultValue) => Promise.resolve(window.prompt(message, defaultValue))
};
const parseJsonSafe = (text) => {
if (text == null) return null;
const t = String(text);
if (!t) return null;
try { return JSON.parse(t); } catch (e) { return null; }
};
const requestJson = async (url, opts = {}, { expectOk = false, timeoutMs = 15000 } = {}) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const r = await fetch(url, { ...opts, signal: controller.signal });
const text = await r.text();
const data = parseJsonSafe(text) ?? (text ? { raw: text } : null);
if (!r.ok) {
const msg = (data && (data.error || data.错误)) || r.statusText || '请求失败';
throw new Error(`${r.status} ${msg}`);
}
if (expectOk) {
if (data && data.ok === false) throw new Error(data.error || data.错误 || '操作失败');
if (data && data.ok == null && (data.error || data.错误)) throw new Error(data.error || data.错误);
}
return data;
} catch (e) {
const msg = (e && e.name === 'AbortError') ? '请求超时' : (e?.message || String(e));
throw new Error(msg);
} finally {
clearTimeout(timer);
}
};
const runMutation = async (action, { successMessage } = {}) => {
if (mutationBusy.value) return;
mutationBusy.value = true;
lastActionError.value = '';
try {
await action();
lastActionOkTs.value = Date.now();
if (successMessage) alert(successMessage);
await fetchData();
} catch (e) {
lastActionError.value = e?.message || String(e);
alert(`操作失败:${lastActionError.value}`);
await fetchData();
} finally {
mutationBusy.value = false;
}
};
const formatTime = (ts) => {
if (ts == null || ts === '') return '---';
let value = Number(ts);
if (Number.isFinite(value)) {
if (value > 0 && value < 1000000000000) value *= 1000;
const d = new Date(value);
if (!Number.isNaN(d.getTime())) {
return d.toLocaleString('zh-CN', { hour12: false });
}
}
const d = new Date(ts);
if (!Number.isNaN(d.getTime())) {
return d.toLocaleString('zh-CN', { hour12: false });
}
return String(ts);
};
const formatLogType = (type) => {
const map = {
'update_config_generic': { icon: 'fa-cog', text: '配置更新', class: 'text-primary' },
'update_station': { icon: 'fa-map-marker-alt', text: '站点更新', class: 'text-info' },
'add_line': { icon: 'fa-plus-circle', text: '新增线路', class: 'text-success' },
'update_line': { icon: 'fa-edit', text: '线路更新', class: 'text-warning' },
'update_fare': { icon: 'fa-coins', text: '票价更新', class: 'text-warning' },
'delete_fare': { icon: 'fa-trash', text: '票价删除', class: 'text-danger' },
'ticket_sold': { icon: 'fa-ticket-alt', text: '售票成功', class: 'text-success' },
'gate_entry': { icon: 'fa-sign-in-alt', text: '进站', class: 'text-info' },
'gate_exit': { icon: 'fa-sign-out-alt', text: '出站', class: 'text-info' }
};
return map[type] || { icon: 'fa-info-circle', text: type, class: 'text-muted' };
};
const formatTicketStatus = (status) => {
const map = {
'valid': { text: '有效', class: 'badge-success' },
'used': { text: '已使用', class: 'badge-secondary' },
'expired': { text: '已过期', class: 'badge-danger' },
'refunded': { text: '已退票', class: 'badge-warning' }
};
return map[status] || { text: status || '未知', class: 'badge-secondary' };
};
const formatTrainType = (type) => {
if (!type) return '普通';
const t = type.toLowerCase();
if (t === 'local') return '普通';
if (t === 'ltd.exp' || t === 'express') return '特急';
return type;
};
const getTicketEventType = (event) => String((event && (event["类型"] || event.type)) || '').toLowerCase();
const getTicketEventAction = (event) => String((event && (event["动作"] || event.action)) || '').toLowerCase();
const formatTicketEvent = (eventOrType) => {
const event = eventOrType && typeof eventOrType === 'object' ? eventOrType : { type: eventOrType };
const type = getTicketEventType(event);
const action = getTicketEventAction(event);
if (type === 'sale' || type === '售票') return '售票成功';
if (type === 'entry' || action === 'entry') return '进站成功';
if (type === 'exit' || action === 'exit') return '出站成功';
if (type === 'status' || type === '状态') {
return { entry: '进站成功', exit: '出站成功' }[action] || '状态变更';
}
const map = {
'entry': '进站',
'exit': '出站',
'check': '验票',
'status': '状态变更',
'refund': '退票'
};
return map[type] || event["类型"] || event.type || '状态变更';
};
const formatTicketEventLocation = (event) => {
const type = getTicketEventType(event);
const stationName = event?.["售票站"] || event?.["发生站"] || event?.station_name || '';
const stationCode = event?.["站点编号"] || event?.station_code || '';
if (type === 'sale' || type === '售票') {
return stationName || '线上售票';
}
if (!stationName && !stationCode) return '---';
return [stationName, stationName && stationCode ? stationCode : ''].filter(Boolean).join(' ');
};
const formatTicketEventAttachment = (value) => {
if (value == null || value === '') return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) {
return value.map(formatTicketEventAttachment).filter(Boolean).join(' | ');
}
if (typeof value === 'object') {
return Object.entries(value)
.filter(([, item]) => item != null && item !== '')
.map(([key, item]) => `${key}: ${formatTicketEventAttachment(item)}`)
.filter(Boolean)
.join(' | ');
}
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}`);
}
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(' | ');
};
const formatLogDetail = (l) => {
if (!l.detail) return '';
if (typeof l.detail === 'string') return l.detail;
try {
return JSON.stringify(l.detail, null, 2);
} catch (e) {
return String(l.detail);
}
};
const getStationName = (code) => {
const s = stations.value.find(x => x.code === code);
return s ? (s.name || s.cn_name) : code;
};
const getStationInfo = (code) => {
return stations.value.find(x => x.code === code) || { code, name: code, en_name: '' };
};
const transferIndex = computed(() => {
const out = new Map();
const inn = new Map();
for (const s of (stations.value || [])) {
const from = String(s?.code || '').trim();
if (!from) continue;
if (!s?.transfer_enabled) continue;
const list = Array.isArray(s.transfer_to) ? s.transfer_to : [];
for (const t of list) {
const to = String((t && typeof t === 'object') ? (t.code || t.station || t.id || '') : t).trim();
if (!to || to === from) continue;
const o = out.get(from) || [];
o.push(to);
out.set(from, o);
const i = inn.get(to) || [];
i.push(from);
inn.set(to, i);
}
}
const uniq = (arr) => Array.from(new Set((arr || []).filter(Boolean)));
const getOut = (code) => uniq(out.get(code));
const getIn = (code) => uniq(inn.get(code));
return { getOut, getIn };
});
const stationLinesIndex = computed(() => {
const map = new Map();
for (const li of (lines.value || [])) {
const id = String(li?.id || '').trim();
const color = li?.color || '#93a2b7';
if (!id) continue;
const arr = Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : []);
for (const sc of arr) {
const code = String(sc || '').trim();
if (!code) continue;
const cur = map.get(code) || [];
cur.push({ id, color });
map.set(code, cur);
}
}
const uniqById = (arr) => {
const seen = new Set();
const out = [];
for (const x of (arr || [])) {
if (!x?.id || seen.has(x.id)) continue;
seen.add(x.id);
out.push({ id: x.id, color: x.color || '#93a2b7' });
}
return out;
};
return {
getLines: (code) => uniqById(map.get(String(code || '').trim()) || [])
};
});
const isTransferStation = (code) => {
const c = String(code || '').trim();
if (!c) return false;
const { getOut, getIn } = transferIndex.value;
return (getOut(c).length + getIn(c).length) > 0;
};
const getTransferLineBadges = (code) => {
const c = String(code || '').trim();
if (!c) return [];
const { getOut, getIn } = transferIndex.value;
const partners = [...getOut(c), ...getIn(c)];
const lineId = selectedLine.value?.id;
const badges = [];
const seen = new Set();
for (const p of partners) {
for (const li of stationLinesIndex.value.getLines(p)) {
if (lineId && li.id === lineId) continue;
if (seen.has(li.id)) continue;
seen.add(li.id);
badges.push(li);
}
}
return badges.slice(0, 6);
};
const getTransferTitleSuffix = (code) => {
const c = String(code || '').trim();
if (!c) return '';
const { getOut, getIn } = transferIndex.value;
const out = getOut(c);
const inn = getIn(c);
if (out.length === 0 && inn.length === 0) return '';
const fmt = (arr) => arr.map(x => `${getStationName(x)} (${x})`).join(', ');
let s = '\nXFER';
if (out.length > 0) s += `\nTo: ${fmt(out)}`;
if (inn.length > 0) s += `\nFrom: ${fmt(inn)}`;
return s;
};
const viewTicketDetails = async (ticket) => {
selectedTicket.value = null;
showTicketModal.value = true;
try {
const res = await requestJson(`/api/tickets/${encodeURIComponent(ticket.ticket_id)}`);
if (res && res.ok) selectedTicket.value = res;
} catch (e) {
console.error(e);
}
};
const closeTicketModal = () => {
showTicketModal.value = false;
selectedTicket.value = null;
};
/* 拖动调整站序 */
const onStationDragStart = (index) => {
if (fareMode.value) return;
draggingStationIndex.value = index;
};
const onStationDragOver = (index) => {
if (draggingStationIndex.value === null) return;
if (draggingStationIndex.value === index) return;
const list = selectedLine.value.stations;
const temp = list[draggingStationIndex.value];
list.splice(draggingStationIndex.value, 1);
list.splice(index, 0, temp);
draggingStationIndex.value = index;
};
const onStationDrop = async () => {
if (draggingStationIndex.value === null) return;
try {
await updateLineStations(selectedLine.value.id, selectedLine.value.stations);
} catch (e) {
alert(`保存站序失败:${e?.message || String(e)}`);
await fetchData();
}
draggingStationIndex.value = null;
};
/* 订单管理 */
const fetchOrders = async () => {
try {
const res = await requestJson('/api/orders');
if (res && res.ok) orders.value = res.orders;
} catch (e) { console.error(e); }
};
const deleteOrder = async (code) => {
if (!await appDialog.confirm({
title: '删除凭证',
message: `确定删除凭证 ${code} 吗?`,
confirmText: '确认删除'
})) return;
await runMutation(async () => {
await requestJson(`/api/orders/${encodeURIComponent(code)}`, { method: 'DELETE' }, { expectOk: true });
await fetchOrders();
});
};
const updateLineInfo = async () => {
if (!selectedLine.value) return;
await runMutation(async () => {
await requestJson(`/api/lines/${encodeURIComponent(selectedLine.value.id)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(selectedLine.value)
}, { expectOk: true });
});
};
const getFareText = (idx) => {
if (!selectedLine.value || !selectedLine.value.stations) return '';
const s1 = selectedLine.value.stations[idx];
const s2 = selectedLine.value.stations[idx + 1];
if (!s1 || !s2) return '';
const f = fares.value.find(x => (x.from === s1 && x.to === s2) || (x.from === s2 && x.to === s1));
if (!f) return '';
const reg = f.cost_regular ?? f.cost ?? 0;
const exp = f.cost_express ?? f.cost ?? 0;
return `¤${reg} / ¤${exp}`;
};
const fetchData = async () => {
try {
const safeFetch = (url, defaultVal) => requestJson(url).catch(e => {
console.error(`Fetch failed for ${url}`, e);
lastActionError.value = e?.message || String(e);
return defaultVal;
});
const safeFetchList = (url, key) => requestJson(url).then(d => d?.[key] || []).catch(e => {
console.error(`Fetch list failed for ${url}`, e);
lastActionError.value = e?.message || String(e);
return [];
});
const [s, l, f, c, t, lg, st, ord] = await Promise.all([
safeFetch('/api/stations', []),
safeFetch('/api/lines', []),
safeFetch('/api/fares', []),
safeFetch('/api/config', {}),
safeFetchList('/api/tickets', 'tickets'),
safeFetchList('/api/logs?max=50', 'logs'),
safeFetch('/api/stats/ticket/total', {}).then(d => d.total || {}),
safeFetchList('/api/orders', 'orders')
]);
stations.value = s;
lines.value = l;
fares.value = f;
Object.assign(config, c);
tickets.value = t;
logs.value = lg;
Object.assign(stats, st);
orders.value = ord;
if (selectedLine.value) {
const found = lines.value.find(l => l.id === selectedLine.value.id);
if (found) selectedLine.value = found;
}
loadFareMap();
} catch (e) {
console.error("Failed to fetch data", e);
}
};
const loadFareMap = async () => {
fareMapLoading.value = true;
fareMapError.value = '';
try {
const r = await fetch(`/api/public/fares/map/light?t=${Date.now()}`);
const svg = await r.text();
fareMapSvg.value = svg;
} catch (e) {
console.error("Failed to load fare map", e);
fareMapError.value = '加载失败';
} finally {
fareMapLoading.value = false;
}
};
const zoomFareMapIn = () => { fareMapScale.value = Math.min(3, Math.round((fareMapScale.value + 0.1) * 100) / 100); };
const zoomFareMapOut = () => { fareMapScale.value = Math.max(0.3, Math.round((fareMapScale.value - 0.1) * 100) / 100); };
const zoomFareMapReset = () => { fareMapScale.value = 1; };
const createStation = async () => {
if (!newStation.code || !newStation.name) return alert('请填写完整');
await runMutation(async () => {
await requestJson('/api/stations', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(newStation) }, { expectOk: true });
showAddStation.value = false;
Object.assign(newStation, { code: '', name: '', en_name: '' });
});
};
const deleteStation = async (code) => {
if (!await appDialog.confirm({
title: '删除站点',
message: '确定从库中删除该站点?这不会影响已存在于线路中的引用,但建议先从线路移除。',
confirmText: '确认删除'
})) return;
await runMutation(async () => {
await requestJson(`/api/stations/${encodeURIComponent(code)}`, { method: 'DELETE' }, { expectOk: true });
});
};
const createLine = async () => {
if (!newLine.id) return alert('请填写ID');
await runMutation(async () => {
await requestJson('/api/lines', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(newLine) }, { expectOk: true });
showAddLine.value = false;
Object.assign(newLine, { id: '', name: '', en_name: '', color: '#3366cc' });
});
};
const deleteLine = async (id) => {
if (!await appDialog.confirm({
title: '删除线路',
message: '确定删除此线路?',
confirmText: '确认删除'
})) return;
await runMutation(async () => {
await requestJson(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' }, { expectOk: true });
if (selectedLine.value && selectedLine.value.id === id) selectedLine.value = null;
});
};
/* 可视化编辑 */
const selectLine = (l) => {
selectedLine.value = l;
fareMode.value = false;
fareSelection.value = [];
};
const availableStations = computed(() => {
return stations.value;
});
const isStationInLine = (code) => {
return selectedLine.value && (selectedLine.value.stations || []).includes(code);
};
const addStationToLine = async () => {
if (!selectedLine.value) return;
const { code, name, en_name } = newStation;
if (!code || !name) return alert('请填写编号和名称');
await runMutation(async () => {
const existing = stations.value.find(s => s.code === code);
if (!existing) {
await requestJson('/api/stations', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ code, name, en_name })
}, { expectOk: true });
}
if (isStationInLine(code)) throw new Error('该站点已在此线路中');
const newStations = [...(selectedLine.value.stations || [])];
newStations.push(code);
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
Object.assign(newStation, { code: '', name: '', en_name: '' });
});
};
const removeStationFromLine = async (code) => {
if (!selectedLine.value) return;
const newStations = selectedLine.value.stations.filter(s => s !== code);
await updateLineStations(selectedLine.value.id, newStations);
};
const updateLineStations = async (lineId, stationsList, { skipFetchData } = {}) => {
const line = lines.value.find(l => l.id === lineId);
if (!line) return;
const updated = { ...line, stations: stationsList };
const r = await requestJson(`/api/lines/${encodeURIComponent(lineId)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(updated)
}, { expectOk: true });
if (!skipFetchData) await fetchData();
return r;
};
const openStationModal = (code) => {
const s = stations.value.find(x => x.code === code) || { code, name: code, en_name: '' };
stationFormOriginalCode.value = s.code || code;
stationForm.code = s.code || code;
stationForm.name = s.name || s.cn_name || '';
stationForm.en_name = s.en_name || s.enName || '';
stationForm.transfer_enabled = !!s.transfer_enabled;
stationForm.transfer_to = Array.isArray(s.transfer_to) ? [...s.transfer_to] : [];
showStationModal.value = true;
};
const closeStationModal = () => {
showStationModal.value = false;
};
const saveStationSettings = async () => {
if (!stationFormOriginalCode.value) return;
if (!stationForm.code) return alert('请填写站点编号');
const oldCode = String(stationFormOriginalCode.value || '').trim();
const newCode = String(stationForm.code || '').trim();
if (!newCode) return alert('请填写站点编号');
if (newCode !== oldCode) {
if (!await appDialog.confirm({
title: '修改站点编号',
message: `确定将站点编号从 ${oldCode} 修改为 ${newCode} 吗?这会同步更新线路、票价、凭证等引用。`,
confirmText: '确认修改'
})) return;
}
const payload = {
code: newCode,
name: stationForm.name,
en_name: stationForm.en_name,
transfer_enabled: !!stationForm.transfer_enabled,
transfer_to: stationForm.transfer_enabled ? (Array.isArray(stationForm.transfer_to) ? stationForm.transfer_to : []) : []
};
await runMutation(async () => {
await requestJson(`/api/stations/${encodeURIComponent(oldCode)}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true });
showStationModal.value = false;
});
};
const transferTargets = computed(() => {
const fromCode = stationForm.code;
return stations.value
.filter(s => s && s.code && s.code !== fromCode)
.map(s => ({ code: s.code, name: s.name || s.cn_name || s.code, en_name: s.en_name || s.enName || '' }));
});
const openLineModal = () => {
if (!selectedLine.value) return;
lineFormOriginalId.value = selectedLine.value.id;
lineForm.id = selectedLine.value.id || '';
lineForm.name = selectedLine.value.name || '';
lineForm.en_name = selectedLine.value.en_name || '';
lineForm.color = selectedLine.value.color || '#3366cc';
lineForm.stations = Array.isArray(selectedLine.value.stations) ? [...selectedLine.value.stations] : [];
showLineModal.value = true;
};
const closeLineModal = () => {
showLineModal.value = false;
};
const saveLineSettings = async () => {
if (!lineFormOriginalId.value) return;
const oldId = String(lineFormOriginalId.value || '').trim();
const newId = String(lineForm.id || '').trim();
if (!newId) return alert('请填写线路编号');
if (newId !== oldId) {
if (!await appDialog.confirm({
title: '修改线路编号',
message: `确定将线路编号从 ${oldId} 修改为 ${newId} 吗?`,
confirmText: '确认修改'
})) return;
}
const payload = {
id: newId,
name: lineForm.name,
en_name: lineForm.en_name,
color: lineForm.color,
stations: Array.isArray(lineForm.stations) ? [...lineForm.stations] : []
};
await runMutation(async () => {
await requestJson(`/api/lines/${encodeURIComponent(oldId)}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true });
showLineModal.value = false;
const next = lines.value.find(l => l.id === newId);
if (next) selectLine(next);
});
};
const handleStationClick = async (code) => {
if (stationEditMode.value) {
openStationModal(code);
return;
}
if (fareMode.value) {
const idx = fareSelection.value.indexOf(code);
if (idx >= 0) {
fareSelection.value.splice(idx, 1);
} else {
if (fareSelection.value.length < 2) {
fareSelection.value.push(code);
} else {
fareSelection.value.shift();
fareSelection.value.push(code);
}
}
if (fareSelection.value.length === 2) {
checkAndOpenFareModal();
}
} else {
if (await appDialog.confirm({
title: '移除站点',
message: `从线路 ${selectedLine.value.id} 中移除站点 ${getStationName(code)}`,
confirmText: '确认移除'
})) {
await removeStationFromLine(code);
}
}
};
watch(fareMode, (v) => {
if (v) stationEditMode.value = false;
});
watch(stationEditMode, (v) => {
if (v) {
fareMode.value = false;
fareSelection.value = [];
}
});
const isStationSelected = (code) => {
return fareSelection.value.includes(code);
};
const checkAndOpenFareModal = () => {
const [from, to] = fareSelection.value;
let f = fares.value.find(x => (x.from === from && x.to === to) || (x.from === to && x.to === from));
if (f) {
currentFare.exists = true;
currentFare.cost_regular = f.cost_regular || f.cost || 0;
currentFare.cost_express = f.cost_express || f.cost || 0;
} else {
currentFare.exists = false;
currentFare.cost_regular = 0;
currentFare.cost_express = 0;
}
showFareModal.value = true;
};
const closeFareModal = () => {
showFareModal.value = false;
fareSelection.value = [];
};
const saveCurrentFare = async () => {
const [from, to] = fareSelection.value;
await runMutation(async () => {
if (selectedLine.value) {
const stations = selectedLine.value.stations || [];
const idx1 = stations.indexOf(from);
const idx2 = stations.indexOf(to);
if (idx1 !== -1 && idx2 !== -1) {
const start = Math.min(idx1, idx2);
const end = Math.max(idx1, idx2);
if (end - start > 1) {
if (!await appDialog.confirm({
title: '区间票价应用',
message: `检测到所选站点间有 ${end - start - 1} 个中间站,是否将此票价应用到该区间内的每一段?`,
confirmText: '应用到整段',
cancelText: '仅保存当前区间'
})) {
await submitFare(from, to);
} else {
for (let k = start; k < end; k++) {
await submitFare(stations[k], stations[k+1]);
}
}
} else {
await submitFare(from, to);
}
}
}
closeFareModal();
});
};
const submitFare = async (from, to) => {
const payload = {
from, to,
cost_regular: currentFare.cost_regular,
cost_express: currentFare.cost_express
};
await requestJson('/api/fares', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true });
};
const deleteCurrentFare = async () => {
const [from, to] = fareSelection.value;
await runMutation(async () => {
await requestJson('/api/fares', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ from, to }) }, { expectOk: true });
closeFareModal();
});
};
const saveConfig = async () => {
await runMutation(async () => {
await requestJson('/api/config', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(config) }, { expectOk: true });
}, { successMessage: '保存成功' });
};
const exportData = () => {
window.open('/api/export', '_blank');
};
/* Socket连接状态 */
socket.on('connect', () => { connected.value = true; });
socket.on('disconnect', () => { connected.value = false; });
socket.on('stations:updated', (data) => {
stations.value = data;
loadFareMap();
});
socket.on('lines:updated', (data) => {
lines.value = data;
if (selectedLine.value) {
const updated = data.find(l => l.id === selectedLine.value.id);
if (updated) {
selectedLine.value = updated;
} else {
selectedLine.value = null;
}
}
loadFareMap();
});
socket.on('fares:updated', (data) => {
fares.value = data;
loadFareMap();
});
socket.on('config:updated', (data) => { Object.assign(config, data); loadFareMap(); });
socket.on('stats:ticket:updated', (item) => {
stats.sold_tickets += item.sold_tickets;
stats.revenue += item.revenue;
});
watch(currentView, () => { sidebarOpen.value = false; });
/* 失败 */
onMounted(() => {
fetchData();
loadFareMap();
window.addEventListener('mouseup', async () => {
if (draggingStationIndex.value !== null) {
if (selectedLine.value) {
try {
await requestJson(`/api/lines/${encodeURIComponent(selectedLine.value.id)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ ...(lines.value.find(l => l.id === selectedLine.value.id) || selectedLine.value), stations: selectedLine.value.stations })
}, { expectOk: true });
await fetchData();
} catch (e) {
alert(`保存站序失败:${e?.message || String(e)}`);
await fetchData();
}
}
draggingStationIndex.value = null;
}
});
});
/* 计算 */
const recentLogs = computed(() => logs.value);
const orderList = computed(() => orders.value);
const ticketList = computed(() => {
if (!ticketSearch.value) return tickets.value.slice(0, 50);
const q = ticketSearch.value.toLowerCase();
return tickets.value.filter(t =>
t.ticket_id.toLowerCase().includes(q) ||
(t.start && t.start.toLowerCase().includes(q)) ||
(t.terminal && t.terminal.toLowerCase().includes(q))
).slice(0, 50);
});
const exportFareMap = () => {
const svgData = fareMapSvg.value;
if (!svgData) return alert('地图尚未加载');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
const matchW = svgData.match(/width="([\d.]+)"/);
const matchH = svgData.match(/height="([\d.]+)"/);
const w = matchW ? Number(matchW[1]) : 1000;
const h = matchH ? Number(matchH[1]) : 1000;
const scale = 3;
canvas.width = Math.max(1, Math.round(w * scale));
canvas.height = Math.max(1, Math.round(h * scale));
const blob = new Blob([svgData], {type: 'image/svg+xml;charset=utf-8'});
const url = URL.createObjectURL(blob);
img.onload = () => {
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
const pngUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = pngUrl;
a.download = 'fare-map.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
img.src = url;
};
return {
currentView, viewTitle, connected, sidebarOpen,
stations, lines, fares, stats, config, recentLogs, ticketList,
orders, orderList, fetchOrders, deleteOrder,
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
/* 管理 */
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
selectLine, createLine, deleteLine, deleteStation, updateLineInfo, getFareText,
isStationInLine, addStationToLine, removeStationFromLine,
handleStationClick, isStationSelected,
onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex,
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,
/* 订单 */
fetchOrders, deleteOrder,
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventExtra, formatLogType, formatTrainType,
saveCurrentFare, deleteCurrentFare, closeFareModal,
saveConfig, exportData, exportFareMap,
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,
isTransferStation, getTransferTitleSuffix, getTransferLineBadges
};
}
}).mount('#app');
+148
View File
@@ -0,0 +1,148 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>票务查询</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12" />
</head>
<body class="public-search jr-public-page">
<div class="jr-public-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE铁路售票系统</strong>
<span>票务查询</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
<a href="https://ticket.fse-media.group/order" data-link="order">线上预定</a>
<a href="https://ticket.fse-media.group/search" data-link="search" class="is-active">车票查询</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
</nav>
</div>
</div>
<main class="jr-public-main">
<section class="jr-page-intro">
<span class="jr-kicker">TICKET SEARCH</span>
<h1>按票号、站点或日期快速查询票据</h1>
<p>输入完整票号、起终点、日期或关键词,左侧浏览结果,右侧查看票据详情与最近流转记录。</p>
</section>
<section class="jr-panel-card" style="margin-bottom:24px;">
<div class="jr-panel-headline">
<h2>检索条件</h2>
<span class="jr-panel-note">Ticket ID / Station / Date</span>
</div>
<div class="jr-search-form">
<input id="q" class="jr-search-input" type="text"
placeholder="输入完整票号 / 起点 / 终点 / 日期 (YYYY-MM-DD)" />
<button id="searchBtn" class="btn primary jr-search-button"><i class="fas fa-search"></i>
立即搜索</button>
</div>
</section>
<section class="jr-search-results">
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h3>结果列表</h3>
<span class="jr-panel-note">Search Results</span>
</div>
<div id="list" class="jr-scroll-box">
<div class="jr-center-empty">
<p>请输入关键词开始查询。</p>
</div>
</div>
</article>
<section id="detail-section">
<article class="jr-panel-card" style="margin-bottom:20px;">
<div class="jr-panel-headline">
<h3>车票详情</h3>
<span class="jr-panel-note">Ticket Overview</span>
</div>
<div id="detail">
<div class="jr-center-empty">
<p>从左侧选择一张车票以查看详情。</p>
</div>
</div>
</article>
<div class="jr-grid-two">
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h3>热门站点</h3>
<span class="jr-panel-note">Popular Stations</span>
</div>
<div id="popularStations" class="jr-popular-list"></div>
</article>
<article class="jr-panel-card">
<div class="jr-panel-headline">
<h3>热门路线</h3>
<span class="jr-panel-note">Popular Routes</span>
</div>
<div id="popularRoutes" class="jr-popular-list"></div>
</article>
</div>
</section>
</section>
<footer class="site-footer jr-footer-space">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-search.js?v=11"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html',
order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html',
search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html',
'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html',
'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
};
const homeLink = document.getElementById('homeLink');
const brandLink = document.getElementById('brandLink');
if (homeLink) homeLink.href = links.home;
if (brandLink) brandLink.href = links.home;
document.querySelectorAll('[data-link]').forEach((el) => {
const key = el.getAttribute('data-link');
if (links[key]) el.href = links[key];
});
});
</script>
</body>
</html>
+275
View File
@@ -0,0 +1,275 @@
(() => {
const $ = (sel) => document.querySelector(sel);
const listEl = $('#list');
const detailEl = $('#detail');
const qEl = $('#q');
const btn = $('#searchBtn');
const api = {
searchTickets: async (q) => {
const r = await fetch(`/api/public/tickets?q=${encodeURIComponent(q || '')}`);
return r.json();
},
ticketDetail: async (id) => {
const r = await fetch(`/api/public/tickets/${encodeURIComponent(id)}`);
return r.json();
},
popular: async () => {
const r = await fetch('/api/public/popular');
return r.json();
}
};
const formatTime = (value) => {
if (value == null || value === '') return '---';
let ts = Number(value);
if (Number.isFinite(ts)) {
if (ts > 0 && ts < 1000000000000) ts *= 1000;
const date = new Date(ts);
if (!Number.isNaN(date.getTime())) {
return date.toLocaleString('zh-CN', {
hour12: false
});
}
}
const date = new Date(value);
if (!Number.isNaN(date.getTime())) {
return date.toLocaleString('zh-CN', {
hour12: false
});
}
return String(value);
};
const formatTrainType = (type) => {
if (!type) return '普通';
const t = type.toLowerCase();
if (t === 'local') return '普通';
if (t === 'ltd.exp' || t === 'express') return '特急';
return type;
};
const getTicketId = (obj) => (obj && (obj.ticket_id || obj["车票编号"] || obj.id)) || '';
const isValidStatus = (status) => {
const s = String(status || '').toLowerCase();
return s === '有效' || s === 'valid' || s === 'unused' || s === 'active' || s.includes('有效') || s.includes('未使用');
};
const formatStatusText = (status) => {
const s = String(status || '').toLowerCase();
if (s === '有效' || s === 'valid' || s === 'unused' || s === 'active' || s.includes('有效') || s.includes('未使用')) return '有效';
if (s === '已使用' || s === 'used') return '已使用';
if (!s) return '未知';
if (s === 'expired') return '失效';
if (s === 'refunded') return '已退票';
return String(status);
};
const getEventType = (event) => String(event.type || event["类型"] || '').toLowerCase();
const formatEventTitle = (event) => {
const type = getEventType(event);
const action = String(event.action || event["动作"] || '').toLowerCase();
if (type === 'sale' || type === '售票') return '售票成功';
if (type === 'entry' || action === 'entry') return '进站成功';
if (type === 'exit' || action === 'exit') return '出站成功';
if (type === 'status' || type === '状态') return '状态变更';
return event.type || event["类型"] || '状态更新';
};
const formatEventLocation = (event) => {
const type = getEventType(event);
const stationName = event.station_name || event["售票站"] || event["发生站"] || '';
const stationCode = event.station_code || event["站点编号"] || '';
if (type === 'sale' || type === '售票') {
return stationName || '线上售票';
}
if (!stationName && !stationCode) return '---';
return [stationName, stationName && stationCode ? stationCode : ''].filter(Boolean).join(' ');
};
const formatEventExtra = (event) => {
const type = getEventType(event);
if (type === 'sale' || type === '售票') {
const amount = event.amount ?? event["售票额"];
if (amount != null && amount !== '') return `票价:¥ ${amount}`;
}
const stationEn = event.station_en || event["站点英文"] || '';
const deviceId = event["设备编号"] || event.device_id || '';
if (stationEn && deviceId) return `${stationEn} (${deviceId})`;
if (deviceId) return `设备:${deviceId}`;
return stationEn;
};
function renderList(items) {
listEl.innerHTML = '';
if (items.length === 0) {
listEl.innerHTML = '<div class="jr-center-empty"><p>未找到匹配结果。</p></div>';
return;
}
items.forEach(it => {
const id = it.ticket_id || it["车票编号"] || '';
const row = document.createElement('div');
row.className = 'jr-ticket-row';
const overview = it.overview || it["概览"] || null;
const startName = overview ? (overview.start_name || overview["起点"]) : (it.start_name || it["起点"] || '---');
const terminalName = overview ? (overview.terminal_name || overview["终点"]) : (it.terminal_name || it["终点"] || '---');
row.innerHTML = `
<div class="jr-ticket-row-head">
<span class="jr-ticket-id mono">${id}</span>
<i class="fas fa-chevron-right text-muted"></i>
</div>
<div class="jr-ticket-route">
${startName}${terminalName}
</div>
`;
row.onclick = () => loadDetail(id);
listEl.appendChild(row);
});
}
function openDetail(id) {
if (window.location.hostname.includes('fse-media.group')) {
window.open(`https://ticket.fse-media.group/detail/${id}`, '_blank');
} else {
window.open(`/${id}`, '_blank');
}
}
function renderDetail(d) {
const ov = d.overview || d["概览"] || {};
const evs = d.events || d["事件"] || [];
const id = getTicketId(d) || getTicketId(ov);
const stRaw = ov.status || ov["状态"] || d.status || d["状态"] || '';
const statusText = formatStatusText(stRaw);
const statusClass = isValidStatus(stRaw) ? 'jr-status-valid' : (statusText === '已使用' ? 'jr-status-used' : 'jr-status-expired');
detailEl.innerHTML = `
<div class="jr-ticket-preview" onclick="openTicketDetail('${id}')" style="cursor:pointer;" title="点击查看电子票看板">
<div class="jr-ticket-row-head">
<span class="jr-ticket-id mono">${id} <i class="fas fa-external-link-alt" style="font-size:0.8em; margin-left:4px"></i></span>
<span class="jr-status-pill ${statusClass}">${statusText}</span>
</div>
<div class="jr-route-board">
<div class="jr-station-block">
<div class="jr-station-line">
<span class="jr-station-title">${ov.start_name || ov["起点"] || '---'}</span>
<span class="jr-station-code">${ov.start_code || ov["起点编号"] || ''}</span>
</div>
<div class="jr-station-en">${ov.start_en || ov["起点英文"] || ''}</div>
</div>
<div class="jr-route-track"><i class="fas fa-train"></i></div>
<div class="jr-station-block is-end">
<div class="jr-station-line">
<span class="jr-station-title">${ov.terminal_name || ov["终点"] || '---'}</span>
<span class="jr-station-code">${ov.terminal_code || ov["终点编号"] || ''}</span>
</div>
<div class="jr-station-en">${ov.terminal_en || ov["终点英文"] || ''}</div>
</div>
</div>
<div class="jr-meta-grid">
<div class="jr-meta-item"><span>车型</span><strong>${formatTrainType(ov.train_type || ov["车型"])}</strong></div>
<div class="jr-meta-item"><span>乘次</span><strong>${ov.trips_total ?? ov["总乘次"] ?? 0}</strong></div>
<div class="jr-meta-item"><span>票价</span><strong>${ov.amount ?? ov["金额"] ?? 0}</strong></div>
<div class="jr-meta-item"><span>更新</span><strong>${formatTime(ov.last_update_ts ?? ov["上次更新时间"])}</strong></div>
</div>
</div>
<div class="jr-panel-headline" style="margin:20px 0 14px;">
<h3>流转记录</h3>
<span class="jr-panel-note">Recent Events</span>
</div>
<div class="jr-history-list">
${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>
</div>
<div class="jr-history-desc">
<div>${formatEventLocation(e)}</div>
<div style="margin-top:4px;">${formatEventExtra(e) || '---'}</div>
</div>
</div>
`).join('')}
</div>
`;
}
async function loadDetail(id) {
detailEl.innerHTML = '<div class="jr-center-empty"><p>正在加载详情...</p></div>';
try {
const d = await api.ticketDetail(id);
if (d && getTicketId(d) && (d.overview || d["概览"])) {
renderDetail(d);
} else {
detailEl.innerHTML = '<div class="jr-center-empty"><p>未找到车票详情。</p></div>';
}
// Update URL without reload
const newUrl = window.location.origin + window.location.pathname + '?id=' + encodeURIComponent(id);
window.history.pushState({ path: newUrl }, '', newUrl);
} catch (e) {
detailEl.innerHTML = '<div class="jr-center-empty"><p>加载详情失败。</p></div>';
}
}
async function doSearch() {
const q = (qEl.value || '').trim();
listEl.innerHTML = '<div class="jr-center-empty"><p>正在搜索...</p></div>';
try {
const d = await api.searchTickets(q);
renderList(d);
} catch (e) {
listEl.innerHTML = '<div class="jr-center-empty"><p>搜索失败。</p></div>';
}
}
async function loadPopular() {
try {
const d = await api.popular();
const ps = $('#popularStations');
const pr = $('#popularRoutes');
ps.innerHTML = d.topStations.map(s => `
<div class="jr-popular-item">
<span><strong>${s.name}</strong></span>
<span>${s.count} 次</span>
</div>
`).join('');
pr.innerHTML = d.topRoutes.map(r => `
<div class="jr-popular-item">
<span><strong>${r.from}${r.to}</strong></span>
<span>${r.count} 次</span>
</div>
`).join('');
} catch (_) { }
}
btn.onclick = doSearch;
qEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
window.openTicketDetail = openDetail;
const sp = new URLSearchParams(location.search);
const qid = sp.get('id');
if (qid) {
qEl.value = qid;
loadDetail(qid);
doSearch();
} else {
doSearch();
}
loadPopular();
})();
+139
View File
@@ -0,0 +1,139 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>凭证详情</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=12" />
</head>
<body class="public-search jr-public-page">
<div class="jr-public-shell">
<header class="jr-topbar">
<div class="jr-topbar-inner">
<a href="https://ticket.fse-media.group" id="homeLink" class="jr-top-link">
<i class="fas fa-arrow-left"></i>
<span>返回首页</span>
</a>
<div class="jr-top-status is-checking" data-server-status-root>
<span class="jr-top-status-label">服务器状态</span>
<span class="jr-top-status-dot"></span>
<span class="jr-top-status-value" data-server-status-value>检测中</span>
</div>
</div>
</header>
<div class="jr-brandbar">
<div class="jr-brandbar-inner">
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE铁路售票系统</strong>
<span>凭证详情</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
<a href="https://ticket.fse-media.group/home.html" data-link="home">首页</a>
<a href="https://ticket.fse-media.group/order" data-link="order" class="is-active">线上预定</a>
<a href="https://ticket.fse-media.group/search" data-link="search">车票查询</a>
<a href="https://ticket.fse-media.group/ic-card/search" data-link="card-search">IC 卡查询</a>
<a href="https://ticket.fse-media.group/ic-card/order" data-link="card-order">线上购卡</a>
</nav>
</div>
</div>
<main class="jr-public-main">
<section class="jr-page-intro">
<span class="jr-kicker">ORDER VOUCHER</span>
<h1>查看订单凭证并准备站内兑票</h1>
<p>生成后的凭证码可用于游戏内售票机兑票。请妥善保存页面中的凭证信息,避免凭证码遗失。</p>
</section>
<div id="loading" class="jr-panel-card">
<div class="jr-center-empty">
<p>正在加载凭证信息...</p>
</div>
</div>
<div id="error" class="jr-panel-card" style="display:none;">
<div class="jr-center-empty">
<h2 style="margin:0 0 10px;">凭证不存在</h2>
<p id="errorMsg">系统未找到该凭证信息。</p>
</div>
</div>
<div id="content" class="jr-voucher-layout" style="display:none;">
<section class="jr-voucher-card">
<div class="jr-panel-headline">
<h2>订单凭证</h2>
<span class="jr-panel-note" id="vStatusTop"></span>
</div>
<div class="jr-voucher-band">
<span class="jr-kicker">VOUCHER CODE</span>
<div class="jr-voucher-code" id="vCodeTop"></div>
</div>
<div class="jr-route-board" style="margin-top:0;">
<div class="jr-station-block">
<div class="jr-station-line">
<span class="jr-station-title vStartName"></span>
<span class="jr-station-code vStartCode"></span>
</div>
<div class="jr-station-en vStartEn"></div>
</div>
<div class="jr-route-track"><i class="fas fa-train"></i></div>
<div class="jr-station-block is-end">
<div class="jr-station-line">
<span class="jr-station-title vTermName"></span>
<span class="jr-station-code vTermCode"></span>
</div>
<div class="jr-station-en vTermEn"></div>
</div>
</div>
<div class="jr-meta-grid">
<div class="jr-meta-item"><span>车型</span><strong id="vTypeTop"></strong></div>
<div class="jr-meta-item"><span>乘次</span><strong id="vTripsTop"></strong></div>
<div class="jr-meta-item"><span>乘车日期</span><strong id="vDateTop"></strong></div>
<div class="jr-meta-item"><span>票价</span><strong id="vPriceTop"></strong></div>
</div>
</section>
<aside class="jr-voucher-card jr-redeem-card">
<div class="jr-panel-headline">
<h3>兑票操作</h3>
<span class="jr-panel-note" id="vStatusTag"></span>
</div>
<div class="jr-redeem-summary">
<span class="jr-kicker">REDEEM CODE</span>
<div class="jr-redeem-code-row">
<span class="jr-redeem-code-label">兑票码</span>
<strong class="jr-redeem-code-value" id="vCode"></strong>
</div>
<p class="jr-redeem-copy">请在游戏内任意售票机选择线上订票后输入该凭证码完成兑票。</p>
</div>
<ol class="jr-guide-list jr-redeem-steps">
<li>前往游戏内任意售票机,选择“线上订票”。</li>
<li>输入上方兑票码并确认订单信息。</li>
<li>完成出票后,该凭证会自动变为已使用。</li>
</ol>
<div class="jr-action-row">
<a href="ticket-order.html" data-link="order" class="btn jr-secondary-btn"><i class="fas fa-arrow-left"></i>
返回预定</a>
<button class="btn primary jr-search-button" id="copyBtn"><i class="fas fa-copy"></i> 复制凭证码</button>
</div>
</aside>
</div>
<footer class="site-footer jr-footer-space">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/token.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ai-assistant.js?v=6"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html', order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html', search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html', 'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html', 'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
}; const homeLink = document.getElementById('homeLink'); const brandLink = document.getElementById('brandLink'); if (homeLink) homeLink.href = links.home; if (brandLink) brandLink.href = links.home; document.querySelectorAll('[data-link]').forEach((el) => { const key = el.getAttribute('data-link'); if (links[key]) el.href = links[key]; });
});</script>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
(() => {
const params = new URLSearchParams(location.search);
const code = params.get('code');
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const errorMsg = document.getElementById('errorMsg');
const content = document.getElementById('content');
const setTextAll = (selector, value) => {
document.querySelectorAll(selector).forEach(el => { el.textContent = value ?? ''; });
};
const setTextById = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value ?? '';
};
const applyStatus = (el, text, cls) => {
if (!el) return;
el.textContent = text;
el.className = cls;
};
const formatTrainType = (type) => {
const t = String(type || '').toLowerCase();
if (t === 'express' || t === 'ltd.exp' || t === '特急') return '特急';
return '普通';
};
if (!code) {
loading.style.display = 'none';
error.style.display = 'block';
errorMsg.textContent = '无效的凭证码';
return;
}
fetch(`/api/public/orders/${encodeURIComponent(code)}`)
.then(r => r.json())
.then(res => {
if (res.ok && res.data) {
loading.style.display = 'none';
error.style.display = 'none';
const d = res.data;
content.style.display = 'flex';
document.getElementById('vCode').textContent = d.code;
const codeTop = document.getElementById('vCodeTop');
if (codeTop) codeTop.textContent = d.code;
const statusTag = document.getElementById('vStatusTag');
const statusTop = document.getElementById('vStatusTop');
if (d.consumed) {
applyStatus(statusTag, '已使用', 'jr-status-pill jr-status-expired');
applyStatus(statusTop, '已使用', 'jr-status-pill jr-status-expired');
} else {
applyStatus(statusTag, '可使用', 'jr-status-pill jr-status-valid');
applyStatus(statusTop, '可使用', 'jr-status-pill jr-status-valid');
}
const startName = d.start_name || d.start || '';
const startEn = d.start_en || '';
const terminalName = d.terminal_name || d.terminal || '';
const terminalEn = d.terminal_en || '';
const trips = d.trips ?? '';
const type = formatTrainType(d.train_type);
const rideDate = d.ride_date ?? '';
const price = d.price ?? '';
setTextAll('.vStartName', startName);
setTextAll('.vStartEn', startEn);
setTextAll('.vStartCode', d.start || '');
setTextAll('.vTermName', terminalName);
setTextAll('.vTermEn', terminalEn);
setTextAll('.vTermCode', d.terminal || '');
setTextById('vCode', d.code);
setTextById('vTypeTop', type);
setTextById('vTripsTop', trips);
setTextById('vDateTop', rideDate);
setTextById('vPriceTop', price);
document.getElementById('copyBtn').onclick = () => {
navigator.clipboard.writeText(d.code).then(() => {
alert('已复制凭证码');
});
};
} else {
loading.style.display = 'none';
error.style.display = 'flex';
let msg = res.error || '未找到凭证信息';
if (msg.includes('not found')) msg = '凭证不存在';
errorMsg.textContent = msg;
}
})
.catch(e => {
loading.style.display = 'none';
error.style.display = 'flex';
errorMsg.textContent = '加载失败: ' + e.message;
});
})();