简介:这是一套面向Web开发初学者与课程设计学习者的火车票订票系统完整源码,基于HTML、CSS与JavaScript三大前端技术,并结合Servlet、JSP等Java Web组件实现前后端联动,可用于理解在线订票平台的查询、登录注册与后台管理等典型业务场景。压缩包共405个文件,约7.46MB,其中png、gif、jpg等图片资源用于页面装饰与图标,css与js文件负责样式布局和表单校验、AJAX异步请求等交互逻辑,java、class、jsp及jar文件构成服务端处理与动态页面生成部分,另含sql数据库脚本用于存储车次、站点、票务与用户数据。资源已有461人学习下载,适合作为课程设计或毕业设计的参考案例。读者可从中获取从界面搭建到数据库操作的完整实现思路,对照前端页面、后端请求处理与数据表结构进行拆解学习,快速掌握Web项目的基本开发流程与模块划分方式。
1. 火车票订票系统:从一张静态页面到能跑通的交互闭环
很多人第一次做「火车票订票系统」的网页设计,交出来的东西长这样:一个搜索框、几张车次卡片、一个「立即预订」按钮,点下去毫无反应。页面看着挺像回事,但一碰就露馅——这其实是把订票系统做成了海报。真正能拿得出手的版本,核心不在视觉多花哨,而在「查询 → 选座 → 填乘客 → 确认订单」这条链路能不能闭环。HTML 负责骨架,CSS 负责布局与状态反馈,JavaScript 负责数据流转和校验,三者缺一不可。这篇笔记面向的是正在做课程设计、练手项目或者想补前端基本功的开发者,我会把车次数据结构、座位选择逻辑、订单状态管理这些容易含糊的地方拆开讲,参数怎么设、坑在哪,都落到能直接抄的代码上。做完之后你手里应该是一个能真实交互、刷新不丢数据、边界情况有兜底的订票页面,而不是一张会动的图。
2. 先把车次数据模型定下来:字段设计错了后面全是返工
订票系统的复杂度,八成来自数据模型没设计好。我见过太多人一上来就写 HTML 结构,车次信息直接硬编码在标签里,等到要做「按出发站筛选」「按日期查余票」的时候,发现根本没法遍历和过滤,只能推倒重来。所以第一步不是画页面,是把车次、座位、订单这三类数据的字段想清楚。
2.1 车次对象该包含哪些字段
一个车次(train)至少要有:车次编号、出发站、到达站、出发时间、到达时间、历时、各席别余票与票价。席别常见的是商务座、一等座、二等座,有的线路还有无座。余票和票价建议做成嵌套对象,而不是平铺成price1、price2这种,否则后面加席别会非常痛苦。
// 车次数据模型:字段命名保持语义化,方便后续筛选和渲染 const trains = [ { id: 'G1001', // 车次编号,唯一标识 from: '北京南', // 出发站 to: '上海虹桥', // 到达站 departTime: '08:00', // 出发时间,HH:mm 格式 arriveTime: '12:28', // 到达时间 duration: '4小时28分', // 历时,直接存字符串省去格式化 seats: { business: { price: 1748, remain: 3 }, // 商务座 first: { price: 933, remain: 12 }, // 一等座 second: { price: 553, remain: 45 } // 二等座 } }, // ... 更多车次 ];这里有个关键决策:余票remain用数字而不是布尔值。因为订票要扣减库存,布尔值只能表示「有/无」,没法处理「只剩 2 张,一次买 3 张」这种场景。价格用整数(元),避免浮点数在计算总价时出现0.1 + 0.2的精度问题。时间用字符串存储,展示时直接输出,只有需要计算跨天历时的时候才转成分钟数处理。
2.2 座位与订单的关联方式
座位选择有两种常见做法:一种是「选席别不选具体座位」,像真实购票那样只选二等座、系统分配;另一种是「可视化选座」,画出车厢座位图让用户点。课程设计里后者视觉冲击强,但实现成本高,而且座位图数据量大。我一般建议先做前者,把订单流程跑通,再考虑加座位图。
订单对象要记录:订单号、车次 id、席别、乘客信息、数量、总价、下单时间、状态。状态用枚举字符串,比如pending、paid、cancelled,不要用 0/1/2 这种魔法数字,否则三个月后你自己都忘了 2 代表什么。
// 订单模型:状态用可读字符串,方便调试和扩展 function createOrder(trainId, seatType, passengers) { const train = trains.find(t => t.id === trainId); const seat = train.seats[seatType]; return { orderId: 'ORD' + Date.now(), // 时间戳做订单号,简单够用 trainId, seatType, passengers, // 乘客数组 count: passengers.length, totalPrice: seat.price * passengers.length, status: 'pending', // pending / paid / cancelled createdAt: new Date().toISOString() }; }乘客信息至少要有姓名和证件号。证件号校验别写太复杂,课程设计里做个长度和格式的基础校验就行,真实身份证校验算法涉及校验位计算,可以单独抽成一个函数,但别让它阻塞主流程。
2.3 用 localStorage 做数据持久化
刷新页面数据就没了,是新手项目最容易被挑毛病的地方。用localStorage存订单列表是最低成本方案。注意存进去之前要JSON.stringify,取出来要JSON.parse,并且要处理解析失败的情况——用户手动改过存储、或者存了旧版本格式,都会让parse抛异常。
// 订单持久化:读写都做异常兜底,避免脏数据导致页面白屏 const ORDER_KEY = 'train_orders'; function saveOrders(orders) { try { localStorage.setItem(ORDER_KEY, JSON.stringify(orders)); } catch (e) { console.error('订单保存失败', e); // 存储满或隐私模式会抛错 } } function loadOrders() { try { const raw = localStorage.getItem(ORDER_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { console.warn('订单数据损坏,已重置'); return []; // 解析失败返回空数组,不阻断渲染 } }参数说明:ORDER_KEY用固定字符串,别用动态 key,否则每次读的都不是同一份数据。try/catch不是可选项,隐私模式下localStorage可能直接不可用,不兜底就是白屏。存之前建议给订单数组做个长度上限,比如只保留最近 50 条,避免长期使用撑爆存储配额。
3. 查询与筛选:让车次列表真正跟着条件动起来
数据模型定好之后,页面上的车次列表就不该是写死的 HTML,而应该由 JavaScript 根据查询条件动态渲染。这一章解决「输入出发站、到达站、日期,点查询,列表正确刷新」这个核心交互,顺带把空结果、无余票这些边界情况处理掉。
3.1 查询条件的读取与校验
查询表单通常有三个输入:出发站、到达站、出发日期。读取值用document.querySelector拿,但要注意用户可能什么都不填就点查询。我的做法是:出发站和到达站必填,日期可以默认今天。校验不通过时,不要用alert弹窗,那体验太糙,在表单下方用一个提示区域显示文字。
<!-- 查询表单结构:用 form 包裹,方便统一处理提交 --> <form id="searchForm" class="search-bar"> <input type="text" id="fromStation" placeholder="出发站" /> <input type="text" id="toStation" placeholder="到达站" /> <input type="date" id="departDate" /> <button type="submit">查询</button> <p id="searchTip" class="tip"></p> </form>// 查询逻辑:先校验,再过滤,最后渲染 document.getElementById('searchForm').addEventListener('submit', function (e) { e.preventDefault(); // 阻止表单默认提交刷新页面 const from = document.getElementById('fromStation').value.trim(); const to = document.getElementById('toStation').value.trim(); const tip = document.getElementById('searchTip'); if (!from || !to) { tip.textContent = '请填写出发站和到达站'; return; } if (from === to) { tip.textContent = '出发站和到达站不能相同'; return; } tip.textContent = ''; const result = trains.filter(t => t.from === from && t.to === to); renderTrainList(result); });e.preventDefault()这行是血泪经验,不写的话表单会以 GET 方式提交,页面直接刷新,你写的所有 JS 状态全丢。trim()处理用户输入前后空格,不然「北京南 」和「北京南」匹配不上。过滤用filter而不是forEach里push,代码更短且不易出错。
3.2 车次卡片的动态渲染
渲染函数接收过滤后的车次数组,遍历生成 HTML 字符串,再一次性塞进容器。这里有个性能细节:不要在循环里反复操作 DOM,拼好字符串再赋值innerHTML,几十条数据下差异不明显,但习惯要养好。
// 渲染车次列表:空结果要有兜底提示 function renderTrainList(list) { const container = document.getElementById('trainList'); if (list.length === 0) { container.innerHTML = '<p class="empty">没有找到符合条件的车次</p>'; return; } const html = list.map(train => { // 席别按钮:余票为 0 时禁用,用 disabled 属性控制 const seatButtons = Object.entries(train.seats).map(([type, seat]) => { const label = { business: '商务座', first: '一等座', second: '二等座' }[type]; const disabled = seat.remain === 0 ? 'disabled' : ''; return `<button class="seat-btn">// 事件委托:父容器监听,动态生成的按钮也能响应 document.getElementById('trainList').addEventListener('click', function (e) { const btn = e.target.closest('.seat-btn'); if (!btn || btn.disabled) return; // 不是席别按钮或已禁用则忽略 const trainId = btn.dataset.train; const seatType = btn.dataset.seat; openBookingPanel(trainId, seatType); // 打开订票面板 });closest从点击目标向上找最近的匹配元素,比e.target.classList.contains更稳,因为按钮里可能还有文字节点。btn.disabled判断不能省,虽然 disabled 按钮通常不触发 click,但不同浏览器行为有差异,显式判断更保险。dataset.train对应 HTML 里的>// 打开订票面板:填充车次与席别信息 let currentSelection = null; // 记录当前选择,下单时要用 function openBookingPanel(trainId, seatType) { const train = trains.find(t => t.id === trainId); const seat = train.seats[seatType]; currentSelection = { trainId, seatType, price: seat.price }; document.getElementById('panelTrainId').textContent = train.id; document.getElementById('panelRoute').textContent = `${train.from} → ${train.to}`; document.getElementById('panelSeat').textContent = { business: '商务座', first: '一等座', second: '二等座' }[seatType]; document.getElementById('panelPrice').textContent = seat.price; document.getElementById('bookingPanel').style.display = 'block'; renderPassengerInputs(1); // 默认显示 1 个乘客输入框 }
currentSelection用模块级变量存当前选择,比每次从 DOM 里反读更可靠。面板里的车次信息用textContent而不是innerHTML,因为这些都是纯文本,用innerHTML有 XSS 风险——虽然数据是自己定义的,但习惯要正。
4.2 乘客数量增减与总价实时计算
乘客数量用「加/减」按钮控制,最少 1 人,最多不超过该席别余票。每次增减都重新渲染乘客输入框,并更新总价。总价 = 单价 × 乘客数,显示在面板底部。
// 乘客数量管理:增减时同步更新输入框和总价 let passengerCount = 1; function changePassenger(delta) { const max = trains.find(t => t.id === currentSelection.trainId) .seats[currentSelection.seatType].remain; const next = passengerCount + delta; if (next < 1 || next > max) return; // 边界拦截 passengerCount = next; renderPassengerInputs(passengerCount); updateTotalPrice(); } function updateTotalPrice() { const total = currentSelection.price * passengerCount; document.getElementById('totalPrice').textContent = total; } function renderPassengerInputs(count) { const box = document.getElementById('passengerBox'); let html = ''; for (let i = 0; i < count; i++) { html += ` <div class="passenger-row"> <input type="text" class="p-name" placeholder="乘客${i + 1}姓名" /> <input type="text" class="p-id" placeholder="证件号" /> </div>`; } box.innerHTML = html; }边界拦截next < 1 || next > max是关键,不拦的话用户能减到 0 人或者加到超过余票,下单时才发现问题就晚了。renderPassengerInputs每次全量重绘,简单直接,代价是已填内容会丢——如果用户填了 2 人再加到 3 人,前两个输入框内容会被清空。要避免这个体验问题,可以改成只追加/删除最后一个输入框,但代码复杂度上升。课程设计里全量重绘可接受,真实项目建议做增量。
4.3 下单校验与订单写入
点「确认下单」时要做三件事:校验每个乘客的姓名和证件号非空、扣减对应席别余票、生成订单并存入 localStorage。校验不通过要明确指出是第几个乘客缺什么,别只说「请填写完整」。
// 确认下单:校验 → 扣库存 → 存订单 → 刷新列表 document.getElementById('confirmBtn').addEventListener('click', function () { const names = [...document.querySelectorAll('.p-name')]; const ids = [...document.querySelectorAll('.p-id')]; for (let i = 0; i < names.length; i++) { if (!names[i].value.trim()) { alert(`请填写乘客${i + 1}的姓名`); return; } if (!ids[i].value.trim()) { alert(`请填写乘客${i + 1}的证件号`); return; } } const passengers = names.map((n, i) => ({ name: n.value.trim(), idCard: ids[i].value.trim() })); // 扣减余票 const train = trains.find(t => t.id === currentSelection.trainId); train.seats[currentSelection.seatType].remain -= passengers.length; // 生成并保存订单 const order = createOrder(currentSelection.trainId, currentSelection.seatType, passengers); const orders = loadOrders(); orders.unshift(order); // 新订单放最前 saveOrders(orders); alert('订票成功,订单号:' + order.orderId); document.getElementById('bookingPanel').style.display = 'none'; renderTrainList(trains); // 重渲染,余票数字更新 renderOrderList(); // 刷新订单列表 });unshift把新订单放数组头部,订单列表渲染时最新的在最上面,符合直觉。扣库存要在生成订单之前,顺序反了万一存订单失败,库存就白扣了。重渲染车次列表用完整的trains数组,因为余票变了,之前筛选的结果可能不再准确,简单起见直接全量刷新。
5. 避坑与排查:订票系统里最容易翻车的五个地方
这一章是我自己踩过和帮别人看代码时反复遇到的坑,每条按「现象 → 原因 → 解决」写,你对着排查能省不少时间。
5.1 点击查询后页面闪一下,数据全没了
现象:填好条件点查询,页面刷新,输入框清空,列表回到初始状态。原因:form的默认提交行为没被阻止,浏览器以 GET 方式提交并刷新页面。解决:在submit事件处理函数第一行加e.preventDefault()。注意监听的是submit事件而不是按钮的click,因为用户按回车也会触发提交,只监听 click 会漏掉回车场景。
5.2 动态生成的按钮点了没反应
现象:车次列表渲染出来后,席别按钮点击无任何效果。原因:事件监听器绑在了按钮本身上,但按钮是后来用innerHTML生成的,绑定时按钮还不存在。解决:改用事件委托,把监听器绑在始终存在的父容器trainList上,通过e.target.closest('.seat-btn')判断点击目标。这是动态内容绑事件的通用解法,记住「绑父不绑子」。
5.3 刷新后订单列表空了
现象:下单成功能看到订单,一刷新页面订单全消失。原因:订单只存在内存变量里,没写进localStorage,或者写了但读取时 key 不一致。解决:确认saveOrders和loadOrders用的是同一个ORDER_KEY,并且页面加载时主动调用一次renderOrderList()从存储读取。另外检查JSON.parse是否被try/catch包住,解析失败时返回空数组而不是抛异常中断后续代码。
5.4 余票扣减后数字对不上
现象:买了 2 张票,余票从 45 变成 44 而不是 43。原因:扣减时用了-= 1而不是-= passengers.length,或者扣减逻辑写在了遍历乘客的循环里,每个乘客扣一次但基数算错。解决:扣减语句只写一次,数量用passengers.length。扣减后立即重渲染列表,用渲染结果验证数字,别靠脑补。
5.5 证件号输入框能填进奇怪字符
现象:证件号里出现字母、符号甚至表情,下单也不报错。原因:只做了非空校验,没做格式校验。解决:加一个基础的正则校验,比如身份证 18 位(末位可能是 X),护照按字母数字组合。校验失败时提示具体原因,别只说「格式错误」。正则别写太严,课程设计里能拦住明显非法输入即可,真实校验规则复杂得多。
// 证件号基础校验:身份证或护照,按需调整 function isValidIdCard(value) { const idPattern = /^\d{17}[\dXx]$/; // 18 位身份证 const passportPattern = /^[A-Za-z0-9]{6,15}$/; // 护照:6-15 位字母数字 return idPattern.test(value) || passportPattern.test(value); }6. 进阶技巧:用状态驱动渲染替代手动 DOM 操作
前面几章的写法是「数据变了就手动调渲染函数」,能跑通,但车次列表、订单列表、面板状态各自为政,改一处容易漏另一处。进阶做法是引入一个极简的状态对象,所有变化都先改状态,再统一调一个render(),让页面成为状态的投影。这不是要你上框架,而是理解「状态驱动」这个思路,几十行代码就能实现。
// 极简状态驱动:所有 UI 都是 state 的函数 const state = { keyword: { from: '', to: '' }, // 当前查询条件 filtered: trains, // 当前展示的车次 orders: loadOrders(), // 订单列表 panel: null // 当前订票面板选择,null 表示关闭 }; function render() { renderTrainList(state.filtered); renderOrderList(state.orders); renderPanel(state.panel); } // 查询:只改状态,不直接碰 DOM function search(from, to) { state.keyword = { from, to }; state.filtered = trains.filter(t => t.from === from && t.to === to); render(); } // 下单:改状态后统一渲染 function placeOrder(order) { state.orders.unshift(order); saveOrders(state.orders); state.panel = null; render(); }这样改的好处是:任何操作都只关心「状态怎么变」,不用记「该调哪几个渲染函数」。新增一个展示区域时,只要在render()里加一行,所有触发点自动生效。调试也简单,把state打印出来,页面该长什么样一目了然。
验证方法上,我习惯做三组检查:第一,空状态——不填条件查询、订单为空时页面是否正常;第二,边界——余票 1 张时买 2 张是否被拦、乘客数减到 0 是否被拦;第三,持久化——下单后刷新、关掉标签页再打开,订单是否还在。这三组过了,基本就稳了。
最后说个我自己的习惯:每次改完逻辑,先把localStorage清空再测一遍完整流程。因为开发过程中存的脏数据经常掩盖真实问题,清空重来能暴露「首次使用」路径上的 bug。这个习惯帮我省过很多次「明明本地好好的,别人一打开就报错」的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取