From fc4fe5101c98dac55a214210b7835c60cf573d56 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Fri, 10 Jul 2026 00:08:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(ticket):=20=E6=96=B0=E5=A2=9E=E5=8D=95?= =?UTF-8?q?=E7=A8=8B=E7=A5=A8=E8=B4=AD=E7=A5=A8=E4=B8=8E=E6=A3=80=E7=A5=A8?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E4=B8=8E=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端新增单程票购票界面,支持票种切换与区间选择、价格预览 - 完善后端检票接口,添加单程票的校验与处理逻辑 - 优化售票机脚本的车票类型映射,新增单程车票支持 - 简化前端售票日志输出,移除冗余站点信息字段 - 新增服务层付费区间计算字段,优化公共接口返回数据 --- server/routes/api.js | 32 ++++++--- server/routes/public.js | 10 ++- server/services/logic.js | 5 +- ticketmachine.lua | 12 +++- web/index.js | 4 +- web/ticket-order.html | 87 +++++++++++++++++------- web/ticket-order.js | 142 ++++++++++++++++++++++++++++++++++++--- web/ticket-route.js | 4 +- web/ticket-search.js | 4 +- 9 files changed, 246 insertions(+), 54 deletions(-) diff --git a/server/routes/api.js b/server/routes/api.js index 38bab84..c71fa8f 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -1336,18 +1336,23 @@ router.post('/tickets/check', async (req, res) => { toFiniteNumberOrUndef(hintTripsRemaining); const tripsRemainEffective = (tripsRemain0 == null) ? tripsTotal : tripsRemain0; + const isSingleTicket = String(cur.train_type || '').toLowerCase() === 'single'; + if (action === 'entry') { - if (!matchesStation(start)) return deny('wrong_station'); + if (!isSingleTicket && !matchesStation(start)) return deny('wrong_station'); if (entered && !exited) return deny('already_entered'); - const ev = { ts, type: 'check', ticket_id, action, result: 'pass', station_code: start, station_name: resolveStationName(start), device }; + const entryCode = isSingleTicket ? (station_code_raw || start) : start; + + const ev = { ts, type: 'check', ticket_id, action, result: 'pass', station_code: entryCode, station_name: resolveStationName(entryCode), device }; await DataService.appendTicketEvent(ev); await DataService.upsertTicketIndex({ ticket_id, entered: true, exited: false, last_action: 'entry', - last_station_code: start, + last_station_code: entryCode, + start: isSingleTicket ? entryCode : cur.start, last_result: 'pass', last_reason: '', last_event: 'check', @@ -1357,22 +1362,31 @@ router.post('/tickets/check', async (req, res) => { }); io.emit('ticket:check', ev); appendReqLog(req, { category: 'device', type: 'ticket_check', detail: ev }); - return res.json({ ok: true, ticket_id, action, result: 'pass', station_code: start, trips_remaining: tripsRemainEffective, destroy_ticket: false }); + return res.json({ ok: true, ticket_id, action, result: 'pass', station_code: entryCode, trips_remaining: tripsRemainEffective, destroy_ticket: false }); + } + + const exitCode = station_code_raw || terminal; + if (isSingleTicket) { + const fareInfo = LogicService.computeFareBoth(cur.start, exitCode); + if (!fareInfo || fareInfo.paid_segments !== 1) { + return deny('非单区间'); + } + } else { + if (!matchesStation(terminal)) return deny('wrong_station'); } - if (!matchesStation(terminal)) return deny('wrong_station'); if (!entered) return deny('not_entered'); if (exited) return deny('already_exited'); const newTripsRemaining = Math.max(0, (Number(tripsRemainEffective) || 0) - 1); - const shouldSetUsed = newTripsRemaining <= 0; - const ev = { ts, type: 'check', ticket_id, action, result: 'pass', station_code: terminal, station_name: resolveStationName(terminal), device, trips_remaining: newTripsRemaining }; + const shouldSetUsed = isSingleTicket ? true : (newTripsRemaining <= 0); + const ev = { ts, type: 'check', ticket_id, action, result: 'pass', station_code: exitCode, station_name: resolveStationName(exitCode), device, trips_remaining: newTripsRemaining }; await DataService.appendTicketEvent(ev); await DataService.upsertTicketIndex({ ticket_id, exited: true, last_action: 'exit', - last_station_code: terminal, + last_station_code: exitCode, last_result: 'pass', last_reason: '', last_event: 'check', @@ -1383,7 +1397,7 @@ router.post('/tickets/check', async (req, res) => { }); io.emit('ticket:check', ev); appendReqLog(req, { category: 'device', type: 'ticket_check', detail: ev }); - return res.json({ ok: true, ticket_id, action, result: 'pass', station_code: terminal, trips_remaining: newTripsRemaining, destroy_ticket: shouldSetUsed }); + return res.json({ ok: true, ticket_id, action, result: 'pass', station_code: exitCode, trips_remaining: newTripsRemaining, destroy_ticket: shouldSetUsed }); }); router.post('/tickets/status', async (req, res) => { diff --git a/server/routes/public.js b/server/routes/public.js index 1e38931..d5e0d4e 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -714,13 +714,19 @@ router.get('/tickets/:id', async (req, res, next) => { action: e.action, ts: e.ts, ticket_id: e.ticket_id, - station_name: nameFor(e.station_code), + station_name: e.station_name || nameFor(e.station_code), station_code: e.station_code || '', station_en: e.station_code ? enNameFor(e.station_code) : (e.station_en || ''), trips_remaining: e.trips_remaining ?? e.rides_remaining ?? null }; + } else if (e.type === 'check') { + return { + ...e, + station_name: e.station_name || nameFor(e.station_code), + station_en: e.station_code ? enNameFor(e.station_code) : (e.station_en || '') + }; } - return { raw: e }; + return e; }); appendReqLog(req, { category: 'public', type: 'ticket_detail', detail: { ticket_id: id, ok: true } }); diff --git a/server/services/logic.js b/server/services/logic.js index 6c20afd..a92f46e 100644 --- a/server/services/logic.js +++ b/server/services/logic.js @@ -231,7 +231,8 @@ const LogicService = { regular_path: [src], express_path: [src], regular_transfers: [], - express_transfers: [] + express_transfers: [], + paid_segments: 0 }; } @@ -245,6 +246,7 @@ const LogicService = { const totals = accumulateFareAlongPath(graph, result.path); if (!totals) return null; const transfers = computeTransfersAlongPath(graph, result.path); + const dstCost = result.dist.get(dst); return { regular: Math.round(Number(totals.regular ?? 0) || 0), @@ -253,6 +255,7 @@ const LogicService = { express_path: result.path, regular_transfers: transfers, express_transfers: transfers, + paid_segments: dstCost ? dstCost.paidSegments : Infinity }; } catch (_) { return null; } }, diff --git a/ticketmachine.lua b/ticketmachine.lua index 875bc32..039f84d 100644 --- a/ticketmachine.lua +++ b/ticketmachine.lua @@ -250,6 +250,9 @@ local function normalizeTrainTypeLabel(v) if s == 'express' or s == 'limited_express' or s == 'limited express' then return 'Express' end + if s == 'single' then + return 'single' + end return 'Local' end @@ -2090,10 +2093,13 @@ local function showDone() local toNameEnArg = tostring(toNameEn or '???') local fromNameCnUArg = tostring(fromNameCnU or '') local toNameCnUArg = tostring(toNameCnU or '') + local issueArgsType = 'local' + if state.trainType == 'Express' then issueArgsType = 'limited_express' + elseif state.trainType == 'single' then issueArgsType = 'single' end local issueArgs = { start_name_en = fromNameEnArg, terminal_name_en = toNameEnArg, - type = (state.trainType == 'Express') and 'limited_express' or 'local', + type = issueArgsType, rides = rides, cost = cost, start_station = startStationArg, @@ -2113,7 +2119,9 @@ local function showDone() ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000) } - local apiType = (state.trainType == 'Express') and 'limited_express' or 'local' + local apiType = 'local' + if state.trainType == 'Express' then apiType = 'limited_express' + elseif state.trainType == 'single' then apiType = 'single' end local localGeneratedTicketId = generateTicketId() local okIssueTicket, issuedTicketId, issueMethod = issueTicketFromPeripheral( fromNameEnArg, diff --git a/web/index.js b/web/index.js index c77d682..cc1d6d6 100644 --- a/web/index.js +++ b/web/index.js @@ -353,9 +353,7 @@ createApp({ if (type === 'sale' || type === '售票') { const cost = ev?.cost ?? ev?.price ?? ev?.amount ?? ev?.["票价"] ?? ev?.["售票额"] ?? '0'; - const code = ev?.station_code || ev?.["站点编号"] || '---'; - const name = ev?.station_name || ev?.["售票站"] || ev?.["发生站"] || (typeof getStationName === 'function' && code !== '---' ? getStationName(code) : code); - return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};票价: ${cost};时间: ${timeStr}`; + return `机器ID: ${deviceId};票价: ${cost};时间: ${timeStr}`; } if (type === 'entry' || action === 'entry') { const code = ev?.station_code || ev?.["站点编号"] || '---'; diff --git a/web/ticket-order.html b/web/ticket-order.html index cb2d91e..078ccff 100644 --- a/web/ticket-order.html +++ b/web/ticket-order.html @@ -47,32 +47,73 @@
-
-
- -

选择乘车区间

+
+
+ 票种 + 请选择购买的车票类型
-

点击下方线路图中的站点,依次选择起点与终点。

-
-
-
加载线路图中...
-
-
-
- 出发站 - 请在上方地图选择 - -
-
- -
-
- 到达站 - 请在上方地图选择 - +
+ +
-
+ +
+
+
+ +

选择乘车区间

+
+

点击下方线路图中的站点,依次选择起点与终点。

+
+
+
加载线路图中...
+
+
+
+ 出发站 + 请在上方地图选择 + +
+
+ +
+
+ 到达站 + 请在上方地图选择 + +
+
+
+ + + +
车型 diff --git a/web/ticket-order.js b/web/ticket-order.js index df32b01..60b3a76 100644 --- a/web/ticket-order.js +++ b/web/ticket-order.js @@ -529,10 +529,45 @@ async function previewPrice(force = false){ const seq = ++previewSeq; - const from = (fromEl.value||'').trim(); - const to = (toEl.value||'').trim(); + const ticketClass = document.querySelector('input[name="ticketClass"]:checked').value; const type = document.querySelector('input[name="trainType"]:checked').value; const trips = normalizeTrips(); + + if (ticketClass === 'single') { + const lineName = document.getElementById('singleSegmentSelection').value; + const priceStr = document.getElementById('singleSegmentPrice').value; + if (!lineName || !priceStr) { + priceBox.innerHTML = '

请选择单程区间

'; + return; + } + const cost = Number(priceStr) * trips; + priceBox.innerHTML = ` +
+ 线路 + ${lineName} +
+
+ 区间 + 任意单区间 +
+
+ 车型 + ${type} +
+
+ 单价 + $${priceStr} +
+
+ 总价 + $${cost} +
+ `; + return; + } + + const from = (fromEl.value||'').trim(); + const to = (toEl.value||'').trim(); if(!from || !to){ priceBox.innerHTML = '

请选择起点与终点

'; return; } const previewKey = `${from}|${to}|${type}|${trips}`; if (!force && previewKey === lastPreviewKey) return; @@ -608,18 +643,31 @@ // Create Order with auto-preview async function createOrder(){ - const from = (fromEl.value||'').trim(); - const to = (toEl.value||'').trim(); + const ticketClass = document.querySelector('input[name="ticketClass"]:checked').value; 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); + let payload = null; + if (ticketClass === 'single') { + const lineName = document.getElementById('singleSegmentSelection').value; + const priceStr = document.getElementById('singleSegmentPrice').value; + if (!lineName || !priceStr) { + alert('请选择单程区间'); + return; + } + payload = { start: lineName, terminal: 'SINGLE_SEGMENT', train_type: 'single', price: Number(priceStr), trips, ride_date }; + } else { + const from = (fromEl.value||'').trim(); + const to = (toEl.value||'').trim(); + if(!from || !to){ alert('请完整填写信息'); return; } + + // Auto-fetch price before creating + await previewPrice(true); + payload = { start: from, terminal: to, train_type: type, trips, ride_date }; + } 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){ @@ -656,8 +704,86 @@ document.querySelectorAll('input[name="trainType"]').forEach(el => { el.onchange = () => { if(fromEl.value && toEl.value) previewPrice(); }; }); + document.querySelectorAll('input[name="ticketClass"]').forEach(r => r.addEventListener('change', toggleTicketClass)); if (tripsEl) { tripsEl.oninput = () => { if(fromEl.value && toEl.value) previewPrice(); }; tripsEl.onchange = () => { normalizeTrips(); if(fromEl.value && toEl.value) previewPrice(); }; } + + function toggleTicketClass() { + const isSingle = document.querySelector('input[name="ticketClass"]:checked').value === 'single'; + if (isSingle) { + document.getElementById('normalTicketView').style.display = 'none'; + document.getElementById('singleTicketView').style.display = 'block'; + renderSingleSegments(); + } else { + document.getElementById('normalTicketView').style.display = 'block'; + document.getElementById('singleTicketView').style.display = 'none'; + } + previewPrice(true); + } + + async function renderSingleSegments() { + const listEl = document.getElementById('singleSegmentList'); + if (listEl.innerHTML.includes('jr-result-row')) return; // Already rendered + listEl.innerHTML = '
加载区间数据...
'; + try { + const [lines, fares] = await Promise.all([ + fetch('/api/public/lines').then(r=>r.json()), + fetch('/api/public/fares').then(r=>r.json()) + ]); + + const type = document.querySelector('input[name="trainType"]:checked').value; + const segments = []; + + lines.forEach(line => { + const stops = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []); + const lineFares = new Set(); + for (let i = 0; i < stops.length - 1; i++) { + const a = stops[i], b = stops[i+1]; + const f = fares.find(x => (x.from === a && x.to === b) || (x.from === b && x.to === a)); + if (f) { + const cost = type === 'Express' ? (f.cost_express ?? f.cost ?? 0) : (f.cost_regular ?? f.cost ?? 0); + lineFares.add(Number(cost)); + } + } + lineFares.forEach(cost => { + segments.push({ + lineName: line.name || line.线路名称 || '未知线路', + color: line.color || line.颜色 || '#93a2b7', + cost + }); + }); + }); + + if (segments.length === 0) { + listEl.innerHTML = '

暂无单程区间数据

'; + return; + } + + let html = ''; + segments.forEach((seg, idx) => { + html += ` + + `; + }); + listEl.innerHTML = html; + } catch (e) { + listEl.innerHTML = '
加载区间数据失败
'; + } + } + + window.selectSingleSegment = (lineName, cost) => { + document.getElementById('singleSegmentSelection').value = lineName; + document.getElementById('singleSegmentPrice').value = cost; + previewPrice(true); + }; })(); diff --git a/web/ticket-route.js b/web/ticket-route.js index 3f03f56..950a576 100644 --- a/web/ticket-route.js +++ b/web/ticket-route.js @@ -233,9 +233,7 @@ createApp({ if (type === 'sale' || type === '售票') { const cost = ev?.cost ?? ev?.price ?? ev?.amount ?? ev?.["票价"] ?? ev?.["售票额"] ?? '0'; - const code = ev?.station_code || ev?.["站点编号"] || '---'; - const name = ev?.station_name || ev?.["售票站"] || ev?.["发生站"] || (typeof getStationName === 'function' && code !== '---' ? getStationName(code) : code); - return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};票价: ${cost};时间: ${timeStr}`; + return `机器ID: ${deviceId};票价: ${cost};时间: ${timeStr}`; } if (type === 'entry' || action === 'entry') { const code = ev?.station_code || ev?.["站点编号"] || '---'; diff --git a/web/ticket-search.js b/web/ticket-search.js index 3070bcf..3e00135 100644 --- a/web/ticket-search.js +++ b/web/ticket-search.js @@ -115,9 +115,7 @@ if (type === 'sale' || type === '售票') { const cost = ev.cost ?? ev.price ?? ev.amount ?? ev["票价"] ?? ev["售票额"] ?? '0'; - const code = ev.station_code || ev["站点编号"] || '---'; - const name = ev.station_name || ev["售票站"] || ev["发生站"] || (typeof getStationName === 'function' && code !== '---' ? getStationName(code) : code); - return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};票价: ${cost};时间: ${timeStr}`; + return `机器ID: ${deviceId};票价: ${cost};时间: ${timeStr}`; } if (type === 'entry' || action === 'entry') { const code = ev.station_code || ev["站点编号"] || '---';