From 9e5ce14bcec62bd18988debf7a8e329c957266dc Mon Sep 17 00:00:00 2001 From: xiangsilian Date: Sun, 30 Aug 2026 13:18:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(report):=20=E6=8A=A5=E5=91=8A=20PDF=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E7=AB=AF=E4=BE=A7=E9=93=BE=E8=B7=AF=20+=20?= =?UTF-8?q?=E6=96=B0=E9=A1=B5=E9=9D=A2=E8=B7=AF=E7=94=B1=E6=B3=A8=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReportResultPage:「导出 PDF 报告」按钮(仅 succeeded 显示)→ POST /reports/{id}/export(request_key 幂等,失败重试沿用同键不重复扣 hd_export)→ 任务轮询 → 下载到 filesDir/reports/ → 跳预览页 - ReportPdfPage:PDF Kit PdfView 预览(LAYOUT_SINGLE)+ Share Kit systemShare 分享 + DocumentViewPicker 另存 - main_pages.json 注册 LlmTestPage/TryonPage/HairDesignPage/ReportPdfPage 四条路由 - 后端配套:meida_backend cd0ddb1(export 管线);E2E 已验证(导出→下载→渲染逐页目检) --- .../src/main/ets/pages/ReportPdfPage.ets | 183 ++++++++++++++++++ .../src/main/ets/pages/ReportResultPage.ets | 124 +++++++++++- .../resources/base/profile/main_pages.json | 6 +- 3 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 app/entry/src/main/ets/pages/ReportPdfPage.ets diff --git a/app/entry/src/main/ets/pages/ReportPdfPage.ets b/app/entry/src/main/ets/pages/ReportPdfPage.ets new file mode 100644 index 0000000..ad05cf8 --- /dev/null +++ b/app/entry/src/main/ets/pages/ReportPdfPage.ets @@ -0,0 +1,183 @@ +/** + * ReportPdfPage —— 形象报告 PDF 预览与分发页。 + * + * 链路(产品方案:服务端 WeasyPrint 生成 → 端侧下载 → 本页预览/分享/另存): + * - 预览:PDF Kit 的 PdfView(API 12+,模拟器不支持,需真机); + * - 分享:Share Kit systemShare(系统分享面板,目标应用是否出现取决于其注册类型); + * - 另存:DocumentViewPicker(免权限,用户选位置后写入)。 + * 入参(router.getParams()):{ pdfPath: string, title?: string } + */ + +import { router } from '@kit.ArkUI' +import { pdfViewManager, PdfView, pdfService } from '@kit.PDFKit' +import { systemShare } from '@kit.ShareKit' +import { uniformTypeDescriptor } from '@kit.ArkData' +import { common } from '@kit.AbilityKit' +import { picker, fileUri } from '@kit.CoreFileKit' +import fs from '@ohos.file.fs' +import { ContextHolder } from '../core/ContextHolder' + +const PAGE_BG: string = '#F5F6F8' +const ACCENT: string = '#C2185B' +const TEXT_PRIMARY: string = '#222222' +const TEXT_SECONDARY: string = '#8A9099' + +interface PdfRouteParams { + pdfPath?: string + title?: string +} + +@Entry +@Component +struct ReportPdfPage { + @State title: string = '形象报告' + @State pdfPath: string = '' + @State loadError: string = '' + @State ready: boolean = false + @State actionText: string = '' + + private controller: pdfViewManager.PdfController = new pdfViewManager.PdfController() + + aboutToAppear(): void { + const raw = router.getParams() + if (raw !== undefined && raw !== null) { + const p = raw as PdfRouteParams + if (p.pdfPath !== undefined && p.pdfPath !== '') { + this.pdfPath = p.pdfPath + } + if (p.title !== undefined && p.title !== '') { + this.title = p.title + } + } + if (this.pdfPath === '') { + this.loadError = '缺少 PDF 文件路径' + return + } + this.loadDocument() + } + + private async loadDocument(): Promise { + try { + const result = await this.controller.loadDocument(this.pdfPath) + if (result === pdfService.ParseResult.PARSE_SUCCESS) { + this.ready = true + } else { + this.loadError = `PDF 解析失败(${result})` + } + } catch (e) { + this.loadError = `PDF 加载异常:${(e as Error).message}` + } + } + + private async share(): Promise { + try { + const data = new systemShare.SharedData({ + utd: uniformTypeDescriptor.UniformDataType.PDF, + uri: fileUri.getUriFromPath(this.pdfPath), + title: this.title + }) + const controller = new systemShare.ShareController(data) + const opts: systemShare.ShareControllerOptions = { + selectionMode: systemShare.SelectionMode.SINGLE, + previewMode: systemShare.SharePreviewMode.DEFAULT + } + await controller.show(ContextHolder.get() as common.UIAbilityContext, opts) + } catch (e) { + this.actionText = `分享取消或失败:${(e as Error).message}` + } + } + + private async saveAs(): Promise { + try { + const docPicker = new picker.DocumentViewPicker(ContextHolder.get()) + const opts = new picker.DocumentSaveOptions() + opts.newFileNames = [`${this.title}.pdf`] + const uris = await docPicker.save(opts) + if (uris.length === 0) { + return + } + const src = fs.openSync(this.pdfPath, fs.OpenMode.READ_ONLY) + const dst = fs.openSync(uris[0], fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC) + try { + const buf = new ArrayBuffer(64 * 1024) + let readLen = fs.readSync(src.fd, buf) + while (readLen > 0) { + fs.writeSync(dst.fd, buf.slice(0, readLen)) + readLen = fs.readSync(src.fd, buf) + } + } finally { + fs.closeSync(src) + fs.closeSync(dst) + } + this.actionText = '已保存到文件管理' + } catch (e) { + this.actionText = `另存取消或失败:${(e as Error).message}` + } + } + + build() { + Column() { + // 顶部栏 + Row() { + Text('‹ 返回').fontSize(15).fontColor(TEXT_SECONDARY).onClick(() => router.back()) + Text(this.title).fontSize(17).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY) + Text('').width(48) + } + .width('100%') + .padding({ left: 16, right: 16, top: 12, bottom: 12 }) + .justifyContent(FlexAlign.SpaceBetween) + .backgroundColor(Color.White) + + // PDF 预览区 + if (this.ready) { + PdfView({ + controller: this.controller, + pageLayout: pdfService.PageLayout.LAYOUT_SINGLE, + isContinuous: true, + showScroll: true, + pageFit: pdfService.PageFit.FIT_WIDTH + }) + .layoutWeight(1) + .width('100%') + } else { + Column({ space: 10 }) { + if (this.loadError === '') { + LoadingProgress().width(40).height(40).color(ACCENT) + Text('PDF 加载中…').fontSize(13).fontColor(TEXT_SECONDARY) + } else { + Text(this.loadError).fontSize(13).fontColor('#E5484D') + } + } + .layoutWeight(1) + .width('100%') + .justifyContent(FlexAlign.Center) + } + + if (this.actionText !== '') { + Text(this.actionText).fontSize(12).fontColor(TEXT_SECONDARY) + .width('100%').padding({ left: 16 }) + } + + // 底部操作 + Row({ space: 12 }) { + Button('分享') + .layoutWeight(1).height(44) + .backgroundColor(ACCENT) + .enabled(this.ready) + .onClick(() => { this.share() }) + Button('另存到文件') + .layoutWeight(1).height(44) + .fontColor(ACCENT) + .backgroundColor('#F7E3EA') + .enabled(this.ready) + .onClick(() => { this.saveAs() }) + } + .width('100%') + .padding({ left: 16, right: 16, top: 10, bottom: 14 }) + .backgroundColor(Color.White) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} diff --git a/app/entry/src/main/ets/pages/ReportResultPage.ets b/app/entry/src/main/ets/pages/ReportResultPage.ets index 3bd0d22..24a7d9a 100644 --- a/app/entry/src/main/ets/pages/ReportResultPage.ets +++ b/app/entry/src/main/ets/pages/ReportResultPage.ets @@ -17,6 +17,10 @@ import { router } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' import { ApiError } from '../core/ApiTypes' +import { AuthStore } from '../core/AuthStore' +import { ContextHolder } from '../core/ContextHolder' +import request from '@ohos.request' +import fs from '@ohos.file.fs' import { TaskPollingPanel } from '../components/f3/TaskPollingPanel' import { DimensionCard } from '../components/f3/DimensionCard' import { @@ -56,6 +60,10 @@ struct ReportResultPage { @State regenTaskId: string = '' @State regenError: string = '' private regenRequestKey: string = '' // 重新生成失败重试时沿用同一 key:regenerate 幂等重放 + @State exporting: boolean = false + @State exportTaskId: string = '' + @State exportError: string = '' + private exportRequestKey: string = '' // 导出失败重试沿用同一 key(幂等重放,不重复扣 hd_export 配额) aboutToAppear(): void { const raw = router.getParams() @@ -149,6 +157,71 @@ struct ReportResultPage { this.regenError = `重新生成失败:${reason}` } + /** 导出 PDF:提交渲染任务(quota_key=hd_export,服务端熔断 tier2 可拦)→ 轮询 → 下载 → 预览页。 */ + private async doExport(): Promise { + if (this.exporting || this.reportId === '') { + return + } + this.exportError = '' + if (this.exportRequestKey === '') { + this.exportRequestKey = randomHex(16) + } + const req: Record = { 'request_key': this.exportRequestKey } + try { + const data = await ApiClient.post(`/api/v1/reports/${this.reportId}/export`, req) + const tid = data.task_id ?? '' + if (tid === '') { + this.exportError = '服务端未返回任务 ID,请稍后重试' + return + } + this.exportTaskId = tid + this.exporting = true + } catch (e) { + if (e instanceof ApiError) { + this.exportError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message + } else { + this.exportError = '请求失败,请检查网络' + } + } + } + + /** 导出任务成功:下载 PDF 到沙箱后跳预览页。下载失败不致命——预览页可重进。 */ + private async onExportSucceeded(): Promise { + this.exporting = false + this.exportError = '' + try { + const dir: string = `${ContextHolder.get().filesDir}/reports` + if (!fs.accessSync(dir)) { + fs.mkdirSync(dir, true) + } + const path: string = `${dir}/report_${this.reportId}.pdf` + const token = AuthStore.getAccessToken() + const header: Record = {} + if (token !== undefined) { + header['Authorization'] = `Bearer ${token}` + } + const base = await AuthStore.getBaseUrl() + const task = await request.downloadFile(ContextHolder.get(), { + url: `${base}/api/v1/reports/${this.reportId}/export/download`, + header: header, + filePath: path + }) + await new Promise((resolve, reject) => { + task.on('complete', () => resolve()) + task.on('fail', (err: number) => reject(new Error(`download fail: ${err}`))) + }) + const params: Record = { 'pdfPath': path, 'title': `形象报告_${this.reportId}` } + router.pushUrl({ url: 'pages/ReportPdfPage', params: params }) + } catch (e) { + this.exportError = `PDF 下载失败:${(e as Error).message}` + } + } + + private onExportFailed(reason: string): void { + this.exporting = false + this.exportError = `导出失败:${reason}` + } + private dimKey(d: DimensionWire): string { const k = d.key if (k !== undefined && k !== '') { @@ -248,6 +321,34 @@ struct ReportResultPage { .padding({ left: 24, right: 24 }) } + @Builder + ExportView() { + Column({ space: 16 }) { + Text('正在生成 PDF 报告') + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Text('排版渲染中,完成后自动下载并打开预览') + .fontSize(13) + .fontColor('#999999') + .textAlign(TextAlign.Center) + TaskPollingPanel({ + taskId: this.exportTaskId, + onSucceeded: (): void => { + this.onExportSucceeded() + }, + onFailed: (reason: string): void => { + this.onExportFailed(reason) + } + }) + .width('86%') + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .padding({ left: 24, right: 24 }) + } + @Builder HeaderCard() { Column({ space: 6 }) { @@ -451,6 +552,25 @@ struct ReportResultPage { .width('100%') .textAlign(TextAlign.Start) } + if (this.exportError !== '') { + Text(this.exportError) + .fontSize(12) + .fontColor('#E5484D') + .width('100%') + .textAlign(TextAlign.Start) + } + // 仅成功结案的报告可导出 PDF(服务端同样校验,端侧只是提前挡) + if (this.statusRaw === 'SUCCEEDED') { + Button(this.exportError !== '' ? '重试导出 PDF' : '导出 PDF 报告') + .width('100%') + .height(46) + .fontSize(15) + .fontColor(COLOR_PRIMARY) + .backgroundColor('#F7E3EA') + .onClick(() => { + this.doExport() + }) + } Button(this.regenError !== '' ? '重试重新生成' : '重新生成报告') .width('100%') .height(46) @@ -470,7 +590,9 @@ struct ReportResultPage { build() { Column() { this.TopBar() - if (this.regenerating) { + if (this.exporting) { + this.ExportView() + } else if (this.regenerating) { this.RegenView() } else if (this.loading) { this.LoadingView() diff --git a/app/entry/src/main/resources/base/profile/main_pages.json b/app/entry/src/main/resources/base/profile/main_pages.json index f59e15a..70caad4 100644 --- a/app/entry/src/main/resources/base/profile/main_pages.json +++ b/app/entry/src/main/resources/base/profile/main_pages.json @@ -6,6 +6,10 @@ "pages/ReportCreatePage", "pages/ReportResultPage", "pages/ChatPage", - "pages/ProfilePage" + "pages/ProfilePage", + "pages/LlmTestPage", + "pages/TryonPage", + "pages/HairDesignPage", + "pages/ReportPdfPage" ] }