在票务系统、差旅报销平台或出行类应用中,还原12306官方火车票样式的展示组件是常见需求。本文将详细介绍如何基于 Vue3 Composition API 开发一款高度还原原版、支持自适应布局、动态数据驱动的火车票组件,拆解实现过程中的核心技术亮点与设计思路。
开源地址在文末!!!
这款火车票组件完全对标12306官方报销凭证样式,具备以下核心特性:

<script setup> 语法糖)原版火车票的宽高比为 856:540(基于真实报销凭证尺寸),组件需在不同容器宽度下保持该比例,同时实现自适应缩放。核心实现思路如下:
首先定义火车票原始宽高常量,作为缩放计算的基准:
javascript// 基础尺寸(复刻12306原版火车票实际尺寸)
const BASE_WIDTH = 856
const BASE_HEIGHT = 540
通过 aspect-ratio 确保外层容器始终保持原始宽高比,避免拉伸变形:
css.ticket-wrapper {
width: 100%;
position: relative;
overflow: hidden;
aspect-ratio: 856 / 540; /* 固定宽高比,适配任何宽度容器 */
}
监听窗口 resize 事件和组件挂载事件,通过容器宽度与基准宽度的比值计算缩放比例,再通过 transform: scale() 实现自适应:
javascriptconst wrapper = ref(null)
const scale = ref(1)
// 计算缩放比例
function updateScale() {
if (wrapper.value) {
const width = wrapper.value.clientWidth
scale.value = width / BASE_WIDTH // 容器宽度 / 基准宽度 = 缩放比例
}
}
// 组件挂载时初始化,窗口变化时更新
onMounted(() => {
updateScale()
window.addEventListener('resize', updateScale)
})
// 组件卸载时解绑事件,避免内存泄漏
onUnmounted(() => {
window.removeEventListener('resize', updateScale)
})
通过 transformOrigin: 'top left' 确保缩放以左上角为原点,避免布局偏移:
html<div
class="ticket-container"
:style="{
transform: exporting ? 'none' : `scale(${scale})`, // 导出时取消缩放
transformOrigin: 'top left', // 缩放原点:左上角
width: BASE_WIDTH + 'px',
height: BASE_HEIGHT + 'px'
}"
>
组件通过 defineProps 定义输入数据,兼顾灵活性与数据合法性,核心设计如下:
针对「优惠类型」这类可能单个或多个的场景,支持 String 和 Array 两种类型,并通过 validator 验证合法性:
javascriptconst props = defineProps({
// 其他Props...
discountType: {
type: [String, Array],
default: '',
validator: (value) => {
const validTypes = ['student', 'discount', 'child', 'elder', 'military', 'disabled', 'group', 'worker-group', 'student-group', '']
if (Array.isArray(value)) {
return value.every(item => validTypes.includes(item)) // 数组元素均需合法
}
return validTypes.includes(value) // 单个值需合法
}
}
})
通过 computed 对传入数据进行二次处理,适配组件展示需求:
YYYY-MM-DD HH:mm 格式拆分为年、月、日、时单独展示discountType 映射为直观的文字标识(如 student → 「学」「惠」)javascript// 拆分时间格式
const dateTime = computed(() => {
return {
year: props.dateTime.slice(0, 4),
month: props.dateTime.slice(5, 7),
day: props.dateTime.slice(8, 10),
time: props.dateTime.slice(11)
}
})
// 映射优惠标识文本
const discountTexts = computed(() => {
const texts = []
const types = Array.isArray(props.discountType) ? props.discountType : props.discountType ? [props.discountType] : []
types.forEach(type => {
switch(type) {
case 'student': texts.push('学', '惠'); break // 学生票显示「学」「惠」双标识
case 'child': texts.push('儿'); break
case 'elder': texts.push('老'); break
case 'military': texts.push('军'); break
// 其他优惠类型映射...
}
})
return texts
})
原版火车票的背景包含「条纹底纹」和「淡化列车图案」,通过多层背景叠加实现:
css/* 条纹底纹:使用linear-gradient实现重复纹理 */
.ticket::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background-color: #e8f3f7;
background-image: linear-gradient(
-45deg,
rgba(180, 200, 220, 0.3) 1px,
transparent 1px,
transparent 4px
);
background-size: 4px 4px;
}
/* 淡化列车背景图:底部居中显示,低透明度 */
.bgmain .absolute.inset-0.z-\[-2\] {
background-image: url(/CRH-Dr3OhT7q.jpg);
background-position: bottom;
background-repeat: no-repeat;
background-size: contain;
opacity: 0.05;
}
text-align: justify 实现两字站名均匀分布css/* 两字站名对齐 */
.two-char {
min-width: 145px;
text-align: justify;
text-align-last: justify;
}
/* CSS箭头实现 */
.arrow .line {
height: 4px;
background-color: #3a5874;
width: 100%;
}
.arrow .arrow-head {
position: absolute;
right: 0;
top: -7px;
width: 4px;
height: 4px;
border-top: 4px solid #3a5874;
border-right: 4px solid #3a5874;
transform: rotate(45deg);
}
/* 优惠标识圆圈 */
.discount-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 3px solid #1f1d1d;
border-radius: 50%;
font-size: 24px;
line-height: 1;
}
组件通过 exporting 状态控制是否取消缩放,确保导出时为原始尺寸:
javascriptconst exporting = ref(false)
defineExpose({ wrapper, exporting }) // 暴露给父组件控制
父组件可通过暴露的 API 切换导出模式:
javascript// 父组件中控制导出
const ticketRef = ref(null)
const handleExport = () => {
ticketRef.value.exporting = true // 取消缩放,使用原始尺寸
// 执行打印/截图逻辑...
setTimeout(() => {
ticketRef.value.exporting = false // 恢复缩放
}, 100)
}
vue<template> <TrainTicket :fromStation="fromStation" :toStation="toStation" :trainCode="trainCode" :dateTime="dateTime" :discountType="['student', 'discount']" <!-- 其他Props... --> /> </template> <script setup> import TrainTicket from './components/TrainTicket.vue' // 配置数据 const fromStation = '北京西' const toStation = '深圳北' const trainCode = 'G71' const dateTime = '2025-11-15 08:00' // ...其他配置 </script>
支持传入单个字符串或数组形式的优惠类型:
vue<!-- 单个优惠类型 --> <TrainTicket discountType="child" /> <!-- 多个优惠类型 --> <TrainTicket discountType="['student', 'group']" />
discountTexts 中添加新的类型映射即可qrcode.vue 等库,根据票据信息动态生成二维码@media print 样式,优化打印效果,隐藏无关元素resize 事件添加防抖处理,避免频繁触发缩放计算javascriptimport { debounce } from 'lodash'
// 防抖处理:50ms内只触发一次
onMounted(() => {
updateScale()
window.addEventListener('resize', debounce(updateScale, 50))
})
aspect-ratio 的浏览器,添加降级方案:通过 padding-bottom 计算高度css@supports not (aspect-ratio: 856/540) {
.ticket-wrapper {
padding-bottom: calc(540 / 856 * 100%); /* 高度 = 宽度 * 540/856 */
}
}
本文作者:司小远
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!