在这里插入图片描述
在这里插入图片描述

一、业务需求(为什么这么设计)

产品是一个面向全球的跨境商城 demo,核心需求是把前面 12 个应用的本地化能力整合进一个真实结算流程

  • 界面语言 5 种(中/繁/英/日/韩),结算币种 4 种(CNY/USD/EUR/JPY),两个维度自由组合;
  • 所有金额存人民币整数分,展示时按所选币种汇率换算并本地化格式化;
  • 税费 8.5%、满 ¥300 免运费(否则 ¥12)、满 ¥200 减 ¥50——规则在 CNY 分空间计算;
  • 支付流程:待支付 → 处理中(1.5s)→ 已支付(含订单号、下单时间、状态);
  • 数量用复数规则(英文 “1 item”/“3 items”),订单号带千分位,时间随语言格式化。

本文记录完整技术方案,所有代码与 BillFormatPage.ets 一一对应。

二、总体架构

┌─ 语言层:LANGS + STRINGS(5 语言文案表)+ t() 三级降级
├─ 币种层:CURRENCIES(ISO 4217 码 + 符号 + 汇率 + 小数位)+ fmtMoney() 货币格式化
├─ 商品层:PRODUCTS(5 语言名称/描述 + CNY 整数分价格)
├─ 计算层:整数分运算(小计/税费/运费/满减/合计)
├─ 格式化层:fmtQty() 复数 + fmtOrderNo() 千分位 + fmtTime() 日期时间
└─ 状态层:@StorageLink locale + currency(双持久化)+ @State tab/qty/paying/paidAt

架构核心是两条管线金额管线(CNY 分 → 汇率换算 → 货币格式化)与文案管线(locale → 文案表 → 界面文本)。两条管线在 build 时汇合,任何一维变化都触发全页重渲染。

三、币种层:ISO 4217 元数据

interface CurrencyCfg {
  code: string;       // ISO 4217
  symbol: string;     // 展示符号
  rate: number;       // 1 CNY = ? 
  digits: number;     // 小数位
}

const CURRENCIES: CurrencyCfg[] = [
  { code: 'CNY', symbol: '¥', rate: 1, digits: 2 },
  { code: 'USD', symbol: '$', rate: 0.14, digits: 2 },
  { code: 'EUR', symbol: '€', rate: 0.13, digits: 2 },
  { code: 'JPY', symbol: '¥', rate: 21.5, digits: 0 }
];

三个关键设计:

  1. code 用 ISO 4217(CNY/USD/EUR/JPY):Intl.NumberFormat style:'currency' 需要标准货币码,自造符号会被格式化器拒绝;
  2. digits 独立配置:日元无小数位(¥27,928)、人民币 2 位(¥1,299.00)——虽然 style:'currency' 会按 CLDR 自动决定小数位,但 digits 字段用于兜底分支toFixed(digits)),保证降级路径同样正确;
  3. rate 是演示固定值:真实产品汇率来自服务端,且必须带时间戳与版本(见"生产铁律")。

四、金额管线:整数分 → 汇率 → 货币格式化

4.1 存储与运算:全部整数分

interface Product {
  priceCents: number;   // CNY 整数分
}
// 例:耳机 129900 分 = ¥1,299.00

为什么用整数分:浮点数 0.1 + 0.2 !== 0.3,金额用 number 直接运算会产生 1299.0000000000002 之类的尾差。金额以整数分存储与运算129900),加减乘除全是整数,零误差——这正是页面底部 tip 文案(K.tip)强调的内容。

4.2 换算:toDisplay()

private toDisplay(cnyCents: number): number {
  const rate = this.curCfg().rate;
  if (rate === 1) {
    return cnyCents / 100;                    // CNY:分 → 元
  }
  return Math.round(cnyCents * rate) / 100;   // 其他币种:先按汇率换算再保留两位
}
  • Math.round(cnyCents * rate) / 100:先四舍五入到分再除以 100 转元——129900 × 0.14 = 18186 分 = $181.86
  • 汇率 1(CNY)走快路径避免无谓运算;
  • 注意:换算在"分"空间做四舍五入,保证显示精确到目标币种的分。

4.3 格式化:fmtMoney()

private fmtMoney(cnyCents: number): string {
  try {
    const fmt = new intl.NumberFormat(this.currentLocale, {
      style: 'currency',
      currency: this.curCfg().code
    });
    return fmt.format(this.toDisplay(cnyCents));
  } catch (err) {
    return `${this.curCfg().symbol}${this.toDisplay(cnyCents).toFixed(this.curCfg().digits)}`;
  }
}

style:'currency' 的 CLDR 数据驱动能力一览:

locale CNY 1299.00 USD 181.86 JPY 27928
zh_CN ¥1,299.00 US$181.86 JP¥27,928
en_US CN¥1,299.00 $181.86 ¥27,928
ja_JP ¥1,299.00 $181.86 ¥27,928
  • 符号随语言变:中文界面下美元显示 US$(消歧义)、英文界面人民币显示 CN¥——CLDR 的"货币符号消歧"规则自动处理;
  • 千分位/小数点随语言1,299.00(英文逗号)vs 德语 1.299,00(反);
  • try/catch 兜底:格式化失败时用 symbol + toFixed(digits) 手工拼接——digits 字段在此刻派上用场。

五、计算层:规则全部在 CNY 分空间

const TAX_RATE_PERCENT: number = 8.5;
const FREE_SHIPPING_THRESHOLD: number = 30000; // 满 ¥300(分)
const SHIPPING_CENTS: number = 1200;           // 未满运费 ¥12
const DISCOUNT_THRESHOLD: number = 20000;      // 满 ¥200(分)
const DISCOUNT_CENTS: number = 5000;           // 减 ¥50

private taxCents(): number {
  return Math.round(this.goodsTotalCents() * TAX_RATE_PERCENT / 100);
}
private shippingCents(): number {
  if (this.goodsTotalCents() >= FREE_SHIPPING_THRESHOLD) return 0;
  return SHIPPING_CENTS;
}
private discountCents(): number {
  return this.goodsTotalCents() >= DISCOUNT_THRESHOLD ? DISCOUNT_CENTS : 0;
}
private totalCents(): number {
  return this.goodsTotalCents() + this.taxCents() + this.shippingCents() - this.discountCents();
}

三个要点:

  1. 规则参数化:门槛/运费/满减全部提为模块级常量,改规则不动逻辑;
  2. Math.round 处理 8.5% 税率129900 × 8.5 / 100 = 11041.5 → 四舍五入 11042 分——税费必须整数分,舍入在计算层做而非显示层;
  3. 计算与显示彻底分离taxCents() 返回分,只有 fmtMoney() 负责呈现——规则改、显示改互不影响。

六、格式化三件套:复数/订单号/时间

6.1 数量复数 fmtQty()

private fmtQty(n: number): string {
  try {
    const pr = new intl.PluralRules(this.currentLocale);
    const cat = pr.select(n);
    if (this.currentLocale === 'en_US') {
      return cat === 'one' ? `1 ${this.T(K.item)}` : `${n} ${this.T(K.item)}s`;
    }
    return `${n}`;   // 中/日/韩无复数形态
  } catch (err) {
    return `${n}`;
  }
}
  • PluralRules.select(1)'one'select(3)'other'
  • 英文 1 item / 3 itemsitem + s 伪复数);中/日/韩直接数字(语言层无复数概念);
  • 与 05 应用(复数规则专项)呼应,这里是它的轻量应用。

6.2 订单号 fmtOrderNo()

private fmtOrderNo(): string {
  try {
    return new intl.NumberFormat(this.currentLocale).format(ORDER_NO);
  } catch (err) {
    return `${ORDER_NO}`;
  }
}

2026081912345678 → 英文 20,260,819,123,456,78(千分位分组)——订单号这种"数字型标识"也走 NumberFormat,保证分组习惯随语言。

6.3 时间 fmtTime()

private fmtTime(ts: number): string {
  try {
    const fmt = new intl.DateTimeFormat(this.currentLocale, {
      dateStyle: 'long', timeStyle: 'short'
    });
    return fmt.format(new Date(ts));
  } catch (err) {
    return new Date(ts).toString();
  }
}

中文"2026年8月19日 14:30"、英文 “August 19, 2026 at 2:30 PM”——dateStyle:'long' + timeStyle:'short' 的组合模板由语言决定,与 02 应用一致。

七、状态管理与双持久化

@StorageLink(STORAGE_LOCALE) currentLocale: string = DEFAULT_LOCALE;
@StorageLink(STORAGE_CURRENCY) currentCurrency: string = DEFAULT_CURRENCY;
@State tab: string = 'market';
@State qty: number[] = [0, 0, 0, 0, 0, 0];
@State paying: boolean = false;
@State paidAt: number = -1;
private timer: number = -1;

aboutToAppear(): void {
  // locale 与 currency 各做一次 setOrCreate + persistProp
  if (!AppStorage.get<string>(STORAGE_LOCALE)) {
    AppStorage.setOrCreate(STORAGE_LOCALE, DEFAULT_LOCALE);
  }
  PersistentStorage.persistProp(STORAGE_LOCALE, DEFAULT_LOCALE);
  if (!AppStorage.get<string>(STORAGE_CURRENCY)) {
    AppStorage.setOrCreate(STORAGE_CURRENCY, DEFAULT_CURRENCY);
  }
  PersistentStorage.persistProp(STORAGE_CURRENCY, DEFAULT_CURRENCY);
}
  • 两个 @StorageLink 独立持久化i18n_series_13_localei18n_series_13_currency 两个 key——重启后"中文界面 + USD 结算"的组合被完整记忆;
  • qtyslice() 拷贝再赋值this.qty = arr):ArkUI 的 @State 数组必须整体替换才能触发渲染,原地 this.qty[i]++ 不生效——这是 ArkTS 状态管理的经典陷阱;
  • 支付状态机paying(处理中)→ paidAt(时间戳,-1 表示未支付)——adjust()/clearCart() 修改购物车后把 paidAt 复位,保证"改单后回到待支付"的语义正确;
  • timeraboutToDisappear 清理setTimeout 1.5s 支付模拟,页面销毁时必须 clearTimeout

八、数据流复盘(一次完整交互)

启动 → 双 persistProp(语言 + 币种)→ 中文界面 + CNY
  → 市场页 6 商品:¥1,299.00 / ¥459.00 / ¥899.00 / ¥2,199.00 / ¥299.00 / ¥159.00

用户点击 $ USD 币种徽章
  → currentCurrency = 'USD'(@StorageLink → AppStorage → 落盘)
  → curCfg() = USD(rate 0.14)
  → 全页 fmtMoney 重算:¥1,299.00 → $181.86
  → 汇率提示条变 "1 CNY ≈ $0.14"

用户加购 2 件耳机 → qty[0]=2
  → 购物车 Tab 角标 (2)
  → 小计 $363.72、税费 8.5% → $30.92、运费 满¥300 → 免运费
  → 合计 $394.64

用户点击立即支付 → paying=true(按钮变"支付处理中…")
  → 1.5s 后 paidAt=now → 横幅:🎉 支付成功!订单号 20,260,819,… 下单时间 2026年8月19日 14:30

九、ArkTS 兼容要点

  1. catch (err) 不带类型注解,所有 intl 调用包 try/catch;
  2. qty: number[]slice() 产生新数组再赋值,触发 @State 渲染;
  3. ForEach(PRODUCTS, (p: Product, i: number) => ...) key 用 p.key(稳定唯一);
  4. buildStrings(pairs: Pair)type Pair = Array<[string, string]> 显式类型别名,new Map(pairs) 免断言;
  5. PRODUCTSnames/descsMap<string, string>,取用用 ?? 链降级(get(code) ?? get(DEFAULT_LOCALE) ?? key);
  6. Button(...).enabled(this.paidAt === -1 && !this.paying):条件禁用用 enabled 而非点击内拦截,语义更清晰。

十、性能与内存

  • 每次 build 创建 fmtMoney × 20+ 次(6 商品单价 + 5 费用行 × 2 个视图分支),NumberFormat 实例化毫秒级;生产优化:模块级缓存 code+currency → fmt,避免重复创建;
  • qty 数组仅 6 个元素,slice() 拷贝开销可忽略;
  • 支付 setTimeout 是唯一定时器,aboutToDisappear 清理无泄漏;
  • PRODUCTS 的 5 语言 Map 在编译期构建,无运行时开销。

十一、小结

本应用是系列的"总集成测试":整数分存储(9 的 SI 基准思想)× 货币格式化(3 的深化)× 复数(5 的应用)× 日期时间(2 的应用)× 文案表(1 的骨架)× 双持久化(1 的扩展)。技术上最值得记的两点:金额永远整数分运算、展示永远走 style:'currency'——前者杜绝浮点误差,后者接管符号/分组/小数位的全部本地化。

应用 深化维度
01 文案表 + 三级降级 + 持久化
03 NumberFormat 货币专项
05 PluralRules 复数专项
13 货币+数字+日期+复数综合(生产级账单)← 本文

给生产环境的三条铁律:① 金额存整数分(或 decimal 字符串),禁止浮点运算;② 汇率必须带版本与生效时间,换算结果缓存并在汇率更新时失效;③ 货币显示用 ISO 4217 码走 style:'currency',兜底路径才用手拼符号——因为符号随语言消歧(US$ vs $ vs ¥),手拼必然出错。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐