diff --git a/frontend_miniprogram/miniprogram/components/action-dock/action-dock.wxml b/frontend_miniprogram/miniprogram/components/action-dock/action-dock.wxml index 323805d..1e2fad1 100644 --- a/frontend_miniprogram/miniprogram/components/action-dock/action-dock.wxml +++ b/frontend_miniprogram/miniprogram/components/action-dock/action-dock.wxml @@ -1,4 +1,12 @@ + + + 律所简介 + + + + 律所导航 + 收下名片 @@ -7,8 +15,4 @@ 分享名片 - - - 律所导航 - diff --git a/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.less b/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.less index a885526..31cf5c9 100644 --- a/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.less +++ b/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.less @@ -175,12 +175,6 @@ white-space: pre-wrap; } -.bio-scroll { - height: 360rpx; - box-sizing: border-box; - padding-right: 8rpx; -} - .firm-mini-card { background: #fff; padding: var(--spacing-md); @@ -232,6 +226,16 @@ height: 160rpx; } +.share-poster-canvas { + position: fixed; + left: -9999px; + top: -9999px; + width: 500px; + height: 400px; + opacity: 0; + pointer-events: none; +} + .not-found-action { padding: var(--spacing-lg); text-align: center; diff --git a/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.ts b/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.ts index 75dc02b..87a4f34 100644 --- a/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.ts +++ b/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.ts @@ -1,7 +1,38 @@ -import { getLawyerDetail, recordCardShare } from '../../api/open'; -import type { FirmInfo } from '../../types/card'; -import type { Lawyer } from '../../types/card'; +import { getFirmProfile, getLawyerDetail, recordCardShare } from '../../api/open'; +import type { FirmInfo, Lawyer } from '../../types/card'; import { appendCardViewRecord } from '../../utils/history'; +import { generateSharePoster, SHARE_POSTER_CANVAS_ID } from '../../utils/share-poster'; + +interface DetailPageData { + firm: FirmInfo; + lawyer: Lawyer | null; + notFound: boolean; + specialtiesText: string; + detailBgStyle: string; + loading: boolean; + sharePosterPath: string; +} + +interface DetailPageCustom { + handleDockAction(event: WechatMiniprogram.CustomEvent<{ action: string }>): void; + saveContact(): void; + callPhone(): void; + navigateOffice(): void; + previewWechatQr(): void; + goFirmProfile(): void; + goLawyerList(): void; + getSharePosterCanvas(): Promise; + loadFirmForShare(): Promise; + prepareSharePoster(): void; + ensureSharePoster(): Promise; +} + +type DetailPageInstance = WechatMiniprogram.Page.Instance; + +const sharePosterPromiseMap = new WeakMap>(); +const shareFirmPromiseMap = new WeakMap>(); +const shareCanvasPromiseMap = new WeakMap>(); +const pageReadyMap = new WeakMap(); function createEmptyFirm(): FirmInfo { return { @@ -29,29 +60,24 @@ function buildDetailBgStyle(coverImage?: string): string { return `background-image: url("${cover}"); background-size: cover; background-position: center;`; } -function shouldUseBioScroll(bio?: string): boolean { - const text = typeof bio === 'string' ? bio.trim() : ''; - return text.length > 180; -} - -Page({ +Page({ data: { firm: createEmptyFirm(), - lawyer: null as Lawyer | null, + lawyer: null, notFound: false, specialtiesText: '', detailBgStyle: '', - bioScrollable: false, loading: false, + sharePosterPath: '', }, - async onLoad(options: Record) { + async onLoad(options) { const lawyerId = typeof options.id === 'string' ? options.id : ''; const sourceType = typeof options.sourceType === 'string' ? options.sourceType : 'DIRECT'; const shareFromCardId = typeof options.shareFromCardId === 'string' ? options.shareFromCardId : ''; if (!lawyerId) { - this.setData({ notFound: true, specialtiesText: '', detailBgStyle: '', bioScrollable: false }); + this.setData({ notFound: true, specialtiesText: '', detailBgStyle: '' }); return; } @@ -64,13 +90,13 @@ Page({ notFound: false, specialtiesText: payload.lawyer.specialties.join(';'), detailBgStyle: buildDetailBgStyle(payload.lawyer.coverImage), - bioScrollable: shouldUseBioScroll(payload.lawyer.bio), }); appendCardViewRecord(payload.lawyer); wx.showShareMenu({ menus: ['shareAppMessage'] }); + this.prepareSharePoster(); } catch (error) { const message = error instanceof Error ? error.message : '律师信息不存在'; - this.setData({ notFound: true, specialtiesText: '', detailBgStyle: '', bioScrollable: false }); + this.setData({ notFound: true, specialtiesText: '', detailBgStyle: '' }); wx.showToast({ title: message, icon: 'none', @@ -80,9 +106,24 @@ Page({ } }, - handleDockAction(event: WechatMiniprogram.CustomEvent<{ action: string }>) { + onReady() { + pageReadyMap.set(this, true); + this.prepareSharePoster(); + }, + + onUnload() { + pageReadyMap.delete(this); + sharePosterPromiseMap.delete(this); + shareFirmPromiseMap.delete(this); + shareCanvasPromiseMap.delete(this); + }, + + handleDockAction(event) { const action = event.detail.action; switch (action) { + case 'home': + this.goFirmProfile(); + break; case 'save': this.saveContact(); break; @@ -137,6 +178,7 @@ Page({ wx.showToast({ title: '暂未配置地图位置', icon: 'none' }); return; } + wx.openLocation({ latitude: this.data.firm.hqLatitude, longitude: this.data.firm.hqLongitude, @@ -155,21 +197,166 @@ Page({ wx.showToast({ title: '二维码未配置', icon: 'none' }); return; } + wx.previewImage({ current: url, urls: [url], }); }, + goFirmProfile() { + wx.navigateTo({ url: '/pages/firm/index' }); + }, + goLawyerList() { wx.navigateTo({ url: '/pages/lawyer-list/index' }); }, - onShareAppMessage() { + async getSharePosterCanvas() { + const cachedPromise = shareCanvasPromiseMap.get(this); + if (cachedPromise) { + return cachedPromise; + } + + const canvasPromise = new Promise((resolve, reject) => { + const query = wx.createSelectorQuery(); + query + .in(this) + .select(`#${SHARE_POSTER_CANVAS_ID}`) + .node((result) => { + const canvas = result && 'node' in result ? (result.node as WechatMiniprogram.Canvas | undefined) : undefined; + if (canvas) { + resolve(canvas); + return; + } + reject(new Error('分享海报画布未就绪')); + }) + .exec(); + }).then( + (canvas) => { + shareCanvasPromiseMap.delete(this); + return canvas; + }, + (error: unknown) => { + shareCanvasPromiseMap.delete(this); + throw error; + } + ); + + shareCanvasPromiseMap.set(this, canvasPromise); + return canvasPromise; + }, + + async loadFirmForShare() { + const cachedPromise = shareFirmPromiseMap.get(this); + if (cachedPromise) { + return cachedPromise; + } + + const currentFirm = this.data.firm; + if (currentFirm.logo) { + return currentFirm; + } + + const firmPromise = getFirmProfile() + .then((firmProfile) => ({ + ...currentFirm, + ...firmProfile, + name: firmProfile.name || currentFirm.name, + hqAddress: currentFirm.hqAddress || firmProfile.hqAddress, + })) + .catch(() => currentFirm); + + const firmPromiseWithCleanup = firmPromise.then( + (result) => { + shareFirmPromiseMap.delete(this); + return result; + }, + (error: unknown) => { + shareFirmPromiseMap.delete(this); + throw error; + } + ); + + shareFirmPromiseMap.set(this, firmPromiseWithCleanup); + return firmPromiseWithCleanup; + }, + + prepareSharePoster() { + if (!pageReadyMap.get(this) || !this.data.lawyer || this.data.notFound) { + return; + } + + this.ensureSharePoster().catch(() => { + // 海报生成失败时使用默认头像兜底 + }); + }, + + async ensureSharePoster() { + if (this.data.sharePosterPath) { + return this.data.sharePosterPath; + } + + const currentPromise = sharePosterPromiseMap.get(this); + if (currentPromise) { + return currentPromise; + } + const lawyer = this.data.lawyer; + if (!lawyer) { + throw new Error('名片数据未就绪'); + } + + const posterPromise = this.loadFirmForShare() + .then((firm) => + this.getSharePosterCanvas().then((canvas) => + generateSharePoster({ + canvas, + firm: { + name: firm.name, + logo: firm.logo, + hqAddress: firm.hqAddress, + }, + lawyer: { + name: lawyer.name, + title: lawyer.title, + office: lawyer.office, + phone: lawyer.phone, + email: lawyer.email, + address: lawyer.address, + avatar: lawyer.avatar, + }, + }) + ) + ) + .then((posterPath) => { + if (posterPath) { + this.setData({ sharePosterPath: posterPath }); + } + return posterPath; + }); + + const posterPromiseWithCleanup = posterPromise.then( + (result) => { + sharePosterPromiseMap.delete(this); + return result; + }, + (error: unknown) => { + sharePosterPromiseMap.delete(this); + throw error; + } + ); + + sharePosterPromiseMap.set(this, posterPromiseWithCleanup); + return posterPromiseWithCleanup; + }, + + onShareAppMessage() { + const lawyer = this.data.lawyer; + const firmName = this.data.firm.name || '电子名片'; if (!lawyer) { return { - title: `${this.data.firm.name || '电子名片'}电子名片`, + title: firmName, path: '/pages/firm/index', }; } @@ -179,10 +366,33 @@ Page({ // 分享埋点失败不阻断分享动作 }); + const title = firmName; + const fallbackImage = lawyer.avatar; + if (this.data.sharePosterPath) { + return { + title, + path: sharePath, + imageUrl: this.data.sharePosterPath, + }; + } + + const promise = this.ensureSharePoster() + .then((posterPath) => ({ + title, + path: sharePath, + imageUrl: posterPath || fallbackImage, + })) + .catch(() => ({ + title, + path: sharePath, + imageUrl: fallbackImage, + })); + return { - title: `${lawyer.name}律师电子名片`, + title, path: sharePath, - imageUrl: lawyer.avatar, + imageUrl: fallbackImage, + promise, }; }, }); diff --git a/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.wxml b/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.wxml index 461e199..0934b2d 100644 --- a/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.wxml +++ b/frontend_miniprogram/miniprogram/pages/lawyer-detail/index.wxml @@ -61,10 +61,7 @@ 个人简介 - - {{lawyer.bio}} - - {{lawyer.bio}} + {{lawyer.bio}} @@ -73,6 +70,15 @@ + + diff --git a/frontend_miniprogram/miniprogram/utils/share-poster.ts b/frontend_miniprogram/miniprogram/utils/share-poster.ts new file mode 100644 index 0000000..8adf4ee --- /dev/null +++ b/frontend_miniprogram/miniprogram/utils/share-poster.ts @@ -0,0 +1,477 @@ +import type { FirmInfo, Lawyer } from '../types/card'; + +const POSTER_WIDTH = 500; +const POSTER_HEIGHT = 400; +const EXPORT_SCALE = 2; +const EXPORT_WIDTH = POSTER_WIDTH * EXPORT_SCALE; +const EXPORT_HEIGHT = POSTER_HEIGHT * EXPORT_SCALE; +const BACKGROUND_COLOR = '#f4f0ee'; +const PRIMARY_COLOR = '#8E2230'; +const PRIMARY_DARK = '#5C0D15'; +const TEXT_MAIN = '#1A1A1A'; + +type CanvasNode = WechatMiniprogram.Canvas; +type Canvas2DContext = any; +type CanvasImage = any; + +export const SHARE_POSTER_CANVAS_ID = 'sharePosterCanvas'; + +export interface SharePosterPayload { + canvas: CanvasNode; + firm: Pick; + lawyer: Pick; +} + +interface PosterAssets { + avatarImage: CanvasImage | null; + logoImage: CanvasImage | null; +} + +interface ContactRow { + kind: 'phone' | 'email' | 'address'; + value: string; + maxLines: number; +} + +interface MultilineTextOptions { + x: number; + y: number; + maxWidth: number; + lineHeight: number; + maxLines: number; + color: string; + font: string; +} + +function normalizeText(value: string | undefined | null): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function buildBrandInitial(name: string): string { + const normalizedName = normalizeText(name); + return normalizedName ? normalizedName.slice(0, 1) : '律'; +} + +function buildContactRows(payload: SharePosterPayload): ContactRow[] { + const rows: ContactRow[] = []; + const phone = normalizeText(payload.lawyer.phone); + const email = normalizeText(payload.lawyer.email); + const address = normalizeText(payload.lawyer.address) || normalizeText(payload.firm.hqAddress); + + if (phone) { + rows.push({ kind: 'phone', value: phone, maxLines: 1 }); + } + if (email) { + rows.push({ kind: 'email', value: email, maxLines: 1 }); + } + if (address) { + rows.push({ kind: 'address', value: address, maxLines: 2 }); + } + + return rows; +} + +function getImageInfo(src: string): Promise { + const normalizedSrc = normalizeText(src); + if (!normalizedSrc) { + return Promise.resolve(''); + } + + return new Promise((resolve) => { + wx.getImageInfo({ + src: normalizedSrc, + success: (result) => { + resolve(result.path || normalizedSrc); + }, + fail: () => { + resolve(''); + }, + }); + }); +} + +function loadCanvasImage(canvas: CanvasNode, src: string): Promise { + const normalizedSrc = normalizeText(src); + if (!normalizedSrc) { + return Promise.resolve(null); + } + + return new Promise((resolve) => { + const image = canvas.createImage(); + image.onload = () => resolve(image); + image.onerror = () => resolve(null); + image.src = normalizedSrc; + }); +} + +async function preloadAssets(payload: SharePosterPayload): Promise { + const [avatarPath, logoPath] = await Promise.all([ + getImageInfo(payload.lawyer.avatar), + getImageInfo(payload.firm.logo), + ]); + const [avatarImage, logoImage] = await Promise.all([ + loadCanvasImage(payload.canvas, avatarPath), + loadCanvasImage(payload.canvas, logoPath), + ]); + + return { + avatarImage, + logoImage, + }; +} + +function createRoundedRectPath( + ctx: Canvas2DContext, + x: number, + y: number, + width: number, + height: number, + radius: number +) { + const safeRadius = Math.min(radius, width / 2, height / 2); + ctx.beginPath(); + ctx.moveTo(x + safeRadius, y); + ctx.lineTo(x + width - safeRadius, y); + ctx.arcTo(x + width, y, x + width, y + safeRadius, safeRadius); + ctx.lineTo(x + width, y + height - safeRadius); + ctx.arcTo(x + width, y + height, x + width - safeRadius, y + height, safeRadius); + ctx.lineTo(x + safeRadius, y + height); + ctx.arcTo(x, y + height, x, y + height - safeRadius, safeRadius); + ctx.lineTo(x, y + safeRadius); + ctx.arcTo(x, y, x + safeRadius, y, safeRadius); + ctx.closePath(); +} + +function fillRoundedRect( + ctx: Canvas2DContext, + x: number, + y: number, + width: number, + height: number, + radius: number, + color: string +) { + createRoundedRectPath(ctx, x, y, width, height, radius); + ctx.fillStyle = color; + ctx.fill(); +} + +function clipRoundedImage( + ctx: Canvas2DContext, + image: CanvasImage, + x: number, + y: number, + width: number, + height: number, + radius: number +) { + ctx.save(); + createRoundedRectPath(ctx, x, y, width, height, radius); + ctx.clip(); + ctx.drawImage(image, x, y, width, height); + ctx.restore(); +} + +function drawPhoneIcon(ctx: Canvas2DContext, x: number, y: number) { + ctx.save(); + ctx.strokeStyle = PRIMARY_DARK; + ctx.lineWidth = 1.8; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(x + 5, y + 8); + ctx.quadraticCurveTo(x + 4, y + 10, x + 6, y + 13); + ctx.lineTo(x + 9, y + 16); + ctx.quadraticCurveTo(x + 11, y + 18, x + 13, y + 17); + ctx.lineTo(x + 15, y + 15); + ctx.quadraticCurveTo(x + 16.5, y + 13.5, x + 15.5, y + 12); + ctx.lineTo(x + 13.5, y + 10); + ctx.quadraticCurveTo(x + 12, y + 8.5, x + 10.5, y + 9.5); + ctx.lineTo(x + 9.5, y + 10.5); + ctx.quadraticCurveTo(x + 8.5, y + 11.5, x + 7.5, y + 10.5); + ctx.lineTo(x + 6.5, y + 9.5); + ctx.quadraticCurveTo(x + 5.5, y + 8.5, x + 6.5, y + 7.5); + ctx.lineTo(x + 7.5, y + 6.5); + ctx.quadraticCurveTo(x + 8.5, y + 5, x + 7, y + 3.5); + ctx.lineTo(x + 5, y + 1.5); + ctx.quadraticCurveTo(x + 3.5, y, x + 2.5, y + 2); + ctx.lineTo(x + 1.5, y + 4); + ctx.quadraticCurveTo(x + 0.5, y + 6, x + 2.5, y + 7.5); + ctx.stroke(); + ctx.restore(); +} + +function drawEmailIcon(ctx: Canvas2DContext, x: number, y: number) { + ctx.save(); + ctx.strokeStyle = PRIMARY_DARK; + ctx.lineWidth = 1.6; + ctx.lineJoin = 'round'; + createRoundedRectPath(ctx, x + 1, y + 3, 18, 14, 4); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x + 3.5, y + 5.5); + ctx.lineTo(x + 10, y + 11); + ctx.lineTo(x + 16.5, y + 5.5); + ctx.stroke(); + ctx.restore(); +} + +function drawAddressIcon(ctx: Canvas2DContext, x: number, y: number) { + ctx.save(); + ctx.strokeStyle = PRIMARY_DARK; + ctx.lineWidth = 1.6; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(x + 10, y + 18); + ctx.bezierCurveTo(x + 15, y + 12.5, x + 17, y + 9.5, x + 17, y + 7); + ctx.arc(x + 10, y + 7, 7, 0, Math.PI, true); + ctx.bezierCurveTo(x + 3, y + 9.5, x + 5, y + 12.5, x + 10, y + 18); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(x + 10, y + 7, 2.3, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); +} + +function drawContactIcon(ctx: Canvas2DContext, kind: ContactRow['kind'], x: number, y: number) { + fillRoundedRect(ctx, x, y, 28, 28, 14, 'rgba(142,34,48,0.07)'); + if (kind === 'phone') { + drawPhoneIcon(ctx, x + 6, y + 5); + return; + } + if (kind === 'email') { + drawEmailIcon(ctx, x + 4, y + 4); + return; + } + drawAddressIcon(ctx, x + 4, y + 4); +} + +function buildWrappedLines( + ctx: Canvas2DContext, + text: string, + maxWidth: number, + maxLines: number +): { lines: string[]; truncated: boolean } { + const normalizedText = normalizeText(text); + if (!normalizedText) { + return { lines: [], truncated: false }; + } + + const paragraphs = normalizedText.split(/\n+/).filter(Boolean); + const lines: string[] = []; + let truncated = false; + + for (let paragraphIndex = 0; paragraphIndex < paragraphs.length; paragraphIndex += 1) { + const paragraph = paragraphs[paragraphIndex]; + let currentLine = ''; + + for (let index = 0; index < paragraph.length; index += 1) { + const nextLine = `${currentLine}${paragraph[index]}`; + if (ctx.measureText(nextLine).width <= maxWidth) { + currentLine = nextLine; + continue; + } + + if (currentLine) { + lines.push(currentLine); + } + currentLine = paragraph[index]; + + if (lines.length >= maxLines) { + truncated = true; + break; + } + } + + if (lines.length >= maxLines) { + truncated = true; + break; + } + + if (currentLine) { + lines.push(currentLine); + } + } + + const finalLines = lines.slice(0, maxLines); + if (truncated && finalLines.length > 0) { + let lastLine = finalLines[finalLines.length - 1]; + while (lastLine && ctx.measureText(`${lastLine}...`).width > maxWidth) { + lastLine = lastLine.slice(0, -1); + } + finalLines[finalLines.length - 1] = `${lastLine}...`; + } + + return { lines: finalLines, truncated }; +} + +function drawMultilineText( + ctx: Canvas2DContext, + text: string, + options: MultilineTextOptions +): number { + const normalizedText = normalizeText(text); + if (!normalizedText) { + return 0; + } + + ctx.fillStyle = options.color; + ctx.font = options.font; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + const { lines: finalLines } = buildWrappedLines(ctx, normalizedText, options.maxWidth, options.maxLines); + + finalLines.forEach((line, index) => { + ctx.fillText(line, options.x, options.y + index * options.lineHeight); + }); + + return finalLines.length * options.lineHeight; +} + +function drawBackground(ctx: Canvas2DContext) { + const gradient = ctx.createLinearGradient(0, 0, POSTER_WIDTH, POSTER_HEIGHT); + gradient.addColorStop(0, '#fbf8f6'); + gradient.addColorStop(1, BACKGROUND_COLOR); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, POSTER_WIDTH, POSTER_HEIGHT); + + ctx.save(); + ctx.globalAlpha = 0.14; + const ribbonGradient = ctx.createLinearGradient(80, 0, 360, 240); + ribbonGradient.addColorStop(0, '#c9929b'); + ribbonGradient.addColorStop(1, '#8E2230'); + ctx.fillStyle = ribbonGradient; + ctx.beginPath(); + ctx.moveTo(220, 0); + ctx.lineTo(POSTER_WIDTH, 0); + ctx.lineTo(POSTER_WIDTH, 180); + ctx.lineTo(340, 280); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + + ctx.save(); + ctx.globalAlpha = 0.08; + ctx.fillStyle = PRIMARY_DARK; + ctx.beginPath(); + ctx.moveTo(0, 220); + ctx.lineTo(110, 150); + ctx.lineTo(180, 190); + ctx.lineTo(0, 340); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} + +function drawHeaderSection(ctx: Canvas2DContext, payload: SharePosterPayload, assets: PosterAssets): number { + const avatarX = 46; + const avatarY = 62; + const avatarSize = 108; + + if (assets.avatarImage) { + clipRoundedImage(ctx, assets.avatarImage, avatarX, avatarY, avatarSize, avatarSize, 20); + } else { + fillRoundedRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 20, 'rgba(142,34,48,0.12)'); + ctx.fillStyle = PRIMARY_COLOR; + ctx.font = '600 28px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(buildBrandInitial(payload.lawyer.name), avatarX + avatarSize / 2, avatarY + avatarSize / 2); + } + + const infoX = 176; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillStyle = TEXT_MAIN; + ctx.font = '600 35px sans-serif'; + ctx.fillText(payload.lawyer.name || '未命名律师', infoX, 80); + + ctx.fillStyle = PRIMARY_COLOR; + ctx.font = '600 20px sans-serif'; + ctx.fillText(payload.lawyer.title || '律师', infoX, 128); + + return 226; +} + +function drawContactRows(ctx: Canvas2DContext, rows: ContactRow[], startY: number): number { + let currentY = startY; + + rows.forEach((row, index) => { + ctx.save(); + if (index > 0) { + ctx.strokeStyle = 'rgba(142,34,48,0.08)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(96, currentY - 12); + ctx.lineTo(430, currentY - 12); + ctx.stroke(); + } + + drawContactIcon(ctx, row.kind, 52, currentY - 3); + + const usedHeight = drawMultilineText(ctx, row.value, { + x: 98, + y: currentY - 1, + maxWidth: 334, + lineHeight: row.maxLines > 1 ? 24 : 22, + maxLines: row.maxLines, + color: TEXT_MAIN, + font: row.maxLines > 1 ? '500 16px sans-serif' : '600 18px sans-serif', + }); + + currentY += Math.max(usedHeight, 30) + 18; + ctx.restore(); + }); + + return currentY; +} + +function drawPoster(ctx: Canvas2DContext, payload: SharePosterPayload, assets: PosterAssets) { + drawBackground(ctx); + const contactRows = buildContactRows(payload); + const contactStartY = 234; + drawHeaderSection(ctx, payload, assets); + drawContactRows(ctx, contactRows, contactStartY); +} + +function configureCanvas(canvas: CanvasNode): Canvas2DContext { + const context = canvas.getContext('2d') as Canvas2DContext; + canvas.width = EXPORT_WIDTH; + canvas.height = EXPORT_HEIGHT; + if (typeof context.setTransform === 'function') { + context.setTransform(1, 0, 0, 1, 0, 0); + } + context.clearRect(0, 0, EXPORT_WIDTH, EXPORT_HEIGHT); + context.scale(EXPORT_SCALE, EXPORT_SCALE); + return context; +} + +function exportCanvas(canvas: CanvasNode): Promise { + return new Promise((resolve, reject) => { + wx.canvasToTempFilePath({ + canvas, + x: 0, + y: 0, + width: POSTER_WIDTH, + height: POSTER_HEIGHT, + destWidth: EXPORT_WIDTH, + destHeight: EXPORT_HEIGHT, + fileType: 'jpg', + quality: 0.92, + success: (result) => { + resolve(result.tempFilePath); + }, + fail: () => { + reject(new Error('分享海报导出失败')); + }, + }); + }); +} + +export async function generateSharePoster(payload: SharePosterPayload): Promise { + const ctx = configureCanvas(payload.canvas); + const assets = await preloadAssets(payload); + drawPoster(ctx, payload, assets); + return exportCanvas(payload.canvas); +}