浏览代码

feat: 通道事件实时推送——RFID识别后自动填入进出场页面

lq 1 月之前
父节点
当前提交
e5f7f4f363

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

@@ -196,3 +196,11 @@ export const getInParkVehicles = (params) => {
     params
   })
 }
+
+// 通道事件(RFID实时扫描)
+export const getChannelEvents = (after) => {
+  return service({
+    url: '/channel/events?after=' + (after || 0),
+    method: 'get'
+  })
+}

+ 45 - 103
frontend/src/view/parking/entryExit.vue

@@ -36,7 +36,6 @@
         <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>
@@ -51,25 +50,15 @@
         </el-form>
       </div>
 
-      <!-- 出场操作区 -->
       <div v-if="activeTab === 'exit' && exitData" class="action-area">
         <el-divider />
         <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-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">
@@ -77,7 +66,6 @@
             <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" />
@@ -111,9 +99,9 @@
 </template>
 
 <script setup>
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, onMounted, onUnmounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { obtainVehicleLicense, vehicleEntry, exitPreview, exitConfirm } from '@/api/vehicle'
+import { obtainVehicleLicense, vehicleEntry, exitPreview, exitConfirm, getChannelEvents } from '@/api/vehicle'
 import { obtainPullOverList } from '@/api/pullOver'
 import { t } from '@/lang/index.js'
 
@@ -126,11 +114,13 @@ 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)
 
+let eventTimer = null
+const lastEventTs = ref(0)
+
 onMounted(async () => {
   try {
     const res = await obtainPullOverList({ page: 1, pageSize: 100 })
@@ -138,8 +128,24 @@ onMounted(async () => {
   } catch (e) {
     console.error('Failed to load parking lots', e)
   }
+  eventTimer = setInterval(pollEvents, 3000)
 })
 
+onUnmounted(() => { clearInterval(eventTimer) })
+
+async function pollEvents() {
+  try {
+    const r = await getChannelEvents(lastEventTs.value)
+    if (r.code === 0 && r.data && r.data.length > 0) {
+      const latest = r.data[r.data.length - 1]
+      lastEventTs.value = latest.timestamp
+      queryForm.rfid_tag = latest.rfid_tag
+      activeTab.value = latest.direction === 'out' ? 'exit' : 'entry'
+      queryVehicle()
+    }
+  } catch (e) { /* ignore */ }
+}
+
 async function queryVehicle() {
   const plate = queryForm.plate_number.trim()
   const rfid = queryForm.rfid_tag.trim()
@@ -156,10 +162,7 @@ async function queryVehicle() {
     if (res.code === 0 && res.data) {
       vehicleInfo.value = res.data
       vehicleLoaded.value = true
-      // 出场模式:预览费用
-      if (activeTab.value === 'exit') {
-        await previewExitFee()
-      }
+      if (activeTab.value === 'exit') { await previewExitFee() }
     } else {
       notFound.value = true
     }
@@ -170,114 +173,53 @@ 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)
-  }
+    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) { console.error('Preview failed', e) }
 }
 
 async function confirmEntry() {
-  if (!entryForm.parking_lot_id) {
-    ElMessage.warning(t.value.pleaseSelectPark)
-    return
-  }
+  if (!entryForm.parking_lot_id) { ElMessage.warning(t.value.pleaseSelectPark); return }
   if (submitting.value) return
   submitting.value = true
   try {
-    const res = await vehicleEntry({
-      plate_number: queryForm.plate_number.trim(),
-      rfid_tag: queryForm.rfid_tag.trim(),
-      parking_lot_id: entryForm.parking_lot_id
-    })
-    if (res.code === 0) {
-      const plate = res.data?.data?.plate_number || queryForm.plate_number.trim()
-      ElMessage.success(`${t.value.entrySuccessMsg}!${plate}`)
-      resetForm()
-    } else {
-      ElMessage.error(res.msg || t.value.entryFailed)
-    }
-  } catch (e) {
-    ElMessage.error(e.message || t.value.entryFailed)
-  } finally {
-    submitting.value = false
-  }
+    const res = await vehicleEntry({ plate_number: queryForm.plate_number.trim(), rfid_tag: queryForm.rfid_tag.trim(), parking_lot_id: entryForm.parking_lot_id })
+    if (res.code === 0) { ElMessage.success(`${t.value.entrySuccessMsg}!${res.data?.data?.plate_number || queryForm.plate_number.trim()}`); resetForm() }
+    else ElMessage.error(res.msg || t.value.entryFailed)
+  } catch (e) { ElMessage.error(e.message || t.value.entryFailed) } finally { submitting.value = false }
 }
 
 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,
-    { confirmButtonText: t.value.Confirm, cancelButtonText: t.value.Cancel, type: 'warning' }
-  )
+  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, { confirmButtonText: t.value.Confirm, cancelButtonText: t.value.Cancel, type: 'warning' })
   if (submitting.value) return
   submitting.value = true
   try {
     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(),
-      payment_method: pm,
-      paid_amount: pa
-    })
+    const res = await exitConfirm({ plate_number: queryForm.plate_number.trim(), rfid_tag: queryForm.rfid_tag.trim(), payment_method: pm, paid_amount: pa })
     if (res.code === 0) {
       const d = res.data
-      const detail = t.value.exitRecordDetail
-        .replace('{entryTime}', new Date(d.entry_time * 1000).toLocaleString('zh-CN'))
-        .replace('{duration}', formatDuration(d.stay_time))
-        .replace('{fee}', d.fee.toFixed(2))
+      const detail = t.value.exitRecordDetail.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 {
-      ElMessage.error(res.msg || t.value.exitFailed)
-    }
-  } catch (e) {
-    ElMessage.error(e.message || t.value.exitFailed)
-  } finally {
-    submitting.value = false
-  }
+    } else ElMessage.error(res.msg || t.value.exitFailed)
+  } catch (e) { ElMessage.error(e.message || t.value.exitFailed) } finally { submitting.value = false }
 }
 
 function handleTabChange() { resetForm() }
 
 function resetForm() {
-  queryForm.plate_number = ''
-  queryForm.rfid_tag = ''
-  vehicleInfo.value = null
-  vehicleLoaded.value = false
-  notFound.value = false
-  exitData.value = null
-  entryForm.parking_lot_id = null
-  paidAmount.value = 0
-  paymentMethod.value = 'cash'
+  queryForm.plate_number = ''; queryForm.rfid_tag = ''
+  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 formatTime(ts) { if (!ts) return '-'; return new Date(ts * 1000).toLocaleString('zh-CN') }
 
 function formatDuration(minutes) {
   if (!minutes && minutes !== 0) return '-'
-  const h = Math.floor(minutes / 60)
-  const m = minutes % 60
+  const h = Math.floor(minutes / 60); const m = minutes % 60
   return h > 0 ? `${h}h ${m}${t.value.minute}` : `${m}${t.value.minute}`
 }
 </script>

+ 15 - 0
internal/api/v1/parking/channel_event.go

@@ -0,0 +1,15 @@
+package parking
+
+import (
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/service/uhf"
+)
+
+func GetChannelEvents(c *gin.Context) {
+	after, _ := strconv.ParseInt(c.Query("after"), 10, 64)
+	events := uhf.GetChannelEvents(after)
+	response.OkWithData(events, c)
+}

+ 2 - 0
internal/router/parking/parking.go

@@ -8,6 +8,8 @@ import (
 )
 
 func SetupParkingRouter(router *gin.RouterGroup) {
+	router.GET("/channel/events", parking.GetChannelEvents)
+
 	parkingGroup := router.Group("/parking")
 	parkLotGroup := parkingGroup.Group("/lot")
 	{

+ 49 - 0
internal/service/uhf/reader.go

@@ -30,6 +30,43 @@ type Reader interface {
 	ReleaseRelay2() error
 }
 
+// ChannelEvent 通道事件(RFID 识别到标签后推送给前端)
+type ChannelEvent struct {
+	DeviceCode  string `json:"device_code"`
+	RFIDTag     string `json:"rfid_tag"`
+	Direction   string `json:"direction"`
+	ChannelName string `json:"channel_name"`
+	Timestamp   int64  `json:"timestamp"`
+}
+
+var (
+	channelEventMu     sync.Mutex
+	ChannelEventQueue  = make([]ChannelEvent, 0, 50)
+)
+
+// PushChannelEvent 追加通道事件(保留最近 50 条)
+func PushChannelEvent(e ChannelEvent) {
+	channelEventMu.Lock()
+	defer channelEventMu.Unlock()
+	if len(ChannelEventQueue) >= 50 {
+		ChannelEventQueue = ChannelEventQueue[1:]
+	}
+	ChannelEventQueue = append(ChannelEventQueue, e)
+}
+
+// GetChannelEvents 获取 timestamp > after 的事件
+func GetChannelEvents(after int64) []ChannelEvent {
+	channelEventMu.Lock()
+	defer channelEventMu.Unlock()
+	result := make([]ChannelEvent, 0)
+	for _, e := range ChannelEventQueue {
+		if e.Timestamp > after {
+			result = append(result, e)
+		}
+	}
+	return result
+}
+
 // 上报数据模型
 type ReportData struct {
 	DeviceCode string   `json:"device_code"` // 设备编码
@@ -266,6 +303,18 @@ func handleReportData(report *ReportData) {
 	}
 
 	fmt.Printf("✅ 进出场成功 | 方向:%s 车牌:%s 费用:%.2f\n", result.Direction, result.PlateNumber, result.Fee)
+
+	// 推送通道事件给前端
+	var device dao.UHFReader
+	if err := global.GVA_DB.Preload("Channel").First(&device, "device_code = ?", report.DeviceCode).Error; err == nil {
+		PushChannelEvent(ChannelEvent{
+			DeviceCode:  report.DeviceCode,
+			RFIDTag:     epc,
+			Direction:   result.Direction,
+			ChannelName: device.Channel.ChannelName,
+			Timestamp:   time.Now().Unix(),
+		})
+	}
 }
 
 // ==============================