ArkTS + HarmonyOS 账期封存:多人分摊的只读结算单与新账期 · 06【共创稿事节】
旅费账本的三人版已经完成整数分守恒、多人参与式分摊、转账建议和 schemaVersion=2 的持久化恢复。最后一个容易让实际用户困惑的场景却是旅行结束之后:十一月这次旅行已经核对清楚,大家仍想看看当时每笔费用由谁支付、谁承担多少,却不希望某个人误触编辑按钮,导致旧账反复变化。与此同时,下一次周末旅行应该从零开始录入,而不是删除上次的五笔费用,失去追溯依据。
因此本篇选择一个与前五篇不同的工程目标:封存已核对账期、生成只读结算单、再启动空白的新账期。它不尝试接入银行付款或制造已清算的凭证。前一篇 LedgerSnapshot{schemaVersion,revision,members,expenses} 和 splitExpense() 仍是唯一计算来源;已有三位成员李、周、陈,五条示例记录是住宿128元、交通48元、饮品26元、停车18元、共同晚餐90元,总支出310元。封账不是改变这些金额,而是把已验证的账本固定成历史快照并阻止页面再修改。

一、为何“清空旧账再录新账”不是新账期
如果只在点击“新建账本”时执行 this.expenses=[],并立即调用上一轮 Repository 的 save(),旧费用就被覆盖了。用户过几天回查当时的付款人和承担成员,无法凭总支出310元重建分摊过程;即便页面保留了一张转账结果截图,也无法知道它对应哪一次数据修订。另一方面,如果仅在 @State 上增加 closed=true,重启后又可能恢复为默认可编辑状态。两种方案都把封账误解为一种 UI 颜色或按钮显隐。
本轮引入 LedgerWorkspace:一份 active 当前账期、一个 archives 历史封存数组和工作区格式版本号3。每个封存条目包含只读的旧 LedgerSnapshot、封存时刻、总金额、费用条数和账期 ID。金额和余额不是独立可信来源:封账前用之前的分摊引擎逐条验证,并重算总金额;阅读历史时仍以 snapshot 为准,摘要仅用于展示和快速核对。这样的结构将“账本是什么”和“它是否仍允许编辑”分开存储,不必在每一笔 Expense 上增加 isClosed 布尔属性。
建议账期 ID 由业务层生成并确保唯一,不使用数组下标;本文用 period_1 示意,后续可升级为时间与随机值组合。封存后新账期保留当前成员名单,但费用为空、修订号递增。若成员再次旅行时发生变化,可在新的空白账期中变更成员,不能倒过来修改封存时的姓名与成员 ID 记录。
// model/LedgerWorkspace.ets:继承第05篇的账本结构
import { LedgerSnapshot, validateSnapshot } from './LedgerSnapshot';
import { Expense } from './LedgerModel';
import { splitExpense } from '../utils/AllocationEngine';
export interface ClosedPeriod {
id: string;
closedAtMs: number;
totalCents: number;
expenseCount: number;
snapshot: LedgerSnapshot;
}
export interface LedgerWorkspace {
schemaVersion: number;
active: LedgerSnapshot;
archives: ClosedPeriod[];
}
export function checkedTotal(s: LedgerSnapshot): number {
validateSnapshot(s);
let total: number = 0;
s.expenses.forEach((e: Expense) => {
splitExpense(e, s.members);
total += e.cents;
if (!Number.isSafeInteger(total)) { throw new Error('账期金额溢出'); }
});
return total;
}
export function closePeriod(w: LedgerWorkspace, id: string,
nowMs: number): LedgerWorkspace {
if (w.schemaVersion !== 3 || w.archives.some(a => a.id === id) ||
!/^period_[1-9][0-9]*$/.test(id) || !Number.isSafeInteger(nowMs) ||
w.active.expenses.length === 0) {
throw new Error('账期编号重复、时间无效或当前没有费用');
}
const total: number = checkedTotal(w.active);
const frozen: LedgerSnapshot = JSON.parse(JSON.stringify(w.active)) as LedgerSnapshot;
const archive: ClosedPeriod = { id: id, closedAtMs: nowMs,
totalCents: total, expenseCount: frozen.expenses.length, snapshot: frozen };
const nextActive: LedgerSnapshot = { schemaVersion: 2,
revision: w.active.revision + 1, members: w.active.members.map(m => ({...m})),
expenses: [] };
return { schemaVersion: 3, active: nextActive,
archives: [...w.archives, archive] };
}
深拷贝发生在封账瞬间,而不是把仍然会变化的页面数组引用直接塞进历史。函数返回一份新工作区,不在原对象上原地 push;这既降低 ArkUI 状态更新时引用共享的问题,也让封账失败时能保留原来的当前账期。checkedTotal() 先对每笔费用调用原分摊算法,因此固定承担与比例承担的边界仍由之前已定义的规则负责;封账层不重写一份“看似更快”的分摊函数。如果业务要求从未支付余额为零才能结账,应把这种规则写成显式条件;本示例只代表账期录入完成,不等于成员已经转账。
二、把一次封账提交到同一个持久化边界
第五篇用 Preferences 的 ledger_snapshot_v2 保存当前账本。现在如果分别写 archive_list 和 active_snapshot 两个键,可能先写成功历史、后写失败新账期,下一次打开时就出现同一笔费用既在封存页又在可编辑页。为降低此类局部更新窗口,新版本引入单个 ledger_workspace_v3 JSON 键,同一次 put 和 flush 保存 active+archives。首次启动时优先读 v3;没有 v3 才读取之前经过检验的 v2 快照并构建一个空 archives 工作区,待用户首次封账时写出 v3。这样不会假定所有旧用户都已经完成迁移。
// data/WorkspaceRepository.ets
import { preferences } from '@kit.ArkData';
import { LedgerSnapshot, validateSnapshot } from '../model/LedgerSnapshot';
import { LedgerWorkspace, checkedTotal } from '../model/LedgerWorkspace';
export class WorkspaceRepository {
private db: preferences.Preferences;
private readonly NEW_KEY: string = 'ledger_workspace_v3';
constructor(context: Context) {
this.db = preferences.getPreferencesSync(context, { name: 'travel_ledger' });
}
load(): LedgerWorkspace {
const current: string = this.db.getSync(this.NEW_KEY, '') as string;
if (current) {
const w: LedgerWorkspace = JSON.parse(current) as LedgerWorkspace;
if (w.schemaVersion !== 3 || !Array.isArray(w.archives)) {
throw new Error('工作区格式无效');
}
checkedTotal(w.active);
w.archives.forEach(a => {
if (checkedTotal(a.snapshot) !== a.totalCents ||
a.snapshot.expenses.length !== a.expenseCount) {
throw new Error('历史结算单摘要不匹配');
}
});
return w;
}
const old: string = this.db.getSync('ledger_snapshot_v2', '') as string;
if (!old) { throw new Error('当前没有可迁移的账本'); }
const active: LedgerSnapshot = JSON.parse(old) as LedgerSnapshot;
validateSnapshot(active);
checkedTotal(active);
return { schemaVersion: 3, active: active, archives: [] };
}
async save(w: LedgerWorkspace): Promise<void> {
if (w.schemaVersion !== 3) { throw new Error('版本不正确'); }
checkedTotal(w.active);
w.archives.forEach(a => checkedTotal(a.snapshot));
await this.db.put(this.NEW_KEY, JSON.stringify(w));
await this.db.flush();
}
}
这是面向少量账目的示例,不应把它误读成数据库事务实现。put() 修改 Preferences 缓存,flush() 请求持久化;单键减少多键更新中的状态分裂,却不能替代断电恢复、备份保留和操作审计。正式产品应在写入前后保有最近可信工作区,并处理失败后缓存已被改变的可能性:一旦回退写盘也失败,就显示“存储状态不确定,暂停编辑”,而不是继续声称封账成功。Preferences 的缓存、API10起的同步获取方式和异步刷盘详见华为官方 Preferences 指南 与 API 参考。

三、前端只发布已经落盘的封账结果
封存属于不可随意撤销的业务动作。按钮交互应采用“查看本期数据—二次确认—封存并开启新账期”。一次点击必须防重入:saving=true 时不能再点击第二次;提交前检查账目不能为空、费用 ID 唯一、所有付款人都存在、成员承担总额守恒,并保留上一次成功的 workspace 快照。调用仓储成功后才能让页面切换到“本期已封存”;异常时保留可编辑旧账,提示重试或查看错误原因。
// pages/ClosePeriodPage.ets 关键交互,旧版数据加载沿用仓储
import { WorkspaceRepository } from '../data/WorkspaceRepository';
import { LedgerWorkspace, closePeriod } from '../model/LedgerWorkspace';
@Entry
@Component
struct ClosePeriodPage {
@State workspace: LedgerWorkspace = { schemaVersion: 3,
active: { schemaVersion: 2, revision: 0, members: [], expenses: [] },
archives: [] };
@State saving: boolean = false;
@State message: string = '请先核对所有费用';
@State viewedPeriodId: string = '';
private repo: WorkspaceRepository = new WorkspaceRepository(getContext(this));
aboutToAppear(): void {
try { this.workspace = this.repo.load(); }
catch (e) { this.message = '读取失败,暂不可封账'; }
}
private async finish(): Promise<void> {
if (this.saving || this.workspace.active.expenses.length === 0) { return; }
this.saving = true;
try {
const id: string = `period_${this.workspace.archives.length + 1}`;
const next: LedgerWorkspace = closePeriod(this.workspace, id, Date.now());
await this.repo.save(next);
this.workspace = next;
this.viewedPeriodId = id;
this.message = '本期已封存,新账期已建立';
} catch (e) { this.message = '封账未完成,保留当前账期'; }
finally { this.saving = false; }
}
build() {
Column({ space: 16 }) {
Text('旅费账本 · 账期管理').fontSize(25).fontWeight(FontWeight.Bold)
Text(this.message)
Text(`当前费用:${this.workspace.active.expenses.length}笔`)
Button('核对后封账并开启新账期')
.enabled(!this.saving && this.workspace.active.expenses.length > 0)
.onClick(() => { this.finish(); })
ForEach(this.workspace.archives, (item) => {
Text(`${item.id} · 已封存 · ${item.expenseCount}笔`)
.onClick(() => { this.viewedPeriodId = item.id; })
}, (item) => item.id)
}.padding(24).width('100%')
}
}
本页为了突出封账边界,仅列出历史摘要;实际只读详情页按 viewedPeriodId 在 archives 中取 snapshot,显示成员、原始 Expense 与由 splitExpense() 重新算出的转账建议,不传入任何保存、编辑、删除回调。界面按钮可隐藏编辑入口,但真正的约束必须在 Repository 或业务命令层执行,不能让其他页面直接对归档对象调用旧的 commit()。例如“查看已封存记录”仅返回数据副本,拒绝覆盖操作;若需要纠错,应通过新的显式更正账期创建相反调整记录,留下变更原因,而不是悄悄修改旧封存快照。
还应注意示例里的 period_1 分配方式只适合档案永不删除且按顺序增长的单机示例;允许删除历史或多端合并时,应改为稳定的唯一标识策略。ClosePeriodPage 中使用的 @Entry 要按实际项目的 main_pages.json 导航配置安排,不应在一个页面路由中同时出现两个不受管理的入口。ForEach 选择条目 ID 作为稳定键,官方 ForEach 指南 对这一点给出了明确建议。
四、按“历史不变、新账为空”来验收
首先载入第五篇的三人快照:五笔记录合计310元,付款金额分别是李244元、周66元、陈0元;上一轮给出的承担金额分别为李153元、周127元、陈30元,净余额分别是+91、-61、-30元。此时账期尚未封存,可以继续添加、编辑、撤销。封账后在历史列表中应看到费用5笔、总额310.00元,而当前新账期费用0笔。打开旧账期,重算转账建议仍是周转李61元、陈转李30元;这两笔建议的合计91元恰好对应李的正余额。
其次验证恢复:结束应用并再次进入页面,先读取 ledger_workspace_v3,确保历史结算单仍可见、当前账期仍为空、封存记录的总额和原始费用均未变。再输入一笔新费用,应该只影响新账期,旧账期费用数仍为5。若故意制造一条重复费用 ID、缺失成员付款人或固定承担金额不守恒的记录,必须在封账前阻止提交;若写盘失败,则旧账仍是当前可编辑账期,不能显示“已封账”。此外还应检查两次快速点击、空账期封账、重启后的重复编号、历史快照损坏等路径,而不只验证主流程。

最后注意金额显示统一由整数分格式化;不要在 Text 里各自用浮点元重新求和。若以后需要生成 CSV 或供家人核对的文本清单,应从已封存的 snapshot 派生,并显式注明“本机账目核对单”,而不是使用具有法律意味的支付回执或银行对账单称谓。对于真正涉及多设备协作的旅费系统,还需要远端身份、并发修订冲突、加密备份和授权审计;本地封账只能防止示例应用中无意的业务修改,不能证明他人无法改写设备文件。
五、六篇连载的收官取舍
这套旅费账本从两人四笔费用开始,逐步加入编辑撤销、承担对象、三人分摊、版本化快照,最终落到明确的账期生命周期。贯穿六篇的核心原则始终没有改变:金额和承担额都使用整数分;每笔费用有稳定 ID、付款人和承担范围;结算建议由原始数据计算;写入成功后才发布新状态。历史封账则补充了最后一条约束:过去已经核对过的输入不再与当前编辑态共享可变引用。
这不是财务凭证系统,也未承诺跨设备防篡改。它适合单设备、低频维护、少量亲友的旅行费用草稿;当数据量和协作范围变大时,应把单键 Preferences 快照迁移到带事务和审计能力的数据层。把“可编辑当前账”和“只读历史账”拆开,意味着下一次扩展提醒、按月份查阅或者数据导出时,不必重新冒着修改旧账的风险。至此,旅费账本的六篇工程主线形成一个能够解释计算、保存、恢复和归档边界的闭环。
更多推荐




所有评论(0)