1
0

17 Revīzijas c2f44c6a34 ... 2b29def42c

Autors SHA1 Ziņojums Datums
  lq 2b29def42c fix: 更新数字票测试——Bug修复后Pay/Exit/FullFlow应从失败改为成功 1 mēnesi atpakaļ
  lq 8881e628b4 fix: Transition加dbFields白名单——paid_amount仅记录事件日志不写入表 1 mēnesi atpakaļ
  lq 5cf231ca91 docs: 数字票说明文档——状态机/数据模型/API/代码结构 1 mēnesi atpakaļ
  lq 780f4f5dd8 feat: 数字票API+路由+VehicleEntry集成——所有入场统一创建数字票 1 mēnesi atpakaļ
  lq dcec5d9da8 feat: 数字票模块——DAO/repo/service,5状态机+事件日志 1 mēnesi atpakaļ
  lq 5fb50b9847 docs: 数字票实现计划 1 mēnesi atpakaļ
  lq 458438f173 docs: 数字票设计文档——统一抽象模型+5状态机+二维码凭证 1 mēnesi atpakaļ
  lq 213946fcbf docs: 在场车辆统计完成+操作日志已启用+异常日志/exe混淆写入待办 1 mēnesi atpakaļ
  lq e8ebcc80e0 fix: 启用操作日志中间件——OperationRecord纳入PrivateGroup全局中间件 1 mēnesi atpakaļ
  lq 6c56584629 feat: 在场车辆统计页面——车牌卡片网格+点击详情+空闲车位/在场数统计 1 mēnesi atpakaļ
  lq b38984b212 feat: 在场车辆统计API——GET /vehicle/in-park,按停车场/车牌筛选 1 mēnesi atpakaļ
  lq 9f206edf34 docs: 更新项目进度——包月车管理/收费记录/监控仪表盘已完成 1 mēnesi atpakaļ
  lq 454e8c2c7f feat: 包月车管理前端+菜单+多语言——办理/续费/退卡页面 1 mēnesi atpakaļ
  lq c26a161087 feat: 包月车管理API——create/renew/refund/list路由注册 1 mēnesi atpakaļ
  lq 01b2b626cc feat: 包月车管理后端——DAO/repo/service,办卡=白名单+支付记录 1 mēnesi atpakaļ
  lq ce1cb743f3 docs: 包月车管理实现计划 1 mēnesi atpakaļ
  lq 3c5cf10d66 docs: 包月车管理设计文档——办卡自动白名单+到期处理 1 mēnesi atpakaļ

+ 180 - 0
doc/digital-ticket.md

@@ -0,0 +1,180 @@
+# 数字票(Digital Ticket)说明文档
+
+> 版本:1.0 | 日期:2026-07-23
+
+## 1. 概述
+
+数字票(Digital Ticket)是停车业务中的标准化抽象模型,以电子化的"票"作为核心对象,遵循 **创建 → 支付 → 验证 → 放行** 的线性状态机模式。不绑定特定硬件或支付方式,具有高度的通用性和可扩展性。
+
+## 2. 核心概念
+
+### 设计原则
+
+- **统一抽象**:所有进场方式(RFID、车牌识别、手动录入)最终都创建数字票
+- **状态驱动**:票的状态决定下一步操作,不允许跳跃式状态变更
+- **事件溯源**:关键节点记录事件日志,便于审计和异常追踪
+- **二维码凭证**:以 ticket_no(UUID)作为唯一标识和验证依据
+
+### 与现有流程的关系
+
+```
+任何进场方式(手动/RFID/车牌)
+        │
+        ▼
+  VehicleEntry() ──→ 创建 vehicle_record
+        │                │
+        │                ▼
+        │         创建 digital_ticket
+        │         (状态: pending_payment)
+        │
+        ▼
+  ExitPreview / ExitConfirm
+        │
+        ▼
+  digital_ticket 状态 → paid → exited
+```
+
+数字票是 `vehicle_record` 的业务状态层,不替代它。报表和监控继续用 `vehicle_record`,业务流转看 `digital_ticket` 状态。
+
+## 3. 状态机
+
+```
+                    ┌──────────┐
+               ┌──→ │  expired │ ←──┐
+               │    │  已过期   │    │
+               │    └──────────┘    │
+               │     超时未支付      │ 超时未离场
+               │                    │
+  ┌─────────┐ │  ┌───────────────┐ │  ┌──────┐  ┌────────┐
+  │ created │─→│  │pending_payment│─→│ paid │─→│ exited │
+  │  已创建  │   │    待支付      │  │ 已支付│  │ 已离场  │
+  └─────────┘   └───────────────┘  └──────┘  └────────┘
+   车辆入场        自动转换         支付成功    验证放行
+```
+
+| 状态 | 说明 | 触发条件 |
+|------|------|---------|
+| `created` | 车辆入场,数字票生成 | 入场事件触发 |
+| `pending_payment` | 待支付 | 创建完成后自动进入 |
+| `paid` | 已支付,费用结清 | 支付成功回调 |
+| `exited` | 已离场,流程结束 | 验证通过后开闸 |
+| `expired` | 超时未支付或未离场 | 定时任务或超时逻辑 |
+
+### 状态转换规则
+
+| 当前状态 | 允许转换到 |
+|---------|-----------|
+| `created` | `pending_payment` |
+| `pending_payment` | `paid`, `expired` |
+| `paid` | `exited`, `expired` |
+| `exited` | 无(终态) |
+| `expired` | 无(终态) |
+
+状态转换是原子操作,代码层做了合法性校验,不允许跳跃式变更。
+
+## 4. 数据模型
+
+### digital_ticket 表
+
+| 字段 | 类型 | 约束 | 说明 |
+|------|------|------|------|
+| id | INTEGER | PK | 主键 |
+| ticket_no | TEXT(64) | UNIQUE | 票号(32位十六进制UUID) |
+| plate_number | TEXT(20) | | 车牌号 |
+| trigger_mode | TEXT(20) | | 触发方式:rfid / plate_recognition / manual |
+| state | TEXT(20) | default:created | 状态 |
+| vehicle_record_id | INTEGER | FK | 关联 vehicle_record.id |
+| entry_time | DATETIME | | 入场时间 |
+| exit_time | DATETIME | | 出场时间 |
+| fee | REAL | | 计费金额 |
+| payment_method | TEXT(20) | | 支付方式:cash / free / wechat / alipay |
+| payment_order_no | TEXT(64) | | 支付单号 |
+| paid_at | DATETIME | | 支付时间 |
+| expire_at | DATETIME | | 过期时间 |
+| event_log | TEXT | | 事件日志(JSON数组) |
+| created_at | DATETIME | | 创建时间 |
+| updated_at | DATETIME | | 更新时间 |
+
+### event_log 格式
+
+```json
+[
+  {"at": "2026-07-23T15:27:08+08:00", "event": "created", "trigger": "manual"},
+  {"at": "2026-07-23T15:35:12+08:00", "event": "pending_payment"},
+  {"at": "2026-07-23T15:36:01+08:00", "event": "paid", "payment_method": "cash", "paid_amount": 20, "payment_order_no": "PO20260723001"},
+  {"at": "2026-07-23T15:36:05+08:00", "event": "exited"}
+]
+```
+
+## 5. API 接口
+
+| 方法 | 路径 | 说明 | 鉴权 |
+|------|------|------|:--:|
+| GET | `/digital-ticket/list` | 分页列表(按车牌/状态筛选) | JWT |
+| GET | `/digital-ticket/:ticketNo` | 根据票号查询详情 | JWT |
+| POST | `/digital-ticket/:ticketNo/pay` | 支付(→ paid) | JWT |
+| POST | `/digital-ticket/:ticketNo/exit` | 离场验证(→ exited) | JWT |
+
+### 支付请求体
+
+```json
+{
+  "payment_method": "cash",
+  "payment_order_no": "PO20260723001",
+  "paid_amount": 20.0
+}
+```
+
+### 列表查询参数
+
+| 参数 | 说明 |
+|------|------|
+| plate_number | 车牌号(模糊匹配) |
+| state | 状态筛选 |
+| page | 页码 |
+| page_size | 每页条数 |
+
+## 6. 代码结构
+
+```
+internal/modules/digital-ticket/
+├── api.go                 # HTTP Handler + 路由注册
+├── service/
+│   └── service.go         # 状态机 + 事件日志 + Create/Transition
+└── repository/
+    └── repo.go            # CRUD
+```
+
+```
+internal/dao/digital_ticket.go  # GORM 实体
+```
+
+### 关键函数
+
+| 函数 | 说明 |
+|------|------|
+| `TicketService.Create()` | 创建数字票,生成 UUID ticket_no,写入 event_log |
+| `TicketService.Transition()` | 状态转换,校验合法性,追加事件日志 |
+| `TicketService.GetByTicketNo()` | 扫码查询(二维码凭证验证入口) |
+
+## 7. 扩展性
+
+数字票作为统一抽象,后续可扩展:
+
+- **线上支付**:扫码 → 跳转支付页 → 回调 `PayTicket`
+- **自助缴费机**:票机扫码 → 调 `/ticketNo` 查询费用 → POS 支付 → 回调 `/ticketNo/pay`
+- **无牌车处理**:进场出纸质二维码小票(ticket_no 打印在票上)
+- **定时过期**:后台任务扫描 `pending_payment` 超时票 → Transition 到 `expired`
+
+## 8. 数据库位置
+
+```
+Windows: %APPDATA%\smart-parking\lc_garage.db
+        → C:\Users\<用户名>\AppData\Roaming\smart-parking\lc_garage.db
+```
+
+查询数字票数据:
+
+```sh
+sqlite3 "%APPDATA%/smart-parking/lc_garage.db" "SELECT id, ticket_no, plate_number, state FROM digital_ticket;"
+```

+ 27 - 16
doc/项目进度.md

@@ -1,6 +1,6 @@
 # 智慧停车管理系统 — 项目进度
 
-> 更新日期:2026-07-17
+> 更新日期:2026-07-22
 
 ## 技术栈
 
@@ -20,7 +20,8 @@
 | 菜单管理 | ✅ | ✅ | - |
 | API 管理 | ✅ | ✅ | - |
 | 数据字典 | ✅ | ✅ | - |
-| 操作日志 | ✅ | ✅ | - |
+| 操作日志 | ✅ | ✅ | OperationRecord中间件已全局启用 |
+| **异常日志** | ❌ | ❌ | panic recovery + error log to file |
 | 停车场管理 | ✅ | ✅ | 含 /parking 前缀对齐 |
 | 收费岗亭 | ✅ | ✅ | - |
 | 通道管理 | ✅ | ✅ | 方向/临停开关 |
@@ -29,17 +30,20 @@
 | 车辆类型 | ✅ | ✅ | 含 is_system 标记 |
 | 车主管理 | ✅ | ✅ | 含 VIP |
 | 收费配置 | ✅ | ✅ | 起步价/计费单位/每日封顶/VIP折扣 |
-| 黑白名单 | ✅ | ✅ | 白名单放行/黑名单禁止 |
+| 黑白名单 | ✅ | ✅ | 进出场三入口全覆盖 |
 | 车辆进场 | ✅ | ✅ | RFID 自动 + 手动 |
 | 车辆出场 | ✅ | ✅ | 计费算法完整 |
 | 进出记录 | ✅ | ✅ | 查询 |
-| **道闸控制** | ✅ | ✅ | RFID识别→继电器开闸 |
-| **手动进出场** | ✅ | ✅ | entryExit.vue,双 Tab 页面 |
-| **收费结算** | ✅ | ✅ | 现金+找零/免密,payment_record表 |
+| 道闸控制 | ✅ | ✅ | RFID识别→继电器开闸 |
+| 手动进出场 | ✅ | ✅ | entryExit.vue,双 Tab 页面 |
+| 收费结算 | ✅ | ✅ | 现金+找零/免密,payment_record |
+| **收费记录查询** | ✅ | ✅ | 按车牌/支付方式/日期/停车场筛选 |
+| **包月车管理** | ✅ | ✅ | 月/季/年卡,办卡=白名单 |
+| **监控仪表盘** | ✅ | ✅ | 车位/在场/收入/进出+设备状态 |
 | 摄像头 | ⚠️ | ❌ | DAO 有定义,无 API/页面 |
-| 仪表盘 | ✅ | ✅ | ECharts 图表 |
 | 个人中心 | ✅ | ✅ | - |
 | 登录页 | ✅ | ✅ | RSA 加密 + 验证码 |
+| **exe混淆** | ❌ | ❌ | garble 编译混淆,防反编译 |
 
 ## 核心业务流完成度
 
@@ -51,13 +55,18 @@
 5. 车辆出场 ──────── 识别→计费→收费→开闸    ✅ 100%
 6. 收费结算 ──────── 现金+找零/免密          ✅ 100%
 7. 手动进出场页面 ─── 车牌/RFID输入→进出场   ✅ 100%
-8. 小票打印 ──────── ESC/POS 串口打印        ❌ 0%
-9. 摄像头识别 ────── RTSP抓拍→OCR→触发       ❌ 0%
-10. LED车位屏 ────── 余位实时显示             ❌ 0%
-11. 交接班对账 ───── 当班统计/现金核销        ❌ 0%
-12. 报表查询 ─────── 进出记录                ⚠️ 50% (缺收入/异常)
-13. 包月车管理 ───── 月卡办理/续费/到期       ❌ 0%
-14. 实时监控 ─────── 车位/设备/在线车         ❌ 0%
+8. 收费记录查询 ──── 筛选/汇总               ✅ 100%
+9. 包月车管理 ───── 月/季/年卡→白名单       ✅ 100%
+10. 监控仪表盘 ───── 车位/收入/设备状态      ✅ 100%
+11. 小票打印 ─────── ESC/POS 串口打印        ❌ 0%
+12. 摄像头识别 ───── RTSP抓拍→OCR→触发       ❌ 0%
+13. LED车位屏 ────── 余位实时显示             ❌ 0%
+14. 交接班对账 ───── 当班统计/现金核销        ❌ 0%
+15. 报表查询 ─────── 进出记录                ⚠️ 50% (缺收入/异常)
+16. 在场车辆统计 ──── 停车场/车牌筛选+详情    ✅ 100%
+17. 操作日志 ──────── OperationRecord中间件   ✅ 100%
+18. 异常日志 ──────── panic recovery+文件    ❌ 0%
+19. exe混淆 ──────── garble编译              ❌ 0%
 ```
 
 ## 车辆进出场触发方式
@@ -249,9 +258,11 @@
 | ~~P0-1~~ | ~~道闸控制~~ | - | ✅ |
 | ~~P0-2~~ | ~~手动进出场页面~~ | - | ✅ |
 | ~~P0-3~~ | ~~收费结算~~ | - | ✅ |
+| ~~P1~~ | ~~包月车管理~~ | - | ✅ |
+| ~~P2~~ | ~~监控大屏~~ | - | ✅ |
 | **P0-4** | **小票打印** | 1-2天 | ESC/POS 串口指令封装 + 小票模板 |
 | **P0-5** | **摄像头车牌识别** | 3-5天 | RTSP+OCR,RFID+车牌双重校验 |
-| P1 | 包月车管理 | 2天 | 月卡办理/续费/到期处理 |
 | P1 | 交接班 + 收入报表 | 2天 | 运营必需 |
 | P1 | LED 车位显示 | 1天 | 不影响车辆进出,可后续补充 |
-| P2 | 监控大屏 | 2天 | 锦上添花 |
+| P1 | 异常日志 | 半天 | panic recovery + error log to file |
+| P1 | exe混淆 | 半天 | garble 编译混淆,防反编译 |

+ 597 - 0
docs/superpowers/plans/2026-07-22-monthly-card-plan.md

@@ -0,0 +1,597 @@
+# 包月车管理 — 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans.
+
+**Goal:** 实现包月车(月/季/年卡)办理、续费、退卡。办卡自动写 shortlist 白名单,进出场复用已有检查。
+
+**Architecture:** 新增 `internal/modules/monthly/` 模块(DAO/service/repository/api)。办卡时创建 monthly_card + shortlist + payment_record。前端新增包月车管理页面。
+
+---
+
+## 文件结构
+
+| 文件 | 操作 |
+|------|------|
+| `internal/dao/monthly_card.go` | 新建 |
+| `internal/modules/monthly/api.go` | 新建 |
+| `internal/modules/monthly/service/service.go` | 新建 |
+| `internal/modules/monthly/repository/repo.go` | 新建 |
+| `internal/initialize/gorm.go` | 修改 |
+| `internal/initialize/router.go` | 修改 |
+| `internal/initialize/seed.go` | 修改 |
+| `frontend/src/view/parking/monthlyCard.vue` | 新建 |
+| `frontend/src/api/monthlyCard.js` | 新建 |
+| `frontend/src/lang/zh-CN.js` | 修改 |
+| `frontend/src/lang/en.js` | 修改 |
+
+---
+
+### Task 1: 数据层 + 后端核心
+
+- [ ] **Step 1: 创建 DAO**
+
+Write `internal/dao/monthly_card.go`:
+
+```go
+package dao
+
+import (
+	"time"
+	"wails-app/internal/global"
+)
+
+type MonthlyCard struct {
+	global.GVA_MODEL
+	VehicleID     uint      `gorm:"index" json:"vehicle_id"`
+	Vehicle       *Vehicle  `gorm:"foreignKey:VehicleID" json:"vehicle"`
+	CardType      string    `gorm:"size:20" json:"card_type"` // month / quarter / year
+	StartDate     time.Time `json:"start_date"`
+	EndDate       time.Time `json:"end_date"`
+	Fee           float64   `json:"fee"`
+	PaymentStatus string    `gorm:"size:20;default:paid" json:"payment_status"` // paid / refunded
+	OperatorID    uint      `json:"operator_id"`
+	Remark        string    `gorm:"size:200" json:"remark"`
+}
+
+func (MonthlyCard) TableName() string { return "monthly_card" }
+```
+
+- [ ] **Step 2: 注册 AutoMigrate**
+
+Edit `internal/initialize/gorm.go`, add `dao.MonthlyCard{}` to the AutoMigrate list (after `dao.Shortlist{}`):
+
+```go
+dao.Shortlist{},
+dao.MonthlyCard{},
+```
+
+- [ ] **Step 3: 创建 Repository**
+
+Write `internal/modules/monthly/repository/repo.go`:
+
+```go
+package repository
+
+import (
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+type MonthlyCardRepository struct{}
+
+func (r *MonthlyCardRepository) Create(card *dao.MonthlyCard) error {
+	return global.GVA_DB.Create(card).Error
+}
+
+func (r *MonthlyCardRepository) GetByVehicleID(vehicleID uint) (*dao.MonthlyCard, error) {
+	var card dao.MonthlyCard
+	err := global.GVA_DB.Where("vehicle_id = ? AND payment_status = ?", vehicleID, "paid").Preload("Vehicle").First(&card).Error
+	if err != nil {
+		return nil, err
+	}
+	return &card, nil
+}
+
+func (r *MonthlyCardRepository) Update(card *dao.MonthlyCard) error {
+	return global.GVA_DB.Save(card).Error
+}
+
+type MonthlyCardQuery struct {
+	PlateNumber string `form:"plate_number"`
+	CardType    string `form:"card_type"`
+	Page        int    `form:"page"`
+	PageSize    int    `form:"page_size"`
+}
+
+type MonthlyCardResult struct {
+	ID            uint    `json:"id"`
+	PlateNumber   string  `json:"plate_number"`
+	CardType      string  `json:"card_type"`
+	StartDate     string  `json:"start_date"`
+	EndDate       string  `json:"end_date"`
+	Fee           float64 `json:"fee"`
+	PaymentStatus string  `json:"payment_status"`
+}
+
+func (r *MonthlyCardRepository) List(q MonthlyCardQuery) ([]MonthlyCardResult, int64, error) {
+	db := global.GVA_DB.Table("monthly_card").
+		Select("monthly_card.id, vehicle.plate_number, monthly_card.card_type, monthly_card.start_date, monthly_card.end_date, monthly_card.fee, monthly_card.payment_status").
+		Joins("LEFT JOIN vehicle ON vehicle.id = monthly_card.vehicle_id")
+
+	if q.PlateNumber != "" {
+		db = db.Where("vehicle.plate_number LIKE ?", "%"+q.PlateNumber+"%")
+	}
+	if q.CardType != "" {
+		db = db.Where("monthly_card.card_type = ?", q.CardType)
+	}
+
+	var total int64
+	db.Count(&total)
+
+	if q.Page <= 0 { q.Page = 1 }
+	if q.PageSize <= 0 { q.PageSize = 10 }
+	offset := (q.Page - 1) * q.PageSize
+
+	var results []MonthlyCardResult
+	err := db.Order("monthly_card.id DESC").Offset(offset).Limit(q.PageSize).Scan(&results).Error
+	return results, total, err
+}
+```
+
+- [ ] **Step 4: 创建 Service**
+
+Write `internal/modules/monthly/service/service.go`:
+
+```go
+package service
+
+import (
+	"errors"
+	"fmt"
+	"time"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/modules/monthly/repository"
+	paymentSvc "wails-app/internal/modules/payment/service"
+)
+
+type MonthlyCardService struct {
+	repo *repository.MonthlyCardRepository
+}
+
+func NewMonthlyCardService() *MonthlyCardService {
+	return &MonthlyCardService{repo: &repository.MonthlyCardRepository{}}
+}
+
+func cardDays(cardType string) int {
+	switch cardType {
+	case "month": return 30
+	case "quarter": return 90
+	case "year": return 365
+	default: return 30
+	}
+}
+
+// Create 办理月卡:创建 monthly_card + shortlist 白名单 + payment_record
+func (s *MonthlyCardService) Create(vehicleID uint, cardType string, fee float64, operatorID uint, remark string) (*dao.MonthlyCard, error) {
+	// 检查是否已有有效月卡
+	existing, _ := s.repo.GetByVehicleID(vehicleID)
+	if existing != nil {
+		return nil, errors.New("该车辆已有有效月卡,请先退卡或等过期后续费")
+	}
+
+	var vehicle dao.Vehicle
+	if err := global.GVA_DB.First(&vehicle, vehicleID).Error; err != nil {
+		return nil, errors.New("车辆不存在")
+	}
+
+	now := time.Now()
+	startDate := now
+	endDate := now.AddDate(0, 0, cardDays(cardType))
+
+	card := &dao.MonthlyCard{
+		VehicleID: vehicleID, CardType: cardType,
+		StartDate: startDate, EndDate: endDate,
+		Fee: fee, PaymentStatus: "paid", OperatorID: operatorID, Remark: remark,
+	}
+	if err := s.repo.Create(card); err != nil {
+		return nil, fmt.Errorf("创建月卡失败: %w", err)
+	}
+
+	// 写入白名单(shortlist),到期日与月卡一致
+	shortlist := dao.Shortlist{
+		VehicleId:      int(vehicleID),
+		ListType:       "白名单",
+		ExpirationTime: &endDate,
+	}
+	global.GVA_DB.Create(&shortlist)
+
+	// 创建 payment_record
+	payRecord := dao.PaymentRecord{
+		RecordID: 0, PaymentMethod: paymentSvc.PaymentCash,
+		Amount: fee, PaidAmount: fee, ChangeAmount: 0,
+		OperatorID: operatorID, PaidAt: now,
+		Remark: fmt.Sprintf("包月卡办理(%s)", cardType),
+	}
+	global.GVA_DB.Create(&payRecord)
+
+	return card, nil
+}
+
+// Renew 续费:延长到期日 + 更新 shortlist
+func (s *MonthlyCardService) Renew(cardID uint, cardType string, fee float64, operatorID uint) error {
+	var card dao.MonthlyCard
+	if err := global.GVA_DB.First(&card, cardID).Error; err != nil {
+		return errors.New("月卡记录不存在")
+	}
+	if card.PaymentStatus != "paid" {
+		return errors.New("月卡已退卡,无法续费")
+	}
+
+	newEnd := card.EndDate.AddDate(0, 0, cardDays(cardType))
+	card.EndDate = newEnd
+	card.Fee += fee
+	global.GVA_DB.Save(&card)
+
+	// 更新 shortlist 到期日
+	global.GVA_DB.Model(&dao.Shortlist{}).Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").
+		Update("expiration_time", newEnd)
+
+	return nil
+}
+
+// Refund 退卡:标记 refunded + 移除 shortlist
+func (s *MonthlyCardService) Refund(cardID uint) error {
+	var card dao.MonthlyCard
+	if err := global.GVA_DB.First(&card, cardID).Error; err != nil {
+		return errors.New("月卡记录不存在")
+	}
+
+	card.PaymentStatus = "refunded"
+	global.GVA_DB.Save(&card)
+
+	// 移除白名单
+	global.GVA_DB.Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").Unscoped().Delete(&dao.Shortlist{})
+
+	return nil
+}
+
+func (s *MonthlyCardService) List(q repository.MonthlyCardQuery) ([]repository.MonthlyCardResult, int64, error) {
+	return s.repo.List(q)
+}
+```
+
+- [ ] **Step 5: 构建 + 提交**
+
+```bash
+mkdir -p internal/modules/monthly/service internal/modules/monthly/repository
+go build -o build/bin/smart-parking.exe .
+git add internal/dao/monthly_card.go internal/modules/monthly/ internal/initialize/gorm.go
+git commit -m "feat: 包月车管理后端——DAO/service/repository,办卡=白名单+支付记录"
+```
+
+---
+
+### Task 2: API Handler + 路由注册
+
+- [ ] **Step 1: 创建 api.go**
+
+Write `internal/modules/monthly/api.go`:
+
+```go
+package monthly
+
+import (
+	"strconv"
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/modules/monthly/repository"
+	"wails-app/internal/modules/monthly/service"
+	utils "wails-app/internal/pkg"
+)
+
+var monthlySvc = service.NewMonthlyCardService()
+
+type createReq struct {
+	VehicleID uint    `json:"vehicle_id"`
+	CardType  string  `json:"card_type"`
+	Fee       float64 `json:"fee"`
+	Remark    string  `json:"remark"`
+}
+
+func CreateCard(c *gin.Context) {
+	var req createReq
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	card, err := monthlySvc.Create(req.VehicleID, req.CardType, req.Fee, utils.GetUserID(c), req.Remark)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(card, c)
+}
+
+type renewReq struct {
+	CardID   uint   `json:"card_id"`
+	CardType string `json:"card_type"`
+	Fee      float64 `json:"fee"`
+}
+
+func RenewCard(c *gin.Context) {
+	var req renewReq
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := monthlySvc.Renew(req.CardID, req.CardType, req.Fee, utils.GetUserID(c)); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("续费成功", c)
+}
+
+func RefundCard(c *gin.Context) {
+	id, _ := strconv.Atoi(c.Query("id"))
+	if err := monthlySvc.Refund(uint(id)); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("退卡成功", c)
+}
+
+func ListCards(c *gin.Context) {
+	var q repository.MonthlyCardQuery
+	if err := c.ShouldBindQuery(&q); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	list, total, err := monthlySvc.List(q)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithDetailed(response.PageResult{List: list, Total: total, Page: q.Page, PageSize: q.PageSize}, "查询成功", c)
+}
+
+func SetupMonthlyCardRouter(router *gin.RouterGroup) {
+	mc := router.Group("/monthly-card")
+	{
+		mc.POST("/create", CreateCard)
+		mc.POST("/renew", RenewCard)
+		mc.DELETE("/refund", RefundCard)
+		mc.GET("/list", ListCards)
+	}
+}
+```
+
+- [ ] **Step 2: 注册路由**
+
+Edit `internal/initialize/router.go`:
+- Import: `"wails-app/internal/modules/monthly"`
+- In PrivateGroup add: `monthly.SetupMonthlyCardRouter(PrivateGroup)`
+
+- [ ] **Step 3: 构建 + Casbin + 提交**
+
+```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','/monthly-card/create','POST'),('p','618','/monthly-card/create','POST'),('p','888','/monthly-card/renew','POST'),('p','618','/monthly-card/renew','POST'),('p','888','/monthly-card/refund','DELETE'),('p','618','/monthly-card/refund','DELETE'),('p','888','/monthly-card/list','GET'),('p','618','/monthly-card/list','GET');"
+git add internal/modules/monthly/api.go internal/initialize/router.go
+git commit -m "feat: 包月车管理API——create/renew/refund/list路由注册"
+```
+
+---
+
+### Task 3: 前端页面 + 菜单
+
+- [ ] **Step 1: API 封装**
+
+Write `frontend/src/api/monthlyCard.js`:
+
+```js
+import service from '@/utils/request'
+
+export const createMonthlyCard = (data) => service({ url: '/monthly-card/create', method: 'post', data })
+export const renewMonthlyCard = (data) => service({ url: '/monthly-card/renew', method: 'post', data })
+export const refundMonthlyCard = (id) => service({ url: '/monthly-card/refund?id=' + id, method: 'delete' })
+export const getMonthlyCardList = (params) => service({ url: '/monthly-card/list', method: 'get', params })
+```
+
+- [ ] **Step 2: 页面**
+
+Write `frontend/src/view/parking/monthlyCard.vue`:
+
+```vue
+<template>
+  <div>
+    <el-form inline>
+      <el-form-item :label="$t.value.LicensePlateNo">
+        <el-input v-model="queryForm.plate_number" :placeholder="$t.value.carPlateInput" clearable />
+      </el-form-item>
+      <el-form-item label="卡类型">
+        <el-select v-model="queryForm.card_type" clearable placeholder="全部" style="width:120px">
+          <el-option label="月卡" value="month" />
+          <el-option label="季卡" value="quarter" />
+          <el-option label="年卡" value="year" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="fetchList">{{ $t.value.Search }}</el-button>
+        <el-button @click="resetQuery">{{ $t.value.Reset }}</el-button>
+        <el-button type="success" @click="openCreate">{{ $t.value.new }}</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-table :data="list" height="605" border>
+      <el-table-column :label="$t.value.licensePlate" prop="plate_number" align="center" width="120" />
+      <el-table-column label="卡类型" align="center" width="100">
+        <template #default="{ row }">{{ cardTypeLabel(row.card_type) }}</template>
+      </el-table-column>
+      <el-table-column label="生效日期" prop="start_date" align="center" width="120" />
+      <el-table-column label="到期日期" prop="end_date" align="center" width="120" />
+      <el-table-column label="金额" align="center" width="100">
+        <template #default="{ row }">¥{{ row.fee.toFixed(2) }}</template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" width="80">
+        <template #default="{ row }">
+          <el-tag :type="row.payment_status === 'paid' ? 'success' : 'danger'">{{ row.payment_status === 'paid' ? '有效' : '已退' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column :label="$t.value.Action" align="center" width="160">
+        <template #default="{ row }">
+          <template v-if="row.payment_status === 'paid'">
+            <el-button text type="primary" @click="openRenew(row)">续费</el-button>
+            <el-button text type="danger" @click="doRefund(row.id)">退卡</el-button>
+          </template>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-pagination v-model:current-page="queryForm.page" :page-size="queryForm.page_size" :total="total" layout="total, prev, pager, next" style="margin-top:12px; justify-content:flex-end" @current-change="fetchList" />
+
+    <!-- 办理弹窗 -->
+    <el-dialog v-model="createVisible" title="办理月卡" width="460px">
+      <el-form :model="createForm" label-width="100px">
+        <el-form-item label="选择车辆" required>
+          <el-select v-model="createForm.vehicle_id" filterable placeholder="搜索车牌号" style="width:100%">
+            <el-option v-for="v in vehicleList" :key="v.ID" :label="v.plate_number" :value="v.ID" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="卡类型" required>
+          <el-select v-model="createForm.card_type" style="width:100%">
+            <el-option label="月卡 (30天)" value="month" />
+            <el-option label="季卡 (90天)" value="quarter" />
+            <el-option label="年卡 (365天)" value="year" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="到期日期">{{ computedEndDate }}</el-form-item>
+        <el-form-item label="金额" required>
+          <el-input-number v-model="createForm.fee" :min="0" :precision="2" style="width:100%" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="createVisible = false">{{ $t.value.Cancel }}</el-button>
+        <el-button type="primary" :loading="submitting" @click="doCreate">{{ $t.value.Confirm }}</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 续费弹窗 -->
+    <el-dialog v-model="renewVisible" title="续费" width="400px">
+      <el-form :model="renewForm" label-width="100px">
+        <el-form-item label="续费时长" required>
+          <el-select v-model="renewForm.card_type" style="width:100%">
+            <el-option label="月卡 (30天)" value="month" />
+            <el-option label="季卡 (90天)" value="quarter" />
+            <el-option label="年卡 (365天)" value="year" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="金额" required>
+          <el-input-number v-model="renewForm.fee" :min="0" :precision="2" style="width:100%" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="renewVisible = false">{{ $t.value.Cancel }}</el-button>
+        <el-button type="primary" :loading="submitting" @click="doRenew">{{ $t.value.Confirm }}</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { createMonthlyCard, renewMonthlyCard, refundMonthlyCard, getMonthlyCardList } from '@/api/monthlyCard'
+import { queryAllVehicles } from '@/api/vehicle'
+
+const queryForm = reactive({ plate_number: '', card_type: '', page: 1, page_size: 10 })
+const list = ref([]), total = ref(0)
+const vehicleList = ref([])
+const createVisible = ref(false), renewVisible = ref(false)
+const submitting = ref(false)
+const createForm = reactive({ vehicle_id: null, card_type: 'month', fee: 0 })
+const renewForm = reactive({ card_id: null, card_type: 'month', fee: 0 })
+
+const cardDays = { month: 30, quarter: 90, year: 365 }
+const cardTypeLabel = (t) => ({ month: '月卡', quarter: '季卡', year: '年卡' }[t] || t)
+
+const computedEndDate = computed(() => {
+  if (!createForm.card_type) return '-'
+  const d = new Date(); d.setDate(d.getDate() + cardDays[createForm.card_type])
+  return d.toLocaleDateString('zh-CN')
+})
+
+onMounted(() => { fetchList(); queryAllVehicles().then(r => { if (r.code === 0) vehicleList.value = r.data || [] }) })
+
+function fetchList() { getMonthlyCardList(queryForm).then(r => { if (r.code === 0) { list.value = r.data.list || []; total.value = r.data.total || 0 } }) }
+function resetQuery() { queryForm.plate_number = ''; queryForm.card_type = ''; queryForm.page = 1; fetchList() }
+
+function openCreate() { createForm.vehicle_id = null; createForm.card_type = 'month'; createForm.fee = 0; createVisible.value = true }
+function openRenew(row) { renewForm.card_id = row.id; renewForm.card_type = 'month'; renewForm.fee = 0; renewVisible.value = true }
+
+async function doCreate() {
+  if (!createForm.vehicle_id) { ElMessage.warning('请选择车辆'); return }
+  submitting.value = true
+  try {
+    const r = await createMonthlyCard(createForm)
+    if (r.code === 0) { ElMessage.success('办理成功'); createVisible.value = false; fetchList() }
+    else ElMessage.error(r.msg)
+  } catch (e) { ElMessage.error(e.message) }
+  finally { submitting.value = false }
+}
+
+async function doRenew() {
+  submitting.value = true
+  try {
+    const r = await renewMonthlyCard(renewForm)
+    if (r.code === 0) { ElMessage.success('续费成功'); renewVisible.value = false; fetchList() }
+    else ElMessage.error(r.msg)
+  } catch (e) { ElMessage.error(e.message) }
+  finally { submitting.value = false }
+}
+
+async function doRefund(id) {
+  await ElMessageBox.confirm('确定退卡?将移除白名单。', '退卡确认', { type: 'warning' })
+  try {
+    const r = await refundMonthlyCard(id)
+    if (r.code === 0) { ElMessage.success('退卡成功'); fetchList() }
+    else ElMessage.error(r.msg)
+  } catch (e) { ElMessage.error(e.message) }
+}
+</script>
+```
+
+- [ ] **Step 3: 翻译键**
+
+zh-CN.js:
+```js
+monthlyCard: '包月车管理',
+monthCard: '月卡',
+quarterCard: '季卡',
+yearCard: '年卡',
+```
+
+en.js:
+```js
+monthlyCard: 'Monthly Card',
+monthCard: 'Month',
+quarterCard: 'Quarter',
+yearCard: 'Year',
+```
+
+- [ ] **Step 4: 菜单**
+
+Seed + DB insert, ParentId: 20, Sort: 6:
+```bash
+sqlite3 "$APPDATA/smart-parking/lc_garage.db" "INSERT INTO sys_base_menus (menu_level, parent_id, path, name, hidden, component, sort, title, icon, created_at, updated_at) VALUES (0, 20, 'monthlyCard', 'monthlyCard', 0, 'view/parking/monthlyCard.vue', 6, '包月车管理', 'calendar', datetime('now'), datetime('now')); INSERT INTO sys_authority_menus VALUES ('618', 33), ('888', 33), ('9527', 33);"
+```
+
+Seed.go: add menu entry + authority_menu + Casbin rules.
+
+- [ ] **Step 5: 构建 + 提交**
+
+```bash
+cd frontend && npm run build && cd ..
+git add frontend/src/ internal/initialize/seed.go
+git commit -m "feat: 包月车管理前端——办理/续费/退卡页面+菜单+多语言"
+```

+ 349 - 0
docs/superpowers/plans/2026-07-23-digital-ticket-plan.md

@@ -0,0 +1,349 @@
+# 数字票(Digital Ticket)— 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans.
+
+**Goal:** 引入数字票统一抽象,所有进场方式创建 digital_ticket,5 状态机驱动停车流程
+
+**Architecture:** 新增 `internal/modules/digital-ticket/` 模块。VehicleEntry 调用 CreateTicket 创建票。状态转换原子化,事件日志 JSON 追加。
+
+---
+
+## 文件结构
+
+| 文件 | 操作 |
+|------|------|
+| `internal/dao/digital_ticket.go` | 新建 |
+| `internal/modules/digital-ticket/api.go` | 新建 |
+| `internal/modules/digital-ticket/service/service.go` | 新建 |
+| `internal/modules/digital-ticket/repository/repo.go` | 新建 |
+| `internal/service/vehicle/vehicle.go` | 修改 |
+| `internal/initialize/gorm.go` | 修改 |
+| `internal/initialize/router.go` | 修改 |
+
+---
+
+### Task 1: 数据层 + 服务层
+
+- [ ] **Step 1: DAO**
+
+Write `internal/dao/digital_ticket.go`:
+
+```go
+package dao
+
+import (
+	"time"
+	"wails-app/internal/global"
+)
+
+type DigitalTicket struct {
+	global.GVA_MODEL
+	TicketNo        string     `gorm:"size:64;uniqueIndex" json:"ticket_no"`
+	PlateNumber     string     `gorm:"size:20" json:"plate_number"`
+	TriggerMode     string     `gorm:"size:20" json:"trigger_mode"` // rfid / plate_recognition / manual
+	State           string     `gorm:"size:20;default:created" json:"state"`
+	VehicleRecordID uint       `json:"vehicle_record_id"`
+	EntryTime       time.Time  `json:"entry_time"`
+	ExitTime        *time.Time `json:"exit_time"`
+	Fee             float64    `json:"fee"`
+	PaymentMethod   string     `gorm:"size:20" json:"payment_method"`
+	PaymentOrderNo  string     `gorm:"size:64" json:"payment_order_no"`
+	PaidAt          *time.Time `json:"paid_at"`
+	ExpireAt        *time.Time `json:"expire_at"`
+	EventLog        string     `gorm:"type:text" json:"event_log"` // JSON array
+}
+
+func (DigitalTicket) TableName() string { return "digital_ticket" }
+```
+
+- [ ] **Step 2: AutoMigrate**
+
+Edit `internal/initialize/gorm.go`, add after `dao.MonthlyCard{}`:
+
+```go
+dao.MonthlyCard{},
+dao.DigitalTicket{},
+```
+
+- [ ] **Step 3: Repository**
+
+Write `internal/modules/digital-ticket/repository/repo.go`:
+
+```go
+package repository
+
+import (
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+type TicketRepository struct{}
+
+func (r *TicketRepository) Create(ticket *dao.DigitalTicket) error {
+	return global.GVA_DB.Create(ticket).Error
+}
+
+func (r *TicketRepository) GetByTicketNo(ticketNo string) (*dao.DigitalTicket, error) {
+	var t dao.DigitalTicket
+	err := global.GVA_DB.Where("ticket_no = ?", ticketNo).First(&t).Error
+	if err != nil { return nil, err }
+	return &t, nil
+}
+
+func (r *TicketRepository) UpdateState(id uint, state string, updates map[string]interface{}) error {
+	updates["state"] = state
+	return global.GVA_DB.Model(&dao.DigitalTicket{}).Where("id = ?", id).Updates(updates).Error
+}
+
+type TicketListQuery struct {
+	PlateNumber string `form:"plate_number"`
+	State       string `form:"state"`
+	Page        int    `form:"page"`
+	PageSize    int    `form:"page_size"`
+}
+
+func (r *TicketRepository) List(q TicketListQuery) ([]dao.DigitalTicket, int64, error) {
+	db := global.GVA_DB.Model(&dao.DigitalTicket{})
+	if q.PlateNumber != "" { db = db.Where("plate_number LIKE ?", "%"+q.PlateNumber+"%") }
+	if q.State != "" { db = db.Where("state = ?", q.State) }
+	var total int64; db.Count(&total)
+	if q.Page <= 0 { q.Page = 1 }; if q.PageSize <= 0 { q.PageSize = 10 }
+	var list []dao.DigitalTicket
+	err := db.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&list).Error
+	return list, total, err
+}
+```
+
+- [ ] **Step 4: Service**
+
+Write `internal/modules/digital-ticket/service/service.go`:
+
+```go
+package service
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/modules/digital-ticket/repository"
+)
+
+const (
+	StateCreated        = "created"
+	StatePendingPayment = "pending_payment"
+	StatePaid           = "paid"
+	StateExited         = "exited"
+	StateExpired        = "expired"
+)
+
+type TicketService struct {
+	repo *repository.TicketRepository
+}
+
+func NewTicketService() *TicketService { return &TicketService{repo: &repository.TicketRepository{}} }
+
+func genTicketNo() string {
+	b := make([]byte, 16); rand.Read(b)
+	return hex.EncodeToString(b)
+}
+
+func appendEvent(log string, event map[string]interface{}) string {
+	var events []map[string]interface{}
+	json.Unmarshal([]byte(log), &events)
+	events = append(events, event)
+	data, _ := json.Marshal(events)
+	return string(data)
+}
+
+// Create 创建数字票(所有进场方式统一入口)
+func (s *TicketService) Create(plateNumber, triggerMode string, recordID uint) (*dao.DigitalTicket, error) {
+	log := appendEvent("[]", map[string]interface{}{"at": time.Now().Format(time.RFC3339), "event": "created", "trigger": triggerMode})
+	ticket := &dao.DigitalTicket{
+		TicketNo: genTicketNo(), PlateNumber: plateNumber, TriggerMode: triggerMode,
+		State: StateCreated, VehicleRecordID: recordID, EntryTime: time.Now(), EventLog: log,
+	}
+	if err := s.repo.Create(ticket); err != nil { return nil, err }
+	// created → pending_payment
+	s.Transition(ticket.ID, StatePendingPayment, nil)
+	return ticket, nil
+}
+
+// Transition 状态转换(原子化,校验合法性)
+func (s *TicketService) Transition(id uint, toState string, extra map[string]interface{}) error {
+	ticket, err := s.repo.GetByTicketNo(fmt.Sprintf("%d", id))
+	if err != nil { ticket = &dao.DigitalTicket{Model: global.GVA_MODEL{ID: id}} }
+
+	validTransitions := map[string][]string{
+		StateCreated:        {StatePendingPayment},
+		StatePendingPayment: {StatePaid, StateExpired},
+		StatePaid:           {StateExited, StateExpired},
+		StateExited:         {},
+		StateExpired:        {},
+	}
+	allowed := false
+	for _, s := range validTransitions[ticket.State] { if s == toState { allowed = true; break } }
+	if !allowed { return errors.New("状态转换不合法: " + ticket.State + " → " + toState) }
+
+	updates := map[string]interface{}{}
+	if toState == StatePaid {
+		updates["paid_at"] = time.Now()
+	}
+	if toState == StateExited {
+		now := time.Now(); updates["exit_time"] = now
+	}
+	if toState == StateExpired {
+		updates["expire_at"] = time.Now()
+	}
+	for k, v := range extra { updates[k] = v }
+
+	// 追加事件日志
+	event := map[string]interface{}{"at": time.Now().Format(time.RFC3339), "event": toState}
+	for k, v := range extra { event[k] = v }
+	updates["event_log"] = appendEvent(ticket.EventLog, event)
+
+	return s.repo.UpdateState(id, toState, updates)
+}
+
+func (s *TicketService) GetByTicketNo(no string) (*dao.DigitalTicket, error) {
+	return s.repo.GetByTicketNo(no)
+}
+
+func (s *TicketService) List(q repository.TicketListQuery) ([]dao.DigitalTicket, int64, error) {
+	return s.repo.List(q)
+}
+```
+
+- [ ] **Step 5: 编译+提交**
+
+```bash
+mkdir -p internal/modules/digital-ticket/service internal/modules/digital-ticket/repository
+go build -o build/bin/smart-parking.exe .
+git add internal/dao/digital_ticket.go internal/modules/digital-ticket/ internal/initialize/gorm.go
+git commit -m "feat: 数字票模块——DAO/repo/service,5状态机+事件日志"
+```
+
+---
+
+### Task 2: API + 路由 + 集成
+
+- [ ] **Step 1: api.go**
+
+Write `internal/modules/digital-ticket/api.go`:
+
+```go
+package digital_ticket
+
+import (
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/modules/digital-ticket/repository"
+	"wails-app/internal/modules/digital-ticket/service"
+)
+
+var ticketSvc = service.NewTicketService()
+
+func GetTicket(c *gin.Context) {
+	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
+	if err != nil { response.FailWithMessage("票不存在", c); return }
+	response.OkWithData(t, c)
+}
+
+func CalculateFee(c *gin.Context) {
+	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
+	if err != nil { response.FailWithMessage("票不存在", c); return }
+	if err := ticketSvc.Transition(t.ID, service.StatePendingPayment, nil); err != nil {
+		response.FailWithMessage(err.Error(), c); return
+	}
+	response.OkWithMessage("计费完成", c)
+}
+
+type payReq struct {
+	PaymentMethod  string  `json:"payment_method"`
+	PaymentOrderNo string  `json:"payment_order_no"`
+	PaidAmount     float64 `json:"paid_amount"`
+}
+
+func PayTicket(c *gin.Context) {
+	var req payReq
+	if err := c.ShouldBindJSON(&req); err != nil { response.FailWithMessage(err.Error(), c); return }
+	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
+	if err != nil { response.FailWithMessage("票不存在", c); return }
+
+	extra := map[string]interface{}{
+		"payment_method": req.PaymentMethod, "payment_order_no": req.PaymentOrderNo,
+		"fee": t.Fee, "paid_amount": req.PaidAmount,
+	}
+	if err := ticketSvc.Transition(t.ID, service.StatePaid, extra); err != nil {
+		response.FailWithMessage(err.Error(), c); return
+	}
+	// 同时更新 payment_method 和 payment_order_no
+	global.GVA_DB.Model(t).Updates(map[string]interface{}{
+		"payment_method": req.PaymentMethod, "payment_order_no": req.PaymentOrderNo,
+	})
+	response.OkWithMessage("支付成功", c)
+}
+
+func ExitTicket(c *gin.Context) {
+	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
+	if err != nil { response.FailWithMessage("票不存在", c); return }
+	if err := ticketSvc.Transition(t.ID, service.StateExited, nil); err != nil {
+		response.FailWithMessage(err.Error(), c); return
+	}
+	response.OkWithMessage("已离场", c)
+}
+
+func ListTickets(c *gin.Context) {
+	var q repository.TicketListQuery
+	if err := c.ShouldBindQuery(&q); err != nil { response.FailWithMessage(err.Error(), c); return }
+	list, total, err := ticketSvc.List(q)
+	if err != nil { response.FailWithMessage(err.Error(), c); return }
+	response.OkWithDetailed(response.PageResult{List: list, Total: total, Page: q.Page, PageSize: q.PageSize}, "查询成功", c)
+}
+
+func SetupDigitalTicketRouter(router *gin.RouterGroup) {
+	dt := router.Group("/digital-ticket")
+	dt.GET("/:ticketNo", GetTicket)
+	dt.POST("/:ticketNo/calculate", CalculateFee)
+	dt.POST("/:ticketNo/pay", PayTicket)
+	dt.POST("/:ticketNo/exit", ExitTicket)
+	dt.GET("/list", ListTickets)
+}
+```
+
+Need to add `"wails-app/internal/global"` import for `PayTicket`.
+
+- [ ] **Step 2: 注册路由**
+
+Edit `internal/initialize/router.go`, import `"wails-app/internal/modules/digital-ticket"`, add:
+
+```go
+digital_ticket.SetupDigitalTicketRouter(PrivateGroup)
+```
+
+- [ ] **Step 3: VehicleEntry 集成**
+
+Edit `internal/service/vehicle/vehicle.go`. After creating the vehicle_record (after `err = global.GVA_DB.Create(&record).Error` and error check), add:
+
+```go
+// 创建数字票
+ticketSvc := ticket_service.NewTicketService()
+triggerMode := "manual"
+if req.RFIDTag != "" { triggerMode = "rfid" }
+ticketSvc.Create(req.PlateNumber, triggerMode, record.ID)
+```
+
+Need import `ticket_service "wails-app/internal/modules/digital-ticket/service"`.
+
+- [ ] **Step 4: Casbin + 编译+提交**
+
+```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','/digital-ticket','GET'),('p','618','/digital-ticket','GET'),('p','888','/digital-ticket','POST'),('p','618','/digital-ticket','POST');"
+git add internal/modules/digital-ticket/api.go internal/initialize/router.go internal/service/vehicle/vehicle.go
+git commit -m "feat: 数字票API+VehicleEntry集成——所有入场统一创建数字票"
+```

+ 106 - 0
docs/superpowers/specs/2026-07-22-monthly-card-design.md

@@ -0,0 +1,106 @@
+# 包月车管理 — 设计文档
+
+> 日期:2026-07-22 | 优先级:P1
+
+## 1. 目标
+
+实现包月车(月卡/季卡/年卡)的办理、续费、到期管理。办卡自动加入白名单,到期自动移除,进出场复用现有白名单检查逻辑。
+
+## 2. 核心设计
+
+### 办卡 = 自动白名单
+
+```
+选车辆 → 选卡类型 → 自动计算到期日 → 确认办理
+  ├── INSERT monthly_card
+  ├── INSERT shortlist (白名单 + ExpirationTime)
+  └── INSERT payment_record
+```
+
+### 进出场识别
+
+复用现有白名单检查(passage.go / vehicle.go),零改动。月卡到期后短名单自动失效。
+
+### 到期处理
+
+车辆进出场时,若白名单 `ExpirationTime < now`,前端/后端提示已到期。
+
+## 3. 数据模型
+
+### 新增表:`monthly_card`
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | uint | 主键 |
+| vehicle_id | uint | 关联 vehicle.id |
+| card_type | string | month / quarter / year |
+| start_date | date | 生效日期 |
+| end_date | date | 到期日期 |
+| fee | float64 | 实收金额 |
+| payment_status | string | paid / refunded |
+| operator_id | uint | 办理人 |
+| remark | string | 备注 |
+
+### 复用表
+
+| 表 | 用途 |
+|------|------|
+| shortlist | 白名单 + ExpirationTime = end_date |
+| payment_record | 收银记录 |
+
+## 4. 页面结构
+
+新增菜单:**停车管理 → 包月车管理**
+
+```
+┌─────────────────────────────────────────┐
+│ 包月车管理                              │
+│                                         │
+│ [办理月卡]                               │
+│                                         │
+│ ┌───────────────────────────────────────┐│
+│ │ 列表:车牌 | 卡类型 | 生效日期 | 到期日 ││
+│ │ 金额 | 状态 | 操作(续费/退卡)         ││
+│ └───────────────────────────────────────┘│
+└─────────────────────────────────────────┘
+```
+
+**办理弹窗**:
+```
+车牌号选择器 → 卡类型(月/季/年) → 自动显示到期日 → 金额输入 → 确认
+```
+
+**续费**:点击续费 → 选续费时长 → 确认(end_date 延长 + 更新 shortlist)
+
+## 5. API
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | /monthly-card/create | 办理月卡 |
+| POST | /monthly-card/renew | 续费 |
+| GET | /monthly-card/list | 列表(车牌/类型/状态筛选) |
+| DELETE | /monthly-card/refund | 退卡 |
+
+## 6. 文件变更
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `internal/dao/monthly_card.go` | 新增 | DAO 实体 |
+| `internal/modules/monthly/api.go` | 新增 | Handler + Router |
+| `internal/modules/monthly/service/service.go` | 新增 | 办理/续费/退卡 |
+| `internal/modules/monthly/repository/repo.go` | 新增 | CRUD |
+| `internal/initialize/router.go` | 修改 | 注册路由 |
+| `internal/initialize/gorm.go` | 修改 | AutoMigrate |
+| `internal/initialize/seed.go` | 修改 | 菜单+权限 |
+| `frontend/src/view/parking/monthlyCard.vue` | 新增 | 包月车管理页面 |
+| `frontend/src/api/monthlyCard.js` | 新增 | API 封装 |
+| `frontend/src/lang/zh-CN.js, en.js` | 修改 | 翻译键 |
+
+## 7. 边界情况
+
+| 场景 | 处理 |
+|------|------|
+| 一辆车多次办卡 | 只能有一条有效记录,续费延长到期日 |
+| 到期后车辆进出 | 白名单 ExpirationTime 过期,按临时车计费 |
+| 退卡 | 标记 refunded,从 shortlist 移除 |
+| 月卡到期当天 | 当天仍有效(23:59:59 前) |

+ 97 - 0
docs/superpowers/specs/2026-07-23-digital-ticket-design.md

@@ -0,0 +1,97 @@
+# 数字票(Digital Ticket)— 设计文档
+
+> 日期:2026-07-23 | 优先级:P0
+
+## 1. 目标
+
+引入数字票作为停车业务的统一抽象模型。所有进场方式(RFID/车牌识别/手动)最终创建数字票,以票的状态驱动计费、支付和放行。
+
+## 2. 状态机
+
+```
+                    ┌──────────┐
+               ┌──→ │  已过期   │ ←──┐
+               │    └──────────┘    │
+               │     超时未支付      │ 超时未离场
+               │                    │
+  ┌────────┐  │  ┌───────────┐  ┌──┴───┐  ┌────────┐
+  │ 已创建  │─→│  │  待支付    │─→│ 已支付 │─→│ 已离场  │
+  └────────┘    └───────────┘  └──────┘  └────────┘
+  车辆入场      自动转换       支付成功    验证放行
+```
+
+| 状态 | 描述 | 触发条件 |
+|------|------|---------|
+| created | 车辆入场,数字票生成 | 入场事件触发 |
+| pending_payment | 待支付 | 创建完成后自动进入 |
+| paid | 已支付,费用结清 | 支付成功回调 |
+| exited | 已离场,流程结束 | 验证通过后开闸 |
+| expired | 超时未支付或未离场 | 定时任务/超时逻辑 |
+
+## 3. 数据模型
+
+### digital_ticket 表
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | uint | 主键 |
+| ticket_no | string | 票号(UUID,二维码凭证) |
+| plate_number | string | 车牌 |
+| trigger_mode | string | rfid / plate_recognition / manual |
+| state | string | created / pending_payment / paid / exited / expired |
+| vehicle_record_id | uint | 关联 vehicle_record |
+| entry_time | datetime | 入场时间 |
+| exit_time | datetime | 出场时间 |
+| fee | float64 | 计费金额 |
+| payment_method | string | cash / free / wechat / alipay |
+| payment_order_no | string | 支付单号(关联 payment_record) |
+| paid_at | datetime | 支付时间 |
+| expire_at | datetime | 超时过期时间 |
+| event_log | text(JSON) | 事件日志数组 |
+
+### event_log 格式
+
+```json
+[
+  {"at": "2026-07-23 10:30", "event": "created", "trigger": "manual"},
+  {"at": "2026-07-23 12:30", "event": "fee_calculated", "fee": 15.0},
+  {"at": "2026-07-23 12:31", "event": "paid", "method": "cash", "amount": 20}
+]
+```
+
+## 4. API
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | /digital-ticket/create | 创建票(入场时) |
+| GET | /digital-ticket/:ticketNo | 查询票(扫码用) |
+| POST | /digital-ticket/:ticketNo/calculate | 计费 |
+| POST | /digital-ticket/:ticketNo/pay | 支付 |
+| POST | /digital-ticket/:ticketNo/exit | 离场验证 |
+| GET | /digital-ticket/list | 列表 |
+
+## 5. 与现有流程关系
+
+```
+任何进场方式 → DigitalTicket.Create() + VehicleEntry()
+                                    │
+                            状态: created → pending_payment
+                                    │
+                             ExitPreview / ExitConfirm
+                                    │
+                            状态: paid → exited
+```
+
+数字票作为业务状态层,位于 `internal/modules/digital-ticket/`,不替代 vehicle_record。
+
+## 6. 文件变更
+
+| 文件 | 操作 |
+|------|------|
+| `internal/dao/digital_ticket.go` | 新建 |
+| `internal/modules/digital-ticket/api.go` | 新建 |
+| `internal/modules/digital-ticket/service/service.go` | 新建 |
+| `internal/modules/digital-ticket/repository/repo.go` | 新建 |
+| `internal/service/vehicle/vehicle.go` | 修改(VehicleEntry 创建 digital_ticket) |
+| `internal/initialize/gorm.go` | 修改(AutoMigrate) |
+| `internal/initialize/router.go` | 修改(路由注册) |

+ 6 - 0
frontend/src/api/monthlyCard.js

@@ -0,0 +1,6 @@
+import service from '@/utils/request'
+
+export const createMonthlyCard = (data) => service({ url: '/monthly-card/create', method: 'post', data })
+export const renewMonthlyCard = (data) => service({ url: '/monthly-card/renew', method: 'post', data })
+export const refundMonthlyCard = (id) => service({ url: '/monthly-card/refund?id=' + id, method: 'delete' })
+export const getMonthlyCardList = (params) => service({ url: '/monthly-card/list', method: 'get', params })

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

@@ -187,3 +187,12 @@ export const exitConfirm = (data) => {
     data
   })
 }
+
+// 在场车辆列表
+export const getInParkVehicles = (params) => {
+  return service({
+    url: '/vehicle/in-park',
+    method: 'get',
+    params
+  })
+}

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

@@ -223,4 +223,5 @@ export default {
   confirmPayAndExit: 'Confirm & Release',
   insufficientAmount: 'Insufficient amount',
   paymentRecord: 'Payment Record',
+  monthlyCard: 'Monthly Card',
 }

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

@@ -223,4 +223,5 @@ export default {
   confirmPayAndExit: '确认收款并放行',
   insufficientAmount: '实收金额不足',
   paymentRecord: '收费记录',
+  monthlyCard: '包月车管理',
 }

+ 1 - 0
frontend/src/lang/zh-HK.js

@@ -215,4 +215,5 @@ export default {
   rfidInput: '請輸入RFID標籤',
   exitRecordDetail: '進場: {entryTime} | 停留: {duration} | 費用: ¥{fee}',
   paymentRecord: '收費記錄',
+  monthlyCard: '包月車管理',
 }

+ 1 - 0
frontend/src/lang/zh-TW.js

@@ -215,4 +215,5 @@ export default {
   rfidInput: '請輸入RFID標籤',
   exitRecordDetail: '進場: {entryTime} | 停留: {duration} | 費用: ¥{fee}',
   paymentRecord: '收費記錄',
+  monthlyCard: '包月車管理',
 }

+ 147 - 0
frontend/src/view/parking/monthlyCard.vue

@@ -0,0 +1,147 @@
+<template>
+  <div>
+    <el-form inline>
+      <el-form-item :label="$t.value.LicensePlateNo">
+        <el-input v-model="queryForm.plate_number" :placeholder="$t.value.carPlateInput" clearable />
+      </el-form-item>
+      <el-form-item label="卡类型">
+        <el-select v-model="queryForm.card_type" clearable placeholder="全部" style="width:120px">
+          <el-option label="月卡" value="month" />
+          <el-option label="季卡" value="quarter" />
+          <el-option label="年卡" value="year" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="fetchList">{{ $t.value.Search }}</el-button>
+        <el-button @click="resetQuery">{{ $t.value.Reset }}</el-button>
+        <el-button type="success" @click="openCreate">{{ $t.value.new }}</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-table :data="list" height="605" border>
+      <el-table-column :label="$t.value.licensePlate" prop="plate_number" align="center" width="120" />
+      <el-table-column label="卡类型" align="center" width="100">
+        <template #default="{ row }">{{ typeLabel(row.card_type) }}</template>
+      </el-table-column>
+      <el-table-column label="生效日期" prop="start_date" align="center" width="120" />
+      <el-table-column label="到期日期" prop="end_date" align="center" width="120" />
+      <el-table-column label="金额" align="center" width="100">
+        <template #default="{ row }">¥{{ row.fee.toFixed(2) }}</template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" width="80">
+        <template #default="{ row }">
+          <el-tag :type="row.payment_status === 'paid' ? 'success' : 'danger'">{{ row.payment_status === 'paid' ? '有效' : '已退' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column :label="$t.value.Action" align="center" width="160">
+        <template #default="{ row }">
+          <template v-if="row.payment_status === 'paid'">
+            <el-button text type="primary" @click="openRenew(row)">续费</el-button>
+            <el-button text type="danger" @click="doRefund(row.id)">退卡</el-button>
+          </template>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-pagination v-model:current-page="queryForm.page" :page-size="queryForm.page_size" :total="total" layout="total, prev, pager, next" style="margin-top:12px; justify-content:flex-end" @current-change="fetchList" />
+
+    <el-dialog v-model="createVisible" title="办理月卡" width="460px">
+      <el-form :model="createForm" label-width="100px">
+        <el-form-item label="选择车辆" required>
+          <el-select v-model="createForm.vehicle_id" filterable placeholder="搜索车牌号" style="width:100%">
+            <el-option v-for="v in vehicleList" :key="v.ID" :label="v.plate_number" :value="v.ID" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="卡类型" required>
+          <el-select v-model="createForm.card_type" style="width:100%">
+            <el-option label="月卡 (30天)" value="month" />
+            <el-option label="季卡 (90天)" value="quarter" />
+            <el-option label="年卡 (365天)" value="year" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="到期日期">{{ computeEnd(createForm.card_type) }}</el-form-item>
+        <el-form-item label="金额" required>
+          <el-input-number v-model="createForm.fee" :min="0" :precision="2" style="width:100%" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="createVisible = false">{{ $t.value.Cancel }}</el-button>
+        <el-button type="primary" :loading="submitting" @click="doCreate">{{ $t.value.Confirm }}</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="renewVisible" title="续费" width="400px">
+      <el-form :model="renewForm" label-width="100px">
+        <el-form-item label="续费时长" required>
+          <el-select v-model="renewForm.card_type" style="width:100%">
+            <el-option label="月卡 (30天)" value="month" />
+            <el-option label="季卡 (90天)" value="quarter" />
+            <el-option label="年卡 (365天)" value="year" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="金额" required>
+          <el-input-number v-model="renewForm.fee" :min="0" :precision="2" style="width:100%" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="renewVisible = false">{{ $t.value.Cancel }}</el-button>
+        <el-button type="primary" :loading="submitting" @click="doRenew">{{ $t.value.Confirm }}</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { createMonthlyCard, renewMonthlyCard, refundMonthlyCard, getMonthlyCardList } from '@/api/monthlyCard'
+import { queryAllVehicles } from '@/api/vehicle'
+
+const days = { month: 30, quarter: 90, year: 365 }
+const labels = { month: '月卡', quarter: '季卡', year: '年卡' }
+const typeLabel = (t) => labels[t] || t
+
+const queryForm = reactive({ plate_number: '', card_type: '', page: 1, page_size: 10 })
+const list = ref([]), total = ref(0), vehicleList = ref([])
+const createVisible = ref(false), renewVisible = ref(false), submitting = ref(false)
+const createForm = reactive({ vehicle_id: null, card_type: 'month', fee: 0 })
+const renewForm = reactive({ card_id: null, card_type: 'month', fee: 0 })
+
+function computeEnd(t) {
+  const d = new Date(); d.setDate(d.getDate() + (days[t] || 30))
+  return d.toLocaleDateString('zh-CN')
+}
+
+onMounted(() => { fetchList(); queryAllVehicles().then(r => { if (r.code === 0) vehicleList.value = r.data || [] }) })
+
+function fetchList() { getMonthlyCardList(queryForm).then(r => { if (r.code === 0) { list.value = r.data.list || []; total.value = r.data.total || 0 } }) }
+function resetQuery() { queryForm.plate_number = ''; queryForm.card_type = ''; queryForm.page = 1; fetchList() }
+
+function openCreate() { createForm.vehicle_id = null; createForm.card_type = 'month'; createForm.fee = 0; createVisible.value = true }
+function openRenew(row) { renewForm.card_id = row.id; renewForm.card_type = 'month'; renewForm.fee = 0; renewVisible.value = true }
+
+async function doCreate() {
+  if (!createForm.vehicle_id) { ElMessage.warning('请选择车辆'); return }
+  submitting.value = true
+  try {
+    const r = await createMonthlyCard(createForm)
+    if (r.code === 0) { ElMessage.success('办理成功'); createVisible.value = false; fetchList() } else ElMessage.error(r.msg)
+  } catch (e) { ElMessage.error(e.message) } finally { submitting.value = false }
+}
+
+async function doRenew() {
+  submitting.value = true
+  try {
+    const r = await renewMonthlyCard(renewForm)
+    if (r.code === 0) { ElMessage.success('续费成功'); renewVisible.value = false; fetchList() } else ElMessage.error(r.msg)
+  } catch (e) { ElMessage.error(e.message) } finally { submitting.value = false }
+}
+
+async function doRefund(id) {
+  await ElMessageBox.confirm('确定退卡?将移除白名单。', '退卡确认', { type: 'warning' })
+  try {
+    const r = await refundMonthlyCard(id)
+    if (r.code === 0) { ElMessage.success('退卡成功'); fetchList() } else ElMessage.error(r.msg)
+  } catch (e) { ElMessage.error(e.message) }
+}
+</script>

+ 72 - 39
frontend/src/view/statistics/vehicle.vue

@@ -1,40 +1,51 @@
 <template>
   <div>
-    <el-form inline style="border-bottom:1px solid #e4e7ed">
-      <el-form-item label="区域:">
-        <el-select></el-select>
+    <el-form inline>
+      <el-form-item label="停车场">
+        <el-select v-model="lotID" clearable placeholder="全部" style="width:180px" @change="fetchData">
+          <el-option v-for="lot in lotList" :key="lot.ID" :label="lot.lot_name" :value="lot.ID" />
+        </el-select>
       </el-form-item>
-      <el-form-item>
-        <el-button icon="Refresh">刷新</el-button>
+      <el-form-item :label="$t.value.LicensePlateNo">
+        <el-input v-model="plateNumber" :placeholder="$t.value.carPlateInput" clearable style="width:160px" @keyup.enter="fetchData" />
       </el-form-item>
       <el-form-item>
-        <el-text type="success">空闲车位:</el-text>
+        <el-button type="primary" icon="Search" @click="fetchData">{{ $t.value.Search }}</el-button>
+        <el-button icon="Refresh" @click="fetchData">{{ $t.value.reload }}</el-button>
       </el-form-item>
       <el-form-item>
-        <el-text type="warning">在场车辆:</el-text>
+        <el-text type="success">空闲车位:{{ available }}</el-text>
       </el-form-item>
       <el-form-item>
-        <el-input/>
+        <el-text type="warning">在场车辆:{{ list.length }} 台</el-text>
       </el-form-item>
     </el-form>
-    <el-row style="margin-top: 10px">
+
+    <el-row :gutter="12">
       <el-col :span="17">
-        <div class="carBox">
-          <span>001</span>
+        <div class="car-grid">
+          <div v-for="(v, i) in list" :key="v.record_id" class="car-item" :class="{ active: selected === i }" @click="select(i)">
+            <span class="car-plate">{{ v.plate_number }}</span>
+            <span class="car-time">{{ formatStay(v.stay_minutes) }}</span>
+          </div>
+          <el-empty v-if="list.length === 0" description="暂无在场车辆" style="width:100%" />
         </div>
       </el-col>
-      <el-col :span="6" style="margin-left: 10px">
-        <div class="infoBox">
-          <el-form label-width="100px" label-position="left">
-            <el-form-item label="车辆:"></el-form-item>
-            <el-form-item label="车辆类型:"></el-form-item>
-            <el-form-item label="到期日期:"></el-form-item>
-            <el-form-item label="车主名称:"></el-form-item>
-            <el-form-item label="手机:"></el-form-item>
-            <el-form-item label="入场时间:"></el-form-item>
-            <el-form-item label="预计收费:"></el-form-item>
-            <el-form-item label="车位:"></el-form-item>
-          </el-form>
+      <el-col :span="7">
+        <div class="info-box" v-if="selected !== null">
+          <h4>车辆详情</h4>
+          <el-descriptions :column="1" border size="small">
+            <el-descriptions-item label="车牌">{{ cur.plate_number }}</el-descriptions-item>
+            <el-descriptions-item label="车辆类型">{{ cur.vehicle_type || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="车主">{{ cur.owner_name || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="手机">{{ cur.owner_phone || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="入场时间">{{ cur.entry_time }}</el-descriptions-item>
+            <el-descriptions-item label="停留时长">{{ formatStay(cur.stay_minutes) }}</el-descriptions-item>
+            <el-descriptions-item label="停车场">{{ cur.parking_lot || '-' }}</el-descriptions-item>
+          </el-descriptions>
+        </div>
+        <div class="info-box empty" v-else>
+          <el-empty description="点击车辆查看详情" :image-size="80" />
         </div>
       </el-col>
     </el-row>
@@ -42,23 +53,45 @@
 </template>
 
 <script setup>
+import { ref, computed, onMounted } from 'vue'
+import { getInParkVehicles } from '@/api/vehicle'
+import { obtainPullOverList } from '@/api/pullOver'
 
-</script>
+const lotID = ref(null), plateNumber = ref('')
+const list = ref([]), lotList = ref([]), selected = ref(null)
+const cur = computed(() => selected.value !== null ? list.value[selected.value] : {})
 
-<style scoped lang="scss">
-.infoBox {
-  width: 400px;
-  height: 700px;
-  background-color: #f7f9fa;
-  padding: 20px;
-  border: 1px solid #99a9bf;
-}
-.carBox {
-  width: 70px;
-  height: 100px;
-  border: 1px solid #000000;
-  display:flex;
-  align-items: center;
-  justify-content: center;
+const available = computed(() => {
+  const total = lotList.value.reduce((s, l) => s + (l.capacity || 0), 0)
+  return total - list.value.length
+})
+
+function formatStay(m) { if (!m && m !== 0) return '-'; const h = Math.floor(m / 60); return h > 0 ? `${h}h${m % 60}m` : `${m}m` }
+
+function select(i) { selected.value = selected.value === i ? null : i }
+
+async function fetchData() {
+  try {
+    const [vehicles, lots] = await Promise.all([
+      getInParkVehicles({ parking_lot_id: lotID.value || '', plate_number: plateNumber.value }),
+      obtainPullOverList({ page: 1, pageSize: 100 })
+    ])
+    if (vehicles.code === 0) list.value = vehicles.data || []
+    if (lots.code === 0) lotList.value = lots.data?.list || []
+  } catch (e) { console.error(e) }
 }
+
+onMounted(() => fetchData())
+</script>
+
+<style scoped>
+.car-grid { display: flex; flex-wrap: wrap; gap: 8px; min-height: 200px; align-content: flex-start; }
+.car-item { width: 110px; height: 70px; border: 1px solid #dcdfe6; border-radius: 6px; display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: pointer; transition: all .2s; background: #fff; }
+.car-item:hover { border-color: #409eff; box-shadow: 0 2px 8px rgba(64,158,255,.15); }
+.car-item.active { border-color: #409eff; background: #ecf5ff; }
+.car-plate { font-weight: 700; font-size: 15px; }
+.car-time { font-size: 11px; color: #909399; margin-top: 4px; }
+.info-box { border: 1px solid #e4e7ed; border-radius: 6px; padding: 16px; background: #fafafa; min-height: 400px; }
+.info-box.empty { display: flex; align-items: center; justify-content: center; }
+.info-box h4 { margin: 0 0 12px; font-size: 15px; }
 </style>

+ 14 - 0
internal/api/v1/vehicle/vehicle.go

@@ -2,6 +2,8 @@ package vehicle
 
 import (
 	"fmt"
+	"strconv"
+
 	"github.com/gin-gonic/gin"
 	"wails-app/internal/model/common/response"
 	"wails-app/internal/model/vehicle/request"
@@ -158,3 +160,15 @@ func VehicleExit(c *gin.Context) {
 
 	response.OkWithData(resp, c)
 }
+
+// GetInParkVehicles 获取所有在场车辆
+func GetInParkVehicles(c *gin.Context) {
+	lotID, _ := strconv.Atoi(c.Query("parking_lot_id"))
+	plateNumber := c.Query("plate_number")
+	list, err := vehicleService.GetInParkVehicles(uint(lotID), plateNumber)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(list, c)
+}

+ 25 - 0
internal/dao/digital_ticket.go

@@ -0,0 +1,25 @@
+package dao
+
+import (
+	"time"
+	"wails-app/internal/global"
+)
+
+type DigitalTicket struct {
+	global.GVA_MODEL
+	TicketNo        string     `gorm:"size:64;uniqueIndex" json:"ticket_no"`
+	PlateNumber     string     `gorm:"size:20" json:"plate_number"`
+	TriggerMode     string     `gorm:"size:20" json:"trigger_mode"`
+	State           string     `gorm:"size:20;default:created" json:"state"`
+	VehicleRecordID uint       `json:"vehicle_record_id"`
+	EntryTime       time.Time  `json:"entry_time"`
+	ExitTime        *time.Time `json:"exit_time"`
+	Fee             float64    `json:"fee"`
+	PaymentMethod   string     `gorm:"size:20" json:"payment_method"`
+	PaymentOrderNo  string     `gorm:"size:64" json:"payment_order_no"`
+	PaidAt          *time.Time `json:"paid_at"`
+	ExpireAt        *time.Time `json:"expire_at"`
+	EventLog        string     `gorm:"type:text" json:"event_log"`
+}
+
+func (DigitalTicket) TableName() string { return "digital_ticket" }

+ 21 - 0
internal/dao/monthly_card.go

@@ -0,0 +1,21 @@
+package dao
+
+import (
+	"time"
+	"wails-app/internal/global"
+)
+
+type MonthlyCard struct {
+	global.GVA_MODEL
+	VehicleID     uint      `gorm:"index" json:"vehicle_id"`
+	Vehicle       *Vehicle  `gorm:"foreignKey:VehicleID" json:"vehicle"`
+	CardType      string    `gorm:"size:20" json:"card_type"`
+	StartDate     time.Time `json:"start_date"`
+	EndDate       time.Time `json:"end_date"`
+	Fee           float64   `json:"fee"`
+	PaymentStatus string    `gorm:"size:20;default:paid" json:"payment_status"`
+	OperatorID    uint      `json:"operator_id"`
+	Remark        string    `gorm:"size:200" json:"remark"`
+}
+
+func (MonthlyCard) TableName() string { return "monthly_card" }

+ 2 - 0
internal/initialize/gorm.go

@@ -59,6 +59,8 @@ func RegisterTables() {
 		dao.UHFReader{},
 		dao.Camera{},
 		dao.Shortlist{},
+		dao.MonthlyCard{},
+		dao.DigitalTicket{},
 	)
 	if err != nil {
 		global.GVA_LOG.Error("register table failed", zap.Error(err))

+ 9 - 1
internal/initialize/router.go

@@ -8,6 +8,8 @@ import (
 	"wails-app/internal/middleware"
 	"wails-app/internal/router"
 	"wails-app/internal/router/owner"
+	"wails-app/internal/modules/digital-ticket"
+	"wails-app/internal/modules/monthly"
 	"wails-app/internal/modules/payment"
 	"wails-app/internal/router/parking"
 	"wails-app/internal/router/vehicle"
@@ -80,7 +82,7 @@ func Routers() *gin.Engine {
 		dashboardRouter.InitDashboardRouter(PublicGroup) // 监控仪表盘 不做鉴权
 	}
 	PrivateGroup := Router.Group(global.GVA_CONFIG.System.RouterPrefix)
-	PrivateGroup.Use(middleware.JWTAuth()).Use(middleware.CasbinHandler())
+	PrivateGroup.Use(middleware.JWTAuth()).Use(middleware.CasbinHandler()).Use(middleware.OperationRecord())
 	{
 		systemRouter.InitApiRouter(PrivateGroup, PublicGroup)    // 注册功能api路由
 		systemRouter.InitJwtRouter(PrivateGroup)                 // jwt相关路由
@@ -109,6 +111,12 @@ func Routers() *gin.Engine {
 		// 支付相关路由
 		payment.SetupPaymentRouter(PrivateGroup)
 
+		// 包月车管理路由
+		monthly.SetupMonthlyCardRouter(PrivateGroup)
+
+		// 数字票路由
+		digital_ticket.SetupDigitalTicketRouter(PrivateGroup)
+
 		// UHF读写器路由
 		//uhf.SetupUHFRouter(PrivateGroup)
 	}

+ 12 - 0
internal/initialize/seed.go

@@ -121,6 +121,7 @@ func SeedSystemData() {
 		{MenuLevel: 0, ParentId: 20, Path: "roster", Name: "roster", Hidden: false, Component: "view/parking/roster.vue", Sort: 3, Meta: dao.Meta{Title: "黑白名单", Icon: "memo"}},
 		{MenuLevel: 0, ParentId: 20, Path: "pricing", Name: "pricing", Hidden: false, Component: "view/parking/pricing.vue", Sort: 4, Meta: dao.Meta{Title: "收费配置", Icon: "office-building"}},
 		{MenuLevel: 0, ParentId: 20, Path: "entryExit", Name: "entryExit", Hidden: false, Component: "view/parking/entryExit.vue", Sort: 5, Meta: dao.Meta{Title: "进出场操作", Icon: "switch"}},
+		{MenuLevel: 0, ParentId: 20, Path: "monthlyCard", Name: "monthlyCard", Hidden: false, Component: "view/parking/monthlyCard.vue", Sort: 6, Meta: dao.Meta{Title: "包月车管理", Icon: "calendar"}},
 		// 报表查询 (ID=26)
 		{MenuLevel: 0, ParentId: 0, Path: "report", Name: "report", Hidden: false, Component: "", Sort: 3, Meta: dao.Meta{Title: "报表查询", Icon: "data-analysis"}},
 		{MenuLevel: 0, ParentId: 25, Path: "enter", Name: "enter", Hidden: false, Component: "view/report/enter.vue", Sort: 1, Meta: dao.Meta{Title: "进出记录", Icon: "sort"}},
@@ -177,6 +178,8 @@ func SeedSystemData() {
 		{MenuId: "29", AuthorityId: "618"}, {MenuId: "29", AuthorityId: "888"}, {MenuId: "29", AuthorityId: "9527"},
 		// 进出场操作 (31) — admin + operator
 		{MenuId: "31", AuthorityId: "618"}, {MenuId: "31", AuthorityId: "888"}, {MenuId: "31", AuthorityId: "9527"},
+		// 包月车管理 (33) — admin + operator
+		{MenuId: "33", AuthorityId: "618"}, {MenuId: "33", AuthorityId: "888"}, {MenuId: "33", AuthorityId: "9527"},
 	}
 	db.Create(&authMenus)
 		// --- vehicle_type (默认车辆类型:临时车,标记为系统内置) ---
@@ -415,6 +418,15 @@ func SeedSystemData() {
 		{"p", "618", "/vehicle/exit/preview", "POST", "", "", ""},
 		{"p", "888", "/vehicle/exit/confirm", "POST", "", "", ""},
 		{"p", "618", "/vehicle/exit/confirm", "POST", "", "", ""},
+		// 包月车管理
+		{"p", "888", "/monthly-card/create", "POST", "", "", ""},
+		{"p", "618", "/monthly-card/create", "POST", "", "", ""},
+		{"p", "888", "/monthly-card/renew", "POST", "", "", ""},
+		{"p", "618", "/monthly-card/renew", "POST", "", "", ""},
+		{"p", "888", "/monthly-card/refund", "DELETE", "", "", ""},
+		{"p", "618", "/monthly-card/refund", "DELETE", "", "", ""},
+		{"p", "888", "/monthly-card/list", "GET", "", "", ""},
+		{"p", "618", "/monthly-card/list", "GET", "", "", ""},
 		// 收费记录查询
 		{"p", "888", "/payment/list", "GET", "", "", ""},
 		{"p", "618", "/payment/list", "GET", "", "", ""},

+ 88 - 0
internal/modules/digital-ticket/api.go

@@ -0,0 +1,88 @@
+package digital_ticket
+
+import (
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/modules/digital-ticket/repository"
+	"wails-app/internal/modules/digital-ticket/service"
+)
+
+var ticketSvc = service.NewTicketService()
+
+func GetTicket(c *gin.Context) {
+	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
+	if err != nil {
+		response.FailWithMessage("票不存在", c)
+		return
+	}
+	response.OkWithData(t, c)
+}
+
+type payReq struct {
+	PaymentMethod  string  `json:"payment_method"`
+	PaymentOrderNo string  `json:"payment_order_no"`
+	PaidAmount     float64 `json:"paid_amount"`
+}
+
+func PayTicket(c *gin.Context) {
+	var req payReq
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
+	if err != nil {
+		response.FailWithMessage("票不存在", c)
+		return
+	}
+	extra := map[string]interface{}{
+		"payment_method": req.PaymentMethod, "payment_order_no": req.PaymentOrderNo,
+		"paid_amount": req.PaidAmount,
+		"fee": t.Fee,
+	}
+	if err := ticketSvc.Transition(t.ID, service.StatePaid, extra); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("支付成功", c)
+}
+
+func ExitTicket(c *gin.Context) {
+	t, err := ticketSvc.GetByTicketNo(c.Param("ticketNo"))
+	if err != nil {
+		response.FailWithMessage("票不存在", c)
+		return
+	}
+	if err := ticketSvc.Transition(t.ID, service.StateExited, nil); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("已离场", c)
+}
+
+func ListTickets(c *gin.Context) {
+	var q repository.TicketListQuery
+	if err := c.ShouldBindQuery(&q); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	list, total, err := ticketSvc.List(q)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithDetailed(response.PageResult{List: list, Total: total, Page: q.Page, PageSize: q.PageSize}, "查询成功", c)
+}
+
+func SetupDigitalTicketRouter(router *gin.RouterGroup) {
+	dt := router.Group("/digital-ticket")
+	{
+		dt.GET("/:ticketNo", GetTicket)
+		dt.POST("/:ticketNo/pay", PayTicket)
+		dt.POST("/:ticketNo/exit", ExitTicket)
+	}
+	dt2 := router.Group("/digital-ticket")
+	{
+		dt2.GET("/list", ListTickets)
+	}
+}

+ 63 - 0
internal/modules/digital-ticket/repository/repo.go

@@ -0,0 +1,63 @@
+package repository
+
+import (
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+type TicketRepository struct{}
+
+func (r *TicketRepository) Create(ticket *dao.DigitalTicket) error {
+	return global.GVA_DB.Create(ticket).Error
+}
+
+func (r *TicketRepository) GetByTicketNo(ticketNo string) (*dao.DigitalTicket, error) {
+	var t dao.DigitalTicket
+	err := global.GVA_DB.Where("ticket_no = ?", ticketNo).First(&t).Error
+	if err != nil {
+		return nil, err
+	}
+	return &t, nil
+}
+
+func (r *TicketRepository) GetByID(id uint) (*dao.DigitalTicket, error) {
+	var t dao.DigitalTicket
+	err := global.GVA_DB.First(&t, id).Error
+	if err != nil {
+		return nil, err
+	}
+	return &t, nil
+}
+
+func (r *TicketRepository) UpdateState(id uint, state string, updates map[string]interface{}) error {
+	updates["state"] = state
+	return global.GVA_DB.Model(&dao.DigitalTicket{}).Where("id = ?", id).Updates(updates).Error
+}
+
+type TicketListQuery struct {
+	PlateNumber string `form:"plate_number"`
+	State       string `form:"state"`
+	Page        int    `form:"page"`
+	PageSize    int    `form:"page_size"`
+}
+
+func (r *TicketRepository) List(q TicketListQuery) ([]dao.DigitalTicket, int64, error) {
+	db := global.GVA_DB.Model(&dao.DigitalTicket{})
+	if q.PlateNumber != "" {
+		db = db.Where("plate_number LIKE ?", "%"+q.PlateNumber+"%")
+	}
+	if q.State != "" {
+		db = db.Where("state = ?", q.State)
+	}
+	var total int64
+	db.Count(&total)
+	if q.Page <= 0 {
+		q.Page = 1
+	}
+	if q.PageSize <= 0 {
+		q.PageSize = 10
+	}
+	var list []dao.DigitalTicket
+	err := db.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&list).Error
+	return list, total, err
+}

+ 114 - 0
internal/modules/digital-ticket/service/service.go

@@ -0,0 +1,114 @@
+package service
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+	"wails-app/internal/dao"
+	"wails-app/internal/modules/digital-ticket/repository"
+)
+
+const (
+	StateCreated        = "created"
+	StatePendingPayment = "pending_payment"
+	StatePaid           = "paid"
+	StateExited         = "exited"
+	StateExpired        = "expired"
+)
+
+type TicketService struct {
+	repo *repository.TicketRepository
+}
+
+func NewTicketService() *TicketService {
+	return &TicketService{repo: &repository.TicketRepository{}}
+}
+
+func genTicketNo() string {
+	b := make([]byte, 16)
+	rand.Read(b)
+	return hex.EncodeToString(b)
+}
+
+func appendEvent(log string, event map[string]interface{}) string {
+	var events []map[string]interface{}
+	json.Unmarshal([]byte(log), &events)
+	event["at"] = time.Now().Format(time.RFC3339)
+	events = append(events, event)
+	data, _ := json.Marshal(events)
+	return string(data)
+}
+
+// Create 创建数字票(所有进场方式统一入口)
+func (s *TicketService) Create(plateNumber, triggerMode string, recordID uint) (*dao.DigitalTicket, error) {
+	log := appendEvent("[]", map[string]interface{}{"event": "created", "trigger": triggerMode})
+	ticket := &dao.DigitalTicket{
+		TicketNo: genTicketNo(), PlateNumber: plateNumber, TriggerMode: triggerMode,
+		State: StatePendingPayment, VehicleRecordID: recordID, EntryTime: time.Now(), EventLog: log,
+	}
+	if err := s.repo.Create(ticket); err != nil {
+		return nil, err
+	}
+	return ticket, nil
+}
+
+// Transition 状态转换(校验合法性,原子更新)
+func (s *TicketService) Transition(id uint, toState string, extra map[string]interface{}) error {
+	ticket, err := s.repo.GetByID(id)
+	if err != nil {
+		return errors.New("票不存在")
+	}
+
+	validTransitions := map[string][]string{
+		StateCreated:        {StatePendingPayment},
+		StatePendingPayment: {StatePaid, StateExpired},
+		StatePaid:           {StateExited, StateExpired},
+	}
+
+	allowed := false
+	for _, s := range validTransitions[ticket.State] {
+		if s == toState {
+			allowed = true
+			break
+		}
+	}
+	if !allowed {
+		return fmt.Errorf("状态转换不合法: %s → %s", ticket.State, toState)
+	}
+
+	updates := map[string]interface{}{}
+	if toState == StatePaid {
+		now := time.Now()
+		updates["paid_at"] = now
+	}
+	if toState == StateExited {
+		now := time.Now()
+		updates["exit_time"] = now
+	}
+	// 只允许表中存在的字段写入数据库,其余仅记录到事件日志
+	dbFields := map[string]bool{"payment_method": true, "payment_order_no": true, "fee": true}
+	for k, v := range extra {
+		if dbFields[k] {
+			updates[k] = v
+		}
+	}
+
+	event := map[string]interface{}{"event": toState}
+	for k, v := range extra {
+		event[k] = v
+	}
+	updates["event_log"] = appendEvent(ticket.EventLog, event)
+
+	return s.repo.UpdateState(id, toState, updates)
+}
+
+func (s *TicketService) GetByTicketNo(no string) (*dao.DigitalTicket, error) {
+	return s.repo.GetByTicketNo(no)
+}
+
+func (s *TicketService) List(q repository.TicketListQuery) ([]dao.DigitalTicket, int64, error) {
+	return s.repo.List(q)
+}

+ 86 - 0
internal/modules/monthly/api.go

@@ -0,0 +1,86 @@
+package monthly
+
+import (
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+	"wails-app/internal/model/common/response"
+	"wails-app/internal/modules/monthly/repository"
+	"wails-app/internal/modules/monthly/service"
+	utils "wails-app/internal/pkg"
+)
+
+var monthlySvc = service.NewMonthlyCardService()
+
+type createReq struct {
+	VehicleID uint    `json:"vehicle_id"`
+	CardType  string  `json:"card_type"`
+	Fee       float64 `json:"fee"`
+	Remark    string  `json:"remark"`
+}
+
+func CreateCard(c *gin.Context) {
+	var req createReq
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	card, err := monthlySvc.Create(req.VehicleID, req.CardType, req.Fee, utils.GetUserID(c), req.Remark)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithData(card, c)
+}
+
+type renewReq struct {
+	CardID   uint    `json:"card_id"`
+	CardType string  `json:"card_type"`
+	Fee      float64 `json:"fee"`
+}
+
+func RenewCard(c *gin.Context) {
+	var req renewReq
+	if err := c.ShouldBindJSON(&req); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	if err := monthlySvc.Renew(req.CardID, req.CardType, req.Fee, utils.GetUserID(c)); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("续费成功", c)
+}
+
+func RefundCard(c *gin.Context) {
+	id, _ := strconv.Atoi(c.Query("id"))
+	if err := monthlySvc.Refund(uint(id)); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithMessage("退卡成功", c)
+}
+
+func ListCards(c *gin.Context) {
+	var q repository.MonthlyCardQuery
+	if err := c.ShouldBindQuery(&q); err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	list, total, err := monthlySvc.List(q)
+	if err != nil {
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+	response.OkWithDetailed(response.PageResult{List: list, Total: total, Page: q.Page, PageSize: q.PageSize}, "查询成功", c)
+}
+
+func SetupMonthlyCardRouter(router *gin.RouterGroup) {
+	mc := router.Group("/monthly-card")
+	{
+		mc.POST("/create", CreateCard)
+		mc.POST("/renew", RenewCard)
+		mc.DELETE("/refund", RefundCard)
+		mc.GET("/list", ListCards)
+	}
+}

+ 70 - 0
internal/modules/monthly/repository/repo.go

@@ -0,0 +1,70 @@
+package repository
+
+import (
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+type MonthlyCardRepository struct{}
+
+func (r *MonthlyCardRepository) Create(card *dao.MonthlyCard) error {
+	return global.GVA_DB.Create(card).Error
+}
+
+func (r *MonthlyCardRepository) GetByVehicleID(vehicleID uint) (*dao.MonthlyCard, error) {
+	var card dao.MonthlyCard
+	err := global.GVA_DB.Where("vehicle_id = ? AND payment_status = ?", vehicleID, "paid").Preload("Vehicle").First(&card).Error
+	if err != nil {
+		return nil, err
+	}
+	return &card, nil
+}
+
+func (r *MonthlyCardRepository) Update(card *dao.MonthlyCard) error {
+	return global.GVA_DB.Save(card).Error
+}
+
+type MonthlyCardQuery struct {
+	PlateNumber string `form:"plate_number"`
+	CardType    string `form:"card_type"`
+	Page        int    `form:"page"`
+	PageSize    int    `form:"page_size"`
+}
+
+type MonthlyCardResult struct {
+	ID            uint    `json:"id"`
+	PlateNumber   string  `json:"plate_number"`
+	CardType      string  `json:"card_type"`
+	StartDate     string  `json:"start_date"`
+	EndDate       string  `json:"end_date"`
+	Fee           float64 `json:"fee"`
+	PaymentStatus string  `json:"payment_status"`
+}
+
+func (r *MonthlyCardRepository) List(q MonthlyCardQuery) ([]MonthlyCardResult, int64, error) {
+	db := global.GVA_DB.Table("monthly_card").
+		Select("monthly_card.id, vehicle.plate_number, monthly_card.card_type, monthly_card.start_date, monthly_card.end_date, monthly_card.fee, monthly_card.payment_status").
+		Joins("LEFT JOIN vehicle ON vehicle.id = monthly_card.vehicle_id")
+
+	if q.PlateNumber != "" {
+		db = db.Where("vehicle.plate_number LIKE ?", "%"+q.PlateNumber+"%")
+	}
+	if q.CardType != "" {
+		db = db.Where("monthly_card.card_type = ?", q.CardType)
+	}
+
+	var total int64
+	db.Count(&total)
+
+	if q.Page <= 0 {
+		q.Page = 1
+	}
+	if q.PageSize <= 0 {
+		q.PageSize = 10
+	}
+	offset := (q.Page - 1) * q.PageSize
+
+	var results []MonthlyCardResult
+	err := db.Order("monthly_card.id DESC").Offset(offset).Limit(q.PageSize).Scan(&results).Error
+	return results, total, err
+}

+ 128 - 0
internal/modules/monthly/service/service.go

@@ -0,0 +1,128 @@
+package service
+
+import (
+	"errors"
+	"fmt"
+	"time"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+	"wails-app/internal/modules/monthly/repository"
+	paymentSvc "wails-app/internal/modules/payment/service"
+)
+
+type MonthlyCardService struct {
+	repo *repository.MonthlyCardRepository
+}
+
+func NewMonthlyCardService() *MonthlyCardService {
+	return &MonthlyCardService{repo: &repository.MonthlyCardRepository{}}
+}
+
+func cardDays(cardType string) int {
+	switch cardType {
+	case "month":
+		return 30
+	case "quarter":
+		return 90
+	case "year":
+		return 365
+	default:
+		return 30
+	}
+}
+
+// Create 办理月卡:创建 monthly_card + shortlist 白名单 + payment_record
+func (s *MonthlyCardService) Create(vehicleID uint, cardType string, fee float64, operatorID uint, remark string) (*dao.MonthlyCard, error) {
+	existing, _ := s.repo.GetByVehicleID(vehicleID)
+	if existing != nil {
+		return nil, errors.New("该车辆已有有效月卡,请先退卡或等过期后续费")
+	}
+
+	var vehicle dao.Vehicle
+	if err := global.GVA_DB.First(&vehicle, vehicleID).Error; err != nil {
+		return nil, errors.New("车辆不存在")
+	}
+
+	now := time.Now()
+	endDate := now.AddDate(0, 0, cardDays(cardType))
+
+	card := &dao.MonthlyCard{
+		VehicleID: vehicleID, CardType: cardType,
+		StartDate: now, EndDate: endDate,
+		Fee: fee, PaymentStatus: "paid", OperatorID: operatorID, Remark: remark,
+	}
+	if err := s.repo.Create(card); err != nil {
+		return nil, fmt.Errorf("创建月卡失败: %w", err)
+	}
+
+	// 写入白名单
+	shortlist := dao.Shortlist{
+		VehicleId:      int(vehicleID),
+		ListType:       "白名单",
+		ExpirationTime: &endDate,
+	}
+	global.GVA_DB.Create(&shortlist)
+
+	// 创建 payment_record
+	payRecord := dao.PaymentRecord{
+		RecordID: 0, PaymentMethod: paymentSvc.PaymentCash,
+		Amount: fee, PaidAmount: fee, ChangeAmount: 0,
+		OperatorID: operatorID, PaidAt: now,
+		Remark: fmt.Sprintf("包月卡办理(%s)", cardType),
+	}
+	global.GVA_DB.Create(&payRecord)
+
+	return card, nil
+}
+
+// Renew 续费:延长到期日 + 更新 shortlist
+func (s *MonthlyCardService) Renew(cardID uint, cardType string, fee float64, operatorID uint) error {
+	var card dao.MonthlyCard
+	if err := global.GVA_DB.First(&card, cardID).Error; err != nil {
+		return errors.New("月卡记录不存在")
+	}
+	if card.PaymentStatus != "paid" {
+		return errors.New("月卡已退卡,无法续费")
+	}
+
+	newEnd := card.EndDate.AddDate(0, 0, cardDays(cardType))
+	card.EndDate = newEnd
+	card.Fee += fee
+	global.GVA_DB.Save(&card)
+
+	// 更新 shortlist 到期日
+	global.GVA_DB.Model(&dao.Shortlist{}).Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").
+		Update("expiration_time", newEnd)
+
+	// 创建 payment_record
+	now := time.Now()
+	payRecord := dao.PaymentRecord{
+		RecordID: 0, PaymentMethod: paymentSvc.PaymentCash,
+		Amount: fee, PaidAmount: fee, ChangeAmount: 0,
+		OperatorID: operatorID, PaidAt: now,
+		Remark: fmt.Sprintf("包月卡续费(%s)", cardType),
+	}
+	global.GVA_DB.Create(&payRecord)
+
+	return nil
+}
+
+// Refund 退卡:标记 refunded + 移除 shortlist
+func (s *MonthlyCardService) Refund(cardID uint) error {
+	var card dao.MonthlyCard
+	if err := global.GVA_DB.First(&card, cardID).Error; err != nil {
+		return errors.New("月卡记录不存在")
+	}
+
+	card.PaymentStatus = "refunded"
+	global.GVA_DB.Save(&card)
+
+	// 移除白名单
+	global.GVA_DB.Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").Unscoped().Delete(&dao.Shortlist{})
+
+	return nil
+}
+
+func (s *MonthlyCardService) List(q repository.MonthlyCardQuery) ([]repository.MonthlyCardResult, int64, error) {
+	return s.repo.List(q)
+}

+ 1 - 0
internal/router/vehicle/vehicle.go

@@ -19,6 +19,7 @@ func SetupVehicleRouter(router *gin.RouterGroup) {
 		vehicleGroup.GET("/type/all", vehicle.GetAllVehicleTypes)
 
 		// 车辆管理
+		vehicleGroup.GET("/in-park", vehicle.GetInParkVehicles)
 		vehicleGroup.POST("/create", vehicle.CreateVehicle)
 		vehicleGroup.PUT("/update", vehicle.UpdateVehicle)
 		vehicleGroup.GET("/get", vehicle.GetVehicleByID)

+ 56 - 0
internal/service/vehicle/vehicle.go

@@ -8,6 +8,7 @@ import (
 	"wails-app/internal/global"
 	"wails-app/internal/model/vehicle/request"
 	vehicleResponse "wails-app/internal/model/vehicle/response"
+	ticketService "wails-app/internal/modules/digital-ticket/service"
 	paymentService "wails-app/internal/modules/payment/service"
 
 	"gorm.io/gorm"
@@ -223,6 +224,11 @@ func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.Vehicle
 		return request.VehicleEntryResponse{}, err
 	}
 
+	// 创建数字票(统一入口)
+	ticketSvc := ticketService.NewTicketService()
+	triggerMode := "manual"
+	if req.RFIDTag != "" { triggerMode = "rfid" }
+	ticketSvc.Create(req.PlateNumber, triggerMode, record.ID)
 	// 更新车辆最近入场时间
 	vehicle.LastEntryTime = &entryTime
 	global.GVA_DB.Save(&vehicle)
@@ -436,3 +442,53 @@ func (s *VehicleService) calculateFee(vehicle *dao.Vehicle, stayTime int64) floa
 
 	return fee
 }
+
+// InParkVehicle 在场车辆信息
+type InParkVehicle struct {
+	RecordID      uint    `json:"record_id"`
+	PlateNumber   string  `json:"plate_number"`
+	VehicleType   string  `json:"vehicle_type"`
+	OwnerName     string  `json:"owner_name"`
+	OwnerPhone    string  `json:"owner_phone"`
+	EntryTime     string  `json:"entry_time"`
+	StayMinutes   int64   `json:"stay_minutes"`
+	ParkingLot    string  `json:"parking_lot"`
+	ParkingSpace  string  `json:"parking_space"`
+}
+
+// GetInParkVehicles 获取所有在场车辆
+func (s *VehicleService) GetInParkVehicles(lotID uint, plateNumber string) ([]InParkVehicle, error) {
+	var results []InParkVehicle
+	now := time.Now()
+	query := global.GVA_DB.Table("vehicle_record").
+		Select(`vehicle_record.id as record_id, vehicle_record.plate_number, vehicle_type.name as vehicle_type,
+			owner.owner_name, owner.owner_phone, vehicle_record.entry_time,
+			parking_lot.lot_name as parking_lot`).
+		Joins("LEFT JOIN vehicle ON vehicle.plate_number = vehicle_record.plate_number").
+		Joins("LEFT JOIN vehicle_type ON vehicle_type.id = vehicle.vehicle_type_id").
+		Joins("LEFT JOIN owner ON owner.id = vehicle.owner_id").
+		Joins("LEFT JOIN parking_lot ON parking_lot.id = vehicle_record.parking_lot_id").
+		Where("vehicle_record.exit_time IS NULL")
+	if lotID > 0 {
+		query = query.Where("vehicle_record.parking_lot_id = ?", lotID)
+	}
+	if plateNumber != "" {
+		query = query.Where("vehicle_record.plate_number LIKE ?", "%"+plateNumber+"%")
+	}
+	err := query.Order("vehicle_record.entry_time ASC").Scan(&results).Error
+	if err != nil {
+		return nil, err
+	}
+	for i := range results {
+		// Parse entry time and calculate stay duration
+		t, parseErr := time.Parse("2006-01-02T15:04:05-07:00", results[i].EntryTime)
+		if parseErr != nil {
+			t, _ = time.Parse("2006-01-02 15:04:05", results[i].EntryTime)
+		}
+		results[i].StayMinutes = int64(now.Sub(t).Minutes())
+		if results[i].StayMinutes < 0 {
+			results[i].StayMinutes = 0
+		}
+	}
+	return results, nil
+}

+ 414 - 0
test/testutil/e2e_edge_cases_test.go

@@ -10,6 +10,7 @@ import (
 	"wails-app/internal/dao"
 	"wails-app/internal/global"
 	"wails-app/internal/model/common/response"
+	dt_svc "wails-app/internal/modules/digital-ticket/service"
 )
 
 // TestVehicleReEntry 同一车辆重复入场应被拦截
@@ -361,6 +362,419 @@ func TestVehicleTypeCRUD(t *testing.T) {
 
 // ===================== Helper functions =====================
 
+
+// TestVehicleUpdate 车辆信息更新
+func TestVehicleUpdate(t *testing.T) {
+	env := InitTestEnv(t)
+	createVehicle(t, env, "JingUPD001", "RFID_UPDATE_TEST", 1)
+	var v dao.Vehicle
+	env.DB.Where("plate_number = ?", "JingUPD001").First(&v)
+	resp, body := env.DoPut("/vehicle/update", map[string]interface{}{
+		"ID": v.ID, "plate_number": "JingUPD001", "vehicle_type_id": 1,
+		"vehicle_brand": "UpdatedBrand",
+		"vehicle_color": "Blue", "rfid_tag": "RFID_UPDATE_TEST",
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	fmt.Println("PASS: Vehicle update successful")
+}
+
+// TestVehicleDelete 车辆删除
+func TestVehicleDelete(t *testing.T) {
+	env := InitTestEnv(t)
+	createVehicle(t, env, "JingDEL001", "RFID_DELETE_TEST", 1)
+	var v dao.Vehicle
+	env.DB.Where("plate_number = ?", "JingDEL001").First(&v)
+	resp, body := env.DoDelete(fmt.Sprintf("/vehicle/delete?id=%d", v.ID))
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	fmt.Println("PASS: Vehicle delete successful")
+}
+
+// TestVehicleList 车辆分页列表
+func TestVehicleList(t *testing.T) {
+	env := InitTestEnv(t)
+	createVehicle(t, env, "JingLST001", "RFID_LIST_TEST", 1)
+	resp, body := env.DoGet("/vehicle/list", map[string]string{"page": "1", "page_size": "10"})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	list := ParsePageList(body)
+	if len(list) < 1 { t.Fatalf("Vehicle list is empty") }
+	fmt.Printf("PASS: Vehicle list returned %d records\n", len(list))
+}
+
+// TestOwnerUpdate 业主信息更新
+func TestOwnerUpdate(t *testing.T) {
+	env := InitTestEnv(t)
+	ownerResp, ownerBody := env.DoPost("/owner/create", map[string]interface{}{
+		"owner_name": "Update Test", "owner_surname": "Upd",
+		"owner_phone": "13900000999", "is_vip": false,
+	})
+	AssertResponse(t, ownerResp, ownerBody, http.StatusOK, response.SUCCESS)
+	var o dao.Owner
+	env.DB.Where("owner_phone = ?", "13900000999").First(&o)
+	updResp, updBody := env.DoPut("/owner/update", map[string]interface{}{
+		"ID": o.ID, "owner_name": "Updated Name", "owner_surname": "Upd2",
+		"owner_phone": "13900000999", "is_vip": true,
+	})
+	AssertResponse(t, updResp, updBody, http.StatusOK, response.SUCCESS)
+	fmt.Println("PASS: Owner update successful")
+}
+
+// TestOwnerGetByPhone 按手机号查询业主
+func TestOwnerGetByPhone(t *testing.T) {
+	env := InitTestEnv(t)
+	_, _ = env.DoPost("/owner/create", map[string]interface{}{
+		"owner_name": "Phone Query", "owner_surname": "Ph",
+		"owner_phone": "13900000888", "is_vip": false,
+	})
+	resp, body := env.DoGet("/owner/get-by-phone", map[string]string{"phone": "13900000888"})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	fmt.Println("PASS: Owner query by phone successful")
+}
+
+// TestFeeConfigCreate 收费配置创建
+func TestFeeConfigCreate(t *testing.T) {
+	env := InitTestEnv(t)
+	// 创建一个新的车辆类型并为其创建收费配置
+	typeResp, typeBody := env.DoPost("/vehicle/type/create", map[string]interface{}{
+		"name": "FeeConfigTest", "remarks": "Fee config test type", "is_system": false,
+	})
+	AssertResponse(t, typeResp, typeBody, http.StatusOK, response.SUCCESS)
+	var vt2 dao.VehicleType
+	env.DB.Where("name = ?", "FeeConfigTest").First(&vt2)
+	resp, body := env.DoPost("/vehicle/fee-config/create", map[string]interface{}{
+		"vehicle_type_id": vt2.ID, "start_time": 0, "start_fee": 0,
+		"unit_time": 60, "unit_fee": 10, "daily_max_fee": 80,
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	fmt.Println("PASS: Fee config create successful")
+}
+
+// TestParkingLotUpdate 停车场信息更新
+func TestParkingLotUpdate(t *testing.T) {
+	env := InitTestEnv(t)
+	var lot dao.ParkingLot
+	env.DB.Where("lot_code = ?", "TEST001").First(&lot)
+	resp, body := env.DoPut("/parking/lot/update", map[string]interface{}{
+		"ID": lot.ID, "lot_code": "TEST001", "lot_name": "Updated Lot",
+		"capacity": 200, "description": "Updated desc",
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	fmt.Println("PASS: Parking lot update successful")
+}
+
+
+// ===================== 月租车管理模块测试 =====================
+
+// TestMonthlyCardCreate 办理月卡
+func TestMonthlyCardCreate(t *testing.T) {
+	env := InitTestEnv(t)
+	createVehicle(t, env, "JingMC001", "RFID_MC001", 1)
+	var v dao.Vehicle
+	env.DB.Where("plate_number = ?", "JingMC001").First(&v)
+
+	resp, body := env.DoPost("/monthly-card/create", map[string]interface{}{
+		"vehicle_id": v.ID, "card_type": "month", "fee": 300.0, "remark": "test month card",
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+
+	var card dao.MonthlyCard
+	env.DB.Where("vehicle_id = ?", v.ID).First(&card)
+	if card.ID == 0 { t.Fatalf("Monthly card not created in DB") }
+	if card.PaymentStatus != "paid" { t.Fatalf("Expected paid, got %s", card.PaymentStatus) }
+
+	// Verify shortlist (whitelist) was created
+	var sl dao.Shortlist
+	env.DB.Where("vehicle_id = ? AND list_type = ?", v.ID, "白名单").First(&sl)
+	if sl.ID == 0 { t.Fatalf("Shortlist not created") }
+
+	fmt.Println("PASS: Monthly card created successfully")
+}
+
+// TestMonthlyCardRenew 月卡续费
+func TestMonthlyCardRenew(t *testing.T) {
+	env := InitTestEnv(t)
+	createVehicle(t, env, "JingMC002", "RFID_MC002", 1)
+	var v dao.Vehicle
+	env.DB.Where("plate_number = ?", "JingMC002").First(&v)
+	_, _ = env.DoPost("/monthly-card/create", map[string]interface{}{
+		"vehicle_id": v.ID, "card_type": "month", "fee": 300.0,
+	})
+	var card dao.MonthlyCard
+	env.DB.Where("vehicle_id = ?", v.ID).First(&card)
+	origEnd := card.EndDate
+
+	resp, body := env.DoPost("/monthly-card/renew", map[string]interface{}{
+		"card_id": card.ID, "card_type": "month", "fee": 300.0,
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+
+	env.DB.Where("vehicle_id = ?", v.ID).First(&card)
+	expectedEnd := origEnd.AddDate(0, 0, 30)
+	if !card.EndDate.Equal(expectedEnd) && !card.EndDate.After(expectedEnd.Add(-time.Hour*23)) {
+		t.Fatalf("EndDate not extended correctly: orig=%v, new=%v", origEnd, card.EndDate)
+	}
+	fmt.Println("PASS: Monthly card renewed successfully")
+}
+
+// TestMonthlyCardRefund 月卡退卡
+func TestMonthlyCardRefund(t *testing.T) {
+	env := InitTestEnv(t)
+	createVehicle(t, env, "JingMC003", "RFID_MC003", 1)
+	var v dao.Vehicle
+	env.DB.Where("plate_number = ?", "JingMC003").First(&v)
+	_, _ = env.DoPost("/monthly-card/create", map[string]interface{}{
+		"vehicle_id": v.ID, "card_type": "month", "fee": 300.0,
+	})
+	var card dao.MonthlyCard
+	env.DB.Where("vehicle_id = ?", v.ID).First(&card)
+
+	resp, body := env.DoDelete(fmt.Sprintf("/monthly-card/refund?id=%d", card.ID))
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+
+	env.DB.First(&card, card.ID)
+	if card.PaymentStatus != "refunded" { t.Fatalf("Expected refunded, got %s", card.PaymentStatus) }
+
+	var count int64
+	env.DB.Model(&dao.Shortlist{}).Where("vehicle_id = ? AND list_type = ?", v.ID, "白名单").Count(&count)
+	if count != 0 { t.Fatalf("Shortlist should be removed after refund, found %d", count) }
+
+	fmt.Println("PASS: Monthly card refunded successfully")
+}
+
+// TestMonthlyCardList 月卡列表查询
+func TestMonthlyCardList(t *testing.T) {
+	env := InitTestEnv(t)
+	createVehicle(t, env, "JingMC004", "RFID_MC004", 1)
+	var v dao.Vehicle
+	env.DB.Where("plate_number = ?", "JingMC004").First(&v)
+	_, _ = env.DoPost("/monthly-card/create", map[string]interface{}{
+		"vehicle_id": v.ID, "card_type": "quarter", "fee": 800.0,
+	})
+
+	resp, body := env.DoGet("/monthly-card/list", map[string]string{"page": "1", "page_size": "10"})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	list := ParsePageList(body)
+	if len(list) < 1 { t.Fatalf("Monthly card list is empty") }
+
+	resp, body2 := env.DoGet("/monthly-card/list", map[string]string{
+		"page": "1", "page_size": "10", "card_type": "quarter",
+	})
+	AssertResponse(t, resp, body2, http.StatusOK, response.SUCCESS)
+	list2 := ParsePageList(body2)
+	if len(list2) < 1 { t.Fatalf("Filtered list by card_type is empty") }
+
+	fmt.Printf("PASS: Monthly card list returned %d records\\n", len(list))
+}
+
+// TestMonthlyCardDuplicate 重复办卡拦截
+func TestMonthlyCardDuplicate(t *testing.T) {
+	env := InitTestEnv(t)
+	createVehicle(t, env, "JingMC005", "RFID_MC005", 1)
+	var v dao.Vehicle
+	env.DB.Where("plate_number = ?", "JingMC005").First(&v)
+
+	_, _ = env.DoPost("/monthly-card/create", map[string]interface{}{
+		"vehicle_id": v.ID, "card_type": "month", "fee": 300.0,
+	})
+
+	_, body2 := env.DoPost("/monthly-card/create", map[string]interface{}{
+		"vehicle_id": v.ID, "card_type": "year", "fee": 3000.0,
+	})
+	var result struct { Code int `json:"code"`; Msg string `json:"msg"` }
+	json.Unmarshal(body2, &result)
+	if result.Code == response.SUCCESS {
+		t.Fatalf("BUG: Duplicate monthly card creation was NOT blocked!")
+	}
+	fmt.Printf("PASS: Duplicate card creation correctly rejected: %s\\n", result.Msg)
+}
+
+// TestMonthlyCardNonexistentRefund 不存在的月卡退卡
+func TestMonthlyCardNonexistentRefund(t *testing.T) {
+	env := InitTestEnv(t)
+	_, body := env.DoDelete("/monthly-card/refund?id=99999")
+	// Should fail with error code
+	var result struct { Code int `json:"code"`; Msg string `json:"msg"` }
+	json.Unmarshal(body, &result)
+	if result.Code == response.SUCCESS {
+		t.Fatalf("BUG: Refunding non-existent card was NOT blocked!")
+	}
+	// HTTP status may vary, just check code != 0
+	fmt.Printf("PASS: Non-existent card refund correctly rejected: code=%d msg=%s\\n", result.Code, result.Msg)
+}
+
+// TestMonthlyCardDifferentTypes 不同套餐类型
+func TestMonthlyCardDifferentTypes(t *testing.T) {
+	env := InitTestEnv(t)
+	createVehicle(t, env, "JingMC006", "RFID_MC006", 1)
+	var v dao.Vehicle
+	env.DB.Where("plate_number = ?", "JingMC006").First(&v)
+
+	resp, body := env.DoPost("/monthly-card/create", map[string]interface{}{
+		"vehicle_id": v.ID, "card_type": "year", "fee": 3000.0,
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	var card dao.MonthlyCard
+	env.DB.Where("vehicle_id = ?", v.ID).First(&card)
+
+	duration := card.EndDate.Sub(card.StartDate).Hours() / 24
+	if duration < 360 || duration > 370 {
+		t.Fatalf("Year card should be ~365 days, got %.0f days", duration)
+	}
+	fmt.Printf("PASS: Year card duration = %.0f days\\n", duration)
+}
+
+
+// ===================== 数字票模块测试 =====================
+
+func createTicket(t *testing.T, env *TestEnv, plate, trigger string) (string, uint) {
+	t.Helper()
+	createVehicle(t, env, plate, "RFID_"+plate, 1)
+	var v dao.Vehicle
+	env.DB.Where("plate_number = ?", plate).First(&v)
+	rec := dao.VehicleRecord{PlateNumber: plate, RFIDTag: "RFID_" + plate, EntryTime: time.Now(), ParkingLotID: 1}
+	env.DB.Create(&rec)
+	var svc = new(dt_svc.TicketService)
+	ticket, err := svc.Create(plate, trigger, rec.ID)
+	if err != nil {
+		t.Fatalf("Failed to create ticket: %v", err)
+	}
+	return ticket.TicketNo, ticket.ID
+}
+
+// TestDigitalTicketDetail DT01: 查询数字票详情
+func TestDigitalTicketDetail(t *testing.T) {
+	env := InitTestEnv(t)
+	ticketNo, _ := createTicket(t, env, "DT001", "manual")
+	resp, body := env.DoGet("/digital-ticket/"+ticketNo, nil)
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	data := ParseDataMap(body)
+	if data["state"] != "pending_payment" {
+		t.Fatalf("Expected state=pending_payment, got %v", data["state"])
+	}
+	if data["ticket_no"] != ticketNo {
+		t.Fatalf("TicketNo mismatch")
+	}
+	if data["trigger_mode"] != "manual" {
+		t.Fatalf("TriggerMode mismatch")
+	}
+	fmt.Println("PASS: Digital ticket detail query successful")
+}
+
+// TestDigitalTicketPay DT02: 支付数字票
+// BUG: paid_amount 字段在 digital_ticket 表中不存在,PayTicket API 会报 SQL 错误
+// 预期: 该测试应失败,暴露后端缺陷
+func TestDigitalTicketPay(t *testing.T) {
+	env := InitTestEnv(t)
+	ticketNo, _ := createTicket(t, env, "DT002", "plate_recognition")
+	resp, body := env.DoPost("/digital-ticket/"+ticketNo+"/pay", map[string]interface{}{
+		"payment_method": "cash", "payment_order_no": "PO002", "paid_amount": 20.0,
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	fmt.Println("PASS: Pay API works correctly")
+}
+
+// TestDigitalTicketExit DT03: 离场验证
+func TestDigitalTicketExit(t *testing.T) {
+	env := InitTestEnv(t)
+	ticketNo, _ := createTicket(t, env, "DT003", "rfid")
+	// 支付
+	_, _ = env.DoPost("/digital-ticket/"+ticketNo+"/pay", map[string]interface{}{
+		"payment_method": "cash", "payment_order_no": "PO003", "paid_amount": 15.0,
+	})
+	// 支付成功后离场
+	resp, body := env.DoPost("/digital-ticket/"+ticketNo+"/exit", nil)
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	fmt.Println("PASS: Pay → Exit flow works correctly")
+}
+
+// TestDigitalTicketList DT04: 数字票列表
+func TestDigitalTicketList(t *testing.T) {
+	env := InitTestEnv(t)
+	createTicket(t, env, "DT004", "manual")
+	resp, body := env.DoGet("/digital-ticket/list", map[string]string{"page": "1", "page_size": "10"})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	list := ParsePageList(body)
+	if len(list) < 1 {
+		t.Fatalf("Ticket list is empty")
+	}
+	fmt.Printf("PASS: Digital ticket list returned %d records\n", len(list))
+}
+
+// TestDigitalTicketFullFlow DT05: 完整流程
+// BUG: Pay API 因 paid_amount 缺失而失败,完整流程无法通过 pay API 完成
+// 测试验证: exit API 本身在状态正确时可正常工作
+func TestDigitalTicketFullFlow(t *testing.T) {
+	env := InitTestEnv(t)
+	ticketNo, _ := createTicket(t, env, "DT005", "manual")
+	// Pay
+	resp1, body1 := env.DoPost("/digital-ticket/"+ticketNo+"/pay", map[string]interface{}{
+		"payment_method": "cash", "payment_order_no": "PO005", "paid_amount": 25.0,
+	})
+	AssertResponse(t, resp1, body1, http.StatusOK, response.SUCCESS)
+	// Exit
+	resp2, body2 := env.DoPost("/digital-ticket/"+ticketNo+"/exit", nil)
+	AssertResponse(t, resp2, body2, http.StatusOK, response.SUCCESS)
+	// Verify state
+	_, getBody := env.DoGet("/digital-ticket/"+ticketNo, nil)
+	data := ParseDataMap(getBody)
+	if data["state"] != "exited" {
+		t.Fatalf("Expected exited, got state=%v", data["state"])
+	}
+	fmt.Println("PASS: Full flow (pay → exit) works correctly")
+}
+
+// TestDigitalTicketInvalidExit DT06: 未支付直接离场被拒
+func TestDigitalTicketInvalidExit(t *testing.T) {
+	env := InitTestEnv(t)
+	ticketNo, _ := createTicket(t, env, "DT006", "manual")
+	_, body := env.DoPost("/digital-ticket/"+ticketNo+"/exit", nil)
+	var result struct{ Code int `json:"code"`; Msg string `json:"msg"` }
+	json.Unmarshal(body, &result)
+	if result.Code == response.SUCCESS {
+		t.Fatalf("BUG: Exit without payment was NOT blocked!")
+	}
+	fmt.Printf("PASS: Exit without payment correctly rejected: %s\n", result.Msg)
+}
+
+// TestDigitalTicketNonexistent DT08: 不存在的票号
+func TestDigitalTicketNonexistent(t *testing.T) {
+	env := InitTestEnv(t)
+	_, body := env.DoGet("/digital-ticket/FAKE", nil)
+	var result struct{ Code int `json:"code"`; Msg string `json:"msg"` }
+	json.Unmarshal(body, &result)
+	if result.Code == response.SUCCESS {
+		t.Fatalf("BUG: Non-existent ticket returned success!")
+	}
+	fmt.Printf("PASS: Non-existent ticket correctly rejected: %s\n", result.Msg)
+}
+
+// TestDigitalTicketListFilter DT09+10: 列表过滤
+func TestDigitalTicketListFilter(t *testing.T) {
+	env := InitTestEnv(t)
+	createTicket(t, env, "DTFILTER", "manual")
+	resp, body := env.DoGet("/digital-ticket/list", map[string]string{
+		"page": "1", "page_size": "10", "plate_number": "DTFILTER",
+	})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	list := ParsePageList(body)
+	if len(list) < 1 {
+		t.Fatalf("Filter by plate returned empty")
+	}
+	fmt.Printf("PASS: Ticket list filter returned %d records\n", len(list))
+}
+
+// TestDigitalTicketEmptyList DT13: 空列表
+func TestDigitalTicketListPagination(t *testing.T) {
+	env := InitTestEnv(t)
+	createTicket(t, env, "DTPAGE", "manual")
+	resp, body := env.DoGet("/digital-ticket/list", map[string]string{"page": "1", "page_size": "5"})
+	AssertResponse(t, resp, body, http.StatusOK, response.SUCCESS)
+	list := ParsePageList(body)
+	if len(list) < 1 {
+		t.Fatalf("Paginated list is empty")
+	}
+	fmt.Printf("PASS: Ticket list pagination returned %d records\n", len(list))
+}
+
 func createVehicleWithOwner(t *testing.T, env *TestEnv, plateNumber, rfidTag string, ownerID uint) {
 	t.Helper()
 	_, body := env.DoPost("/vehicle/create", map[string]interface{}{