h5移动端项目总结
移动端项目总结
项目名称:金贝移动端(fe-banner-pub-mobile)
项目定位:面向经纪人的商机购买与管理 H5 平台
📋 项目概述
金贝移动端是一个面向房产经纪人的综合性商机管理平台,提供潜客包、准客包、钻展、商机直通车、CPL/CPT、金贝会员等多种商机产品的购买、管理和跟进服务。项目采用前后端分离架构,前端使用 React 技术栈,后端使用 Node.js 中间层,支持多端(APP 内嵌、H5)访问。
核心业务模块
- 潜客包/准客包管理:商机包的购买、查看、跟进、转委托、退名额等全流程管理
- 钻展(抢购、报买):广告位购买与管理
- 商机直通车:CPA 商机购买
- CPL/CPT:按线索/按时间计费的商机包
- 金贝会员:会员权益管理
- 订单管理:订单列表、详情、支付
- 数据看板:商机数据统计与分析
- 预算池:预算管理与分配
🛠️ 技术栈
前端技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| React | ^16.8.6 | UI 框架 |
| Redux | ^4.0.1 | 状态管理 |
| React Router | ^5.1.0 | 路由管理 |
| antd-mobile | ^2.3.1 / ^5.34.0 | UI 组件库(v2 + v5 混用) |
| dayjs | ^1.8.8 | 日期时间处理 |
| axios | ^0.19.2 | HTTP 请求库 |
| webpack | ^5.75.0 | 构建工具 |
| less | ^4.1.3 | CSS 预处理器 |
| react-hot-loader | ^4.3.12 | 热更新 |
| @antv/f2 | ^3.8.6 | 移动端图表库 |
后端技术栈
- Node.js (v12)
- TypeScript
- Express/Koa (中间层)
- Redis (缓存)
开发工具
- Babel:ES6+ 转译
- ESLint:代码规范
- PostCSS:CSS 后处理(px2rem、px-to-viewport)
- vconsole:移动端调试工具
第三方服务集成
✨ 项目亮点
1. 完整的商机处理闭环设计
业务价值:实现了从商机领取到关闭的全流程管理,提升经纪人工作效率。
技术实现:
- 统一的交互入口:通过
bottomAreaClick方法统一处理所有商机操作 - 多场景适配:根据商机来源(
opptySource)、类型(新房/二手/租赁)、状态(opptyStatus)动态展示操作按钮 - 操作类型包括:
-
加私/转委托:根据
conversionType动态展示,支持预校验和跳转 -
联系:区分电话(
contactType === 1)和 IM(contactType === 2) - 记录跟进:弹窗录入,支持局部数据更新
- 申请退名额:复杂的时间规则和风控校验
- 查看工单:跳转司南工单系统
-
加私/转委托:根据
代码示例:
bottomAreaClick = (e, item, desc, callBack) => {
const { opptyId, opptyProcessVo, custId } = item
const { contactType, conversionType } = opptyProcessVo
e.stopPropagation()
switch (desc) {
case '加私':
this.openXinfang(conversionPageScheme, opptyId)
break
case '联系':
contactType == 2 ? this.imCustomer(custId, opptyId) : this.callTel(opptyId)
break
// ... 其他操作
}
}
2. 复杂状态标签体系的可视化呈现
业务价值:通过标签系统清晰展示商机状态,降低业务理解成本。
技术实现:
- 动态标签生成:根据
conventionLevel、isGiven、isWeihupan等状态动态拼装标签数组 - 交互式标签:点击特定标签(赠送、已联系、未联系、已委托)显示业务提示
- 样式差异化:不同标签使用不同颜色和样式(如
赠送红色、维护盘商机金色背景)
代码示例:
// 标签动态生成
if (isGiven && Array.isArray(tags)) {
tags.unshift('赠送')
}
if (+conventionLevel === -1) {
tags.push('待委托')
}
if (+conventionLevel === 0) {
tags.push('已联系')
}
if (+conventionLevel === 1) {
tags.push('已委托')
}
// 标签点击交互
isGivenClick = (evt, item, shangjiItem) => {
if (item === '已联系') {
if (shangjiItem.opptySource === '400') {
Toast.info('您已拨打过客户电话', 2)
} else {
Toast.info('您已回复过客户消息', 2)
}
}
}
3. 多端跳转与埋点闭环
业务价值:打通 APP、H5、工单系统等多个平台,实现数据追踪和运营分析。
技术实现:
-
统一埋点封装:
sendDig(clickId, opptyId)方法统一上报点击事件 -
多端跳转封装:通过
Utils工具类封装拨打电话、IM 联系、跳转客户端等功能 -
环境区分:根据
window.location.host区分测试和正式环境,使用不同的跳转链接 -
埋点数据:包含
opptyId、agent_ucid、click_id、c_uicode等关键字段
代码示例:
sendDig(clickId, opptyId) {
if (window.$ULOG) {
window.$ULOG.send(this.evtId, {
event: 'mModuleClick',
action: {
opptyId,
c_uicode: 'qiankebao_qiankebaoliebiao',
click_id: clickId,
agent_ucid: window._GLOBAL_DATA.userInfo.id,
},
})
}
}
4. 列表性能与体验优化
业务价值:提升移动端加载速度和用户体验,降低服务器压力。
技术实现:
-
无限滚动:使用
antd-mobile-v5的InfiniteScroll组件实现分页加载 - 滚动位置保持:记录跟进后恢复滚动位置,避免页面跳回顶部
-
局部数据更新:通过回调函数
addTempRecordData更新列表中的单条数据,避免全量刷新 - 按需插入数据:根据权限和数据返回情况动态插入数据概览卡片
代码示例:
// 无限滚动
<InfiniteScroll
loadMore={() => {
this.getPackages()
}}
hasMore={this.state.packages.pageNum < this.state.packages.totalPage}
threshold={120}
/>
// 滚动位置保持
record = (data) => {
this.setState({ textareaModal: false })
KeFetch(api.saveRecord, { method: 'post', data }).then(() => {
document.documentElement.scrollTo(0, this.ScroolTop)
this.genjiCallBack && this.genjiCallBack(data)
})
}
5. 统一请求封装与错误处理
业务价值:统一 API 调用规范,提升代码可维护性和错误处理能力。
技术实现:
-
KeFetch 封装:基于
Ketch库封装统一请求方法 -
多环境适配:支持 Node 层(
SUCCESS_CODE_NODE: 100000)和 H5 层(SUCCESS_CODE_H5: 1)不同的成功码 - 统一错误提示:自动处理错误码并展示 Toast 提示
- 超时控制:默认 20s 超时
🎯 项目难点
难点一:多状态、多来源商机的分支逻辑复杂
问题描述:
- 不同商机来源(
OPPTY_POOL、SSC、CUSTOMER_CLUE、400等)有不同的处理逻辑 - 不同商机类型(新房/二手/租赁)需要不同的跳转路径
- 不同场景(
OPPTY、ZHUN_KE_BAO、B_PLUS、CPS)影响功能展示
解决方案:
-
统一入口函数:通过
bottomAreaClick方法统一处理所有操作,内部根据desc参数分支处理 -
预校验封装:将复杂的校验逻辑抽离成独立方法(如
delegatePrecheck、refundPrecheck) -
状态机模式:使用
switch-case清晰表达不同操作的处理流程 -
配置化:通过
getOpptyType()方法统一获取场景类型,避免重复判断
代码示例:
getOpptyType = () => {
const { type } = Utils.getUrlParams()
if (type === ZHUN_KE_BAO) return ZHUN_KE_BAO
else if (type === B_PLUS) return B_PLUS
else if (type === CPS) return CPS
else return OPPTY
}
// 转委托预校验
inputCustomer = async (d, conversionPageScheme) => {
if (d.opptySource == 'OPPTY_POOL' || d.opptySource == 'SSC') {
KeFetch(api.delegatePrecheck, { data: { opptyId: d.opptyId } })
.then((data) => {
if (data.resultCode == 1) {
// 继续转委托流程
} else {
Modal.alert('提示', data.tip || '')
}
})
}
}
收获:通过统一入口和预校验封装,将复杂的业务逻辑集中管理,提升了代码的可维护性和可扩展性。
难点二:时间规则与风控、退款规则耦合
问题描述:
- 申请退名额需要满足多个条件:
- 商机下发时间需满 25 小时
- 风控拦截:14 天内提交不通过工单超过 3 次
- 后端预校验:
refundPrecheck接口返回是否允许退款
- 时间计算和展示需要精确到秒
解决方案:
-
时间库统一:使用
dayjs统一处理所有时间计算和格式化 -
状态分离:将不同场景拆解成独立的 Modal 状态(
antiCheatModal、refundModal) -
链式调用:使用
dayjs(opptyTime).add(25, 'hour')等链式 API 提升可读性 - 用户提示:在弹窗中明确展示截止时间,提升用户体验
代码示例:
case '申请退名额':
const { opptyStatus, opptyTime } = item
const deadline = dayjs(opptyTime).add(25, 'hour')
if (opptyStatus === 4) {
// 风控拦截
this.setState({ antiCheatModal: true })
} else if (!dayjs().isAfter(deadline)) {
// 时间未到
this.setState({
refundModal: true,
deadline: deadline.format('YYYY-MM-DD HH:mm:ss'),
})
} else {
// 后端预校验
KeFetch(api.refundPrecheck, { data: { opptyId } }).then((res) => {
const { refund, remark } = res
if (remark) {
Modal.alert('提示', remark, [/* ... */])
} else if (!refund) {
this.onNext(item) // 跳转司南
}
})
}
break
收获:通过时间库统一和状态分离,将复杂的业务规则清晰地表达出来,便于后续根据运营策略调整。
难点三:列表内局部数据更新与用户滚动位置保持
问题描述:
- 用户在列表中点击「记录跟进」后,需要更新对应商机的
opptyBrief数据 - 更新后页面不能跳回顶部,需要保持用户当前的滚动位置
- 需要支持新增和更新两种场景
解决方案:
-
滚动位置缓存:在打开弹窗前记录当前滚动位置
this.ScroolTop -
回调函数传递:通过
bottomAreaClick的callBack参数传递更新函数 -
局部状态更新:在子组件中通过
useState维护列表状态,通过addTempRecordData方法更新单条数据 -
恢复滚动位置:接口成功后调用
document.documentElement.scrollTo(0, this.ScroolTop)
代码示例:
// 父组件:记录滚动位置
case '记录跟进':
this.genjiCallBack = callBack
this.ScroolTop = document.body.scrollTop || document.documentElement.scrollTop
this.genji(opptyId, opptyRemarkVo || {})
break
// 父组件:恢复滚动位置
record = (data) => {
this.setState({ textareaModal: false })
KeFetch(api.saveRecord, { method: 'post', data }).then(() => {
document.documentElement.scrollTo(0, this.ScroolTop)
this.genjiCallBack && this.genjiCallBack(data)
})
}
// 子组件:局部数据更新
const addTempRecordData = recordData => {
const newData = opptyListState.map(item => {
if (item.opptyId === recordData.opptyId) {
if (Array.isArray(item.opptyBrief)) {
const result = item.opptyBrief.filter(item2 => item2.name === '跟进反馈')
if (result.length <= 0) {
item.opptyBrief.push({
name: '跟进反馈',
desc: `${recordData.opptyFlagName},${recordData.remark}`,
})
} else {
item.opptyBrief.map(item2 => {
if (item2.name === '跟进反馈') {
item2.desc = `${recordData.opptyFlagName},${recordData.remark}`
}
return item2
})
}
}
}
return item
})
setOpptyListState(newData)
}
收获:通过滚动位置缓存和回调函数机制,实现了局部数据更新和用户体验的平衡,避免了全量刷新带来的性能问题。
难点四:老项目技术栈混用带来的兼容问题
问题描述:
- 项目中同时使用 antd-mobile v2 和 v5 两个版本
- 类组件和函数组件混用(
QianKe是类组件,waitDeal是函数组件) - 需要兼容不同版本的 API 和组件特性
解决方案:
-
渐进式升级:新功能使用新版本(如
InfiniteScroll使用 v5),老功能保持原样 - 统一接口设计:通过 props 和回调函数统一组件间的通信接口
- 工具函数封装:将通用逻辑抽离成工具函数,避免重复代码
- 文档记录:在代码注释中标注版本差异和注意事项
代码示例:
// 使用 v5 的 InfiniteScroll
import { InfiniteScroll } from 'antd-mobile-v5'
// 使用 v2 的 Modal、Toast
import { Modal, Toast } from 'antd-mobile'
// 统一回调接口
<QkbOrder
data={item}
onClick={this.itemClick}
onSpeedClick={this.executeSpeed}
bottomAreaClick={this.bottomAreaClick}
purposeValiageClick={this.purposeValiageClick}
/>
收获:通过渐进式升级和统一接口设计,在保证项目稳定性的同时,逐步引入新技术,为后续整体重构打下基础。
📁 项目结构
fe-banner-pub-mobile/
├── client/ # 前端代码
│ ├── src/
│ │ ├── components/ # 公共组件
│ │ │ └── QkbOrder/ # 潜客包订单组件
│ │ │ └── component/
│ │ │ └── waitDeal.js # 待处理商机列表
│ │ ├── containers/ # 页面容器
│ │ │ └── ShangJi/ # 商机模块
│ │ │ ├── Package/ # 我的商品
│ │ │ │ └── Qianke/ # 潜客包页面
│ │ │ ├── Home/ # 首页
│ │ │ ├── Data/ # 数据看板
│ │ │ └── Order/ # 订单管理
│ │ ├── config/ # 配置文件
│ │ │ ├── apiConfig.js # API 配置
│ │ │ └── digConfig.js # 埋点配置
│ │ ├── utils/ # 工具函数
│ │ │ ├── keFetch.js # 请求封装
│ │ │ └── storage.js # 本地存储
│ │ ├── router/ # 路由配置
│ │ ├── store/ # Redux store
│ │ └── App.js # 根组件
│ ├── webpack/ # Webpack 配置
│ └── package.json
├── server/ # Node.js 中间层
│ ├── src/
│ │ ├── apis/ # API 接口
│ │ ├── actions/ # 业务逻辑
│ │ └── configs/ # 配置文件
│ └── package.json
└── README.md
🔧 核心功能实现
1. 潜客包列表加载
getPackages(Kdata) {
const pageNum = (this.state.packages.pageNum || 0) + 1
const scene = this.getOpptyType()
KeFetch(api.getPackages, {
data: {
cityCode: workCity(),
pageSize: 10,
pageNum,
scene,
},
}).then((data) => {
// 根据权限动态插入数据概览
if (data.statisticsShow && Kdata && scene === OPPTY) {
const hasDataOverviewPermission = (window._GLOBAL_DATA.userInfo.perms || [])
.includes('BRAND_M_qianke_dataOverview')
if (!this.hasInsert && hasDataOverviewPermission) {
data.list.unshift({ ...Kdata, type: 'qkDataOverview' })
this.hasInsert = true
}
}
data.list = [...(this.state.packages.list || []), ...data.list]
this.setState({
packages: data,
loading: false,
})
})
}
2. 虚拟号码拨打
callTel = (opptyId) => {
this.sendDig(10016, opptyId)
KeFetch(api.getShangjiVirtualPhone, { data: { opptyId } })
.then((data) => {
if (data.virtualPhone) {
Modal.alert(
`客户电话${data.virtualPhone}`,
'此号码为虚拟号码,非客户真实号码',
[
{ text: '取消', onPress: () => {} },
{ text: '立即拨打', onPress: () => Utils.callTelphone(data.virtualPhone) },
]
)
}
})
}
3. 转委托流程
inputCustomer = async (d, conversionPageScheme) => {
// 预校验
if (d.opptySource == 'OPPTY_POOL' || d.opptySource == 'SSC') {
const precheckData = await KeFetch(api.delegatePrecheck, {
data: { opptyId: d.opptyId },
})
if (precheckData.resultCode !== 1) {
Modal.alert('提示', precheckData.tip || '')
return
}
}
// 执行转委托
const res = await KeFetch(api.delegateQiankeOpportunity, {
data: { opptyId: d.opptyId },
})
if (res.result) {
Modal.alert('提示', res.resultDesc || '', [
{
text: '去编辑委托',
onPress: () => {
Utils.inputCustomer(() => {
Modal.alert('请先将客户端升级至最新版本', '', [
{ text: '取消' },
{ text: '立即升级', onPress: () => Utils.openAbout() },
])
}, res.conversionPageScheme)
},
},
])
}
}
📊 性能优化
- 代码分割:使用 Webpack 的 code splitting 按路由分割代码
-
图片优化:使用
url-loader和file-loader处理图片资源 -
CSS 优化:使用
mini-css-extract-plugin提取 CSS,使用px2rem适配移动端 -
无限滚动:使用
InfiniteScroll实现分页加载,避免一次性加载大量数据 - 局部更新:通过回调函数更新局部数据,避免全量刷新
🐛 常见问题与解决方案
1. 鸿蒙系统 Picker 组件滑动穿透
问题:antd-mobile v2 的 Picker 组件在鸿蒙系统滑动选择时会有滑动穿透 bug。
解决方案:使用 components/WithTouchMoveControlHoc 高阶组件包裹 Picker 组件。
2. Node 版本兼容
问题:client 端使用 Node 14,server 端使用 Node 12。
解决方案:使用 nvm 进行 Node 版本切换,或在启动脚本中自动切换。
3. VPN 冲突
问题:Node 层加了 Redis 之后,挂了外网 VPN 项目启动不起来。
解决方案:关闭 VPN 后再启动项目。
🚀 部署流程
-
开发环境:
npm install npm start # 自动切换 Node 版本并启动前后端
📝 总结
技术收获
- 复杂业务逻辑处理:通过统一入口函数和预校验封装,将复杂的业务规则清晰地表达出来
- 用户体验优化:通过滚动位置保持、局部数据更新等技术手段,提升用户操作体验
- 多端适配:通过 JSBridge 和工具函数封装,实现 APP 和 H5 的统一适配
- 性能优化:通过无限滚动、代码分割等技术,提升页面加载速度和运行性能
业务理解
- 商机管理全流程:深入理解了从商机产生到关闭的完整业务流程
- 风控规则:理解了时间规则、风控拦截等业务规则的设计思路
- 数据埋点:理解了埋点数据对运营分析和产品优化的重要性
项目价值
- 提升效率:通过完整的商机处理闭环,大幅提升经纪人的工作效率
- 规范操作:通过统一的交互入口和校验逻辑,规范了业务操作流程
- 数据支撑:通过完善的埋点体系,为后续的运营分析和策略优化提供了数据支撑
📚 相关文档
最后更新:2024年