保修登记完成身份去重后,接待人员通常还需要补充购机票据照片。问题不在于把照片显示到页面,而在于照片究竟归属于哪张凭证、选图产生的访问路径可以使用多久、页面退出后附件是否还在。把系统选择器返回的地址直接写入凭证表,短时间看似方便,重新打开记录时却可能面对不可访问的来源地址。更麻烦的是同一设备存在多个历史登记来源,照片如果按设备序列号绑定,可能被挂到错误的业务凭证下。

这一节把附件处理拆成三段:用户主动选择图片,应用生成受自己管理的文件副本,再把副本和已经确定的凭证主键建立关系。第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”与“凭证真正拥有了附件”分开了。选择权限、沙箱字节、附件关系、页面状态四层各自清楚,后续增加维修过程照片、发票和维修单时才能复用同一套关联机制,而不把系统文件访问地址当成业务主键。

参考资料:华为开发者:使用系统图片选择与文件复制的工程流程、华为开发者:PhotoPicker与图片读取。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐