Parcourir la source

feat: 出场Tab增加收费结算UI——现金收款+找零+免密放行,预留扫码/POS

lq il y a 1 mois
Parent
commit
ccb1ceaf24

+ 18 - 0
frontend/src/api/vehicle.js

@@ -169,3 +169,21 @@ export const getAllVehicleTypes = () => {
     method: 'get'
   })
 }
+
+// 出场费用预览
+export const exitPreview = (data) => {
+  return service({
+    url: '/vehicle/exit/preview',
+    method: 'post',
+    data
+  })
+}
+
+// 确认出场并支付
+export const exitConfirm = (data) => {
+  return service({
+    url: '/vehicle/exit/confirm',
+    method: 'post',
+    data
+  })
+}

+ 8 - 0
frontend/src/lang/en.js

@@ -214,4 +214,12 @@ export default {
   pleaseSelectPark: 'Please select parking lot',
   rfidInput: 'Please enter RFID tag',
   exitRecordDetail: 'Entry: {entryTime} | Stay: {duration} | Fee: ¥{fee}',
+  paymentMethod: 'Payment Method',
+  cashPayment: 'Cash',
+  wechatPayment: 'QR Code',
+  posPayment: 'POS Card',
+  cashReceived: 'Received',
+  change: 'Change',
+  confirmPayAndExit: 'Confirm & Release',
+  insufficientAmount: 'Insufficient amount',
 }

+ 8 - 0
frontend/src/lang/zh-CN.js

@@ -214,4 +214,12 @@ export default {
   pleaseSelectPark: '请选择停车场',
   rfidInput: '请输入RFID标签',
   exitRecordDetail: '进场: {entryTime} | 停留: {duration} | 费用: ¥{fee}',
+  paymentMethod: '支付方式',
+  cashPayment: '现金',
+  wechatPayment: '扫码支付',
+  posPayment: 'POS刷卡',
+  cashReceived: '实收金额',
+  change: '找零',
+  confirmPayAndExit: '确认收款并放行',
+  insufficientAmount: '实收金额不足',
 }

+ 100 - 14
frontend/src/view/parking/entryExit.vue

@@ -29,13 +29,14 @@
         <el-descriptions-item :label="$t.value.LicensePlateNo">{{ vehicleInfo.plate_number || '-' }}</el-descriptions-item>
         <el-descriptions-item :label="$t.value.RFID_Tag">{{ vehicleInfo.rfid_tag || '-' }}</el-descriptions-item>
         <el-descriptions-item :label="$t.value.VehicleType">
-          <el-tag v-if="vehicleInfo.VehicleType" :type="vehicleInfo.VehicleType.ID === 1 ? 'warning' : 'success'">{{ vehicleInfo.VehicleType.name }}</el-tag>
+          <el-tag v-if="vehicleInfo.VehicleType" :type="vehicleInfo.VehicleType.is_system ? 'warning' : 'success'">{{ vehicleInfo.VehicleType.name }}</el-tag>
           <el-tag v-else type="warning">{{ $t.value.tempCar }}</el-tag>
         </el-descriptions-item>
         <el-descriptions-item v-if="vehicleInfo.Owner" :label="$t.value.CarOwner">{{ vehicleInfo.Owner.owner_name }}</el-descriptions-item>
         <el-descriptions-item v-if="vehicleInfo.Owner && vehicleInfo.Owner.is_vip" label="VIP"><el-tag type="danger">{{ $t.value.yes }}</el-tag></el-descriptions-item>
       </el-descriptions>
 
+      <!-- 进场特有操作区 -->
       <div v-if="activeTab === 'entry'" class="action-area">
         <el-divider />
         <el-form :model="entryForm" inline>
@@ -50,10 +51,45 @@
         </el-form>
       </div>
 
-      <div v-if="activeTab === 'exit'" class="action-area">
+      <!-- 出场操作区 -->
+      <div v-if="activeTab === 'exit' && exitData" class="action-area">
         <el-divider />
-        <p class="exit-tip">{{ $t.value.exitAutoCalcFee }}</p>
-        <el-button type="danger" icon="Check" :loading="submitting" @click="confirmExit">{{ $t.value.confirmExitAndPay }}</el-button>
+        <el-descriptions :column="2" border>
+          <el-descriptions-item :label="$t.value.EntryTime">
+            {{ formatTime(exitData.entry_time) }}
+          </el-descriptions-item>
+          <el-descriptions-item :label="$t.value.StayDuration">
+            {{ formatDuration(exitData.stay_time) }}
+          </el-descriptions-item>
+          <el-descriptions-item :label="$t.value.Fee">
+            <span class="fee-amount">¥{{ exitData.fee.toFixed(2) }}</span>
+          </el-descriptions-item>
+          <el-descriptions-item :label="$t.value.VehicleType">
+            {{ exitData.vehicle_type || '-' }}
+          </el-descriptions-item>
+        </el-descriptions>
+
+        <!-- 临时车:支付方式选择 -->
+        <div v-if="exitData.is_temp" class="payment-section">
+          <p class="section-label">{{ $t.value.paymentMethod }}:</p>
+          <el-radio-group v-model="paymentMethod">
+            <el-radio-button value="cash">{{ $t.value.cashPayment }}</el-radio-button>
+            <el-radio-button value="wechat" disabled>{{ $t.value.wechatPayment }}</el-radio-button>
+            <el-radio-button value="pos" disabled>{{ $t.value.posPayment }}</el-radio-button>
+          </el-radio-group>
+
+          <div v-if="paymentMethod === 'cash'" class="cash-input">
+            <span class="cash-label">{{ $t.value.cashReceived }}:</span>
+            <el-input-number v-model="paidAmount" :min="0" :precision="1" style="width: 160px" />
+            <span class="change-label">{{ $t.value.change }}: ¥{{ Math.max(0, paidAmount - exitData.fee).toFixed(1) }}</span>
+          </div>
+        </div>
+
+        <div style="margin-top: 16px">
+          <el-button type="danger" icon="Check" :loading="submitting" @click="confirmExit">
+            {{ exitData.is_temp ? $t.value.confirmPayAndExit : $t.value.confirmExitAndPay }}
+          </el-button>
+        </div>
       </div>
     </el-card>
 
@@ -77,7 +113,7 @@
 <script setup>
 import { ref, reactive, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { obtainVehicleLicense, vehicleEntry, vehicleAppear } from '@/api/vehicle'
+import { obtainVehicleLicense, vehicleEntry, exitPreview, exitConfirm } from '@/api/vehicle'
 import { obtainPullOverList } from '@/api/pullOver'
 import { t } from '@/lang/index.js'
 
@@ -90,6 +126,11 @@ const submitting = ref(false)
 const entryForm = reactive({ parking_lot_id: null })
 const parkingLotList = ref([])
 
+// 出场收费状态
+const exitData = ref(null)
+const paymentMethod = ref('cash')
+const paidAmount = ref(0)
+
 onMounted(async () => {
   try {
     const res = await obtainPullOverList({ page: 1, pageSize: 100 })
@@ -109,11 +150,16 @@ async function queryVehicle() {
   vehicleInfo.value = null
   vehicleLoaded.value = false
   notFound.value = false
+  exitData.value = null
   try {
     const res = await obtainVehicleLicense(plate || rfid)
     if (res.code === 0 && res.data) {
       vehicleInfo.value = res.data
       vehicleLoaded.value = true
+      // 出场模式:预览费用
+      if (activeTab.value === 'exit') {
+        await previewExitFee()
+      }
     } else {
       notFound.value = true
     }
@@ -122,6 +168,23 @@ async function queryVehicle() {
   }
 }
 
+async function previewExitFee() {
+  try {
+    const res = await exitPreview({
+      plate_number: queryForm.plate_number.trim(),
+      rfid_tag: queryForm.rfid_tag.trim()
+    })
+    if (res.code === 0) {
+      exitData.value = res.data
+      paidAmount.value = 0
+      paymentMethod.value = 'cash'
+    }
+  } catch (e) {
+    // preview 失败不影响,confirmExit 会再次校验
+    console.error('Preview failed', e)
+  }
+}
+
 async function confirmEntry() {
   if (!entryForm.parking_lot_id) {
     ElMessage.warning(t.value.pleaseSelectPark)
@@ -150,6 +213,14 @@ async function confirmEntry() {
 }
 
 async function confirmExit() {
+  // 临时车现金:校验实收金额
+  if (exitData.value?.is_temp && paymentMethod.value === 'cash') {
+    if (paidAmount.value < exitData.value.fee) {
+      ElMessage.warning(t.value.insufficientAmount)
+      return
+    }
+  }
+
   await ElMessageBox.confirm(
     t.value.confirmExitMsg,
     t.value.confirmExitTitle,
@@ -158,19 +229,20 @@ async function confirmExit() {
   if (submitting.value) return
   submitting.value = true
   try {
-    const res = await vehicleAppear({
+    const pm = exitData.value?.is_temp ? paymentMethod.value : 'free'
+    const pa = exitData.value?.is_temp ? paidAmount.value : 0
+    const res = await exitConfirm({
       plate_number: queryForm.plate_number.trim(),
-      rfid_tag: queryForm.rfid_tag.trim()
+      rfid_tag: queryForm.rfid_tag.trim(),
+      payment_method: pm,
+      paid_amount: pa
     })
     if (res.code === 0) {
-      const data = res.data?.data || res.data || {}
-      const fee = data.fee || 0
-      const stayTime = data.stay_time || 0
-      const entryTime = data.entry_time ? new Date(data.entry_time * 1000).toLocaleString('zh-CN') : '-'
+      const d = res.data
       const detail = t.value.exitRecordDetail
-        .replace('{entryTime}', entryTime)
-        .replace('{duration}', formatDuration(stayTime))
-        .replace('{fee}', fee.toFixed(2))
+        .replace('{entryTime}', new Date(d.entry_time * 1000).toLocaleString('zh-CN'))
+        .replace('{duration}', formatDuration(d.stay_time))
+        .replace('{fee}', d.fee.toFixed(2))
       ElMessage.success(`${t.value.exitSuccessMsg}!${detail}`)
       resetForm()
     } else {
@@ -191,7 +263,15 @@ function resetForm() {
   vehicleInfo.value = null
   vehicleLoaded.value = false
   notFound.value = false
+  exitData.value = null
   entryForm.parking_lot_id = null
+  paidAmount.value = 0
+  paymentMethod.value = 'cash'
+}
+
+function formatTime(ts) {
+  if (!ts) return '-'
+  return new Date(ts * 1000).toLocaleString('zh-CN')
 }
 
 function formatDuration(minutes) {
@@ -209,4 +289,10 @@ function formatDuration(minutes) {
 .card-title { font-weight: bold; font-size: 16px; }
 .action-area { margin-top: 8px; }
 .exit-tip { color: #909399; margin-bottom: 12px; }
+.fee-amount { color: #e6a23c; font-size: 20px; font-weight: bold; }
+.payment-section { margin-top: 16px; }
+.section-label { font-weight: bold; margin-bottom: 8px; }
+.cash-input { margin-top: 12px; }
+.cash-label { margin-right: 8px; }
+.change-label { margin-left: 16px; color: #67c23a; font-weight: bold; }
 </style>