Преглед на файлове

feat(participants): 添加票券打印功能

- 新增打印票券信息接口和实现
- 添加票券信息弹窗展示和打印功能
- 集成二维码生成功能用于票券验证
- 实现票券打印页面布局和样式设计
- 添加打印辅助工具函数和类型定义
- 在参赛者列表中添加打印票券按钮和功能
fugui преди 1 седмица
родител
ревизия
47973b8315

+ 2 - 0
package.json

@@ -46,6 +46,7 @@
     "leaflet-geosearch": "^4.4.0",
     "nprogress": "0.2.0",
     "pinia": "3.0.2",
+    "qrcode": "^1.5.4",
     "screenfull": "6.0.2",
     "tinymce": "^7.9.1",
     "vue": "^3.5.13",
@@ -64,6 +65,7 @@
     "@types/js-cookie": "3.0.6",
     "@types/node": "^22.13.4",
     "@types/nprogress": "0.2.3",
+    "@types/qrcode": "^1.5.6",
     "@unocss/preset-attributify": "66.0.0",
     "@unocss/preset-icons": "66.0.0",
     "@unocss/preset-uno": "66.0.0",

+ 11 - 0
src/api/system/physical/participants/index.ts

@@ -72,3 +72,14 @@ export const promoteTournament = (data: ParticipantsForm) => {
     data: data
   });
 };
+/**
+ * 打印票券信息
+ * @param data
+ */
+export const printTicketInfo = (data: ParticipantsForm) => {
+  return request({
+    url: '/physical/participants/printTicketInfo',
+    method: 'post',
+    data: data
+  });
+};

+ 288 - 0
src/api/system/physical/participants/printHelper.ts

@@ -0,0 +1,288 @@
+import QRCode from 'qrcode';
+import CryptoJS from 'crypto-js';
+import type { TicketTournamentsVo } from './types';
+
+export interface TicketPrintItem {
+  orderNumber?: number;
+  tournamentSerialNumber?: string;
+  leagueTournamentRegion?: string;
+  seatNumInfo?: string;
+  startTime?: string;
+  leagueTournamentTitle?: string;
+  name?: string;
+  idCardNumber?: string;
+  realName?: string;
+  ticketCode?: string;
+  qrCodeContent?: string;
+  userId?: number;
+  id?: number;
+}
+
+// ... existing code ...
+export async function generateTicketQRCode(item: TicketPrintItem): Promise<string> {
+  const content = item.qrCodeContent || item.ticketCode;
+  if (content) {
+    try {
+      const qrData = {
+        userId: item.userId,
+        id: item.id,
+        ticketCode: item.ticketCode
+      };
+      const jsonStr = JSON.stringify(qrData);
+      const encodedData = CryptoJS.enc.Utf8.parse(jsonStr).toString(CryptoJS.enc.Base64);
+      return await QRCode.toDataURL(encodedData, {
+        width: 200,
+        margin: 1,
+        errorCorrectionLevel: 'H',
+        color: {
+          dark: '#000000',
+          light: '#FFFFFF'
+        }
+      });
+    } catch (err) {
+      console.error('生成二维码失败:', err);
+      return '';
+    }
+  }
+  return '';
+}
+// ... existing code ...
+
+
+export function executeTicketPrint(printData: TicketPrintItem, qrCode: string, onSuccess?: () => void, onError?: (error: any) => void): void {
+  const printWindow = window.open('', '', 'width=600,height=800');
+  if (!printWindow) {
+    onError?.(new Error('请允许弹出窗口以进行打印'));
+    return;
+  }
+
+  const htmlContent = generateTicketPrintHtml(printData, qrCode);
+  printWindow.document.write(htmlContent);
+  printWindow.document.close();
+
+  waitForImagesLoaded(
+    printWindow,
+    () => {
+      setTimeout(() => {
+        printWindow.print();
+        setTimeout(() => {
+          printWindow.close();
+          onSuccess?.();
+        }, 500);
+      }, 300);
+    },
+    onError
+  );
+}
+
+function generateTicketPrintHtml(item: TicketPrintItem, qrCode: string): string {
+  const headerText = item.orderNumber && item.tournamentSerialNumber ? `${item.orderNumber}-#${item.tournamentSerialNumber}` : item.seatNumInfo || '';
+
+  const regionText = item.seatNumInfo ? `${item.leagueTournamentRegion || ''}-${item.seatNumInfo}` : item.leagueTournamentRegion || '';
+
+  return `<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="UTF-8">
+  <title>票券打印</title>
+  <style>
+    * {
+      margin: 0 !important;
+      padding: 0 !important;
+      box-sizing: border-box !important;
+      -webkit-print-color-adjust: exact !important;
+      print-color-adjust: exact !important;
+    }
+
+    @page {
+      size: 80mm 140mm;
+      margin: 0 !important;
+    }
+
+    body {
+      width: 80mm !important;
+      margin: 0 !important;
+      padding: 4mm !important;
+      font-family: "Microsoft YaHei", Arial, sans-serif;
+      -webkit-print-color-adjust: exact !important;
+      print-color-adjust: exact !important;
+    }
+
+    .ticket-header {
+      text-align: center;
+      margin-bottom: 3mm;
+    }
+
+    .ticket-header-title {
+      font-size: 32px;
+      font-weight: bold;
+      color: #333 !important;
+      line-height: 1.2;
+    }
+
+    .ticket-header-region {
+      font-size: 14px;
+      color: #555 !important;
+      margin-top: 2mm;
+    }
+
+    .ticket-main {
+      display: flex;
+      align-items: flex-start;
+      gap: 3mm;
+      margin-bottom: 4mm;
+    }
+
+    .ticket-qr {
+      flex-shrink: 0;
+    }
+
+    .ticket-qr img {
+      display: block;
+      width: 30mm;
+      height: 30mm;
+    }
+
+    .ticket-info {
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+      gap: 1.5mm;
+    }
+
+    .ticket-info-row {
+      font-size: 11px;
+      color: #333 !important;
+      line-height: 1.4;
+    }
+
+    .ticket-info-row.name-row {
+      font-size: 13px;
+      font-weight: bold;
+      margin-top: 1mm;
+    }
+
+    .ticket-divider {
+      border: none;
+      border-top: 1px dashed #999 !important;
+      margin: 3mm 0;
+    }
+
+    .ticket-stub {
+      display: flex;
+      align-items: center;
+      gap: 2mm;
+    }
+
+    .ticket-stub-qr img {
+      display: block;
+      width: 20mm;
+      height: 20mm;
+    }
+
+    .ticket-stub-info {
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+      gap: 1mm;
+    }
+
+    .ticket-stub-info-row {
+      font-size: 10px;
+      color: #333 !important;
+      line-height: 1.4;
+    }
+
+    .ticket-stub-title {
+      font-size: 11px;
+      font-weight: bold;
+    }
+
+    @media print {
+      * {
+        -webkit-print-color-adjust: exact !important;
+        print-color-adjust: exact !important;
+      }
+    }
+  </style>
+</head>
+<body>
+  <div class="ticket-header">
+    <div class="ticket-header-title">${headerText}</div>
+    <div class="ticket-header-region">${regionText}</div>
+  </div>
+
+  <div class="ticket-main">
+    <div class="ticket-qr">
+      ${qrCode ? `<img src="${qrCode}" alt="二维码"/>` : '<div style="color:red;font-size:12px;">二维码生成失败</div>'}
+    </div>
+    <div class="ticket-info">
+      ${item.startTime ? `<div class="ticket-info-row">${item.startTime}</div>` : ''}
+      ${item.name ? `<div class="ticket-info-row">${item.name}</div>` : ''}
+      ${item.leagueTournamentTitle ? `<div class="ticket-info-row">${item.leagueTournamentTitle}</div>` : ''}
+      ${item.idCardNumber ? `<div class="ticket-info-row">${item.idCardNumber}</div>` : ''}
+      ${item.realName ? `<div class="ticket-info-row name-row">${item.realName}</div>` : ''}
+    </div>
+  </div>
+
+  <hr class="ticket-divider"/>
+
+  <div class="ticket-stub">
+    <div class="ticket-stub-qr">
+      ${qrCode ? `<img src="${qrCode}" alt="二维码"/>` : ''}
+    </div>
+    <div class="ticket-stub-info">
+      <div class="ticket-stub-info-row ticket-stub-title">${headerText}</div>
+      ${regionText ? `<div class="ticket-stub-info-row">${regionText}</div>` : ''}
+      ${item.idCardNumber ? `<div class="ticket-stub-info-row">${item.idCardNumber}</div>` : ''}
+      ${item.realName ? `<div class="ticket-stub-info-row">${item.realName}</div>` : ''}
+    </div>
+  </div>
+</body>
+</html>`;
+}
+
+function waitForImagesLoaded(printWindow: Window, onLoaded: () => void, onError?: (error: any) => void): void {
+  const images = printWindow.document.querySelectorAll('img');
+
+  if (images.length === 0) {
+    console.error('没有找到任何图片!');
+    onError?.(new Error('二维码未生成'));
+    printWindow.close();
+    return;
+  }
+
+  let loadedCount = 0;
+  const totalImages = images.length;
+  let allLoaded = false;
+
+  for (let i = 0; i < totalImages; i++) {
+    const img = images[i];
+
+    if (img.complete && img.naturalHeight !== 0) {
+      loadedCount++;
+    } else {
+      img.onload = () => {
+        loadedCount++;
+        if (loadedCount === totalImages && !allLoaded) {
+          allLoaded = true;
+          onLoaded();
+        }
+      };
+
+      img.onerror = () => {
+        console.error(`第${i + 1}个图片加载失败`);
+        loadedCount++;
+        if (loadedCount === totalImages && !allLoaded) {
+          allLoaded = true;
+          onLoaded();
+        }
+      };
+    }
+  }
+
+  if (loadedCount === totalImages && !allLoaded) {
+    allLoaded = true;
+    onLoaded();
+  }
+}

+ 26 - 0
src/api/system/physical/participants/types.ts

@@ -200,3 +200,29 @@ export interface ParticipantsQuery extends PageQuery {
    */
   params?: any;
 }
+export interface TicketTournamentsVo {
+  /** 赛事序号 */
+  orderNumber?: number;
+  /** 赛事编号 */
+  tournamentSerialNumber?: string;
+  /** 所属赛区 */
+  leagueTournamentRegion?: string;
+  /** 座位信息 几桌几号 或者 补位多少 */
+  seatNumInfo?: string;
+  /** 比赛开始时间 */
+  startTime?: string;
+  /** 赛事ID */
+  id?: number;
+  /** 赛事名称 */
+  name?: string;
+  /** 联赛名 */
+  leagueTournamentTitle?: string;
+  /** 身份证编号 */
+  idCardNumber?: string;
+  /** 用户姓名 */
+  realName?: string;
+  /** 用户ID */
+  userId?: number;
+  /** 电子票码/二维码 */
+  ticketCode?: string;
+}

+ 129 - 7
src/views/system/physical/participants/index.vue

@@ -89,7 +89,7 @@
         <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
           <template #default="scope">
             <el-tooltip content="手动取票" placement="top">
-              <el-button link type="primary" icon="Edit" @click="getNextTournament(scope.row)">手动取票</el-button>
+              <el-button link type="primary" icon="Printer" @click="handlePrintTicket(scope.row)">手动取票</el-button>
             </el-tooltip>
           </template>
         </el-table-column>
@@ -148,26 +148,70 @@
         </div>
       </template>
     </el-dialog>
+    <!-- 打印票券弹窗 -->
+    <el-dialog v-model="ticketDialog.visible" title="票券信息" width="400px" append-to-body>
+      <div class="ticket-info-list">
+        <div class="info-item" v-if="ticketHeaderTitle">
+          <span class="info-label">编号:</span>
+          <span class="info-value">{{ ticketHeaderTitle }}</span>
+        </div>
+        <div class="info-item" v-if="ticketData.leagueTournamentRegion">
+          <span class="info-label">赛区:</span>
+          <span class="info-value">{{ ticketData.leagueTournamentRegion }}</span>
+        </div>
+        <div class="info-item" v-if="ticketData.startTime">
+          <span class="info-label">时间:</span>
+          <span class="info-value">{{ ticketData.startTime }}</span>
+        </div>
+        <div class="info-item" v-if="ticketData.name">
+          <span class="info-label">赛事:</span>
+          <span class="info-value">{{ ticketData.name }}</span>
+        </div>
+        <div class="info-item" v-if="ticketData.leagueTournamentTitle">
+          <span class="info-label">联赛:</span>
+          <span class="info-value">{{ ticketData.leagueTournamentTitle }}</span>
+        </div>
+        <div class="info-item" v-if="ticketData.idCardNumber">
+          <span class="info-label">身份证:</span>
+          <span class="info-value">{{ ticketData.idCardNumber }}</span>
+        </div>
+        <div class="info-item" v-if="ticketData.realName">
+          <span class="info-label">姓名:</span>
+          <span class="info-value">{{ ticketData.realName }}</span>
+        </div>
+        <div class="info-item" v-if="ticketData.seatNumInfo">
+          <span class="info-label">座位:</span>
+          <span class="info-value">{{ ticketData.seatNumInfo }}</span>
+        </div>
+      </div>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button type="primary" @click="doPrint">打 印</el-button>
+          <el-button @click="ticketDialog.visible = false">关 闭</el-button>
+        </div>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup name="Participants" lang="ts">
 import { ref, reactive, onMounted, toRefs, watch } from 'vue';
-import { useRoute } from 'vue-router';  // 添加这一行
+import { useRoute } from 'vue-router'; // 添加这一行
 import { ElFormInstance } from 'element-plus';
 import { ComponentInternalInstance, getCurrentInstance } from 'vue';
-const route = useRoute();  // 添加这一行
+const route = useRoute(); // 添加这一行
 import {
   listParticipants,
   getParticipants,
   delParticipants,
   addParticipants,
   updateParticipants,
-  promoteTournament
+  promoteTournament,
+  printTicketInfo
 } from '@/api/system/physical/participants';
-import { ParticipantsVO, ParticipantsQuery, ParticipantsForm } from '@/api/system/physical/participants/types';
+import { ParticipantsVO, ParticipantsQuery, ParticipantsForm, TicketTournamentsVo } from '@/api/system/physical/participants/types';
 import { parseTime } from '@/utils/dateUtils';
-
+import { generateTicketQRCode, executeTicketPrint, type TicketPrintItem } from '@/api/system/physical/participants/printHelper';
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
 const participantsList = ref<ParticipantsVO[]>([]);
@@ -178,6 +222,8 @@ const ids = ref<Array<string | number>>([]);
 const single = ref(true);
 const multiple = ref(true);
 const total = ref(0);
+const ticketQrCode = ref();
+const ticketHeaderTitle = ref();
 
 const queryFormRef = ref<ElFormInstance>();
 const participantsFormRef = ref<ElFormInstance>();
@@ -186,6 +232,12 @@ const dialog = reactive({
   visible: false,
   title: ''
 });
+const ticketDialog = reactive({
+  visible: false
+});
+
+const ticketPrintRef = ref<HTMLElement>();
+const ticketData = ref<TicketTournamentsVo>({});
 
 const initFormData: ParticipantsForm = {
   id: undefined,
@@ -417,7 +469,56 @@ const handleExport = () => {
     `用户报名记录${parseTime(new Date(), '{y}{m}{d}{h}{i}{s}')}.xlsx`
   );
 };
+/** 手动取票 - 打印票券 */
+const handlePrintTicket = async (row: ParticipantsVO) => {
+  try {
+    const res = await printTicketInfo({
+      id: row.id,
+      tournamentId: row.tournamentId,
+      playerId: row.playerId
+    } as ParticipantsForm);
+    debugger;
+    if (res.code === 200 && res.data) {
+
+      console.log('res.data:', res.data);
+      console.log('orderNumber:', res.data.orderNumber);
+      console.log('tournamentSerialNumber:', res.data.tournamentSerialNumber);
+      console.log('seatNumInfo:', res.data.seatNumInfo);
 
+      ticketData.value = res.data;
+      const printItem: TicketPrintItem = {
+        ...res.data,
+        qrCodeContent: res.data.ticketCode
+      };
+      ticketQrCode.value = await generateTicketQRCode(printItem);
+      if (res.data.orderNumber && res.data.tournamentSerialNumber) {
+        ticketHeaderTitle.value = `${res.data.orderNumber}-#${res.data.tournamentSerialNumber}`;
+      }
+      ticketDialog.visible = true;
+    } else {
+      proxy?.$modal.msgError(res.msg || '获取票券信息失败');
+    }
+  } catch (error) {
+    proxy?.$modal.msgError('获取票券信息失败:' + (error as Error).message);
+  }
+};
+/** 执行打印 */
+const doPrint = () => {
+  const printItem: TicketPrintItem = {
+    ...ticketData.value,
+    qrCodeContent: ticketData.value.ticketCode
+  };
+  executeTicketPrint(
+    printItem,
+    ticketQrCode.value,
+    () => {
+      ticketDialog.visible = false;
+    },
+    (error) => {
+      proxy?.$modal.msgError(error.message || '打印失败');
+    }
+  );
+};
 // 监听路由变化
 watch(
   () => route.query,
@@ -436,7 +537,7 @@ watch(
       getList();
     }
   },
-  { immediate: false }  // 改为 false,不在组件挂载时立即执行
+  { immediate: false } // 改为 false,不在组件挂载时立即执行
 );
 
 // 组件挂载时的处理
@@ -451,3 +552,24 @@ onMounted(() => {
   getList();
 });
 </script>
+<!-- ... existing code ... -->
+<style scoped>
+.ticket-info-list {
+  padding: 10px 0;
+}
+.info-item {
+  display: flex;
+  padding: 8px 0;
+  border-bottom: 1px solid #f0f0f0;
+  font-size: 14px;
+}
+.info-label {
+  width: 70px;
+  color: #909399;
+  flex-shrink: 0;
+}
+.info-value {
+  color: #303133;
+  font-weight: 500;
+}
+</style>