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

发布时间:2026/9/12 23:54:44
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在实验性新侧边栏中重构账户列表加入可折叠的账户分组、余额、净资产与同步状态指示器综合来看该特性包含两大能力账户分组管理创建、重命名、删除、排序自定义分组并将账户归属到指定分组或移出分组分组化侧边栏在实验性新侧边栏中以预算内 / 预算外两个区段渲染树形结构每组可独立折叠/展开并展示组内余额合计、净资产与同步失败状态。⚠️实验性说明这两份说明均标记为 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_orderREAL 排序权重默认递增、tombstone软删除标记CRDT 同步场景下不物理删除行accounts表新增account_group_id外键列默认NULL表示未分组整体采用**软删除tombstone**而非硬删除与 Actual 的 CRDT 同步机制保持一致见下文删除语义。2.2 实体类型定义前端与核心层共享的实体类型定义在 packages/loot-core/src/types/models/account-group.tsexport 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 createAppAccountGroupsHandlers(); 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_orderaccount-group-create{ name }新分组id创建分组并追加到末尾account-group-update{ id, name }void重命名分组account-group-delete{ id }被删分组id软删除并清空成员引用account-group-move{ id, targetId }void移动到目标分组之前targetIdnull时追加到末尾3.2 数据库层实现数据库操作位于 packages/loot-core/src/server/db/index.tsL787-L867查询L787-791——按sort_order, id排序仅返回未删除行export function getAccountGroups() { return allDbAccountGroup( SELECT * FROM account_groups WHERE tombstone 0 ORDER BY sort_order, id, ); }创建L793-819——三步查重 → 计算排序权重 → 插入export async function insertAccountGroup( group: WithRequiredPartialDbAccountGroup, name, ): PromiseDbAccountGroup[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: WithRequiredPartialDbAccountGroup, 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: PickDbAccountGroup, 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.tsL98-L136中account-update处理器透传account_group_id字段并落库查询账户时同样把account_group_id缺失时归一为null返回给前端。前端侧在AccountGroupsModal中选择分组时实际调用的是useUpdateAccountMutationpayload 形如updateAccount.mutate({ account: { id: accountId, account_group_id: groupId }, });4.2 排序权重算法shoveSortOrdersmoveAccountGroup依赖的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 的多人/多设备同步基于 CRDTdeleteAccountGroup将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: ReadonlySetAccountGroupEntity[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: () queryOptionsAccountGroupEntity[]({ 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 mutationuseCreateAccountGroupMutation→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 本地化的错误通知addNotificationtype: error附带错误详情pre。6.3 管理 UIAccountGroupsModalAccountGroupsModal.tsx 是分组管理入口用户在账户的组选择器中打开该 Modal可以选择未分组onSelect(null)即清空account_group_id通过AccountGroupAutocompleteAccountGroupAutocomplete.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落库重命名在分组行上重命名名称大小写不敏感唯一不能与其他分组重名排序拖拽或通过移动操作调整分组顺序targetIdnull时移至末尾删除分组删除后组内账户自动回到未分组组名可被后续重新使用在侧边栏查看新侧边栏中预算内/预算外区段按分组桶渲染可独立折叠/展开每个分组分组头展示余额、净资产与同步失败指示。九、常见问题与边界行为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),仅供参考