尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue 3 与 Node.js 实现 S-Bahn 座位推荐全栈应用

Vue 3 与 Node.js 实现 S-Bahn 座位推荐全栈应用 最近在 Hacker News 上看到一个很有意思的项目S-Bahn Seat Picker。第一反应是这个需求实在太真实了。每天早晚高峰坐 S-Bahn 通勤推开车门那一刻大家都在快速扫视哪一排有空位、哪个座位靠窗、哪里能充电。如果能提前查看车厢座位布局甚至根据偏好自动推荐座位整个通勤体验会完全不一样。本文就围绕“S-Bahn Seat Picker”这个主题从零实现一个可运行的座位选择器 Web 应用。项目不复杂但覆盖了前端座位图渲染、后端接口设计、偏好推荐算法、联调排错这些完整链路。适合正在学习 Vue 3 Node.js 全栈开发的读者也适合想做类似座位可视化工具的开发者参考。1. 项目背景S-Bahn 与座位选择工具1.1 S-Bahn 是什么S-Bahn 是德国城市快铁系统主要承担城市中心与郊区之间的通勤运输。它的车厢布局和国内高铁有点像但更偏向城市通勤场景座位密度高、停站多、乘客频繁上下。S-Bahn 车厢内普遍采用 22 座位布局中间一条过道部分座位靠窗、部分靠过道有些座位附近有充电插座。因为通勤时间集中乘客对座位的需求差异很大有人只想靠窗方便休息。有人需要充电口手机电量告急。有人希望靠近车门下车方便。有人喜欢安静区域不想被上下车人流打扰。现实中的选择往往很随机上了车才发现这节车厢座位布局不理想。座位选择器要做的事情就是把“随机找座位”变成“有预期的选座位”。1.2 Show HN 项目带来的启发Show HN 上的这个 S-Bahn Seat Picker 项目核心思路是把列车座位信息可视化并支持按偏好筛选。和常见的航班选座系统、高铁选座系统相比S-Bahn 场景更轻量也更适合做原型验证。这个项目的价值点在于数据可视化把座位布局画成平面图用户一眼看清。偏好匹配通过推荐算法把符合条件的座位优先展示。交互闭环从查看、选择到预订形成完整操作流程。本文不是照搬某个项目而是带着这个需求重新设计并实现一个简化但完整的版本。1.3 这类工具的核心问题座位选择器看起来简单真正动手做会遇到几个核心问题座位图如何建模。不能只存一个“座位名称”字符串还要包含车厢、排数、左右侧、是否靠窗、是否靠过道等信息。前端如何渲染。座位图不是普通表格需要按车厢布局精确绘制。推荐算法如何设计。用户偏好和座位属性如何匹配如何排序。前后端如何联调。环境搭建、接口设计、跨域问题、状态刷新都要处理。接下来我按照一个完整项目的开发顺序逐步拆解这些环节。2. 需求分析与功能拆解2.1 用户故事在动手写代码之前先明确用户场景。我们可以把核心用户故事写成这样作为乘客我想查看某条车次的座位布局以便上车前了解座位情况。作为乘客我想按“靠窗、靠过道、有充电口”等条件筛选座位。作为乘客我希望系统自动推荐最合适的座位。作为乘客我想选择一个空闲座位并完成预订。作为乘客我希望预订后看到座位状态更新避免重复选择。第一版不需要做登录、支付、实时余座这些可以放在后续扩展中。2.2 功能模块模块功能说明线路/车次选择展示可乘坐的车次列表模拟数据正式环境接实时接口车厢选择展示车次下的车厢列表支持切换车厢查看座位座位图以 SVG 渲染 22 布局座位图区分空闲、占用、选中、推荐四种状态偏好过滤靠窗 / 靠过道 / 充电口推荐算法输入参数智能推荐根据偏好给空闲座位评分返回 Top N 推荐座位预订选择座位并提交预订模拟修改座位状态2.3 非功能需求响应速度接口是本地 mock单次请求应在毫秒级返回。可读性代码结构清晰座位数据模型容易扩展。可访问性座位图使用颜色区分状态的同时也应该保留文字标签不能只靠颜色传达信息。移动端适配S-Bahn 通勤场景大量发生在手机上页面布局要能在窄屏下正常使用。3. 技术选型与系统设计3.1 技术栈选择这个项目采用 Node.js 全栈方案前后端都用 JavaScript减少环境切换成本。层次技术说明前端框架Vue 3 Vite组合式 API开发体验好座位图渲染SVG适合绘制规则图形交互事件处理方便后端Express轻量适合快速搭建 REST API数据存储内存对象演示项目使用重启后数据恢复初始状态联调方式Vite Proxy前端/api转发到后端 3000 端口选 Vue 3 是因为它上手快模板语法直观单文件组件适合把页面拆成多个模块。选 SVG 是因为座位图是由矩形、文本组成的规则图形SVG 比 Canvas 更容易处理点击事件和样式。3.2 系统架构浏览器Vue 3 SVG 座位图 ↓ REST API ExpressNode.js ↓ 内存数据 车次/车厢/座位 JSON 数据整体流程前端启动后拉取车次列表。用户选择车次前端拉取车厢列表。用户选择车厢前端拉取该车厢座位数据。SVG 根据座位数据渲染平面图。用户设置偏好并点击推荐后端计算推荐结果。用户点击座位并提交预订后端更新座位状态。3.3 数据模型设计座位数据是核心模型。每个座位需要包含这些字段字段类型示例说明idstringcar-1-1A全局唯一carIdstringcar-1所属车厢labelstring1A座位展示名rownumber1座位所在排sidestringleft左侧/右侧positionstringwindow靠窗/靠过道windowbooleantrue是否靠窗aislebooleanfalse是否靠过道powerbooleanfalse是否有充电口statusstringfree空闲/占用数据和渲染分离后前端只要拿到座位数组就能根据row、side、position计算坐标完成 SVG 绘制。以后如果要支持 23 布局只需要扩展座位数据不需要改动渲染逻辑。4. 环境准备与项目结构4.1 环境要求开发这个项目需要提前准备好 Node.js 环境。推荐使用 Node.js 18 及以上版本因为 Vite 4/5 和 Express 5 对 Node 版本有要求低版本可能会启动失败。在终端确认版本node -v npm -v如果版本过低建议使用 nvm 这类版本管理工具切换 Node 版本。不同系统安装 nvm 的方式不同这里不展开但原则是项目遇到版本兼容问题时第一反应应该是检查 Node 版本和依赖版本是否匹配。4.2 创建项目并安装依赖使用 Vite 官方脚手架创建 Vue 3 项目npm create vitelatest sbahn-seat-picker -- --template vue cd sbahn-seat-picker npm install npm install express cors如果你的 Vite 版本较新脚手架可能会进入交互式界面这时选择 Vue 模板即可。express和cors是后端运行需要的依赖。安装完成后在项目根目录下新建server文件夹用于放后端代码。最终目录结构如下sbahn-seat-picker/ ├── index.html ├── package.json ├── vite.config.js ├── server/ │ ├── index.js │ ├── data.js │ └── recommend.js └── src/ ├── main.js ├── App.vue ├── api.js └── components/ └── SeatMap.vue5. 后端接口实现5.1 模拟数据设计后端先把车次、车厢、座位数据准备好。为了让演示效果稳定我不建议用随机函数生成座位状态因为每次重启服务结果都不一样排错时很难复现。这里直接指定一小部分座位为“已占用”保证界面一开始就有可读的状态区分。文件路径server/data.jsconst trains [ { id: S7-1042, name: S7 线 1042 次, from: Potsdam Hbf, to: Ahrensfelde } ]; const cars [ { id: car-1, trainId: S7-1042, index: 1, className: 二等座, layout: 22 }, { id: car-2, trainId: S7-1042, index: 2, className: 二等座, layout: 22 } ]; const seats []; function createSeats(carId) { const colConfig [ { label: A, side: left, position: window }, { label: B, side: left, position: aisle }, { label: C, side: right, position: aisle }, { label: D, side: right, position: window } ]; const initialOccupied { car-1: [1A, 2D, 3B, 5A, 6C], car-2: [1C, 4D] }; const rows 10; for (let row 1; row rows; row 1) { for (const col of colConfig) { const label ${row}${col.label}; const occupied (initialOccupied[carId] || []).includes(label); seats.push({ id: ${carId}-${label}, carId, label, row, side: col.side, position: col.position, window: col.position window, aisle: col.position aisle, power: row % 3 0, status: occupied ? occupied : free }); } } } createSeats(car-1); createSeats(car-2); function getTrains() { return trains; } function getCarsByTrain(trainId) { return cars.filter((car) car.trainId trainId); } function getSeatsByCar(carId) { return seats.filter((seat) seat.carId carId); } function updateSeatStatus(seatId, status) { const seat seats.find((item) item.id seatId); if (seat) { seat.status status; } return seat; } module.exports { getTrains, getCarsByTrain, getSeatsByCar, updateSeatStatus };这段代码的核心思想是布局规则是固定的数据是动态生成的。每一节车厢都是 10 排、每排 4 个座位左侧 A/B右侧 C/D。A 和 D 靠窗B 和 C 靠过道。每三排设置一个充电口模拟真实车厢中部分座位有电源的情况。initialOccupied用于预置占用状态这样前端第一次打开就能看到“空闲/占用”的视觉区分。5.2 推荐算法模块推荐逻辑单独放到server/recommend.js便于测试。算法思路是对每个空闲座位打分分数越高越适合推荐。打分规则基础分 50 分。用户勾选“靠窗”且座位靠窗加 15 分。用户勾选“靠过道”且座位靠过道加 10 分。用户勾选“充电口”且座位有充电口加 8 分。用户指定了坐左侧或右侧且匹配加 5 分。已占用座位直接减 1000 分确保不出现在推荐结果中。推荐接口返回前 5 名并附带评分和推荐原因方便前端解释“为什么推荐这个座位”。文件路径server/recommend.jsfunction scoreSeat(seat, preferences) { let score 50; if (preferences.window seat.window) { score 15; } if (preferences.aisle seat.aisle) { score 10; } if (preferences.power seat.power) { score 8; } if (preferences.side seat.side preferences.side) { score 5; } if (seat.status ! free) { score - 1000; } return score; } function buildReasons(seat, preferences) { const reasons []; if (preferences.window seat.window) { reasons.push(靠窗); } if (preferences.aisle seat.aisle) { reasons.push(靠过道); } if (preferences.power seat.power) { reasons.push(有充电口); } if (preferences.side seat.side preferences.side) { reasons.push(在指定侧); } return reasons; } function recommendSeats(seats, preferences {}) { return seats .map((seat) ({ ...seat, score: scoreSeat(seat, preferences), reasons: buildReasons(seat, preferences) })) .filter((item) item.status free) .sort((a, b) b.score - a.score) .slice(0, 5); } module.exports { recommendSeats };这个推荐算法属于最简单的评分模型。它有一个明显缺点只考虑单个座位没有考虑“两人同行需要相邻座位”的场景。第一版把范围控制在单人选座多人组合留到扩展阶段。生产环境如果要支持多人算法需要升级为组合搜索这会显著增加复杂度。5.3 Express 接口文件路径server/index.jsconst express require(express); const cors require(cors); const { getTrains, getCarsByTrain, getSeatsByCar, updateSeatStatus } require(./data); const { recommendSeats } require(./recommend); const app express(); const PORT 3000; app.use(cors()); app.use(express.json()); app.get(/api/trains, (req, res) { res.json({ code: 0, data: getTrains() }); }); app.get(/api/trains/:trainId/cars, (req, res) { const { trainId } req.params; res.json({ code: 0, data: getCarsByTrain(trainId) }); }); app.get(/api/cars/:carId/seats, (req, res) { const seats getSeatsByCar(req.params.carId); if (!seats || seats.length 0) { return res.status(404).json({ code: 404, message: car not found }); } res.json({ code: 0, data: seats }); }); app.post(/api/recommend, (req, res) { const { carId, preferences } req.body || {}; const seats getSeatsByCar(carId); if (!seats || seats.length 0) { return res.status(404).json({ code: 404, message: car not found }); } const suggestions recommendSeats(seats, preferences || {}); res.json({ code: 0, data: suggestions }); }); app.post(/api/book, (req, res) { const { seatIds } req.body || {}; if (!Array.isArray(seatIds) || seatIds.length 0) { return res.status(400).json({ code: 400, message: seatIds is required }); } const updated []; for (const seatId of seatIds) { const seat updateSeatStatus(seatId, occupied); if (seat) { updated.push(seat); } } res.json({ code: 0, data: updated }); }); app.listen(PORT, () { console.log(mock server running at http://localhost:${PORT}); });这里要注意几个设计细节所有接口返回值统一为{ code, data, message }结构前端可以根据code判断请求是否成功。cors()中间件解决跨域问题。开发环境实际上走 Vite Proxy但加上 CORS 后前端直连后端端口也能访问。预订接口使用updateSeatStatus修改内存中的座位状态。这是演示项目所以没有加幂等控制。正式项目中这个接口必须考虑重复请求、并发抢座、权限校验等问题。后端启动命令node server/index.js启动成功后终端会输出mock server running at http://localhost:30006. 前端核心实现6.1 Vite 代理配置前端开发服务器默认跑在 5173 端口后端跑在 3000 端口直接请求/api会 404。需要通过 Vite Proxy 把/api开头的请求转发到后端。文件路径vite.config.jsimport { defineConfig } from vite; import vue from vitejs/plugin-vue; export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } });配置好代理后前端代码里只需要写相对路径/api/xxxVite 会自动转发到http://localhost:3000/api/xxx。这比在代码中硬编码http://localhost:3000更利于后续部署因为部署时可以通过反向代理统一处理接口路径。6.2 前端请求封装文件路径src/api.jsconst BASE_URL /api; async function request(path, options {}) { const res await fetch(${BASE_URL}${path}, { headers: { Content-Type: application/json }, ...options }); if (!res.ok) { throw new Error(HTTP ${res.status}); } const json await res.json(); if (json.code ! 0) { throw new Error(json.message || request failed); } return json.data; } export function fetchTrains() { return request(/trains); } export function fetchCars(trainId) { return request(/trains/${trainId}/cars); } export function fetchSeats(carId) { return request(/cars/${carId}/seats); } export function fetchRecommend(carId, preferences) { return request(/recommend, { method: POST, body: JSON.stringify({ carId, preferences }) }); } export function bookSeats(seatIds) { return request(/book, { method: POST, body: JSON.stringify({ seatIds }) }); }封装请求函数的好处是页面组件不用关心fetch的细节也不用每次重复处理错误状态。后续如果要把fetch换成axios只需要改这一个文件。6.3 页面主组件文件路径src/App.vuescript setup import { onMounted, ref } from vue; import SeatMap from ./components/SeatMap.vue; import { fetchTrains, fetchCars, fetchSeats, fetchRecommend, bookSeats } from ./api; const trains ref([]); const selectedTrainId ref(); const cars ref([]); const selectedCarId ref(); const seats ref([]); const loading ref(false); const selectedSeatIds ref([]); const recommendations ref([]); const preferences ref({ window: false, aisle: false, power: false, side: auto }); async function loadTrains() { trains.value await fetchTrains(); if (trains.value.length 0) { selectedTrainId.value trains.value[0].id; await loadCars(); } } async function loadCars() { cars.value await fetchCars(selectedTrainId.value); if (cars.value.length 0) { selectedCarId.value cars.value[0].id; await loadSeats(); } else { cars.value []; seats.value []; } } async function loadSeats() { loading.value true; try { seats.value await fetchSeats(selectedCarId.value); selectedSeatIds.value []; recommendations.value []; } finally { loading.value false; } } function onSeatClick(seat) { if (seat.status occupied) { return; } const index selectedSeatIds.value.indexOf(seat.id); if (index 0) { selectedSeatIds.value.splice(index, 1); } else { selectedSeatIds.value.push(seat.id); } } async function runRecommend() { if (!selectedCarId.value) { return; } recommendations.value await fetchRecommend(selectedCarId.value, preferences.value); if (recommendations.value.length 0) { selectedSeatIds.value recommendations.value.slice(0, 1).map((s) s.id); } } async function confirmBook() { if (selectedSeatIds.value.length 0) { alert(请先选择座位); return; } await bookSeats(selectedSeatIds.value); alert(预订成功); await loadSeats(); } onMounted(loadTrains); /script template main classpage h1S-Bahn 座位选择器/h1 section classtoolbar label 车次 select v-modelselectedTrainId changeloadCars option v-fort in trains :keyt.id :valuet.id {{ t.name }} /option /select /label label 车厢 select v-modelselectedCarId changeloadSeats option v-forc in cars :keyc.id :valuec.id {{ c.className }} {{ c.index }} 号车厢 /option /select /label /section section classpreferences h2座位偏好/h2 label input typecheckbox v-modelpreferences.window / 靠窗 /label label input typecheckbox v-modelpreferences.aisle / 靠过道 /label label input typecheckbox v-modelpreferences.power / 需要充电口 /label button clickrunRecommend推荐座位/button /section SeatMap :seatsseats :selected-seat-idsselectedSeatIds :recommended-seat-idsrecommendations.map((s) s.id) seat-clickonSeatClick / section classbooking p当前已选{{ selectedSeatIds.join(, ) || 未选择 }}/p button :disabledloading clickconfirmBook确认预订/button /section section v-ifrecommendations.length classrecommend-list h2推荐结果/h2 ul li v-forr in recommendations :keyr.id {{ r.label }}{{ r.reasons.length ? r.reasons.join(、) : 常规座位 }}评分 {{ r.score }} /li /ul /section /main /template style scoped .page { max-width: 760px; margin: 0 auto; padding: 24px 16px; font-family: Arial, sans-serif; } .toolbar { display: flex; gap: 16px; margin-bottom: 20px; } .preferences { margin-bottom: 20px; padding: 16px; border: 1px solid #dee2e6; border-radius: 8px; } .preferences label { margin-right: 16px; } .preferences button { margin-left: 8px; padding: 6px 14px; } .booking { margin-top: 16px; padding: 16px; background: #f8f9fa; border-radius: 8px; } .recommend-list ul { padding-left: 20px; } /styleApp.vue 的主要逻辑可以概括为页面加载后先拉取车次列表默认选中第一个车次。车次变化后重新拉取车厢列表。
返回列表