HarmonyOS6.1.1-Canvas:现场签收笔迹作为交付附件时-怎样划分手写过程、页面状态与归档结果
项目上线的最后一公里,最怕的不是签名板画不出一笔,而是所有人都看见了笔迹,却没有人能说清这笔迹究竟完成了哪一步。现场工程师认为“客户已经签字”;实施人员认为“页面已经确认”;项目经理以为“附件已经归档”;客户代表则等待一份可以追溯的交付凭证。若这四种理解没有在验收前对齐,后续出现工单找不到、附件缺失、签署人不一致时,任何一方都可能认为责任在别处。
本工程提供的是一个全屏手写板:它接收触摸坐标、即时重绘笔迹、判断有效笔画、在当前页面创建本地确认记录,并支持历史笔迹只读预览。它没有导出图片、没有调用工单接口,也没有向客户档案或对象存储写入附件。因此,本文的目标不是解释如何“做一个签名功能”,而是建立一套实施口径:什么结果可以在现场签收时确认,什么材料必须在交接单中注明待补,哪个角色应在下一步接手。
一、把“签好了”拆成四个验收结论
现场签收不要只使用“已签字”一个状态。实施顾问需要将其拆成四个彼此独立的结论,分别记录在操作记录、交付清单或问题台账中。
| 验收结论 | 当前工程能否支持 | 现场可观察证据 | 下一责任方 |
|---|---|---|---|
| 手写输入已经采集 | 可以 | 画布出现连续笔迹;状态提示完成采集 | 现场工程师 |
| 本地确认已经生成 | 可以 | 有效笔画通过后,历史列表增加本地记录 | 实施人员 |
| 交付附件已经生成 | 不可以 | 需要文件名、文件大小、存储位置或下载核验 | 应用服务/文档服务负责人 |
| 工单和客户档案已归档 | 不可以 | 需要接口回执、工单状态、归档编号与客户确认 | 业务系统对接人与客户代表 |
这不是措辞游戏。四个结论对应四类证据、四个责任面和不同的故障处理方式。当前工程的状态文案已经明确写出“本地确认已记录,未回写真实工单”,因此实施记录不能把它压缩成“工单已签收”。

图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 现场验收的三组固定动作
- 短触摸验证:轻触或短划后,确认状态停在“笔迹过短,请重新签字”或“请先完成有效签字”。
- 连续书写验证:完成连续书写后,确认状态转为“笔迹已采集,可确认本地归档”。
- 更正验证:点击撤销或清空,确认笔迹和状态同步刷新,再重新书写。
这三组动作覆盖的是输入可靠性,不包括签字法律效力。验收报告应把“有效笔画门槛通过”写成页面交互结论,不写成“签名真实性通过”。
四、本地确认必须保留为独立状态
确认按钮的名称包含“归档”,很容易造成项目误解。实际处理流程先统计有效笔画,再创建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);
}
}
实施顾问在升级前应:
- ✅ 备份所有现有的本地确认记录
- ✅ 制定旧版本数据的迁移策略
- ✅ 在测试环境完整验证数据迁移
- ✅ 制定升级失败时的回滚方案
- ✅ 在正式升级前通知所有相关方
七、客户验收时应交付什么
客户代表不需要审核每一段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 格式 |
| 幂等标识与失败码 | 支撑网络重试和重复提交处理 | 多次提交生成重复签收 | 测试网络超时和重试场景,验证幂等性 |
| 归档编号与查询权限 | 支撑客户追溯和权限核验 | 验收后无法定位交付物 | 确认服务端返回的回执包含足够的追踪信息 |
| 离线补录支持 | 网络不可用时的备用方案 | 断网时无法进行现场签署 | 如涉及离线,制定完整的缓存和补录流程 |
实施顾问的对接流程:
- ✅ 前期设计(第一周):与应用负责人确认上表的 8 项核心约定
- ✅ 联调准备(第二周初):应用负责人提供接口文档和测试环境
- ✅ 集成开发(第二周中):页面接入接口,进行单机测试
- ✅ 集成测试(第二周末):在测试环境进行上表列出的 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个点的触摸会被拒绝),而不是验证签署人的身份真实性。它的作用是:
- 防止误触:现场工程师的手掌或笔不小心碰到屏幕时不会创建无意义的记录
- 确保可用笔迹:确认用户真的在认真书写,而不是随意点击
- 提供明确的反馈:用户立即知道这一笔是否被接受
这是一个交互设计决策,不涉及法律效力或身份认证。如果项目需要更强的身份认证(例如手指纹、证件验证),应作为独立需求在后续集成中实现。
历史预览能否作为长期留档?
答:不能。 当前历史数据是页面内存中的状态。是否能跨重启、跨设备或跨账号保留,需要满足三个条件:
- 持久化存储:数据写入数据库或本地文件系统
- 跨会话查询:应用重启后仍能读取
- 查询链路验证:确认其他人、其他设备也能查询到
现阶段,历史预览只能用于当前会话内的复核。如需长期留档,应由后续的档案系统负责。
客户要求立即拿到签字附件怎么办?
答: 应将附件导出与存储列为交付前置条件,明确以下内容后再承诺:
- 文件格式:PNG、JPG、PDF还是其他格式
- 命名规则:工单号-签署人-日期的格式
- 存储位置:页面本地、服务器、对象存储还是客户档案系统
- 访问权限:谁能查看、下载或打印
- 回执方式:如何证明文件已生成和交付
未完成前不能用页面截图替代正式附件,除非客户书面确认该临时方案。
网络不可用时,能否先让客户签完以后再补?
答: 可以按项目批准的人工交接流程处理,但必须记录以下内容:
- 工单:工单号、设备标识
- 事项:签署内容(完工确认、质检复核等)
- 签署人:谁签的,如何核验身份
- 时间:现场签署的确切时间
- 经办人:现场工程师和实施顾问
- 补录责任:谁负责后续将本地记录补录到系统
- 补录截止:必须在几小时或几天内完成
人工记录不应被标为系统归档成功。这只是一个临时交接方案,后续仍需通过系统进行追溯。
抗锯齿开关为什么不在验收主结论里?
答: 抗锯齿是一个页面渲染质量的参数,影响笔迹的显示观感但不改变以下内容:
- 手写采集的坐标数据
- 有效笔画的判定逻辑
- 本地确认的状态转移
- 外部系统的接口调用
因此,它可以作为界面质量检查项(例如”笔迹显示清晰”),但不能作为功能交付的主要依据。实施顾问应将重点放在状态转移和数据准确性上,而不是渲染细节。
同一页面能支持多个不同的工单吗?
答: 可以,但需要明确的流程:
- 每次新工单前必须主动清空前一个工单的数据
- 页面启动时应强制进行工单核对,不能依赖页面的缓存
- 状态转移日志应清晰记录每次工单切换的时间和原因
建议的做法是:现场工程师完成一个工单的签字后,退出应用,然后为下一个工单重新启动应用。这避免了混淆和误操作。
如果签字过程中应用崩溃,本地确认的数据会丢失吗?
答: 取决于何时崩溃:
- 签字中途崩溃:未确认的笔迹会丢失(这是正常的,因为未持久化)
- 本地确认后崩溃:已确认的记录应该保留在历史列表中,除非应用的内存管理有缺陷
- 预览模式崩溃:预览本身不会改变历史数据,重启后应该能重新看到
建议为避免数据丢失:
- 现场工程师完成签字后立即点击”本地确认”
- 确认看到状态提示后再离开页面
- 如需修改或查看,使用”历史预览”功能
可以用截屏作为签字证明吗?
答: 截屏可以作为现场过程证明,但不能作为最终交付凭证。区别如下:
| 用途 | 截屏是否足够 | 补充要求 |
|---|---|---|
| 现场验证页面工作正常 | ✅ 足够 | 记录时间和工单号 |
| 证明本地确认已完成 | ✅ 可用 | 需标注时间戳和操作人 |
| 作为客户最终交付 | ❌ 不足 | 需要正式附件(图片、PDF或档案) |
| 用于后续审计 | ⚠️ 有限 | 最好配合系统回执使用 |
截屏应由实施顾问或客户代表进行,并在截屏中标注工单号、操作人、时间,然后与本地确认记录一起归档。
附录:必要条件|模拟器与真机准备对照
| 条件 | API 24 模拟器 | HarmonyOS 6.1.1 真机 |
|---|---|---|
| SDK/API与构建工具 | 使用 API 24 镜像验证构建和签字页面基础交互 | 使用兼容 API 24 的签名包安装,并记录设备型号与系统版本 |
| Kit引入 | 先确认 ArkUI、Canvas 相关类型在编译期可用 | 再确认安装包在设备运行时可以启动并响应触控 |
| 模块/页面配置 | 页面路由和 Stage 启动可验证 | 页面路由、应用签名和安装状态均需验证 |
| 权限 | 当前页面没有相机、麦克风、文件写入等动态权限,可验证无权限阻塞 | 当前页面同样不申请动态权限;新增附件导出、媒体访问或工单上传能力后,需重新核验权限声明与授权状态 |
| 系统能力/硬件 | 可验证页面逻辑和鼠标/模拟触控输入 | 真实触控、屏幕密度、横竖屏与现场网络条件以真机实际表现为准 |
下图记录的是本工程使用的 HarmonyOS 6.1.1 SDK 已安装状态,用于核对构建基础;它不代表签字附件、工单回写或客户档案归档已经完成。

当前工程只验证手写采集、页面内本地确认与历史只读预览。涉及附件导出、媒体访问或工单上传时,应在相应工程加入能力后重新核对权限与动态授权,并以业务系统回执作为归档完成依据。
总结:从现场签收到交付闭环
本文围绕一个看似简单的问题——“签好了”——提供了一套完整的企业实施视角。关键要点总结如下:
核心原则
- 不要混淆层级:页面能力、接口能力和业务归档能力是三个独立的验收项,不能混为一谈
- 明确责任边界:每个决策点都应有唯一的执行方和验收方
- 保留中间节点:本地确认不是终点,而是一个重要的检查点,为后续集成打下基础
- 文档先于代码:在编码前制定接口契约和验收标准,避免事后补救
实施顾问的三个关键动作
- 现场前:准备操作指南、验收清单、应急方案
- 现场中:记录状态转移、区分正常行为和异常、及时分流问题
- 现场后:形成验收结论、建立交接清单、启动后续环节
项目的分阶段交付
- 第一阶段:页面能力通过(本文重点)
- 第二阶段:附件和工单接口集成
- 第三阶段:客户档案系统集成和权限管理
每个阶段都是独立的验收事件,允许客户在功能逐步接入时持续获得可核验的成果。
给客户的承诺
不要承诺页面本身做不到的事。清晰地说明:
- ✅ 这个版本能做到什么
- ⭕ 这个版本暂时做不到什么
- ➡️ 下一阶段怎么做到
这样的透明度会比虚假的”全能”更能赢得客户信任。
必要条件|模拟器与真机准备对照
| 条件 | API 24 模拟器 | HarmonyOS 6.1.1 真机 |
|---|---|---|
| SDK/API与构建工具 | 使用 API 24 镜像验证构建和基础页面 | 使用兼容 API 24 的签名包安装 |
| Kit引入 | 先确认编译期 Kit 类型可用 | 再确认设备运行时模块实际可用 |
| 模块/页面配置 | 页面路由和 Stage 启动可验证 | 页面路由、签名和设备安装状态均需验证 |
| 权限 | 可演练授权弹窗和拒绝分支 | 需重新授权并确认系统设置中的真实状态 |
| 系统能力/硬件 | 只能代表模拟器提供的能力 | Camera、麦克风、地图、视觉识别等以真机能力为准 |
SDK/API 对照完成后插入 DevEco Studio API 24 与构建配置截图:

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

更多推荐


所有评论(0)