06|HarmonyOS资料板附件来源失效与重新关联【共创稿事节】
现场资料板做到第五篇时,卡片已经能够按稳定ID跨组件投放。待整理栏里的“配电柜现场.jpg”移到“设备照片”后,源栏、目标栏和计数都从同一个BoardStore更新,拖拽过程中没有各组件各自保存一份数据。现在遇到的故障不再来自卡片移动:这张照片的卡片还在,标题、类型和缩略图入口也都保留着,但用户打开附件时,原来的文件来源已经无法访问。
这个问题很容易被当成“卡片损坏”,随后有人直接删除卡片、重新导入一张。这样处理会把第五篇整理好的栏位位置、审核意见和变更历史一起丢掉。更稳妥的选择是把卡片身份与文件来源拆开:卡片ID及其业务位置继续保留,来源URI和访问状态单独管理,重新选择文件只更新绑定版本。这里的来源包括通过文件选择入口得到的用户文档,也包括业务内已登记的其他合法来源;不同来源的授权范围需要分别判断。

卡片还在,不等于文件仍可打开
资料板里的附件至少包含两层身份。第一层是cardId,比如设备照片栏中的photo-01,表示这张资料卡片参与过怎样的排序和审核;第二层是attachmentId及其sourceUri,表示某次用户选择的文件来源。文件大小、MIME类型、名称、采集时间是附加元信息,能够帮助重新关联,但都不足以单独担保是同一个内容。一个文件可以改名,两个同名文件也可能内容完全不同。
附件目录可以保留attachmentId、cardId、mediaType、sourceUri、bindingRevision、status、lastCheckedAt等字段。若曾在得到适当读取权限时计算文件摘要,还可以保存内容摘要供人工核对,但不能为了校验直接越过授权去读取任意外部路径。页面展示文件名是一回事,真正打开前能否访问是另一回事。这两个状态分离以后,UI能够标记“来源失效”,而不是假装点击卡片一定能打开文件。
华为的文件访问文档强调通过选择器访问用户文件所涉及的授权与生命周期。临时授权受应用退出和设备重启等条件影响;需要持久访问时应按文档中的持久化授权流程处理,同时考虑能力及用户授予情况。业务存储一串URI并不自动带来永久文件权限。对于不能再使用的来源,界面应先提供重新选择入口,不应尝试猜测另一个未授权路径。
失效原因先归类,别立即覆盖来源字段
打开失败可能是URI当前不可访问、原文件被移动或删除、用户撤回了访问权限,也可能是内容类型发生变化。记录页使用统一的业务状态linked、unavailable、needsReview和relinkPending。unavailable表示无法正常打开;needsReview用于文件类型或关键元信息不符;relinkPending表示用户正在选择候选来源。页面不能把“来源失效”直接改成“资料卡片删除”,否则历史变更中的引用将失去位置。
我习惯把来源探测结果转换成清晰的状态对象,UI拿到后只负责展示,不参与决定文件是否可信。下面的纯JavaScript函数不触碰系统文件接口;适配层可以在取得合法授权后尝试打开来源,获得openable、MIME及字节数等信息,再交给规则层分类。实际打开后取得的句柄,应由适配层在成功或异常路径关闭。
const assert = require('node:assert/strict');
function classifySource(item, probe) {
if (!probe || probe.openable !== true) return { status: 'unavailable', reason: 'SOURCE_NOT_OPENABLE' };
if (probe.mime !== item.mediaType) return { status: 'needsReview', reason: 'MIME_CHANGED' };
if (!Number.isSafeInteger(probe.bytes) || probe.bytes < 0) return { status: 'needsReview', reason: 'BAD_SIZE' };
return { status: 'linked', reason: 'SOURCE_READY' };
}
const item = { mediaType: 'application/pdf' };
assert.equal(classifySource(item, null).status, 'unavailable');
assert.equal(classifySource(item, { openable:true, mime:'image/png', bytes:300 }).status, 'needsReview');
assert.equal(classifySource(item, { openable:true, mime:'application/pdf', bytes:300 }).status, 'linked');
同一个来源可能在手机和大屏环境下表现不同,尤其是系统授予的访问方式及生命周期并不相同。openable=false只应该写成当前检查结果,不能推导出文件永久消失;并且当用户离开页面时应取消新的探测请求,对晚到的检查响应核对当前绑定版本。否则旧URI检测失败的消息可能覆盖刚刚关联成功的新URI,让卡片从正常突然跳回红色告警。

重新选择文件时要保住原来那张卡片
重新关联并非新建一张资料卡。先根据cardId打开文件选择入口,拿到候选URI后重新读取能够取得的基本元信息。目标栏仍按第二篇的类型规则接收文件:设备照片栏允许约定的图片类型,检修记录栏允许文档类型。若新来源与原附件的MIME不一致,至少需要进一步确认,不能只看扩展名一致就默默替换。
当原卡片保存过内容摘要而候选摘要不一致时,业务应提醒“内容可能变化”,让审核人确定是同一份重新保存的文件,还是另一份新资料。摘要只是一种比对手段,计算摘要本身需要真实的文件读取权限和合理的数据量限制。对于很大的视频或多媒体资源,可以先核对类型与长度、再按产品约定安排摘要校验,避免在UI线程一次读取整份内容。候选文件没有被最终确认以前,原卡片仍保持失效状态和可追溯来源。
把规则写成一个可重复执行的函数,能够让文件选择页和卡片详情页得到一致的提示。这里明确把内容变化分类为待确认,而不是自动拒绝;设备照片更新可能是现场补拍后的合法版本更新,不能拿完全相同的摘要作为业务上唯一的通过条件。
const assert = require('node:assert/strict');
function compareCandidate(old, candidate) {
const problems = [];
if (!candidate?.openable) problems.push('CANNOT_OPEN');
if (old.mediaType !== candidate?.mime) problems.push('TYPE_MISMATCH');
if (typeof candidate?.name !== 'string' || candidate.name.trim() === '') problems.push('NO_NAME');
if (old.sha256 && candidate?.sha256 !== old.sha256) problems.push('CONTENT_CHANGED');
return { accepted: problems.length === 0, problems };
}
const original = { mediaType:'application/pdf', sha256:'digest-1' };
assert.equal(compareCandidate(original, {openable:true,mime:'application/pdf',name:'巡查记录.pdf',sha256:'digest-1'}).accepted,true);
assert.deepEqual(compareCandidate(original, {openable:true,mime:'application/pdf',name:'巡查记录.pdf',sha256:'digest-2'}).problems,['CONTENT_CHANGED']);
页面上至少需要显示原名称、原类型、目标栏、候选文件名称和差异提示。交互中途取消时,候选信息直接丢弃,原来的失效状态保留。如果选择器返回空结果,按取消操作处理,避免写进空URI。用户重新选择了一份文件但还没有确定,状态可以叫relinkPending,此时其他组件不应该把它展示成已经可正常使用的附件。
一次确认只改变一条绑定关系
第五篇让BoardStore统一处理拖拽移动,本篇仍沿用那个状态归属原则。来源重绑提交也必须经过BoardStore的版本核对。假设用户在详情页打开重绑弹窗时资料板版本为8、绑定版本为2,另一个入口已经先一步把文件关联到新版来源,则原弹窗确认不能把较新的绑定覆盖掉。两个版本号用于不同冲突面:板级版本防止跨组件旧状态写回,绑定版本防止同一附件的迟到确认覆盖新来源。
下面的业务层函数要求卡片存在、两个版本均匹配、候选URI非空且用户已确认。成功后卡片ID、栏位和排序位置不变,仅更新来源URI、状态及绑定版本,并产生一个独立历史条目。实际持久化时,BoardStore与数据库适配层应确保变更与历史记录原子提交;内存对象版本一致不等于底层存储已经安全落盘。
const assert = require('node:assert/strict');
function commitRebind(board, intent) {
const card = board.cards[intent.cardId];
if (!card) return { ok:false, reason:'MISSING_CARD' };
if (board.revision !== intent.expectedBoardRevision || card.bindingRevision !== intent.expectedBindingRevision)
return { ok:false, reason:'STALE_REVIEW' };
if (!intent.candidateUri || !intent.approved) return { ok:false, reason:'NEEDS_CONFIRMATION' };
const nextCard = {...card, sourceUri:intent.candidateUri, status:'linked', bindingRevision:card.bindingRevision+1};
return { ok:true, board:{...board, revision:board.revision+1, cards:{...board.cards,[card.id]:nextCard}}, history:{cardId:card.id, action:'rebind', fromRevision:card.bindingRevision, toRevision:nextCard.bindingRevision}};
}
const board = {revision:8,cards:{'photo-01':{id:'photo-01',bindingRevision:2,status:'unavailable',sourceUri:'old-uri'}}};
const intent = {cardId:'photo-01',expectedBoardRevision:8,expectedBindingRevision:2,candidateUri:'new-uri',approved:true};
const applied = commitRebind(board, intent);
assert.equal(applied.board.cards['photo-01'].bindingRevision,3);
assert.equal(commitRebind(applied.board,intent).reason,'STALE_REVIEW');
对已保存过的URI,重绑后的重新打开检查仍有意义。候选来源当时能够读取,并不意味着之后永远可访问。卡片内容区应区分“文件可访问”“需要重审”“来源失效”,同时保留原附件的审核意见、拖拽历史和所属栏。若业务需要导出资料板,导出程序也应先检查关联状态,对失效附件列出可读的缺失清单,而不是生成一份看似完整但部分文件为空的归档。
三种异常容易漏到最后
第一种是用户连续两次重新选择:第一个选择器回调迟到,应对话框会话编号与绑定版本双重核对;第二种是文件探测超时:保持旧状态并允许重试,不能把“超时”记录成“文件确实不存在”;第三种是来源内容已经换成另一类文件:类型与目标栏不符时必须要求人工确认或重新选择。还有一种实际排查时常见的情况是同一文件的显示名称发生变化,名称不一致应作为线索,不应直接认定内容不同。
把状态变化做成可以查询的历史比直接弹一个错误提示更有价值。记录应该包含卡片ID、原绑定版本、新绑定版本、操作类型和检查原因;来源URI可能带有不适合展示的敏感信息,诊断页面使用摘要或经过脱敏的标识即可。关闭页面或切换卡片时撤销未确认候选并释放临时文件句柄,避免持有不再需要的访问资源。

经过这一层拆分,资料板的卡片仍可以移动、审阅和追踪,外部文件来源却能独立失效、重新选择与再次校验。稳定的是业务卡片身份,不稳定的是外部文件访问条件。这个区分能让后续大屏键鼠操作和历史撤销继续围绕同一个BoardStore演进,而不必每次附件不可用都推倒整张资料板。
更多推荐



所有评论(0)