feat(SharePoster): 使用html-to-image生成海报并优化资源加载
重构海报生成逻辑,使用html-to-image替代canvas手动绘制,提升生成稳定性 添加生成中状态提示,优先使用base64资源避免跨域问题 优化封面和二维码加载逻辑,移除冗余canvas代码
Showing
3 changed files
with
201 additions
and
283 deletions
| ... | @@ -34,6 +34,7 @@ | ... | @@ -34,6 +34,7 @@ |
| 34 | "@vue-office/pptx": "^1.0.1", | 34 | "@vue-office/pptx": "^1.0.1", |
| 35 | "browser-md5-file": "^1.1.1", | 35 | "browser-md5-file": "^1.1.1", |
| 36 | "dayjs": "^1.11.13", | 36 | "dayjs": "^1.11.13", |
| 37 | + "html-to-image": "^1.11.13", | ||
| 37 | "html2canvas": "^1.4.1", | 38 | "html2canvas": "^1.4.1", |
| 38 | "lodash": "^4.17.21", | 39 | "lodash": "^4.17.21", |
| 39 | "pdf-vue3": "^1.0.12", | 40 | "pdf-vue3": "^1.0.12", | ... | ... |
| ... | @@ -47,6 +47,9 @@ importers: | ... | @@ -47,6 +47,9 @@ importers: |
| 47 | dayjs: | 47 | dayjs: |
| 48 | specifier: ^1.11.13 | 48 | specifier: ^1.11.13 |
| 49 | version: 1.11.19 | 49 | version: 1.11.19 |
| 50 | + html-to-image: | ||
| 51 | + specifier: ^1.11.13 | ||
| 52 | + version: 1.11.13 | ||
| 50 | html2canvas: | 53 | html2canvas: |
| 51 | specifier: ^1.4.1 | 54 | specifier: ^1.4.1 |
| 52 | version: 1.4.1 | 55 | version: 1.4.1 |
| ... | @@ -1167,6 +1170,9 @@ packages: | ... | @@ -1167,6 +1170,9 @@ packages: |
| 1167 | resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} | 1170 | resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} |
| 1168 | engines: {node: '>= 0.4'} | 1171 | engines: {node: '>= 0.4'} |
| 1169 | 1172 | ||
| 1173 | + html-to-image@1.11.13: | ||
| 1174 | + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} | ||
| 1175 | + | ||
| 1170 | html2canvas@1.4.1: | 1176 | html2canvas@1.4.1: |
| 1171 | resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} | 1177 | resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} |
| 1172 | engines: {node: '>=8.0.0'} | 1178 | engines: {node: '>=8.0.0'} |
| ... | @@ -3036,6 +3042,8 @@ snapshots: | ... | @@ -3036,6 +3042,8 @@ snapshots: |
| 3036 | dependencies: | 3042 | dependencies: |
| 3037 | function-bind: 1.1.2 | 3043 | function-bind: 1.1.2 |
| 3038 | 3044 | ||
| 3045 | + html-to-image@1.11.13: {} | ||
| 3046 | + | ||
| 3039 | html2canvas@1.4.1: | 3047 | html2canvas@1.4.1: |
| 3040 | dependencies: | 3048 | dependencies: |
| 3041 | css-line-break: 2.1.0 | 3049 | css-line-break: 2.1.0 | ... | ... |
| ... | @@ -13,22 +13,25 @@ | ... | @@ -13,22 +13,25 @@ |
| 13 | <van-icon name="cross" @click="close" /> | 13 | <van-icon name="cross" @click="close" /> |
| 14 | </div> | 14 | </div> |
| 15 | 15 | ||
| 16 | + <!-- 生成中提示 --> | ||
| 17 | + <div v-if="is_generating" class="text-center text-gray-500 text-sm mb-2">正在生成海报...</div> | ||
| 18 | + | ||
| 16 | <!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 --> | 19 | <!-- 海报区域:直接使用 Canvas 合成的图片,支持长按保存 --> |
| 17 | <!-- 当已生成海报图时,容器不再应用卡片边框与阴影,避免双重边框视觉效果;降级展示仍保留卡片样式 --> | 20 | <!-- 当已生成海报图时,容器不再应用卡片边框与阴影,避免双重边框视觉效果;降级展示仍保留卡片样式 --> |
| 18 | - <div :class="poster_img_src ? 'PosterCard mx-auto' : 'PosterCard bg-white rounded-xl shadow-md overflow-hidden mx-auto'" ref="card_ref"> | 21 | + <div :class="poster_img_src ? 'PosterCard mx-auto' : 'PosterCard bg-white rounded-xl overflow-hidden border border-gray-200 mx-auto'" ref="card_ref"> |
| 19 | <img v-if="poster_img_src" :src="poster_img_src" alt="分享海报" class="w-full h-auto object-contain block" /> | 22 | <img v-if="poster_img_src" :src="poster_img_src" alt="分享海报" class="w-full h-auto object-contain block" /> |
| 20 | <!-- 生成失败或尚未生成时的降级展示(可长按截图保存) --> | 23 | <!-- 生成失败或尚未生成时的降级展示(可长按截图保存) --> |
| 21 | <div v-else> | 24 | <div v-else> |
| 22 | <!-- 上部封面图 --> | 25 | <!-- 上部封面图 --> |
| 23 | <div class="PosterCover rounded-t-xl overflow-hidden"> | 26 | <div class="PosterCover rounded-t-xl overflow-hidden"> |
| 24 | - <img :src="cover_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" /> | 27 | + <img :src="cover_final_src" alt="课程封面" class="w-full h-auto object-contain" crossorigin="anonymous" /> |
| 25 | </div> | 28 | </div> |
| 26 | <!-- 下部信息区:左二维码 + 右文案 --> | 29 | <!-- 下部信息区:左二维码 + 右文案 --> |
| 27 | <div class="PosterInfo p-4"> | 30 | <div class="PosterInfo p-4"> |
| 28 | <div class="flex items-start"> | 31 | <div class="flex items-start"> |
| 29 | <!-- 左侧二维码 --> | 32 | <!-- 左侧二维码 --> |
| 30 | <div class="PosterQR mr-4"> | 33 | <div class="PosterQR mr-4"> |
| 31 | - <img :src="qr_src" alt="课程二维码" class="w-24 h-24 rounded" crossorigin="anonymous" /> | 34 | + <img :src="qr_final_src" alt="课程二维码" class="w-24 h-24 rounded" crossorigin="anonymous" /> |
| 32 | </div> | 35 | </div> |
| 33 | <!-- 右侧文案 --> | 36 | <!-- 右侧文案 --> |
| 34 | <div class="flex-1 flex flex-col space-y-2 -mt-1"> | 37 | <div class="flex-1 flex flex-col space-y-2 -mt-1"> |
| ... | @@ -53,7 +56,8 @@ | ... | @@ -53,7 +56,8 @@ |
| 53 | </template> | 56 | </template> |
| 54 | 57 | ||
| 55 | <script setup> | 58 | <script setup> |
| 56 | -import { ref, computed, watch, nextTick, onMounted } from 'vue' | 59 | +import { ref, computed, watch, nextTick } from 'vue' |
| 60 | +import { toPng } from 'html-to-image' | ||
| 57 | import QRCode from 'qrcode' | 61 | import QRCode from 'qrcode' |
| 58 | import { showToast } from 'vant' | 62 | import { showToast } from 'vant' |
| 59 | 63 | ||
| ... | @@ -78,11 +82,12 @@ function normalize_image_url(src) { | ... | @@ -78,11 +82,12 @@ function normalize_image_url(src) { |
| 78 | try { | 82 | try { |
| 79 | const u = new URL(url, window.location.origin) | 83 | const u = new URL(url, window.location.origin) |
| 80 | if (u.hostname === 'cdn.ipadbiz.cn') { | 84 | if (u.hostname === 'cdn.ipadbiz.cn') { |
| 81 | - // CDN 图片统一追加压缩参数,若已存在查询参数则使用 & 连接 | 85 | + // CDN 图片统一追加压缩参数,若地址未包含 imageMogr2,则追加压缩参数 |
| 82 | - // const has_mogr = url.includes('imageMogr2') | 86 | + const param = 'imageMogr2/thumbnail/200x/strip/quality/70' |
| 83 | - // if (!has_mogr) { | 87 | + const has_mogr = url.includes('imageMogr2') |
| 84 | - // return url + (url.includes('?') ? '&' : '?') + 'imageMogr2/thumbnail/200x/strip/quality/70' | 88 | + if (!has_mogr) { |
| 85 | - // } | 89 | + return url + (url.includes('?') ? '&' : '?') + param |
| 90 | + } | ||
| 86 | return url | 91 | return url |
| 87 | } | 92 | } |
| 88 | } catch (e) { | 93 | } catch (e) { |
| ... | @@ -126,6 +131,12 @@ const show_proxy = computed({ | ... | @@ -126,6 +131,12 @@ const show_proxy = computed({ |
| 126 | */ | 131 | */ |
| 127 | const card_ref = ref(null) | 132 | const card_ref = ref(null) |
| 128 | 133 | ||
| 134 | +/** | ||
| 135 | + * @var {import('vue').Ref<boolean>} is_generating | ||
| 136 | + * @description 是否处于海报生成中状态,用于在弹窗内展示“正在生成海报...”提示。 | ||
| 137 | + */ | ||
| 138 | +const is_generating = ref(false) | ||
| 139 | + | ||
| 129 | /** 标题/副标题/介绍 */ | 140 | /** 标题/副标题/介绍 */ |
| 130 | const title_text = computed(() => props.course?.title || '课程') | 141 | const title_text = computed(() => props.course?.title || '课程') |
| 131 | const subtitle_text = computed(() => props.course?.subtitle || '') | 142 | const subtitle_text = computed(() => props.course?.subtitle || '') |
| ... | @@ -176,6 +187,13 @@ const date_range_text = computed(() => { | ... | @@ -176,6 +187,13 @@ const date_range_text = computed(() => { |
| 176 | 187 | ||
| 177 | /** 封面图地址(含CDN压缩规则) */ | 188 | /** 封面图地址(含CDN压缩规则) */ |
| 178 | const cover_src = computed(() => normalize_image_url(props.course?.cover || '')) | 189 | const cover_src = computed(() => normalize_image_url(props.course?.cover || '')) |
| 190 | +/** | ||
| 191 | + * @var {import('vue').Ref<string>} cover_data_url | ||
| 192 | + * @description 封面图的 base64;优先用 base64 以避免跨域获取失败 | ||
| 193 | + */ | ||
| 194 | +const cover_data_url = ref('') | ||
| 195 | +/** 最终封面图地址:优先使用 base64,否则回退原地址 */ | ||
| 196 | +const cover_final_src = computed(() => cover_data_url.value || cover_src.value) | ||
| 179 | 197 | ||
| 180 | /** | 198 | /** |
| 181 | * @function build_qr_service_url | 199 | * @function build_qr_service_url |
| ... | @@ -199,316 +217,207 @@ const qr_src = computed(() => { | ... | @@ -199,316 +217,207 @@ const qr_src = computed(() => { |
| 199 | const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '') | 217 | const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '') |
| 200 | return build_qr_service_url(url) | 218 | return build_qr_service_url(url) |
| 201 | }) | 219 | }) |
| 220 | +/** | ||
| 221 | + * @var {import('vue').Ref<string>} qr_data_url | ||
| 222 | + * @description 本地生成的二维码 base64,避免跨域图片导致生成失败 | ||
| 223 | + */ | ||
| 224 | +const qr_data_url = ref('') | ||
| 225 | +/** 最终二维码地址:优先使用本地生成的 base64,否则回退服务端地址 */ | ||
| 226 | +const qr_final_src = computed(() => qr_data_url.value || qr_src.value) | ||
| 202 | 227 | ||
| 203 | // 海报图片 dataURL(用于长按保存) | 228 | // 海报图片 dataURL(用于长按保存) |
| 204 | const poster_img_src = ref('') | 229 | const poster_img_src = ref('') |
| 205 | 230 | ||
| 206 | /** | 231 | /** |
| 207 | - * @function load_image | 232 | + * @function try_fetch_to_data_url |
| 208 | - * @description 以匿名跨域方式加载图片并追加时间戳防缓存;失败返回 null。 | 233 | + * @description 尝试将图片地址转为 base64;跨域或失败时返回空串。 |
| 209 | - * @param {string} src 图片地址 | 234 | + * @param {string} url 图片地址 |
| 210 | - * @returns {Promise<HTMLImageElement|null>} 加载成功的图片对象或 null | 235 | + * @returns {Promise<string>} base64 dataURL 或空串 |
| 211 | */ | 236 | */ |
| 212 | -function load_image(src) { | 237 | +async function try_fetch_to_data_url(url) { |
| 213 | - return new Promise((resolve) => { | 238 | + if (!url) return '' |
| 214 | - if (!src) return resolve(null) | 239 | + try { |
| 215 | - const img = new Image() | 240 | + const res = await fetch(url, { mode: 'cors', cache: 'no-cache', credentials: 'omit' }) |
| 216 | - img.crossOrigin = 'anonymous' | 241 | + if (!res.ok) return '' |
| 217 | - const with_ts = src + (src.includes('?') ? '&' : '?') + 'v=' + Date.now() | 242 | + const blob = await res.blob() |
| 218 | - img.onload = () => resolve(img) | 243 | + return await new Promise((resolve) => { |
| 219 | - img.onerror = () => resolve(null) | 244 | + const reader = new FileReader() |
| 220 | - img.src = with_ts | 245 | + reader.onloadend = () => resolve(String(reader.result || '')) |
| 221 | - }) | 246 | + reader.onerror = () => resolve('') |
| 222 | -} | 247 | + reader.readAsDataURL(blob) |
| 223 | - | 248 | + }) |
| 224 | -/** | 249 | + } catch (e) { |
| 225 | - * @function wrap_text | 250 | + return '' |
| 226 | - * @description 按最大宽度自动换行并限制最大行数;溢出末行追加省略号。 | ||
| 227 | - * @param {CanvasRenderingContext2D} ctx 画布上下文 | ||
| 228 | - * @param {string} text 文本内容 | ||
| 229 | - * @param {number} max_w 最大宽度 | ||
| 230 | - * @param {string} font 字体样式(如 'bold 32px sans-serif') | ||
| 231 | - * @param {number} line_h 行高 | ||
| 232 | - * @param {number} max_lines 最大行数 | ||
| 233 | - * @returns {string[]} 处理后的行数组 | ||
| 234 | - */ | ||
| 235 | -function wrap_text(ctx, text, max_w, font, line_h, max_lines) { | ||
| 236 | - ctx.font = font | ||
| 237 | - const content = (text || '').trim() | ||
| 238 | - if (!content) return [] | ||
| 239 | - const lines = [] | ||
| 240 | - let cur = '' | ||
| 241 | - const tokens = Array.from(content) | ||
| 242 | - tokens.forEach(ch => { | ||
| 243 | - const test = cur + ch | ||
| 244 | - if (ctx.measureText(test).width <= max_w) { | ||
| 245 | - cur = test | ||
| 246 | - } else { | ||
| 247 | - lines.push(cur) | ||
| 248 | - cur = ch | ||
| 249 | - } | ||
| 250 | - }) | ||
| 251 | - if (cur) lines.push(cur) | ||
| 252 | - if (lines.length > max_lines) { | ||
| 253 | - const truncated = lines.slice(0, max_lines) | ||
| 254 | - const last = truncated[truncated.length - 1] | ||
| 255 | - truncated[truncated.length - 1] = (last || '').slice(0, Math.max(0, (last || '').length - 1)) + '…' | ||
| 256 | - return truncated | ||
| 257 | } | 251 | } |
| 258 | - return lines | ||
| 259 | } | 252 | } |
| 260 | 253 | ||
| 261 | /** | 254 | /** |
| 262 | - * @function rounded_rect_path | 255 | + * @function prepare_assets |
| 263 | - * @description 绘制圆角矩形路径(仅定义 path,不进行填充或描边)。 | 256 | + * @description 在截图前准备资源:封面尝试转 base64、二维码本地生成为 base64。 |
| 264 | - * @param {CanvasRenderingContext2D} ctx 画布上下文 | 257 | + * @returns {Promise<void>} |
| 265 | - * @param {number} x 起始x | ||
| 266 | - * @param {number} y 起始y | ||
| 267 | - * @param {number} w 宽度 | ||
| 268 | - * @param {number} h 高度 | ||
| 269 | - * @param {number} r 圆角半径 | ||
| 270 | - * @returns {void} | ||
| 271 | - */ | ||
| 272 | -function rounded_rect_path(ctx, x, y, w, h, r) { | ||
| 273 | - const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2)) | ||
| 274 | - ctx.beginPath() | ||
| 275 | - ctx.moveTo(x + rr, y) | ||
| 276 | - ctx.lineTo(x + w - rr, y) | ||
| 277 | - ctx.quadraticCurveTo(x + w, y, x + w, y + rr) | ||
| 278 | - ctx.lineTo(x + w, y + h - rr) | ||
| 279 | - ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h) | ||
| 280 | - ctx.lineTo(x + rr, y + h) | ||
| 281 | - ctx.quadraticCurveTo(x, y + h, x, y + h - rr) | ||
| 282 | - ctx.lineTo(x, y + rr) | ||
| 283 | - ctx.quadraticCurveTo(x, y, x + rr, y) | ||
| 284 | - ctx.closePath() | ||
| 285 | -} | ||
| 286 | - | ||
| 287 | -/** | ||
| 288 | - * @function rounded_top_rect_path | ||
| 289 | - * @description 绘制仅顶部两角为圆角、底部直角的矩形路径(用于封面区域裁剪)。 | ||
| 290 | - * @param {CanvasRenderingContext2D} ctx 画布上下文 | ||
| 291 | - * @param {number} x 起始x | ||
| 292 | - * @param {number} y 起始y | ||
| 293 | - * @param {number} w 宽度 | ||
| 294 | - * @param {number} h 高度 | ||
| 295 | - * @param {number} r 圆角半径(仅作用于顶部) | ||
| 296 | - * @returns {void} | ||
| 297 | */ | 258 | */ |
| 298 | -function rounded_top_rect_path(ctx, x, y, w, h, r) { | 259 | +async function prepare_assets() { |
| 299 | - const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2)) | 260 | + // 二维码本地生成 |
| 300 | - ctx.beginPath() | 261 | + try { |
| 301 | - // 顶部边 + 右上圆角 | 262 | + const url = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '') |
| 302 | - ctx.moveTo(x + rr, y) | 263 | + const data_url = await QRCode.toDataURL(url, { margin: 2, width: 256, color: { dark: '#000000', light: '#ffffff' } }) |
| 303 | - ctx.lineTo(x + w - rr, y) | 264 | + qr_data_url.value = data_url || '' |
| 304 | - ctx.quadraticCurveTo(x + w, y, x + w, y + rr) | 265 | + } catch (e) { |
| 305 | - // 右侧直边到底部直角 | 266 | + qr_data_url.value = '' |
| 306 | - ctx.lineTo(x + w, y + h) | 267 | + } |
| 307 | - // 底部直边到左下直角 | 268 | + // 封面尝试转 base64(若跨域失败则回退原地址) |
| 308 | - ctx.lineTo(x, y + h) | 269 | + try { |
| 309 | - // 左侧直边到左上圆角 | 270 | + const b64 = await try_fetch_to_data_url(cover_src.value) |
| 310 | - ctx.lineTo(x, y + rr) | 271 | + cover_data_url.value = b64 || '' |
| 311 | - ctx.quadraticCurveTo(x, y, x + rr, y) | 272 | + } catch (e) { |
| 312 | - ctx.closePath() | 273 | + cover_data_url.value = '' |
| 274 | + } | ||
| 313 | } | 275 | } |
| 314 | 276 | ||
| 315 | /** | 277 | /** |
| 316 | * @function compose_poster | 278 | * @function compose_poster |
| 317 | - * @description 以 Canvas 合成海报:上部封面、左侧二维码、右侧文本信息;生成 dataURL 供长按保存。 | 279 | + * @description 使用 html-to-image 对可视卡片 DOM 截图并生成 PNG 的 dataURL,仅在弹窗打开时触发。 |
| 318 | - * @returns {Promise<void>} | ||
| 319 | - */ | ||
| 320 | -/** | ||
| 321 | - * @function compose_poster | ||
| 322 | - * @description 以 Canvas 合成海报:底部仅渲染标题与副标题,右侧居中排版;生成 dataURL 供长按保存。 | ||
| 323 | * @returns {Promise<void>} | 280 | * @returns {Promise<void>} |
| 324 | */ | 281 | */ |
| 325 | async function compose_poster() { | 282 | async function compose_poster() { |
| 326 | poster_img_src.value = '' | 283 | poster_img_src.value = '' |
| 327 | try { | 284 | try { |
| 328 | - // 固定设计宽度,整体高度动态计算以避免封面留白 | 285 | + // 标记进入生成流程 |
| 329 | - const width = 750 | 286 | + is_generating.value = true |
| 330 | - const card_margin = 12 | 287 | + // 准备资源,尽量使用 base64 避免跨域失败 |
| 331 | - const card_x = card_margin | 288 | + await prepare_assets() |
| 332 | - const card_y = card_margin | 289 | + await nextTick() |
| 333 | - const card_w = width - card_margin * 2 | 290 | + const node = card_ref.value |
| 334 | - const card_radius = 16 | 291 | + if (!node) { |
| 335 | - | 292 | + is_generating.value = false |
| 336 | - // 预加载封面以获取真实宽高,按 contain(宽度占满)动态计算封面高度 | 293 | + return |
| 337 | - const cover_img = await load_image(cover_src.value) | ||
| 338 | - let cover_h = Math.round(card_w * 2 / 3) | ||
| 339 | - if (cover_img) { | ||
| 340 | - const sw = cover_img.width | ||
| 341 | - const sh = cover_img.height | ||
| 342 | - const scale_w = card_w / Math.max(1, sw) | ||
| 343 | - cover_h = Math.round(sh * scale_w) | ||
| 344 | - } | ||
| 345 | - | ||
| 346 | - // 文本与二维码区尺寸计算:根据内容动态确定信息区高度 | ||
| 347 | - const padding = 32 | ||
| 348 | - const qr_size = 160 | ||
| 349 | - | ||
| 350 | - const title_font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' | ||
| 351 | - const subtitle_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' | ||
| 352 | - const date_font = 'normal 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' | ||
| 353 | - const footnote_font = 'normal 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' | ||
| 354 | - | ||
| 355 | - const measurer = document.createElement('canvas') | ||
| 356 | - const mctx = measurer.getContext('2d') | ||
| 357 | - const text_max_w = card_w - padding * 2 - qr_size - 20 | ||
| 358 | - | ||
| 359 | - // 测量标题/副标题/日期,并在信息区顶部对齐排版(脚注单独贴底绘制) | ||
| 360 | - const title_lines = wrap_text(mctx, title_text.value, text_max_w, title_font, 44, 1) | ||
| 361 | - const subtitle_lines = subtitle_text.value ? wrap_text(mctx, subtitle_text.value, text_max_w, subtitle_font, 32, 1) : [] | ||
| 362 | - const date_lines = date_range_text.value ? wrap_text(mctx, date_range_text.value, text_max_w, date_font, 40, 1) : [] | ||
| 363 | - // 文本行间距:用于增大标题/副标题/日期的间隔 | ||
| 364 | - const line_gap = 10 | ||
| 365 | - const lines_count = (title_lines.length ? 1 : 0) + (subtitle_lines.length ? 1 : 0) + (date_lines.length ? 1 : 0) | ||
| 366 | - const gaps_count = Math.max(0, lines_count - 1) | ||
| 367 | - const total_text_h = (title_lines.length ? 44 : 0) | ||
| 368 | - + (subtitle_lines.length ? 32 : 0) | ||
| 369 | - + (date_lines.length ? 40 : 0) | ||
| 370 | - + gaps_count * line_gap | ||
| 371 | - const min_gap = 24 | ||
| 372 | - const footnote_line_h = 28 | ||
| 373 | - const info_h = padding * 2 + Math.max(qr_size, total_text_h + min_gap + footnote_line_h) | ||
| 374 | - const card_h = cover_h + info_h | ||
| 375 | - | ||
| 376 | - const text_offset_y = 0 | ||
| 377 | - const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)) | ||
| 378 | - const canvas = document.createElement('canvas') | ||
| 379 | - canvas.width = Math.round(width * dpr) | ||
| 380 | - canvas.height = Math.round((card_h + card_margin * 2) * dpr) | ||
| 381 | - const ctx = canvas.getContext('2d') | ||
| 382 | - ctx.scale(dpr, dpr) | ||
| 383 | - | ||
| 384 | - // 卡片阴影与背景(白色圆角卡片 + 灰色边框) | ||
| 385 | - ctx.save() | ||
| 386 | - rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius) | ||
| 387 | - ctx.shadowColor = 'rgba(0,0,0,0.12)' | ||
| 388 | - ctx.shadowBlur = 12 | ||
| 389 | - ctx.shadowOffsetX = 0 | ||
| 390 | - ctx.shadowOffsetY = 4 | ||
| 391 | - ctx.fillStyle = '#ffffff' | ||
| 392 | - ctx.fill() | ||
| 393 | - ctx.restore() | ||
| 394 | - | ||
| 395 | - // 卡片边框 | ||
| 396 | - ctx.save() | ||
| 397 | - rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius) | ||
| 398 | - ctx.strokeStyle = '#e5e7eb' // gray-200 | ||
| 399 | - ctx.lineWidth = 2 | ||
| 400 | - ctx.stroke() | ||
| 401 | - ctx.restore() | ||
| 402 | - | ||
| 403 | - // 卡片内容裁剪区域 | ||
| 404 | - ctx.save() | ||
| 405 | - rounded_rect_path(ctx, card_x, card_y, card_w, card_h, card_radius) | ||
| 406 | - ctx.clip() | ||
| 407 | - | ||
| 408 | - // 封面图:宽度占满,高度按等比缩放(无留白) | ||
| 409 | - if (cover_img) { | ||
| 410 | - ctx.save() | ||
| 411 | - ctx.beginPath() | ||
| 412 | - // 仅顶部圆角,底部直角,避免封面底部出现弧形 | ||
| 413 | - rounded_top_rect_path(ctx, card_x, card_y, card_w, cover_h, card_radius) | ||
| 414 | - ctx.clip() | ||
| 415 | - ctx.drawImage(cover_img, card_x, card_y, card_w, cover_h) | ||
| 416 | - ctx.restore() | ||
| 417 | - } else { | ||
| 418 | - ctx.fillStyle = '#f3f4f6' // gray-100 | ||
| 419 | - ctx.fillRect(card_x, card_y, card_w, cover_h) | ||
| 420 | - ctx.fillStyle = '#9ca3af' // gray-400 | ||
| 421 | - ctx.textAlign = 'center' | ||
| 422 | - ctx.font = 'normal 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"' | ||
| 423 | - ctx.fillText('封面加载失败', card_x + card_w / 2, card_y + cover_h / 2 + 10) | ||
| 424 | - ctx.textAlign = 'left' | ||
| 425 | - } | ||
| 426 | - | ||
| 427 | - // 二维码(本地生成避免跨域) | ||
| 428 | - const qr_canvas = document.createElement('canvas') | ||
| 429 | - qr_canvas.width = Math.round(qr_size * dpr) | ||
| 430 | - qr_canvas.height = Math.round(qr_size * dpr) | ||
| 431 | - const qr_url_val = props.qr_url || (typeof window !== 'undefined' ? window.location.href : '') | ||
| 432 | - try { | ||
| 433 | - await QRCode.toCanvas(qr_canvas, qr_url_val, { width: Math.round(qr_size * dpr), margin: Math.round(2 * dpr), color: { dark: '#000000', light: '#ffffff' } }) | ||
| 434 | - } catch (e) { | ||
| 435 | - const qctx = qr_canvas.getContext('2d') | ||
| 436 | - qctx.fillStyle = '#ffffff' | ||
| 437 | - qctx.fillRect(0, 0, Math.round(qr_size * dpr), Math.round(qr_size * dpr)) | ||
| 438 | - qctx.strokeStyle = '#e5e7eb' | ||
| 439 | - qctx.strokeRect(0, 0, Math.round(qr_size * dpr), Math.round(qr_size * dpr)) | ||
| 440 | - qctx.fillStyle = '#9ca3af' | ||
| 441 | - qctx.font = `${Math.round(16 * dpr)}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei"` | ||
| 442 | - qctx.fillText('二维码生成失败', Math.round(12 * dpr), Math.round((qr_size * dpr) / 2)) | ||
| 443 | - } | ||
| 444 | - ctx.drawImage(qr_canvas, card_x + padding, card_y + cover_h + padding, qr_size, qr_size) | ||
| 445 | - | ||
| 446 | - // 文案(右侧,顶部对齐) | ||
| 447 | - let tx = card_x + padding + qr_size + 20 | ||
| 448 | - let ty = card_y + cover_h + padding | ||
| 449 | - ctx.fillStyle = '#1f2937' // gray-800 | ||
| 450 | - ctx.font = title_font | ||
| 451 | - // 增加标题与后续文本的间距 | ||
| 452 | - title_lines.forEach(line => { ctx.fillText(line, tx, ty + 34); ty += 44 + line_gap }) | ||
| 453 | - | ||
| 454 | - ctx.fillStyle = '#6b7280' // gray-500 | ||
| 455 | - ctx.font = subtitle_font | ||
| 456 | - // 增加副标题与后续文本的间距 | ||
| 457 | - subtitle_lines.forEach(line => { ctx.fillText(line, tx, ty + 24); ty += 32 + line_gap }) | ||
| 458 | - | ||
| 459 | - ctx.fillStyle = '#9ca3af' // gray-400 | ||
| 460 | - ctx.font = date_font | ||
| 461 | - // 日期作为最后一行,不再额外叠加间距 | ||
| 462 | - date_lines.forEach(line => { ctx.fillText(line, tx, ty + 30); ty += 40 }) | ||
| 463 | - | ||
| 464 | - // 脚注:底部绿色提示文案(贴底显示) | ||
| 465 | - ctx.fillStyle = '#10b981' // green-500 | ||
| 466 | - ctx.font = footnote_font | ||
| 467 | - let foot_y = card_y + cover_h + info_h - padding - 14 | ||
| 468 | - const last_text_bottom_y = ty | ||
| 469 | - if (foot_y - last_text_bottom_y < min_gap) { | ||
| 470 | - foot_y = last_text_bottom_y + min_gap | ||
| 471 | - } | ||
| 472 | - ctx.fillText('扫码了解详情', tx, foot_y) | ||
| 473 | - | ||
| 474 | - // 恢复裁剪 | ||
| 475 | - ctx.restore() | ||
| 476 | - | ||
| 477 | - // 生成 dataURL | ||
| 478 | - try { | ||
| 479 | - const data_url = canvas.toDataURL('image/png') | ||
| 480 | - poster_img_src.value = data_url | ||
| 481 | - } catch (e) { | ||
| 482 | - console.error('海报生成失败(跨域):', e) | ||
| 483 | - showToast('海报生成失败,已展示标准卡片,请长按保存截图') | ||
| 484 | - poster_img_src.value = '' | ||
| 485 | } | 294 | } |
| 295 | + // 等待容器内图片加载完成,避免首次截图丢失封面 | ||
| 296 | + await wait_images_loaded(node) | ||
| 297 | + // 轻微延迟,确保弹窗过渡动画结束,布局稳定 | ||
| 298 | + await new Promise(r => setTimeout(r, 80)) | ||
| 299 | + // 克隆离屏节点,避免弹窗动画与布局抖动影响截图 | ||
| 300 | + const { wrapper, clone, size } = create_offscreen_clone(node) | ||
| 301 | + await wait_images_loaded(clone, 1200) | ||
| 302 | + // 设置像素比例,提升清晰度(在高分屏上更清晰) | ||
| 303 | + const pixel_ratio = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1)) | ||
| 304 | + // 透明 1x1 PNG 作为占位图,避免资源获取失败直接中断 | ||
| 305 | + const placeholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wm3T9kAAAAASUVORK5CYII=' | ||
| 306 | + // 生成 PNG dataURL | ||
| 307 | + const data_url = await toPng(clone, { | ||
| 308 | + pixelRatio: pixel_ratio, | ||
| 309 | + cacheBust: true, | ||
| 310 | + backgroundColor: '#ffffff', | ||
| 311 | + imagePlaceholder: placeholder, | ||
| 312 | + fetchRequestInit: { mode: 'cors', cache: 'no-cache', credentials: 'omit' }, | ||
| 313 | + // 避免外层 margin 影响截图结果 | ||
| 314 | + style: { margin: '0' }, | ||
| 315 | + width: Math.round(size.width), | ||
| 316 | + height: Math.round(size.height) | ||
| 317 | + }) | ||
| 318 | + poster_img_src.value = data_url | ||
| 319 | + // 清理离屏节点 | ||
| 320 | + if (wrapper && wrapper.parentNode) wrapper.parentNode.removeChild(wrapper) | ||
| 321 | + is_generating.value = false | ||
| 486 | } catch (err) { | 322 | } catch (err) { |
| 487 | - console.error('compose_poster 异常:', err) | 323 | + console.error('html-to-image 生成海报失败:', err) |
| 488 | - showToast('海报生成失败,请稍后重试') | 324 | + showToast('海报生成失败,已展示标准卡片,请长按保存截图') |
| 489 | poster_img_src.value = '' | 325 | poster_img_src.value = '' |
| 326 | + is_generating.value = false | ||
| 490 | } | 327 | } |
| 491 | } | 328 | } |
| 492 | 329 | ||
| 493 | /** | 330 | /** |
| 494 | - * @function init_once | 331 | + * @function create_offscreen_clone |
| 495 | - * @description 组件挂载时生成一次海报,避免因弹框开关导致重复计算与变形。 | 332 | + * @description 创建一个离屏的卡片克隆用于稳定截图,避免受弹窗动画与布局影响。 |
| 333 | + * @param {HTMLElement} node 原始容器节点 | ||
| 334 | + * @returns {{wrapper: HTMLElement, clone: HTMLElement, size: {width: number, height: number}}} | ||
| 335 | + */ | ||
| 336 | +function create_offscreen_clone(node) { | ||
| 337 | + const rect = node.getBoundingClientRect() | ||
| 338 | + const size = { width: rect.width, height: rect.height } | ||
| 339 | + const wrapper = document.createElement('div') | ||
| 340 | + const clone = node.cloneNode(true) | ||
| 341 | + // 离屏包裹容器样式 | ||
| 342 | + Object.assign(wrapper.style, { | ||
| 343 | + position: 'fixed', | ||
| 344 | + left: '-99999px', | ||
| 345 | + top: '-99999px', | ||
| 346 | + width: `${Math.round(size.width)}px`, | ||
| 347 | + height: `${Math.round(size.height)}px`, | ||
| 348 | + overflow: 'hidden', | ||
| 349 | + opacity: '0', | ||
| 350 | + pointerEvents: 'none', | ||
| 351 | + background: '#ffffff', | ||
| 352 | + zIndex: '-1000' | ||
| 353 | + }) | ||
| 354 | + // 克隆节点样式校正,避免动画、阴影、滤镜干扰 | ||
| 355 | + Object.assign(clone.style, { | ||
| 356 | + width: '100%', | ||
| 357 | + height: '100%', | ||
| 358 | + margin: '0', | ||
| 359 | + transform: 'none', | ||
| 360 | + filter: 'none', | ||
| 361 | + boxShadow: 'none' | ||
| 362 | + }) | ||
| 363 | + wrapper.appendChild(clone) | ||
| 364 | + document.body.appendChild(wrapper) | ||
| 365 | + return { wrapper, clone, size } | ||
| 366 | +} | ||
| 367 | + | ||
| 368 | +/** | ||
| 369 | + * @function watch_open_and_generate | ||
| 370 | + * @description 仅在弹窗打开时开始生成海报;关闭不生成。每次打开都重新生成以保证信息最新。 | ||
| 496 | * @returns {void} | 371 | * @returns {void} |
| 497 | */ | 372 | */ |
| 498 | -onMounted(() => { | 373 | +watch(show_proxy, (opened) => { |
| 499 | - if (!poster_img_src.value) { | 374 | + if (opened) { |
| 375 | + if (is_generating.value) return | ||
| 376 | + poster_img_src.value = '' | ||
| 500 | nextTick(() => compose_poster()) | 377 | nextTick(() => compose_poster()) |
| 501 | } | 378 | } |
| 502 | }) | 379 | }) |
| 503 | 380 | ||
| 504 | /** | 381 | /** |
| 505 | * @function recompose_on_data_change | 382 | * @function recompose_on_data_change |
| 506 | - * @description 仅当课程数据或二维码地址发生变化时重新合成海报;避免因弹窗显隐导致的重复计算。 | 383 | + * @description 当弹窗处于打开状态且课程数据或二维码地址发生变化时重新合成海报;弹窗关闭时不生成。 |
| 507 | * @returns {void} | 384 | * @returns {void} |
| 508 | */ | 385 | */ |
| 509 | -watch(() => [props.course, props.qr_url], () => { | 386 | +// 已移除对象引用监听,改为监听具体字段(见下方) |
| 510 | - nextTick(() => compose_poster()) | 387 | + |
| 511 | -}, { deep: false }) | 388 | +/** |
| 389 | + * @function wait_images_loaded | ||
| 390 | + * @description 等待卡片容器中的图片加载完成(或超时),避免首次截图遗漏封面。 | ||
| 391 | + * @param {HTMLElement} node 容器节点 | ||
| 392 | + * @param {number} timeout 超时时间毫秒 | ||
| 393 | + * @returns {Promise<void>} | ||
| 394 | + */ | ||
| 395 | +async function wait_images_loaded(node, timeout = 2500) { | ||
| 396 | + try { | ||
| 397 | + if (!node) return | ||
| 398 | + const imgs = Array.from(node.querySelectorAll('img')) | ||
| 399 | + if (!imgs.length) return | ||
| 400 | + await Promise.all(imgs.map(img => new Promise((resolve) => { | ||
| 401 | + if (img.complete) return resolve() | ||
| 402 | + let done = false | ||
| 403 | + const finish = () => { if (!done) { done = true; resolve() } } | ||
| 404 | + img.addEventListener('load', finish, { once: true }) | ||
| 405 | + img.addEventListener('error', finish, { once: true }) | ||
| 406 | + setTimeout(finish, timeout) | ||
| 407 | + }))) | ||
| 408 | + } catch (_) { | ||
| 409 | + // 忽略加载异常,继续生成 | ||
| 410 | + } | ||
| 411 | +} | ||
| 412 | + | ||
| 413 | +// 调整数据变更监听:仅在封面地址或二维码地址变化时重新生成 | ||
| 414 | +watch([() => props.course?.cover, () => props.qr_url], () => { | ||
| 415 | + if (show_proxy.value) { | ||
| 416 | + if (is_generating.value) return | ||
| 417 | + poster_img_src.value = '' | ||
| 418 | + nextTick(() => compose_poster()) | ||
| 419 | + } | ||
| 420 | +}) | ||
| 512 | </script> | 421 | </script> |
| 513 | 422 | ||
| 514 | <style lang="less" scoped> | 423 | <style lang="less" scoped> |
| ... | @@ -539,7 +448,7 @@ watch(() => [props.course, props.qr_url], () => { | ... | @@ -539,7 +448,7 @@ watch(() => [props.course, props.qr_url], () => { |
| 539 | overflow: hidden; | 448 | overflow: hidden; |
| 540 | .PosterQR { | 449 | .PosterQR { |
| 541 | img { | 450 | img { |
| 542 | - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); | 451 | + box-shadow: none; |
| 543 | } | 452 | } |
| 544 | } | 453 | } |
| 545 | } | 454 | } | ... | ... |
-
Please register or login to post a comment