chuangte_bike_newxcx/page_shanghu/guanli/agent_order.vue

861 lines
24 KiB
Vue
Raw Normal View History

2026-06-02 16:35:58 +08:00
<template>
<view class="page">
<u-navbar title="代客下单" :border-bottom="false" :background="bgc" title-color="#2E4975" title-size="36"
back-icon-color="#2E4975" height="45"></u-navbar>
<scroll-view scroll-y class="scroll-body">
<view class="scroll-inner">
<!-- 车辆信息 -->
<view class="vehicle-wrap" v-if="deviceInfo" hover-class="app-tap-hover" @click="openDeviceSelector">
<view class="change-device-icon">
<u-icon name="reload" color="#4C97E7" size="28"></u-icon>
</view>
<VehicleCard :bikeobj="deviceInfo" :iconobj="iconobj" />
</view>
<view class="device-loading" v-else-if="deviceLoading">
<u-loading mode="circle" size="40" color="#4C97E7"></u-loading>
<text>车辆信息加载中...</text>
</view>
<CustomerSearchCard
:phone-number="phoneNumber"
:target-user="targetUser"
@update:phoneNumber="phoneNumber = $event"
@search="searchUser"
/>
<block v-if="targetUser">
<!-- 套餐选择与用户端一致 -->
<view class="package-section">
<view class="section-head">
<text class="section-title">选择套餐</text>
</view>
<view class="package-wrap" v-if="suitList.length > 0">
<PackageSelector
:taocanlist="suitList"
:fanganindex="fanganindex"
:bikeobj="packageBikeobj"
:actiobj="packageActiobj"
:instructions="packageInstructions"
@select="onPackageSelect"
/>
</view>
<view class="empty-tip" v-else>暂无可用套餐</view>
</view>
<PayChannelSelector
:channel-list="channelList"
:selected-channel-id="selectedChannelId"
@select="selectChannel"
/>
<InsuranceSelector
:insurance-device-current="insuranceDeviceCurrent"
:selected-insurance-id="selectedInsuranceId"
@toggle="toggleInsurance"
@skip="skipInsurance"
/>
<view class="scroll-bottom-spacer"></view>
</block>
</view>
</scroll-view>
<AgentOrderFooter
v-if="targetUser"
:display-deposit="displayDeposit"
:selected-suit-id="selectedSuitId"
:submitting="submitting"
@open-detail="openPriceDetail"
@submit="submitOrder"
/>
<DeviceSelectorPopup
:visible="deviceSelectorShow"
:area-id="areaId"
:current-device-id="deviceId"
@update:visible="deviceSelectorShow = $event"
@select="switchDevice"
/>
<AgentOrderConfirmModal
:visible="showConfirmModal"
:target-user="targetUser"
:selected-suit="selectedSuit"
:display-deposit="displayDeposit"
:selected-channel-obj="selectedChannelObj"
:price-info="priceInfo"
@close="closeConfirmModal"
@confirm="confirmScanOrder"
/>
<PriceDetailPopup
:visible="priceDetailShow"
:price-info="priceInfo"
:display-deposit="displayDeposit"
@update:visible="priceDetailShow = $event"
/>
</view>
</template>
<script>
import { ChannelApiType } from '@/common/enums/channel';
import PackageSelector from '@/components/order/PackageSelector.vue';
import VehicleCard from '@/components/order/VehicleCard.vue';
import AgentOrderConfirmModal from './components/AgentOrderConfirmModal.vue';
import AgentOrderFooter from './components/AgentOrderFooter.vue';
import CustomerSearchCard from './components/CustomerSearchCard.vue';
import DeviceSelectorPopup from './components/DeviceSelectorPopup.vue';
import InsuranceSelector from './components/InsuranceSelector.vue';
import PayChannelSelector from './components/PayChannelSelector.vue';
import PriceDetailPopup from './components/PriceDetailPopup.vue';
export default {
components: {
PackageSelector,
VehicleCard,
AgentOrderConfirmModal,
AgentOrderFooter,
CustomerSearchCard,
DeviceSelectorPopup,
InsuranceSelector,
PayChannelSelector,
PriceDetailPopup
},
data() {
return {
bgc: { backgroundColor: '#EEF3FA' },
deviceId: '',
areaId: '',
deviceInfo: null,
deviceLoading: false,
iconobj: {},
phoneNumber: '',
targetUser: null,
suitList: [],
fanganindex: 0,
channelList: [],
/** /app/insuranceDevice/current 有 data 时非空,与用户端一致 */
insuranceDeviceCurrent: null,
selectedSuitId: null,
selectedSuit: null,
selectedChannelId: null,
selectedInsuranceId: null,
priceInfo: null,
submitting: false,
priceDetailShow: false,
showConfirmModal: false,
deviceSelectorShow: false,
deviceLoadSeq: 0,
priceCalcSeq: 0,
offlinePayNo: '',
pendingOfflineAudit: false,
offlineAuditHandled: false
}
},
computed: {
selectedChannelObj() {
if (!this.selectedChannelId) return null
return this.channelList.find(c => c.id === this.selectedChannelId) || null
},
displayDeposit() {
if (this.priceInfo && this.priceInfo.depositPrice != null) {
let total = Number(this.priceInfo.depositPrice) || 0
if (this.priceInfo.insureFee) {
total += Number(this.priceInfo.insureFee) || 0
}
return total
}
return '--'
},
packageBikeobj() {
return this.deviceInfo || { areaId: this.areaId }
},
packageActiobj() {
const suit = this.selectedSuit
return {
freeRideTime: suit ? suit.freeRideTime : null
}
},
packageInstructions() {
return this.selectedSuit && this.selectedSuit.instructions ? this.selectedSuit.instructions : ''
}
},
onLoad(e) {
this.deviceId = e.deviceId || ''
this.areaId = e.areaId || ''
this.iconobj = this.$store.state.iconobj || {}
this.loadDevice()
},
onShow() {
this.handleOfflineAuditReturn()
},
methods: {
readOfflineAuditResult() {
let result = uni.getStorageSync('agentOrderOfflineResult')
if (!result) return null
if (typeof result === 'string') {
try {
result = JSON.parse(result)
} catch (e) {
return null
}
}
return result && typeof result === 'object' ? result : null
},
handleOfflineAuditReturn() {
if (!this.pendingOfflineAudit) {
uni.removeStorageSync('agentOrderOfflineResult')
return
}
const result = this.readOfflineAuditResult()
if (!result) return
this.processOfflineAuditResult(result)
},
processOfflineAuditResult(result) {
if (!result || this.offlineAuditHandled) return
this.offlineAuditHandled = true
this.pendingOfflineAudit = false
uni.removeStorageSync('agentOrderOfflineResult')
if (result.pass) {
const payNo = result.payNo || this.offlinePayNo
this.offlinePayNo = payNo || this.offlinePayNo
if (payNo) {
this.submitting = true
uni.showLoading({ title: '确认支付结果...', mask: true })
this.pollPayResult(payNo)
return
}
this.submitting = false
uni.showToast({ title: '下单成功', icon: 'success', duration: 1500 })
setTimeout(() => { uni.navigateBack() }, 1500)
return
}
this.submitting = false
this.offlinePayNo = ''
if (result.rejected) {
uni.showToast({ title: '线下支付审核已驳回', icon: 'none', duration: 2500 })
return
}
if (result.canceled) {
uni.showToast({ title: '已取消线下支付审核', icon: 'none', duration: 2000 })
}
},
// 加载车辆信息
loadDevice() {
if (!this.deviceId) return
const seq = ++this.deviceLoadSeq
this.deviceLoading = true
this.deviceInfo = null
this.resetDeviceRelatedState()
this.$u.get(`/bst/device?id=${this.deviceId}`).then(res => {
if (seq !== this.deviceLoadSeq) return
this.deviceLoading = false
if (res.code === 200 && res.data) {
this.deviceInfo = res.data
if (res.data.areaId) {
this.areaId = res.data.areaId
}
this.loadSuits()
this.fetchInsuranceDeviceCurrent()
this.loadChannels()
}
}).catch(() => {
if (seq !== this.deviceLoadSeq) return
this.deviceLoading = false
uni.showToast({ title: '车辆信息加载失败', icon: 'none', duration: 2000 })
})
},
resetDeviceRelatedState() {
this.suitList = []
this.fanganindex = 0
this.channelList = []
this.insuranceDeviceCurrent = null
this.selectedSuitId = null
this.selectedSuit = null
this.selectedChannelId = null
this.selectedInsuranceId = null
this.priceInfo = null
this.priceCalcSeq++
this.priceDetailShow = false
this.showConfirmModal = false
},
openDeviceSelector() {
if (this.submitting) return
this.deviceSelectorShow = true
},
switchDevice(device) {
const id = device && (device.id != null ? device.id : device.deviceId)
if (id === undefined || id === null || String(id).trim() === '') {
uni.showToast({ title: '车辆信息异常', icon: 'none', duration: 2000 })
return
}
if (String(id) === String(this.deviceId)) {
return
}
this.deviceId = id
if (device.areaId) {
this.areaId = device.areaId
}
this.loadDevice()
},
// 按车型加载套餐(与用户端一致)
loadSuits() {
const seq = this.deviceLoadSeq
const modelId = this.deviceInfo && this.deviceInfo.modelId
if (!modelId) {
this.suitList = []
this.selectedSuitId = null
this.selectedSuit = null
this.priceInfo = null
return
}
this.$u.get(`/app/suit/listByModel?temp=false&modelId=${modelId}`).then(res => {
if (seq !== this.deviceLoadSeq) return
if (res.code === 200) {
const list = res.data || []
this.suitList = this.normalizeTaocanList(list)
if (this.suitList.length > 0) {
this.fanganindex = 0
this.selectSuit(this.suitList[0])
} else {
this.selectedSuitId = null
this.selectedSuit = null
this.priceInfo = null
}
}
})
},
// 加载支付渠道列表scene=MCH 商户场景)
loadChannels() {
const seq = this.deviceLoadSeq
const appId = this.$store.state.appid || ''
let url = `/app/channel/list?scene=MCH&bstType=1&appType=1&appId=${appId}`
if (this.areaId) url += `&areaId=${this.areaId}`
this.$u.get(url).then(res => {
if (seq !== this.deviceLoadSeq) return
if (res.code === 200) {
this.channelList = res.data || []
if (this.channelList.length > 0) {
this.selectChannel(this.channelList[0])
} else {
this.selectedChannelId = null
this.priceInfo = null
}
}
})
},
// 加载当前设备保险(与用户端一致)
fetchInsuranceDeviceCurrent() {
const seq = this.deviceLoadSeq
const raw = this.deviceInfo
if (!raw || typeof raw !== 'object') {
this.insuranceDeviceCurrent = null
this.selectedInsuranceId = null
return
}
const did = raw.id != null && raw.id !== '' ? raw.id : raw.deviceId
if (did === undefined || did === null || String(did).trim() === '') {
this.insuranceDeviceCurrent = null
this.selectedInsuranceId = null
return
}
this.insuranceDeviceCurrent = null
this.selectedInsuranceId = null
this.$u
.get(`/app/insuranceDevice/current?deviceId=${encodeURIComponent(String(did))}`)
.then((res) => {
if (seq !== this.deviceLoadSeq) return
if (res.code != 200) {
this.insuranceDeviceCurrent = null
this.selectedInsuranceId = null
return
}
if (!this._insuranceCurrentDataHasValue(res.data)) {
this.insuranceDeviceCurrent = null
this.selectedInsuranceId = null
return
}
this.insuranceDeviceCurrent = res.data
this.selectedInsuranceId = null
})
.catch(() => {
if (seq !== this.deviceLoadSeq) return
this.insuranceDeviceCurrent = null
this.selectedInsuranceId = null
})
},
_insuranceCurrentDataHasValue(d) {
if (d === null || d === undefined) return false
if (typeof d === 'string') return d.trim() !== ''
if (typeof d === 'number' || typeof d === 'boolean') return true
if (Array.isArray(d)) return d.length > 0
if (typeof d === 'object') return Object.keys(d).length > 0
return false
},
// 预计算价格(套餐/渠道/保险变更后调用)
calculatePrice() {
if (!this.selectedSuitId || !this.selectedChannelId) {
this.priceInfo = null
return
}
const seq = ++this.priceCalcSeq
const appId = this.$store.state.appid || ''
const data = {
suitId: this.selectedSuitId,
channelId: this.selectedChannelId,
appId
}
if (this.selectedInsuranceId) {
data.insuranceDeviceId = this.selectedInsuranceId
}
this.$u.post('/app/order/calculatePrice', data).then(res => {
if (seq !== this.priceCalcSeq) return
if (res.code === 200) {
this.priceInfo = res.data
}
})
},
// 查询客户信息
searchUser() {
if (!this.phoneNumber || this.phoneNumber.length !== 11) {
uni.showToast({ title: '请输入正确的11位手机号', icon: 'none', duration: 2000 })
return
}
uni.showLoading({ title: '查询中...', mask: true })
this.$u.get(`/app/user/getByUserName?userName=${this.phoneNumber}`).then(res => {
uni.hideLoading()
if (res.code === 200 && res.data) {
this.targetUser = res.data
uni.showToast({ title: '查询成功', icon: 'success', duration: 1500 })
} else {
this.targetUser = null
uni.showToast({ title: '未找到该手机号对应用户', icon: 'none', duration: 2000 })
}
}).catch(() => {
uni.hideLoading()
uni.showToast({ title: '查询失败,请重试', icon: 'none', duration: 2000 })
})
},
parseRuleValue(ruleValue) {
if (!ruleValue) return null
if (typeof ruleValue === 'object') return ruleValue
if (typeof ruleValue !== 'string') return null
try {
return JSON.parse(ruleValue)
} catch (e) {
return null
}
},
normalizeTaocanList(list) {
return (list || []).map((item) => {
const ridingRule = Number(item.ridingRule || 1)
const parsedRule = this.parseRuleValue(item.rule)
const normalized = { ...item, ridingRule }
if (ridingRule === 1) {
normalized.startRule = parsedRule && !Array.isArray(parsedRule)
? parsedRule
: (this.parseRuleValue(item.startRule) || item.startRule || {})
normalized.intervalRule = []
normalized.deadlineRule = null
} else if (ridingRule === 2) {
normalized.intervalRule = Array.isArray(parsedRule)
? parsedRule
: (this.parseRuleValue(item.intervalRule) || item.intervalRule || [])
normalized.startRule = {}
normalized.deadlineRule = null
} else if (ridingRule === 3) {
normalized.deadlineRule = parsedRule && !Array.isArray(parsedRule)
? parsedRule
: {}
normalized.startRule = {}
normalized.intervalRule = []
} else {
normalized.startRule = {}
normalized.intervalRule = []
normalized.deadlineRule = null
}
return normalized
})
},
onPackageSelect(index, item) {
this.fanganindex = index
this.selectSuit(item)
},
openPriceDetail() {
if (!this.selectedSuitId) {
uni.showToast({ title: '请先选择套餐', icon: 'none', duration: 2000 })
return
}
this.priceDetailShow = true
},
// 选择套餐
selectSuit(item) {
this.selectedSuitId = item.id
this.selectedSuit = item
const idx = this.suitList.findIndex(s => s.id === item.id)
if (idx >= 0) {
this.fanganindex = idx
}
this.calculatePrice()
},
// 选择支付渠道
selectChannel(item) {
this.selectedChannelId = item.id
this.calculatePrice()
},
// 切换保险选中状态
toggleInsurance() {
if (!this.insuranceDeviceCurrent) return
if (this.selectedInsuranceId === this.insuranceDeviceCurrent.id) {
this.selectedInsuranceId = null
} else {
this.selectedInsuranceId = this.insuranceDeviceCurrent.id
}
this.calculatePrice()
},
// 不投保
skipInsurance() {
this.selectedInsuranceId = null
this.calculatePrice()
},
// 扫客户付款码,返回 Promise<authCode>
scanPayCode() {
return new Promise((resolve, reject) => {
uni.scanCode({
scanType: ['barCode', 'qrCode'],
onlyFromCamera: false,
success: (res) => {
const code = (res && res.result || '').trim()
if (code) {
resolve(code)
} else {
reject(new Error('未识别到付款码'))
}
},
fail: () => {
reject(new Error('已取消扫码'))
}
})
})
},
// 轮询支付结果,最长约 30 秒
pollPayResult(no) {
const maxTimes = 15
const interval = 2000
let times = 0
const queryDetail = () => {
this.$u.get(`/app/pay/detail?no=${no}`).then(res => {
const status = res && res.data ? res.data.status : ''
if (status === 'PAYED') {
uni.hideLoading()
this.submitting = false
uni.showToast({ title: '收款成功', icon: 'success', duration: 1500 })
setTimeout(() => { uni.navigateBack() }, 1500)
return
}
if (status === 'CANCELED' || status === 'REFUNDED') {
uni.hideLoading()
this.submitting = false
uni.showToast({ title: '支付未完成,订单已取消', icon: 'none', duration: 2500 })
return
}
if (times >= maxTimes) {
uni.hideLoading()
this.submitting = false
uni.showModal({
title: '支付处理中',
content: '支付结果尚未确认,请稍后在订单列表中查看。',
showCancel: false,
success: () => { uni.navigateBack() }
})
return
}
setTimeout(checkOnce, interval)
}).catch(() => {
if (times >= maxTimes) {
uni.hideLoading()
this.submitting = false
uni.showToast({ title: '支付结果查询失败,请稍后在订单中查看', icon: 'none', duration: 2500 })
return
}
setTimeout(checkOnce, interval)
})
}
const checkOnce = () => {
times++
this.$u.put(`/app/pay/refreshPayResult?no=${no}`).then(queryDetail).catch(queryDetail)
}
setTimeout(checkOnce, interval)
},
// 提交代客下单
submitOrder() {
if (this.submitting) return
if (!this.targetUser) {
uni.showToast({ title: '请先查询并确认客户信息', icon: 'none', duration: 2000 })
return
}
if (!this.selectedSuitId) {
uni.showToast({ title: '请选择套餐', icon: 'none', duration: 2000 })
return
}
if (!this.selectedChannelId) {
uni.showToast({ title: '请选择支付渠道', icon: 'none', duration: 2000 })
return
}
const apiType = this.selectedChannelObj && this.selectedChannelObj.apiType
if (this.isScanPayChannel(apiType)) {
this.showConfirmModal = true
} else if (this.isOfflinePayChannel(apiType)) {
this.doAgentCreate()
} else {
uni.showToast({ title: '当前支付方式暂不支持', icon: 'none', duration: 2000 })
}
},
isScanPayChannel(apiType) {
return apiType === ChannelApiType.TM_MICRO.value
},
isOfflinePayChannel(apiType) {
return apiType === ChannelApiType.OFFLINE.value
},
// 关闭确认弹窗
closeConfirmModal() {
this.showConfirmModal = false
},
// 确认扫码下单
confirmScanOrder() {
this.showConfirmModal = false
this.scanPayCode().then(payAuthCode => {
this.doAgentCreate(payAuthCode)
}).catch(err => {
uni.showToast({ title: (err && err.message) || '扫码失败', icon: 'none', duration: 2000 })
})
},
// 携带付款码创建订单并发起收款
doAgentCreate(payAuthCode) {
const apiType = this.selectedChannelObj && this.selectedChannelObj.apiType
const isOfflinePay = this.isOfflinePayChannel(apiType)
this.submitting = true
uni.showLoading({ title: isOfflinePay ? '创建审核中...' : '收款中...', mask: true })
const appId = this.$store.state.appid || ''
const areaPromotionId = uni.getStorageSync('areaPromotionId') || ''
const data = {
deviceId: this.deviceId,
suitId: this.selectedSuitId,
channelId: this.selectedChannelId,
userId: this.targetUser.userId,
appId,
appType: 1,
areaPromotionId: areaPromotionId || undefined,
price: this.priceInfo || undefined
}
if (payAuthCode) {
data.payAuthCode = payAuthCode
}
if (this.selectedInsuranceId) {
data.insuranceDeviceId = this.selectedInsuranceId
}
this.$u.post('/bst/order/agentCreate', data).then(res => {
if (res.code === 200) {
if (isOfflinePay) {
uni.hideLoading()
this.submitting = false
this.handleOfflinePay(res.data || {})
return
}
const payNo = res.data && res.data.pay ? res.data.pay.no : ''
if (payNo) {
uni.showLoading({ title: '等待支付结果...', mask: true })
this.pollPayResult(payNo)
} else {
uni.hideLoading()
this.submitting = false
uni.showToast({ title: '下单成功', icon: 'success', duration: 1500 })
setTimeout(() => { uni.navigateBack() }, 1500)
}
} else {
uni.hideLoading()
this.submitting = false
uni.showToast({ title: res.msg || '下单失败', icon: 'none', duration: 3000 })
}
}).catch(() => {
uni.hideLoading()
this.submitting = false
uni.showToast({ title: '网络异常,请重试', icon: 'none', duration: 2000 })
})
},
handleOfflinePay(data) {
const applyNo = data.payParams && data.payParams.no ? String(data.payParams.no) : ''
const payNo = data.pay && data.pay.no ? String(data.pay.no) : ''
if (!applyNo) {
uni.showToast({ title: '线下支付申请单创建失败', icon: 'none', duration: 2500 })
return
}
const query = `no=${encodeURIComponent(applyNo)}&payNo=${encodeURIComponent(payNo)}`
this.offlinePayNo = payNo
this.pendingOfflineAudit = true
this.offlineAuditHandled = false
uni.navigateTo({
url: `/page_shanghu/offlinePay/audit?${query}`,
events: {
offlineAuditResult: (payload) => {
this.processOfflineAuditResult(payload)
}
}
})
}
}
}
</script>
<style lang="scss" scoped>
@import './components/agent-order-theme.scss';
page {
background-color: $ao-bg-page;
}
.page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: $ao-bg-page;
box-sizing: border-box;
}
.scroll-body {
flex: 1;
height: 0;
width: 100%;
}
.scroll-inner {
padding: 24rpx 32rpx 0;
box-sizing: border-box;
}
.scroll-bottom-spacer {
height: 24rpx;
}
.vehicle-wrap {
@include ao-section;
position: relative;
::v-deep .jieshao {
width: 100%;
margin: 0;
margin-top: 0;
border: 1rpx solid rgba(76, 151, 231, 0.08);
}
::v-deep .vehicle-info {
padding-left: 48rpx;
}
}
.change-device-icon {
position: absolute;
left: 24rpx;
top: 24rpx;
z-index: 2;
width: 44rpx;
height: 44rpx;
border-radius: 999rpx;
background: #fff;
border: 1rpx solid $ao-border;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(46, 73, 117, 0.08);
}
.device-loading {
@include ao-section;
@include ao-empty-tip;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 48rpx 0;
}
.section-head {
@include ao-section-head;
}
.section-title {
@include ao-section-title;
}
.package-section {
@include ao-section;
}
.package-wrap {
margin: 0;
::v-deep .zcfangan {
width: 100%;
margin: 0;
margin-top: 0;
padding-right: 0 !important;
}
::v-deep .zcfangan > .name {
display: none;
}
::v-deep .package-info-box {
width: 100%;
margin-left: 0;
margin-right: 0;
}
::v-deep .package-header .package-buttons {
display: none;
}
::v-deep .package-content .info-item {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 8rpx 0;
}
::v-deep .package-content .info-icon-wrap {
width: 40rpx;
height: 40rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
margin-right: 8rpx;
}
::v-deep .package-content .info-text {
flex: 1;
min-width: 0;
font-size: 28rpx;
line-height: 40rpx;
margin-left: 0;
}
::v-deep .fanganlist_item.gaoliang {
background: #fff !important;
}
::v-deep .package-info-box,
::v-deep .fanganlist_item {
background: #fff;
}
}
.empty-tip {
@include ao-empty-tip;
}
</style>