项目概述
金贝移动端是一个面向房产经纪人的综合性商机管理平台,提供潜客包、准客包、钻展、商机直通车、CPL/CPT、金贝会员等多种商机产品的购买、管理和跟进服务。项目采用前后端分离架构,前端使用 React 技术栈,后端使用 Node.js 中间层,支持多端(APP 内嵌、H5)访问。
核心业务模块
潜客包/准客包管理:商机包的购买、查看、跟进、转委托、退名额等全流程管理
钻展(抢购、报买):广告位购买与管理
商机直通车:CPA 商机购买
CPL/CPT:按线索/按时间计费的商机包
金贝会员:会员权益管理
订单管理:订单列表、详情、支付
数据看板:商机数据统计与分析
预算池:预算管理与分配
🛠️ 技术栈
前端技术栈
技术版本用途React^16.8.6UI 框架Redux^4.0.1状态管理React Router^5.1.0路由管理antd-mobile^2.3.1 / ^5.34.0UI 组件库(v2 + v5 混用)dayjs^1.8.8日期时间处理axios^0.19.2HTTP 请求库webpack^5.75.0构建工具less^4.1.3CSS 预处理器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:移动端调试工具
第三方服务集成
✨ 项目亮点
- 完整的商机处理闭环设计
业务价值:实现了从商机领取到关闭的全流程管理,提升经纪人工作效率。
技术实现:
统一的交互入口:通过 bottomAreaClick 方法统一处理所有商机操作
多场景适配:根据商机来源(opptySource)、类型(新房/二手/租赁)、状态(opptyStatus)动态展示操作按钮
操作类型包括:
加私/转委托:根据 conversionType 动态展示,支持预校验和跳转
联系:区分电话(contactType === 1)和 IM(contactType === 2)
记录跟进:弹窗录入,支持局部数据更新
申请退名额:复杂的时间规则和风控校验
查看工单:跳转司南工单系统
代码示例:
javascript 体验AI代码助手 代码解读复制代码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
// ... 其他操作}
}
- 复杂状态标签体系的可视化呈现
业务价值:通过标签系统清晰展示商机状态,降低业务理解成本。
技术实现:
动态标签生成:根据 conventionLevel、isGiven、isWeihupan 等状态动态拼装标签数组
交互式标签:点击特定标签(赠送、已联系、未联系、已委托)显示业务提示
样式差异化:不同标签使用不同颜色和样式(如 赠送 红色、维护盘商机 金色背景)
代码示例:
javascript 体验AI代码助手 代码解读复制代码// 标签动态生成
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)
}}
}
- 多端跳转与埋点闭环
业务价值:打通 APP、H5、工单系统等多个平台,实现数据追踪和运营分析。
技术实现:
统一埋点封装:sendDig(clickId, opptyId) 方法统一上报点击事件
多端跳转封装:通过 Utils 工具类封装拨打电话、IM 联系、跳转客户端等功能
环境区分:根据 window.location.host 区分测试和正式环境,使用不同的跳转链接
埋点数据:包含 opptyId、agent_ucid、click_id、c_uicode 等关键字段
代码示例:
javascript 体验AI代码助手 代码解读复制代码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,
},
})}
}
- 列表性能与体验优化
业务价值:提升移动端加载速度和用户体验,降低服务器压力。
技术实现:
无限滚动:使用 antd-mobile-v5 的 InfiniteScroll 组件实现分页加载
滚动位置保持:记录跟进后恢复滚动位置,避免页面跳回顶部
局部数据更新:通过回调函数 addTempRecordData 更新列表中的单条数据,避免全量刷新
按需插入数据:根据权限和数据返回情况动态插入数据概览卡片
代码示例:
javascript 体验AI代码助手 代码解读复制代码// 无限滚动
<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)})
}
- 统一请求封装与错误处理
业务价值:统一 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() 方法统一获取场景类型,避免重复判断
代码示例:
javascript 体验AI代码助手 代码解读复制代码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 提升可读性
用户提示:在弹窗中明确展示截止时间,提升用户体验
代码示例:
javascript 体验AI代码助手 代码解读复制代码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)
代码示例:
javascript 体验AI代码助手 代码解读复制代码// 父组件:记录滚动位置
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 和回调函数统一组件间的通信接口
工具函数封装:将通用逻辑抽离成工具函数,避免重复代码
文档记录:在代码注释中标注版本差异和注意事项
代码示例:
javascript 体验AI代码助手 代码解读复制代码// 使用 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}
/>
收获:通过渐进式升级和统一接口设计,在保证项目稳定性的同时,逐步引入新技术,为后续整体重构打下基础。
📁 项目结构
bash 体验AI代码助手 代码解读复制代码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
🔧 核心功能实现
潜客包列表加载
javascript 体验AI代码助手 代码解读复制代码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,
})
})
}虚拟号码拨打
javascript 体验AI代码助手 代码解读复制代码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) }, ] )}
})
}转委托流程
javascript 体验AI代码助手 代码解读复制代码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 实现分页加载,避免一次性加载大量数据
局部更新:通过回调函数更新局部数据,避免全量刷新
🐛 常见问题与解决方案
- 鸿蒙系统 Picker 组件滑动穿透
问题:antd-mobile v2 的 Picker 组件在鸿蒙系统滑动选择时会有滑动穿透 bug。
解决方案:使用 components/WithTouchMoveControlHoc 高阶组件包裹 Picker 组件。 - Node 版本兼容
问题:client 端使用 Node 14,server 端使用 Node 12。
解决方案:使用 nvm 进行 Node 版本切换,或在启动脚本中自动切换。 - VPN 冲突
问题:Node 层加了 Redis 之后,挂了外网 VPN 项目启动不起来。
解决方案:关闭 VPN 后再启动项目。
🚀 部署流程
开发环境:
bash 体验AI代码助手 代码解读复制代码npm install
npm start # 自动切换 Node 版本并启动前后端
📝 总结
技术收获
复杂业务逻辑处理:通过统一入口函数和预校验封装,将复杂的业务规则清晰地表达出来
用户体验优化:通过滚动位置保持、局部数据更新等技术手段,提升用户操作体验
多端适配:通过 JSBridge 和工具函数封装,实现 APP 和 H5 的统一适配
性能优化:通过无限滚动、代码分割等技术,提升页面加载速度和运行性能
业务理解
商机管理全流程:深入理解了从商机产生到关闭的完整业务流程
风控规则:理解了时间规则、风控拦截等业务规则的设计思路
数据埋点:理解了埋点数据对运营分析和产品优化的重要性
项目价值
提升效率:通过完整的商机处理闭环,大幅提升经纪人的工作效率
规范操作:通过统一的交互入口和校验逻辑,规范了业务操作流程
数据支撑:通过完善的埋点体系,为后续的运营分析和策略优化提供了数据支撑
作者:用户4481853632859
链接:https://juejin.cn/post/7600068631359143942
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。