HarmonyOS 多设备表单的金额精度与输入提交【共创稿事节】

同一张价格表单运行在手机、平板和桌面窗口时,输入方式会发生变化:软键盘逐字输入、物理键盘粘贴、窗口缩放后继续修改,都可能经过同一个文本框。若每次文字变化就把内容转成浮点数,再乘以一百得到分,价格精度与编辑体验会同时受到影响。用户刚输入的“12.”也可能立即变成“12”,下一位小数失去原本的输入位置。

这里把表单拆成三个层次:草稿始终保存原始文本,提交时解析成整数分,展示时再按固定两位小数格式化。设备适配只调整页面的空间安排,金额语义保持一致。示例面向以人民币元录入、以分提交的单价字段,允许零,不接收负数、千位分隔符或指数记法。

多设备金额表单

先确定协议,再选择输入控件

业务协议应明确币种、单位、范围与小数位数。本例规定最大金额为 999999999 分,即 9999999.99 元,接口字段是 amountFen,类型为整数。这个上界远低于 JavaScript 安全整数上界,因此解析、求和前的范围判断都可以在整数域完成。若其他业务需要更大金额,应另选十进制字符串协议,并同步调整服务端规则。

页面采用 API 12 及以上工程中的 TextInput、Button、Scroll 与区域变化回调,无需额外权限。控件由 ArkUI 声明式语法提供,下面页面不需要为基础组件增加 Kit 导入。涉及网络提交时,再由工程的服务层处理网络权限、鉴权与错误反馈,不能把本地“生成提交数据”当成订单保存成功。

华为的输入值与光标处理说明指出,自定义处理输入值后重新赋值会影响光标位置。这也是草稿层保持原字符串的重要原因。输入类型只是键盘和输入行为的提示,最终合法性仍由业务解析器决定。

工程可把前两段放在 ets/model/Money.ts,第三段是同目录业务规则检查函数,第四段放入 ets/pages/PriceForm.ets。这里的检查函数使用同一份解析器,关键输入和预期值都写在正文中,便于直接复用。

用十进制字符构造整数分

解析前先限制格式,再拆分整数部分和小数部分。整数部分允许前导零,但只在提交成功后的展示阶段做规范化。小数部分最多两位;“12.”作为提交值时按 12 元处理,编辑过程中仍保留那个小数点。空串与单独的小数点返回错误,避免把“尚未填写”混同为零。

export class MoneyResult {
  ok: boolean = false;
  fen: number = 0;
  error: string = '';
}

export function parseMoney(text: string): MoneyResult {
  const result = new MoneyResult();
  if (text.length > 32 || !/^\d+(\.\d{0,2})?$/.test(text)) {
    result.error = '请输入金额,最多两位小数';
    return result;
  }
  const parts: string[] = text.split('.');
  const whole: string = parts[0].replace(/^0+/, '') || '0';
  const fraction: string = parts.length === 2 ? parts[1] : '';
  if (whole.length > 7) {
    result.error = '金额超过上限';
    return result;
  }
  const cents: string = (fraction + '00').slice(0, 2);
  const fen: number = Number(whole) * 100 + Number(cents);
  if (!Number.isSafeInteger(fen) || fen > 999999999) {
    result.error = '金额超过上限';
    return result;
  }
  result.ok = true;
  result.fen = fen;
  return result;
}

export function formatMoney(fen: number): string {
  if (!Number.isSafeInteger(fen) || fen < 0 || fen > 999999999) {
    throw new Error('金额整数无效');
  }
  return Math.floor(fen / 100).toString() + '.' +
    ('0' + (fen % 100).toString()).slice(-2);
}

这里的 Number(whole) * 100 是对已经限制位数的整数进行运算,避开了小数金额先参与二进制浮点运算的路径。小数部分只作为两位十进制字符转为整数,既没有按第三位四舍五入,也没有静默截断超出两位的小数;超出两位在格式检查阶段就会被拒绝。

空白、货币符号和全角数字也被拒绝。这是接口输入规则,而不是对所有地区格式的通用处理。如果产品需要其他地区的分组符或小数符号,应新增明确的地区解析层,先转换为统一的十进制字符,再进入此函数,不能简单删除所有标点。业务也要区分单价与数量:两者的小数精度未必相同。

草稿与提交值各有自己的生命周期

表单在输入阶段只更新草稿和错误提示。只有提交动作解析通过,才更新已提交的整数分。失败时保留用户原文,用户才能看见是第三位小数、空格还是金额范围出了问题。数据对象是新建的提交快照,不把可继续修改的表单对象直接传给服务层。

export class PricePayload {
  currency: string = 'CNY';
  amountFen: number = 0;
}

export class MoneyForm {
  draft: string = '';
  error: string = '';
  committedFen: number = 0;
  hasCommitted: boolean = false;

  edit(value: string): void {
    this.draft = value;
    this.error = '';
  }

  commit(): PricePayload | undefined {
    const result: MoneyResult = parseMoney(this.draft);
    if (!result.ok) {
      this.error = result.error;
      return undefined;
    }
    this.committedFen = result.fen;
    this.hasCommitted = true;
    this.error = '';
    const payload = new PricePayload();
    payload.amountFen = result.fen;
    return payload;
  }
}

表单保留 hasCommitted,因为“已提交零元”与“尚无提交值”具有不同状态。窗口变宽、横竖方向变化或者键盘收起时,都不能重新创建模型并把草稿重设为已提交金额。若页面需要跨页面保存,再由页面上层持有模型或写入业务草稿存储,金额解析器本身不承担持久化。

异步请求还需要单独维护提交状态,防止重复点击。请求发送的是当次返回的 payload;回包后应核对当前编辑状态,避免旧回包覆盖新草稿。这里先完成本地的数据准备,按钮文案和提示也围绕“生成提交数据”,保持界面反馈与实际动作一致。

金额表单开发环境

用边界输入验证规则

检查不只覆盖“12.34能否变成1234”。更重要的是零值、尾随小数点、前导零、第三位小数、指数记法以及上界。下面检查函数放在模型文件末尾,开发阶段可由规则检查入口调用,返回通过条目数;异常会携带对应输入,便于定位。

export class MoneyCase {
  text: string;
  expected: number;
  constructor(text: string, expected: number) {
    this.text = text;
    this.expected = expected;
  }
}

export function checkMoneyRules(): number {
  const cases: MoneyCase[] = [
    new MoneyCase('0', 0), new MoneyCase('0.1', 10),
    new MoneyCase('12.', 1200), new MoneyCase('00012.30', 1230),
    new MoneyCase('9999999.99', 999999999)
  ];
  cases.forEach((item: MoneyCase) => {
    const result: MoneyResult = parseMoney(item.text);
    if (!result.ok || result.fen !== item.expected) {
      throw new Error('解析失败:' + item.text);
    }
    const back: MoneyResult = parseMoney(formatMoney(item.expected));
    if (!back.ok || back.fen !== item.expected) {
      throw new Error('格式往返失败:' + item.text);
    }
  });
  const invalid: string[] = ['', '.', '1.234', '-1', '1e2',
    ' 12', '1,000', '10000000'];
  invalid.forEach((text: string) => {
    if (parseMoney(text).ok) {
      throw new Error('应拒绝输入:' + text);
    }
  });
  return cases.length + invalid.length;
}

性能上,输入长度限制把单次检查控制在很小的字符范围内;输入时也没有必要重复格式化所有金额。大表单可以在单字段提交或离开编辑状态后检查,再在总提交入口汇总。服务端仍需使用相同单位与范围复核请求,前端检查不能替代接口数据规则。

布局变化只影响可用宽度

多设备适配要观察应用窗口,而不只是设备型号。华为的多设备适配指南提供了不同窗口形态的适配入口;区域变化回调示例使用 Area 与 Number(newValue.width) 一类数值转换来处理布局信息。

下面页面在宽窗口中将表单限制为560vp并居中,窄窗口使用可用宽度。根区域变化只更新 wide,输入框保持相同的组件位置和状态绑定,减少切换布局时的焦点干扰。

import { MoneyForm, PricePayload, formatMoney } from '../model/Money';

@Entry
@Component
struct PriceForm {
  private form: MoneyForm = new MoneyForm();
  @State draft: string = '12.30';
  @State message: string = '尚未生成提交数据';
  @State wide: boolean = false;

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('单价录入').fontSize(24).fontWeight(FontWeight.Bold)
        Text('人民币元 · 最多两位小数').fontSize(14)
        TextInput({ text: this.draft, placeholder: '输入金额' })
          .type(InputType.Normal)
          .onChange((value: string) => {
            this.draft = value;
            this.form.edit(value);
            this.message = '编辑中';
          })
        Text('提交单位:分').fontSize(14)
        Button('生成提交数据').onClick(() => {
          this.form.edit(this.draft);
          const payload: PricePayload | undefined = this.form.commit();
          if (payload === undefined) {
            this.message = this.form.error;
          } else {
            this.message = formatMoney(payload.amountFen) +
              ' 元 / ' + payload.amountFen.toString() + ' 分';
          }
        }).width('100%')
        Text(this.message).fontSize(18)
      }.padding(24).width(this.wide ? 560 : '100%')
    }.width('100%').height('100%')
      .onAreaChange((_oldArea: Area, area: Area) => {
        this.wide = Number(area.width) >= 600;
      })
  }
}

金额输入与提交界面

验证页面时应让“12.”停留在编辑态,再改变窗口宽度,确认文字没有被强制格式化;粘贴“1.234”后点击按钮,应保留原文并给出错误;输入“0”则应得到合法的零分提交数据。还要检查键盘弹出后的滚动空间、按钮可达性和外接键盘的编辑行为,避免金额规则正确而操作入口被遮挡。

交互文案同样属于数据协议的一部分。输入框标签写“元”,提交说明写“分”,返回提示同时给出两种单位,能帮助开发者发现一百倍的单位偏差。与接口联调时,应核对请求中的整数及响应中的整数,而不是仅比较两端格式化后的文本,因为文本都显示两位小数也可能掩盖换算错误。

已有数据进入编辑页时,可以通过 formatMoney 初始化草稿,但这个动作应发生在明确的数据装载节点,不能放在区域变化或每次页面重绘中。用户开始编辑以后,新数据到达应有清楚的覆盖策略。对批量单价录入,还可以保留每一项的原始草稿和独立错误提示,使一行失败时其他行的已输入内容继续可见。页面缩放也应保留这些字段身份,避免按列表下标重建后把错误提示贴到别的金额上。

金额输入的稳定性来自分层:原始文本尊重编辑过程,整数分承载提交含义,固定格式服务阅读,窗口布局只处理空间。把这几个职责分开后,同一张表单在不同设备上可以共享清楚的数值规则,也更容易定位输入与提交之间的差异。

Logo

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

更多推荐