|
|
@@ -0,0 +1,842 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import VbQrGen from "./VbQrGen.vue"
|
|
|
+import type { QRCodeGenerateOptions } from "./VbQrGen.vue"
|
|
|
+
|
|
|
+// 二维码项类型
|
|
|
+export interface QRCodeItem {
|
|
|
+ id: string
|
|
|
+ text: string
|
|
|
+ title?: string
|
|
|
+ options?: QRCodeGenerateOptions
|
|
|
+}
|
|
|
+
|
|
|
+const props = withDefaults(
|
|
|
+ defineProps<{
|
|
|
+ items?: QRCodeItem[] // 初始二维码列表
|
|
|
+ size?: number // 默认尺寸
|
|
|
+ margin?: number // 默认边距
|
|
|
+ dotScale?: number // 默认点缩放比例
|
|
|
+ colorDark?: string // 默认深色块颜色
|
|
|
+ colorLight?: string // 默认浅色块颜色
|
|
|
+ correctLevel?: 0 | 1 | 2 | 3 // 默认纠错等级
|
|
|
+ logoImage?: string // 默认Logo图片
|
|
|
+ logoScale?: number // 默认Logo比例
|
|
|
+ logoMargin?: number // 默认Logo边距
|
|
|
+ logoCornerRadius?: number // 默认Logo圆角
|
|
|
+ backgroundImage?: string // 默认背景图
|
|
|
+ backgroundDimming?: string // 默认背景遮罩
|
|
|
+ gifBackgroundURL?: string // 默认GIF背景
|
|
|
+ whiteMargin?: boolean // 默认白色边距
|
|
|
+ autoColor?: boolean // 默认自动计算浅色
|
|
|
+ maskPattern?: number // 默认掩码图案
|
|
|
+ version?: number // 默认版本
|
|
|
+ layout?: "grid" | "list" // 布局方式:网格或列表
|
|
|
+ columns?: number // 网格列数
|
|
|
+ title?: string | ((item: QRCodeItem, index: number) => string) // 标题或标题生成函数
|
|
|
+ showItemActions?: boolean // 是否显示每个二维码卡片的操作按钮
|
|
|
+ concurrency?: number // 批量生成并发数,默认20
|
|
|
+ autoGenerate?: boolean // 是否自动生成:true=页面初始化时自动批量生成,添加新项后也自动生成
|
|
|
+ }>(),
|
|
|
+ {
|
|
|
+ items: () => [],
|
|
|
+ size: 150,
|
|
|
+ margin: 10,
|
|
|
+ dotScale: 0.35,
|
|
|
+ colorDark: undefined,
|
|
|
+ colorLight: undefined,
|
|
|
+ correctLevel: undefined,
|
|
|
+ logoImage: undefined,
|
|
|
+ logoScale: undefined,
|
|
|
+ logoMargin: undefined,
|
|
|
+ logoCornerRadius: undefined,
|
|
|
+ backgroundImage: undefined,
|
|
|
+ backgroundDimming: undefined,
|
|
|
+ gifBackgroundURL: undefined,
|
|
|
+ whiteMargin: undefined,
|
|
|
+ autoColor: undefined,
|
|
|
+ maskPattern: undefined,
|
|
|
+ version: undefined,
|
|
|
+ layout: "grid",
|
|
|
+ columns: 5,
|
|
|
+ title: undefined,
|
|
|
+ showItemActions: true,
|
|
|
+ concurrency: 50,
|
|
|
+ autoGenerate: true
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+const emits = defineEmits<{
|
|
|
+ (e: "update:items", items: QRCodeItem[]): void
|
|
|
+ (e: "add", item: QRCodeItem): void
|
|
|
+ (e: "remove", id: string): void
|
|
|
+ (e: "generate", item: QRCodeItem): void
|
|
|
+ (e: "clear", id: string): void
|
|
|
+ (e: "generate-all-complete", result: { success: number; fail: number }): void
|
|
|
+ (e: "download-all-complete", count: number): void
|
|
|
+ (e: "print-all-complete", count: number): void
|
|
|
+}>()
|
|
|
+
|
|
|
+// 二维码列表
|
|
|
+const qrList = ref<QRCodeItem[]>(props.items || [])
|
|
|
+
|
|
|
+// VbQrGen 组件引用映射
|
|
|
+const qrRefs = ref<Map<string, InstanceType<typeof VbQrGen>>>(new Map())
|
|
|
+
|
|
|
+// 计算样式
|
|
|
+const containerStyle = computed(() => {
|
|
|
+ if (props.layout === "grid") {
|
|
|
+ return {
|
|
|
+ display: "grid" as const,
|
|
|
+ gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
|
|
+ gap: "20px"
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ display: "flex" as const,
|
|
|
+ flexDirection: "column" as const,
|
|
|
+ gap: "20px"
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取二维码标题
|
|
|
+ */
|
|
|
+function getQRTitle(item: QRCodeItem, index: number): string {
|
|
|
+ // 优先使用 item 自身的 title
|
|
|
+ if (item.title) {
|
|
|
+ return item.title
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果 props.title 是函数,调用它
|
|
|
+ if (typeof props.title === "function") {
|
|
|
+ return props.title(item, index)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果 props.title 是字符串,使用它
|
|
|
+ if (props.title) {
|
|
|
+ return props.title
|
|
|
+ }
|
|
|
+
|
|
|
+ // 默认标题
|
|
|
+ return `二维码 ${index + 1}`
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 合并配置选项
|
|
|
+ */
|
|
|
+function mergeOptions(item: QRCodeItem): QRCodeGenerateOptions {
|
|
|
+ return {
|
|
|
+ size: props.size,
|
|
|
+ margin: props.margin,
|
|
|
+ dotScale: props.dotScale,
|
|
|
+ colorDark: props.colorDark,
|
|
|
+ colorLight: props.colorLight,
|
|
|
+ correctLevel: props.correctLevel,
|
|
|
+ logoImage: props.logoImage,
|
|
|
+ logoScale: props.logoScale,
|
|
|
+ logoMargin: props.logoMargin,
|
|
|
+ logoCornerRadius: props.logoCornerRadius,
|
|
|
+ backgroundImage: props.backgroundImage,
|
|
|
+ backgroundDimming: props.backgroundDimming,
|
|
|
+ gifBackgroundURL: props.gifBackgroundURL,
|
|
|
+ whiteMargin: props.whiteMargin,
|
|
|
+ autoColor: props.autoColor,
|
|
|
+ maskPattern: props.maskPattern,
|
|
|
+ version: props.version,
|
|
|
+ ...item.options
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 添加二维码
|
|
|
+ */
|
|
|
+function addQRCode(item?: Partial<QRCodeItem>) {
|
|
|
+ const index = qrList.value.length
|
|
|
+ const newItem: QRCodeItem = {
|
|
|
+ id: item?.id || `qr_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
|
+ text: item?.text || "",
|
|
|
+ title:
|
|
|
+ item?.title ||
|
|
|
+ (typeof props.title === "function" ? props.title({} as QRCodeItem, index) : props.title) ||
|
|
|
+ "",
|
|
|
+ options: mergeOptions({ options: item?.options } as QRCodeItem)
|
|
|
+ }
|
|
|
+
|
|
|
+ qrList.value.push(newItem)
|
|
|
+ emits("update:items", qrList.value)
|
|
|
+ emits("add", newItem)
|
|
|
+ message.msgSuccess("已添加新二维码")
|
|
|
+
|
|
|
+ // 如果开启了自动生成,立即生成新添加的二维码
|
|
|
+ if (props.autoGenerate && newItem.text) {
|
|
|
+ nextTick(() => {
|
|
|
+ const qrRef = qrRefs.value.get(newItem.id)
|
|
|
+ if (qrRef) {
|
|
|
+ try {
|
|
|
+ const options = mergeOptions(newItem)
|
|
|
+ options.text = newItem.text
|
|
|
+ qrRef.generateQRCode(options)
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`自动生失败:`, error)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 删除二维码
|
|
|
+ */
|
|
|
+function removeQRCode(id: string) {
|
|
|
+ const index = qrList.value.findIndex((item) => item.id === id)
|
|
|
+ if (index > -1) {
|
|
|
+ qrList.value.splice(index, 1)
|
|
|
+ qrRefs.value.delete(id)
|
|
|
+ emits("update:items", qrList.value)
|
|
|
+ emits("remove", id)
|
|
|
+ message.msgSuccess("已删除二维码")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 批量生成所有二维码(并发处理)
|
|
|
+ * @returns Promise,解析为成功和失败数量
|
|
|
+ */
|
|
|
+function generateAll(): Promise<{ success: number; fail: number }> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const total = qrList.value.length
|
|
|
+ if (total === 0) {
|
|
|
+ message.msgWarning("没有可生成的二维码")
|
|
|
+ resolve({ success: 0, fail: 0 })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let successCount = 0
|
|
|
+ let failCount = 0
|
|
|
+
|
|
|
+ // 创建任务队列
|
|
|
+ type TaskItem = { item: QRCodeItem; index: number }
|
|
|
+ const tasks: TaskItem[] = qrList.value.map((item, index) => ({ item, index }))
|
|
|
+
|
|
|
+ // 并发控制函数 - 使用信号量模式
|
|
|
+ function runWithConcurrency(tasks: TaskItem[], concurrency: number) {
|
|
|
+ let activeCount = 0
|
|
|
+ let taskIndex = 0
|
|
|
+
|
|
|
+ function runNext() {
|
|
|
+ // 如果没有更多任务且没有活动任务,完成
|
|
|
+ if (taskIndex >= tasks.length && activeCount === 0) {
|
|
|
+ const result = { success: successCount, fail: failCount }
|
|
|
+ emits("generate-all-complete", result)
|
|
|
+ resolve(result)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 启动新任务直到达到并发限制
|
|
|
+ while (activeCount < concurrency && taskIndex < tasks.length) {
|
|
|
+ const task = tasks[taskIndex]
|
|
|
+ taskIndex++
|
|
|
+ activeCount++
|
|
|
+
|
|
|
+ // 执行任务
|
|
|
+ ;(() => {
|
|
|
+ try {
|
|
|
+ const qrRef = qrRefs.value.get(task.item.id)
|
|
|
+ if (qrRef && task.item.text) {
|
|
|
+ const options = mergeOptions(task.item)
|
|
|
+ options.text = task.item.text
|
|
|
+
|
|
|
+ let resolved = false
|
|
|
+ let timeoutId: any = null
|
|
|
+
|
|
|
+ const cleanup = () => {
|
|
|
+ if (timeoutId) {
|
|
|
+ clearTimeout(timeoutId)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const onSuccess = () => {
|
|
|
+ if (!resolved) {
|
|
|
+ resolved = true
|
|
|
+ cleanup()
|
|
|
+ successCount++
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const onError = (_error: unknown) => {
|
|
|
+ if (!resolved) {
|
|
|
+ resolved = true
|
|
|
+ cleanup()
|
|
|
+ failCount++
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 超时处理
|
|
|
+ timeoutId = setTimeout(() => {
|
|
|
+ if (!resolved) {
|
|
|
+ resolved = true
|
|
|
+ failCount++
|
|
|
+ }
|
|
|
+ }, 10000)
|
|
|
+
|
|
|
+ // 清空旧的二维码,确保能检测到新生成的
|
|
|
+ qrRef.clearQRCode()
|
|
|
+
|
|
|
+ // 调用生成方法
|
|
|
+ qrRef.generateQRCode(options)
|
|
|
+
|
|
|
+ // 轮询检查是否生成完成
|
|
|
+ const checkInterval = setInterval(() => {
|
|
|
+ if (resolved) {
|
|
|
+ clearInterval(checkInterval)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取状态检查是否有错误
|
|
|
+ const state = qrRef.getQRCodeState()
|
|
|
+ if (state.error) {
|
|
|
+ onError(state.error)
|
|
|
+ clearInterval(checkInterval)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否生成了新的二维码
|
|
|
+ if (state.url && typeof state.url === "string") {
|
|
|
+ onSuccess()
|
|
|
+ clearInterval(checkInterval)
|
|
|
+ }
|
|
|
+ }, 50)
|
|
|
+ } else {
|
|
|
+ failCount++
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ failCount++
|
|
|
+ console.error(`二维码 ${task.item.id} 生成失败:`, error)
|
|
|
+ } finally {
|
|
|
+ // 任务完成后,减少活动计数并启动下一个任务
|
|
|
+ activeCount--
|
|
|
+ runNext()
|
|
|
+ }
|
|
|
+ })()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 开始执行
|
|
|
+ runNext()
|
|
|
+ }
|
|
|
+
|
|
|
+ // 执行并发任务
|
|
|
+ runWithConcurrency(tasks, props.concurrency)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 生成单个二维码
|
|
|
+ */
|
|
|
+function generateOne(item: QRCodeItem) {
|
|
|
+ const qrRef = qrRefs.value.get(item.id)
|
|
|
+ if (qrRef && item.text) {
|
|
|
+ const options = mergeOptions(item)
|
|
|
+ options.text = item.text
|
|
|
+ qrRef.clearQRCode()
|
|
|
+ qrRef.generateQRCode(options)
|
|
|
+ } else {
|
|
|
+ message.msgWarning("二维码文本为空")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 下载单个二维码
|
|
|
+ */
|
|
|
+function downloadOne(item: QRCodeItem, index: number) {
|
|
|
+ const qrRef = qrRefs.value.get(item.id)
|
|
|
+ if (qrRef) {
|
|
|
+ const title = getQRTitle(item, index)
|
|
|
+ qrRef
|
|
|
+ .downloadQRCode(title)
|
|
|
+ .then(() => {
|
|
|
+ message.msgSuccess(`已下载:${title}`)
|
|
|
+ })
|
|
|
+ .catch((error) => {
|
|
|
+ console.error(`二维码 ${item.id} 下载失败:`, error)
|
|
|
+ message.msgError("下载失败")
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 打印单个二维码
|
|
|
+ */
|
|
|
+function printOne(item: QRCodeItem, index: number) {
|
|
|
+ const qrRef = qrRefs.value.get(item.id)
|
|
|
+ if (qrRef) {
|
|
|
+ const title = getQRTitle(item, index)
|
|
|
+ qrRef
|
|
|
+ .printQRCode(title)
|
|
|
+ .then(() => {
|
|
|
+ message.msgSuccess(`已打开打印窗口:${title}`)
|
|
|
+ })
|
|
|
+ .catch((error) => {
|
|
|
+ console.error(`二维码 ${item.id} 打印失败:`, error)
|
|
|
+ message.msgError("打印失败")
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 批量清除所有二维码
|
|
|
+ */
|
|
|
+function clearAll() {
|
|
|
+ qrList.value.forEach((item) => {
|
|
|
+ const qrRef = qrRefs.value.get(item.id)
|
|
|
+ if (qrRef) {
|
|
|
+ qrRef.clearQRCode()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ emits("clear", "all")
|
|
|
+ message.msgSuccess("已清除所有二维码")
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 批量下载所有二维码(合并为一张图片)
|
|
|
+ * @returns Promise
|
|
|
+ */
|
|
|
+function downloadAll(): Promise<void> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ try {
|
|
|
+ // 收集所有已生成的二维码 Data URL
|
|
|
+ const dataURLs: { title: string; dataURL: string }[] = []
|
|
|
+
|
|
|
+ for (let i = 0; i < qrList.value.length; i++) {
|
|
|
+ const item = qrList.value[i]
|
|
|
+ const qrRef = qrRefs.value.get(item.id)
|
|
|
+ if (qrRef) {
|
|
|
+ const dataURL = qrRef.getQRCodeDataURL()
|
|
|
+ if (dataURL) {
|
|
|
+ dataURLs.push({
|
|
|
+ title: getQRTitle(item, i),
|
|
|
+ dataURL
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (dataURLs.length === 0) {
|
|
|
+ message.msgWarning("没有可下载的二维码,请先生成")
|
|
|
+ resolve()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建 canvas 合并所有二维码
|
|
|
+ const canvas = document.createElement("canvas")
|
|
|
+ const ctx = canvas.getContext("2d")
|
|
|
+ if (!ctx) {
|
|
|
+ throw new Error("无法创建 canvas 上下文")
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算画布大小
|
|
|
+ const qrSize = props.size
|
|
|
+ const padding = 40
|
|
|
+ const titleHeight = 30
|
|
|
+ const cols = Math.min(dataURLs.length, props.columns)
|
|
|
+ const rows = Math.ceil(dataURLs.length / cols)
|
|
|
+
|
|
|
+ canvas.width = cols * (qrSize + padding) + padding
|
|
|
+ canvas.height = rows * (qrSize + titleHeight + padding) + padding
|
|
|
+
|
|
|
+ // 填充白色背景
|
|
|
+ ctx.fillStyle = "#ffffff"
|
|
|
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
|
+
|
|
|
+ // 绘制所有二维码
|
|
|
+ let loadedCount = 0
|
|
|
+ const totalImages = dataURLs.length
|
|
|
+
|
|
|
+ const checkComplete = () => {
|
|
|
+ loadedCount++
|
|
|
+ if (loadedCount === totalImages) {
|
|
|
+ // 所有图片加载完成,下载
|
|
|
+ const link = document.createElement("a")
|
|
|
+ link.href = canvas.toDataURL("image/png")
|
|
|
+ link.download = `qrcodes_${Date.now()}.png`
|
|
|
+ document.body.appendChild(link)
|
|
|
+ link.click()
|
|
|
+ document.body.removeChild(link)
|
|
|
+
|
|
|
+ emits("download-all-complete", dataURLs.length)
|
|
|
+ message.msgSuccess(`已下载包含 ${dataURLs.length} 个二维码的图片`)
|
|
|
+ resolve()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (let i = 0; i < dataURLs.length; i++) {
|
|
|
+ const col = i % cols
|
|
|
+ const row = Math.floor(i / cols)
|
|
|
+ const x = col * (qrSize + padding) + padding
|
|
|
+ const y = row * (qrSize + titleHeight + padding) + padding
|
|
|
+
|
|
|
+ // 绘制标题
|
|
|
+ ctx.fillStyle = "#333333"
|
|
|
+ ctx.font = "bold 14px Arial"
|
|
|
+ ctx.textAlign = "center"
|
|
|
+ ctx.fillText(dataURLs[i].title, x + qrSize / 2, y + 20)
|
|
|
+
|
|
|
+ // 绘制二维码
|
|
|
+ const img = new Image()
|
|
|
+ img.onload = () => {
|
|
|
+ ctx.drawImage(img, x, y + titleHeight, qrSize, qrSize)
|
|
|
+ checkComplete()
|
|
|
+ }
|
|
|
+ img.onerror = () => {
|
|
|
+ console.error(`图片加载失败: ${dataURLs[i].title}`)
|
|
|
+ checkComplete()
|
|
|
+ }
|
|
|
+ img.src = dataURLs[i].dataURL
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error("批量下载失败:", error)
|
|
|
+ message.msgError("批量下载失败")
|
|
|
+ reject(error)
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 批量打印所有二维码(合并在一个页面上)
|
|
|
+ * @returns Promise
|
|
|
+ */
|
|
|
+function printAll(): Promise<void> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ try {
|
|
|
+ // 收集所有已生成的二维码 Data URL
|
|
|
+ const dataURLs: { title: string; dataURL: string }[] = []
|
|
|
+
|
|
|
+ for (let i = 0; i < qrList.value.length; i++) {
|
|
|
+ const item = qrList.value[i]
|
|
|
+ const qrRef = qrRefs.value.get(item.id)
|
|
|
+ if (qrRef) {
|
|
|
+ const dataURL = qrRef.getQRCodeDataURL()
|
|
|
+ if (dataURL) {
|
|
|
+ dataURLs.push({
|
|
|
+ title: getQRTitle(item, i),
|
|
|
+ dataURL
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (dataURLs.length === 0) {
|
|
|
+ message.msgWarning("没有可打印的二维码,请先生成")
|
|
|
+ resolve()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建打印窗口
|
|
|
+ const printWindow = window.open("", "_blank")
|
|
|
+ if (!printWindow) {
|
|
|
+ throw new Error("无法打开打印窗口,请检查浏览器弹窗设置")
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算布局
|
|
|
+ const cols = props.columns
|
|
|
+
|
|
|
+ // 构建打印页面内容
|
|
|
+ const printContent = `
|
|
|
+ <!DOCTYPE html>
|
|
|
+ <html>
|
|
|
+ <head>
|
|
|
+ <meta charset="UTF-8">
|
|
|
+ <title>批量二维码打印</title>
|
|
|
+ <style>
|
|
|
+ * {
|
|
|
+ margin: 0;
|
|
|
+ padding: 0;
|
|
|
+ box-sizing: border-box;
|
|
|
+ }
|
|
|
+ body {
|
|
|
+ padding: 15px;
|
|
|
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
|
+ }
|
|
|
+ .qr-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(${cols}, 1fr);
|
|
|
+ gap: 15px;
|
|
|
+ max-width: 1200px;
|
|
|
+ margin: 0 auto;
|
|
|
+ }
|
|
|
+ .qr-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ padding: 10px;
|
|
|
+ border: none;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #fff;
|
|
|
+ }
|
|
|
+ .qr-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #333;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ text-align: center;
|
|
|
+ word-break: break-all;
|
|
|
+ }
|
|
|
+ .qr-image {
|
|
|
+ width: ${props.size}px;
|
|
|
+ height: ${props.size}px;
|
|
|
+ object-fit: contain;
|
|
|
+ }
|
|
|
+ @media print {
|
|
|
+ body {
|
|
|
+ print-color-adjust: exact;
|
|
|
+ -webkit-print-color-adjust: exact;
|
|
|
+ }
|
|
|
+ .qr-item {
|
|
|
+ break-inside: avoid;
|
|
|
+ page-break-inside: avoid;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ </style>
|
|
|
+ </head>
|
|
|
+ <body onload="setTimeout(function() { window.print(); setTimeout(function() { window.close(); }, 100); }, 500)">
|
|
|
+ <div class="qr-grid">
|
|
|
+ ${dataURLs
|
|
|
+ .map(
|
|
|
+ (item) => `
|
|
|
+ <div class="qr-item">
|
|
|
+ <div class="qr-title">${item.title}</div>
|
|
|
+ <img src="${item.dataURL}" alt="${item.title}" class="qr-image" />
|
|
|
+ </div>
|
|
|
+ `
|
|
|
+ )
|
|
|
+ .join("")}
|
|
|
+ </div>
|
|
|
+ </body>
|
|
|
+ </html>
|
|
|
+ `
|
|
|
+
|
|
|
+ // 使用 Blob 和 URL.createObjectURL
|
|
|
+ const blob = new Blob([printContent], { type: "text/html;charset=utf-8" })
|
|
|
+ const url = URL.createObjectURL(blob)
|
|
|
+ printWindow.location.href = url
|
|
|
+
|
|
|
+ emits("print-all-complete", dataURLs.length)
|
|
|
+ message.msgSuccess(`已打开打印窗口,包含 ${dataURLs.length} 个二维码`)
|
|
|
+ resolve()
|
|
|
+ } catch (error) {
|
|
|
+ console.error("批量打印失败:", error)
|
|
|
+ message.msgError("批量打印失败")
|
|
|
+ reject(error)
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取组件引用
|
|
|
+ */
|
|
|
+function setQRRef(id: string, ref: InstanceType<typeof VbQrGen> | null) {
|
|
|
+ if (ref) {
|
|
|
+ qrRefs.value.set(id, ref)
|
|
|
+ } else {
|
|
|
+ qrRefs.value.delete(id)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 页面挂载后,如果开启了自动生成,则自动批量生成
|
|
|
+onMounted(() => {
|
|
|
+ if (props.autoGenerate && qrList.value.length > 0) {
|
|
|
+ // 等待 DOM 渲染完成
|
|
|
+ nextTick(() => {
|
|
|
+ // 延迟一小段时间确保所有子组件都已挂载
|
|
|
+ setTimeout(() => {
|
|
|
+ generateAll()
|
|
|
+ }, 100)
|
|
|
+ })
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 监听 items 变化,如果有新项且开启了自动生成,则自动生成新项
|
|
|
+watch(
|
|
|
+ () => props.items,
|
|
|
+ (newItems, oldItems) => {
|
|
|
+ if (!props.autoGenerate) return
|
|
|
+
|
|
|
+ // 如果是初始化,不处理(由 onMounted 处理)
|
|
|
+ if (!oldItems || oldItems.length === 0) return
|
|
|
+
|
|
|
+ // 检测是否有新项
|
|
|
+ if (newItems.length > oldItems.length) {
|
|
|
+ // 找到新添加的项
|
|
|
+ const newIds = newItems.map((item) => item.id)
|
|
|
+ const oldIds = oldItems.map((item) => item.id)
|
|
|
+ const addedIds = newIds.filter((id) => !oldIds.includes(id))
|
|
|
+
|
|
|
+ // 为每个新项生成二维码
|
|
|
+ if (addedIds.length > 0) {
|
|
|
+ nextTick(() => {
|
|
|
+ addedIds.forEach((id) => {
|
|
|
+ const item = newItems.find((i) => i.id === id)
|
|
|
+ if (item && item.text) {
|
|
|
+ const qrRef = qrRefs.value.get(id)
|
|
|
+ if (qrRef) {
|
|
|
+ try {
|
|
|
+ const options = mergeOptions(item)
|
|
|
+ options.text = item.text
|
|
|
+ qrRef.generateQRCode(options)
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`自动生成失败:`, error)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { deep: true }
|
|
|
+)
|
|
|
+
|
|
|
+// 暴露方法给父组件
|
|
|
+defineExpose({
|
|
|
+ qrList,
|
|
|
+ addQRCode,
|
|
|
+ removeQRCode,
|
|
|
+ generateAll,
|
|
|
+ generateOne,
|
|
|
+ downloadOne,
|
|
|
+ printOne,
|
|
|
+ clearAll,
|
|
|
+ downloadAll,
|
|
|
+ printAll,
|
|
|
+ getQRRef: (id: string) => qrRefs.value.get(id)
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="vb-qr-multi">
|
|
|
+ <!-- 二维码列表/网格 -->
|
|
|
+ <div class="vb-qr-multi-container" :style="containerStyle">
|
|
|
+ <div v-for="(item, index) in qrList" :key="item.id" class="vb-qr-multi-item">
|
|
|
+ <!-- 使用插槽自定义二维码卡片内容 -->
|
|
|
+ <slot
|
|
|
+ name="item"
|
|
|
+ :item="item"
|
|
|
+ :index="index"
|
|
|
+ :title="getQRTitle(item, index)"
|
|
|
+ :generate="() => generateOne(item)"
|
|
|
+ :download="() => downloadOne(item, index)"
|
|
|
+ :print="() => printOne(item, index)"
|
|
|
+ :remove="() => removeQRCode(item.id)">
|
|
|
+ <!-- 默认卡片内容 -->
|
|
|
+ <!-- 二维码组件 -->
|
|
|
+ <VbQrGen
|
|
|
+ :ref="(el) => setQRRef(item.id, el as InstanceType<typeof VbQrGen>)"
|
|
|
+ v-model="item.text"
|
|
|
+ :title="getQRTitle(item, index)"
|
|
|
+ :size="item.options?.size ?? size"
|
|
|
+ :margin="item.options?.margin ?? margin"
|
|
|
+ :dot-scale="item.options?.dotScale ?? dotScale"
|
|
|
+ :color-dark="item.options?.colorDark"
|
|
|
+ :color-light="item.options?.colorLight"
|
|
|
+ :correct-level="item.options?.correctLevel"
|
|
|
+ :logo-image="item.options?.logoImage"
|
|
|
+ :background-image="item.options?.backgroundImage"
|
|
|
+ class="vb-qr-multi-qrcode" />
|
|
|
+
|
|
|
+ <!-- 二维码卡片底部操作 -->
|
|
|
+ <div v-if="showItemActions" class="vb-qr-multi-item-footer">
|
|
|
+ <!-- <el-button size="small" @click="generateOne(item)">生成</el-button> -->
|
|
|
+ <el-button size="small" @click="downloadOne(item, index)">下载</el-button>
|
|
|
+ <el-button size="small" @click="printOne(item, index)">打印</el-button>
|
|
|
+ </div>
|
|
|
+ </slot>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 空状态 -->
|
|
|
+ <div v-if="qrList.length === 0" class="vb-qr-multi-empty">
|
|
|
+ <slot name="empty">
|
|
|
+ <el-empty description="暂无二维码,点击「添加二维码」开始创建" />
|
|
|
+ </slot>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.vb-qr-multi {
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ .vb-qr-multi-toolbar {
|
|
|
+ margin-bottom: 20px;
|
|
|
+ padding: 16px;
|
|
|
+ background-color: #f5f7fa;
|
|
|
+ border-radius: 8px;
|
|
|
+
|
|
|
+ .vb-qr-multi-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .vb-qr-multi-container {
|
|
|
+ .vb-qr-multi-item {
|
|
|
+ position: relative;
|
|
|
+ padding: 16px;
|
|
|
+ background-color: #fff;
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ border-radius: 8px;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
|
|
+ border-color: #1677ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .vb-qr-multi-item-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ padding-bottom: 8px;
|
|
|
+ border-bottom: 1px solid #f0f0f0;
|
|
|
+
|
|
|
+ .vb-qr-multi-item-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ max-width: calc(100% - 40px);
|
|
|
+ }
|
|
|
+
|
|
|
+ .vb-qr-multi-item-remove {
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .vb-qr-multi-qrcode {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ min-height: 200px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .vb-qr-multi-item-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin-top: 12px;
|
|
|
+ padding-top: 12px;
|
|
|
+ border-top: 1px solid #f0f0f0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .vb-qr-multi-empty {
|
|
|
+ grid-column: 1 / -1;
|
|
|
+ padding: 60px 20px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|