【Jack实战】如何在《时光旅记》中实现旅途记账

大家好,我是鸿蒙Jack。本期以我的《时光旅记》APP为例,聊一聊我在旅行计划里做的"旅途记账"能力。

这个能力不是一个孤立的记账小页面,而是挂在旅行计划详情里的一个业务模块。用户在做旅行规划时,通常会同时关心几件事:每天去哪、票据放哪、清单还有什么没买、这趟旅行到底花了多少钱。所以我把"旅途记账"放在旅行计划详情页里,让它和行程、清单、票夹、地图共用同一份 TravelPlan 数据。

这里先放最终效果图位置。

这次用到的技术栈

这块实现横跨端侧、本地存储、截图分享和后端云同步,技术栈需要讲清楚。

端侧使用 HarmonyOS ArkTS 和 ArkUI。账本页面是一个 @Component,用 @Prop 接收旅行计划,用 @State 管理页面内的草稿、统计值、弹窗开关和图表配置。

本地持久化使用 @kit.ArkData 里的 relationalStore,也就是 HarmonyOS 端侧关系型数据库。旅行计划存到 travel_plans,账本明细单独存到 travel_expense_items,通过 plan_id 关联。

网络请求使用 @kit.NetworkKithttp,云同步接口以 JSON 形式提交到后端。

分类统计图使用三方库 @mcui/mccharts,当前项目依赖版本是 ^2.8.9,账本页里用 McPieChart 渲染支出分类环图。

账本分享使用 @kit.ArkUIcomponentSnapshot 把账本区域截成 PixelMap,再通过 @kit.ShareKit 和项目里的 ShareUtil 拉起系统分享面板。

后端使用 Spring Boot 3.3.6、Java 17、Spring Web、Validation、Spring Data JPA、MySQL、Sa-Token。云同步相关数据模型在 cloudtravel 包里,费用明细实体是 CloudTravelExpenseItem

整体架构

旅途记账的核心思路是:页面只改 TravelPlan.expenseItems,保存时把整份 TravelPlan 回传给上层,由上层统一持久化、触发云同步。这样做的好处是,费用账本不会绕过旅行计划的数据生命周期。
#mermaid-svg-BI6apFCHf4BstYZQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BI6apFCHf4BstYZQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BI6apFCHf4BstYZQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BI6apFCHf4BstYZQ .error-icon{fill:#552222;}#mermaid-svg-BI6apFCHf4BstYZQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BI6apFCHf4BstYZQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BI6apFCHf4BstYZQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BI6apFCHf4BstYZQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BI6apFCHf4BstYZQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BI6apFCHf4BstYZQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BI6apFCHf4BstYZQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BI6apFCHf4BstYZQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BI6apFCHf4BstYZQ .marker.cross{stroke:#333333;}#mermaid-svg-BI6apFCHf4BstYZQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BI6apFCHf4BstYZQ p{margin:0;}#mermaid-svg-BI6apFCHf4BstYZQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BI6apFCHf4BstYZQ .cluster-label text{fill:#333;}#mermaid-svg-BI6apFCHf4BstYZQ .cluster-label span{color:#333;}#mermaid-svg-BI6apFCHf4BstYZQ .cluster-label span p{background-color:transparent;}#mermaid-svg-BI6apFCHf4BstYZQ .label text,#mermaid-svg-BI6apFCHf4BstYZQ span{fill:#333;color:#333;}#mermaid-svg-BI6apFCHf4BstYZQ .node rect,#mermaid-svg-BI6apFCHf4BstYZQ .node circle,#mermaid-svg-BI6apFCHf4BstYZQ .node ellipse,#mermaid-svg-BI6apFCHf4BstYZQ .node polygon,#mermaid-svg-BI6apFCHf4BstYZQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BI6apFCHf4BstYZQ .rough-node .label text,#mermaid-svg-BI6apFCHf4BstYZQ .node .label text,#mermaid-svg-BI6apFCHf4BstYZQ .image-shape .label,#mermaid-svg-BI6apFCHf4BstYZQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-BI6apFCHf4BstYZQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BI6apFCHf4BstYZQ .rough-node .label,#mermaid-svg-BI6apFCHf4BstYZQ .node .label,#mermaid-svg-BI6apFCHf4BstYZQ .image-shape .label,#mermaid-svg-BI6apFCHf4BstYZQ .icon-shape .label{text-align:center;}#mermaid-svg-BI6apFCHf4BstYZQ .node.clickable{cursor:pointer;}#mermaid-svg-BI6apFCHf4BstYZQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BI6apFCHf4BstYZQ .arrowheadPath{fill:#333333;}#mermaid-svg-BI6apFCHf4BstYZQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BI6apFCHf4BstYZQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BI6apFCHf4BstYZQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BI6apFCHf4BstYZQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BI6apFCHf4BstYZQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BI6apFCHf4BstYZQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BI6apFCHf4BstYZQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BI6apFCHf4BstYZQ .cluster text{fill:#333;}#mermaid-svg-BI6apFCHf4BstYZQ .cluster span{color:#333;}#mermaid-svg-BI6apFCHf4BstYZQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BI6apFCHf4BstYZQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BI6apFCHf4BstYZQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-BI6apFCHf4BstYZQ .icon-shape,#mermaid-svg-BI6apFCHf4BstYZQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BI6apFCHf4BstYZQ .icon-shape p,#mermaid-svg-BI6apFCHf4BstYZQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BI6apFCHf4BstYZQ .icon-shape .label rect,#mermaid-svg-BI6apFCHf4BstYZQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BI6apFCHf4BstYZQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BI6apFCHf4BstYZQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BI6apFCHf4BstYZQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 旅行计划详情页 TravelPlanDetailPage
旅途记账页 TravelExpenseLedgerPage
TravelExpenseItem 明细
TravelPlan.expenseItems
onSavePlan 回调
MainPage / TravelPlanPage 更新 Store
relationalStore 本地数据库
云同步调度
TravelPlanCloudSyncApiService
Spring Boot CloudTravelPlanController
MySQL cloud_travel_expense_item
componentSnapshot
ShareKit 系统分享
McPieChart 分类统计

从用户视角看,这是一个"进入旅行详情,打开记账,新增一笔支出"的动作。从代码视角看,真正的链路是:新建 TravelExpenseItem,克隆并更新 TravelPlan,回调保存,写本地库,必要时进入云同步队列。
Spring Boot 云同步接口 relationalStore App Store TravelExpenseLedgerPage TravelPlanDetailPage 用户 Spring Boot 云同步接口 relationalStore App Store TravelExpenseLedgerPage TravelPlanDetailPage 用户 #mermaid-svg-N2glZ8VxsMuXh0bi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-N2glZ8VxsMuXh0bi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-N2glZ8VxsMuXh0bi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-N2glZ8VxsMuXh0bi .error-icon{fill:#552222;}#mermaid-svg-N2glZ8VxsMuXh0bi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-N2glZ8VxsMuXh0bi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-N2glZ8VxsMuXh0bi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-N2glZ8VxsMuXh0bi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-N2glZ8VxsMuXh0bi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-N2glZ8VxsMuXh0bi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-N2glZ8VxsMuXh0bi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-N2glZ8VxsMuXh0bi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-N2glZ8VxsMuXh0bi .marker.cross{stroke:#333333;}#mermaid-svg-N2glZ8VxsMuXh0bi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-N2glZ8VxsMuXh0bi p{margin:0;}#mermaid-svg-N2glZ8VxsMuXh0bi .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-N2glZ8VxsMuXh0bi text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-N2glZ8VxsMuXh0bi .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-N2glZ8VxsMuXh0bi .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-N2glZ8VxsMuXh0bi .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-N2glZ8VxsMuXh0bi .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-N2glZ8VxsMuXh0bi #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-N2glZ8VxsMuXh0bi .sequenceNumber{fill:white;}#mermaid-svg-N2glZ8VxsMuXh0bi #sequencenumber{fill:#333;}#mermaid-svg-N2glZ8VxsMuXh0bi #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-N2glZ8VxsMuXh0bi .messageText{fill:#333;stroke:none;}#mermaid-svg-N2glZ8VxsMuXh0bi .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-N2glZ8VxsMuXh0bi .labelText,#mermaid-svg-N2glZ8VxsMuXh0bi .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-N2glZ8VxsMuXh0bi .loopText,#mermaid-svg-N2glZ8VxsMuXh0bi .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-N2glZ8VxsMuXh0bi .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-N2glZ8VxsMuXh0bi .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-N2glZ8VxsMuXh0bi .noteText,#mermaid-svg-N2glZ8VxsMuXh0bi .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-N2glZ8VxsMuXh0bi .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-N2glZ8VxsMuXh0bi .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-N2glZ8VxsMuXh0bi .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-N2glZ8VxsMuXh0bi .actorPopupMenu{position:absolute;}#mermaid-svg-N2glZ8VxsMuXh0bi .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-N2glZ8VxsMuXh0bi .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-N2glZ8VxsMuXh0bi .actor-man circle,#mermaid-svg-N2glZ8VxsMuXh0bi line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-N2glZ8VxsMuXh0bi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击"旅途记账"传入 plan 和 onSavePlan输入金额、分类、日期、备注addExpenseItem()onSavePlan(nextPlan)更新 travelPlans写入 travel_expense_items若已开启云同步则排队同步PUT /api/v1/cloud-travel-plans/{sourcePlanId}replaceExpenseItems()

数据模型怎么设计

我没有把账本做成一个单独的大对象,而是把一笔支出抽象成 TravelExpenseItem,再挂到 TravelPlan.expenseItems。这样它天然属于某一趟旅行,也能跟随旅行计划一起导入、分享、同步。

ts 复制代码
export class TravelExpenseItem {
  id: string = '';
  planId: string = '';
  sortOrder: number = 0;
  title: string = '';
  amount: number = 0;
  category: string = '其他';
  note: string = '';
  spentAt: string = '';
  createdAt: string = '';
  updatedAt: string = '';
}

export class TravelPlan {
  id: string = '';
  title: string = '';
  description: string = '';
  startDate: string = '';
  endDate: string = '';
  shareIncludeExpenses: boolean = false;
  cloudSyncEnabled: boolean = false;
  cloudSyncOwnerUserId: number = 0;
  cloudSyncSnapshotVersion: string = '';
  cloudSyncSyncedAt: string = '';
  cloudSyncErrorMessage: string = '';

  @Type(TravelExpenseItem)
  expenseItems: Array<TravelExpenseItem> = [];
}

字段里有几个点比较关键。planId 用来关联旅行计划,sortOrder 保证展示顺序稳定,amount 在端侧按两位小数处理,category 用来做分类统计,spentAt 用 ISO 字符串保存具体消费时间,同时页面会按日期切出"第几天"的账本视图。

本地表结构

本地持久化使用 relationalStore。费用明细没有塞进旅行计划 JSON,而是单独建表,这样后续按 plan_id、日期、分类做查询和迁移会更稳。

ts 复制代码
transaction.executeSync(
  'CREATE TABLE IF NOT EXISTS travel_expense_items (' +
  'id TEXT PRIMARY KEY, ' +
  'plan_id TEXT NOT NULL, ' +
  'sort_order INTEGER NOT NULL DEFAULT 0, ' +
  'title TEXT NOT NULL, ' +
  'amount REAL NOT NULL DEFAULT 0, ' +
  'category TEXT NOT NULL, ' +
  'note TEXT NOT NULL, ' +
  'spent_at TEXT NOT NULL, ' +
  'created_at TEXT NOT NULL, ' +
  'updated_at TEXT NOT NULL' +
  ')'
);

transaction.executeSync(
  'CREATE INDEX IF NOT EXISTS idx_travel_expense_items_plan_id ON travel_expense_items(plan_id)'
);

读库时,先读出旅行计划,再按 plan_id 把费用明细挂回对应的 TravelPlan

ts 复制代码
resultSet = transaction.querySqlSync(
  'SELECT id, plan_id, sort_order, title, amount, category, note, spent_at, created_at, updated_at ' +
  'FROM travel_expense_items ORDER BY plan_id ASC, sort_order ASC'
);

while (resultSet.goToNextRow()) {
  const planId: string = resultSet.getString(resultSet.getColumnIndex('plan_id'));
  const plan: TravelPlan | undefined = planMap.get(planId);
  if (plan === undefined) {
    continue;
  }

  const expenseItem: TravelExpenseItem = new TravelExpenseItem();
  expenseItem.id = resultSet.getString(resultSet.getColumnIndex('id'));
  expenseItem.planId = planId;
  expenseItem.sortOrder = resultSet.getLong(resultSet.getColumnIndex('sort_order'));
  expenseItem.title = resultSet.getString(resultSet.getColumnIndex('title'));
  expenseItem.amount = resultSet.getDouble(resultSet.getColumnIndex('amount'));
  expenseItem.category = resultSet.getString(resultSet.getColumnIndex('category'));
  expenseItem.note = resultSet.getString(resultSet.getColumnIndex('note'));
  expenseItem.spentAt = resultSet.getString(resultSet.getColumnIndex('spent_at'));
  expenseItem.createdAt = resultSet.getString(resultSet.getColumnIndex('created_at'));
  expenseItem.updatedAt = resultSet.getString(resultSet.getColumnIndex('updated_at'));
  plan.expenseItems.push(expenseItem);
}

写库时,我采用快照写入方式。保存整份 Store 时先清空相关表,再批量插入当前 Store 的数据。费用明细写入 expenseValues,最后用 batchInsertSync 批量落库。

ts 复制代码
const expenseItems: Array<TravelExpenseItem> = Array.isArray(plan.expenseItems) ? plan.expenseItems : [];
for (let expenseIndex: number = 0; expenseIndex < expenseItems.length; expenseIndex++) {
  const expenseItem: TravelExpenseItem = expenseItems[expenseIndex];
  expenseValues.push({
    id: expenseItem.id,
    plan_id: plan.id,
    sort_order: expenseItem.sortOrder,
    title: expenseItem.title,
    amount: expenseItem.amount,
    category: expenseItem.category,
    note: expenseItem.note,
    spent_at: expenseItem.spentAt,
    created_at: expenseItem.createdAt,
    updated_at: expenseItem.updatedAt
  });
}

if (expenseValues.length > 0) {
  transaction.batchInsertSync('travel_expense_items', expenseValues);
}

账本页面怎么调用

在旅行详情页里,我把账本页做成一个子目的地。TravelPlanDetailPage 控制入口和关闭,TravelExpenseLedgerPage 只关心账本内部交互。

ts 复制代码
@State showExpenseLedgerPage: boolean = false;
@State expenseViewItems: Array<TravelExpenseItem> = [];

private openExpenseLedgerPage(): void {
  if (this.plan.id.length === 0) {
    return;
  }
  this.showExpenseLedgerPage = true;
}

private closeExpenseLedgerPage(): void {
  this.clearExpenseLedgerShareHandlers();
  this.showExpenseLedgerPage = false;
}

页面调用的关键是把 planonSavePlan 传进去。账本页每次新增、编辑、删除后都会构造一份新的 TravelPlan,再通过 onSavePlan 交回详情页。

ts 复制代码
TravelExpenseLedgerPage({
  plan: this.plan,
  initialSpentDateKey: this.getExpenseInitialDateKey(this.plan),
  floatingActionOnLeft: this.floatingActionOnLeft,
  floatingActionSwapDirection: this.floatingActionSwapDirection,
  onSavePlan: (committedPlan: TravelPlan) => {
    this.markLocalExpenseMutation(committedPlan);
    this.syncExpenseViewItems(committedPlan);
    this.onSavePlan(committedPlan);
  },
  onClose: () => {
    this.closeExpenseLedgerPage();
  },
  onRegisterShareHandler: (
    shareDataBuilder: () => Promise<systemShare.SharedData>,
    sharePanelHandler: () => Promise<void>
  ) => {
    this.registerExpenseLedgerShareHandlers(shareDataBuilder, sharePanelHandler);
  },
  onUnregisterShareHandler: () => {
    this.clearExpenseLedgerShareHandlers();
  }
});

这里有一个细节:markLocalExpenseMutation 用来记录最近一次账本本地修改,避免外层刷新时拿旧的 plan 把新账本状态覆盖掉。做复杂页面时,这类"防旧数据回灌"的保护很有必要。

新增一笔支出

账本页内部维护了输入草稿:

ts 复制代码
@State expenseItems: Array<TravelExpenseItem> = [];
@State draftTitle: string = '';
@State draftAmount: string = '';
@State draftCategory: string = '餐饮';
@State draftNote: string = '';
@State selectedSpentDateKey: string = '';

private readonly categories: Array<string> = ['餐饮', '交通', '住宿', '门票', '购物', '其他'];

金额输入需要做一次归一化。这里允许用户输入 ¥、逗号,并统一四舍五入到两位小数。

ts 复制代码
private parseAmount(value: string): number {
  let normalized: string = value.trim().replace('¥', '').replace('¥', '').replace(',', '');
  if (normalized.length === 0) {
    return 0;
  }
  let amount: number = Number(normalized);
  if (isNaN(amount) || amount <= 0) {
    return 0;
  }
  return Math.round(amount * 100) / 100;
}

真正新增时,会生成 ID、绑定 planId、设置排序、分类、金额和消费时间,然后刷新页面统计并保存。

ts 复制代码
private addExpenseItem(): void {
  if (this.plan.id.length === 0) {
    return;
  }

  let amount: number = this.parseAmount(this.draftAmount);
  if (amount <= 0) {
    this.showToast('先输入有效金额');
    return;
  }

  let nowDate: Date = new Date();
  let now: string = nowDate.toISOString();
  let nextItem: TravelExpenseItem = new TravelExpenseItem();
  nextItem.id = createIdentifier('expense');
  nextItem.planId = this.plan.id;
  nextItem.sortOrder = this.getNextSortOrder(this.expenseItems);
  nextItem.title = this.buildDraftExpenseTitle();
  nextItem.amount = amount;
  nextItem.category = this.draftCategory;
  nextItem.note = this.draftNote.trim();
  nextItem.spentAt = this.buildSpentAtForSelectedDate(nowDate);
  nextItem.createdAt = now;
  nextItem.updatedAt = now;

  let nextItems: Array<TravelExpenseItem> = this.expenseItems.concat([nextItem]);
  this.closeAddExpenseSheet();
  this.refreshAfterExpenseItemsChanged(nextItems);
}

保存不是直接改原对象,而是克隆出一份新的旅行计划。这样 ArkUI 的状态刷新更清晰,上层也能拿到完整的新快照。

ts 复制代码
private saveExpenseItems(expenseItems: Array<TravelExpenseItem>): void {
  let nextPlan: TravelPlan = this.clonePlan(this.plan, expenseItems);
  nextPlan.updatedAt = new Date().toISOString();
  this.plan = nextPlan;
  this.onSavePlan(nextPlan);
}

private refreshAfterExpenseItemsChanged(nextItems: Array<TravelExpenseItem>): void {
  this.expenseItems = nextItems;
  this.refreshExpenseMetrics(nextItems);
  this.refreshSelectedDayTotal(nextItems);
  this.refreshPieOptions(nextItems);
  this.ensureExpandedExpenseCategory(nextItems);
  this.renderVersion = this.renderVersion + 1;
  this.saveExpenseItems(nextItems);
}

按旅行日期记账

旅途记账和普通记账的区别在于,它要围绕"这趟旅行的第几天"来组织信息。我的做法是优先从计划开始时间、结束时间、子行程时间推导可选日期,再把已有支出的日期补进去。

ts 复制代码
private buildExpenseDayOptions(): Array<ExpenseDayOption> {
  let options: Array<ExpenseDayOption> = [];
  let startKey: string = this.normalizeDateKey(this.getPlanStartValue());
  let endKey: string = this.normalizeDateKey(this.getPlanEndValue());

  if (startKey.length > 0 && endKey === '') {
    endKey = startKey;
  }

  let startDate: Date = new Date(startKey + 'T00:00:00');
  let endDate: Date = new Date(endKey + 'T00:00:00');

  if (startKey.length > 0 && endKey.length > 0 && !isNaN(startDate.getTime()) && !isNaN(endDate.getTime())) {
    if (endDate.getTime() < startDate.getTime()) {
      endDate = startDate;
    }
    let dayCount: number = Math.min(
      Math.floor((endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)) + 1,
      90
    );
    for (let i: number = 0; i < dayCount; i++) {
      let date: Date = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000);
      this.appendExpenseDayOption(options, this.formatDateKey(date), i);
    }
  }

  for (let i: number = 0; i < this.plan.subPlans.length; i++) {
    let subPlan: TravelSubPlan = this.plan.subPlans[i];
    let key: string = this.normalizeDateKey(subPlan.startTime.length > 0 ? subPlan.startTime : subPlan.endTime);
    this.appendExpenseDayOption(options, key, options.length);
  }

  for (let i: number = 0; i < this.expenseItems.length; i++) {
    this.appendExpenseDayOption(options, this.normalizeDateKey(this.expenseItems[i].spentAt), options.length);
  }

  if (options.length === 0) {
    this.appendExpenseDayOption(options, this.formatDateKey(new Date()), 0);
  }

  return options.sort((left: ExpenseDayOption, right: ExpenseDayOption) => left.key.localeCompare(right.key));
}

生成 spentAt 时,用选中的日期加上当前时分秒。这样既能按天统计,也保留了具体记录时间。

ts 复制代码
private buildSpentAtForSelectedDate(now: Date): string {
  this.ensureSelectedSpentDateKey();
  return this.buildSpentAtForDateKey(this.selectedSpentDateKey, now);
}

private buildSpentAtForDateKey(dateKey: string, now: Date): string {
  if (dateKey.length === 0) {
    return now.toISOString();
  }
  return `${dateKey}T${this.padNumber(now.getHours())}:${this.padNumber(now.getMinutes())}:${this.padNumber(now.getSeconds())}`;
}

分类统计和图表

分类统计没有交给后端做,因为账本页需要即时反馈。每次账本变化后,端侧直接按分类聚合金额,再刷新图表配置。

ts 复制代码
private getCategorySummaries(sourceItems?: Array<TravelExpenseItem>): Array<ExpenseCategorySummary> {
  let expenseItems: Array<TravelExpenseItem> = sourceItems === undefined ? this.expenseItems : sourceItems;
  let summaries: Array<ExpenseCategorySummary> = [];

  for (let itemIndex: number = 0; itemIndex < expenseItems.length; itemIndex++) {
    let item: TravelExpenseItem = expenseItems[itemIndex];
    let category: string = this.getExpenseItemCategory(item);
    let summaryIndex: number = summaries.findIndex(
      (summary: ExpenseCategorySummary) => summary.category === category
    );

    if (summaryIndex < 0) {
      let summary: ExpenseCategorySummary = new ExpenseCategorySummary();
      summary.category = category;
      summary.amount = item.amount;
      summary.color = this.getCategoryColor(category);
      summaries.push(summary);
    } else {
      summaries[summaryIndex].amount = summaries[summaryIndex].amount + item.amount;
    }
  }

  return summaries.sort((left: ExpenseCategorySummary, right: ExpenseCategorySummary) => right.amount - left.amount);
}

@mcui/mccharts 的配置也很直接,账本页把分类金额转成饼图数据。

ts 复制代码
private refreshPieOptions(sourceItems?: Array<TravelExpenseItem>): void {
  let pieData: Array<PieDataItem> = [];
  let pieColors: Array<string> = [];
  let summaries: Array<ExpenseCategorySummary> = this.getCategorySummaries(this.getCategorySummaryItems(sourceItems));

  for (let i: number = 0; i < summaries.length; i++) {
    let summary: ExpenseCategorySummary = summaries[i];
    if (summary.amount <= 0) {
      continue;
    }

    let item: PieDataItem = new PieDataItem();
    item.name = summary.category;
    item.value = Math.round(summary.amount * 100) / 100;
    item.color = summary.color;
    item.itemStyle = new PieItemStyle();
    item.itemStyle.color = summary.color;
    pieData.push(item);
    pieColors.push(summary.color);
  }

  this.pieOptions = new Options({
    cPaddingL: 12,
    cPaddingR: 12,
    cPaddingT: 10,
    cPaddingB: 10,
    color: pieColors,
    tooltip: { show: true },
    legend: { show: false },
    series: [
      {
        type: 'pie',
        radius: ['46%', '74%'],
        data: pieData,
        label: { show: false }
      }
    ]
  });
}

账本截图分享

账本分享用的是端侧截图方案。账本内容区域设置一个固定 ID,分享时通过 componentSnapshot.getSync 截取,再交给 ShareUtil 生成系统分享数据。

ts 复制代码
const TRAVEL_EXPENSE_LEDGER_SHARE_CANVAS_ID: string = 'travelExpenseLedgerShareCanvas';

private async buildExpenseLedgerShareData(): Promise<systemShare.SharedData> {
  let pixelMap = componentSnapshot.getSync(TRAVEL_EXPENSE_LEDGER_SHARE_CANVAS_ID);
  try {
    return await ShareUtil.buildImageShareDataFromPixelMap(
      this.getUIContext(),
      pixelMap,
      'travel_expense_ledger_share.png',
      '旅途记账',
      this.getExpenseLedgerShareDescription()
    );
  } finally {
    pixelMap.release();
  }
}

private async shareExpenseLedger(): Promise<void> {
  if (this.isSharingExpenseLedger) {
    return;
  }

  this.isSharingExpenseLedger = true;
  try {
    let shareData: systemShare.SharedData = await this.buildExpenseLedgerShareData();
    await ShareUtil.showSharePanel(this.getUIContext(), shareData);
  } catch (_error) {
    this.showToast('账本截图暂时无法分享');
  } finally {
    this.isSharingExpenseLedger = false;
  }
}

账本分享适合分享"我这趟旅行花费概览",而旅行计划分享则是另一条链路。旅行计划分享可以选择是否包含账本明细,对应 includeExpensesexpenseItems

ts 复制代码
private buildPlanShareRequest(coverUri: string): CreateTravelPlanShareRequest {
  let sortedSubPlans: Array<TravelSubPlan> = this.getSortedSubPlans(this.plan);
  let sortedChecklistItems: Array<TravelChecklistItem> = this.getSortedChecklistItems(this.plan);
  let sortedExpenseItems: Array<TravelExpenseItem> = this.getSortedExpenseItems(this.plan);

  return {
    planId: this.plan.id,
    currentShareKey: this.currentShareKey,
    regenerate: false,
    expiresAt: this.buildShareExpiryIsoString(),
    title: this.getPlanShareTitle(),
    description: this.plan.description.trim(),
    startDate: this.plan.startDate,
    endDate: this.plan.endDate,
    coverUri: coverUri,
    coverPresetId: this.plan.coverPresetId,
    isTimePrecisionEnabled: this.isTimePrecisionEnabled,
    publishToPlaza: this.publishShareToPlaza,
    includeChecklist: this.includeChecklistInShare,
    includeExpenses: this.includeExpensesInShare,
    subPlans: sortedSubPlans.map((subPlan: TravelSubPlan): SharedTravelSubPlanPayload => {
      return {
        title: subPlan.title,
        content: subPlan.content,
        location: subPlan.location,
        latitude: subPlan.latitude,
        longitude: subPlan.longitude,
        fullAddress: subPlan.fullAddress,
        startTime: subPlan.startTime,
        endTime: subPlan.endTime,
        isCompleted: subPlan.isCompleted
      };
    }),
    checklistItems: this.includeChecklistInShare ? sortedChecklistItems.map((item: TravelChecklistItem): SharedTravelChecklistItemPayload => {
      return {
        title: item.title,
        isCompleted: item.isCompleted
      };
    }) : [],
    expenseItems: this.includeExpensesInShare ? sortedExpenseItems.map((item: TravelExpenseItem): SharedTravelExpenseItemPayload => {
      return {
        title: item.title,
        amount: item.amount,
        category: item.category,
        note: item.note,
        spentAt: item.spentAt
      };
    }) : []
  };
}

云同步怎么接

账本明细会跟随旅行计划一起同步。端侧的云同步 payload 里有 expenseItems 字段:

ts 复制代码
export interface CloudTravelExpenseItemPayload {
  title: string;
  amount: number;
  category: string;
  note: string;
  spentAt: string;
}

export interface UpsertCloudTravelPlanRequest {
  sourcePlanId: string;
  title: string;
  description: string;
  startDate: string;
  endDate: string;
  coverUri: string;
  coverPresetId: string;
  isTimePrecisionEnabled: boolean;
  isDailyGroupingEnabled: boolean;
  sourceCreatedAt: string;
  sourceUpdatedAt: string;
  subPlans: Array<CloudTravelSubPlanPayload>;
  checklistItems: Array<CloudTravelChecklistItemPayload>;
  expenseItems: Array<CloudTravelExpenseItemPayload>;
  ticketItems: Array<CloudTravelTicketItemPayload>;
}

构造同步请求时,把本地账本按 sortOrder 排序后转换成后端需要的字段。

ts 复制代码
export function buildCloudTravelPlanPayload(
  plan: TravelPlan,
  coverUri: string
): UpsertCloudTravelPlanRequest {
  return {
    sourcePlanId: plan.id,
    title: plan.title.trim(),
    description: plan.description.trim(),
    startDate: plan.startDate,
    endDate: plan.endDate,
    coverUri: coverUri.trim(),
    coverPresetId: plan.coverPresetId,
    isTimePrecisionEnabled: plan.isTimePrecisionEnabled !== false,
    isDailyGroupingEnabled: plan.isDailyGroupingEnabled,
    sourceCreatedAt: plan.createdAt,
    sourceUpdatedAt: plan.updatedAt,
    subPlans: getSortedCloudTravelSubPlans(plan),
    checklistItems: getSortedCloudTravelChecklistItems(plan),
    expenseItems: getSortedCloudTravelExpenseItems(plan),
    ticketItems: getSortedCloudTravelTicketItems(plan)
  };
}

function getSortedCloudTravelExpenseItems(plan: TravelPlan): Array<CloudTravelExpenseItemPayload> {
  return (Array.isArray(plan.expenseItems) ? plan.expenseItems : [])
    .slice(0)
    .sort((left: TravelExpenseItem, right: TravelExpenseItem) => left.sortOrder - right.sortOrder)
    .map((item: TravelExpenseItem): CloudTravelExpenseItemPayload => {
      return {
        title: item.title,
        amount: item.amount,
        category: item.category,
        note: item.note,
        spentAt: item.spentAt
      };
    });
}

请求接口是:

ts 复制代码
export async function upsertCloudTravelPlan(
  payload: UpsertCloudTravelPlanRequest,
  tokenName: string,
  tokenValue: string
): Promise<CloudTravelPlanRecord> {
  const headerName: string = tokenName.trim().length > 0 ? tokenName : BackendConstants.DEFAULT_TOKEN_HEADER;
  return requestApi<CloudTravelPlanRecord>(
    `${BackendConstants.CLOUD_TRAVEL_PLAN_PATH}/${encodeURIComponent(payload.sourcePlanId)}`,
    http.RequestMethod.PUT,
    payload,
    headerName,
    tokenValue
  );
}

后端控制器对应 /api/v1/cloud-travel-plans

java 复制代码
@RestController
@RequestMapping("/api/v1/cloud-travel-plans")
public class CloudTravelPlanController {

    private final CloudTravelPlanService cloudTravelPlanService;

    public CloudTravelPlanController(CloudTravelPlanService cloudTravelPlanService) {
        this.cloudTravelPlanService = cloudTravelPlanService;
    }

    @SaCheckLogin
    @PutMapping("/{sourcePlanId}")
    public ApiResponse<CloudTravelPlanResponse> upsertCloudTravelPlan(
        @PathVariable String sourcePlanId,
        @Valid @RequestBody UpsertCloudTravelPlanRequest request
    ) {
        Long userId = StpUtil.getLoginIdAsLong();
        CloudTravelPlanResponse response = cloudTravelPlanService.upsertCloudTravelPlan(userId, sourcePlanId, request);
        return ApiResponse.success("旅行计划已同步到云端", response);
    }
}

费用明细请求 DTO 做了基本校验:

java 复制代码
public record CloudTravelExpenseItemRequest(
    @Size(max = 128, message = "记账标题不能超过128个字符")
    String title,

    @DecimalMin(value = "0.0", inclusive = true, message = "记账金额不能为负数")
    BigDecimal amount,

    @Size(max = 64, message = "记账分类不能超过64个字符")
    String category,

    @Size(max = 512, message = "记账备注不能超过512个字符")
    String note,

    @Size(max = 64, message = "记账时间格式异常")
    String spentAt
) {
}

后端实体使用 JPA 映射到 cloud_travel_expense_item 表,金额用 BigDecimal 保存,精度是 12,2

java 复制代码
@Entity
@Table(
    name = "cloud_travel_expense_item",
    indexes = {
        @Index(name = "idx_cloud_travel_expense_item_plan_id", columnList = "cloud_travel_plan_id"),
        @Index(name = "idx_cloud_travel_expense_item_sort_order", columnList = "sort_order"),
        @Index(name = "idx_cloud_travel_expense_item_spent_at", columnList = "spent_at")
    }
)
public class CloudTravelExpenseItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "cloud_travel_plan_id", nullable = false)
    private CloudTravelPlan cloudTravelPlan;

    @Column(name = "sort_order", nullable = false)
    private Integer sortOrder = 0;

    @Column(name = "title", length = 128)
    private String title;

    @Column(name = "amount", nullable = false, precision = 12, scale = 2)
    private BigDecimal amount = BigDecimal.ZERO;

    @Column(name = "category", length = 64)
    private String category;

    @Column(name = "note", length = 512)
    private String note;

    @Column(name = "spent_at", length = 64)
    private String spentAt;
}

服务层同步时采用替换策略。也就是说,每次同步旅行计划时,如果请求里带了 expenseItems,后端会重建这份计划下的费用明细,避免端云之间做复杂的单条 diff。

java 复制代码
if (request.expenseItems() != null) {
    plan.replaceExpenseItems(buildExpenseItems(request.expenseItems()));
}

private List<CloudTravelExpenseItem> buildExpenseItems(List<CloudTravelExpenseItemRequest> requests) {
    List<CloudTravelExpenseItem> result = new ArrayList<>();
    if (requests == null) {
        return result;
    }
    for (int i = 0; i < requests.size(); i++) {
        CloudTravelExpenseItemRequest request = requests.get(i);
        CloudTravelExpenseItem item = new CloudTravelExpenseItem();
        item.setSortOrder(i);
        item.setTitle(trimToEmpty(request.title()));
        item.setAmount(request.amount() == null ? BigDecimal.ZERO : request.amount());
        item.setCategory(trimToEmpty(request.category()));
        item.setNote(trimToEmpty(request.note()));
        item.setSpentAt(trimToEmpty(request.spentAt()));
        result.add(item);
    }
    return result;
}

从云端恢复到本地

云端拉回旅行计划时,账本也会恢复成本地 TravelExpenseItem。如果本地已经有对应索引的明细,就复用本地对象的 ID;如果没有,就生成一个稳定的兜底 ID。

ts 复制代码
const expenseItems: Array<CloudTravelExpenseItemPayload> = Array.isArray(record.expenseItems)
  ? record.expenseItems
  : [];

plan.expenseItems = expenseItems.map(
  (item: CloudTravelExpenseItemPayload, index: number): TravelExpenseItem => {
    const nextItem: TravelExpenseItem = index < existingExpenseItems.length
      ? cloneTravelExpenseItem(existingExpenseItems[index])
      : new TravelExpenseItem();

    nextItem.id = nextItem.id.trim().length > 0 ? nextItem.id : `${plan.id}_expense_${index.toString()}`;
    nextItem.planId = plan.id;
    nextItem.sortOrder = index;
    nextItem.title = item.title;
    nextItem.amount = item.amount;
    nextItem.category = item.category;
    nextItem.note = item.note;
    nextItem.spentAt = item.spentAt;
    nextItem.createdAt = nextItem.createdAt.trim().length > 0 ? nextItem.createdAt : record.sourceCreatedAt;
    nextItem.updatedAt = record.sourceUpdatedAt;
    return nextItem;
  }
);

这也是为什么我前面说账本不能只做成一个页面状态。它要能随着旅行计划完整地走一遍本地、云端、导入和分享流程。

接入时最容易踩的点

第一,新增支出后一定要更新 TravelPlan.updatedAt。云同步判断本地是否有新改动时,会比较 updatedAtcloudSyncSnapshotVersion,如果只改 expenseItems 不更新时间,可能不会进入同步队列。

第二,金额不要直接保存用户输入字符串。端侧统一转成 number,后端用 BigDecimal,展示时再格式化成 ¥xx.xx

第三,账本页不要直接操作全局 Store。它应该通过 onSavePlan 把新计划交给上层,这样本地持久化、云同步、页面刷新都能走同一个出口。

第四,分享账本截图时一定要释放 PixelMapcomponentSnapshot.getSync 拿到的是图像资源,用完要 release()

第五,云同步使用整份计划替换策略时,要先明确业务是否接受"最后一次同步快照覆盖"。《时光旅记》这里把旅行计划作为整体同步对象,用户场景更关注整份计划一致性,所以这条策略是合适的。

总结

在《时光旅记》里,旅途记账不是单纯的"输入金额并展示列表"。它和旅行计划绑定,围绕旅行日期组织消费,用分类统计帮助用户理解花费结构,同时进入本地数据库、旅行分享和云同步链路。

我自己的实现原则是:账本页面只负责交互和计算,TravelPlan 负责承载业务数据,上层页面负责保存,持久化层负责落库,云同步层负责端云一致。这样后面继续扩展预算、多人分摊、币种换算或者票据 OCR 自动入账时,都可以沿着同一条链路往下加,而不用推翻原来的结构。