VbQrGen.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. <script setup lang="ts">
  2. import { useQRCode } from "vue3-next-qrcode"
  3. import "vue3-next-qrcode/es/style.css"
  4. // 二维码生成选项类型
  5. export interface QRCodeGenerateOptions {
  6. text?: string // 二维码文本内容
  7. size?: number // 二维码尺寸(像素)
  8. margin?: number // 二维码边距(像素)
  9. correctLevel?: 0 | 1 | 2 | 3 // 纠错等级 (0=L, 1=M, 2=Q, 3=H)
  10. maskPattern?: number // 掩码图案 (0-7),用于优化二维码图案分布
  11. version?: number // 二维码版本 (1-40),控制二维码容量和复杂度
  12. colorDark?: string // 二维码深色块颜色
  13. colorLight?: string // 二维码浅色块颜色
  14. autoColor?: boolean // 是否自动计算浅色值,根据背景智能调整
  15. logoImage?: string // 中心Logo图片地址
  16. logoScale?: number // Logo尺寸相对于二维码的比例 (0-1)
  17. logoMargin?: number // Logo边距(像素)
  18. logoCornerRadius?: number // Logo圆角半径(像素)
  19. backgroundImage?: string // 背景图片地址
  20. backgroundDimming?: string // 背景图片遮罩颜色
  21. gifBackgroundURL?: string // GIF背景图片地址
  22. whiteMargin?: boolean // 是否使用白色边距,避免透明背景
  23. dotScale?: number // 点的缩放比例 (0-1),默认 1,越小定位点越精致
  24. }
  25. const props = withDefaults(
  26. defineProps<{
  27. modelValue?: string // 二维码文本内容
  28. title?: string // 标题文本
  29. titlePosition?: "top" | "bottom" // 标题位置:上方或下方
  30. titleStyle?: object // 标题自定义样式
  31. size?: number // 二维码尺寸(像素)
  32. margin?: number // 二维码边距(像素)
  33. colorDark?: string // 二维码深色块颜色
  34. colorLight?: string // 二维码浅色块颜色
  35. correctLevel?: 0 | 1 | 2 | 3 // 纠错等级 (0=L, 1=M, 2=Q, 3=H)
  36. logoImage?: string // 中心Logo图片地址
  37. logoScale?: number // Logo尺寸相对于二维码的比例
  38. logoMargin?: number // Logo边距(像素)
  39. logoCornerRadius?: number // Logo圆角半径(像素)
  40. backgroundImage?: string // 背景图片地址
  41. backgroundDimming?: string // 背景图片遮罩颜色
  42. gifBackgroundURL?: string // GIF背景图片地址
  43. whiteMargin?: boolean // 是否使用白色边距
  44. autoColor?: boolean // 是否自动计算浅色值
  45. dotScale?: number // 点的缩放比例 (0-1),默认 1,越小定位点越小
  46. loadingText?: string // 加载提示文本
  47. errorText?: string // 错误提示文本
  48. }>(),
  49. {
  50. modelValue: "",
  51. title: "",
  52. titlePosition: "top",
  53. titleStyle: undefined,
  54. size: 200,
  55. margin: 12,
  56. colorDark: "#000000",
  57. colorLight: "#ffffff",
  58. correctLevel: 1,
  59. logoImage: undefined,
  60. logoScale: 0.4,
  61. logoMargin: 6,
  62. logoCornerRadius: 8,
  63. backgroundImage: undefined,
  64. backgroundDimming: "rgba(0, 0, 0, 0)",
  65. gifBackgroundURL: undefined,
  66. whiteMargin: true,
  67. autoColor: true,
  68. dotScale: 1, // 默认减小定位点大小
  69. loadingText: "生成中...",
  70. errorText: "生成失败"
  71. }
  72. )
  73. const emits = defineEmits<{
  74. (e: "update:modelValue", v: string): void
  75. (e: "success", dataURL: ArrayBuffer | string | undefined): void
  76. (e: "error", error: unknown): void
  77. }>()
  78. // 使用 useQRCode Composable
  79. const { qrcodeURL, isLoading, error: qrError, generate, clear } = useQRCode()
  80. /**
  81. * 生成二维码(暴露给父组件调用)
  82. * @param options 二维码配置选项
  83. * @returns Promise,成功时解析为 dataURL
  84. */
  85. function generateQRCode(
  86. options?: QRCodeGenerateOptions
  87. ): Promise<ArrayBuffer | string | undefined> {
  88. // 如果传入了 text 则使用传入的,否则使用 modelValue
  89. const textToUse = options?.text !== undefined ? options.text : props.modelValue
  90. if (!textToUse) {
  91. console.warn("VbQrGen:未提供二维码文本内容")
  92. const error = new Error("未提供二维码文本内容")
  93. emits("error", error)
  94. return Promise.reject(error)
  95. }
  96. return generate({
  97. text: textToUse,
  98. size: options?.size ?? props.size,
  99. margin: options?.margin ?? props.margin,
  100. correctLevel: options?.correctLevel ?? props.correctLevel,
  101. maskPattern: options?.maskPattern,
  102. version: options?.version,
  103. colorDark: options?.colorDark ?? props.colorDark,
  104. colorLight: options?.colorLight ?? props.colorLight,
  105. autoColor: options?.autoColor ?? props.autoColor,
  106. logoImage: options?.logoImage ?? props.logoImage,
  107. logoScale: options?.logoScale ?? props.logoScale,
  108. logoMargin: options?.logoMargin ?? props.logoMargin,
  109. logoCornerRadius: options?.logoCornerRadius ?? props.logoCornerRadius,
  110. backgroundImage: options?.backgroundImage ?? props.backgroundImage,
  111. backgroundDimming: options?.backgroundDimming ?? props.backgroundDimming,
  112. gifBackgroundURL: options?.gifBackgroundURL ?? props.gifBackgroundURL,
  113. whiteMargin: options?.whiteMargin ?? props.whiteMargin,
  114. dotScale: options?.dotScale ?? props.dotScale
  115. })
  116. .then((dataURL) => {
  117. // 将返回值转换为 emits 期望的类型
  118. const result = dataURL as ArrayBuffer | string | undefined
  119. emits("success", result)
  120. return result
  121. })
  122. .catch((error) => {
  123. console.error("VbQrGen:二维码生成失败", error)
  124. emits("error", error)
  125. throw error
  126. })
  127. }
  128. /**
  129. * 清除二维码
  130. */
  131. function clearQRCode() {
  132. clear()
  133. }
  134. /**
  135. * 下载二维码图片
  136. * @param fileName 文件名(不含扩展名),默认为 'qrcode'
  137. * @returns Promise
  138. */
  139. function downloadQRCode(fileName: string = "qrcode"): Promise<void> {
  140. return new Promise((resolve, reject) => {
  141. try {
  142. const dataURL = qrcodeURL.value
  143. if (!dataURL || typeof dataURL !== "string") {
  144. const error = new Error("VbQrGen:二维码数据未生成")
  145. console.error(error.message)
  146. reject(error)
  147. return
  148. }
  149. // 创建下载链接
  150. const link = document.createElement("a")
  151. link.href = dataURL
  152. link.download = `${fileName}.png`
  153. document.body.appendChild(link)
  154. link.click()
  155. document.body.removeChild(link)
  156. console.log("VbQrGen:二维码下载成功")
  157. resolve()
  158. } catch (error) {
  159. console.error("VbQrGen:二维码下载失败", error)
  160. reject(error)
  161. }
  162. })
  163. }
  164. /**
  165. * 打印二维码图片
  166. * @param title 打印标题,默认为使用组件的 title prop 或 '二维码'
  167. * @returns Promise
  168. */
  169. function printQRCode(title?: string): Promise<void> {
  170. return new Promise((resolve, reject) => {
  171. try {
  172. // 使用 Composable 生成的 qrcodeURL
  173. const dataURL = qrcodeURL.value
  174. if (!dataURL || typeof dataURL !== "string") {
  175. const error = new Error("VbQrGen:二维码数据未生成")
  176. console.error(error.message)
  177. reject(error)
  178. return
  179. }
  180. // 确定打印标题:优先使用传入的 title,其次使用 props.title,最后使用默认值
  181. const printTitle = title || props.title || "二维码"
  182. // 尝试获取自定义插槽内容用于打印(优先使用 ref,降级为 querySelector)
  183. const contentContainer = contentRef.value || document.querySelector(".vb-qr-content")
  184. let customContentHTML = ""
  185. if (contentContainer) {
  186. // 克隆节点以获取当前显示的内容(包括自定义插槽)
  187. const clonedContent = contentContainer.cloneNode(true) as HTMLElement
  188. customContentHTML = clonedContent.innerHTML
  189. }
  190. // 创建打印窗口
  191. const printWindow = window.open("", "_blank")
  192. if (!printWindow) {
  193. const error = new Error("VbQrGen:无法打开打印窗口,请检查浏览器弹窗设置")
  194. console.error(error.message)
  195. reject(error)
  196. return
  197. }
  198. // 构建打印页面内容(不使用 script 标签,改用 onload 属性)
  199. const printContent = `
  200. <!DOCTYPE html>
  201. <html>
  202. <head>
  203. <meta charset="UTF-8">
  204. <title>${printTitle}</title>
  205. <style>
  206. * {
  207. margin: 0;
  208. padding: 0;
  209. box-sizing: border-box;
  210. }
  211. body {
  212. display: flex;
  213. flex-direction: column;
  214. justify-content: center;
  215. align-items: center;
  216. min-height: 100vh;
  217. padding: 20px;
  218. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  219. }
  220. .title {
  221. font-size: 24px;
  222. font-weight: bold;
  223. margin-bottom: 20px;
  224. color: #333;
  225. text-align: center;
  226. }
  227. .qrcode-container {
  228. display: flex;
  229. justify-content: center;
  230. align-items: center;
  231. margin: 20px 0;
  232. }
  233. .qrcode-image {
  234. max-width: 100%;
  235. height: auto;
  236. }
  237. @media print {
  238. body {
  239. print-color-adjust: exact;
  240. -webkit-print-color-adjust: exact;
  241. }
  242. }
  243. </style>
  244. </head>
  245. <body onload="setTimeout(function() { window.print(); setTimeout(function() { window.close(); }, 100); }, 500)">
  246. <div class="title">${printTitle}</div>
  247. <div class="qrcode-container">
  248. ${customContentHTML || `<img src="${dataURL}" alt="二维码" class="qrcode-image" />`}
  249. </div>
  250. </body>
  251. </html>
  252. `
  253. // 使用 Blob 和 URL.createObjectURL 替代已弃用的 document.write
  254. const blob = new Blob([printContent], { type: "text/html;charset=utf-8" })
  255. const url = URL.createObjectURL(blob)
  256. printWindow.location.href = url
  257. console.log("VbQrGen:二维码打印窗口已打开")
  258. resolve()
  259. } catch (error) {
  260. console.error("VbQrGen:二维码打印失败", error)
  261. reject(error)
  262. }
  263. })
  264. }
  265. /**
  266. * 获取二维码 Data URL
  267. * @returns Data URL 字符串或 null
  268. */
  269. function getQRCodeDataURL(): string | null {
  270. try {
  271. const dataURL = qrcodeURL.value
  272. if (!dataURL || typeof dataURL !== "string") {
  273. console.warn("VbQrGen:二维码数据未生成")
  274. return null
  275. }
  276. return dataURL
  277. } catch (error) {
  278. console.error("VbQrGen:获取二维码 Data URL 失败", error)
  279. return null
  280. }
  281. }
  282. // 标题样式
  283. const titleStyle = computed(() => {
  284. const defaultStyle: Record<string, string> = {
  285. fontSize: "16px",
  286. fontWeight: "bold",
  287. color: "#333",
  288. marginBottom: props.titlePosition === "top" ? "12px" : "0",
  289. marginTop: props.titlePosition === "bottom" ? "12px" : "0",
  290. textAlign: "center"
  291. }
  292. return {
  293. ...defaultStyle,
  294. ...(props.titleStyle || {})
  295. }
  296. })
  297. // 内容容器引用(用于打印时获取自定义插槽内容)
  298. const contentRef = ref<HTMLElement | null>(null)
  299. // 暴露方法给父组件
  300. defineExpose({
  301. // 核心方法
  302. generateQRCode,
  303. clearQRCode,
  304. downloadQRCode,
  305. printQRCode,
  306. getQRCodeDataURL,
  307. // 状态获取方法(不直接暴露内部状态)
  308. getQRCodeState() {
  309. return {
  310. url: qrcodeURL.value,
  311. loading: isLoading.value,
  312. error: qrError.value
  313. }
  314. }
  315. })
  316. </script>
  317. <template>
  318. <div class="vb-qr-gen">
  319. <!-- 标题在上方 -->
  320. <template v-if="props.title && props.titlePosition === 'top'">
  321. <!-- 使用插槽自定义标题 -->
  322. <slot v-if="$slots.title" name="title"></slot>
  323. <!-- 使用默认标题 -->
  324. <div v-else class="vb-qr-title" :style="titleStyle">{{ props.title }}</div>
  325. </template>
  326. <!-- 二维码图片 -->
  327. <div v-if="qrcodeURL && typeof qrcodeURL === 'string'" ref="contentRef" class="vb-qr-content">
  328. <!-- 使用插槽自定义二维码显示 -->
  329. <slot v-if="$slots.qrcode" name="qrcode" :qrcode-url="qrcodeURL" :size="props.size"></slot>
  330. <!-- 默认显示图片 -->
  331. <img
  332. v-else
  333. :src="qrcodeURL"
  334. alt="二维码"
  335. class="vb-qr-image"
  336. :style="{ width: props.size + 'px', height: props.size + 'px' }" />
  337. </div>
  338. <!-- 加载中状态 -->
  339. <div v-else-if="isLoading" class="vb-qr-content">
  340. <!-- 使用插槽自定义加载状态 -->
  341. <slot v-if="$slots.loading" name="loading"></slot>
  342. <!-- 默认加载提示 -->
  343. <template v-else>
  344. <div class="vb-qr-spinner"></div>
  345. <span>{{ loadingText }}</span>
  346. </template>
  347. </div>
  348. <!-- 错误状态 -->
  349. <div v-else-if="qrError" class="vb-qr-content">
  350. <!-- 使用插槽自定义错误状态 -->
  351. <slot v-if="$slots.error" name="error" :error="qrError"></slot>
  352. <!-- 默认错误提示 -->
  353. <template v-else>
  354. <span>{{ errorText }}</span>
  355. </template>
  356. </div>
  357. <!-- 标题在下方 -->
  358. <template v-if="props.title && props.titlePosition === 'bottom'">
  359. <!-- 使用插槽自定义标题 -->
  360. <slot v-if="$slots.title" name="title"></slot>
  361. <!-- 使用默认标题 -->
  362. <div v-else class="vb-qr-title" :style="titleStyle">{{ props.title }}</div>
  363. </template>
  364. </div>
  365. </template>
  366. <style scoped lang="scss">
  367. .vb-qr-gen {
  368. display: inline-flex;
  369. flex-direction: column;
  370. justify-content: center;
  371. align-items: center;
  372. .vb-qr-title {
  373. width: 100%;
  374. }
  375. .vb-qr-content {
  376. display: flex;
  377. justify-content: center;
  378. align-items: center;
  379. }
  380. .vb-qr-image {
  381. display: block;
  382. object-fit: contain;
  383. }
  384. .vb-qr-spinner {
  385. width: 30px;
  386. height: 30px;
  387. border: 3px solid #e5e7eb;
  388. border-top-color: #1677ff;
  389. border-radius: 50%;
  390. animation: spin 0.75s linear infinite;
  391. }
  392. @keyframes spin {
  393. to {
  394. transform: rotate(360deg);
  395. }
  396. }
  397. }
  398. </style>