大家好,我是鸿蒙Jack。本期以我的《时光旅记》APP为例,聊一聊我在旅行计划里做的"旅途记账"能力。
这个能力不是一个孤立的记账小页面,而是挂在旅行计划详情里的一个业务模块。用户在做旅行规划时,通常会同时关心几件事:每天去哪、票据放哪、清单还有什么没买、这趟旅行到底花了多少钱。所以我把"旅途记账"放在旅行计划详情页里,让它和行程、清单、票夹、地图共用同一份 TravelPlan 数据。
这里先放最终效果图位置。

这次用到的技术栈
这块实现横跨端侧、本地存储、截图分享和后端云同步,技术栈需要讲清楚。
端侧使用 HarmonyOS ArkTS 和 ArkUI。账本页面是一个 @Component,用 @Prop 接收旅行计划,用 @State 管理页面内的草稿、统计值、弹窗开关和图表配置。
本地持久化使用 @kit.ArkData 里的 relationalStore,也就是 HarmonyOS 端侧关系型数据库。旅行计划存到 travel_plans,账本明细单独存到 travel_expense_items,通过 plan_id 关联。
网络请求使用 @kit.NetworkKit 的 http,云同步接口以 JSON 形式提交到后端。
分类统计图使用三方库 @mcui/mccharts,当前项目依赖版本是 ^2.8.9,账本页里用 McPieChart 渲染支出分类环图。
账本分享使用 @kit.ArkUI 的 componentSnapshot 把账本区域截成 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;
}
页面调用的关键是把 plan 和 onSavePlan 传进去。账本页每次新增、编辑、删除后都会构造一份新的 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;
}
}
账本分享适合分享"我这趟旅行花费概览",而旅行计划分享则是另一条链路。旅行计划分享可以选择是否包含账本明细,对应 includeExpenses 和 expenseItems。
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。云同步判断本地是否有新改动时,会比较 updatedAt 和 cloudSyncSnapshotVersion,如果只改 expenseItems 不更新时间,可能不会进入同步队列。
第二,金额不要直接保存用户输入字符串。端侧统一转成 number,后端用 BigDecimal,展示时再格式化成 ¥xx.xx。
第三,账本页不要直接操作全局 Store。它应该通过 onSavePlan 把新计划交给上层,这样本地持久化、云同步、页面刷新都能走同一个出口。
第四,分享账本截图时一定要释放 PixelMap。componentSnapshot.getSync 拿到的是图像资源,用完要 release()。
第五,云同步使用整份计划替换策略时,要先明确业务是否接受"最后一次同步快照覆盖"。《时光旅记》这里把旅行计划作为整体同步对象,用户场景更关注整份计划一致性,所以这条策略是合适的。
总结
在《时光旅记》里,旅途记账不是单纯的"输入金额并展示列表"。它和旅行计划绑定,围绕旅行日期组织消费,用分类统计帮助用户理解花费结构,同时进入本地数据库、旅行分享和云同步链路。
我自己的实现原则是:账本页面只负责交互和计算,TravelPlan 负责承载业务数据,上层页面负责保存,持久化层负责落库,云同步层负责端云一致。这样后面继续扩展预算、多人分摊、币种换算或者票据 OCR 自动入账时,都可以沿着同一条链路往下加,而不用推翻原来的结构。