ArkTS + HarmonyOS 运费试算:阶梯重量计费与输入边界【共创稿事节】
一个快递运费计算器看似只有“重量乘单价”,但最容易让产品被投诉的不是界面,而是边界重量附近的一分钱误差。例如首重一公斤8元,续重每不足半公斤也按半公斤计,那么1.00公斤收费8元,1.01公斤却应收费10元。若页面先把 1.01 四舍五入为一位小数,就会错误显示8元。再叠加偏远地区附加费、空白输入、连续小数点和非法负数,原本的两个输入框就变成了一套需要明确定义的数据处理规则。
这里用纯 ArkTS 与 ArkUI 做一个叫 ParcelQuote 的本机快递运费试算页面。它不连接任何物流公司,也不代表实时官方报价。固定示例规则为:重量上限30公斤;首重1公斤以内8元;超过首重的部分,每开始500克增加2元;用户勾选偏远地区时另加5元。核心决策是把界面字符串先转换为整数克,再以整数分计算费用,保证按钮、摘要与测试用例共享同一套规则。

一、先定计费语义,再画输入框
需求里的“续重每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段”的解释,也方便测试判定是否出现多计一段或少计一段的错误。若规则变成“超过首重后每公斤进位”或“偏远地区按比例加价”,只需修改这一层,不必让输入组件知道复杂的商业细则。

三、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 页面只维护输入状态。模型、视图和异常状态各自有明确边界:输入不合法就不给价格,合法报价可追溯续重段数,边界重量不借助浮点猜测。
如果后续需要接入企业运费计算服务,可保留这个纯函数作为本地构造和边界回归样例,再把正式报价交给带版本号、地区规则和签名结果的后端接口。只要前端明确标注“试算”而非“实际承运报价”,并在真正下单前重新获取权威结果,简洁的示例也能保持工程表达上的准确性。
更多推荐




所有评论(0)