一个快递运费计算器看似只有“重量乘单价”,但最容易让产品被投诉的不是界面,而是边界重量附近的一分钱误差。例如首重一公斤8元,续重每不足半公斤也按半公斤计,那么1.00公斤收费8元,1.01公斤却应收费10元。若页面先把 1.01 四舍五入为一位小数,就会错误显示8元。再叠加偏远地区附加费、空白输入、连续小数点和非法负数,原本的两个输入框就变成了一套需要明确定义的数据处理规则。

这里用纯 ArkTS 与 ArkUI 做一个叫 ParcelQuote 的本机快递运费试算页面。它不连接任何物流公司,也不代表实时官方报价。固定示例规则为:重量上限30公斤;首重1公斤以内8元;超过首重的部分,每开始500克增加2元;用户勾选偏远地区时另加5元。核心决策是把界面字符串先转换为整数克,再以整数分计算费用,保证按钮、摘要与测试用例共享同一套规则。

HarmonyOS 运费试算与分段计价技术封面

一、先定计费语义,再画输入框

需求里的“续重每500克2元”需要进一步询问:500克是按实际比例收费,还是向上进位?本示例明确采用后一种阶梯模式。重量在 0 < g <= 1000 时只收首重;若 g > 1000,续重段数为 ceil((g - 1000)/500)。比如 1001 克多出1克,但已经进入第一段,续重段数为1;1500克也是1段;1501克为2段。只有把区间的开闭端点写清楚,日后产品、测试和客服才能对齐。

金额不使用浮点“元”作为内部计量单位。基础费用800分,半公斤续重200分,偏远地区500分;展示时才除以100并格式化为两位小数。这样避免将浮点误差带进叠加过程,也方便未来向对账服务传入整数金额。重量最多允许两位小数公斤,即最小单位10克;例如字符串 "1.01" 映射为1010克,"0.10" 映射为100克,"30.00" 映射为30000克。

输入控件可以限制到小数键盘,但键盘约束不等于数据验证。粘贴、系统输入法和程序赋值仍可能带来空串、0、01、1..2 或超量内容。正确处理方式是先校验字符串语法,再解析成整数克,然后检查业务范围,最后计算费用。不能一开始就 parseFloat(),因为它可能把 1.2abc 解释为1.2,这种宽松解析不适合作为用户报价入口。

与其他业务不同,试算无需注册账号、网络权限、磁盘数据库或第三方依赖。Stage 模型中的 @Entry、@Component、@State、TextInput、Button 和 Text 足够完成示例。以 HarmonyOS API 12 及以上基础 ArkUI 工程为例,官方 TextInput 输入组件指南 说明 InputType.NUMBER_DECIMAL 会提供小数数字输入模式,但应用仍应自行执行字符与数值校验;ArkUI @State 文档 解释状态变量赋值如何触发绑定组件更新。

这里还存在一个与货币无关、但会影响用户信任的细节:质量单位必须在输入区旁边固定写明。若页面只展示数字 1.5,用户可能以为是斤或者千克。代码里选择公斤字符串作为输入、克作为计算单位,是为了隔离界面可读性与算法精度;如果将来增加克输入模式,必须提供独立的转换器,而不能直接把原来的 parseGrams() 当通用数字解析函数。精度边界也要写成产品规则:只接受两位小数公斤,而不是悄悄截断第三位。比如 1.005 公斤,示例必须明确拒绝,而不是自动按1.00或1.01公斤计价。

分段规则还有“计费重量”和“称重重量”的区别。真实物流公司可能依据长宽高计算体积重,取实重与体积重中的较大值;本篇只有单个重量输入,故图中所有费用都来自用户提供的示例实重。引入体积重时,先在模型中增加长度、宽度、高度和规则系数,再把转换后的整数克送入同一个阶梯计算函数,避免将多套区间逻辑散落在表单控件里。

二、把纯计价函数和页面状态彻底分开

先在 entry/src/main/ets/model/QuoteRule.ets 放置类型与计算函数。这部分代码完全不依赖 UI,单元测试可以直接调用,无须为了检验 1.01 公斤的边界启动预览环境。结果采用带 ok 字段的结构化类型,区分合法报价与错误信息;错误时不返回零元,因为“输入错误”与“合法零元”是不同状态。为了便于用户看懂,错误提示在这里直接使用中文字符串。

export interface QuoteResult {
  ok: boolean;
  grams: number;
  amountFen: number;
  extraSteps: number;
  error: string;
}

function invalid(message: string): QuoteResult {
  return { ok: false, grams: 0, amountFen: 0,
    extraSteps: 0, error: message };
}

export function parseGrams(text: string): number | undefined {
  const value: string = text.trim();
  if (!/^(0|[1-9]\d*)(\.\d{1,2})?$/.test(value)) {
    return undefined;
  }
  const parts: string[] = value.split('.');
  const kg: number = Number(parts[0]);
  const fraction: string = (parts.length === 2 ? parts[1] : '').padEnd(2, '0');
  const grams: number = kg * 1000 + Number(fraction) * 10;
  if (!Number.isSafeInteger(grams)) { return undefined; }
  return grams;
}

export function quoteFreight(text: string, remote: boolean): QuoteResult {
  const grams: number | undefined = parseGrams(text);
  if (grams === undefined) { return invalid('请输入最多两位小数的公斤数'); }
  if (grams <= 0) { return invalid('重量必须大于零'); }
  if (grams > 30000) { return invalid('单件重量不能超过30公斤'); }
  const extraSteps: number = grams <= 1000 ? 0 :
    Math.ceil((grams - 1000) / 500);
  const amountFen: number = 800 + extraSteps * 200 + (remote ? 500 : 0);
  return { ok: true, grams: grams, amountFen: amountFen,
    extraSteps: extraSteps, error: '' };
}

export function formatYuan(amountFen: number): string {
  return (amountFen / 100).toFixed(2);
}

用这段纯函数试算几个边界:0.50公斤为800分;1.00公斤为800分;1.01和1.50公斤均为1000分;1.51公斤为1200分;2.40公斤多出1.40公斤,续重向上进位三段,合计1400分;若勾选偏远地区,2.40公斤就变成1900分。30.00公斤的续重是58个半公斤段,总计12400分。此处所有结果来自公式推导,是测试预期,不是某物流公司的收费标准。

与常见的 weight * rate 相比,extraSteps 是值得在结果中显式保留的字段。它既能生成“续重3段”的解释,也方便测试判定是否出现多计一段或少计一段的错误。若规则变成“超过首重后每公斤进位”或“偏远地区按比例加价”,只需修改这一层,不必让输入组件知道复杂的商业细则。

ParcelQuote 在 DevEco Studio 白色主题下的重量校验与报价界面示意

三、ArkUI 页面只负责输入、状态反馈与展示

页面文件 entry/src/main/ets/pages/Index.ets 导入上面的 quoteFreight() 和 formatYuan()。输入字符串永远是一个 @State,偏远地区开关用布尔状态,计算结果通过函数派生。不要同时维护 weightText、weightNumber、priceYuan、priceFen 四套状态;否则某个事件只更新其中两项时,屏幕会显示与实际计费规则不一致的金额。

import { QuoteResult, quoteFreight, formatYuan } from '../model/QuoteRule';

@Entry
@Component
struct Index {
  @State weightText: string = '1.50';
  @State remote: boolean = false;

  private result(): QuoteResult {
    return quoteFreight(this.weightText, this.remote);
  }

  build() {
    Column({ space: 18 }) {
      Text('运费试算台').fontSize(28).fontWeight(FontWeight.Bold)
      Text('首重1公斤 ¥8|续重每500克 ¥2')
        .fontSize(14).fontColor('#51677C')
      Text('包裹重量(公斤)').fontSize(16)
      TextInput({ text: this.weightText, placeholder: '例如 1.50' })
        .type(InputType.NUMBER_DECIMAL)
        .onChange((value: string) => { this.weightText = value; })
        .width('100%').height(52)
      Button(this.remote ? '偏远地区:是(+¥5)' : '偏远地区:否')
        .onClick(() => { this.remote = !this.remote; })
        .width('100%')
      if (this.result().ok) {
        Text(`计费重量 ${this.result().grams} 克`)
          .fontSize(16).fontColor('#526779')
        Text(`续重 ${this.result().extraSteps} 段`)
          .fontSize(16).fontColor('#526779')
        Text(`预计运费 ¥${formatYuan(this.result().amountFen)}`)
          .fontSize(26).fontWeight(FontWeight.Bold)
          .fontColor('#146B8F')
      } else {
        Text(this.result().error)
          .fontSize(16).fontColor('#B4443C')
      }
      Text('示例计价规则,非物流公司实时运价')
        .fontSize(12).fontColor('#758292')
    }.padding(24).width('100%').height('100%')
  }
}

由于结果来自纯函数,每次 weightText 或 remote 改变时,绑定 UI 能依据当前状态计算新的结果。合法输入时显示整数克、续重段数及两位小数金额;非法输入时只显示错误,不沿用上一次合法报价。错误反馈不能只通过红色边框表达,还应有可阅读文字,方便读屏及低色觉辨识场景。对于宽度较小的设备,建议用纵向 Column 保持单列布局,优先确保键盘弹出后仍能看见错误和价格区域。

可以把重复调用 this.result() 改成每次事件预计算一份 @State quote,尤其是在规则逐渐复杂时。但此时必须保证任何状态变更都同步重算 quote,避免出现“重量更新了、偏远地区开关没更新报价”的错误。示例选择纯派生方式,是因为计算量极小,且能直接展示单一事实源的价值。后续如果报价要请求远程接口,就必须引入请求版本控制和加载失败状态,不能继续把异步结果当作同步纯函数。

四、以区间表而不是视觉截图验收计费边界

对于分段计价,图表比口头“应该差不多”可靠。下面的图把0~1公斤首重和之后每段500克的开闭区间分开,标明 1.00、1.01、1.50、1.51 公斤处的变化。图中金额来自本例计算公式,不宣称是测得的运行性能;它用于审查产品定义和测试断言。

运费首重和续重的分段规则、区间边界与费用示例

建议把测试分三层。第一层是语法:空串、空格、-1、1..0、1.001、01.2、1kg 必须返回明确错误;1、1.0、1.00 都解析为1000克。第二层是业务范围:0、30.01、极长的数字不得得出正常报价;30.00是合法上界。第三层是区间边界:0.99和1.00的价格相同,1.01和1.50同价,1.51立即进入下一段;偏远地区附加费在每个合法区间恒定增加500分。把几组边界用例和规则函数一起提交代码仓库,修改运价表时才有可靠的回归依据。

还需要看交互阶段。用户删除输入框中的全部内容时,页面应提示重新填写,而不是保留之前的 ¥10.00;输入非法字符时不应弹出“报价成功”;点击偏远地区按钮后,只有合法重量才能出现变化后的结果。如果产品支持系统字体放大,显示文字可能换行,应保证错误提示和按钮不会互相遮挡。UI 验收侧重阅读、点击和状态一致性;具体费用以纯函数测试为准。

生产系统还涉及运送地区、体积重、货物类别、保价、最低收费以及税费等规则。若规则来自远端,需要记录费率版本、适用地区、订单重量单位与币种;报价和下单必须由同一服务端费率引擎重新核算,避免客户端被修改导致低价下单。当前示例刻意不做虚构的“支付”或“创建运单”按钮,因为它们没有可验证的后端实现。

如果希望在单元测试中覆盖更多数据,可使用边界值等价类:首重内部任选0.2、0.8公斤,首重上界取1.00,续重左开端点取1.01,中间取1.25,右闭端点取1.50;然后平移一个完整续重区间验证1.51和2.00。由于页面目前的重量最小步长是0.01公斤,不必测试不能从界面输入的1.001公斤作为正常值,它应该归到非法精度类。对每个合法用例再加一次偏远地区标记测试,就能证明附加费不受重量段数影响。

五、把计算正确和显示清楚当作两件事

这篇的工程价值不是一个花哨的价格大卡片,而是把规则变成可测试的整数计算。parseGrams() 负责严格解析,quoteFreight() 负责业务范围和阶梯收费,formatYuan() 负责展示,ArkUI 页面只维护输入状态。模型、视图和异常状态各自有明确边界:输入不合法就不给价格,合法报价可追溯续重段数,边界重量不借助浮点猜测。

如果后续需要接入企业运费计算服务,可保留这个纯函数作为本地构造和边界回归样例,再把正式报价交给带版本号、地区规则和签名结果的后端接口。只要前端明确标注“试算”而非“实际承运报价”,并在真正下单前重新获取权威结果,简洁的示例也能保持工程表达上的准确性。

Logo

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

更多推荐