Actual 预算软件账户分组管理:自定义账户组织、侧边栏树形视图与源码级解析
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
导读:Actual Budget(本地优先个人财务应用)正逐步将"实验性新侧边栏"升级为账户组织的核心入口,其中自定义账户分组(Account Groups)是其关键特性。本文以官方发布说明 upcoming-release-notes/add-account-groups-management-ui.md 为主线,系统讲解账户分组的创建、重命名、删除、排序与移动,以及配套的侧边栏可折叠分组视图、余额与同步状态展示;并结合 loot-core 服务端与 desktop-client 前端源码,深入剖析其数据模型、API 链路、排序算法、CRDT 删除语义与 UI 树构建逻辑,帮助读者既会操作又能理解底层原理。
一、功能概览:从发布说明到完整实现
仓库中的发布说明 upcoming-release-notes/add-account-groups-management-ui.md 用一句话概括了该特性:
Experimental: Add the ability to organise accounts into custom groups(实验性:新增将账户组织为自定义分组的能力)
这一行文字背后,是一套贯穿数据库、服务端 API、前端状态管理与 UI 的完整功能链。配套的另一份说明 upcoming-release-notes/add-new-sidebar-account-list.md 补充了它的呈现载体:
Redesign the account list in the experimental new sidebar with collapsible account groups, balances, net worth, and sync status indicators(在实验性新侧边栏中重构账户列表,加入可折叠的账户分组、余额、净资产与同步状态指示器)
综合来看,该特性包含两大能力:
- 账户分组管理:创建、重命名、删除、排序自定义分组,并将账户归属到指定分组(或移出分组);
- 分组化侧边栏:在实验性新侧边栏中以"预算内 / 预算外"两个区段渲染树形结构,每组可独立折叠/展开,并展示组内余额合计、净资产与同步失败状态。
⚠️实验性说明:这两份说明均标记为 Experimental / Features,属于新侧边栏迭代过程中的实验特性,读者在当前开发分支上体验时需注意其 API 与界面仍可能调整。
二、数据模型与存储:账户分组从哪来
2.1 数据表结构
账户分组通过迁移 1787013118115_add_account_groups.sql 引入:
BEGIN TRANSACTION; CREATE TABLE account_groups (id TEXT PRIMARY KEY, name TEXT, sort_order REAL, tombstone INTEGER DEFAULT 0); ALTER TABLE accounts ADD COLUMN account_group_id TEXT DEFAULT NULL; COMMIT;要点:
account_groups表字段为id(文本主键)、name(分组名)、sort_order(REAL 排序权重,默认递增)、tombstone(软删除标记,CRDT 同步场景下不物理删除行);accounts表新增account_group_id外键列,默认NULL,表示"未分组";- 整体采用**软删除(tombstone)**而非硬删除,与 Actual 的 CRDT 同步机制保持一致(见下文删除语义)。
2.2 实体类型定义
前端与核心层共享的实体类型定义在 packages/loot-core/src/types/models/account-group.ts:
export type AccountGroupEntity = { id: string; name: string; sort_order: number; tombstone?: boolean; };而账户实体则通过 packages/loot-core/src/types/models/account.ts 中的account_group_id字段与分组关联。
三、服务端 API:五个核心方法及其链路
3.1 处理器注册与装饰器
账户分组的全部后端逻辑集中在 packages/loot-core/src/server/account-groups/app.ts,五个方法统一注册:
export type AccountGroupsHandlers = { 'account-groups-get': typeof getAccountGroups; 'account-group-create': typeof createAccountGroup; 'account-group-update': typeof updateAccountGroup; 'account-group-delete': typeof deleteAccountGroup; 'account-group-move': typeof moveAccountGroup; }; export const app = createApp<AccountGroupsHandlers>(); app.method('account-groups-get', getAccountGroups); app.method('account-group-create', mutator(undoable(createAccountGroup))); app.method('account-group-update', mutator(undoable(updateAccountGroup))); app.method('account-group-delete', mutator(undoable(deleteAccountGroup))); app.method('account-group-move', mutator(undoable(moveAccountGroup)));值得注意的是,除查询外的四个写操作均被mutator(undoable(...))包装——这意味着创建、重命名、删除、移动分组都支持撤销(undo),与账户、交易等既有资源的处理方式一致。
各方法职责如下:
| 方法名 | 入参 | 返回值 | 说明 |
|---|---|---|---|
account-groups-get | 无 | AccountGroupEntity[] | 返回所有未删除分组,含id、name、sort_order |
account-group-create | { name } | 新分组id | 创建分组并追加到末尾 |
account-group-update | { id, name } | void | 重命名分组 |
account-group-delete | { id } | 被删分组id | 软删除并清空成员引用 |
account-group-move | { id, targetId } | void | 移动到目标分组之前;targetId=null时追加到末尾 |
3.2 数据库层实现
数据库操作位于 packages/loot-core/src/server/db/index.ts(L787-L867):
查询(L787-791)——按sort_order, id排序,仅返回未删除行:
export function getAccountGroups() { return all<DbAccountGroup>( `SELECT * FROM account_groups WHERE tombstone = 0 ORDER BY sort_order, id`, ); }创建(L793-819)——三步:查重 → 计算排序权重 → 插入:
export async function insertAccountGroup( group: WithRequired<Partial<DbAccountGroup>, 'name'>, ): Promise<DbAccountGroup['id']> { // 1) 大小写不敏感查重 const existingGroup = await first<...>( `SELECT id, name FROM account_groups WHERE UPPER(name) = ? AND tombstone = 0 LIMIT 1`, [group.name.toUpperCase()], ); if (existingGroup) { throw new Error(`An '${existingGroup.name}' account group already exists.`); } // 2) 取当前最大 sort_order,递增一个 SORT_INCREMENT 作为新组的排序权重 const lastGroup = await first<...>(` SELECT sort_order FROM account_groups WHERE tombstone = 0 ORDER BY sort_order DESC, id DESC LIMIT 1 `); const sort_order = (lastGroup ? lastGroup.sort_order : 0) + SORT_INCREMENT; group = { ...accountGroupModel.validate(group), sort_order }; const id = await insertWithUUID('account_groups', group); return id; }重命名(L821-833)——同样查重(排除自身id != ?),再校验更新:
export async function updateAccountGroup( group: WithRequired<Partial<DbAccountGroup>, 'id' | 'name'>, ) { const existingGroup = await first<...>( `SELECT id, name FROM account_groups WHERE UPPER(name) = ? AND id != ? AND tombstone = 0 LIMIT 1`, [group.name.toUpperCase(), group.id], ); if (existingGroup) { throw new Error(`An '${existingGroup.name}' account group already exists.`); } group = accountGroupModel.validate(group, { update: true }); return update('account_groups', group); }移动(L835-850)——读取全部分组,利用shoveSortOrders批量重排,再更新目标行:
export async function moveAccountGroup( id: DbAccountGroup['id'], targetId?: DbAccountGroup['id'] | null, ) { const groups = await all<...>( `SELECT id, sort_order FROM account_groups WHERE tombstone = 0 ORDER BY sort_order, id`, ); const { updates, sort_order } = shoveSortOrders(groups, targetId); await batchMessages(async () => { for (const info of updates) { await update('account_groups', info); } await update('account_groups', { id, sort_order }); }); }删除(L852-867)——清空成员引用 + 软删除(详见第五节):
export async function deleteAccountGroup(group: Pick<DbAccountGroup, 'id'>) { const accounts = await all<...>( `SELECT id FROM accounts WHERE account_group_id = ? AND tombstone = 0`, [group.id], ); await batchMessages(async () => { for (const account of accounts) { await update('accounts', { id: account.id, account_group_id: null }); } await delete_('account_groups', group.id); }); }四、账户归属与排序算法细节
4.1 账户如何归属分组
账户的分组归属通过账户更新 API 完成。在 packages/loot-core/src/server/accounts/app.ts(L98-L136)中,account-update处理器透传account_group_id字段并落库;查询账户时同样把account_group_id(缺失时归一为null)返回给前端。前端侧在AccountGroupsModal中选择分组时,实际调用的是useUpdateAccountMutation,payload 形如:
updateAccount.mutate({ account: { id: accountId, account_group_id: groupId }, });4.2 排序权重算法(shoveSortOrders)
moveAccountGroup依赖的shoveSortOrders是 Actual 在账户、分类等排序场景中的通用"插入并重排"算法:当把分组 A 移到分组 B 之前时,算法会为 A 与 B 之间被挤压到的所有分组重新分配sort_order,使它们均匀分布在区间内(保留 REAL 权重,避免频繁全表重写)。因此:
move(id, targetId):把id分组移到targetId分组之前;move(id, null):把id分组移到列表末尾。
4.3 测试用例佐证
packages/loot-core/src/server/account-groups/app.test.ts 完整覆盖了上述语义:
- 创建后按创建顺序返回,且
sort_order递增(expect(groups[0].sort_order).toBeLessThan(groups[1].sort_order)); - 大小写不敏感查重:
Savings与savings冲突,抛错/already exists/; - 删除后可复用同名:新创建的组获得全新 id;
- 重命名时拒绝改为其他已存在的名字,但允许改为自身名字的其他大小写(
SAVINGS→CARDS合法); - 移动语义:
move({id: cId, targetId: aId})后顺序变为[c, a, b];move({id: cId, targetId: null})后变为[a, b, c]; - 删除语义:tombstone 分组并仅清空该组内账户的引用,其他分组成员不受影响。
五、删除的 CRDT 语义:软删除与引用失效兜底
删除分组并非"物理删除",原因在于 Actual 的多人/多设备同步基于 CRDT:
deleteAccountGroup将account_groups行标记为 tombstone(代码中通过delete_写入墓碑),而不是DELETE;- 同时把组内所有账户的
account_group_id置null; - 源码注释明确说明:"Clearing member refs is best-effort under CRDT sync: a concurrent assignment on another device can win against these nulls, so consumers must always treat a ref to a missing/tombstoned group as ungrouped."—— 即并发场景下,另一台设备上的并发赋值可能"赢过"这次清空操作,因此所有消费端都必须把"指向已缺失/已删除分组的引用"当作未分组处理。
这正是前端 useSidebarAccountTree.ts 中getEffectiveGroupId的职责:
export function getEffectiveGroupId( account: AccountEntity, liveGroupIds: ReadonlySet<AccountGroupEntity['id']>, ): AccountGroupEntity['id'] | null { return account.account_group_id != null && liveGroupIds.has(account.account_group_id) ? account.account_group_id : null; }只有当账户的account_group_id仍然存在于"存活分组集合"中时才视为已分组,否则一律归入未分组桶——与后端注释要求的消费端约定完全一致。
六、前端数据流:TanStack Query 与 React Query 变更
6.1 查询层
packages/desktop-client/src/account-groups/queries.ts 定义查询键与请求函数:
export const accountGroupQueries = { all: () => ['account-groups'], lists: () => [...accountGroupQueries.all(), 'lists'], list: () => queryOptions<AccountGroupEntity[]>({ queryKey: [...accountGroupQueries.lists()], queryFn: () => send('account-groups-get'), placeholderData: [], staleTime: Infinity, }), };placeholderData: []保证首屏渲染时即使数据未返回也不会空白闪烁;staleTime: Infinity表示组数据在会话内视为长期稳定,减少不必要的重复请求。
6.2 变更层
packages/desktop-client/src/account-groups/mutations.ts 提供四个 React Query mutation:
useCreateAccountGroupMutation→send('account-group-create', { name })useUpdateAccountGroupMutation→send('account-group-update', { id, name })useDeleteAccountGroupMutation→send('account-group-delete', { id })useMoveAccountGroupMutation→send('account-group-move', { id, targetId })
每个 mutation 都遵循统一模式:成功时通过queryClient.invalidateQueries失效分组列表缓存(删除时还会连带失效accountQueries.lists(),因为账户的分组归属发生了变化);失败时console.error并派发 i18n 本地化的错误通知(addNotification,type: 'error',附带错误详情pre)。
6.3 管理 UI:AccountGroupsModal
AccountGroupsModal.tsx 是分组管理入口:用户在账户的组选择器中打开该 Modal,可以:
- 选择"未分组"(
onSelect(null)即清空account_group_id); - 通过
AccountGroupAutocomplete(AccountGroupAutocomplete.tsx,含NEW_ACCOUNT_GROUP_ID特殊项)选择已有分组或现场新建分组; - 每个分组行由 AccountGroupRow.tsx 渲染,支持重命名、删除与拖拽/按钮移动(复用上述 mutation);
- 数据加载中通过
AnimatedLoading展示占位动画,并依赖isPlaceholderData判断是否处于占位状态。
七、侧边栏树形视图:分组、折叠与统计信息
7.1 树构建逻辑
useSidebarAccountTree.ts 负责把扁平账户列表组装为分组树:
export function buildAccountSide( accounts: AccountEntity[], groups: AccountGroupEntity[], ): SidebarAccountSide { const liveGroupIds = new Set(groups.map(group => group.id)); const buckets: GroupBucket[] = []; const ungrouped = accounts.filter( account => getEffectiveGroupId(account, liveGroupIds) == null, ); if (ungrouped.length > 0) { buckets.push({ group: null, accounts: ungrouped, failedCount: ungrouped.filter(isAccountFailedSync).length, }); } for (const group of groups) { const members = accounts.filter( account => account.account_group_id === group.id, ); if (members.length > 0) { buckets.push({ group, accounts: members, failedCount: ... }); } } return { buckets, accountCount: accounts.length, failedCount: ... }; }结构要点:
SidebarAccountTree由onBudget(预算内)、offBudget(预算外)两个区段与closed(已关闭账户)组成;- 每个区段由一组
GroupBucket组成,每个 bucket 要么是"未分组"(group: null),要么是某个具体分组; - 空分组不显示(
if (members.length > 0)),避免侧边栏出现空壳分组; - 每个 bucket 聚合
failedCount(组内同步失败的账户数),供同步状态指示器使用; onBudget/offBudget/closed分别由useOnBudgetAccounts、useOffBudgetAccounts、useClosedAccounts三个 hooks 提供账户数据,组数据来自useAccountGroups。
7.2 折叠/展开与持久化
折叠状态由 useSidebarCollapseState.ts 管理,配合 AccountsSection.tsx 使用:
- 区段级折叠键:
'onbudget'、'offbudget'、'closed'; - 分组级折叠键:
bucketKey('on', bucket)/bucketKey('off', bucket),每个分组桶可独立开关; - 搜索时强制全部展开(
isSearching ? true : ...),保证搜索结果可见; - 提供"全部展开/全部折叠"的
toggleAll能力。
渲染侧,AccountGroupHeader.tsx 为每个分组头设置aria-expanded={isOpen}以支持无障碍访问,SidebarAccountGroup.tsx 负责分组内账户行的具体展示。
7.3 余额、净资产与同步状态
分组头的展示信息(余额合计、净资产、同步状态指示器)由对应组件与 hooks 协作完成:账户级同步失败判定来自#accounts/syncStatus的isAccountFailedSync,失败账户数在GroupBucket.failedCount中聚合;余额与净资产在分组维度求和后渲染于分组头(具体实现可继续阅读 AccountsSection.tsx 与 SidebarAccountGroup.tsx)。配套单测 useSidebarAccountTree.test.ts 覆盖了未分组桶生成、空分组隐藏、失效引用回退等关键分支。
八、端到端实操:如何上手账户分组
以下是基于当前仓库源码可以确认的完整使用路径:
- 开启实验性新侧边栏:该特性属于实验性 UI,需在应用的实验性功能/偏好设置中启用"新侧边栏"(参考 add-new-sidebar-account-list.md 的说明与 packages/desktop-client/src/components/sidebar/redesign 下的实现);
- 创建分组:在账户页打开"账户分组"管理界面(
AccountGroupsModal),输入分组名(如Savings、Cards),系统自动追加到分组列表末尾; - 分配账户:选择某个账户 → 在分组选择器中选择已有分组或新建分组,账户即被归入该组(
account_group_id落库); - 重命名:在分组行上重命名,名称大小写不敏感唯一,不能与其他分组重名;
- 排序:拖拽或通过移动操作调整分组顺序,
targetId=null时移至末尾; - 删除分组:删除后组内账户自动回到"未分组",组名可被后续重新使用;
- 在侧边栏查看:新侧边栏中"预算内/预算外"区段按分组桶渲染,可独立折叠/展开每个分组,分组头展示余额、净资产与同步失败指示。
九、常见问题与边界行为(FAQ)
| 问题 | 行为依据 |
|---|---|
| 能否创建两个同名分组? | 不能。UPPER(name)大小写不敏感唯一约束(db/index.ts),测试见 app.test.ts |
| 删除分组后账户会怎样? | 组内账户account_group_id置null,回到"未分组";组名可复用 |
| 组被删了但某些设备仍引用它? | 前端通过getEffectiveGroupId把指向已删除组的引用视为未分组(CRDT 兜底约定) |
| 空分组会显示在侧边栏吗? | 不会,buildAccountSide仅输出有成员的分组桶 |
| 分组操作可以撤销吗? | 可以,写操作均被mutator(undoable(...))包装 |
| 新侧边栏搜索时折叠状态如何? | 搜索时强制展开所有区段与分组,保证结果可见 |
十、延伸阅读
- 数据迁移:1787013118115_add_account_groups.sql
- 服务端处理器:app.ts | 测试:app.test.ts
- 数据库实现:packages/loot-core/src/server/db/index.ts
- 类型定义:account-group.ts
- 前端查询/变更:queries.ts | mutations.ts
- 管理界面:AccountGroupsModal.tsx | AccountGroupRow.tsx
- 侧边栏树:useSidebarAccountTree.ts | 测试:useSidebarAccountTree.test.ts
- 相关发布说明:add-new-sidebar-account-list.md
【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考