hydrateTemplateImagesForPrint.ts 6.82 KB
/**
 * 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<string, unknown>
}

const hydrateDebugRecords: HydrateDebugRecord[] = []
const MAX_HYDRATE_DEBUG_RECORDS = 40

function logHydrateDebug (
  stage: HydrateDebugStage,
  payload: Record<string, unknown>,
): 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<string, string> {
  const h: Record<string, string> = {}
  try {
    const token = uni.getStorageSync('access_token')
    if (token) h.Authorization = `Bearer ${token}`
  } catch (_) {}
  return h
}

function cfgStr(config: Record<string, unknown>, 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<string | null> {
  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<string, unknown>,
): Promise<string | null> {
  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<SystemTemplateElementBase> {
  const type = String(el.type || '').toUpperCase()
  const cfg = { ...(el.config || {}) } as Record<string, unknown>
  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<SystemLabelTemplate> {
  const elements = tmpl.elements || []
  if (elements.length === 0) return tmpl

  const next = await Promise.all(elements.map((el) => hydrateElement(el)))
  return { ...tmpl, elements: next }
}