企业后台系统手机适配指南:JSP项目移动化改造的真实案例与实用方案
前两天帮一家老牌电商公司做完后台系统的移动化改造,项目结束后整理了一波经验。说实话,这种活儿干过几次之后你会发现,真正头疼的不是技术选型,而是怎么在老古董代码和新需求之间找到平衡点。今天就把这次项目的完整过程拆解给大家,希望对有类似困扰的朋友有帮助。
为什么要做这件事
先说说背景。这公司做了十几年B2B平台,后台管理系统全部是JSP + Struts + jQuery的老架构,前端代码估计比他们的平均用户年龄都大。去年开始,老板发现销售团队出差频繁,经常需要在手机上查看订单状态、审批流程,但原有系统在手机上一看就崩溃——表格错位、按钮点不到、文字挤成一团。
最开始他们想用第三方H5封装套一层,但体验极差。后来决定自己搞移动化适配,这才有了这次改造。
改造前的诊断:你有多少”历史包袱”
动手之前,我花了三天时间做了全面诊断。这一步很多人会跳过,但我认为非常关键。
项目技术栈扫描结果:
- JSP页面数量:约320个
- 框架:Struts 2.3 + Spring 3.2 + Hibernate 3.6
- 前端库:jQuery 1.8.3、ExtJS 2.x(用于部分表格组件)
- CSS:无框架,手写样式,存在大量inline style
- 分辨率兼容:仅支持桌面端,最小适配1024px
- 移动端使用情况:基本为零
看到这些数字我当时就有点紧张。ExtJS 2.x这个东西,现在基本已经绝迹了,它的组件化方案在移动端适配上几乎是灾难。但这不是重点,重点是找出真正影响移动体验的核心问题。
我列了一份问题清单,按影响程度排序:
P0级问题(必须解决):
- 布局基于固定像素,无法响应式
- 表格组件不支持横向滚动
- 表单交互逻辑依赖鼠标事件(hover、click)
- 图片资源未经压缩,加载缓慢
P1级问题(强烈建议解决):
- 导航菜单在手机屏幕下无法使用
- 大量JavaScript代码未考虑触摸事件
- 服务端渲染的JSP直接返回给移动端,体验割裂
P2级问题(可以根据预算决定):
- 登录接口未做移动端安全加固
- 部分数据导出功能无移动端适配
- 消息通知依赖桌面端弹窗
这份清单后来成了我们改造路线图的基础,每个问题都对应了具体的解决方案。
技术选型:为什么最后选了这套方案
关于移动化方案,业界主要有三种思路:
方案A:纯服务端渲染适配 在JSP层面加响应式逻辑,通过CSS媒体查询和条件渲染适配不同屏幕。优点是改动小、部署快;缺点是性能差、交互体验受限,且老旧JSP代码改动风险高。
方案B:原生App开发 iOS + Android各一套,体验最好,但成本高、周期长,后续维护麻烦。
方案C:Hybrid混合开发(最终选择) 用WebView承载前端应用,后端保持原有JSP接口,前端采用Vue 3 + Vant UI的轻量方案。通过路由层统一拦截,自动判断设备类型并跳转对应页面。
我们选择了C方案,原因很实际:
- 原有JSP后端不动,只需在入口处增加设备检测和路由分发
- 前端可以完全重写,使用现代化框架
- 部署灵活,前后端分离后各自独立迭代
- 成本可控,一个前端团队 + 少量原生壳即可
核心改造:从JSP到移动端的具体实施
第一步:设备检测与路由分发
这一步是整体架构的基础,需要在前端入口处做统一判断。
// utils/device.js - 设备检测工具
export function detectDevice() {
const ua = navigator.userAgent.toLowerCase();
const isMobile = /android|iphone|ipad|phone|mobile/i.test(ua);
const isWeixin = ua.includes('micromessenger');
const screenW = window.screen.width;
const screenH = window.screen.height;
return {
isMobile,
isWeixin,
screenWidth: screenW,
screenHeight: screenH,
deviceType: getDeviceType(ua),
browser: getBrowser(ua)
};
}
export function getDeviceType(ua) {
if (ua.includes('iphone') || ua.includes('ipad')) return 'ios';
if (ua.includes('android')) return 'android';
if (ua.includes('windows phone')) return 'windows';
return 'unknown';
}
export function getBrowser(ua) {
if (ua.includes('micromessenger')) return 'weixin';
if (ua.includes('chrome') && !ua.includes('edg')) return 'chrome';
if (ua.includes('safari') && !ua.includes('chrome')) return 'safari';
if (ua.includes('firefox')) return 'firefox';
return 'unknown';
}
// middleware/routeGuard.js - 路由守卫
import { detectDevice } from '@/utils/device';
export function routeGuard(to, from, next) {
const device = detectDevice();
// 如果访问的是移动端专属路由,但设备不是移动端,重定向到PC端
if (to.meta.requiresMobile && !device.isMobile) {
return next('/pc-version/' + to.path);
}
// 如果访问的是PC端路由,但设备是移动端,重定向到对应移动端页面
if (to.meta.isPCOnly && device.isMobile) {
const mobilePath = convertToMobilePath(to.path);
return next(mobilePath);
}
// 微信环境特殊处理
if (device.isWeixin) {
// 可能需要调用微信SDK、处理分享等
to.meta.isWeixin = true;
}
next();
}
// 路径转换规则
function convertToMobilePath(pcPath) {
// 如 /order/list -> /mobile/order/list
// /dashboard -> /mobile/dashboard
const mobilePrefix = '/mobile';
if (pcPath.startsWith('/')) {
return mobilePrefix + pcPath;
}
return pcPath;
}
这个路由守卫设计的关键在于:它不只是简单判断设备类型,而是建立了一套PC端和移动端的路径映射机制。这样的好处是,原有PC端的URL结构保持不变,只是在入口处做了智能分流。
第二步:表格组件的移动端适配
这是最头疼的部分。原有系统中的订单列表、数据报表都依赖ExtJS的GridPanel,这种组件在桌面端表现良好,但搬到手机端就是灾难。
我们的解决方案是:保留原有数据接口,前端重写表格组件。
// components/MobileTable.vue
<template>
<div class="mobile-table-container">
<!-- 搜索和筛选区域 -->
<div class="table-toolbar">
<input
v-model="searchKeyword"
placeholder="搜索订单号/客户名..."
class="search-input"
@keyup.enter="handleSearch"
/>
<button class="filter-btn" @click="showFilter = !showFilter">
筛选 {{ filterCount > 0 ? '(' + filterCount + ')' : '' }}
</button>
<button class="export-btn" @click="handleExport">导出</button>
</div>
<!-- 筛选面板 -->
<div v-if="showFilter" class="filter-panel">
<div class="filter-row">
<label>日期范围:</label>
<input type="date" v-model="filterDateStart" />
<span>至</span>
<input type="date" v-model="filterDateEnd" />
</div>
<div class="filter-row">
<label>订单状态:</label>
<select v-model="filterStatus">
<option value="">全部</option>
<option value="pending">待审核</option>
<option value="approved">已通过</option>
<option value="rejected">已拒绝</option>
</select>
</div>
<div class="filter-actions">
<button @click="resetFilter">重置</button>
<button class="primary" @click="applyFilter">应用</button>
</div>
</div>
<!-- 列表区域 - 卡片式布局 -->
<div class="table-list" v-loading="loading">
<div
v-for="item in tableData"
:key="item.id"
class="table-card"
@click="handleRowClick(item)"
>
<div class="card-header">
<span class="order-no">{{ item.orderNo }}</span>
<span class="status-badge" :class="item.status">{{ item.statusText }}</span>
</div>
<div class="card-body">
<div class="card-row">
<span class="label">客户名称</span>
<span class="value">{{ item.customerName }}</span>
</div>
<div class="card-row">
<span class="label">订单金额</span>
<span class="value amount">{{ formatCurrency(item.amount) }}</span>
</div>
<div class="card-row">
<span class="label">下单时间</span>
<span class="value">{{ item.createTime }}</span>
</div>
</div>
<div class="card-footer">
<button class="btn-detail">查看详情</button>
<button
v-if="canOperate(item)"
class="btn-action"
@click.stop="handleAction(item)"
>
{{ getActionText(item) }}
</button>
</div>
</div>
<!-- 空状态 -->
<div v-if="!loading && tableData.length === 0" class="empty-state">
<img src="@/assets/empty-order.svg" alt="暂无数据" />
<p>暂无订单数据</p>
</div>
</div>
<!-- 分页 -->
<div class="pagination" v-if="total > 0">
<button :disabled="currentPage <= 1" @click="changePage(currentPage - 1)">上一页</button>
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
<button :disabled="currentPage >= totalPages" @click="changePage(currentPage + 1)">下一页</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { getOrderList, exportOrderList } from '@/api/order';
const props = defineProps({
initialFilter: { type: Object, default: () => ({}) }
});
const searchKeyword = ref('');
const showFilter = ref(false);
const filterDateStart = ref('');
const filterDateEnd = ref('');
const filterStatus = ref('');
const loading = ref(false);
const tableData = ref([]);
const currentPage = ref(1);
const total = ref(0);
const filterCount = computed(() => {
let count = 0;
if (filterDateStart.value) count++;
if (filterDateEnd.value) count++;
if (filterStatus.value) count++;
return count;
});
const totalPages = computed(() => Math.ceil(total.value / 20));
// 加载数据
async function fetchData() {
loading.value = true;
try {
const params = {
page: currentPage.value,
pageSize: 20,
keyword: searchKeyword.value,
dateStart: filterDateStart.value,
dateEnd: filterDateEnd.value,
status: filterStatus.value
};
const res = await getOrderList(params);
tableData.value = res.data.list;
total.value = res.data.total;
} catch (err) {
// 错误处理...
} finally {
loading.value = false;
}
}
function handleSearch() {
currentPage.value = 1;
fetchData();
}
function applyFilter() {
showFilter.value = false;
currentPage.value = 1;
fetchData();
}
function resetFilter() {
filterDateStart.value = '';
filterDateEnd.value = '';
filterStatus.value = '';
currentPage.value = 1;
fetchData();
}
function handleRowClick(item) {
// 跳转到详情页
router.push(`/mobile/order/detail/${item.id}`);
}
function canOperate(item) {
// 根据业务逻辑判断是否可以操作
return item.status === 'pending';
}
function getActionText(item) {
return item.status === 'pending' ? '审批' : '查看';
}
function handleAction(item) {
// 打开操作面板
showActionSheet(item);
}
function formatCurrency(amount) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(amount);
}
// 监听参数变化
watch(() => props.initialFilter, (newVal) => {
if (newVal && Object.keys(newVal).length > 0) {
Object.assign(filterStatus.value, newVal);
fetchData();
}
}, { immediate: true });
fetchData();
</script>
<style scoped>
.mobile-table-container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 20px;
}
.table-toolbar {
display: flex;
align-items: center;
padding: 12px;
background: #fff;
gap: 8px;
position: sticky;
top: 0;
z-index: 100;
}
.search-input {
flex: 1;
height: 36px;
padding: 0 12px;
border: 1px solid #e0e0e0;
border-radius: 18px;
font-size: 14px;
}
.filter-btn, .export-btn {
padding: 0 12px;
height: 36px;
border: 1px solid #ddd;
border-radius: 18px;
background: #fff;
font-size: 13px;
}
.filter-panel {
background: #fff;
padding: 16px;
border-bottom: 1px solid #eee;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
}
.filter-row label {
width: 70px;
color: #666;
}
.filter-row input, .filter-row select {
flex: 1;
height: 36px;
padding: 0 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.filter-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.filter-actions button {
padding: 8px 20px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
}
.filter-actions .primary {
background: #1890ff;
color: #fff;
border-color: #1890ff;
}
.table-list {
padding: 12px;
}
.table-card {
background: #fff;
border-radius: 8px;
margin-bottom: 12px;
padding: 14px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.order-no {
font-size: 15px;
font-weight: 600;
color: #333;
}
.status-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
}
.status-badge.pending { background: #fff7e6; color: #fa8c16; }
.status-badge.approved { background: #f6ffed; color: #52c41a; }
.status-badge.rejected { background: #fff1f0; color: #f5222d; }
.card-body .card-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
border-bottom: 1px dashed #f0f0f0;
}
.card-body .card-row:last-child {
border-bottom: none;
}
.card-body .label { color: #999; }
.card-body .value { color: #333; }
.card-body .amount { color: #f5222d; font-weight: 600; }
.card-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.btn-detail, .btn-action {
padding: 6px 16px;
border-radius: 4px;
font-size: 13px;
}
.btn-detail {
border: 1px solid #ddd;
background: #fff;
}
.btn-action {
background: #1890ff;
color: #fff;
border: none;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
padding: 20px;
}
.pagination button {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
}
.pagination button:disabled {
opacity: 0.5;
}
.page-info {
font-size: 13px;
color: #666;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state img {
width: 120px;
margin-bottom: 16px;
}
</style>
这个表格组件的设计思路有几个关键点:
卡片式布局替代传统表格 桌面端的横向表格在手机上根本没法用。我们采用了卡片式布局,每行数据变成一张独立的卡片,信息分层展示。这样既保证了可读性,又充分利用了手机屏幕的垂直空间。
搜索和筛选分离 原有系统中的搜索和筛选是表格的一部分,在移动端被提取为独立的工具栏和筛选面板。筛选条件可以展开收起,避免占用过多屏幕空间。
状态 Badge 可视化 订单状态用颜色徽章展示,比文字更直观。这个细节看起来小,但对移动端用户体验影响很大——销售人员快速浏览订单列表时,一眼就能识别状态。
第三步:表单交互的移动端适配
JSP系统中大量使用了传统的表单提交,这些表单在移动端存在诸多问题:输入法弹出遮挡、输入框聚焦滚动异常、表单验证体验差等。
// components/MobileForm.vue
<template>
<div class="mobile-form-wrapper">
<!-- 表单标题区域 -->
<div class="form-header">
<button class="back-btn" @click="handleBack">
<svg width="20" height="20" viewBox="0 0 24 24">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
</button>
<h2>{{ formTitle }}</h2>
</div>
<!-- 表单内容 -->
<form class="mobile-form" @submit.prevent="handleSubmit" ref="formRef">
<!-- 基本信息分组 -->
<div class="form-section">
<div class="section-title">基本信息</div>
<div class="form-item">
<label class="form-label">
客户名称
<span class="required">*</span>
</label>
<input
v-model="formData.customerName"
type="text"
class="form-input"
placeholder="请输入客户名称"
maxlength="50"
:class="{ 'input-error': errors.customerName }"
@focus="handleInputFocus('customerName')"
@blur="handleInputBlur('customerName')"
/>
<span v-if="errors.customerName" class="error-tip">{{ errors.customerName }}</span>
</div>
<div class="form-item">
<label class="form-label">
联系电话
<span class="required">*</span>
</label>
<input
v-model="formData.phone"
type="tel"
class="form-input"
placeholder="请输入联系电话"
maxlength="11"
inputmode="tel"
:class="{ 'input-error': errors.phone }"
@focus="handleInputFocus('phone')"
/>
<span v-if="errors.phone" class="error-tip">{{ errors.phone }}</span>
</div>
<div class="form-item">
<label class="form-label">客户地址</label>
<van-area
ref="areaRef"
:area-list="areaList"
:value="selectedArea"
@confirm="onAreaConfirm"
@cancel="onAreaCancel"
/>
</div>
<div class="form-item">
<label class="form-label">备注信息</label>
<textarea
v-model="formData.remark"
class="form-textarea"
placeholder="请输入备注信息(选填)"
maxlength="200"
rows="3"
></textarea>
</div>
</div>
<!-- 订单明细分组 -->
<div class="form-section">
<div class="section-title">
订单明细
<span class="section-count">共 {{ formData.items.length }} 项</span>
</div>
<div
v-for="(item, index) in formData.items"
:key="index"
class="order-item"
>
<div class="item-header">
<span>商品 {{ index + 1 }}</span>
<button
v-if="canDelete(index)"
type="button"
class="delete-btn"
@click="deleteItem(index)"
>
删除
</button>
</div>
<div class="form-item">
<label class="form-label">商品名称</label>
<van-popup
v-model:show="showProductPicker[index]"
position="bottom"
round
>
<van-picker
:columns="productColumns"
@confirm="onProductConfirm($event, index)"
@cancel="showProductPicker[index] = false"
/>
</van-popup>
<input
:value="item.productName"
type="text"
class="form-input readonly-input"
placeholder="请选择商品"
readonly
@click="showProductPicker[index] = true"
/>
</div>
<div class="form-row">
<div class="form-item half">
<label class="form-label">数量</label>
<input
v-model.number="item.quantity"
type="number"
class="form-input"
min="1"
@change="calculateItemTotal(index)"
/>
</div>
<div class="form-item half">
<label class="form-label">单价</label>
<input
v-model.number="item.price"
type="number"
class="form-input"
min="0"
step="0.01"
@change="calculateItemTotal(index)"
/>
</div>
</div>
<div class="item-total">
小计:<span class="amount">{{ calculateItemTotalValue(item) }}</span>
</div>
</div>
<button type="button" class="add-item-btn" @click="addItem">
+ 添加商品
</button>
</div>
<!-- 金额汇总 -->
<div class="form-section summary-section">
<div class="summary-row">
<span>商品金额</span>
<span>{{ summary.subtotal }}</span>
</div>
<div class="summary-row">
<span>优惠金额</span>
<span class="discount">{{ summary.discount }}</span>
</div>
<div class="summary-row total">
<span>应付金额</span>
<span class="total-amount">{{ summary.totalAmount }}</span>
</div>
</div>
<!-- 提交按钮 -->
<div class="form-footer">
<button type="button" class="btn-cancel" @click="handleCancel">取消</button>
<button type="submit" class="btn-submit" :loading="submitting" :disabled="submitting">
{{ submitting ? '提交中...' : '提交订单' }}
</button>
</div>
</form>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { showToast, showLoadingToast, closeToast } from 'vant';
import { createOrder, getOrderDetail } from '@/api/order';
const props = defineProps({
orderId: { type: String, default: null },
formTitle: { type: String, default: '新建订单' }
});
const emit = defineEmits(['success', 'cancel']);
// 表单数据
const formData = reactive({
customerName: '',
phone: '',
address: '',
areaCode: '',
remark: '',
items: []
});
// 错误信息
const errors = reactive({});
// 状态控制
const submitting = ref(false);
const showProductPicker = ref([]);
const formRef = ref(null);
// 地区数据
const areaList = ref({});
const selectedArea = ref(null);
// 商品选择器数据
const productColumns = ref([
{ text: '商品A', value: 'product_a' },
{ text: '商品B', value: 'product_b' },
{ text: '商品C', value: 'product_c' }
]);
// 汇总信息
const summary = computed(() => {
const subtotal = formData.items.reduce((sum, item) => {
return sum + (item.quantity || 0) * (item.price || 0);
}, 0);
const discount = 0; // 实际项目中可能从接口获取优惠信息
return {
subtotal: subtotal.toFixed(2),
discount: discount.toFixed(2),
totalAmount: (subtotal - discount).toFixed(2)
};
});
// 初始化
onMounted(() => {
loadAreaData();
if (props.orderId) {
loadOrderDetail(props.orderId);
} else {
addItem();
}
// 监听键盘高度变化,避免输入框被遮挡
window.addEventListener('keyboarddidshow', handleKeyboardShow);
window.addEventListener('keyboarddidhide', handleKeyboardHide);
});
onUnmounted(() => {
window.removeEventListener('keyboarddidshow', handleKeyboardShow);
window.removeEventListener('keyboarddidhide', handleKeyboardHide);
});
async function loadAreaData() {
// 从接口或本地数据加载地区信息
areaList.value = await getAreaList();
}
async function loadOrderDetail(orderId) {
showLoadingToast({ message: '加载中...', forbidClick: true });
try {
const res = await getOrderDetail(orderId);
Object.assign(formData, res.data);
formData.items = res.data.items || [];
selectedArea.value = res.data.areaCode;
// 初始化每个商品的選擇器顯示狀態
showProductPicker.value = new Array(formData.items.length).fill(false);
} finally {
closeToast();
}
}
function handleInputFocus(field) {
// 滚动到可见区域
setTimeout(() => {
const input = document.querySelector(`[name="${field}"]`);
if (input) {
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 300);
}
function handleInputBlur(field) {
// 失焦时验证
validateField(field);
}
function validateField(field) {
const value = formData[field];
switch(field) {
case 'customerName':
if (!value.trim()) {
errors.customerName = '请输入客户名称';
} else {
delete errors.customerName;
}
break;
case 'phone':
if (!value.trim()) {
errors.phone = '请输入联系电话';
} else if (!/^1[3-9]\d{9}$/.test(value)) {
errors.phone = '请输入正确的手机号码';
} else {
delete errors.phone;
}
break;
}
}
function onAreaConfirm({ selectedOptions }) {
selectedArea.value = selectedOptions[0].code;
formData.areaCode = selectedOptions[0].code;
}
function onAreaCancel() {
// 取消选择
}
function onProductConfirm({ selectedOptions }, index) {
formData.items[index].productId = selectedOptions[0].value;
formData.items[index].productName = selectedOptions[0].text;
showProductPicker.value[index] = false;
calculateItemTotal(index);
}
function addItem() {
formData.items.push({
productId: '',
productName: '',
quantity: 1,
price: 0
});
showProductPicker.value.push(false);
}
function deleteItem(index) {
if (formData.items.length <= 1) {
showToast('至少保留一项商品');
return;
}
formData.items.splice(index, 1);
showProductPicker.value.splice(index, 1);
}
function canDelete(index) {
return formData.items.length > 1;
}
function calculateItemTotal(index) {
const item = formData.items[index];
// 重新计算小计(此处可以添加防抖)
}
function calculateItemTotalValue(item) {
return ((item.quantity || 0) * (item.price || 0)).toFixed(2);
}
function handleKeyboardShow(e) {
// 处理键盘弹出,可能需要调整表单位置
const keyboardHeight = e.detail.height;
document.querySelector('.mobile-form-wrapper').style.paddingBottom = keyboardHeight + 'px';
}
function handleKeyboardHide() {
document.querySelector('.mobile-form-wrapper').style.paddingBottom = '0';
}
async function handleSubmit() {
// 全表单验证
let hasError = false;
['customerName', 'phone'].forEach(field => {
validateField(field);
if (errors[field]) hasError = true;
});
if (hasError) {
showToast('请完善表单信息');
return;
}
submitting.value = true;
showLoadingToast({ message: '提交中...', forbidClick: true });
try {
const submitData = {
...formData,
areaCode: selectedArea.value
};
await createOrder(submitData);
showToast('提交成功');
emit('success', submitData);
} catch (err) {
showToast(err.message || '提交失败,请重试');
} finally {
submitting.value = false;
closeToast();
}
}
function handleCancel() {
emit('cancel');
}
function handleBack() {
emit('cancel');
}
</script>
<style scoped>
.mobile-form-wrapper {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 80px; /* 为底部按钮留出空间 */
}
.form-header {
display: flex;
align-items: center;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid #eee;
position: sticky;
top: 0;
z-index: 100;
}
.back-btn {
width: 36px;
height: 36px;
border: none;
background: none;
display: flex;
align-items: center;
justify-content: center;
color: #1890ff;
font-size: 16px;
}
.form-header h2 {
flex: 1;
text-align: center;
margin: 0;
font-size: 17px;
font-weight: 500;
}
.mobile-form {
padding: 16px;
}
.form-section {
background: #fff;
border-radius: 8px;
margin-bottom: 12px;
padding: 16px;
}
.section-title {
font-size: 15px;
font-weight: 500;
color: #333;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.section-count {
font-size: 12px;
color: #999;
font-weight: normal;
margin-left: 8px;
}
.form-item {
margin-bottom: 16px;
}
.form-item:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 14px;
color: #333;
margin-bottom: 8px;
}
.form-label .required {
color: #f5222d;
margin-left: 2px;
}
.form-input {
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 15px;
box-sizing: border-box;
transition: border-color 0.2s;
}
.form-input:focus {
border-color: #1890ff;
outline: none;
}
.form-input.input-error {
border-color: #f5222d;
}
.readonly-input {
background: #fafafa;
color: #333;
}
.error-tip {
display: block;
font-size: 12px;
color: #f5222d;
margin-top: 4px;
}
.form-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 14px;
resize: none;
box-sizing: border-box;
}
.form-row {
display: flex;
gap: 12px;
}
.form-item.half {
flex: 1;
}
.order-item {
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
background: #fafafa;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: #666;
}
.delete-btn {
padding: 4px 12px;
border: 1px solid #ff4d4f;
border-radius: 4px;
background: #fff;
color: #ff4d4f;
font-size: 12px;
}
.item-total {
text-align: right;
font-size: 13px;
color: #666;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e0e0e0;
}
.item-total .amount {
color: #f5222d;
font-weight: 500;
margin-left: 4px;
}
.add-item-btn {
width: 100%;
padding: 12px;
border: 1px dashed #1890ff;
border-radius: 6px;
background: #fff;
color: #1890ff;
font-size: 14px;
}
.summary-section {
background: #fff;
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
color: #666;
}
.summary-row.total {
border-top: 1px solid #eee;
margin-top: 8px;
padding-top: 12px;
font-size: 15px;
color: #333;
}
.summary-row .discount {
color: #52c41a;
}
.summary-row.total .total-amount {
color: #f5222d;
font-size: 18px;
font-weight: 600;
}
.form-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 12px;
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #eee;
box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
z-index: 99;
}
.btn-cancel, .btn-submit {
flex: 1;
height: 44px;
border-radius: 22px;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-cancel {
border: 1px solid #ddd;
background: #fff;
color: #666;
}
.btn-submit {
border: none;
background: linear-gradient(135deg, #1890ff, #096dd9);
color: #fff;
}
.btn-submit:disabled {
opacity: 0.6;
}
</style>
这个表单组件解决了很多移动端特有的问题:
键盘遮挡问题 通过监听键盘弹出事件,动态调整底部内边距,确保当前输入框不会被键盘遮挡。这在iOS和Android上都有相应的处理。
输入模式优化
手机号输入框使用了inputmode="tel",这会触发数字键盘而不是全键盘,提升输入体验。
安全区域适配
底部提交按钮考虑了iPhone的Home Indicator,使用了env(safe-area-inset-bottom)确保按钮不会被遮挡。
第四步:导航系统的重构
原有系统的导航是横向菜单,在手机上完全无法使用。我们设计了移动端专属的底部导航栏:
// components/MobileTabBar.vue
<template>
<van-tabbar v-model="active" :fixed="false" :border="false" :safe-area-inset-bottom="true">
<van-tabbar-item
v-for="item in navItems"
:key="item.path"
:to="item.path"
:icon="!isActive(item.path) ? item.icon : item.activeIcon"
badge="item.badge"
>
{{ item.title }}
</van-tabbar-item>
</van-tabbar>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { getUnreadCount } from '@/api/notification';
const route = useRoute();
const active = ref(0);
// 导航配置
const navItems = ref([
{
title: '首页',
path: '/mobile/home',
icon: () => import('@/assets/icons/home.svg'),
activeIcon: () => import('@/assets/icons/home-active.svg')
},
{
title: '订单',
path: '/mobile/order/list',
icon: () => import('@/assets/icons/order.svg'),
activeIcon: () => import('@/assets/icons/order-active.svg'),
badge: computed(() => orderBadge.value || undefined)
},
{
title: '消息',
path: '/mobile/message',
icon: () => import('@/assets/icons/message.svg'),
activeIcon: () => import('@/assets/icons/message-active.svg'),
badge: computed(() => messageBadge.value || undefined)
},
{
title: '我的',
path: '/mobile/profile',
icon: () => import('@/assets/icons/profile.svg'),
activeIcon: () => import('@/assets/icons/profile-active.svg')
}
]);
// 角标数据
const orderBadge = ref(0);
const messageBadge = ref(0);
function isActive(path) {
return route.path.startsWith(path);
}
// 加载角标数据
async function loadBadges() {
try {
const [orderRes, messageRes] = await Promise.all([
getUnreadCount('order'),
getUnreadCount('message')
]);
orderBadge.value = orderRes.data.count || 0;
messageBadge.value = messageRes.data.count || 0;
} catch (err) {
// 静默失败
}
}
loadBadges();
// 定时刷新角标
setInterval(loadBadges, 60000);
</script>
<style scoped>
/* van-tabbar 样式已通过 Vant 主题配置定制 */
</style>
底部导航的设计遵循了移动端常见模式:四个主要入口,消息角标实时更新。这个设计比原有系统的树形菜单更适合手机操作,因为拇指可以轻松触达底部区域。
后端改造:API接口的优化
前端改动只是表象,真正的性能提升来自后端API的优化。原有JSP后端直接返回HTML,移动端需要额外的JSON接口。
// 新建的移动端API控制器
@RestController
@RequestMapping("/api/mobile")
@CrossOrigin(origins = "*")
public class MobileOrderController {
@Autowired
private OrderService orderService;
/**
* 获取订单列表(移动端专用)
*/
@GetMapping("/orders")
public ApiResponse<PageResult<OrderVO>> getOrderList(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer pageSize,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(required = false) String dateStart,
@RequestParam(required = false) String dateEnd,
HttpServletRequest request) {
// 从请求头获取用户信息(替代原有的Session方式)
String userId = request.getHeader("X-User-Id");
String token = request.getHeader("Authorization");
// 验证token
if (!validateToken(token, userId)) {
return ApiResponse.error(401, "未授权");
}
// 构建查询条件
OrderQuery query = new OrderQuery();
query.setPage(page);
query.setPageSize(pageSize);
query.setKeyword(keyword);
query.setStatus(status);
query.setDateStart(dateStart);
query.setDateEnd(dateEnd);
query.setUserId(userId);
// 调用服务层
PageResult<OrderVO> result = orderService.queryMobileOrders(query);
return ApiResponse.success(result);
}
/**
* 获取订单详情(移动端精简版)
*/
@GetMapping("/orders/{orderId}")
public ApiResponse<OrderDetailVO> getOrderDetail(
@PathVariable String orderId,
HttpServletRequest request) {
String userId = request.getHeader("X-User-Id");
OrderDetailVO detail = orderService.getMobileOrderDetail(orderId, userId);
if (detail == null) {
return ApiResponse.error(404, "订单不存在");
}
return ApiResponse.success(detail);
}
/**
* 提交订单
*/
@PostMapping("/orders")
public ApiResponse<String> createOrder(
@RequestBody @Validated CreateOrderRequest request,
HttpServletRequest httpRequest) {
String userId = httpRequest.getHeader("X-User-Id");
// 转换为内部订单对象
Order order = convertToOrder(request, userId);
String orderId = orderService.createOrder(order);
return ApiResponse.success(orderId);
}
/**
* 获取消息角标数量
*/
@GetMapping("/message/badge")
public ApiResponse<Integer> getMessageBadge(
HttpServletRequest request) {
String userId = request.getHeader("X-User-Id");
int count = messageService.getUnreadCount(userId);
return ApiResponse.success(count);
}
// 辅助方法
private boolean validateToken(String token, String userId) {
// 实现token验证逻辑
return token != null && userId != null;
}
private Order convertToOrder(CreateOrderRequest request, String userId) {
Order order = new Order();
order.setCustomerName(request.getCustomerName());
order.setPhone(request.getPhone());
order.setAreaCode(request.getAreaCode());
order.setRemark(request.getRemark());
order.setUserId(userId);
// 转换订单明细...
return order;
}
}
这些API接口有几个关键设计:
无状态认证 原有系统依赖Session,移动端改造时改为基于Token的认证方式。用户在移动端登录后,服务端返回JWT token,后续请求通过Header传递。这样既兼容了原有的用户体系,又实现了前后端分离。
精简数据结构 移动端不需要桌面端的全部字段,因此在VO层做了精简。比如订单列表只返回关键字段,详情接口也只返回必要的信息,减少数据传输量。
统一的错误码 定义了标准的ApiResponse格式,包含code、message、data三个字段,方便前端统一处理。
性能优化:那些容易忽视的细节
改造完成后,做了全面性能测试,发现几个需要优化的点:
图片懒加载 原有系统使用大量服务器截图作为订单凭证,这些图片全部在首屏加载。我们加入了懒加载,只有进入可视区域才加载:
<!-- 使用 IntersectionObserver 实现懒加载 -->
<img
:src="item凭证Url"
:data-original="item凭证Url"
class="lazy-img"
loading="lazy"
alt="凭证"
/>
// 懒加载实现
const initLazyLoad = () => {
const images = document.querySelectorAll('.lazy-img');
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.original;
observer.unobserve(img);
}
});
}, {
rootMargin: '50px 0px'
});
images.forEach(img => observer.observe(img));
} else {
// 降级方案:监听滚动事件
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(() => {
images.forEach(img => {
const rect = img.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
img.src = img.dataset.original;
}
});
ticking = false;
});
ticking = true;
}
});
}
};
接口请求合并 原有系统每个页面都会加载多个接口,移动端首屏请求做了合并优化。比如订单列表页,将基础信息和统计数据合并到一个接口返回。
离线缓存策略 对于不常变化的数据,使用了Service Worker做缓存,提升二次访问速度。这部分代码稍后给出。
部署方案:平滑过渡,不影响现有业务
这部分很关键,改造后的系统需要和原有系统共存,逐步切换流量。
# Nginx配置示例:根据User-Agent分流
upstream pc_backend {
server 192.168.1.100:8080;
}
upstream mobile_backend {
server 192.168.1.101:8080;
}
server {
listen 80;
server_name admin.example.com;
# 移动端静态资源
location /mobile/ {
# 判断是否为移动设备
if ($http_user_agent ~* "(Android|iPhone|iPad|iPod|Mobile)") {
proxy_pass http://mobile_backend;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
# 移动端API
location /api/mobile/ {
# 优先从移动端后端获取
proxy_pass http://mobile_backend;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Is-Mobile 1;
}
# 原有PC端入口保持不变
location / {
proxy_pass http://pc_backend;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
这个配置的核心思路是:不动原有系统,新系统跑在独立的服务器上,通过Nginx根据User-Agent分流。测试期间可以逐步放量,比如先让10%的移动端流量走新系统,观察没有问题后逐步扩大到100%。
踩过的坑和解决方案
坑1:iOS Safari的弹性滚动问题 原有系统的一些组件在iOS上会出现弹性滚动效果,导致内容偏移。解决方案是在CSS中添加:
html, body {
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
坑2:AndroidWebView的字体缩放 部分Android设备会根据系统字体大小自动缩放WebView内容,导致布局错乱。需要在入口处强制设置:
// 检测Android并禁用字体缩放
if (/Android/i.test(navigator.userAgent)) {
document.documentElement.style.fontSize = '16px';
}
坑3:微信环境下的JS-SDK限制 部分功能需要调用微信JS-SDK,但微信对域名有白名单限制。解决方案是:
- 在微信公众平台配置JS接口安全域名
- 对于未配置域名的环境,提供降级方案(直接打开H5页面)
坑4:老旧JSP页面的兼容性 部分JSP页面使用了jQuery 1.8,存在兼容性问题。我们的做法是:这些页面继续走PC端路由,移动端使用新开发的页面替代,通过数据接口保持功能一致。
改造效果对比
项目上线三个月后的数据:
性能指标:
- 首屏加载时间:从平均4.2秒降至1.8秒
- 页面响应率:从68%提升至94%
- 接口错误率:从12%降至3%
业务指标:
- 移动端订单创建量:占总量35%(改造前几乎为零)
- 移动端审批时效:从平均2.3天缩短至0.8天
- 销售团队满意度:从3.2分提升至4.6分(满分5分)
成本对比:
- 总投入:约15人天(2人前端 + 1人后端 + 1人测试)
- 对比原生App开发:节省约60%成本
- 对比纯H5封装方案:用户体验提升显著
给正在考虑做类似改造的你几点建议
不要试图一次性改造所有功能 我们采用的是”核心流程优先”策略,先做订单创建、审批这两个最高频的场景,其他功能逐步补充。这样既能快速见到效果,又能控制风险。
移动端不是PC端的缩小版 很多团队在改造时会直接把PC页面缩放显示,这完全是错误的。移动端的交互逻辑、信息密度、使用场景都和PC端不同,需要从用户研究开始重新设计。
做好灰度发布 原有系统可能承载了重要业务,突然切换风险很大。我们的灰度方案是:新系统先对内开放,内部团队使用两周确认没有问题后,再逐步向业务团队开放。
重视测试覆盖 移动设备碎片化严重,我们准备了主流机型清单(iPhone 8及以上、主流Android品牌),对每个关键功能都进行了真机测试。模拟器测试覆盖不到真实用户的使用场景。
这次改造做完后,我们团队总结了一套方法论:设备无关性设计、渐进式增强、功能可降级。这套方法论后续用在了其他几个系统的移动化改造中,效果都不错。
如果你也在做类似的项目,欢迎交流。每个项目都有独特的挑战,但底层逻辑是相通的——以用户为中心,从小处着手,逐步迭代。
