HarmonyOS保修凭证连载04:照片副本与附件关联【共创稿事节】
保修登记完成身份去重后,接待人员通常还需要补充购机票据照片。问题不在于把照片显示到页面,而在于照片究竟归属于哪张凭证、选图产生的访问路径可以使用多久、页面退出后附件是否还在。把系统选择器返回的地址直接写入凭证表,短时间看似方便,重新打开记录时却可能面对不可访问的来源地址。更麻烦的是同一设备存在多个历史登记来源,照片如果按设备序列号绑定,可能被挂到错误的业务凭证下。
这一节把附件处理拆成三段:用户主动选择图片,应用生成受自己管理的文件副本,再把副本和已经确定的凭证主键建立关系。第03篇确认的是设备身份,本篇绑定的是具体凭证记录,两者是不同维度。用户可以选择图片,却不能凭一次相册访问自动取得其他图片。文件副本保存和关联记录提交也不能混作一条“成功”状态;前者可能完成,后者仍可能失败。

先固定凭证主键,而不是重新推断设备身份
假设凭证编号为 AB-2048-73,对应设备序列号 SN2409A18。用户从历史记录打开这个凭证后,页面应接收确定的 voucherId,而不是根据型号、序列号或最近一次编辑时间查询一个“最像”的条目。对于已有重复登记归并结果,附件应指向保留下来的凭证主键;若业务允许补充新登记来源,可将来源写进附件备注,但不修改设备的身份规则。
我把路径组织为三个层次:model/Attachment.ets 表达附件状态,service/ReceiptPhotoService.ets 管理系统选择和文件复制,service/AttachmentRepository.ets 负责本地关系写入,pages/AttachmentPage.ets 显示用户操作。目录间传递的是凭证主键、应用内路径和状态,不把相册来源 URI 当成长期数据库字段。对外部 URI 的使用集中在复制过程,方便后续排查授权失败。
要解决的问题: 为每份附件建立清晰的归属和状态约束,并在二次点击时避免重复发起两个相册选择过程。
// model/Attachment.ets
export type ImportPhase = 'idle' | 'picking' | 'copying' | 'binding' | 'ready' | 'failed';
export interface AttachmentDraft {
voucherId: string;
localPath: string;
bytes: number;
phase: ImportPhase;
error: string;
}
export interface ReceiptAttachment {
attachmentId: string;
voucherId: string;
localPath: string;
byteSize: number;
createdAt: number;
}
export function canImport(phase: ImportPhase, voucherId: string): boolean {
return voucherId.length > 0 && (phase === 'idle' || phase === 'ready' || phase === 'failed');
}
export function belongsTo(item: ReceiptAttachment, voucherId: string): boolean {
return item.voucherId === voucherId;
}
ImportPhase 不是界面动画的临时文案,而是用来防止重复入库的业务状态。从 picking 到 copying 再到 binding,按钮都应保持不可重复提交。取消选择时恢复到 idle;已复制但尚未关联成功时进入 failed,并保留本次目标路径供清理。界面刷新只改变呈现,不得改变当前附件所属的凭证编号。
选择器返回后立即落地应用沙箱
Media Library Kit 的 PhotoViewPicker 可在用户操作后返回已选图片 URI。此时的访问入口适合读取和复制,不宜当成永久附件地址。Core File Kit 的 fileIo 能读取选择结果并将字节复制到应用私有目录。保修票据可能包含姓名、订单号甚至联系方式,因此不应在日志中输出原始图片路径和完整业务信息。示例限制单张图片大小,并且无论成功还是异常均关闭文件句柄。
要解决的问题: 处理用户取消、源文件不可读、超限、目标写入失败以及资源释放,返回稳定的沙箱路径。
// service/ReceiptPhotoService.ets
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
export interface CopiedReceipt { path: string; byteSize: number; }
export class ReceiptPhotoService {
async chooseAndCopy(context: common.UIAbilityContext): Promise<CopiedReceipt | null> {
const options = new photoAccessHelper.PhotoSelectOptions();
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 1;
const result = await new photoAccessHelper.PhotoViewPicker().select(options);
if (result.photoUris.length === 0) return null;
const dest = `${context.filesDir}/receipt_${Date.now()}_${Math.floor(Math.random() * 100000)}.img`;
let source: fileIo.File | undefined;
let target: fileIo.File | undefined;
try {
source = fileIo.openSync(result.photoUris[0], fileIo.OpenMode.READ_ONLY);
const size = fileIo.statSync(source.fd).size;
if (size <= 0 || size > 10 * 1024 * 1024) throw new Error('图片大小不符合要求');
target = fileIo.openSync(dest, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
fileIo.copyFileSync(source.fd, target.fd);
return { path: dest, byteSize: size };
} catch (error) {
try { fileIo.unlinkSync(dest); } catch (_) {}
throw error;
} finally {
if (source) fileIo.closeSync(source);
if (target) fileIo.closeSync(target);
}
}
}
这里给应用副本使用独立文件名,而不是沿用相册 URI 的末尾文本。相册中的原文件可以被用户删除、迁移或编辑,应用负责的是自己的附件副本。为了避免图片格式与文件扩展名混淆,本地以中性扩展名保存,实际展示前应按内容类型识别解码能力;如果后续要提供下载、分享或转存,则需要在附件元信息中补充可靠的媒体类型,而不是简单假定都是 JPEG。
拷贝过程放在服务里还有一个重要原因:页面销毁与文件 I/O 不一定同步。如果用户切换页面,文件复制已经开始,就不能靠页面变量恢复原状。服务应把目标路径交给存储层,未建立数据库关系的副本进入回收名单。大量附件也需要限制并行数,避免接待人员连续点击导致同时打开太多文件描述符。

附件关系写入要有唯一身份
本地持久化可以使用 ArkData 的关系型数据库。最小表包含附件 ID、凭证 ID、应用副本路径、大小和创建时间。这里的附件 ID 是每次导入的独立标识,不能与凭证 ID 混用;一张凭证可以关联多张附件,一个本地副本默认属于一个附件记录。页面查询时必须加 voucher_id 条件,不能把库中所有票据照片直接列到当前凭证。
要解决的问题: 在数据库层建立附件主键和凭证归属,并在异常时保留可清理的目标文件。
// service/AttachmentRepository.ets
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { ReceiptAttachment } from '../model/Attachment';
export class AttachmentRepository {
private db?: relationalStore.RdbStore;
async open(context: common.UIAbilityContext): Promise<void> {
this.db = await relationalStore.getRdbStore(context, {
name: 'warranty_receipts.db',
securityLevel: relationalStore.SecurityLevel.S1
});
await this.db.executeSql(`CREATE TABLE IF NOT EXISTS receipt_attachment (
attachment_id TEXT PRIMARY KEY,
voucher_id TEXT NOT NULL,
local_path TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL,
created_at INTEGER NOT NULL
)`);
}
async add(item: ReceiptAttachment): Promise<void> {
if (!this.db) throw new Error('附件存储尚未打开');
await this.db.insert('receipt_attachment', {
attachment_id: item.attachmentId,
voucher_id: item.voucherId,
local_path: item.localPath,
byte_size: item.byteSize,
created_at: item.createdAt
});
}
}
写库前还要检查所指向的凭证记录确实存在,不能仅凭非空字符串接受关系。如果一条原始凭证被后续归并迁移,附件绑定应由领域服务按数据库事务完成转移,避免短暂出现“无主附件”。示例采用顺序写入:复制成功后才插入附件记录。插入异常时要删除新复制的文件;相反,记录删除时先改变数据库关联,再由回收任务清理不再引用的文件,避免删文件成功却留下无法访问的数据库行。
页面处理重复点击和退出后的回流
页面侧只需要关心当前凭证、进度和用户可见的错误。选择器关闭但没有选图与发生读取异常是两种不同结果,不应共同显示为“导入失败”。异步操作回来时还要核对操作的代次,防止用户已切换至另一份凭证,旧操作才把路径塞到新的页面中。
要解决的问题: 将文件服务接到 ArkUI 页面,保持一次操作的状态顺序,避免晚到结果覆盖新页面。
// pages/AttachmentPage.ets
import { common } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { ReceiptPhotoService } from '../service/ReceiptPhotoService';
import { AttachmentRepository } from '../service/AttachmentRepository';
import { canImport, ImportPhase, ReceiptAttachment } from '../model/Attachment';
@Entry
@Component
struct AttachmentPage {
@State voucherId: string = 'AB-2048-73';
@State phase: ImportPhase = 'idle';
@State hint: string = '';
@State savedPath: string = '';
private generation: number = 0;
private photoService: ReceiptPhotoService = new ReceiptPhotoService();
private repository: AttachmentRepository = new AttachmentRepository();
private async addReceipt(): Promise<void> {
if (!canImport(this.phase, this.voucherId)) return;
const ticket = ++this.generation;
const ownerId = this.voucherId;
this.phase = 'picking';
let copiedPath = '';
try {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const photo = await this.photoService.chooseAndCopy(context);
if (!photo) { this.phase = 'idle'; return; }
copiedPath = photo.path;
this.phase = 'binding';
await this.repository.open(context);
const item: ReceiptAttachment = {
attachmentId: `r_${Date.now()}_${ticket}`,
voucherId: ownerId,
localPath: photo.path,
byteSize: photo.byteSize,
createdAt: Date.now()
};
await this.repository.add(item);
if (ticket === this.generation) { this.savedPath = photo.path; this.phase = 'ready'; }
} catch (e) {
if (copiedPath) { try { fileIo.unlinkSync(copiedPath); } catch (_) {} }
if (ticket === this.generation) { this.hint = '附件关联失败,请重新选择'; this.phase = 'failed'; }
}
}
aboutToDisappear(): void { this.generation++; }
build() {
Column({ space: 16 }) {
Text('凭证附件').fontSize(24).fontWeight(FontWeight.Bold)
Text(this.voucherId)
if (this.savedPath) { Image(this.savedPath).width(240).height(180) }
Text(this.hint)
Button(this.phase === 'picking' || this.phase === 'binding' ? '正在处理' : '添加购机票据')
.enabled(canImport(this.phase, this.voucherId))
.onClick(() => { this.addReceipt(); })
}.padding(20)
}
}
从生命周期角度看,上面的 generation 仅屏蔽旧回调修改界面,并不会取消复制或关联的写入;生产工程可将导入工作移交到页面外的任务协调器,确保退出后仍能查询处理进度。对于已归档凭证,应允许用户预览与移除关联,但移除前要确认照片副本是否还被其他附件引用。不能直接按同一设备序列号批量删除,以免误删不同凭证的原始材料。

调试时看状态序列,不看单次按钮反馈
调试入口可以安排四组输入:正常选择一张票据、关闭选择器不选、选择大于阈值的图片、在导入途中离开页面。观察点不是“页面有没有绿色提示”,而是 picking → binding → ready 是否与文件存在性和数据库记录对应。需要记录的技术字段包括凭证主键的脱敏值、导入阶段、失败类型、文件大小和关联行数;不记录原始相册路径,也不输出购机票据的内容。
还应在重启页面后按 voucher_id 重新查询附件,并对比 local_path 是否存在。如果某条记录找不到对应文件,界面应提示重新关联或执行修复操作,而不是无限加载空白图片。对于文件存在、关联行不存在的情况,可依据文件创建时间与临时命名规则做延迟回收。保修资料的清理周期应由实际产品的数据保留策略决定,不能仅根据页面卸载时机处理。
这一节把“有了图片 URI”与“凭证真正拥有了附件”分开了。选择权限、沙箱字节、附件关系、页面状态四层各自清楚,后续增加维修过程照片、发票和维修单时才能复用同一套关联机制,而不把系统文件访问地址当成业务主键。
更多推荐

所有评论(0)