|
@@ -0,0 +1,317 @@
|
|
|
|
|
+# 取票打印 + 扫码出场 — 实现计划
|
|
|
|
|
+
|
|
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans.
|
|
|
|
|
+
|
|
|
|
|
+**Goal:** 临时车取票入场(按钮→打印二维码小票),扫码出场(扫码枪→查票→结算)
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** 新增 `internal/modules/printer/` 模块。ESC/POS 指令构建 + 串口通信。票机按钮POST创建无车牌入场记录并打印。前端监听扫码枪input自动填入ticket_no。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 文件结构
|
|
|
|
|
+
|
|
|
|
|
+| 文件 | 操作 |
|
|
|
|
|
+|------|------|
|
|
|
|
|
+| `internal/dao/printer.go` | 新建 |
|
|
|
|
|
+| `internal/modules/printer/api.go` | 新建 |
|
|
|
|
|
+| `internal/modules/printer/service/service.go` | 新建 |
|
|
|
|
|
+| `internal/modules/printer/service/escpos.go` | 新建 |
|
|
|
|
|
+| `internal/modules/printer/service/serial.go` | 新建 |
|
|
|
|
|
+| `internal/initialize/gorm.go` | 修改 |
|
|
|
|
|
+| `internal/initialize/router.go` | 修改 |
|
|
|
|
|
+| `frontend/src/view/parking/entryExit.vue` | 修改 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 1: ESC/POS 打印模块
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: DAO**
|
|
|
|
|
+
|
|
|
|
|
+Write `internal/dao/printer.go`:
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+package dao
|
|
|
|
|
+
|
|
|
|
|
+import "wails-app/internal/global"
|
|
|
|
|
+
|
|
|
|
|
+type Printer struct {
|
|
|
|
|
+ global.GVA_MODEL
|
|
|
|
|
+ Name string `gorm:"size:50" json:"name"`
|
|
|
|
|
+ PortName string `gorm:"size:20" json:"port_name"` // COM3
|
|
|
|
|
+ BaudRate int `json:"baud_rate"` // 9600
|
|
|
|
|
+ Status string `gorm:"size:20;default:offline" json:"status"`
|
|
|
|
|
+ Location string `gorm:"size:20" json:"location"` // entry / exit
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (Printer) TableName() string { return "printer" }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add to `internal/initialize/gorm.go` AutoMigrate (after `dao.ShiftRecord{}`):
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+dao.ShiftRecord{},
|
|
|
|
|
+dao.Printer{},
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: ESC/POS 协议**
|
|
|
|
|
+
|
|
|
|
|
+Write `internal/modules/printer/service/escpos.go`:
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+package service
|
|
|
|
|
+
|
|
|
|
|
+// ESC/POS commands for 58mm thermal printer
|
|
|
|
|
+const (
|
|
|
|
|
+ ESC = 0x1B
|
|
|
|
|
+ GS = 0x1D
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+var (
|
|
|
|
|
+ Init = []byte{ESC, '@'} // 初始化
|
|
|
|
|
+ AlignLeft = []byte{ESC, 'a', 0} // 左对齐
|
|
|
|
|
+ AlignCenter= []byte{ESC, 'a', 1} // 居中
|
|
|
|
|
+ FeedLine = []byte{ESC, 'd', 1} // 换行
|
|
|
|
|
+ CutPaper = []byte{GS, 'V', 66, 0} // 切纸
|
|
|
|
|
+ DoubleH = []byte{ESC, '!', 0x11} // 双倍高度
|
|
|
|
|
+ Normal = []byte{ESC, '!', 0x00} // 正常字体
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+func ESCPOS_Text(text string) []byte { return append([]byte(text), '\n') }
|
|
|
|
|
+func ESCPOS_QRCode(data string) []byte {
|
|
|
|
|
+ // GS ( k <fn> <cn> <a> <m> <nL> <nH> <d1...dk>
|
|
|
|
|
+ n := len(data) + 3
|
|
|
|
|
+ cmd := []byte{GS, '(','k', byte(n), 0, 49, 80, 48} // QR model 2, ECC L
|
|
|
|
|
+ cmd = append(cmd, byte(len(data)+3), 0, 49, 80, 48) // store
|
|
|
|
|
+ cmd = append(cmd, []byte(data)...)
|
|
|
|
|
+ cmd = append(cmd, GS,'(','k',3,0,49,81,48) // print QR
|
|
|
|
|
+ return cmd
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 串口通信**
|
|
|
|
|
+
|
|
|
|
|
+Write `internal/modules/printer/service/serial.go`:
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+package service
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "github.com/tarm/serial"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+type SerialPrinter struct {
|
|
|
|
|
+ Port *serial.Port
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func NewSerialPrinter(portName string, baudRate int) (*SerialPrinter, error) {
|
|
|
|
|
+ c := &serial.Config{Name: portName, Baud: baudRate}
|
|
|
|
|
+ port, err := serial.OpenPort(c)
|
|
|
|
|
+ if err != nil { return nil, fmt.Errorf("打开串口失败: %w", err) }
|
|
|
|
|
+ return &SerialPrinter{Port: port}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (p *SerialPrinter) Write(data []byte) error {
|
|
|
|
|
+ _, err := p.Port.Write(data)
|
|
|
|
|
+ return err
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (p *SerialPrinter) Close() error { return p.Port.Close() }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Note: `go get github.com/tarm/serial` required.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Print Service**
|
|
|
|
|
+
|
|
|
|
|
+Write `internal/modules/printer/service/service.go`:
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+package service
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "time"
|
|
|
|
|
+ "wails-app/internal/dao"
|
|
|
|
|
+ "wails-app/internal/global"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+type PrintService struct{}
|
|
|
|
|
+
|
|
|
|
|
+func NewPrintService() *PrintService { return &PrintService{} }
|
|
|
|
|
+
|
|
|
|
|
+// PrintTicket 打印入场小票
|
|
|
|
|
+func (s *PrintService) PrintTicket(printerID uint, lotName, channelCode, plateNo string, ticketID uint, ticketNo string) error {
|
|
|
|
|
+ var printer dao.Printer
|
|
|
|
|
+ if err := global.GVA_DB.First(&printer, printerID).Error; err != nil {
|
|
|
|
|
+ return fmt.Errorf("打印机未配置")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ sp, err := NewSerialPrinter(printer.PortName, printer.BaudRate)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return fmt.Errorf("连接打印机失败: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ defer sp.Close()
|
|
|
|
|
+
|
|
|
|
|
+ now := time.Now().Format("02-01-2006 15:04:05")
|
|
|
|
|
+ slipNo := fmt.Sprintf("%d", ticketID)
|
|
|
|
|
+
|
|
|
|
|
+ write := func(data []byte) { sp.Write(data) }
|
|
|
|
|
+ write(Init)
|
|
|
|
|
+ write(AlignCenter)
|
|
|
|
|
+ write(DoubleH)
|
|
|
|
|
+ write(ESCPOS_Text("PARKING TICKET"))
|
|
|
|
|
+ write(Normal)
|
|
|
|
|
+ write(ESCPOS_Text(""))
|
|
|
|
|
+ write(ESCPOS_Text(lotName))
|
|
|
|
|
+ write(ESCPOS_Text("A-General Car"))
|
|
|
|
|
+ write(ESCPOS_Text(""))
|
|
|
|
|
+ write(ESCPOS_Text("PARK AT YOUR OWN RISK"))
|
|
|
|
|
+ write(FeedLine)
|
|
|
|
|
+ write(AlignLeft)
|
|
|
|
|
+ write(ESCPOS_Text("In-Time::" + now))
|
|
|
|
|
+ if channelCode != "" { write(ESCPOS_Text("InGate::" + channelCode)) }
|
|
|
|
|
+ if plateNo != "" { write(ESCPOS_Text("Veh No::" + plateNo)) }
|
|
|
|
|
+ write(FeedLine)
|
|
|
|
|
+ write(ESCPOS_Text("Slip No::" + slipNo))
|
|
|
|
|
+ write(FeedLine)
|
|
|
|
|
+ write(AlignCenter)
|
|
|
|
|
+ write(ESCPOS_QRCode(ticketNo))
|
|
|
|
|
+ write(FeedLine)
|
|
|
|
|
+ write(ESCPOS_Text("SCAN AND PAY WITH"))
|
|
|
|
|
+ write(ESCPOS_Text("NEW THE CANADIA BANK APP"))
|
|
|
|
|
+ write(FeedLine)
|
|
|
|
|
+ write(CutPaper)
|
|
|
|
|
+ global.GVA_DB.Model(&printer).Update("status", "online")
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 构建+提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd lc_garage && go get github.com/tarm/serial && go build -o build/bin/smart-parking.exe .
|
|
|
|
|
+git add internal/dao/printer.go internal/modules/printer/ internal/initialize/gorm.go go.mod go.sum
|
|
|
|
|
+git commit -m "feat: ESC/POS打印模块——串口通信+58mm小票模板+QRCode"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 2: 按钮事件 API + 入场集成
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: api.go**
|
|
|
|
|
+
|
|
|
|
|
+Write `internal/modules/printer/api.go`:
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+package printer
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "github.com/gin-gonic/gin"
|
|
|
|
|
+ "wails-app/internal/global"
|
|
|
|
|
+ "wails-app/internal/model/common/response"
|
|
|
|
|
+ ticketSvc "wails-app/internal/modules/digital-ticket/service"
|
|
|
|
|
+ "wails-app/internal/modules/printer/service"
|
|
|
|
|
+ "wails-app/internal/service"
|
|
|
|
|
+ utils "wails-app/internal/pkg"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+var printSvc = service.NewPrintService()
|
|
|
|
|
+var vehicleSvc = service.ServiceGroupApp.VehicleServiceGroup.VehicleService
|
|
|
|
|
+
|
|
|
|
|
+type buttonReq struct {
|
|
|
|
|
+ PrinterID uint `json:"printer_id"`
|
|
|
|
|
+ ChannelCode string `json:"channel_code"`
|
|
|
|
|
+ PlateNo string `json:"plate_number"` // 可选,识别到则填
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TicketMachineButton(c *gin.Context) {
|
|
|
|
|
+ var req buttonReq
|
|
|
|
|
+ if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
+ response.FailWithMessage(err.Error(), c); return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 创建入场记录(无车牌临时车)
|
|
|
|
|
+ entryResp, err := vehicleSvc.VehicleEntry(request.VehicleEntry{
|
|
|
|
|
+ PlateNumber: req.PlateNo, RFIDTag: "", ParkingLotID: 1, ParkingSpaceID: 1,
|
|
|
|
|
+ })
|
|
|
|
|
+ if err != nil { response.FailWithMessage(err.Error(), c); return }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取最新 digital_ticket
|
|
|
|
|
+ var tickets []dao.DigitalTicket
|
|
|
|
|
+ global.GVA_DB.Where("plate_number = ?", entryResp.Data.PlateNumber).Order("id DESC").Limit(1).Find(&tickets)
|
|
|
|
|
+ if len(tickets) == 0 { response.FailWithMessage("票创建失败", c); return }
|
|
|
|
|
+
|
|
|
|
|
+ // 打印小票
|
|
|
|
|
+ var lot dao.ParkingLot
|
|
|
|
|
+ global.GVA_DB.First(&lot, entryResp.Data.ParkingLotID)
|
|
|
|
|
+ if err := printSvc.PrintTicket(req.PrinterID, lot.LotName, req.ChannelCode, req.PlateNo, tickets[0].ID, tickets[0].TicketNo); err != nil {
|
|
|
|
|
+ response.FailWithMessage("打印失败: "+err.Error(), c); return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ response.OkWithData(gin.H{"ticket_id": tickets[0].ID, "ticket_no": tickets[0].TicketNo}, c)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func SetupPrinterRouter(router *gin.RouterGroup) {
|
|
|
|
|
+ router.POST("/ticket-machine/button", TicketMachineButton)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Need imports: `"wails-app/internal/dao"`, `"wails-app/internal/model/vehicle/request"`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 注册路由**
|
|
|
|
|
+
|
|
|
|
|
+Edit `internal/initialize/router.go`, import `"wails-app/internal/modules/printer"`, add:
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+printer.SetupPrinterRouter(PrivateGroup)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 构建+提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+go build -o build/bin/smart-parking.exe .
|
|
|
|
|
+sqlite3 "$APPDATA/smart-parking/lc_garage.db" "INSERT INTO casbin_rule (ptype,v0,v1,v2) VALUES ('p','888','/ticket-machine/button','POST'),('p','618','/ticket-machine/button','POST');"
|
|
|
|
|
+git add internal/modules/printer/api.go internal/initialize/router.go
|
|
|
|
|
+git commit -m "feat: 票机按钮事件API——创建无车牌入场+打印小票"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 3: 前端扫码枪监听
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: entryExit.vue 出场 Tab 添加扫码枪监听**
|
|
|
|
|
+
|
|
|
|
|
+扫码枪 = USB键盘,快速输入一串字符后回车。在出场 Tab 添加全局 input 监听,检测快速输入(<50ms/字符)即为扫码枪,自动填入并查询。
|
|
|
|
|
+
|
|
|
|
|
+Add to `<script setup>` of `frontend/src/view/parking/entryExit.vue`:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+// 扫码枪监听(USB键盘模拟输入)
|
|
|
|
|
+let scanBuffer = '', scanTimer = null
|
|
|
|
|
+function onScanKey(e) {
|
|
|
|
|
+ if (e.key === 'Enter' && scanBuffer.length > 10) {
|
|
|
|
|
+ queryForm.rfid_tag = ''; queryForm.plate_number = ''
|
|
|
|
|
+ // 判断是 ticket_no (hex) 还是车牌
|
|
|
|
|
+ if (/^[0-9a-f]{32}$/.test(scanBuffer)) {
|
|
|
|
|
+ // ticket_no → 查 digital_ticket
|
|
|
|
|
+ queryForm.rfid_tag = scanBuffer
|
|
|
|
|
+ } else {
|
|
|
|
|
+ queryForm.plate_number = scanBuffer
|
|
|
|
|
+ }
|
|
|
|
|
+ scanBuffer = ''; queryVehicle()
|
|
|
|
|
+ } else if (e.key.length === 1) {
|
|
|
|
|
+ scanBuffer += e.key; clearTimeout(scanTimer)
|
|
|
|
|
+ scanTimer = setTimeout(() => { scanBuffer = '' }, 100)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+onMounted(() => document.addEventListener('keydown', onScanKey))
|
|
|
|
|
+onUnmounted(() => { document.removeEventListener('keydown', onScanKey); clearInterval(eventTimer) })
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 构建+提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd frontend && npm run build && cd ..
|
|
|
|
|
+git add frontend/src/view/parking/entryExit.vue
|
|
|
|
|
+git commit -m "feat: 出场Tab扫码枪监听——自动识别ticket_no/车牌并查询"
|
|
|
|
|
+```
|