Web 工单要调用相机,第一步不是打开取景框,而是建立能力门禁

在混合工作台里,"从 Web 工单拍一张现场照片"听起来像一个很短的链路:网页点击拍照,调用相机,照片回填表单。真正落到工程时,它至少横跨 Web 页面、容器桥接、运行时权限、设备能力、原生会话、文件结果和 Web 附件状态。只要其中一段未完成,页面就不能把下一段预先写成成功。

Web 容器开发者处理这类需求时,最重要的不是设计一个更像相机的取景框,而是建立一条可以追踪的请求链。页面要清楚表达:Web 端何时提出了什么请求;容器是否收到并接受该请求;权限和设备能力是否已核验;原生拍摄是否真正开始;结果是否已经成为可被工单使用的附件。每一个问题都有不同的证据来源,不能被一个"已拍照"状态合并掉。

本文基于工程第44篇"货架盘点 Web 取景门禁"页面说明这一模型。当前 Demo 能即时切换构图与焦距的请求参数,建立一条本地 Web 相机请求,创建附件占位,并登记人工拍摄出口。它不调用真实 Camera,不申请权限,不查询设备能力,也不写入任何图片文件。因而本文不会将页面上的取景框写成实时预览,更不会把附件占位描述为已经回传的照片。

从工单动作开始,而不是从相机 API 开始

影像工程里常从镜头、对焦、构图能力开始讨论;Web 容器开发的起点应当是工单动作。以货架盘点为例,Web 页面发出的不是抽象的"开相机"命令,而是"为盘点任务 SHELF-44 采集条码与货位区域"的业务请求。这个请求必须带有可关联的身份,后续权限提示、能力查询、用户取消、人工补拍和附件回填都要回到同一条记录。

页面用 requestIdsubjectframingfocal 和状态文本组成最小请求表达。subject 指明页面当前希望采集的对象;构图和焦距是 Web 侧的意图参数;requestId 是容器与原生层关联的键;状态文本只描述当前推进到哪个阶段。它们的存在并不说明 Camera 端接受了这些参数。

ts 复制代码
@State framing: string = '主体优先';
@State focal: string = '1.0x';
@State requestState: string = '尚未发起 Web 请求';
@State attachmentState: string = '尚无附件,等待原生结果';

private prepareRequest(): void {
  this.requestState = `Web 请求 ${this.requestId} 已建立,等待权限与设备能力核验`;
  this.attachmentState = '附件占位已创建,未写入真实拍摄结果';
}

这里的"已建立"只表示 ArkTS 页面在本地保存了请求。它不是权限已同意、能力已支持,更不是相机已经打开。状态文字故意保留"等待权限与设备能力核验",就是为了让读者看到请求建立与能力执行之间还有一层门禁。工程实践中,越早把这层分开,越不容易出现用户拒绝权限后,工单却仍显示"拍照完成"的错误状态。
#mermaid-svg-xpFlWB8WIj1dl32q{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xpFlWB8WIj1dl32q .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xpFlWB8WIj1dl32q .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xpFlWB8WIj1dl32q .error-icon{fill:#552222;}#mermaid-svg-xpFlWB8WIj1dl32q .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xpFlWB8WIj1dl32q .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xpFlWB8WIj1dl32q .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xpFlWB8WIj1dl32q .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xpFlWB8WIj1dl32q .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xpFlWB8WIj1dl32q .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xpFlWB8WIj1dl32q .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xpFlWB8WIj1dl32q .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xpFlWB8WIj1dl32q .marker.cross{stroke:#333333;}#mermaid-svg-xpFlWB8WIj1dl32q svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xpFlWB8WIj1dl32q p{margin:0;}#mermaid-svg-xpFlWB8WIj1dl32q .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xpFlWB8WIj1dl32q .cluster-label text{fill:#333;}#mermaid-svg-xpFlWB8WIj1dl32q .cluster-label span{color:#333;}#mermaid-svg-xpFlWB8WIj1dl32q .cluster-label span p{background-color:transparent;}#mermaid-svg-xpFlWB8WIj1dl32q .label text,#mermaid-svg-xpFlWB8WIj1dl32q span{fill:#333;color:#333;}#mermaid-svg-xpFlWB8WIj1dl32q .node rect,#mermaid-svg-xpFlWB8WIj1dl32q .node circle,#mermaid-svg-xpFlWB8WIj1dl32q .node ellipse,#mermaid-svg-xpFlWB8WIj1dl32q .node polygon,#mermaid-svg-xpFlWB8WIj1dl32q .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xpFlWB8WIj1dl32q .rough-node .label text,#mermaid-svg-xpFlWB8WIj1dl32q .node .label text,#mermaid-svg-xpFlWB8WIj1dl32q .image-shape .label,#mermaid-svg-xpFlWB8WIj1dl32q .icon-shape .label{text-anchor:middle;}#mermaid-svg-xpFlWB8WIj1dl32q .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xpFlWB8WIj1dl32q .rough-node .label,#mermaid-svg-xpFlWB8WIj1dl32q .node .label,#mermaid-svg-xpFlWB8WIj1dl32q .image-shape .label,#mermaid-svg-xpFlWB8WIj1dl32q .icon-shape .label{text-align:center;}#mermaid-svg-xpFlWB8WIj1dl32q .node.clickable{cursor:pointer;}#mermaid-svg-xpFlWB8WIj1dl32q .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xpFlWB8WIj1dl32q .arrowheadPath{fill:#333333;}#mermaid-svg-xpFlWB8WIj1dl32q .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xpFlWB8WIj1dl32q .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xpFlWB8WIj1dl32q .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xpFlWB8WIj1dl32q .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xpFlWB8WIj1dl32q .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xpFlWB8WIj1dl32q .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xpFlWB8WIj1dl32q .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xpFlWB8WIj1dl32q .cluster text{fill:#333;}#mermaid-svg-xpFlWB8WIj1dl32q .cluster span{color:#333;}#mermaid-svg-xpFlWB8WIj1dl32q div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xpFlWB8WIj1dl32q .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xpFlWB8WIj1dl32q rect.text{fill:none;stroke-width:0;}#mermaid-svg-xpFlWB8WIj1dl32q .icon-shape,#mermaid-svg-xpFlWB8WIj1dl32q .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xpFlWB8WIj1dl32q .icon-shape p,#mermaid-svg-xpFlWB8WIj1dl32q .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xpFlWB8WIj1dl32q .icon-shape .label rect,#mermaid-svg-xpFlWB8WIj1dl32q .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xpFlWB8WIj1dl32q .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xpFlWB8WIj1dl32q .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xpFlWB8WIj1dl32q :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

Web 工单点击采集
创建 requestId 与采集意图
容器校验页面与业务对象
申请权限并查询设备能力
门禁通过?
原生会话执行采集
返回文件或失败结果
写入 Web 附件状态
保留请求并提供人工回退

这条图里没有直接把"构图:主体优先"连到"已采集"。构图参数在 Web 层只是用户或业务规则提出的期望。原生层若不支持自动构图、当前镜头不支持对应焦距、权限未通过,容器都应返回可解释的门禁结果。只有明确的回调或文件结果,才有资格更新附件状态。

权限门禁与能力门禁必须分开保存

页面常见的一种错误是用一个布尔值 cameraAvailable 同时表达权限、设备、镜头和会话状态。这样的变量在正常路径里看似够用,一旦用户拒绝权限、设备无相机、相机被其他应用占用或某种构图能力不可用,就无法告诉 Web 页面该显示什么,也无法决定应该重试还是转人工。

建议容器至少区分四组信息。第一组是权限结果,例如未请求、请求中、被拒绝、已授权;第二组是设备与镜头能力结果,例如未查询、相机不可用、基础拍摄可用、特定构图能力未知;第三组是会话结果,例如未创建、创建中、已取消、执行失败;第四组才是附件结果,例如无附件、等待回写、已得到文件标识、人工补录待上传。它们可以在同一条请求记录里,但不能折叠为一个"成功/失败"。

ts 复制代码
interface WebCameraRequestState {
  requestId: string;
  permission: 'idle' | 'requesting' | 'denied' | 'granted';
  capability: 'unknown' | 'unavailable' | 'basic-ready' | 'feature-pending';
  session: 'idle' | 'creating' | 'cancelled' | 'failed' | 'completed';
  attachment: 'none' | 'placeholder' | 'returned' | 'manual-pending';
}

这段接口是生产扩展建议,当前 Demo 没有接入真实相机状态。它的价值在于让回退动作有依据。若 permissiondenied,UI 应提供再次申请或系统设置引导;若 capabilityunavailable,重试权限没有意义,应转人工拍摄;若会话被取消但权限和能力都正常,用户可以重新建立一次请求;若附件回写失败,则需要保留已获得的原生结果标识供后续重传。不同失败阶段需要不同按钮,不能一律显示"请重试"。

Web 请求参数要有边界,不要冒充原生控制结果

第44篇 Demo 可以切换构图与焦距:主体优先/局部居中1.0x/1.5x。这些变化会立即反映在右侧取景卡上,因此非常适合核验 Web 页面是否正确保存了用户输入。但卡片里的边框和文字是 ArkUI 视觉表达,不是相机预览流;焦距文本是请求值,不是镜头实际已应用的值。

ts 复制代码
private switchFraming(): void {
  this.framing = this.framing === '主体优先' ? '局部居中' : '主体优先';
}

private switchFocal(): void {
  this.focal = this.focal === '1.0x' ? '1.5x' : '1.0x';
}

这两个方法没有调用 Camera API,所以它们可以证明"页面参数随操作变化",却不能证明"真实镜头已切到 1.5 倍"或"自动构图已选择主体"。写文章时必须保留这种区分。很多技术文章把 UI 上展示的配置直接叫作"效果",会让后续真机验证无从下手,因为开发者不知道需要补的是参数传递、能力查询、还是图像视觉结果。

正确的桥接协议通常将请求值和应用值并列保存。Web 发送 requestedFocalrequestedFraming;原生层返回 appliedFocalappliedFraming 或明确的拒绝原因;Web 页面再根据回调写入"已应用""部分应用"或"未应用"。如果设备只支持基础拍摄,不支持自动构图,页面仍可以继续完成工单,只需将构图字段标为未应用,并允许用户按人工指引拍摄。业务连续性来自诚实降级,不来自将请求文字伪装成设备结论。
Web 附件区 原生相机会话 权限与能力门禁 容器桥接层 Web 工单 Web 附件区 原生相机会话 权限与能力门禁 容器桥接层 Web 工单 #mermaid-svg-0WCJ6k4k6pI6iqBJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0WCJ6k4k6pI6iqBJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .error-icon{fill:#552222;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .marker.cross{stroke:#333333;}#mermaid-svg-0WCJ6k4k6pI6iqBJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0WCJ6k4k6pI6iqBJ p{margin:0;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0WCJ6k4k6pI6iqBJ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0WCJ6k4k6pI6iqBJ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-0WCJ6k4k6pI6iqBJ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .sequenceNumber{fill:white;}#mermaid-svg-0WCJ6k4k6pI6iqBJ #sequencenumber{fill:#333;}#mermaid-svg-0WCJ6k4k6pI6iqBJ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .messageText{fill:#333;stroke:none;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .labelText,#mermaid-svg-0WCJ6k4k6pI6iqBJ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .loopText,#mermaid-svg-0WCJ6k4k6pI6iqBJ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0WCJ6k4k6pI6iqBJ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .noteText,#mermaid-svg-0WCJ6k4k6pI6iqBJ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .actorPopupMenu{position:absolute;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-0WCJ6k4k6pI6iqBJ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0WCJ6k4k6pI6iqBJ .actor-man circle,#mermaid-svg-0WCJ6k4k6pI6iqBJ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-0WCJ6k4k6pI6iqBJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt门禁通过门禁未通过 requestId + 对象 + 请求参数检查权限与能力可用性或拒绝原因创建采集会话结果标识或错误更新匹配 requestId 的附件保持附件占位并给出回退

附件占位不是附件,人工出口也不是文件上传成功

Web 表单经常需要在用户发起拍摄后立即给出反馈,否则用户会重复点击、离开页面或误以为操作没有生效。第44篇页面因此在建立请求后写入"附件占位已创建,未写入真实拍摄结果"。这是一种有用的 UI 状态:它告诉用户工单正在等待一个外部结果,也让容器能够把后续返回与正确的任务关联起来。

但占位不能携带已经存在的文件 URI、缩略图或上传成功时间。没有真实文件,就不应生成假附件条目。更合适的做法是显示请求编号、采集对象、当前门禁阶段和可用操作。例如"等待权限结果""设备不支持,转人工""原生结果已到达,等待文件写入""人工文件待选择"。每个状态都能回答下一步要做什么,也不会让后台在没有文件的情况下误把工单推进到已完成。

ts 复制代码
private useManualFallback(): void {
  this.attachmentState = `${this.fallbackLabel}已登记;不代表已接收人工文件`;
}

"登记人工拍摄"或"登记人工上传"同样只是建立一个回退任务。它可以用于通知现场人员、打开文件选择入口或留下待补记录,但只有用户确实选择文件、容器校验类型与大小、文件持久化成功并写回业务记录后,状态才能变为真实附件已就绪。把人工路径也拆成若干阶段,可以防止团队因为看见回退按钮就误认为流程已经闭环。

请求应由容器负责去重和失效

Web 页面允许用户连续点两次"建立相机请求"是很常见的。若每次点击都创建原生会话,用户会得到两个权限弹窗、两条附件占位,甚至两张照片竞争同一工单字段。反过来,若容器用一个全局 isCapturing 简单拦截,又可能把设备 A 的请求错误地阻塞到设备 B 的页面。去重的粒度应是业务对象与请求生命周期,而不是整个应用的一个布尔开关。

可以先为每个工单对象维护一个活动请求版本。相同页面、相同业务对象且仍处于 requestingcreating 的请求,点击后返回现有 requestId;参数发生变化时创建新版本,并将旧版本标为过期。回调到达时,容器只允许版本仍匹配的结果写入附件区。旧回调不应丢弃原始日志,但不能覆盖用户最新一次请求。

ts 复制代码
function mayApplyCameraResult(
  activeRequestId: string,
  callbackRequestId: string
): boolean {
  return activeRequestId === callbackRequestId;
}

这段判断很小,却说明了一个原则:附件回写依据的是创建时的请求身份,而不是回调到达时页面当前显示的对象。实际工程还要加会话版本、用户取消标记和文件完整度校验,但最基本的"只让匹配请求写回"应先建立。第44篇当前只展示一个本地请求,没有真实并发回调,因此这部分属于生产方案,不能写成 Demo 已完成的竞争控制。

验证时要把"看到取景框"和"得到相机结果"分层

第44篇页面在模拟器中首先可以验证三个本地动作:构图与焦距的文本是否随点击同步更新;建立 Web 请求后右侧状态是否变成等待门禁;点击人工出口后附件区域是否明确说明"已登记但未接收文件"。这些是页面真实可观察的交互结果。

进入真机后,验证应继续分段。先确认相机权限请求是否出现,用户拒绝、允许时各自返回什么状态;再确认目标设备是否具备所需基础相机能力,自动构图和焦距相关能力是否可查询;然后才创建会话,核对实际应用参数和拍摄结果;最后验证文件保存、附件上传与工单持久化。每一步都记录 requestId、权限结论、能力原值、会话结果和附件标识。不要用"页面启动并显示取景框"代替后面任何一步。

模拟器适合重复点击、请求去重、状态文案和布局边界测试。真机适合确认真实权限面板、镜头可用性、触摸体验、拍摄回调和文件路径。两者形成互补,而不是互相佐证。若当前测试设备不支持某项能力,页面应如实显示能力待核验或不可用,并让人工路径可继续执行;这比为了演示而固定显示"自动构图已开启"更符合实际交付。

桥接协议需要校验来源、版本与恢复策略

Web 页面向原生层发送相机请求时,不能把任意一段 JSON 当作可信命令直接执行。容器至少要校验请求来自允许的页面或域、请求结构符合约定版本、业务对象仍属于当前会话,以及构图和焦距值落在允许范围内。这样做既避免错误页面误触发系统能力,也让协议演进有明确边界。例如新增一个拍摄模式时,旧 Web 页可以被容器识别为旧版本,容器返回"当前版本不支持该参数",而不是在运行时悄悄忽略字段并让用户误以为已经生效。

请求体通常不应直接带文件路径、系统能力结论或可由原生层自行计算的敏感字段。Web 层负责表达业务对象和用户意图,容器负责将其映射为受控的原生参数。比如网页传入"条码与货位优先"这一业务选择,原生层再根据实际镜头与算法能力决定能否应用。这样 Web 页面不会依赖设备私有细节,容器也能在不同设备上给出一致的降级语义。

ts 复制代码
interface WebCameraCommand {
  protocolVersion: number;
  requestId: string;
  pageKey: string;
  businessKey: string;
  requestedFraming: string;
  requestedFocal: string;
}

function validateCommand(command: WebCameraCommand): string {
  if (command.protocolVersion !== 1) return '协议版本不匹配';
  if (command.requestId === '' || command.businessKey === '') return '请求身份不完整';
  return '可进入权限与能力门禁';
}

这段代码是协议校验示意,当前 Demo 并未实现 Web 消息通道。它强调的是校验发生的位置:先在容器入口拒绝无效命令,再进行系统权限与能力访问。若把校验放到相机会话创建之后,页面可能已经触发不必要的权限提示或资源占用,错误状态也会更难归因。

页面恢复是另一项需要提前设计的边界。用户在权限弹窗出现时切到后台、旋转设备,或 Web 视图因内存回收被重建,工单不能只依赖组件内的瞬时 @State。容器应持有最小请求快照,页面恢复后按 requestId 查询当前终态:仍在等待就恢复附件占位;已被拒绝就显示明确原因;已有原生结果则进入回写或重试分支;若会话已经过期,则提示用户重新发起。恢复的目标不是让相机在后台继续偷偷工作,而是让用户回到页面时仍能理解上次操作停在哪一步。

对于人工回退,也需要记录触发原因。权限被拒绝、设备缺相机、特定能力不支持、会话创建失败和用户主动取消,虽然都会把用户带到人工路径,但它们代表不同的后续动作。按原因统计可以发现是权限文案问题、设备适配问题还是桥接稳定性问题;按原因展示则让现场人员知道是否需要重新授权、换设备还是直接补传照片。容器把这些原因结构化保存,文章中的"人工出口"才不是一个模糊的兜底按钮。

建议容器对外提供稳定的阶段码,例如 WEB_REQUESTEDPERMISSION_DENIEDCAPABILITY_UNAVAILABLESESSION_CANCELLEDATTACHMENT_PENDINGMANUAL_FALLBACK_CREATED。页面不必直接展示这些英文码,但日志和审计记录应保留它们,便于跨端排查;页面则根据阶段码给出面向操作的说明,如"请授权相机""当前设备不支持自动采集""已创建人工补拍任务"。同一个阶段码既避免产品文案随意变化破坏统计,也防止业务层把"没有照片"笼统归类成相机故障。

状态文案还应避免承诺用户无法验证的细节。比如能力尚未查询时不要写"设备不支持",只写"等待设备能力核验";会话还未创建时不要写"相机忙",只写"等待原生会话结果"。这看似只是措辞区别,实际上决定了用户会选择重新授权、稍后再试还是人工补传。容器能给出多准确的阶段,就应该让页面使用多准确的动作提示。

一次完整的真机复现可按固定序列执行:清除上次活动请求,打开指定货架任务,记录初始 requestId 与参数;点击建立请求,在权限提示出现前截取容器状态;分别拒绝和允许权限,记录阶段码;在允许分支中读取能力并尝试创建会话;只有取得真实结果标识后,才观察附件是否变化。最后重复一次用户主动取消和人工补拍路径。这样同一个样例可以清楚区分是 Web 页面、权限、设备、会话还是附件层出了问题,不会把"没有照片"归成一个无法行动的总错误。

所有结果都应回到同一条 requestId:没有这个关联键,即使真机日志里出现了相机事件,也无法判断它究竟属于哪张工单。

取消分支也必须清理得有边界。用户关闭权限提示、主动返回工单或容器收到会话取消时,应该结束当前请求的可执行状态,但不能删除请求快照和已记录的原因。附件占位可以转为"已取消,尚无附件",人工出口仍保持可用;只有用户重新操作时才创建新的请求版本。这样既不会让旧会话继续回写,也不会让现场人员失去为什么要补拍的依据。对于已经开始文件写入的请求,取消还要由原生层报告写入是否可安全中止,Web 页面不能凭一个返回按钮自行推断文件不存在。

页面恢复后也应尊重取消标记,不能因为重新创建了组件就把旧请求重新显示为等待中。

这条规则能避免用户在回到工单后反复确认同一个已经结束的采集动作。

FAQ:Web 工单相机能力门禁常见问题

问题一:建立了 Web 相机请求,为什么不能马上显示"正在拍摄"?

建立请求只说明容器收到了页面意图。权限、设备能力和原生会话还未确认,过早显示会把请求状态误写成执行状态。

问题二:构图和焦距按钮变了,是否代表真实相机参数已改变?

不是。当前 Demo 只改变 ArkTS 页面请求值。真实应用值需要由相机会话或能力回调返回后再写入。

问题三:权限被拒绝后应该自动不断重试吗?

不应。应记录拒绝状态,给出用户可理解的重新授权或人工回退入口;频繁弹窗会破坏工单流程,也不解决设备不可用的问题。

问题四:为什么权限和能力要分两个状态?

用户授权不等于设备支持全部功能,设备有相机也不等于当前会话可用。分开保存才能给出正确的下一步操作。

问题五:附件占位能否先上传一个空文件?

不能。空文件会被误当作业务附件。占位只应保存请求和状态,真实文件产生后再进行类型、大小、路径和持久化校验。

问题六:人工拍摄登记后,工单能否直接完成?

不能。登记只说明人工路径已创建。还需要真实文件选择、上传或保存成功、关联校验和工单状态更新。

问题七:用户连续点击拍照该怎么处理?

容器应按业务对象和活动请求版本去重。相同未完成请求复用现有 ID,参数变化则创建新版本并让旧回调失效。

问题八:第44篇当前能证明哪些内容?

它能证明页面内请求、参数、附件占位和人工出口的状态变化;不能证明真实权限、Camera 能力、拍摄结果、文件写入或附件回传。

结语

Web 工单接入相机,不是一条"按钮到照片"的直线,而是一组有前后关系的门禁。先用请求 ID 保存 Web 意图,再分别核验权限、能力、会话和附件结果,失败时保留可执行的人工出口,容器才能既让工单不中断,也不把尚未发生的相机结果写成完成事实。

附录

A. 开发环境要求

使用安装 HarmonyOS 6.1.1 API 24 SDK 的 DevEco Studio,开发语言为 ArkTS,UI 使用 ArkUI Stage 模型。sourceproject/build-profile.json5 的三个 SDK 版本字段保持 6.1.1(24),并在 SDK Manager 安装匹配的 etsnativetoolchainspreviewer 组件。

B. 工程配置要求

本篇页面源码位于:

text 复制代码
entry/src/main/ets/pages/batch03/ShelfInventoryCameraPage.ets

通用桥接工作区位于:

text 复制代码
entry/src/main/ets/components/web/WebCameraBridgeWorkspace.ets

路由登记在:

text 复制代码
entry/src/main/resources/base/profile/main_pages.json

sourceproject 目录执行:

powershell 复制代码
.\hvigorw.bat --mode module -p module=entry@default -p product=default assembleHap --no-daemon

BUILD SUCCESSFUL 仅表示编译和打包通过;真实 Camera 权限、能力与拍摄仍需目标设备验证。

C. 测试环境要求

模拟器使用 HarmonyOS 6.1.1 API 24,固定货架对象和初始构图、焦距,分别执行参数切换、建立请求和人工出口,检查右侧状态与附件占位。真机测试应准备可用调试签名、相机权限和合法的盘点测试对象,按权限、能力、会话、文件和附件回写顺序记录结果。

D. 运行环境要求

模拟器适合页面交互、请求去重和状态回退;真机用于验证相机权限面板、设备能力、镜头表现、拍摄回调与文件处理。生产环境还需约定 Web 与原生桥接协议、附件存储位置、上传服务、失败重试和敏感影像数据处理规则。当前 Demo 不调用真实 Camera,不代表已经采集任何照片。

相关推荐
浪里镖客2 小时前
realsense d435i相机所有内外参获得
数码相机
fthux2 小时前
不必下载整个仓库:GitZip Pro 让 GitHub 文件与文件夹批量下载更简单
前端·chrome·ai·edge·开源·github·firefox
奥莱维3 小时前
【无标题】
java·前端·javascript
用户921080262863 小时前
0. 为什么我们的项目选择 Cesium:从三维地图、离线部署到工程代价
前端
阿懂在掘金3 小时前
同一份弹窗我重构了三次:从 v-model 地狱到路由式调用,终于治好了模板臃肿
前端·vue.js·前端框架
悟空瞎说3 小时前
从 CRA 到 Vite:含 Cesium 的真实项目迁移实战记录
前端
悟空瞎说3 小时前
Vite 中零配置接入 Cesium.js:vite-plugin-cesium-engine 深度解析
前端
To_OC3 小时前
后端接口还没交付,前端如何独立把整套业务跑通
前端·react.js·全栈
王琦03183 小时前
WEB服务
前端