import { API_BASE_URL, getSession, clearSession } from '@/services/api.uts' import { APP_ENVIRONMENT } from '@/services/config.uts' export type ReportDownload = { cancel: () => void, done: Promise } // #ifdef MP-WEIXIN let previewFiles: string[] = [] const cleanPreviewFiles = () => { previewFiles = previewFiles.filter((path: string): boolean => { try { uni.getFileSystemManager().unlinkSync(path); return false } catch (_err) { return true } }) } // 系统文档预览期间保留临时文件,返回小程序后清理;不调用saveFile持久化报告。 uni.onAppShow(() => cleanPreviewFiles()) // #endif export function downloadReportPdf(id: string, revisionId: string = ''): ReportDownload { // #ifndef H5 || MP-WEIXIN return { cancel: () => {}, done: new Promise((_resolve, reject) => { reject(new Error('当前客户端暂未开放PDF下载,请在普通浏览器打开H5并登录后下载。')) }) } // #endif // #ifdef MP-WEIXIN cleanPreviewFiles() let cancelled = false; let opening = false; let settled = false let task: DownloadTask | null = null; let tempPath = ''; let rejectDownload: ((error: Error) => void) | null = null const removeTemp = () => { if (tempPath.length == 0) return try { uni.getFileSystemManager().unlinkSync(tempPath) } catch (_err) { previewFiles.push(tempPath) } tempPath = '' } const fail = (message: string) => { removeTemp() if (settled) return settled = true; rejectDownload?.(new Error(message)) } const stop = (message: string) => { fail(message); task?.abort() } const cancel = () => { // openDocument已经接管文件后,原页面可能隐藏/销毁,不能中止或删除正在预览的文件。 if (opening || settled) return cancelled = true; stop('下载已取消。') } const done = new Promise((resolve, reject) => { rejectDownload = reject if (!/^[1-9][0-9]{0,9}$/.test(id) || (revisionId.length > 0 && !/^[1-9][0-9]{0,9}$/.test(revisionId))) { fail('报告或版本编号无效,请重新打开本人报告。'); return } const token = getSession()?.getString('token') ?? '' if (token.length == 0) { fail('登录已过期,请重新登录后下载。'); return } if (!API_BASE_URL.startsWith('https://')) { fail('小程序PDF下载需要配置HTTPS服务地址。'); return } const current = (): boolean => { if (settled) return false if (cancelled || getSession()?.getString('token') != token) { fail('页面或登录状态已变化,本次下载已取消。'); return false } return true } let mime = ''; let declaredSize = 0; let encoded = false task = uni.downloadFile({ url: API_BASE_URL + '/assessments/' + id + '/report.pdf' + (revisionId.length > 0 ? '?revisionId=' + encodeURIComponent(revisionId) : ''), header: { Authorization: 'Bearer ' + token, 'X-Requested-With': 'XMLHttpRequest' }, timeout: 45000, success: (response) => { tempPath = response.tempFilePath if (!current()) { removeTemp(); return } if (response.statusCode == 401) { clearSession(); fail('登录已过期,请重新登录后下载。'); return } try { const fs = uni.getFileSystemManager() const size = (fs.statSync(tempPath, false) as UniNamespace.Stats).size if (response.statusCode != 200 || mime != 'application/pdf') { let message = 'PDF暂不可下载,请稍后重试;已保存报告不受影响。' if (mime == 'application/json' && size > 0 && size <= 8192) { const body = JSON.parse(fs.readFileSync(tempPath, 'utf8') as string) const serverMessage = body?.getString('msg') ?? '' if (serverMessage.length > 0 && serverMessage.length < 200) message = serverMessage } fail(message); return } if (size > 20971520) { fail('PDF文件过大,请联系管理员。'); return } if (size < 100 || (!encoded && declaredSize > 0 && declaredSize != size)) { fail('下载内容不是完整PDF,请重试。'); return } // 只读取头尾,避免将整份报告加载到小程序内存。 const start = fs.readFileSync(tempPath, 'ascii', 0, 5) as string const tailLength = Math.min(size, 1024) const end = fs.readFileSync(tempPath, 'ascii', size - tailLength, tailLength) as string if (start != '%PDF-' || !end.includes('%%EOF')) { fail('下载内容不是完整PDF,请重试。'); return } if (!current()) return opening = true uni.openDocument({ filePath: tempPath, fileType: 'pdf', showMenu: true, success: () => { if (!current()) return previewFiles.push(tempPath); tempPath = ''; settled = true resolve('PDF已打开,可通过右上角菜单保存或转发。') }, fail: () => { opening = false; fail('PDF已下载但暂时无法打开,请重试或更新微信。') } }) } catch (_err) { fail('PDF文件读取失败或内容异常,请重试。') } }, fail: (error) => { if (!current()) return const reason = error.errMsg ?? '' if (reason.includes('domain list')) fail('PDF下载域名未配置,请联系管理员。') else if (reason.includes('timeout')) fail('PDF生成超时,请稍后重试。') else fail('PDF下载失败,请检查网络后重试。') } }) task!.onHeadersReceived((response) => { if (!current()) { task?.abort(); return } const header = response.header as UTSJSONObject Object.keys(header).forEach((key: string) => { const value = header[key]?.toString() ?? '' if (key.toLowerCase() == 'content-type') mime = value.split(';')[0].trim().toLowerCase() if (key.toLowerCase() == 'content-length') declaredSize = Number(value) if (key.toLowerCase() == 'content-encoding') encoded = value.length > 0 && value.toLowerCase() != 'identity' }) if (declaredSize > 20971520) stop('PDF文件过大,请联系管理员。') }) task!.onProgressUpdate((progress) => { if (!current()) { task?.abort(); return } if (progress.totalBytesWritten > 20971520 || progress.totalBytesExpectedToWrite > 20971520) stop('PDF文件过大,请联系管理员。') }) }) return { cancel, done } // #endif // #ifdef H5 const controller = new AbortController() let cancelled = false const cancel = () => { cancelled = true; controller.abort() } const done = async (): Promise => { if (!/^[1-9][0-9]{0,9}$/.test(id) || (revisionId.length > 0 && !/^[1-9][0-9]{0,9}$/.test(revisionId))) throw new Error('报告或版本编号无效,请重新打开本人报告。') if (/MicroMessenger/i.test(navigator.userAgent)) throw new Error('微信内浏览器暂不支持此下载。请点右上角菜单,选择“在浏览器打开”,登录后重新点击下载PDF。') const token = getSession()?.getString('token') ?? '' if (token.length == 0) throw new Error('登录已过期,请重新登录后下载。') if (API_BASE_URL.length == 0 || (APP_ENVIRONMENT == 'production' && !API_BASE_URL.startsWith('https://'))) throw new Error('下载服务地址尚未配置。') const current = () => { if (cancelled || getSession()?.getString('token') != token) throw new Error('页面或登录状态已变化,本次下载已取消。') } const timeout = setTimeout(() => controller.abort(), 45000) try { const response = await fetch(API_BASE_URL + '/assessments/' + id + '/report.pdf' + (revisionId.length > 0 ? '?revisionId=' + encodeURIComponent(revisionId) : ''), { method: 'GET', headers: { Authorization: 'Bearer ' + token, 'X-Requested-With': 'XMLHttpRequest' }, cache: 'no-store', credentials: 'omit', redirect: 'error', signal: controller.signal }) current() if (response == null) throw new Error('下载服务未返回文件,请重试。') if (response.status == 401) { clearSession(); throw new Error('登录已过期,请重新登录后下载。') } const mime = (response.headers.get('Content-Type') ?? '').split(';')[0].trim().toLowerCase() if (!response.ok || mime != 'application/pdf') { let message = 'PDF暂不可下载,请稍后重试;已保存报告不受影响。' if (mime == 'application/json') { const body = await response.json() if (typeof body.msg == 'string' && body.msg.length > 0 && body.msg.length < 200) message = body.msg } current(); throw new Error(message) } const declaredSize = Number(response.headers.get('Content-Length') ?? '0') if (declaredSize > 20971520) throw new Error('PDF文件过大,请联系管理员。') const bytes = await response.arrayBuffer() current() const start = new TextDecoder('ascii').decode(bytes.slice(0, 5)) const end = new TextDecoder('ascii').decode(bytes.slice(Math.max(0, bytes.byteLength - 1024))) const encoded = (response.headers.get('Content-Encoding') ?? '').toLowerCase() if (bytes.byteLength < 100 || bytes.byteLength > 20971520 || ((encoded.length == 0 || encoded == 'identity') && declaredSize > 0 && declaredSize != bytes.byteLength) || start != '%PDF-' || !end.includes('%%EOF')) throw new Error('下载内容不是完整PDF,请重试。') // 验证响应后才创建本机Blob。令牌只放请求头,不写URL、文件名或本机存储。 const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })) const link = document.createElement('a') link.href = url; link.download = 'financing-report-' + id + '.pdf'; link.style.display = 'none' document.body.appendChild(link); link.click(); link.remove() setTimeout(() => URL.revokeObjectURL(url), 1000) return 'PDF已交给浏览器处理,请查看下载列表;若打开预览,可使用浏览器的保存功能。' } catch (err) { if (cancelled) throw new Error('下载已取消。') if (controller.signal.aborted) throw new Error('PDF生成超时,请稍后重试。') if (err instanceof TypeError) throw new Error('网络连接失败,请检查网络后重试。') throw err } finally { clearTimeout(timeout) } } return { cancel, done: done() } // #endif }