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 { 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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user