项目上线的最后一公里,最怕的不是签名板画不出一笔,而是所有人都看见了笔迹,却没有人能说清这笔迹究竟完成了哪一步。现场工程师认为“客户已经签字”;实施人员认为“页面已经确认”;项目经理以为“附件已经归档”;客户代表则等待一份可以追溯的交付凭证。若这四种理解没有在验收前对齐,后续出现工单找不到、附件缺失、签署人不一致时,任何一方都可能认为责任在别处。

本工程提供的是一个全屏手写板:它接收触摸坐标、即时重绘笔迹、判断有效笔画、在当前页面创建本地确认记录,并支持历史笔迹只读预览。它没有导出图片、没有调用工单接口,也没有向客户档案或对象存储写入附件。因此,本文的目标不是解释如何“做一个签名功能”,而是建立一套实施口径:什么结果可以在现场签收时确认,什么材料必须在交接单中注明待补,哪个角色应在下一步接手。

一、把“签好了”拆成四个验收结论

现场签收不要只使用“已签字”一个状态。实施顾问需要将其拆成四个彼此独立的结论,分别记录在操作记录、交付清单或问题台账中。

验收结论 当前工程能否支持 现场可观察证据 下一责任方
手写输入已经采集 可以 画布出现连续笔迹;状态提示完成采集 现场工程师
本地确认已经生成 可以 有效笔画通过后,历史列表增加本地记录 实施人员
交付附件已经生成 不可以 需要文件名、文件大小、存储位置或下载核验 应用服务/文档服务负责人
工单和客户档案已归档 不可以 需要接口回执、工单状态、归档编号与客户确认 业务系统对接人与客户代表

这不是措辞游戏。四个结论对应四类证据、四个责任面和不同的故障处理方式。当前工程的状态文案已经明确写出“本地确认已记录,未回写真实工单”,因此实施记录不能把它压缩成“工单已签收”。

在这里插入图片描述

图1:现场开始前先核对任务对象、页面模式和签字区域;画面仅用于证明页面交互语境,不代表业务工单已经回写。

现场工程师书写

页面采集轨迹

实施人员核对有效笔画

页面创建本地确认记录

是否已接入附件与工单接口

登记待交接项

取得附件标识与工单回执

客户代表核对交付材料

指定系统对接责任人和完成时限

1.1 实施现场的最小交付口径

在没有外部归档接口的当前范围内,建议把当天的现场结论写为:

已完成现场笔迹采集与页面内本地确认;附件生成、工单回写和客户档案归档不在当前工程验证范围内,需由业务系统链路另行回执。

这句话既没有贬低当前能力,也没有替未来接口提前背书。对项目负责人而言,清楚标记“已完成”和“待交接”比一条看似完整的结论更能降低返工成本。

二、先核对交付对象,再打开手写板

签名不应该脱离业务对象存在。工程页面以工单WO-882、设备“2号冷却泵”和“完工确认”作为页面内的现场语境。它并不读取真实工单,但实施人员在操作前仍应完成对象核对:工单编号、设备标识、签署事项、现场签署人和操作时间必须来自当次交付,而不能依靠上一位人员留下的页面状态。

核对项目 现场工程师要确认 实施顾问要留痕 当前页面提供的辅助
任务对象 本次是否为目标工单和目标设备 工单号、设备名、签署事项 页面显示WO-882和设备语境
签署角色 谁在签、签的是什么事项 签署人身份核验方式 本地记录使用“维修人员”占位
页面状态 是否处于新建签名而非历史预览 起始状态、操作人、时间 “新建签名”或“历史预览”标签
交付目标 是完成现场确认,还是要求生成附件 是否需要后续接口交接 本页不具备导出和回写能力

尤其需要避免一个常见错误:在历史预览页面继续让新的签署人书写。工程在预览模式下将笔迹锁定,触摸时会提示“历史签名只读,请先退出预览后新建签名”。这项限制对应的不是UI偏好,而是交付可追溯性:历史轨迹和本次轨迹必须可区分,否则实施人员无法解释哪一笔属于谁。

在这里插入图片描述

图2:现场书写后应结合状态提示核对有效笔画,再决定是否执行本地确认;按钮本身不构成附件或归档回执。

private handleSignatureTouch(event: TouchEvent): void {
  if (this.viewMode === 'preview') {
    this.signatureStatus = '历史签名只读,请先退出预览后新建签名';
    return;
  }
  const point = this.pointFromTouch(event);
  if (event.type === TouchType.Down && point) {
    const stroke: Stroke = { points: [point] };
    this.activeStroke = stroke;
    this.strokes = [...this.strokes, stroke];
    this.signatureStatus = '正在采集现场签名…';
  }
}

这段代码能支撑的验收结论是:预览状态不会把新的触摸输入追加到历史笔迹中。
它不能支撑的结论是:历史签名已经经过身份认证,或本次签署人的身份已经由系统核验。

二点五、状态机完整实现:从新建到已确认的全流程代码

实施顾问需要理解页面如何从启动、采集、确认转移到已锁定的完整过程。下面是状态管理的核心实现:

enum SignaturePageState {
  INITIALIZING = 'initializing',      // 页面启动中,等待业务对象加载
  READY_FOR_NEW = 'ready_for_new',    // 已准备新建签名
  COLLECTING = 'collecting',          // 采集中,触摸输入已启动
  REVIEW_STROKES = 'review_strokes',  // 笔画审查中,等待有效性判定
  AWAITING_CONFIRM = 'awaiting_confirm', // 笔画有效,等待本地确认操作
  CONFIRMED = 'confirmed',             // 已本地确认,进入只读状态
  PREVIEW_MODE = 'preview_mode',      // 历史预览中,不接收新输入
  ERROR = 'error'                      // 异常状态
}

class SignaturePageController {
  private state: SignaturePageState = SignaturePageState.INITIALIZING;
  private strokes: Stroke[] = [];
  private activeStroke: Stroke | undefined;
  private confirmHistory: SignatureHistoryRecord[] = [];
  private stateChangeLog: StateChangeRecord[] = [];  // 用于实施审计
  
  // 每次状态转移都记录
  private transitionState(fromState: SignaturePageState, toState: SignaturePageState, reason: string): void {
    this.stateChangeLog.push({
      timestamp: new Date().toISOString(),
      fromState,
      toState,
      reason,
      operatorRole: this.currentOperator  // 记录是谁触发的状态转移
    });
    this.state = toState;
  }
  
  // 启动新建签名流程前的对象核对
  public prepareNewSignature(workOrder: WorkOrderContext): boolean {
    if (this.state === SignaturePageState.INITIALIZING) {
      // 核对工单、设备、事项与前次操作是否相同
      if (this.lastWorkOrder && this.lastWorkOrder.id === workOrder.id) {
        throw new Error(`工单 ${workOrder.id} 已在当前会话中签署过,禁止重复签名`);
      }
      this.lastWorkOrder = workOrder;
      this.transitionState(SignaturePageState.INITIALIZING, SignaturePageState.READY_FOR_NEW, 
        `已核对工单 ${workOrder.id}、设备 ${workOrder.equipment}、事项 ${workOrder.action}`);
      return true;
    }
    return false;
  }
  
  // 处理触摸事件的状态转移
  private handleSignatureTouch(event: TouchEvent): void {
    switch (this.state) {
      case SignaturePageState.PREVIEW_MODE:
        this.signatureStatus = '历史签名只读,请先退出预览后新建签名';
        return;
      
      case SignaturePageState.READY_FOR_NEW:
        // 从准备状态转入采集中
        this.transitionState(SignaturePageState.READY_FOR_NEW, SignaturePageState.COLLECTING,
          '触摸输入已启动,开始采集笔迹');
        // 不加 break,继续执行采集逻辑
      
      case SignaturePageState.COLLECTING:
        const point = this.pointFromTouch(event);
        if (event.type === TouchType.Down && point) {
          const stroke: Stroke = { points: [point], startTime: Date.now() };
          this.activeStroke = stroke;
          this.strokes = [...this.strokes, stroke];
        }
        if (event.type === TouchType.Up && this.activeStroke) {
          // 笔画完成,评估是否有效
          if (this.isValidStroke(this.activeStroke)) {
            this.transitionState(SignaturePageState.COLLECTING, SignaturePageState.REVIEW_STROKES,
              `笔画有效,共 ${this.validStrokeCount()} 笔有效笔画`);
            this.signatureStatus = `笔迹已采集,共 ${this.validStrokeCount()} 笔有效笔画,可确认本地归档`;
          } else {
            this.signatureStatus = '笔迹过短,请重新签字';
          }
          this.activeStroke = undefined;
        }
        break;
      
      case SignaturePageState.ERROR:
        this.signatureStatus = '页面处于异常状态,请刷新页面重试';
        return;
      
      default:
        this.signatureStatus = '当前状态不支持签字输入';
    }
  }
  
  // 本地确认操作
  public confirmSignature(): boolean {
    if (this.state !== SignaturePageState.REVIEW_STROKES && 
        this.state !== SignaturePageState.AWAITING_CONFIRM) {
      this.transitionState(this.state, SignaturePageState.ERROR, 
        `非法状态转移:当前状态 ${this.state} 无法执行本地确认`);
      return false;
    }
    
    const count = this.validStrokeCount();
    if (count === 0) {
      this.signatureStatus = '请先完成有效签字';
      return false;
    }
    
    const record: SignatureHistoryRecord = {
      id: `local-${this.confirmHistory.length + 1}`,
      workOrderId: this.lastWorkOrder?.id,
      action: this.lastWorkOrder?.action || '完工确认',
      signer: this.currentOperator,
      signedAt: new Date().toISOString(),
      strokes: this.normalizedStrokes(),
      strokeCount: count,
      confirmTime: new Date().toISOString(),
      pageVersion: this.pageVersion  // 记录页面版本,便于版本升级兼容性检查
    };
    
    this.confirmHistory = [record, ...this.confirmHistory];
    this.transitionState(SignaturePageState.REVIEW_STROKES, SignaturePageState.CONFIRMED,
      `本地确认已完成,记录ID ${record.id},共 ${count} 笔有效笔画`);
    
    // 重要:确认后自动锁定输入
    this.strokes = [];
    this.signatureStatus = `本地确认已记录,共 ${count} 笔有效笔画,未回写真实工单`;
    return true;
  }
  
  // 进入历史预览
  public selectHistoryRecord(id: string): boolean {
    if (!this.confirmHistory.some(r => r.id === id)) {
      this.transitionState(this.state, SignaturePageState.ERROR,
        `历史记录 ${id} 不存在`);
      return false;
    }
    
    this.transitionState(this.state, SignaturePageState.PREVIEW_MODE,
      `已进入历史预览,记录 ${id}`);
    this.signatureStatus = '正在预览历史签名,笔迹已锁定,触摸无效';
    return true;
  }
  
  // 退出预览回到新建准备
  public exitPreview(): boolean {
    if (this.state !== SignaturePageState.PREVIEW_MODE) {
      return false;
    }
    
    this.transitionState(SignaturePageState.PREVIEW_MODE, SignaturePageState.READY_FOR_NEW,
      '已退出历史预览');
    this.signatureStatus = '已退出历史预览,可继续新建签名';
    this.strokes = [];
    return true;
  }
  
  // 撤销操作(仅在采集中允许)
  public undoLastStroke(): boolean {
    if (this.state !== SignaturePageState.COLLECTING && 
        this.state !== SignaturePageState.REVIEW_STROKES &&
        this.state !== SignaturePageState.AWAITING_CONFIRM) {
      return false;
    }
    
    if (this.strokes.length === 0) {
      return false;
    }
    
    this.strokes.pop();
    this.transitionState(this.state, SignaturePageState.REVIEW_STROKES,
      `撤销最后一笔,剩余 ${this.validStrokeCount()} 笔有效笔画`);
    
    if (this.validStrokeCount() === 0) {
      this.signatureStatus = '笔迹已清除,可继续签字或重新开始';
    } else {
      this.signatureStatus = `已撤销,剩余 ${this.validStrokeCount()} 笔有效笔画`;
    }
    return true;
  }
  
  // 清空操作(允许从任何采集状态清空)
  public clearAllStrokes(): boolean {
    if (this.state === SignaturePageState.PREVIEW_MODE || 
        this.state === SignaturePageState.CONFIRMED) {
      return false;
    }
    
    this.strokes = [];
    this.transitionState(this.state, SignaturePageState.READY_FOR_NEW,
      '用户点击清空按钮,已重置所有笔迹');
    this.signatureStatus = '笔迹已清空,请重新签字';
    return true;
  }
  
  // 获取完整的状态变更日志(用于实施审计)
  public getStateChangeAuditLog(): StateChangeRecord[] {
    return this.stateChangeLog.map(record => ({
      ...record,
      readableState: `${record.fromState}${record.toState}`
    }));
  }
  
  // 获取本次签名的完整上下文(用于交接)
  public getSignatureContext(): SignatureContext {
    return {
      workOrderId: this.lastWorkOrder?.id,
      equipment: this.lastWorkOrder?.equipment,
      action: this.lastWorkOrder?.action,
      currentState: this.state,
      validStrokeCount: this.validStrokeCount(),
      confirmHistory: this.confirmHistory,
      stateChangeLog: this.stateChangeLog,
      canConfirm: this.state === SignaturePageState.REVIEW_STROKES || 
                  this.state === SignaturePageState.AWAITING_CONFIRM,
      canPreview: this.confirmHistory.length > 0,
      hasError: this.state === SignaturePageState.ERROR
    };
  }
}

这段代码能支撑的验收结论

  • 页面在每个状态转移时有明确的前置条件和记录
  • 状态日志可用于审计和故障排查
  • 异常状态有明确的错误标记和处理

它不能支撑的结论

  • 工单已经回写到业务系统
  • 笔迹已经持久化到数据库
  • 签署人身份已经由系统核验

三、有效笔画是现场确认的门槛,不是电子签名认证

现场操作中会出现误触、手掌碰屏、短暂划过或签名人临时中断。若每一次触摸都允许确认,现场人员只能靠肉眼猜测页面是否真的采集到了可用笔迹。工程通过“点数”和“轨迹长度”两项规则,将短触摸挡在本地确认之外。

private isValidStroke(stroke: Stroke): boolean {
  if (stroke.points.length < 3) {
    return false;
  }
  let length = 0;
  for (let index = 1; index < stroke.points.length; index += 1) {
    const horizontal = stroke.points[index].x - stroke.points[index - 1].x;
    const vertical = stroke.points[index].y - stroke.points[index - 1].y;
    length += Math.sqrt(horizontal * horizontal + vertical * vertical);
  }
  return length >= 12;
}

private validStrokeCount(): number {
  return this.strokes.filter((stroke: Stroke) => this.isValidStroke(stroke)).length;
}

实施验收不需要争论十二个像素是否适用于所有业务,而应明确它在当前工程中的用途:它是页面确认的最小门槛。如果项目需要更严格的签收规则,例如指定签署人、身份证明、时钟签名、地理位置、笔压或手写比对,这些都应列为独立需求和独立证据,不能从这段长度判断中推导出来。

3.1 现场验收的三组固定动作

  1. 短触摸验证:轻触或短划后,确认状态停在“笔迹过短,请重新签字”或“请先完成有效签字”。
  2. 连续书写验证:完成连续书写后,确认状态转为“笔迹已采集,可确认本地归档”。
  3. 更正验证:点击撤销或清空,确认笔迹和状态同步刷新,再重新书写。

这三组动作覆盖的是输入可靠性,不包括签字法律效力。验收报告应把“有效笔画门槛通过”写成页面交互结论,不写成“签名真实性通过”。

四、本地确认必须保留为独立状态

确认按钮的名称包含“归档”,很容易造成项目误解。实际处理流程先统计有效笔画,再创建SignatureHistoryRecord,将规范化后的笔画加入页面内的历史数组。工程自己也在结果文案中标明“未回写真实工单”。

private confirmSignature(): void {
  const count = this.validStrokeCount();
  if (count === 0) {
    this.signatureStatus = '请先完成有效签字';
    return;
  }
  const record: SignatureHistoryRecord = {
    id: `local-${this.signatureHistory.length + 1}`,
    action: '完工确认',
    signer: '维修人员',
    signedAt: '刚刚',
    strokes: this.normalizedStrokes()
  };
  this.signatureHistory = [record, ...this.signatureHistory];
  this.signatureStatus = `本地确认已记录,共 ${count} 笔有效笔画,未回写真实工单`;
}

这段代码对交付最重要的价值,是让实施人员拥有一个可观察的中间节点:确认后可以检查笔画数量、历史条目和只读预览,再决定是否交给业务系统。它避免了“点击按钮后什么也看不到”的现场尴尬,也避免了把本地内存数组误报为服务端成功。

4.1 建议采用双状态交接单

字段 页面确认完成时填写 外部归档完成时填写
工单号 现场核对值 接口回执中的工单号
签署事项 例如完工确认/质检复核 与业务系统事项一致性
本地记录标识 页面历史记录ID或截图编号 可关联的附件标识
签字结果 有效笔画数量、页面状态 文件哈希、存储地址或档案编号
责任人 现场工程师、实施人员 系统对接人、客户验收人
状态 已本地确认、待归档 已回写、已归档或归档失败

双状态的意义在于允许现场先完成可完成的工作,同时把未完成内容明确交出去。它比“以后再补附件”更可追踪,也比强行要求页面立即具备全部企业能力更符合分阶段实施的实际。

在这里插入图片描述

图3:待签署状态适合用于交接前核对。页面准备完成并不等于客户已签署,也不等于档案已经生成。

五、历史预览用于复核,不用于伪造持久化

当前工程允许从历史列表选择记录,在右侧以只读方式预览笔迹。预览使用归一化坐标重新绘制,因此同一份轨迹可以适应当前画布面积。实施人员可用它完成两类核对:第一,确认刚刚本地确认的笔迹确实进入历史列表;第二,确认预览状态不能继续修改。

private selectHistoryRecord(id: string): void {
  this.selectedHistoryId = id;
  this.viewMode = 'preview';
  this.activeStroke = undefined;
  this.signatureStatus = '正在预览历史签名,笔迹已锁定';
  this.redraw();
}

private exitPreview(): void {
  this.selectedHistoryId = '';
  this.viewMode = 'new';
  this.signatureStatus = '已退出历史预览,可继续新建签名';
  this.redraw();
}

页面内能再次看到笔迹,不代表应用重启后仍能看到,也不代表其他设备、客户后台或档案系统能查询到。实施顾问在验收说明中应使用“本地只读预览可用”这样的描述;只有在重启、重新登录、跨端查询或服务端接口证据完成后,才能升级为“已持久化”。

五点五、多人协作场景:工程师、实施顾问与客户代表的角色分工

在真实项目中,签字不是单人操作。现场工程师执行手写,实施顾问验证状态,客户代表见证过程。若三方的理解不同步,即使页面工作正常,验收也会陷入纠纷。

场景一:第一位工程师签字后,第二位工程师需要补签

// 错误处理:第二位工程师接手前的检查
class MultiEngineerSignatureFlow {
  
  // 第一位工程师完成签名后
  public firstEngineerConfirms(result: SignatureContext): void {
    // 状态日志中应该有明确的操作者标记
    const lastConfirm = result.confirmHistory[0];
    console.log(`第一位工程师 ${lastConfirm.signer}${lastConfirm.confirmTime} 完成了签名`);
    console.log(`本地确认记录ID: ${lastConfirm.id}`);
    console.log(`有效笔画数: ${lastConfirm.strokeCount}`);
  }
  
  // 第二位工程师接手时必须明确检查
  public secondEngineerTakeover(currentContext: SignatureContext, newWorkOrderId: string): void {
    // 1. 检查是否处于历史预览状态
    if (currentContext.currentState === 'preview_mode') {
      console.error('❌ 页面处于历史预览状态,无法继续签字。需先退出预览。');
      return;
    }
    
    // 2. 检查工单是否相同
    if (currentContext.workOrderId === newWorkOrderId) {
      console.error(`❌ 同一工单 ${newWorkOrderId} 不应重复签名。`);
      console.log('应做的处理:');
      console.log('- 如需补签,应在历史预览中查看第一位工程师的签名');
      console.log('- 如需修改,应向实施顾问报告,由实施顾问判断是否需要新建工单');
      return;
    }
    
    // 3. 检查前次签名是否已确认
    if (currentContext.validStrokeCount > 0 && currentContext.confirmHistory.length === 0) {
      console.warn('⚠️  检测到未确认的笔迹。前一位工程师可能没有完成本地确认。');
      console.log('建议:');
      console.log('- 通知实施顾问检查前次操作状态');
      console.log('- 前一位工程师是否需要返回完成确认');
      console.log('- 否则应清空笔迹后重新开始');
      return;
    }
    
    // 4. 所有检查通过,可以继续新建签名
    console.log(`✅ 检查完成。可以为新工单 ${newWorkOrderId} 进行签名。`);
    console.log(`现场工程师 ${this.currentEngineer} 已确认,实施顾问已验证前序状态。`);
  }
}

场景二:实施顾问在现场判断"页面状态是否正常"的完整检查清单

class ImplementationVerificationChecklist {
  
  public performFullHealthCheck(context: SignatureContext): HealthCheckResult {
    const checks: CheckResult[] = [];
    
    // 检查一:状态转移日志完整性
    checks.push(this.verifyStateTransitionLog(context));
    
    // 检查二:工单与设备对象核对
    checks.push({
      name: '工单与设备核对',
      passed: context.workOrderId === this.expectedWorkOrderId &&
              context.equipment === this.expectedEquipment,
      details: passed ? 
        `工单 ${context.workOrderId}、设备 ${context.equipment} 与当前任务对象一致` :
        `❌ 工单或设备不匹配,当前: ${context.workOrderId}/${context.equipment}`
    });
    
    // 检查三:本地确认历史的有效性
    checks.push({
      name: '本地确认历史检查',
      passed: this.validateConfirmHistory(context.confirmHistory),
      details: `已记录 ${context.confirmHistory.length} 条本地确认,最新记录在 ${context.confirmHistory[0]?.confirmTime}`
    });
    
    // 检查四:当前笔迹与历史确认的一致性
    checks.push({
      name: '笔迹一致性检查',
      passed: this.checkStrokeConsistency(context),
      details: context.confirmHistory.length > 0 ? 
        `最新本地确认中的笔画数 ${context.confirmHistory[0].strokeCount} 与当前状态一致` :
        '未检测到本地确认,如已在预览状态可忽略此项'
    });
    
    // 检查五:状态机是否在预期范围内
    checks.push({
      name: '状态机检查',
      passed: this.isValidPageState(context.currentState),
      details: `当前状态: ${context.currentState}${context.hasError ? '⚠️ 页面处于异常状态' : '✅ 状态正常'}`
    });
    
    // 检查六:预览锁定是否有效
    checks.push({
      name: '历史预览锁定检查',
      passed: this.verifyPreviewLockEffective(context),
      details: context.currentState === 'preview_mode' ? 
        '✅ 页面已锁定,不接收新的触摸输入' :
        '当前不在预览状态'
    });
    
    // 汇总
    const allPassed = checks.every(c => c.passed);
    return {
      passed: allPassed,
      checks,
      recommendation: allPassed ? 
        '✅ 页面状态正常,可继续进行现场签收或交接' :
        '❌ 检测到异常,建议按以下顺序处理:' + 
        checks.filter(c => !c.passed).map(c => `\n- ${c.name}: ${c.details}`).join('')
    };
  }
  
  private verifyStateTransitionLog(context: SignatureContext): CheckResult {
    const log = context.stateChangeLog || [];
    return {
      name: '状态转移日志检查',
      passed: log.length > 0,
      details: `共记录 ${log.length} 次状态转移。最后操作: ${log[log.length - 1]?.reason || '无'}`
    };
  }
}

场景三:客户代表在验收时需要看到什么

实施顾问应在现场提供可视化的证据包:

class CustomerAcceptancePackage {
  
  // 为客户代表生成可验证的证据包
  public generateAcceptanceEvidence(context: SignatureContext): AcceptancePackage {
    return {
      // 一、工单对象证明
      workOrderProof: {
        workOrderId: context.workOrderId,
        equipment: context.equipment,
        action: context.action,
        timestamp: new Date().toISOString(),
        evidence: '页面启动时核对的工单与设备信息'
      },
      
      // 二、现场签名过程证明
      signatureProcessProof: {
        stateTransitions: context.stateChangeLog,
        validStrokeCount: context.validStrokeCount,
        confirmRecords: context.confirmHistory.map(record => ({
          id: record.id,
          signer: record.signer,
          signedAt: record.signedAt,
          confirmTime: record.confirmTime,
          strokeCount: record.strokeCount
        })),
        evidence: '状态转移日志与本地确认历史'
      },
      
      // 三、页面能力证明(现场演示的检查点)
      pageCapabilityProof: {
        canHandleTouches: true,
        canValidateStrokes: true,
        canPreviewHistory: context.confirmHistory.length > 0,
        canLockPreviewMode: true,
        evidence: '现场演示或测试结果'
      },
      
      // 四、当前状态说明
      currentStatusExplanation: {
        state: context.currentState,
        meaning: this.explainState(context.currentState),
        nextSteps: this.getNextSteps(context),
        pendingItems: this.identifyPendingItems(context)
      },
      
      // 五、明确的交接清单
      handoverChecklist: [
        { item: '工单与设备核对', completed: true, evidence: context.workOrderId },
        { item: '现场签名采集', completed: context.validStrokeCount > 0, evidence: `${context.validStrokeCount} 笔有效笔画` },
        { item: '页面本地确认', completed: context.confirmHistory.length > 0, evidence: `${context.confirmHistory.length} 条记录` },
        { item: '历史预览验证', completed: context.canPreview, evidence: '可预览且锁定' },
        { item: '附件导出', completed: false, evidence: '页面暂无此能力,需后续集成' },
        { item: '工单回写', completed: false, evidence: '页面暂无此能力,需后续集成' },
        { item: '客户档案归档', completed: false, evidence: '页面暂无此能力,需后续集成' }
      ]
    };
  }
  
  private explainState(state: string): string {
    const explanations: Record<string, string> = {
      'ready_for_new': '页面已准备,等待现场工程师进行手写输入',
      'collecting': '正在采集笔迹中,触摸输入已启动',
      'review_strokes': '笔迹已采集,评估有效性中',
      'confirmed': '本地确认已完成,页面已锁定',
      'preview_mode': '处于历史预览中,新的触摸输入不被接受',
      'error': '页面处于异常状态,建议刷新重试'
    };
    return explanations[state] || '未知状态';
  }
  
  private getNextSteps(context: SignatureContext): string[] {
    if (context.currentState === 'confirmed') {
      return [
        '1. 实施顾问应打印或保存本地确认截图作为现场证据',
        '2. 将本地确认记录ID和笔画数量记入工单交接单',
        '3. 列出待外部链路处理项:附件生成、工单回写、客户档案归档',
        '4. 指定各项的责任人和完成时限'
      ];
    }
    return [];
  }
  
  private identifyPendingItems(context: SignatureContext): PendingItem[] {
    return [
      {
        item: '附件导出能力',
        reason: '页面暂无文件生成和导出接口',
        responsibility: '应用服务负责人',
        deadline: '待定'
      },
      {
        item: '工单回写接口',
        reason: '页面暂无业务系统集成',
        responsibility: '业务系统对接人',
        deadline: '待定'
      },
      {
        item: '客户档案归档',
        reason: '页面暂无档案系统集成',
        responsibility: '文档服务负责人',
        deadline: '待定'
      }
    ];
  }
}

五点八、现场故障诊断与处理流程

当现场出现异常时,实施顾问需要快速定位问题所在,决定是重试、回滚、还是升级。下面是常见故障的诊断与处理代码:

class FieldTroubleshootingGuide {
  
  /**
   * 现场人员报告:"页面无反应"
   * 实施顾问的诊断流程
   */
  public diagnosePage Unresponsive(): TroubleshootingResult {
    const diagnosis: DiagnosisStep[] = [];
    
    // 第一步:确认页面是否真的崩溃
    diagnosis.push({
      step: 1,
      action: '检查页面是否能响应任何操作',
      checkItems: [
        { name: '尝试点击撤销按钮', expect: '按钮有视觉反馈或提示' },
        { name: '尝试点击清空按钮', expect: '按钮有视觉反馈或提示' },
        { name: '尝试在画布外点击', expect: '页面正常响应,不会绘制' }
      ],
      ifFail: '页面完全崩溃,应立即强制关闭应用后重启'
    });
    
    // 第二步:如果按钮有反应但画布无反应,检查触摸权限
    diagnosis.push({
      step: 2,
      action: '检查触摸输入是否被拦截',
      checkItems: [
        { name: '查看是否有弹窗遮挡', expect: '画布清晰可见,无遮挡' },
        { name: '尝试在画布上轻点两下', expect: '页面应提示笔迹过短或已采集' },
        { name: '检查是否处于历史预览状态', expect: '状态提示应明确说明是否在预览中' }
      ],
      ifPass: '触摸输入工作正常,问题可能在笔迹采集逻辑',
      ifFail: '触摸输入有问题,检查是否有系统级的触摸拦截(如屏幕锁定、手套模式)'
    });
    
    // 第三步:检查状态机是否被锁定
    diagnosis.push({
      step: 3,
      action: '检查页面状态是否异常',
      checks: [
        {
          condition: context.currentState === 'error',
          action: '页面处于异常状态',
          handle: '刷新页面,必要时强制关闭应用'
        },
        {
          condition: context.currentState === 'confirmed' && this.hasNewTouches(),
          action: '页面已确认,拒绝新输入',
          handle: '这是正常行为,不是故障。若需要补签,应清空或新建'
        },
        {
          condition: context.currentState === 'preview_mode',
          action: '页面处于历史预览中',
          handle: '点击"退出预览"按钮,返回新建签名状态'
        }
      ]
    });
    
    return {
      diagnosis,
      immediateAction: '按上述步骤逐一检查,确认问题所在',
      escalationPath: context.currentState === 'error' ? 
        '页面异常无法自行恢复,通知实施负责人重启应用或回滚版本' :
        '检查完成后若问题仍未解决,记录当前状态转移日志,上报技术支持'
    };
  }
  
  /**
   * 现场人员报告:"笔迹采集了但按钮不能点"
   * 实施顾问的诊断流程
   */
  public diagnoseButtonUnresponsive(context: SignatureContext): TroubleshootingResult {
    const issues = [];
    
    // 问题一:笔迹数量不足
    if (context.validStrokeCount === 0 && this.canSeeVisibleStrokes()) {
      issues.push({
        issue: '笔迹可见但被判定为无效',
        reason: '笔迹可能过短(<12像素)或触摸点数不足(<3点)',
        diagnosis: [
          '✓ 尝试更重、更长的笔画',
          '✓ 从画布中间开始,确保有足够的移动距离',
          '✓ 避免过快的划动(页面采样可能跟不上)'
        ],
        notARealIssue: '这不是故障,而是有效笔画的门槛机制'
      });
    }
    
    // 问题二:确认按钮被意外禁用
    if (context.validStrokeCount > 0 && !context.canConfirm) {
      issues.push({
        issue: '页面检测到有效笔迹,但本地确认按钮仍被禁用',
        reason: '页面状态可能不在 REVIEW_STROKES 或 AWAITING_CONFIRM',
        diagnosis: [
          `✗ 当前状态: ${context.currentState}`,
          `✗ 有效笔迹数: ${context.validStrokeCount}`,
          '→ 检查状态转移日志,查看最后一次状态变更的原因'
        ],
        handle: '如无法理解状态日志,记录当前完整日志后上报技术支持'
      });
    }
    
    // 问题三:确认按钮可见但点击无反应
    if (this.confirmButtonClickable() && !context.canConfirm) {
      issues.push({
        issue: '按钮视觉上可点击,但没有触发确认',
        reason: '可能是触摸事件没有被正确路由到按钮',
        diagnosis: [
          '✓ 尝试点击按钮的不同位置(上下左右)',
          '✓ 如果只有按钮中心可用,可能是按钮命中区域太小',
          '✓ 等待一秒后重试,可能页面在处理前次输入'
        ],
        handle: '如多次重试仍无反应,刷新页面后重新开始'
      });
    }
    
    return {
      issues,
      nextStep: issues.length === 0 ? 
        '✅ 按钮状态正常,可以进行本地确认' :
        `❌ 检测到 ${issues.length} 个问题,按上述建议处理`
    };
  }
  
  /**
   * 现场人员报告:"笔迹画完了但看不见"
   * 实施顾问的诊断流程
   */
  public diagnoseInvisibleStrokes(): TroubleshootingResult {
    const diagnosis: DiagnosisItem[] = [];
    
    diagnosis.push({
      problem: '手在动但画布没有笔迹出现',
      rootCauses: [
        {
          cause: '画布没有焦点(focus)',
          check: '页面启动时是否有其他输入框获取焦点',
          fix: '点击画布区域,确保它获得触摸焦点'
        },
        {
          cause: '触摸点不在画布范围内',
          check: '是否在画布边缘或外部书写',
          fix: '在画布中心区域重新书写'
        },
        {
          cause: '笔迹绘制被禁用(如处于预览模式)',
          check: '状态是否显示"历史预览只读"或类似提示',
          fix: '退出预览模式后重新书写'
        },
        {
          cause: '笔迹颜色与背景相同或透明度设置错误',
          check: '页面其他操作是否正常,按钮是否可见',
          fix: '这可能需要联系技术支持检查渲染配置'
        },
        {
          cause: '前次确认未清空笔迹缓存',
          check: '页面状态是否为 CONFIRMED',
          fix: '刷新页面或清空所有笔迹后重新开始'
        }
      ]
    });
    
    return {
      diagnosis,
      systematicCheck: [
        '1. 首先确认状态提示,查看当前是否在预览或异常状态',
        '2. 点击画布中心,确保它有焦点反馈',
        '3. 轻点一下,查看是否有任何视觉反馈',
        '4. 尝试长笔画(从一端到另一端),持续观察',
        '5. 如果仍无笔迹,检查系统是否开启了触摸拦截(如手套模式、防误触)',
        '6. 最后手段:刷新页面,从头开始'
      ]
    };
  }
  
  /**
   * 现场人员报告:"确认后笔迹消失了,是不是丢了"
   * 实施顾问的解释
   */
  public explainPostConfirmationBehavior(): ExplanationResult {
    return {
      observation: '本地确认后,画布上的笔迹消失',
      isThisABug: false,
      reason: '这是设计行为,不是故障',
      explanation: [
        '1. 确认后,页面自动清空画布以准备下一次签名',
        '2. 笔迹数据已经保存在"本地确认历史"中,并不会丢失',
        '3. 可以在历史列表中点击记录查看,触发"历史预览"模式'
      ],
      howToVerify: [
        '检查页面的"历史列表"或"已确认记录"区域',
        '点击最新的记录,应该能看到之前的笔迹',
        '预览中会显示笔迹和操作人、确认时间等信息'
      ],
      reassurance: '只要本地确认历史列表中有记录,笔迹就是安全的'
    };
  }
  
  /**
   * 现场人员报告:"同一个工单签了两次"
   * 实施顾问的处理
   */
  public handleDuplicateSignatureAttempt(
    workOrderId: string, 
    existingRecord: SignatureHistoryRecord,
    newSignatureStrokes: Stroke[]
  ): HandlingProcedure {
    return {
      detection: `检测到工单 ${workOrderId} 已有本地确认记录,新输入试图重复签署`,
      
      immediateAction: {
        description: '页面应拒绝重复签署',
        code: `
          if (this.lastWorkOrder?.id === workOrderId && 
              this.confirmHistory.length > 0) {
            throw new Error(\`工单 \${workOrderId} 已在当前会话中签署过,禁止重复签名\`);
          }
        `
      },
      
      whyThisMatters: [
        '同一工单重复签署会造成档案混乱',
        '无法区分哪一次签署才是有效的',
        '可能导致工单状态多次变更'
      ],
      
      correctProcedure: [
        {
          step: '如果第一次签署有误',
          action: '应向实施顾问报告,由实施顾问判断是否需要新建工单或修正第一次记录'
        },
        {
          step: '如果需要第二位工程师补签',
          action: '应使用不同的工单或明确在备注中说明是补签,而不是直接在同一工单重复签署'
        },
        {
          step: '如果已经意外重复签署',
          action: '记录两次签署的时间和操作人,由实施顾问在后续处理中标注哪次是有效的'
        }
      ],
      
      escalation: '实施顾问应记录此事件,在交接单中明确标注重复签署的原因和处理方案'
    };
  }
}

这些诊断流程能让实施顾问在现场快速判断:

  • 这是故障还是设计行为
  • 是否需要重启应用还是继续操作
  • 问题需要升级给哪个角色处理

六、异常时不要让现场人员猜下一步

签字环节发生问题时,现场最需要的是明确分流,而不是一段笼统的“操作失败”。当前工程可识别部分页面状态;外部网络、权限、接口和存储问题尚未接入,因此应按下表进行交接。

现场现象 当前页面可做的动作 实施处置 不应写出的结论
笔迹过短 重新书写 记录一次无效操作即可 已完成签收
写错内容 撤销最后一笔或清空画布 让签署人重新确认 原笔迹已从档案删除
处于历史预览 退出预览后新建签名 核对本次工单与签署人 历史数据已持久化
页面本地确认完成 查看历史条目与状态 建立附件/工单交接项 已完成归档
外部接口不可用 当前工程没有接口重试 采用纸质或受控人工记录并登记补录 系统已保存但暂不可见

对于最后一种情况,项目应预先规定人工兜底材料:工单号、签署事项、签署人、现场时间、问题描述、经办人、补录责任人和补录截止时间。纸质材料或照片是否可以使用,要由项目的数据治理与客户要求决定;本文不把任何替代材料自动视为电子归档。

现场签字

有效笔画?

重新书写或清空

本地确认

附件与工单接口可用?

提交并核对回执

客户材料齐全?

进入客户验收

补齐缺失材料

登记人工交接单

指定补录人和时限

签字环节发生问题时,现场最需要的是明确分流,而不是一段笼统的"操作失败"。当前工程可识别部分页面状态;外部网络、权限、接口和存储问题尚未接入,因此应按下表进行交接。同时,实施顾问必须记录问题的完整上下文,便于后续追踪。

class ExceptionRoutingAndHandling {
  
  /**
   * 根据异常类型和页面状态,将问题路由到正确的处理方
   */
  public routeException(
    exception: SignatureException,
    context: SignatureContext,
    roles: { fieldEngineer: string, implementer: string, systemOwner: string }
  ): ExceptionRoute {
    
    // 第一层:页面级异常(现场工程师可自行处理)
    if (this.isPageLevelException(exception)) {
      return {
        category: 'PAGE_LEVEL',
        handler: roles.fieldEngineer,
        action: '现场工程师可按照提示重新操作',
        examples: [
          { exception: '笔迹过短', action: '重新书写更长的笔迹' },
          { exception: '处于历史预览', action: '点击退出预览按钮后重新签名' },
          { exception: '页面崩溃', action: '刷新页面后重新开始' }
        ],
        recordingRequirement: '记录操作次数和时间'
      };
    }
    
    // 第二层:状态机异常(实施顾问需要判断)
    if (this.isStateMachineException(exception)) {
      return {
        category: 'STATE_MACHINE',
        handler: roles.implementer,
        action: '实施顾问查看状态转移日志,判断是否需要手动重置',
        diagnosticSteps: [
          `检查当前状态: ${context.currentState}`,
          `查看最后的状态转移原因`,
          `判断是否能通过清空笔迹返回到 READY_FOR_NEW 状态`,
          `如不能自行恢复,升级到系统负责人`
        ],
        recordingRequirement: '记录完整的状态转移日志和当前页面截图'
      };
    }
    
    // 第三层:系统集成异常(系统负责人处理)
    if (this.isSystemLevelException(exception)) {
      return {
        category: 'SYSTEM_LEVEL',
        handler: roles.systemOwner,
        action: '系统负责人分析是否需要版本回滚或配置调整',
        diagnos: [
          '检查是否涉及外部接口调用(当前版本不应有)',
          '检查是否是多个版本混用导致的不兼容',
          '检查是否是安装包签名或权限问题'
        ],
        recordingRequirement: '完整的应用日志、系统日志和复现步骤'
      };
    }
    
    return { category: 'UNKNOWN', handler: 'escalate', action: '无法分类,升级处理' };
  }
  
  /**
   * 为交接单生成标准化的异常记录
   */
  public generateExceptionRecord(
    exception: SignatureException,
    context: SignatureContext,
    handlingInfo: HandlingInfo
  ): ExceptionLog {
    return {
      // 问题描述
      summary: {
        description: exception.message,
        severity: this.classifySeverity(exception),
        timestamp: new Date().toISOString(),
        workOrderId: context.workOrderId,
        equipment: context.equipment
      },
      
      // 页面状态快照
      pageSnapshot: {
        state: context.currentState,
        validStrokeCount: context.validStrokeCount,
        confirmHistoryLength: context.confirmHistory.length,
        stateTransitionLog: context.stateChangeLog.slice(-5)  // 最后5次状态转移
      },
      
      // 现场环境信息
      fieldEnvironment: {
        deviceModel: navigator.userAgent,
        systemVersion: this.getSystemVersion(),
        networkStatus: this.getNetworkStatus(),
        timestamp: new Date().toISOString()
      },
      
      // 处理决策
      handlingDecision: {
        handler: handlingInfo.handler,
        category: handlingInfo.category,
        initialAction: handlingInfo.action,
        escalationPath: handlingInfo.escalationPath || 'none'
      },
      
      // 追踪信息
      tracking: {
        reportedBy: handlingInfo.reportedBy,
        reportTime: new Date().toISOString(),
        expectedResolutionTime: handlingInfo.expectedResolutionTime,
        followUpRequired: !handlingInfo.canSelfResolve
      }
    };
  }
  
  private classifySeverity(exception: SignatureException): 'HIGH' | 'MEDIUM' | 'LOW' {
    if (exception.blocksWorkflow) return 'HIGH';
    if (exception.requiresDataRecovery) return 'MEDIUM';
    return 'LOW';
  }
  
  private isPageLevelException(exception: SignatureException): boolean {
    return [
      'SHORT_STROKE',
      'PREVIEW_LOCKED',
      'PAGE_REFRESH_NEEDED',
      'INVALID_STROKE_COUNT'
    ].includes(exception.type);
  }
  
  private isStateMachineException(exception: SignatureException): boolean {
    return [
      'INVALID_STATE_TRANSITION',
      'STATE_LOCK_UNEXPECTED',
      'STATE_LOG_INCONSISTENT'
    ].includes(exception.type);
  }
  
  private isSystemLevelException(exception: SignatureException): boolean {
    return [
      'VERSION_MISMATCH',
      'PERMISSION_DENIED',
      'STORAGE_ERROR',
      'EXTERNAL_API_FAILURE'
    ].includes(exception.type);
  }
}
现场现象 当前页面可做的动作 实施处置 问题转移
笔迹过短 页面提示"笔迹过短,请重新签字" 记录一次无效操作,让现场工程师重新书写 无需转移
写错内容 页面可撤销最后一笔或清空画布 确认签署人是否需要重新签署,记录修正次数 如需要修改工单事项,转给业务系统负责人
处于历史预览 页面拒绝新输入,提示"历史只读" 指导现场工程师点击"退出预览"按钮 无需转移
页面本地确认完成 笔迹已清空,状态转为已确认 截图保存本地确认结果,登记附件/工单交接项 转给应用服务负责人处理附件生成和工单回写
页面崩溃或无反应 页面无法恢复,需要强制刷新 记录刷新前的完整状态(如可能),重新启动流程 转给系统负责人分析崩溃日志和版本兼容性
网络不可用 当前页面没有网络依赖,应继续工作 完成本地确认后,等待网络恢复再进行外部提交 转给基础设施或网络管理员检查连接
触摸输入无反应 尝试点击其他按钮判断是否系统级问题 切换到备用设备或等待系统恢复 转给硬件或驱动支持团队

对于最后一种情况,项目应预先规定人工兜底材料:工单号、签署事项、签署人、现场时间、问题描述、经办人、补录责任人和补录截止时间。纸质材料或照片是否可以使用,要由项目的数据治理与客户要求决定;本文不把任何替代材料自动视为电子归档。

现场签字

有效笔画?

重新书写或清空

本地确认

页面状态正常?

记录异常并升级

附件与工单接口可用?

提交并核对回执

客户材料齐全?

进入客户验收

补齐缺失材料

登记人工交接单

指定补录人和时限

可自行恢复?

实施顾问处理

升级到系统负责人

六点五、版本升级时的兼容性问题与数据迁移

企业项目往往需要进行版本升级。旧版本中的本地确认记录可能与新版本的数据结构不兼容。实施顾问需要在升级前制定迁移策略。

class VersionUpgradeStrategy {
  
  /**
   * 升级前检查:确保旧数据不会丢失
   */
  public preUpgradeValidation(currentVersion: string, targetVersion: string): ValidationResult {
    const checks: ValidationCheck[] = [];
    
    // 检查一:备份现有的本地确认记录
    checks.push({
      name: '本地确认记录备份',
      check: () => {
        const backupData = JSON.stringify({
          timestamp: new Date().toISOString(),
          version: currentVersion,
          confirmHistory: this.getCurrentConfirmHistory(),
          stateChangeLog: this.getCurrentStateChangeLog()
        });
        localStorage.setItem(`backup_${currentVersion}_${Date.now()}`, backupData);
        return backupData.length > 0;
      },
      result: '✅ 已备份,保留以供恢复'
    });
    
    // 检查二:检查数据结构是否兼容
    checks.push({
      name: '数据结构兼容性检查',
      check: () => {
        const mappingStrategy = this.getVersionMappingStrategy(currentVersion, targetVersion);
        return mappingStrategy !== null;
      },
      result: mappingStrategy ? 
        `✅ 存在从 ${currentVersion}${targetVersion} 的映射` :
        `❌ 不存在映射,需要人工处理`
    });
    
    // 检查三:列出需要处理的旧格式字段
    checks.push({
      name: '旧格式字段清单',
      check: () => {
        const deprecatedFields = this.getDeprecatedFields(currentVersion, targetVersion);
        console.log('需要处理的旧字段:');
        deprecatedFields.forEach(field => {
          console.log(`- ${field.name}: ${field.deprecationReason}`);
          console.log(`  处理方式: ${field.migrationStrategy}`);
        });
        return deprecatedFields;
      },
      result: '✅ 已列出所有需要迁移的字段'
    });
    
    return { checks, canProceed: checks.every(c => c.result.startsWith('✅')) };
  }
  
  /**
   * 数据迁移:将旧版本的记录转换为新版本格式
   */
  public migrateData(oldRecord: SignatureHistoryRecord, fromVersion: string, toVersion: string): SignatureHistoryRecord {
    let record = { ...oldRecord };
    
    // 版本 1.0 → 1.1:添加 pageVersion 字段
    if (this.needsMigration('1.0', '1.1', fromVersion, toVersion)) {
      record.pageVersion = '1.1';
      record.legacyFormat = true;  // 标记这是从旧版本迁移的
    }
    
    // 版本 1.1 → 2.0:笔迹坐标格式变更(从像素到归一化)
    if (this.needsMigration('1.1', '2.0', fromVersion, toVersion)) {
      record.strokes = record.strokes.map(stroke => ({
        ...stroke,
        points: stroke.points.map(p => ({
          x: p.x / this.getCanvasWidth(),    // 转换为 0-1 范围
          y: p.y / this.getCanvasHeight(),
          normalized: true
        }))
      }));
      record.pageVersion = '2.0';
    }
    
    // 版本 2.0 → 2.1:添加数字签名支持
    if (this.needsMigration('2.0', '2.1', fromVersion, toVersion)) {
      record.digitalSignature = null;  // 旧记录没有数字签名,设为空
      record.signatureVerified = false;
      record.pageVersion = '2.1';
    }
    
    // 版本通用:更新时间戳格式
    if (record.confirmTime && typeof record.confirmTime === 'string') {
      record.confirmTime = new Date(record.confirmTime).toISOString();
    }
    
    return record;
  }
  
  /**
   * 升级后验证:确保迁移的数据可以正常使用
   */
  public postUpgradeValidation(migratedData: SignatureHistoryRecord[]): ValidationResult {
    const validations: ValidationCheck[] = [];
    
    // 验证一:检查所有记录是否都能在新版本中加载
    validations.push({
      name: '数据可加载性验证',
      check: () => {
        try {
          migratedData.forEach(record => {
            if (!record.id || !record.confirmTime || !record.strokes) {
              throw new Error(`记录 ${record.id} 缺少必要字段`);
            }
          });
          return true;
        } catch (e) {
          console.error('❌ 数据加载失败:', e);
          return false;
        }
      },
      result: '✅ 所有记录都能正常加载'
    });
    
    // 验证二:检查笔迹是否能正常重绘
    validations.push({
      name: '笔迹重绘验证',
      check: () => {
        try {
          migratedData.forEach(record => {
            this.testRenderStrokes(record.strokes);
          });
          return true;
        } catch (e) {
          console.error('❌ 笔迹重绘失败:', e);
          return false;
        }
      },
      result: '✅ 所有笔迹都能正常重绘'
    });
    
    // 验证三:检查状态转移日志是否完整
    validations.push({
      name: '状态日志完整性验证',
      check: () => {
        const recordsWithoutLog = migratedData.filter(r => !r.stateChangeLog);
        if (recordsWithoutLog.length > 0) {
          console.warn(`⚠️  ${recordsWithoutLog.length} 条记录缺少状态转移日志,这是正常的`);
          return true;
        }
        return true;
      },
      result: '✅ 状态日志检查完成'
    });
    
    return {
      checks: validations,
      canProceed: validations.every(v => v.result.startsWith('✅')),
      backupLocation: '旧版本数据已备份,如需恢复可向系统管理员申请'
    };
  }
  
  /**
   * 如果升级失败,恢复到备份版本
   */
  public rollbackToBackup(backupKey: string): boolean {
    const backupData = localStorage.getItem(backupKey);
    if (!backupData) {
      console.error(`❌ 找不到备份 ${backupKey}`);
      return false;
    }
    
    try {
      const backup = JSON.parse(backupData);
      console.log(`恢复备份: ${backup.timestamp}`);
      this.restoreConfirmHistory(backup.confirmHistory);
      this.restoreStateChangeLog(backup.stateChangeLog);
      console.log('✅ 恢复完成');
      return true;
    } catch (e) {
      console.error('❌ 恢复失败:', e);
      return false;
    }
  }
  
  private needsMigration(minVersion: string, maxVersion: string, fromVersion: string, toVersion: string): boolean {
    const fromNum = this.versionToNumber(fromVersion);
    const toNum = this.versionToNumber(toVersion);
    const minNum = this.versionToNumber(minVersion);
    const maxNum = this.versionToNumber(maxVersion);
    return fromNum >= minNum && toNum >= maxNum;
  }
  
  private versionToNumber(version: string): number {
    return parseInt(version.replace(/\./g, ''), 10);
  }
}

实施顾问在升级前应:

  1. ✅ 备份所有现有的本地确认记录
  2. ✅ 制定旧版本数据的迁移策略
  3. ✅ 在测试环境完整验证数据迁移
  4. ✅ 制定升级失败时的回滚方案
  5. ✅ 在正式升级前通知所有相关方

七、客户验收时应交付什么

客户代表不需要审核每一段Canvas绘制代码,但需要拿到能回答责任问题的材料。针对当前工程,建议将交付物分成“已具备”和“待外部链路补齐”两组。

交付材料 当前状态 客户验收关注点
签字页面操作说明 已具备 新建、撤销、清空、确认、预览的操作边界
页面内状态截图或录屏 可采集 有效笔画、本地确认、历史只读预览
构建与运行记录 可采集 页面是否可构建、可启动、可交互
签署附件文件 待补 文件格式、命名、下载或存储位置
工单回写回执 待补 业务ID、状态变更、失败重试规则
档案归档证明 待补 档案编号、权限、保留期限、查询方式

实施顾问的工作不是把待补项藏在最后一页,而是在验收会上提前标注:哪一项由当前工程提供,哪一项依赖下一阶段集成,客户是否接受分阶段签收。这样即使当前版本只交付手写输入与本地确认,也能形成真实、可管理的项目成果。

客户代表不需要审核每一段Canvas绘制代码,但需要拿到能回答责任问题的材料。针对当前工程,建议将交付物分成”已具备”和”待外部链路补齐”两组。同时,需要提供可操作的验收步骤而不仅仅是表格。

class CustomerAcceptanceDeliverable {
  
  /**
   * 生成完整的验收交付包
   */
  public generateAcceptancePackage(): DeliverablePackage {
    return {
      // 第一部分:功能演示清单
      functionalDemonstration: {
        title: '功能演示操作步骤',
        steps: [
          {
            sequence: 1,
            action: '工程师启动页面',
            expectedBehavior: '页面显示工单号、设备名称、签署事项和空白画布',
            checkPoint: '验证工单信息与当前现场任务一致',
            evidence: '屏幕截图'
          },
          {
            sequence: 2,
            action: '工程师在画布上轻点一下(模拟误触)',
            expectedBehavior: '页面提示”笔迹过短,请重新签字”,状态不变',
            checkPoint: '验证页面不会因为误触而创建无效记录',
            evidence: '状态提示截图'
          },
          {
            sequence: 3,
            action: '工程师进行连续书写(从左到右,距离>12像素)',
            expectedBehavior: '画布实时绘制笔迹,笔迹连贯',
            checkPoint: '验证触摸输入能正常采集',
            evidence: '笔迹画面录屏或截图'
          },
          {
            sequence: 4,
            action: '点击”撤销”按钮',
            expectedBehavior: '最后一笔被删除,页面状态更新',
            checkPoint: '验证撤销功能工作正常',
            evidence: '操作前后对比截图'
          },
          {
            sequence: 5,
            action: '再次进行连续书写,然后点击”本地确认”',
            expectedBehavior: '笔迹消失,页面提示”本地确认已记录”,历史列表增加一条记录',
            checkPoint: '验证本地确认能成功创建记录',
            evidence: '操作结果截图'
          },
          {
            sequence: 6,
            action: '从历史列表选择最新的确认记录,点击预览',
            expectedBehavior: '画布重新绘制之前的笔迹,提示”历史签名只读”',
            checkPoint: '验证历史预览能正常工作',
            evidence: '预览状态截图'
          },
          {
            sequence: 7,
            action: '在预览状态下尝试在画布上书写',
            expectedBehavior: '页面拒绝输入,提示”历史只读,请先退出预览”',
            checkPoint: '验证预览模式下的输入锁定有效',
            evidence: '拒绝输入的提示截图'
          },
          {
            sequence: 8,
            action: '点击”退出预览”按钮',
            expectedBehavior: '返回到可以新建签名的状态,画布清空',
            checkPoint: '验证预览退出正常',
            evidence: '返回后的状态截图'
          }
        ]
      },
      
      // 第二部分:现场签字操作指南(给现场工程师使用)
      fieldOperationGuide: {
        title: '现场工程师操作指南',
        prerequisites: [
          '确认工单号、设备名称和签署事项与现场任务一致',
          '设备已安装应用并能正常启动',
          '屏幕已清洁,触摸功能正常'
        ],
        mainFlow: [
          {
            step: '步骤一:启动应用并核对信息',
            details: '应用启动后,确认屏幕上显示的工单号和设备名与当前现场任务相同。如不相同,请通知实施顾问,不要继续操作。'
          },
          {
            step: '步骤二:开始签字',
            details: '在画布的中心区域开始书写。建议从左到右,笔画要连贯,避免过快或过慢。签完整个名字或签署内容。'
          },
          {
            step: '步骤三:检查签字结果',
            details: '书写完成后,观察页面上方的状态提示。如显示”笔迹已采集,可确认本地归档”,说明签字有效。如显示”笔迹过短”,请重新签字。'
          },
          {
            step: '步骤四:修正(如需要)',
            details: '如签字有误,可点击”撤销”删除最后一笔,或点击”清空”删除全部,然后重新签字。'
          },
          {
            step: '步骤五:确认签字',
            details: '确认无误后,点击”本地确认”按钮。按钮会自动禁用,页面显示”本地确认已记录”,这表示当前签字已保存在本页面内存中。'
          },
          {
            step: '步骤六:等待实施顾问下一步指示',
            details: '本地确认完成后,不要再操作。等待实施顾问指示是否需要查看历史记录或进行其他操作。'
          }
        ],
        troubleshooting: [
          {
            problem: '画布上看不到笔迹',
            solution: '尝试点击画布中心以获取焦点,然后重新书写。如仍无效,请通知实施顾问。'
          },
          {
            problem: '笔迹出现了,但页面说”笔迹过短”',
            solution: '下一次书写时,请笔画更长、更慢,确保笔迹从一端至少到达另一端的一半。'
          },
          {
            problem: '本地确认按钮无法点击',
            solution: '检查页面是否显示”笔迹已采集”。如没有,请重新书写。如显示了仍无法点击,刷新页面后重试。'
          },
          {
            problem: '页面无反应',
            solution: '强制关闭应用,等待3秒后重新打开。若问题继续,通知实施顾问。'
          }
        ]
      },
      
      // 第三部分:实施顾问的验收检查清单
      implementerAcceptanceChecklist: {
        title: '实施顾问现场验收清单',
        sections: [
          {
            name: '前置检查',
            items: [
              { item: '应用能正常启动', required: true, selfCheck: true },
              { item: '页面显示的工单号、设备、事项与现场一致', required: true, selfCheck: true },
              { item: '屏幕触摸功能正常(尝试点击按钮)', required: true, selfCheck: true },
              { item: '网络连接状态已检查(当前页面不依赖网络)', required: false, selfCheck: true }
            ]
          },
          {
            name: '核心功能检查',
            items: [
              { 
                item: '短笔画拒绝', 
                required: true,
                steps: '轻点后观察状态提示',
                expect: '提示”笔迹过短”',
                pass: () => this.observePageStatusAfterShortTouch()
              },
              { 
                item: '有效笔画采集', 
                required: true,
                steps: '连续书写后观察状态',
                expect: '提示”笔迹已采集,可确认”',
                pass: () => this.observePageStatusAfterValidStroke()
              },
              { 
                item: '撤销功能', 
                required: true,
                steps: '书写后点撤销,观察笔迹变化',
                expect: '笔迹被删除,状态更新',
                pass: () => this.verifyUndoFunction()
              },
              { 
                item: '清空功能', 
                required: true,
                steps: '书写后点清空,观察笔迹变化',
                expect: '所有笔迹被清空,状态重置',
                pass: () => this.verifyClearFunction()
              },
              { 
                item: '本地确认', 
                required: true,
                steps: '有效笔画后点本地确认',
                expect: '笔迹消失,提示”已记录”,历史列表增加',
                pass: () => this.verifyConfirmFunction()
              },
              { 
                item: '历史预览', 
                required: true,
                steps: '从历史列表选择记录并预览',
                expect: '笔迹重新绘制,显示”只读”提示',
                pass: () => this.verifyPreviewFunction()
              },
              { 
                item: '预览锁定', 
                required: true,
                steps: '在预览状态下尝试书写',
                expect: '页面拒绝输入',
                pass: () => this.verifyPreviewLock()
              }
            ]
          },
          {
            name: '状态机检查',
            items: [
              { 
                item: '状态转移日志完整', 
                required: true,
                check: () => context.stateChangeLog.length > 0,
                details: '应至少记录:INITIALIZING → READY_FOR_NEW → COLLECTING → CONFIRMED'
              },
              { 
                item: '没有未预期的状态转移', 
                required: true,
                check: () => this.validateStateTransitions(context.stateChangeLog),
                details: '每次转移都应有明确的原因'
              }
            ]
          },
          {
            name: '错误处理检查',
            items: [
              { 
                item: '页面崩溃时能够恢复', 
                required: false,
                steps: '强制关闭应用后重启',
                expect: '应用能正常启动',
                pass: () => this.testCrashRecovery()
              },
              { 
                item: '同一工单不能重复签署', 
                required: true,
                steps: '尝试用相同工单号进行第二次签名',
                expect: '页面拒绝或提示警告',
                pass: () => this.testDuplicateProtection()
              }
            ]
          }
        ],
        generateReport: () => ({
          timestamp: new Date().toISOString(),
          allChecksPassed: this.allChecksPass(),
          failedItems: this.getFailedItems(),
          conclusion: this.getConclusion()
        })
      },
      
      // 第四部分:材料清单
      materialChecklist: [
        {
          material: '功能演示视频或演示脚本',
          status: '已具备',
          location: '见功能演示清单',
          notes: '客户可现场观看或自行按步骤验证'
        },
        {
          material: '页面操作说明文档',
          status: '已具备',
          location: '见现场工程师操作指南',
          notes: '现场工程师应在签字前阅读'
        },
        {
          material: '验收检查清单和结果',
          status: '现场生成',
          location: '实施顾问在现场完成检查',
          notes: '检查结果应存档作为验收依据'
        },
        {
          material: '页面状态转移日志',
          status: '已具备',
          location: '页面内存中,或导出为JSON文件',
          notes: '用于审计和问题追踪'
        },
        {
          material: '签字页面截图(含本地确认结果)',
          status: '现场采集',
          location: '应在验收时截图保存',
          notes: '作为现场签字已完成的证明'
        },
        {
          material: '工单交接单(文本或表单)',
          status: '现场填写',
          location: '实施顾问根据验收结果填写',
          notes: '标注已完成项和待外部处理项'
        },
        {
          material: '本地确认记录数据导出',
          status: '可选',
          location: '页面可导出JSON或CSV格式',
          notes: '用于长期存档或数据分析'
        },
        {
          material: '附件、工单回写、客户档案的对接协议',
          status: '待补',
          location: '需与业务系统、应用服务协商',
          notes: '必须在本地确认完成后制定,不能推后'
        }
      ]
    };
  }
}
交付材料 当前状态 提供方 客户验收关注点
签字页面操作说明 已具备 工程团队 新建、撤销、清空、确认、预览的操作边界是否清晰
现场演示或演示脚本 可采集 实施顾问 页面是否能按预期响应各项操作
验收检查清单结果 现场生成 实施顾问 是否通过了所有核心功能检查
页面状态转移日志 可导出 工程 状态转移是否符合设计,是否有异常转移
签字过程截图 现场采集 实施顾问 笔迹、状态提示、历史记录是否可见
工单交接单 现场填写 实施顾问 已完成和待处理项是否清晰标注
附件导出样本 待开发 应用服务 文件格式、命名、查询方式
工单回写回执 待集成 业务系统 回写时间、失败重试策略
档案归档证明 待集成 文档服务 归档编号、权限、保留期限

实施顾问应在验收会上清晰地说明:哪些材料由当前页面提供,哪些依赖未来的集成工作,客户是否接受分阶段签收。

7.1 用责任矩阵结束”谁以为谁会处理”的问题

签收链路跨越现场操作、页面行为和业务归档。若项目只写“研发负责、业务确认”,到了异常现场仍会出现职责空档:现场工程师不知道是否要等待网络,项目经理不知道该向谁索要回执,客户代表又无法判断哪些材料尚未交付。建议在实施方案中为每个结果设置唯一的执行责任和验收责任。

事项 现场工程师 实施顾问 应用/接口负责人 客户代表
核对工单、设备与签署事项 执行 抽查 知会 确认业务口径
完成手写与更正操作 执行 指导与记录 知会 见证或按约参与
判断有效笔画、本地确认和历史预览 配合 验收 知会 可查看结果
生成附件、回写工单与返回错误 知会 跟踪问题 执行 知会
审核归档编号和交付材料 配合 组织验收 提供回执 最终确认

这里的“执行”不等于单独承担全部后果,“验收”也不等于亲自操作设备。它的价值是让某个状态无法被确认时,现场能够立即找到下一位处理者。例如,页面已产生本地确认但接口未接入,实施顾问应登记接口负责人和完成时间,而不是让现场人员重复签字;接口回执返回失败,则由系统负责人处理重试和错误分类,客户代表只需要知晓交付状态未闭环。

7.2 验收会议应形成三种结论,而不是一个“通过”

企业项目通常存在分阶段交付。签字页的页面能力、接口能力和业务归档能力不一定在同一个版本完成。为避免验收会议把“部分可用”误写为“全部通过”,建议使用以下三种结论。

验收结论 使用条件 会议纪要应写明
页面能力通过 手写、有效性门槛、撤销、清空、本地确认、只读预览均正常 工程版本、测试设备、固定工单语境、页面边界
带条件通过 页面能力通过,但附件或工单接口仍待接入 待补项、责任人、截止日期、回归判据
不通过 页面输入、状态门槛或历史预览本身不符合交付要求 复现步骤、影响范围、临时回退方案与复验时间

不能使用“已签字,因此验收通过”这样的表述。签字是一个现场动作,验收通过是多项条件共同成立后的项目结论。将结论拆开,反而能让客户在功能逐步接入时持续获得可核验成果。

签收链路跨越现场操作、页面行为和业务归档。若项目只写"研发负责、业务确认",到了异常现场仍会出现职责空档:现场工程师不知道是否要等待网络,项目经理不知道该向谁索要回执,客户代表又无法判断哪些材料尚未交付。建议在实施方案中为每个结果设置唯一的执行责任和验收责任。

class ResponsibilityMatrix {
  
  /**
   * 建立明确的角色责任表
   */
  public defineResponsibilityMatrix(): ResponsibilityTable {
    return {
      roles: {
        fieldEngineer: { name: '现场工程师', primary: ['手写操作', '异常现象上报'], secondary: ['设备状态核对'] },
        implementer: { name: '实施顾问', primary: ['状态验收', '问题分流', '交接协调'], secondary: ['现场指导', '文档记录'] },
        appOwner: { name: '应用/接口负责人', primary: ['附件生成', '工单回写', '工程交付'], secondary: ['接口测试', '错误处理'] },
        customerRep: { name: '客户代表', primary: ['业务确认', '交付见证'], secondary: ['需求审核'] }
      },
      
      responsibilities: [
        {
          task: '核对工单、设备与签署事项',
          fieldEngineer: {
            role: '执行',
            action: '在现场确认工单号、设备标识与签署内容与当前任务一致',
            timing: '操作前',
            evidence: '人工核对,实施顾问见证'
          },
          implementer: {
            role: '抽查验收',
            action: '从旁观察并检查核对结果,记录工单信息',
            timing: '操作前和操作中',
            evidence: '核对记录、照片或录屏'
          },
          appOwner: {
            role: '知会',
            action: '如有接口集成,需确认工单号格式与系统兼容',
            timing: '前期设计',
            evidence: '接口协议文档'
          },
          customerRep: {
            role: '确认业务口径',
            action: '见证现场核对过程,确认业务信息准确性',
            timing: '操作前',
            evidence: '客户签字确认'
          }
        },
        
        {
          task: '完成手写与更正操作',
          fieldEngineer: {
            role: '执行',
            action: '按照指导完成签字,必要时进行撤销或重写',
            timing: '操作中',
            evidence: '页面状态、笔迹截图'
          },
          implementer: {
            role: '指导与记录',
            action: '指导现场工程师正确操作,记录修正次数和原因',
            timing: '操作中',
            evidence: '操作日志、修正记录'
          },
          appOwner: {
            role: '知会',
            action: '确保页面功能在设计阶段已验证',
            timing: '开发阶段',
            evidence: '功能测试报告'
          },
          customerRep: {
            role: '见证或按约参与',
            action: '按项目协议决定是否现场见证签字过程',
            timing: '操作中',
            evidence: '客户见证记录'
          }
        },
        
        {
          task: '判断有效笔画、本地确认和历史预览',
          fieldEngineer: {
            role: '配合',
            action: '按页面提示操作,如页面提示"笔迹过短"则重新书写',
            timing: '操作中和操作后',
            evidence: '页面提示、状态转移'
          },
          implementer: {
            role: '验收',
            action: '检查页面状态、笔迹数量、本地确认结果和历史预览是否正常',
            timing: '操作后',
            evidence: '状态日志、截图、验收清单'
          },
          appOwner: {
            role: '知会',
            action: '工程已交付,暂无后续操作',
            timing: 'N/A',
            evidence: '版本发布记录'
          },
          customerRep: {
            role: '可查看结果',
            action: '观看验收演示或查看现场照片,了解页面状态',
            timing: '操作后',
            evidence: '演示、照片、实施顾问说明'
          }
        },
        
        {
          task: '生成附件、回写工单与返回错误',
          fieldEngineer: {
            role: '知会',
            action: '了解接下来要发生什么,但不需要主动操作',
            timing: '操作后',
            evidence: '实施顾问的说明'
          },
          implementer: {
            role: '跟踪问题',
            action: '与应用/接口负责人协调,确认是否成功生成附件、回写工单',
            timing: '操作后的1-2小时内',
            evidence: '回执记录、问题跟踪记录'
          },
          appOwner: {
            role: '执行',
            action: '接收本地确认的数据,生成附件,调用工单接口,处理失败',
            timing: '操作后立即开始',
            evidence: '附件标识、工单回执、错误日志'
          },
          customerRep: {
            role: '知会',
            action: '了解附件是否已成功生成和工单是否已更新',
            timing: '操作后1-2小时',
            evidence: '实施顾问的进展汇报'
          }
        },
        
        {
          task: '审核归档编号和交付材料',
          fieldEngineer: {
            role: '配合',
            action: '如有需要,提供补充说明或证明材料',
            timing: '操作后2-4小时',
            evidence: '补充材料、签字'
          },
          implementer: {
            role: '组织验收',
            action: '汇总所有验收材料,形成统一的交付证明',
            timing: '操作后2-4小时',
            evidence: '交付清单、验收记录、问题台账'
          },
          appOwner: {
            role: '提供回执',
            action: '提供附件标识、工单编号、归档编号等追溯信息',
            timing: '操作后1-2小时',
            evidence: '系统回执、接口响应'
          },
          customerRep: {
            role: '最终确认',
            action: '审核交付材料是否完整,确认是否接受分阶段交付',
            timing: '操作后2-4小时',
            evidence: '客户验收签字'
          }
        }
      ]
    };
  }
  
  /**
   * 在责任矩阵中识别潜在的空档
   */
  public identifyResponsibilityGaps(matrix: ResponsibilityTable): Gap[] {
    const gaps: Gap[] = [];
    
    // 空档识别一:如果某个任务没有明确的"执行"方
    matrix.responsibilities.forEach(task => {
      const executors = Object.entries(task).filter(([key, value]) => 
        key !== 'task' && value.role === '执行'
      );
      if (executors.length === 0) {
        gaps.push({
          task: task.task,
          problem: '没有明确的执行方',
          consequence: '这个任务可能无人执行,或多个人争执控制权',
          recommendation: '必须明确指定一个角色作为执行方'
        });
      }
    });
    
    // 空档识别二:如果某个任务没有明确的"验收"方
    matrix.responsibilities.forEach(task => {
      const verifiers = Object.entries(task).filter(([key, value]) => 
        key !== 'task' && (value.role === '验收' || value.role === '最终确认')
      );
      if (verifiers.length === 0) {
        gaps.push({
          task: task.task,
          problem: '没有明确的验收方',
          consequence: '无法判断任务是否真的完成了',
          recommendation: '必须明确指定至少一个角色进行验收'
        });
      }
    });
    
    // 空档识别三:接口失败时的处理链路
    const interfaceTask = matrix.responsibilities.find(t => t.task.includes('接口'));
    if (interfaceTask) {
      const hasErrorHandling = Object.entries(interfaceTask).some(([_, value]) =>
        value.role === '执行' && value.action?.includes('处理失败')
      );
      if (!hasErrorHandling) {
        gaps.push({
          task: '接口失败处理',
          problem: '没有明确的失败处理责任方',
          consequence: '接口返回错误时,可能陷入等待状态',
          recommendation: '必须明确应用负责人应在接口失败时立即处理和重试'
        });
      }
    }
    
    return gaps;
  }
}

7.2 验收会议应形成三种结论,而不是一个"通过"

企业项目通常存在分阶段交付。签字页的页面能力、接口能力和业务归档能力不一定在同一个版本完成。为避免验收会议把"部分可用"误写为"全部通过",建议使用以下三种结论。

class AcceptanceConclusion {
  
  /**
   * 验收会议的三种可能结论
   */
  public generateAcceptanceConclusion(
    pageCapabilityPassed: boolean,
    interfaceCapabilityReady: boolean,
    archiveCapabilityReady: boolean,
    customerAcceptance: boolean
  ): ConclusionResult {
    
    // 结论一:页面能力通过(不依赖后续集成)
    if (pageCapabilityPassed && !interfaceCapabilityReady && !archiveCapabilityReady) {
      return {
        conclusion: '页面能力通过',
        meaning: '当前版本的手写、有效性门槛、撤销、清空、本地确认、只读预览均正常工作',
        conditions: [
          '页面能启动并响应触摸',
          '笔迹能实时采集和绘制',
          '有效笔画门槛能正确识别',
          '本地确认能创建可查询的历史记录',
          '历史预览能以只读方式显示笔迹',
          '页面状态机没有检测到异常'
        ],
        nextSteps: [
          '实施顾问应将页面版本号、测试设备、工单样本、操作时间记入档案',
          '转移到应用/接口负责人,开始开发附件生成和工单回写功能',
          '转移到业务系统负责人,设计数据交接协议',
          '预计后续集成需要3-5个工作日'
        ],
        writeInMeetingNotes: `
验收结论:页面能力通过
- 测试版本:${this.pageVersion}
- 测试设备:${this.deviceModel}
- 测试工单:${this.sampleWorkOrder}
- 测试时间:${new Date().toISOString()}
- 通过的功能:手写输入、有效性判定、本地确认、历史预览
- 暂未验证的功能:附件生成、工单回写、客户档案归档(在后续阶段验证)
        `,
        riskStatement: '当前结论仅涵盖页面功能范围,不代表业务流程完整'
      };
    }
    
    // 结论二:带条件通过(页面通过,但附件/工单接口仍待接入)
    if (pageCapabilityPassed && customerAcceptance) {
      return {
        conclusion: '带条件通过',
        meaning: '页面能力已验证可用,但附件生成和工单回写仍在开发中,项目接受现阶段交付',
        conditions: [
          '页面所有核心功能均通过验收',
          '附件生成功能明确列为"下一版本"或"后续集成"',
          '客户接受现阶段"本地确认"作为临时交付',
          '已制定补齐附件和工单回写的时间表'
        ],
        pendingItems: [
          {
            item: '附件导出接口',
            owner: '应用负责人',
            expectedDate: this.calculateDeadline(3),
            criteria: '能生成包含笔迹的图片或PDF,提供下载或存储位置'
          },
          {
            item: '工单回写接口',
            owner: '业务系统负责人',
            expectedDate: this.calculateDeadline(5),
            criteria: '能将签署结果自动更新到工单系统,返回成功回执或失败码'
          },
          {
            item: '客户档案归档',
            owner: '文档服务负责人',
            expectedDate: this.calculateDeadline(7),
            criteria: '签署记录能归档到客户档案系统,支持权限管理和查询'
          }
        ],
        nextSteps: [
          '形成签署意见记录,标注"页面能力已通过,待后续集成"',
          '为每个待补项指定负责人和完成时限',
          '建立问题跟踪单,定期检查进度',
          '当各项完成时,进行回归验证'
        ],
        writeInMeetingNotes: `
验收结论:页面能力通过,项目接受分阶段交付
- 已通过验收:页面手写、判定、确认、预览功能
- 暂未完成:附件导出(${this.calculateDeadline(3)})、工单回写(${this.calculateDeadline(5)})、档案归档(${this.calculateDeadline(7)})
- 当前交付状态:可用于现场本地确认,后续功能待补齐
- 客户承诺:接受现阶段交付,待补项完成后进行回归验证
        `,
        riskStatement: '在附件和工单回写完成前,本地确认记录的外部追溯性有限,建议人工记录作为备份'
      };
    }
    
    // 结论三:不通过(页面能力存在缺陷)
    if (!pageCapabilityPassed) {
      return {
        conclusion: '不通过',
        meaning: '页面在核心功能上存在问题,不能进入生产使用',
        failedItems: this.getFailedChecks(),
        rootCauses: [
          '检查是否是设计阶段遗漏的需求',
          '检查是否是代码实现错误',
          '检查是否是测试环境差异导致'
        ],
        remediationPlan: [
          {
            step: '复现故障并记录',
            owner: '实施顾问',
            expectedDuration: '1小时'
          },
          {
            step: '分析根本原因',
            owner: '开发负责人',
            expectedDuration: '2-4小时'
          },
          {
            step: '修复和验证',
            owner: '开发负责人',
            expectedDuration: '1-2工作日'
          },
          {
            step: '回归测试',
            owner: '实施顾问',
            expectedDuration: '1小时'
          }
        ],
        nextAcceptanceDate: this.calculateDeadline(2),
        writeInMeetingNotes: `
验收结论:不通过
- 失败项目:${this.getFailedChecks().map(c => c.item).join('、')}
- 原因:${this.getRootCauseSummary()}
- 修复计划:预计 ${this.calculateDeadline(2)} 前完成修复和回归
- 下次验收:${this.calculateDeadline(2)} 之后
        `
      };
    }
  }
  
  /**
   * 为验收会议准备规范化的会议纪要模板
   */
  public generateMeetingMinutes(conclusion: ConclusionResult): MeetingMinutes {
    return {
      header: {
        title: '签字页面验收会议纪要',
        date: new Date().toISOString().split('T')[0],
        location: '现场或视频会议',
        attendees: [
          '现场工程师(代表)',
          '实施顾问(主持)',
          '应用负责人(代表)',
          '客户代表'
        ]
      },
      
      agenda: [
        '功能演示和现场验收',
        '验收结果评议',
        '待补项规划(如有)',
        '交付确认'
      ],
      
      demoResults: this.formatDemoResults(),
      
      conclusion: conclusion.conclusion,
      
      details: conclusion.writeInMeetingNotes,
      
      action_items: [
        ...this.generateActionItems(conclusion),
        {
          action: '实施顾问生成验收报告和问题清单',
          owner: '实施顾问',
          deadline: new Date(Date.now() + 24*3600*1000).toISOString().split('T')[0],
          status: '待执行'
        },
        {
          action: '各方签字确认',
          owner: '实施顾问/客户代表',
          deadline: '当天',
          status: '待执行'
        }
      ],
      
      risks: conclusion.riskStatement,
      
      nextMilestone: this.getNextMilestone(conclusion)
    };
  }
}
验收结论 使用条件 会议纪要应写明 后续行动
页面能力通过 手写、有效性门槛、撤销、清空、本地确认、只读预览均正常 工程版本、测试设备、固定工单语境、页面边界、暂未验证项 转移到应用/接口负责人开发附件和工单回写
带条件通过 页面能力通过,但附件或工单接口仍待接入,客户接受 已通过项、待补项、各项的责任人和截止日期、现阶段交付承诺 建立问题跟踪,定期检查各项进度,完成后进行回归验证
不通过 页面输入、状态门槛或历史预览本身不符合交付要求 复现步骤、影响范围、临时回退方案、修复时间表、下次验收日期 开发负责人修复,实施顾问1-2工作日后重新验收

不能使用"已签字,因此验收通过"这样的表述。签字是一个现场动作,验收通过是多项条件共同成立后的项目结论。将结论拆开,反而能让客户在功能逐步接入时持续获得可核验成果。

八、从本地确认进入外部归档前,还需补齐哪些接口约定

当前工程将笔迹转换为归一化坐标后放入页面历史。若下一阶段要实现附件和工单归档,接口设计不能只接收一个模糊的“签字成功”布尔值。实施顾问应在联调前要求业务与技术双方确认最小数据契约。

接口字段或约定 为什么需要 缺失后的风险
工单唯一标识 将本次签收绑定到正确业务对象 记录无法关联或误回写
签署事项与版本 区分完工确认、质检复核和交底 同一笔迹被错误解释
签署人标识与核验方式 说明是谁完成的业务动作 页面占位名称被误当身份事实
轨迹或附件载体 明确传坐标、图片还是文件引用 前后端对输出格式理解不同
提交时间与服务端回执时间 区分现场发生和系统入档时间 排查离线补录时无法排序
幂等标识与失败码 支撑网络重试和重复提交处理 多次提交生成重复签收
归档编号与查询权限 支撑客户追溯和权限核验 验收后无法定位交付物

这些内容不是要求当前Canvas页面一次实现,而是为下一阶段交接建立准入条件。特别是离线场景:若允许现场先签后传,就要明确本地缓存是否加密、何时上传、失败后保留多久、人工补录如何避免重复。没有这些约定时,页面上的“确认”只能是本地操作,不能承诺为业务归档。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图4:已签署画面可作为页面内复核证据;正式归档仍需附件标识、工单回执和客户档案查询依据。

页面本地确认

生成待提交交接项

核对工单与签署事项

提交轨迹或附件

收到业务回执?

登记归档编号与查询权限

记录失败码、重试或人工补录

实施顾问跟踪闭环

当前工程将笔迹转换为归一化坐标后放入页面历史。若下一阶段要实现附件和工单归档,接口设计不能只接收一个模糊的"签字成功"布尔值。实施顾问应在联调前要求业务与技术双方确认最小数据契约。下面是推荐的接口设计和实施检查清单。

/**
 * 签字附件和工单提交的接口契约
 * 本地确认完成后,应调用这个接口将数据提交到外部系统
 */
interface SignatureSubmissionInterface {
  
  // 1. 接口基本信息
  apiEndpoint: {
    url: string;                    // 例如 /api/v1/orders/{orderId}/signature/submit
    method: 'POST';
    timeout: number;                // 建议 10 秒
    retryPolicy: {
      maxRetries: number;           // 建议 3 次
      backoffMs: number;            // 建议 1000ms 指数递增
    };
  };
  
  // 2. 请求体(Page → Server)
  requestPayload: {
    // 工单唯一标识
    workOrderId: string;            // 来自 context.workOrderId
    workOrderVersion: string;       // 用于防止过期数据被提交
    
    // 签署人标识与核验方式
    signerInfo: {
      operatorId?: string;          // 现场工程师工号或客户ID
      operatorName: string;         // 例如"维修人员"或客户真实姓名
      verificationMethod: 'OPERATOR_FIELD' | 'CUSTOMER_ID_CARD' | 'BIOMETRIC';
      verificationStatus: 'PENDING' | 'VERIFIED' | 'MANUAL_OVERRIDE';
    };
    
    // 签署事项与版本
    signatureAction: {
      actionType: 'COMPLETION' | 'INSPECTION' | 'HANDOVER' | 'OTHER';  // 例如完工确认、质检复核
      actionVersion: string;        // 如果同一工单可以多次签署,需要版本来区分
      actionDescription: string;    // 对签署事项的详细描述
    };
    
    // 笔迹数据
    strokeData: {
      format: 'NORMALIZED_COORDINATES' | 'PIXEL_COORDINATES' | 'IMAGE_BASE64' | 'PDF_BINARY';
      strokes: Array<{
        points: Array<{ x: number; y: number }>;  // 坐标点数组
        timestamp?: number;                        // 可选的笔画时间戳
      }>;
      metadata: {
        canvasWidth: number;
        canvasHeight: number;
        captureDeviceModel: string;
        captureTimestamp: string;                 // ISO 8601 格式
      };
    };
    
    // 提交时间与时序
    submissionContext: {
      submittedAt: string;          // 页面提交时间(ISO 8601)
      signedAt: string;             // 现场签署时间(ISO 8601)
      localConfirmTime: string;     // 页面本地确认时间(ISO 8601)
      networkDelay?: number;        // 如有离线补录,记录延迟时长
    };
    
    // 幂等性与重试支持
    idempotencyKey: string;         // 唯一标识,支持安全重试(UUID)
    previousAttempts?: Array<{
      attemptId: string;
      timestamp: string;
      httpStatus: number;
      errorMessage?: string;
    }>;
  };
  
  // 3. 响应体(Server → Page)
  responsePayload: {
    success: boolean;
    
    // 成功响应
    successResponse?: {
      attachmentId: string;         // 系统生成的附件唯一标识
      attachmentUrl?: string;       // 可选的文件访问URL
      workOrderStatus: string;      // 工单更新后的状态(例如"已完成")
      workOrderUpdateTime: string;  // 系统更新工单的时间戳
      archiveNumber?: string;       // 可选的档案编号(如果已归档)
      archiveAccessToken?: string;  // 访问档案的令牌
      receiptId: string;            // 系统回执编号,用于后续查询
    };
    
    // 失败响应
    errorResponse?: {
      errorCode: string;            // 例如 "WORK_ORDER_NOT_FOUND" | "INVALID_SIGNER" | "STORAGE_FAILED"
      errorMessage: string;
      retryable: boolean;           // 是否可重试
      suggestedAction: string;      // 建议的处理方式
      details?: Record<string, any>;
    };
    
    // 通用字段
    timestamp: string;              // 服务端响应时间戳
    version: string;                // 接口版本号,便于后续兼容性检查
  };
}

/**
 * 联调前的接口验证清单
 */
class InterfaceVerificationChecklist {
  
  public generateInterfaceContractCheckList(): InterfaceCheckList {
    return {
      preIntegration: [
        {
          item: '工单唯一标识的格式和生命周期',
          why: '页面需要知道当前工单号是否有效,是否已过期',
          checkPoints: [
            '工单号格式(例如 WO-* 或 ORDER-*)',
            '工单有效期(从创建到过期的时间窗口)',
            '是否支持工单版本号(用于防止冲并发修改)',
            '测试样本:提供至少 3 个实际工单号'
          ],
          consequence: '如格式不清,可能导致工单号被误解或验证失败'
        },
        
        {
          item: '签署事项的枚举值和含义',
          why: '页面需要记录签署是什么事项(完工确认、质检复核等)',
          checkPoints: [
            '完整的事项枚举列表',
            '每个事项的业务含义',
            '同一工单是否可以多次签署不同事项',
            '事项与工单状态的映射关系'
          ],
          consequence: '如不清楚,可能记录错误的事项类型'
        },
        
        {
          item: '签署人的标识和核验方式',
          why: '系统需要知道谁在签,以及如何核验身份',
          checkPoints: [
            '是否使用工号、客户ID或其他标识',
            '是否需要身份证明(手工输入、扫描、生物识别)',
            '页面是否需要提供核验入口,还是依赖上层系统已核验',
            '核验失败时的处理方式(拒绝、人工审核、标记待验证)'
          ],
          consequence: '如不清楚,可能导致无法追溯签署人'
        },
        
        {
          item: '笔迹数据的承载格式',
          why: '接口需要明确接收坐标、图片还是PDF',
          checkPoints: [
            '推荐格式:归一化坐标 + 画布尺寸(最小化传输,支持重新绘制)',
            '备选方案:已渲染的PNG/JPG(可直接显示)',
            '第三方选项:PDF(可包含签署日期和工单号)',
            '数据大小限制(坐标数据通常<100KB,图片可能>1MB)',
            '离线场景:如支持离线补录,需要明确格式兼容性'
          ],
          consequence: '如格式不确定,可能导致前后端理解不一致或传输失败'
        },
        
        {
          item: '时间戳的精度和时区',
          why: '用于排序、审计和离线补录场景',
          checkPoints: [
            '使用 ISO 8601 格式(yyyy-MM-ddTHH:mm:ss.sssZ)',
            '所有时间戳都采用 UTC,不使用本地时间',
            '页面时间、提交时间、服务端时间三者都需要记录',
            '允许客户端和服务端时间差(例如±5秒的容限)',
            '离线场景:客户端时间可能不准确,服务端应以收到时间为准'
          ],
          consequence: '如时间戳格式不统一,可能导致排序错误或审计问题'
        },
        
        {
          item: '幂等性支持与重试策略',
          why: '网络不稳定时,可能多次提交同一数据',
          checkPoints: [
            '页面生成唯一的 idempotencyKey(UUID)',
            '服务端使用该 key 进行去重',
            '重试次数上限(建议 3-5 次)',
            '重试间隔的指数递增规则(例如 1s, 2s, 4s)',
            '如最后一次重试仍失败,是否支持人工补录'
          ],
          consequence: '如不支持幂等性,重试时可能生成重复的签署记录'
        },
        
        {
          item: '错误码的明确定义',
          why: '页面需要根据不同的错误采取不同的处理',
          checkPoints: [
            '工单不存在(HTTP 404)',
            '工单已过期或已更新(HTTP 409 Conflict)',
            '签署人身份验证失败(HTTP 401)',
            '权限不足(HTTP 403)',
            '存储或数据库失败(HTTP 500)',
            '服务暂时不可用(HTTP 503)',
            '每个错误码是否 retryable'
          ],
          consequence: '如错误码不清,页面可能对失败情况采取错误的处理(例如不该重试的进行了重试)'
        },
        
        {
          item: '响应内容的完整性',
          why: '页面需要知道外部系统的处理结果',
          checkPoints: [
            '成功时:返回附件ID、工单更新状态、回执编号',
            '失败时:返回错误码、错误原因、建议操作',
            '时间戳:服务端响应时间,用于排序',
            '版本号:接口版本,便于兼容性维护'
          ],
          consequence: '如响应内容不完整,页面无法判断后续应该做什么'
        },
        
        {
          item: '离线和补录场景的支持',
          why: '网络不可用时,需要定义如何处理',
          checkPoints: [
            '页面是否缓存本地确认数据(如缓存,多久自动清除)',
            '缓存数据的加密方式',
            '网络恢复后的自动提交还是人工确认',
            '离线补录时是否需要重新核验签署人身份',
            '多次离线补录的去重策略'
          ],
          consequence: '如不支持离线补录,网络断开时无法完成任务'
        }
      ],
      
      integrationTesting: [
        {
          test: '正常路径:有效工单、有效签署人、正常提交',
          steps: [
            '准备一个有效的测试工单号',
            '在页面完成签字并本地确认',
            '点击提交按钮',
            '观察返回的 attachmentId 和 workOrderStatus'
          ],
          expect: 'success=true,返回有效的 attachmentId'
        },
        
        {
          test: '工单不存在',
          steps: [
            '修改工单号为不存在的值(例如 WO-NONEXIST)',
            '完成签字并提交'
          ],
          expect: 'success=false,errorCode=WORK_ORDER_NOT_FOUND,retryable=false'
        },
        
        {
          test: '网络超时,然后重试成功',
          steps: [
            '断开网络或设置代理超时',
            '完成签字并提交,观察超时',
            '恢复网络',
            '页面应自动重试或提供手动重试按钮',
            '第二次提交成功'
          ],
          expect: '第二次提交返回 success=true,且不生成重复的附件'
        },
        
        {
          test: '同一幂等性 key 的重复提交',
          steps: [
            '记录第一次提交的 idempotencyKey',
            '模拟网络重试,使用相同的 key 重新提交',
            '服务端应返回之前的结果,不生成新附件'
          ],
          expect: 'attachmentId 与第一次相同(去重成功)'
        },
        
        {
          test: '离线补录场景',
          steps: [
            '完成签字和本地确认',
            '页面自动尝试提交,但网络不可用',
            '页面缓存本地确认数据',
            '网络恢复后',
            '页面提示"有待提交的数据,是否现在提交"',
            '用户选择提交'
          ],
          expect: '离线数据被正确提交,生成 attachmentId'
        },
        
        {
          test: '并发提交同一工单(应被拒绝或排队)',
          steps: [
            '在两个设备上同时提交相同工单的签署',
            '或在同一设备快速点击提交两次'
          ],
          expect: '第一个提交成功,第二个应返回 WORK_ORDER_ALREADY_SIGNED 或排队'
        }
      ]
    };
  }
}
接口字段或约定 为什么需要 缺失后的风险 实施顾问的检查方式
工单唯一标识 将本次签收绑定到正确业务对象 记录无法关联或误回写 在契约中验证工单号格式,准备测试样本
签署事项与版本 区分完工确认、质检复核和交底 同一笔迹被错误解释 列出所有事项类型,确认映射关系
签署人标识与核验方式 说明是谁完成的业务动作 无法追溯责任人 确认页面是否需要提供核验入口
笔迹或附件载体 明确传坐标、图片还是文件引用 前后端对输出格式理解不同 选择格式后进行一次完整的联调测试
提交时间与服务端回执时间 区分现场发生和系统入档时间 排查离线补录时无法排序 确认所有时间戳都采用 ISO 8601 UTC 格式
幂等标识与失败码 支撑网络重试和重复提交处理 多次提交生成重复签收 测试网络超时和重试场景,验证幂等性
归档编号与查询权限 支撑客户追溯和权限核验 验收后无法定位交付物 确认服务端返回的回执包含足够的追踪信息
离线补录支持 网络不可用时的备用方案 断网时无法进行现场签署 如涉及离线,制定完整的缓存和补录流程

实施顾问的对接流程

  1. 前期设计(第一周):与应用负责人确认上表的 8 项核心约定
  2. 联调准备(第二周初):应用负责人提供接口文档和测试环境
  3. 集成开发(第二周中):页面接入接口,进行单机测试
  4. 集成测试(第二周末):在测试环境进行上表列出的 6 项集成测试
  5. 现场演练(第三周初):在现场使用真实工单进行端到端测试
  6. 上线前检查(第三周中):检查生产环境的接口配置和备份方案

九、上线前的实施检查清单

  • 工单、设备、签署事项与现场任务一致。

  • 操作前确认不处于历史预览状态。

  • 短笔画、有效笔画、撤销和清空均按预期反馈。

  • 本地确认后,历史列表和只读预览可复核。

  • 验收记录明确写为“本地确认”,没有提前写成“工单已归档”。

  • 若客户要求附件、回写或档案查询,已建立接口责任人与验收回执清单。

  • 网络或接口不可用时,人工交接单、补录责任人和时限已明确。

  • 工单、设备、签署事项与现场任务一致。

  • 操作前确认不处于历史预览状态。

  • 短笔画、有效笔画、撤销和清空均按预期反馈。

  • 本地确认后,历史列表和只读预览可复核。

  • 验收记录明确写为"本地确认",没有提前写成"工单已归档"。

  • 若客户要求附件、回写或档案查询,已建立接口责任人与验收回执清单。

  • 网络或接口不可用时,人工交接单、补录责任人和时限已明确。

  • 所有现场工程师已培训,能独立完成签字操作。

  • 实施顾问已进行 5 次以上的现场模拟演练。

  • 版本升级路径和数据迁移方案已制定。

  • 生产环境的接口 URL、超时和重试策略已配置。

  • 备份和灾难恢复方案已测试。

  • 交付文档(操作指南、验收清单、交接单模板)已准备好。

  • 第一线支持团队已培训,能处理常见故障。

/**
 * 上线前的完整实施检查框架
 */
class PreLaunchVerification {
  
  /**
   * 分层次的检查:页面层、流程层、组织层
   */
  public executePreLaunchVerification(): PreLaunchResult {
    
    const checks: VerificationCategory[] = [
      
      // 一、页面层检查:功能是否工作正常
      {
        category: '页面功能检查',
        priority: 'CRITICAL',
        items: [
          this.checkPageStartup(),
          this.checkWorkOrderDisplay(),
          this.checkTouchInput(),
          this.checkValidationLogic(),
          this.checkStateTransitions(),
          this.checkHistoryManagement(),
          this.checkErrorRecovery(),
          this.checkPerformance()
        ]
      },
      
      // 二、流程层检查:端到端流程是否完整
      {
        category: '工作流程检查',
        priority: 'HIGH',
        items: [
          this.checkSignatureWorkflow(),           // 签字→确认→历史的完整流程
          this.checkMultiEngineerHandover(),       // 多人协作的交接
          this.checkOfflineScenario(),             // 离线场景处理
          this.checkErrorRouting(),                // 异常分流
          this.checkInterfaceIntegration(),        // 与后端接口的集成
          this.checkDataMigration(),               // 版本升级时的数据迁移
          this.checkRollbackProcedure()            // 失败回滚
        ]
      },
      
      // 三、组织层检查:团队和文档是否就绪
      {
        category: '组织准备检查',
        priority: 'HIGH',
        items: [
          this.checkTeamTraining(),                // 现场工程师培训完成
          this.checkImplementerReadiness(),        // 实施顾问准备就绪
          this.checkDocumentation(),               // 文档齐全
          this.checkSupportCapability(),           // 支持团队培训
          this.checkEscalationPath(),              // 问题升级路径明确
          this.checkIncidentResponse()             // 应急预案已制定
        ]
      },
      
      // 四、环境层检查:基础设施是否就绪
      {
        category: '环境就绪检查',
        priority: 'HIGH',
        items: [
          this.checkProductionEnvironment(),       // 生产环境配置
          this.checkNetworkConnectivity(),         // 网络连通性
          this.checkStorageCapacity(),             // 存储容量
          this.checkBackupSystem(),                // 备份系统
          this.checkMonitoring(),                  // 监控告警
          this.checkSecurityCompliance()           // 安全合规
        ]
      }
    ];
    
    const results = checks.map(category => this.runCategoryTests(category));
    return this.synthesizeResults(results);
  }
  
  /**
   * 页面功能检查的具体实现
   */
  private checkPageStartup(): VerificationItem {
    return {
      name: '页面启动检查',
      status: 'PENDING',
      execute: () => {
        const result = {
          canLaunch: true,
          launchTime: 0,
          initialState: '',
          errors: [] as string[]
        };
        
        try {
          const startTime = performance.now();
          // 模拟页面启动
          const page = this.initializePage();
          result.launchTime = performance.now() - startTime;
          
          // 检查初始状态
          if (page.state !== 'INITIALIZING') {
            result.errors.push(`初始状态应为 INITIALIZING,实际为 ${page.state}`);
            result.canLaunch = false;
          }
          
          // 检查工单数据是否加载
          if (!page.workOrderData) {
            result.errors.push('工单数据未加载');
            result.canLaunch = false;
          }
          
          // 检查启动时间(不应超过 3 秒)
          if (result.launchTime > 3000) {
            result.errors.push(`启动时间过长: ${result.launchTime}ms,建议 <3000ms`);
          }
          
          result.status = result.canLaunch ? 'PASS' : 'FAIL';
          return result;
        } catch (e) {
          return {
            ...result,
            status: 'FAIL',
            canLaunch: false,
            errors: [`启动异常: ${e.message}`]
          };
        }
      }
    };
  }
  
  private checkStateTransitions(): VerificationItem {
    return {
      name: '状态转移完整性检查',
      status: 'PENDING',
      execute: () => {
        const expectedTransitions = [
          'INITIALIZING → READY_FOR_NEW',
          'READY_FOR_NEW → COLLECTING',
          'COLLECTING → REVIEW_STROKES',
          'REVIEW_STROKES → AWAITING_CONFIRM',
          'AWAITING_CONFIRM → CONFIRMED',
          'CONFIRMED → READY_FOR_NEW (清空)',
          'CONFIRMED → PREVIEW_MODE (预览)',
          'PREVIEW_MODE → READY_FOR_NEW (退出预览)'
        ];
        
        const observed: string[] = [];
        const missing: string[] = [];
        
        // 运行完整的操作流程并记录状态转移
        const stateLog = this.runFullWorkflow();
        const transitions = stateLog.map(log => `${log.fromState}${log.toState}`);
        
        // 检查哪些转移被观察到
        expectedTransitions.forEach(expected => {
          if (transitions.some(t => t === expected)) {
            observed.push(expected);
          } else {
            missing.push(expected);
          }
        });
        
        return {
          status: missing.length === 0 ? 'PASS' : 'FAIL',
          observedTransitions: observed.length,
          missingTransitions: missing,
          details: missing.length > 0 ? `缺失的转移: ${missing.join(', ')}` : '所有核心转移都被观察到'
        };
      }
    };
  }
  
  /**
   * 工作流程检查:测试现实场景
   */
  private checkSignatureWorkflow(): VerificationItem {
    return {
      name: '完整签字工作流测试',
      status: 'PENDING',
      execute: () => {
        const scenarios = [
          {
            name: '正常流程:完整签字→确认→预览',
            steps: [
              () => this.startNewSignature(),
              () => this.performValidStroke(),
              () => this.confirmSignature(),
              () => this.previewHistory(),
              () => this.exitPreview()
            ],
            expectFinalState: 'READY_FOR_NEW'
          },
          {
            name: '修正流程:签错→撤销→重写→确认',
            steps: [
              () => this.startNewSignature(),
              () => this.performValidStroke(),
              () => this.undoLastStroke(),
              () => this.performValidStroke(),
              () => this.confirmSignature()
            ],
            expectFinalState: 'CONFIRMED'
          },
          {
            name: '清空流程:签字→清空→重新开始',
            steps: [
              () => this.startNewSignature(),
              () => this.performValidStroke(),
              () => this.clearAllStrokes(),
              () => this.performValidStroke(),
              () => this.confirmSignature()
            ],
            expectFinalState: 'CONFIRMED'
          },
          {
            name: '异常流程:误触被拒绝',
            steps: [
              () => this.startNewSignature(),
              () => this.performShortTouch(),  // 应被拒绝
              () => this.expectStatusMessage('笔迹过短'),
              () => this.performValidStroke(),
              () => this.confirmSignature()
            ],
            expectFinalState: 'CONFIRMED'
          }
        ];
        
        const results = scenarios.map(scenario => {
          try {
            scenario.steps.forEach(step => step());
            const finalState = this.getCurrentState();
            const passed = finalState === scenario.expectFinalState;
            return {
              scenario: scenario.name,
              passed,
              finalState,
              error: passed ? null : `期望 ${scenario.expectFinalState},实际 ${finalState}`
            };
          } catch (e) {
            return {
              scenario: scenario.name,
              passed: false,
              error: e.message
            };
          }
        });
        
        const allPassed = results.every(r => r.passed);
        return {
          status: allPassed ? 'PASS' : 'FAIL',
          scenarios: results,
          summary: `${results.filter(r => r.passed).length}/${results.length} 个场景通过`
        };
      }
    };
  }
  
  /**
   * 组织准备检查
   */
  private checkTeamTraining(): VerificationItem {
    return {
      name: '现场工程师培训完成度',
      status: 'PENDING',
      execute: () => {
        const trainingRequirements = [
          { topic: '页面启动和工单核对', weight: 0.15 },
          { topic: '正常签字操作', weight: 0.25 },
          { topic: '撤销和清空操作', weight: 0.15 },
          { topic: '本地确认流程', weight: 0.15 },
          { topic: '历史预览和预览退出', weight: 0.15 },
          { topic: '常见故障处理', weight: 0.15 }
        ];
        
        const trainingRecords = this.getTrainingRecords();
        const coverage: Record<string, number> = {};
        
        trainingRequirements.forEach(req => {
          const trained = trainingRecords.filter(record => 
            record.topic === req.topic && record.status === 'PASSED'
          ).length;
          coverage[req.topic] = (trained / this.getTotalEngineerCount()) * 100;
        });
        
        const overallCoverage = trainingRequirements.reduce((sum, req) => 
          sum + (coverage[req.topic] || 0) * req.weight, 0
        );
        
        const passed = overallCoverage >= 80;  // 至少 80% 的人员接受了培训
        
        return {
          status: passed ? 'PASS' : 'FAIL',
          overallCoverage: `${overallCoverage.toFixed(1)}%`,
          topicCoverage: coverage,
          insufficientTopics: trainingRequirements
            .filter(req => (coverage[req.topic] || 0) < 80)
            .map(req => req.topic),
          recommendation: passed ? 
            '人员培训完成,可进行现场演练' :
            `需要补充培训:${Object.entries(coverage)
              .filter(([_, pct]) => pct < 80)
              .map(([topic]) => topic)
              .join(', ')}`
        };
      }
    };
  }
  
  /**
   * 环境准备检查
   */
  private checkProductionEnvironment(): VerificationItem {
    return {
      name: '生产环境配置检查',
      status: 'PENDING',
      execute: () => {
        const configChecks = [
          {
            name: '接口 URL 配置',
            check: () => {
              const url = this.getConfigValue('api.signature.submitUrl');
              return url && url.includes('https://') && url.includes('/api/');
            },
            importance: 'CRITICAL'
          },
          {
            name: '超时时间配置',
            check: () => {
              const timeout = this.getConfigValue('api.timeout');
              return timeout && timeout >= 10000;  // 至少 10 秒
            },
            importance: 'HIGH'
          },
          {
            name: '重试策略配置',
            check: () => {
              const maxRetries = this.getConfigValue('api.maxRetries');
              return maxRetries && maxRetries >= 3;
            },
            importance: 'HIGH'
          },
          {
            name: '日志级别配置',
            check: () => {
              const logLevel = this.getConfigValue('log.level');
              return ['DEBUG', 'INFO', 'WARN'].includes(logLevel);
            },
            importance: 'MEDIUM'
          },
          {
            name: '版本号配置',
            check: () => {
              const version = this.getConfigValue('app.version');
              return version && this.isValidVersion(version);
            },
            importance: 'MEDIUM'
          },
          {
            name: 'HTTPS/SSL 证书',
            check: () => this.validateSSLCertificate(),
            importance: 'CRITICAL'
          },
          {
            name: '备份和恢复方案',
            check: () => this.verifyBackupExists(),
            importance: 'HIGH'
          }
        ];
        
        const criticalChecks = configChecks.filter(c => c.importance === 'CRITICAL');
        const allChecks = configChecks.map(c => ({
          name: c.name,
          passed: c.check(),
          importance: c.importance
        }));
        
        const criticalPassed = criticalChecks.every(c => c.check());
        const overallPassed = allChecks.every(c => c.passed);
        
        return {
          status: criticalPassed ? (overallPassed ? 'PASS' : 'WARN') : 'FAIL',
          checks: allChecks,
          failedCritical: criticalChecks.filter(c => !c.check()).map(c => c.name),
          recommendation: criticalPassed ? 
            '关键配置就绪' :
            `必须修复的配置: ${criticalChecks.filter(c => !c.check()).map(c => c.name).join(', ')}`
        };
      }
    };
  }
  
  /**
   * 汇总所有检查结果
   */
  private synthesizeResults(categoryResults: CategoryResult[]): PreLaunchResult {
    const criticalFails = categoryResults.flatMap(cat => 
      cat.items.filter(item => item.status === 'FAIL' && item.priority === 'CRITICAL')
    );
    
    const allFails = categoryResults.flatMap(cat => 
      cat.items.filter(item => item.status === 'FAIL')
    );
    
    const allWarnings = categoryResults.flatMap(cat => 
      cat.items.filter(item => item.status === 'WARN')
    );
    
    const canLaunch = criticalFails.length === 0;
    
    return {
      canLaunch,
      timestamp: new Date().toISOString(),
      summary: {
        totalChecks: categoryResults.flatMap(c => c.items).length,
        passed: categoryResults.flatMap(c => c.items).filter(i => i.status === 'PASS').length,
        failed: allFails.length,
        warnings: allWarnings.length
      },
      criticalIssues: criticalFails.map(f => ({ name: f.name, reason: f.error })),
      allIssues: allFails.map(f => ({ name: f.name, reason: f.error })),
      recommendations: this.generateRecommendations(canLaunch, allFails, allWarnings),
      approvalStatus: canLaunch ? 'READY_FOR_LAUNCH' : 'BLOCKED',
      nextSteps: this.getNextSteps(canLaunch, allFails)
    };
  }
  
  private generateRecommendations(canLaunch: boolean, fails: any[], warnings: any[]): string[] {
    const recommendations: string[] = [];
    
    if (!canLaunch) {
      recommendations.push('❌ 发现关键问题,不能上线。必须修复所有关键问题后重新检查。');
      fails.forEach(fail => recommendations.push(`  - 修复:${fail.name}`));
    } else {
      recommendations.push('✅ 关键项通过,可以上线。');
    }
    
    if (warnings.length > 0) {
      recommendations.push(`⚠️  发现 ${warnings.length} 个警告项,建议修复但不阻塞上线。`);
      warnings.forEach(warn => recommendations.push(`  - 注意:${warn.name}`));
    }
    
    recommendations.push('- 上线前进行最后一次现场模拟演练');
    recommendations.push('- 第一线支持团队已准备好处理问题');
    recommendations.push('- 备份和回滚方案已验证');
    
    return recommendations;
  }
  
  private getNextSteps(canLaunch: boolean, fails: any[]): string[] {
    if (!canLaunch) {
      return [
        `1. 修复以下 ${fails.length} 个问题`,
        ...fails.map((f, i) => `   ${i+1}. ${f.name}`),
        '2. 重新运行完整检查',
        '3. 所有项通过后,由实施负责人签字确认'
      ];
    }
    
    return [
      '1. 实施负责人最终确认',
      '2. 现场进行全流程模拟演练(至少一次)',
      '3. 现场工程师、实施顾问、客户代表三方签字',
      '4. 切换到生产环境配置',
      '5. 进行第一个真实工单的现场签署',
      '6. 验收通过后,进入常规运维阶段'
    ];
  }
}

上线前 72 小时检查清单

检查项 责任人 完成日期 签字 备注
运行完整的预上线检查框架 技术负责人 Day-3 必须所有关键项通过
现场模拟演练(全流程) 实施顾问 Day-2 至少 1 次完整演练
现场工程师培训复习 实施负责人 Day-2 涵盖所有 6 个主题
备份和回滚测试 系统管理员 Day-2 确保故障时可快速恢复
支持团队最后一次培训 运维主管 Day-1 常见故障和处理流程
验收交付物最后检查 项目经理 Day-1 文档、清单、交接单
生产环境最终检查 系统运维 Day-1 配置、权限、容量
所有人员上线前会议 项目经理 Day-0 早上 确认职责、应急预案

FAQ

页面显示”本地确认已记录”,可以向客户宣布工单已完成吗?

答:不可以。 它只证明当前页面创建了本地历史记录,且笔迹数据保存在页面内存中。工单完成需要业务系统状态、接口回执和项目约定的验收材料。具体的完成标准由客户和项目约定决定,可能包括:

  • 附件已导出并存储
  • 工单系统已更新状态
  • 客户档案已生成归档编号

在这些条件都满足前,只能说”页面本地确认已完成,待外部链路补齐”。

为什么要单独验证短笔画?

答: 短笔画验证的是页面确认门槛(<12像素或<3个点的触摸会被拒绝),而不是验证签署人的身份真实性。它的作用是:

  1. 防止误触:现场工程师的手掌或笔不小心碰到屏幕时不会创建无意义的记录
  2. 确保可用笔迹:确认用户真的在认真书写,而不是随意点击
  3. 提供明确的反馈:用户立即知道这一笔是否被接受

这是一个交互设计决策,不涉及法律效力或身份认证。如果项目需要更强的身份认证(例如手指纹、证件验证),应作为独立需求在后续集成中实现。

历史预览能否作为长期留档?

答:不能。 当前历史数据是页面内存中的状态。是否能跨重启、跨设备或跨账号保留,需要满足三个条件:

  1. 持久化存储:数据写入数据库或本地文件系统
  2. 跨会话查询:应用重启后仍能读取
  3. 查询链路验证:确认其他人、其他设备也能查询到

现阶段,历史预览只能用于当前会话内的复核。如需长期留档,应由后续的档案系统负责。

客户要求立即拿到签字附件怎么办?

答: 应将附件导出与存储列为交付前置条件,明确以下内容后再承诺:

  • 文件格式:PNG、JPG、PDF还是其他格式
  • 命名规则:工单号-签署人-日期的格式
  • 存储位置:页面本地、服务器、对象存储还是客户档案系统
  • 访问权限:谁能查看、下载或打印
  • 回执方式:如何证明文件已生成和交付

未完成前不能用页面截图替代正式附件,除非客户书面确认该临时方案。

网络不可用时,能否先让客户签完以后再补?

答: 可以按项目批准的人工交接流程处理,但必须记录以下内容:

  • 工单:工单号、设备标识
  • 事项:签署内容(完工确认、质检复核等)
  • 签署人:谁签的,如何核验身份
  • 时间:现场签署的确切时间
  • 经办人:现场工程师和实施顾问
  • 补录责任:谁负责后续将本地记录补录到系统
  • 补录截止:必须在几小时或几天内完成

人工记录不应被标为系统归档成功。这只是一个临时交接方案,后续仍需通过系统进行追溯。

抗锯齿开关为什么不在验收主结论里?

答: 抗锯齿是一个页面渲染质量的参数,影响笔迹的显示观感但不改变以下内容:

  • 手写采集的坐标数据
  • 有效笔画的判定逻辑
  • 本地确认的状态转移
  • 外部系统的接口调用

因此,它可以作为界面质量检查项(例如”笔迹显示清晰”),但不能作为功能交付的主要依据。实施顾问应将重点放在状态转移和数据准确性上,而不是渲染细节。

同一页面能支持多个不同的工单吗?

答: 可以,但需要明确的流程:

  1. 每次新工单前必须主动清空前一个工单的数据
  2. 页面启动时应强制进行工单核对,不能依赖页面的缓存
  3. 状态转移日志应清晰记录每次工单切换的时间和原因

建议的做法是:现场工程师完成一个工单的签字后,退出应用,然后为下一个工单重新启动应用。这避免了混淆和误操作。

如果签字过程中应用崩溃,本地确认的数据会丢失吗?

答: 取决于何时崩溃:

  • 签字中途崩溃:未确认的笔迹会丢失(这是正常的,因为未持久化)
  • 本地确认后崩溃:已确认的记录应该保留在历史列表中,除非应用的内存管理有缺陷
  • 预览模式崩溃:预览本身不会改变历史数据,重启后应该能重新看到

建议为避免数据丢失:

  1. 现场工程师完成签字后立即点击”本地确认”
  2. 确认看到状态提示后再离开页面
  3. 如需修改或查看,使用”历史预览”功能

可以用截屏作为签字证明吗?

答: 截屏可以作为现场过程证明,但不能作为最终交付凭证。区别如下:

用途 截屏是否足够 补充要求
现场验证页面工作正常 ✅ 足够 记录时间和工单号
证明本地确认已完成 ✅ 可用 需标注时间戳和操作人
作为客户最终交付 ❌ 不足 需要正式附件(图片、PDF或档案)
用于后续审计 ⚠️ 有限 最好配合系统回执使用

截屏应由实施顾问或客户代表进行,并在截屏中标注工单号、操作人、时间,然后与本地确认记录一起归档。

附录:必要条件|模拟器与真机准备对照

条件 API 24 模拟器 HarmonyOS 6.1.1 真机
SDK/API与构建工具 使用 API 24 镜像验证构建和签字页面基础交互 使用兼容 API 24 的签名包安装,并记录设备型号与系统版本
Kit引入 先确认 ArkUI、Canvas 相关类型在编译期可用 再确认安装包在设备运行时可以启动并响应触控
模块/页面配置 页面路由和 Stage 启动可验证 页面路由、应用签名和安装状态均需验证
权限 当前页面没有相机、麦克风、文件写入等动态权限,可验证无权限阻塞 当前页面同样不申请动态权限;新增附件导出、媒体访问或工单上传能力后,需重新核验权限声明与授权状态
系统能力/硬件 可验证页面逻辑和鼠标/模拟触控输入 真实触控、屏幕密度、横竖屏与现场网络条件以真机实际表现为准

下图记录的是本工程使用的 HarmonyOS 6.1.1 SDK 已安装状态,用于核对构建基础;它不代表签字附件、工单回写或客户档案归档已经完成。

在这里插入图片描述

当前工程只验证手写采集、页面内本地确认与历史只读预览。涉及附件导出、媒体访问或工单上传时,应在相应工程加入能力后重新核对权限与动态授权,并以业务系统回执作为归档完成依据。


总结:从现场签收到交付闭环

本文围绕一个看似简单的问题——“签好了”——提供了一套完整的企业实施视角。关键要点总结如下:

核心原则

  1. 不要混淆层级:页面能力、接口能力和业务归档能力是三个独立的验收项,不能混为一谈
  2. 明确责任边界:每个决策点都应有唯一的执行方和验收方
  3. 保留中间节点:本地确认不是终点,而是一个重要的检查点,为后续集成打下基础
  4. 文档先于代码:在编码前制定接口契约和验收标准,避免事后补救

实施顾问的三个关键动作

  1. 现场前:准备操作指南、验收清单、应急方案
  2. 现场中:记录状态转移、区分正常行为和异常、及时分流问题
  3. 现场后:形成验收结论、建立交接清单、启动后续环节

项目的分阶段交付

  • 第一阶段:页面能力通过(本文重点)
  • 第二阶段:附件和工单接口集成
  • 第三阶段:客户档案系统集成和权限管理

每个阶段都是独立的验收事件,允许客户在功能逐步接入时持续获得可核验的成果。

给客户的承诺

不要承诺页面本身做不到的事。清晰地说明:

  • ✅ 这个版本能做到什么
  • ⭕ 这个版本暂时做不到什么
  • ➡️ 下一阶段怎么做到

这样的透明度会比虚假的”全能”更能赢得客户信任。


必要条件|模拟器与真机准备对照

条件 API 24 模拟器 HarmonyOS 6.1.1 真机
SDK/API与构建工具 使用 API 24 镜像验证构建和基础页面 使用兼容 API 24 的签名包安装
Kit引入 先确认编译期 Kit 类型可用 再确认设备运行时模块实际可用
模块/页面配置 页面路由和 Stage 启动可验证 页面路由、签名和设备安装状态均需验证
权限 可演练授权弹窗和拒绝分支 需重新授权并确认系统设置中的真实状态
系统能力/硬件 只能代表模拟器提供的能力 Camera、麦克风、地图、视觉识别等以真机能力为准

SDK/API 对照完成后插入 DevEco Studio API 24 与构建配置截图:

在这里插入图片描述

授权对照完成后插入真实设备权限截图:
在这里插入图片描述

版本和能力对照完成后插入设备/模拟器信息截图:

在这里插入图片描述

Logo

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

更多推荐