/** * TSC/Android 位图打印:`BitmapFactory.decodeFile` 无法直接读 http(s) 或「仅相对路径」; * 打印前把需网络的图片拉到本地临时路径,重打/预览落库里的 /picture/ URL 才能出图。 */ import { resolveMediaUrlForApp, storedValueLooksLikeImagePath } from '../resolveMediaUrl' import type { SystemLabelTemplate, SystemTemplateElementBase } from './types/printer' type HydrateDebugStage = 'skip' | 'download-success' | 'download-failed' | 'no-need-download' type HydrateDebugRecord = { stage: HydrateDebugStage payload: Record } const hydrateDebugRecords: HydrateDebugRecord[] = [] const MAX_HYDRATE_DEBUG_RECORDS = 40 function logHydrateDebug ( stage: HydrateDebugStage, payload: Record, ): void { hydrateDebugRecords.push({ stage, payload: { ...payload } }) if (hydrateDebugRecords.length > MAX_HYDRATE_DEBUG_RECORDS) { hydrateDebugRecords.splice(0, hydrateDebugRecords.length - MAX_HYDRATE_DEBUG_RECORDS) } try { console.log('[print-image-hydrate]', stage, JSON.stringify(payload)) } catch { console.log('[print-image-hydrate]', stage, payload) } } export function resetHydrateImageDebugRecords (): void { hydrateDebugRecords.length = 0 } export function getHydrateImageDebugReport (): string { if (!hydrateDebugRecords.length) return 'No hydrate image records.' const lines: string[] = [] hydrateDebugRecords.forEach((r, i) => { const p = r.payload || {} lines.push(`#${i + 1} ${r.stage}`) if (p.elementId != null && p.elementId !== '') lines.push(`id=${String(p.elementId)}`) if (p.type != null && p.type !== '') lines.push(`type=${String(p.type)}`) if (p.reason != null && p.reason !== '') lines.push(`reason=${String(p.reason)}`) if (p.raw != null && p.raw !== '') lines.push(`raw=${String(p.raw)}`) if (p.resolvedUrl != null && p.resolvedUrl !== '') lines.push(`resolved=${String(p.resolvedUrl)}`) if (p.tempFilePath != null && p.tempFilePath !== '') lines.push(`temp=${String(p.tempFilePath)}`) lines.push('---') }) return lines.join('\n') } /** 与 usAppApiRequest 一致:静态图 /picture/ 常需登录态,无头下载会 401 → 解码失败、纸面空白 */ function downloadAuthHeaders (): Record { const h: Record = {} try { const token = uni.getStorageSync('access_token') if (token) h.Authorization = `Bearer ${token}` } catch (_) {} return h } function cfgStr(config: Record, keys: string[]): string { for (const k of keys) { const v = config[k] if (v != null && String(v).trim() !== '') return String(v).trim() } return '' } /** 需先下载再 decodeFile 的地址(非 data:、非已是本地 file) */ function needsDownloadForDecode (raw: string): boolean { const s = String(raw || '').trim() if (!s) return false if (s.startsWith('data:')) return false if (s.startsWith('file://')) return false if (s.startsWith('_doc/') || s.startsWith('_www/') || /^[A-Za-z]:[\\/]/.test(s)) return false if (/^https?:\/\//i.test(s)) return true if ( s.startsWith('/picture/') || s.startsWith('/static/') || s.startsWith('picture/') || s.startsWith('static/') ) return true return false } function downloadUrlToTempFile (url: string): Promise { return new Promise((resolve) => { if (!url) { resolve(null) return } try { let done = false const timer = setTimeout(() => { if (done) return done = true resolve(null) }, 6000) uni.downloadFile({ url, header: downloadAuthHeaders(), success: (res) => { if (done) return done = true clearTimeout(timer) if (res.statusCode === 200 && res.tempFilePath) resolve(res.tempFilePath) else resolve(null) }, fail: () => { if (done) return done = true clearTimeout(timer) resolve(null) }, }) } catch { resolve(null) } }) } async function resolveToLocalPathIfNeeded ( raw: string, debugMeta: Record, ): Promise { const trimmed = String(raw || '').trim() if (!trimmed) { logHydrateDebug('skip', { ...debugMeta, reason: 'empty-source' }) return null } if (!needsDownloadForDecode(trimmed)) { logHydrateDebug('no-need-download', { ...debugMeta, raw: trimmed }) return null } const url = resolveMediaUrlForApp(trimmed) if (!url) { logHydrateDebug('download-failed', { ...debugMeta, raw: trimmed, reason: 'resolveMediaUrlForApp-empty', }) return null } const local = await downloadUrlToTempFile(url) if (local) { let finalLocal = local // #ifdef APP-PLUS try { const plusAny = (globalThis as any)?.plus const converted = plusAny?.io?.convertLocalFileSystemURL?.(local) if (converted && typeof converted === 'string') { finalLocal = converted } } catch (_) {} // #endif logHydrateDebug('download-success', { ...debugMeta, raw: trimmed, resolvedUrl: url, tempFilePath: finalLocal, }) return finalLocal } logHydrateDebug('download-failed', { ...debugMeta, raw: trimmed, resolvedUrl: url, reason: 'downloadFile-null', }) return null } async function hydrateElement (el: SystemTemplateElementBase): Promise { const type = String(el.type || '').toUpperCase() const cfg = { ...(el.config || {}) } as Record const debugMeta = { elementId: String(el.id || ''), type, } if (type === 'IMAGE' || type === 'LOGO') { const raw = cfgStr(cfg, ['src', 'url', 'data', 'Src', 'Url', 'Data']) const local = await resolveToLocalPathIfNeeded(raw, debugMeta) if (!local) return el return { ...el, config: { ...cfg, src: local, url: local, data: local, Src: local, Url: local, Data: local }, } } if (type === 'QRCODE') { const raw = cfgStr(cfg, ['data', 'Data']) if (!raw || !storedValueLooksLikeImagePath(raw)) { logHydrateDebug('skip', { ...debugMeta, raw, reason: 'qrcode-data-not-image-path' }) return el } const local = await resolveToLocalPathIfNeeded(raw, debugMeta) if (!local) return el return { ...el, config: { ...cfg, data: local, Data: local, src: local, url: local }, } } return el } /** * 返回新模板对象;无元素或无需下载时可能与原引用相同(未改元素时仍返回浅拷贝以统一调用方)。 */ export async function hydrateSystemTemplateImagesForPrint ( tmpl: SystemLabelTemplate ): Promise { const elements = tmpl.elements || [] if (elements.length === 0) return tmpl const next = await Promise.all(elements.map((el) => hydrateElement(el))) return { ...tmpl, elements: next } }