news 2026/9/12 23:54:28

Actual 预算软件账户分组管理:自定义账户组织、侧边栏树形视图与源码级解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Actual 预算软件账户分组管理:自定义账户组织、侧边栏树形视图与源码级解析

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(在实验性新侧边栏中重构账户列表,加入可折叠的账户分组、余额、净资产与同步状态指示器)

综合来看,该特性包含两大能力:

  1. 账户分组管理:创建、重命名、删除、排序自定义分组,并将账户归属到指定分组(或移出分组);
  2. 分组化侧边栏:在实验性新侧边栏中以"预算内 / 预算外"两个区段渲染树形结构,每组可独立折叠/展开,并展示组内余额合计、净资产与同步失败状态。

⚠️实验性说明:这两份说明均标记为 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-getAccountGroupEntity[]返回所有未删除分组,含idnamesort_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));
  • 大小写不敏感查重Savingssavings冲突,抛错/already exists/
  • 删除后可复用同名:新创建的组获得全新 id;
  • 重命名时拒绝改为其他已存在的名字,但允许改为自身名字的其他大小写SAVINGSCARDS合法);
  • 移动语义:move({id: cId, targetId: aId})后顺序变为[c, a, b]move({id: cId, targetId: null})后变为[a, b, c]
  • 删除语义:tombstone 分组并仅清空该组内账户的引用,其他分组成员不受影响。

五、删除的 CRDT 语义:软删除与引用失效兜底

删除分组并非"物理删除",原因在于 Actual 的多人/多设备同步基于 CRDT:

  1. deleteAccountGroupaccount_groups行标记为 tombstone(代码中通过delete_写入墓碑),而不是DELETE
  2. 同时把组内所有账户的account_group_idnull
  3. 源码注释明确说明:"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:

  • useCreateAccountGroupMutationsend('account-group-create', { name })
  • useUpdateAccountGroupMutationsend('account-group-update', { id, name })
  • useDeleteAccountGroupMutationsend('account-group-delete', { id })
  • useMoveAccountGroupMutationsend('account-group-move', { id, targetId })

每个 mutation 都遵循统一模式:成功时通过queryClient.invalidateQueries失效分组列表缓存(删除时还会连带失效accountQueries.lists(),因为账户的分组归属发生了变化);失败时console.error并派发 i18n 本地化的错误通知(addNotificationtype: '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: ... }; }

结构要点:

  • SidebarAccountTreeonBudget(预算内)、offBudget(预算外)两个区段与closed(已关闭账户)组成;
  • 每个区段由一组GroupBucket组成,每个 bucket 要么是"未分组"(group: null),要么是某个具体分组;
  • 空分组不显示if (members.length > 0)),避免侧边栏出现空壳分组;
  • 每个 bucket 聚合failedCount(组内同步失败的账户数),供同步状态指示器使用;
  • onBudget/offBudget/closed分别由useOnBudgetAccountsuseOffBudgetAccountsuseClosedAccounts三个 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/syncStatusisAccountFailedSync,失败账户数在GroupBucket.failedCount中聚合;余额与净资产在分组维度求和后渲染于分组头(具体实现可继续阅读 AccountsSection.tsx 与 SidebarAccountGroup.tsx)。配套单测 useSidebarAccountTree.test.ts 覆盖了未分组桶生成、空分组隐藏、失效引用回退等关键分支。

八、端到端实操:如何上手账户分组

以下是基于当前仓库源码可以确认的完整使用路径:

  1. 开启实验性新侧边栏:该特性属于实验性 UI,需在应用的实验性功能/偏好设置中启用"新侧边栏"(参考 add-new-sidebar-account-list.md 的说明与 packages/desktop-client/src/components/sidebar/redesign 下的实现);
  2. 创建分组:在账户页打开"账户分组"管理界面(AccountGroupsModal),输入分组名(如SavingsCards),系统自动追加到分组列表末尾;
  3. 分配账户:选择某个账户 → 在分组选择器中选择已有分组或新建分组,账户即被归入该组(account_group_id落库);
  4. 重命名:在分组行上重命名,名称大小写不敏感唯一,不能与其他分组重名;
  5. 排序:拖拽或通过移动操作调整分组顺序,targetId=null时移至末尾;
  6. 删除分组:删除后组内账户自动回到"未分组",组名可被后续重新使用;
  7. 在侧边栏查看:新侧边栏中"预算内/预算外"区段按分组桶渲染,可独立折叠/展开每个分组,分组头展示余额、净资产与同步失败指示。

九、常见问题与边界行为(FAQ)

问题行为依据
能否创建两个同名分组?不能。UPPER(name)大小写不敏感唯一约束(db/index.ts),测试见 app.test.ts
删除分组后账户会怎样?组内账户account_group_idnull,回到"未分组";组名可复用
组被删了但某些设备仍引用它?前端通过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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 23:53:03

OCR多源域对齐数据集预处理与词典引导解码实战

简介&#xff1a;本资源面向人工智能与机器学习方向的初学者及OCR算法实践者&#xff0c;聚焦文本识别任务中的数据预处理与特征工程关键环节&#xff0c;提供开箱即用的标准化数据集与工具支持。压缩包共2000个文件&#xff0c;主体为1993张PNG格式场景文字图像&#xff08;来…

作者头像 李华
网站建设 2026/9/12 23:50:17

如何用 Bevy 的 Picking 事件实现 3D 模型的点击拾取与拖拽交互?

如何用 Bevy 的 Picking 事件实现 3D 模型的点击拾取与拖拽交互&#xff1f; 【免费下载链接】bevy A refreshingly simple data-driven game engine built in Rust 项目地址: https://gitcode.com/GitHub_Trending/be/bevy 要在 Bevy 的 3D 场景中让模型可被指针点击、…

作者头像 李华
网站建设 2026/9/12 23:45:00

Go语言URL编码解码实战与性能优化

1. Go语言URL编码解码实战指南在Web开发中&#xff0c;URL编码是个看似简单却暗藏玄机的基础操作。最近处理一个API项目时&#xff0c;就遇到了因为编码不规范导致签名校验失败的坑。Go语言标准库中的net/url包提供了完整的解决方案&#xff0c;但QueryEscape和PathEscape的区别…

作者头像 李华