feat(report): 报告 PDF 导出端侧链路 + 新页面路由注册

- 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 已验证(导出→下载→渲染逐页目检)
This commit is contained in:
xsl
2026-08-30 13:18:25 +08:00
parent d9868fb4f7
commit 9e5ce14bce
3 changed files with 311 additions and 2 deletions
@@ -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<void> {
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<void> {
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<void> {
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)
}
}
@@ -17,6 +17,10 @@
import { router } from '@kit.ArkUI' import { router } from '@kit.ArkUI'
import { ApiClient } from '../core/ApiClient' import { ApiClient } from '../core/ApiClient'
import { ApiError } from '../core/ApiTypes' 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 { TaskPollingPanel } from '../components/f3/TaskPollingPanel'
import { DimensionCard } from '../components/f3/DimensionCard' import { DimensionCard } from '../components/f3/DimensionCard'
import { import {
@@ -56,6 +60,10 @@ struct ReportResultPage {
@State regenTaskId: string = '' @State regenTaskId: string = ''
@State regenError: string = '' @State regenError: string = ''
private regenRequestKey: string = '' // 重新生成失败重试时沿用同一 key:regenerate 幂等重放 private regenRequestKey: string = '' // 重新生成失败重试时沿用同一 key:regenerate 幂等重放
@State exporting: boolean = false
@State exportTaskId: string = ''
@State exportError: string = ''
private exportRequestKey: string = '' // 导出失败重试沿用同一 key(幂等重放,不重复扣 hd_export 配额)
aboutToAppear(): void { aboutToAppear(): void {
const raw = router.getParams() const raw = router.getParams()
@@ -149,6 +157,71 @@ struct ReportResultPage {
this.regenError = `重新生成失败:${reason}` this.regenError = `重新生成失败:${reason}`
} }
/** 导出 PDF:提交渲染任务(quota_key=hd_export,服务端熔断 tier2 可拦)→ 轮询 → 下载 → 预览页。 */
private async doExport(): Promise<void> {
if (this.exporting || this.reportId === '') {
return
}
this.exportError = ''
if (this.exportRequestKey === '') {
this.exportRequestKey = randomHex(16)
}
const req: Record<string, Object> = { 'request_key': this.exportRequestKey }
try {
const data = await ApiClient.post<ReportCreateResp>(`/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<void> {
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<string, string> = {}
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<void>((resolve, reject) => {
task.on('complete', () => resolve())
task.on('fail', (err: number) => reject(new Error(`download fail: ${err}`)))
})
const params: Record<string, Object> = { '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 { private dimKey(d: DimensionWire): string {
const k = d.key const k = d.key
if (k !== undefined && k !== '') { if (k !== undefined && k !== '') {
@@ -248,6 +321,34 @@ struct ReportResultPage {
.padding({ left: 24, right: 24 }) .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 @Builder
HeaderCard() { HeaderCard() {
Column({ space: 6 }) { Column({ space: 6 }) {
@@ -451,6 +552,25 @@ struct ReportResultPage {
.width('100%') .width('100%')
.textAlign(TextAlign.Start) .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 !== '' ? '重试重新生成' : '重新生成报告') Button(this.regenError !== '' ? '重试重新生成' : '重新生成报告')
.width('100%') .width('100%')
.height(46) .height(46)
@@ -470,7 +590,9 @@ struct ReportResultPage {
build() { build() {
Column() { Column() {
this.TopBar() this.TopBar()
if (this.regenerating) { if (this.exporting) {
this.ExportView()
} else if (this.regenerating) {
this.RegenView() this.RegenView()
} else if (this.loading) { } else if (this.loading) {
this.LoadingView() this.LoadingView()
@@ -6,6 +6,10 @@
"pages/ReportCreatePage", "pages/ReportCreatePage",
"pages/ReportResultPage", "pages/ReportResultPage",
"pages/ChatPage", "pages/ChatPage",
"pages/ProfilePage" "pages/ProfilePage",
"pages/LlmTestPage",
"pages/TryonPage",
"pages/HairDesignPage",
"pages/ReportPdfPage"
] ]
} }