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:
@@ -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 { 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<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 {
|
||||
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()
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"pages/ReportCreatePage",
|
||||
"pages/ReportResultPage",
|
||||
"pages/ChatPage",
|
||||
"pages/ProfilePage"
|
||||
"pages/ProfilePage",
|
||||
"pages/LlmTestPage",
|
||||
"pages/TryonPage",
|
||||
"pages/HairDesignPage",
|
||||
"pages/ReportPdfPage"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user