HarmonyOS 多设备粘贴表格的列映射与错误回显【共创稿事节】

从桌面表格复制一批书目,再粘贴到手机或平板上的入库页面,最容易出错的地方往往是列的位置。有人把“数量”放在第二列,有人把“名称”放在第二列,还有人只复制了数据区。直接按数组下标取值,界面可能看起来正常,实际入库字段却已经错位。窗口变宽以后,更多列同时可见,也不会自动解决这个数据问题。

这里为图书收货表建立一个明确的粘贴协议:首行必须含有书号、名称、数量三个表头;顺序允许变化;数据使用制表符分列、换行分行。程序先完成列映射,再逐行核对字段,把错误定位到原始行号和业务列。所有行都通过以后,才形成可提交的数据集合。

粘贴表格列映射

把粘贴入口与数据协议分开

页面使用TextArea接收用户粘贴的文本,软键盘和物理键盘都进入同一条解析路径。华为的TextArea输入绑定说明给出了text与onChange的配合方式。输入回调中保存原文,不把制表符改成空格,也不按屏幕上的视觉换行拆行。输入框自动折行属于排版,只有原文里的换行字符才是数据边界。

示例使用API 12及以上的ArkUI基础组件,无需剪贴板读取权限。用户主动粘贴到输入框,与应用读取系统剪贴板是不同的交互路径。布局依据当前应用窗口宽度决定内容上限,而不是根据设备名称判断“手机模式”或“平板模式”。多设备适配入口可用于继续检查窗口、键盘和不同交互方式。

协议刻意保持紧凑:不接收带引号转义的CSV、不接收单元格内换行和制表符。若数据来源需要这些内容,应新增相应格式的解析器,并在入口明确格式。把所有分隔文本都交给一个split表达式,会让用户无法区分格式问题与业务问题。

先找到列,再解释单元格

前三段代码按顺序放入ets/model/PasteTable.ts,第四段是ets/pages/PasteTable.ets。列映射只读取表头,返回名称到位置的对应关系。表头前后空格允许清理,但数据单元格的原文仍然保留,便于回显。首字符的BOM单独处理,避免第一列表头带上不可见字符。

export class BookRow {
  isbn: string = '';
  name: string = '';
  count: number = 0;
}

export class PasteResult {
  rows: BookRow[] = [];
  errors: string[] = [];
}

export function mapHeader(line: string): number[] {
  const header: string[] = line.replace(/^\uFEFF/, '')
    .split('\t').map((cell: string) => cell.trim());
  const required: string[] = ['书号', '名称', '数量'];
  if (header.length !== required.length ||
      new Set<string>(header).size !== header.length) {
    throw new Error('表头必须包含三个不同的业务列');
  }
  return required.map((name: string) => {
    const position: number = header.indexOf(name);
    if (position < 0) {
      throw new Error('缺少表头:' + name);
    }
    return position;
  });
}

只判断“包含数量”还不够,还要拒绝重复表头。两个数量列同时存在时,indexOf会取第一个,另一个被静默忽略。这里要求列数严格等于三,并且每个名称只出现一次。遇到额外备注列也会提示表头错误,避免用户以为备注已经被保存。后续若扩展可选列,应明确列的保留或丢弃策略。

书号采用13位数字字符,作为标识保存为字符串,前导零不会被数值转换丢掉。示例只核对长度和字符集合,若业务要求ISBN校验位、目录查询或书目去重,应在此基础上增加对应规则;不同层次的错误应分开呈现。

错误必须回到来源位置

数据区逐行检查列数、书号、名称和数量。数量限制为1至9999的十进制整数,拒绝小数、指数记法与带符号输入。行号从原始文本第一行开始计算,首行表头为第1行,因此首条数据的错误位置是第2行。尾部空行通常来自复制动作,可以移除;中间空行保留为错误,避免行号漂移。

export function parseTable(text: string): PasteResult {
  const result = new PasteResult();
  if (text.length > 100000) {
    result.errors.push('文本超过长度上限');
    return result;
  }
  const lines: string[] = text.replace(/\r\n/g, '\n')
    .replace(/\r/g, '\n').split('\n');
  while (lines.length > 1 && lines[lines.length - 1] === '') {
    lines.pop();
  }
  if (lines.length < 2 || lines.length > 501) {
    result.errors.push('请提供表头及1至500行数据');
    return result;
  }
  let columns: number[];
  try {
    columns = mapHeader(lines[0]);
  } catch {
    result.errors.push('表头错误:请核对书号、名称、数量');
    return result;
  }
  for (let i: number = 1; i < lines.length; i++) {
    const cells: string[] = lines[i].split('\t');
    const prefix: string = '第' + (i + 1).toString() + '行:';
    if (cells.length !== 3) {
      result.errors.push(prefix + '列数应为3');
      continue;
    }
    const isbn: string = cells[columns[0]].trim();
    const name: string = cells[columns[1]].trim();
    const count: string = cells[columns[2]].trim();
    const before: number = result.errors.length;
    if (!/^\d{13}$/.test(isbn)) {
      result.errors.push(prefix + '书号应为13位数字');
    }
    if (name.length === 0 || name.length > 80) {
      result.errors.push(prefix + '名称应为1至80个字符');
    }
    if (!/^[1-9]\d{0,3}$/.test(count)) {
      result.errors.push(prefix + '数量应为1至9999的整数');
    }
    if (result.errors.length === before) {
      const row = new BookRow();
      row.isbn = isbn;
      row.name = name;
      row.count = Number(count);
      result.rows.push(row);
    }
  }
  if (result.errors.length > 0) {
    result.rows = [];
  }
  return result;
}

这里采取整表通过的策略。错误行不会混入提交集合,其他有效行也暂时不提交,用户修正原文后重新检查。若改为允许部分入库,必须为每一行建立稳定身份和已处理状态,否则用户再次粘贴同一段内容,容易重复导入已经成功的行。错误提示本身也不应成为数据源,提交仍以解析得到的BookRow对象为准。

500行和十万字符是入口保护阈值,不是性能测量结论。解析复杂度随单元格数量增长;更大的批量数据可以走文件导入与分段检查。输入变化时只保存草稿,按钮触发解析,避免每按一次键就重新处理整张表。

粘贴表格开发环境

用列顺序变化检查映射

下面的规则检查覆盖交换列顺序、空数据、重复表头、非法数量以及错误行号。测试数据直接包含在代码内,修改协议时可同时更新预期。页面常用的一组输入也在这里给出,名称放在第一列,数量放在第二列,用来确认业务字段没有依赖固定下标。

export const SAMPLE_TABLE: string =
  '名称\t数量\t书号\n窗口设计\t2\t9780000000001';

export function checkPasteRules(): void {
  const ok: PasteResult = parseTable(SAMPLE_TABLE);
  if (ok.errors.length !== 0 || ok.rows.length !== 1 ||
      ok.rows[0].count !== 2 || ok.rows[0].name !== '窗口设计') {
    throw new Error('交换列顺序后字段不匹配');
  }
  const bad: PasteResult = parseTable(
    '名称\t数量\t书号\n窗口设计\t2.5\t9780000000001');
  if (bad.rows.length !== 0 ||
      bad.errors[0] !== '第2行:数量应为1至9999的整数') {
    throw new Error('数量错误定位不匹配');
  }
  const samples: string[] = ['', '名称\t名称\t书号\n甲\t乙\t1',
    '书号\t名称\t数量\n9780000000001\t\t2',
    '书号\t名称\t数量\n9780000000001\t甲\t0'];
  samples.forEach((sample: string) => {
    if (parseTable(sample).errors.length === 0) {
      throw new Error('异常输入应返回错误');
    }
  });
}

调试时可以分别观察原文、表头位置数组和最终对象。原文不能用空格替换后的日志来代替,否则制表符缺失会被掩盖。遇到不可见字符问题,临时输出字符码或字符串长度比截图更有用;业务记录里则只保留错误类别与行列位置,减少书目原文的无关留存。

检查结果还应明确对应哪一次输入。用户修改草稿以后,原来的“可提交”提示必须失效,否则按钮下方仍显示旧结果,容易让人误以为新文本已经通过检查。本例在onChange中把摘要改成需要重新检查,提交入口也应重新解析当前草稿,或者比较内容版本,保证展示状态与实际提交数据一致。

表头映射的检查也可以独立于业务字段规则开展。将三个表头的六种排列分别输入,同一条书目都应得到相同对象;将表头改成“数量、数量、书号”,则应在读取数据之前被拒绝。这样可以区分映射错误与数量规则错误,减少修改一个分支后连带影响其他分支的机会。

界面回显不宜只用红色表达错误。行号、列名和具体条件已经包含在文字里,颜色只是辅助。窄窗口下应让错误说明自然换行,不能用省略号截掉数量范围。外接键盘粘贴后还要检查滚动位置,确保用户能够找到检查按钮,再返回原文修正对应单元格。

对于重复书号,本例保留输入行次序,不自动合并数量。合并会改变业务含义,例如两行可能属于不同收货批次。若入库流程要求唯一书号,应另加重复标识规则,并同时提示首次出现与再次出现的行号,让用户决定如何整理来源数据。

让窗口变化保持同一份草稿

页面保持一个TextArea组件,宽窗口只限制内容容器的最大宽度,输入草稿不会随窗口缩放重建。Scroll保留键盘弹出后的操作空间,错误摘要放在按钮下面。大量错误适合在独立列表逐项显示,本例用换行文本呈现,方便核对基础流程。

import { parseTable, PasteResult, SAMPLE_TABLE } from '../model/PasteTable';

@Entry
@Component
struct PasteTable {
  @State draft: string = SAMPLE_TABLE.replace('\t2\t', '\t2.5\t');
  @State summary: string = '待检查';
  @State wide: boolean = false;

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('书目粘贴检查').fontSize(24).fontWeight(FontWeight.Bold)
        Text('表头映射:名称 → 数量 → 书号').fontSize(14)
        TextArea({ text: this.draft })
          .height(180)
          .onChange((value: string) => {
            this.draft = value;
            this.summary = '内容已变更,请重新检查';
          })
        Button('检查表格').onClick(() => {
          const result: PasteResult = parseTable(this.draft);
          this.summary = result.errors.length > 0 ?
            result.errors.join('\n') :
            '可提交 ' + result.rows.length.toString() + ' 行';
        }).width('100%')
        Text(this.summary).fontSize(18)
      }.padding(24).width(this.wide ? 720 : '100%')
    }.width('100%').height('100%')
      .onAreaChange((_oldArea: Area, area: Area) => {
        this.wide = Number(area.width) >= 760;
      })
  }
}

粘贴表格错误回显

上架前应检查不同来源的换行方式、末尾空行、列顺序以及错误修复后的再次提交。列映射把输入顺序转换成稳定的业务含义,行号把错误指回原始文本,整表检查保证提交集合清晰。多设备页面共享这套协议以后,窗口大小只影响阅读和操作空间,数据解释始终保持一致。

Logo

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

更多推荐