初始提交
This commit is contained in:
@@ -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 |
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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();
|
||||
})();
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>© 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>
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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>`;
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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>';
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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();
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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(); };
|
||||
}
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user