diff --git a/apps/api/test/update.test.ts b/apps/api/test/update.test.ts
index 8ebd59b..77bb8ad 100644
--- a/apps/api/test/update.test.ts
+++ b/apps/api/test/update.test.ts
@@ -432,3 +432,14 @@ test('group order keeps visible subsets stable and distinguishes unnamed and nam
assert.deepEqual(orderedGroups(['日常'], ['理财', '', '日常']), ['日常']);
assert.deepEqual(mergeGroupOrder(['日常', '理财'], ['理财', '隐藏']), ['日常', '理财', '隐藏']);
});
+
+test('drag insertion moves groups without swapping neighbours, mutating input or crossing bounds', async () => {
+ const { moveGroup } = await import('../../web/src/group-order');
+ const original = ['日常', '', '消费', '理财'];
+ assert.deepEqual(moveGroup(original, 3, 0), ['理财', '日常', '', '消费']);
+ assert.deepEqual(moveGroup(original, 0, 3), ['', '消费', '理财', '日常']);
+ assert.deepEqual(original, ['日常', '', '消费', '理财']);
+ assert.equal(moveGroup(original, 0, -1), original);
+ assert.equal(moveGroup(original, 3, 4), original);
+ assert.equal(moveGroup(original, 2, 2), original);
+});
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index d74022a..2b3a8c6 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,5 +1,13 @@
import { t as tr, useLocale, LanguagePicker } from './i18n';
-import { useState, useEffect, useRef, createElement, type FormEvent, type ReactNode } from 'react';
+import {
+ useState,
+ useEffect,
+ useRef,
+ createElement,
+ Fragment,
+ type FormEvent,
+ type ReactNode,
+} from 'react';
import {
LayoutDashboard,
CalendarDays,
@@ -39,6 +47,7 @@ import {
} from './api';
import './style.css';
import { GroupOrderContext } from './GroupOrderContext';
+import { GroupOrderList } from './GroupOrderList';
import { orderedGroups, mergeGroupOrder } from './group-order';
import { Calendar } from './Calendar';
import { SchedulePanel } from './SchedulePanel';
@@ -1357,59 +1366,67 @@ export default function App() {
(groupRanks.get(b.groupName || '') || 0)
: 0,
)
- .map((p) => (
-
+
+
+ {p.iconId ? (
+
+ ) : p.kind === 'asset' ? (
+
+ ) : p.kind === 'debt' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {p.name}
+ {p.groupName && {p.groupName}}
+
+ {categoryLabel(p)} ·{' '}
+ {p.kind === 'debt'
+ ? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')
+ : p.side === 'asset'
+ ? tr('资产')
+ : tr(
+ p.kind === 'account' && p.amount.startsWith('-')
+ ? '溢缴存款'
+ : '负债',
+ )}
+ {p.archived ? tr(' · 已归档') : ''}
+
+
+ {money(positionAmount(p), p.currency)}
+
+
+ {tr('折合')}{' '}
+ {p.converted === null
+ ? tr('缺少汇率')
+ : money(positionAmount(p, p.converted || '0'), user.baseCurrency)}
+
+
+
))}
) : (
@@ -2072,37 +2089,15 @@ export default function App() {
)}
{modal?.kind === 'group-order' && modal.groups && (
!busy && setModal(null)}>
- {tr('调整账户分组顺序,账户页和选择弹窗会同步使用。')}
-
- {modal.groups.map((g, index) => (
- -
- {g || tr('未分组')}
-
- {[-1, 1].map((offset) => (
- = modal.groups!.length
- }
- aria-label={(g || tr('未分组')) + ' ' + tr(offset < 0 ? '上移' : '下移')}
- onClick={() =>
- setModal((current) => {
- if (!current?.groups) return current;
- const next = [...current.groups];
- [next[index], next[index + offset]] = [next[index + offset], next[index]];
- return { ...current, groups: next };
- })
- }
- >
- {tr(offset < 0 ? '上移' : '下移')}
-
- ))}
-
-
- ))}
-
+
+ setModal((current) =>
+ current?.kind === 'group-order' ? { ...current, groups } : current,
+ )
+ }
+ />
setModal(null)}>
{tr('取消')}
diff --git a/apps/web/src/GroupOrderList.tsx b/apps/web/src/GroupOrderList.tsx
new file mode 100644
index 0000000..cd26678
--- /dev/null
+++ b/apps/web/src/GroupOrderList.tsx
@@ -0,0 +1,147 @@
+import { useEffect, useId, useRef, useState, type PointerEvent } from 'react';
+import { GripVertical } from 'lucide-react';
+import { t as tr } from './i18n';
+import { moveGroup } from './group-order';
+
+export function GroupOrderList({
+ groups,
+ onChange,
+ busy,
+}: {
+ groups: string[];
+ onChange: (groups: string[]) => void;
+ busy: boolean;
+}) {
+ const list = useRef(null),
+ help = useId();
+ const current = useRef({ groups, onChange });
+ current.current = { groups, onChange };
+ const drag = useRef<{ group: string; pointer: number; initial: string[]; y: number } | null>(
+ null,
+ );
+ const frame = useRef(null);
+ const [active, setActive] = useState(null),
+ [announcement, setAnnouncement] = useState('');
+ useEffect(
+ () => () => {
+ if (frame.current !== null) cancelAnimationFrame(frame.current);
+ },
+ [],
+ );
+ function move(from: number, to: number) {
+ const next = moveGroup(current.current.groups, from, to);
+ if (next === current.current.groups) return;
+ current.current.groups = next;
+ current.current.onChange(next);
+ setAnnouncement((next[to] || tr('未分组')) + ' · ' + (to + 1) + ' / ' + next.length);
+ }
+ function track() {
+ const value = drag.current,
+ container = list.current;
+ if (!value || !container) return;
+ const bounds = container.getBoundingClientRect();
+ if (value.y < bounds.top + 30) container.scrollTop -= 10;
+ else if (value.y > bounds.bottom - 30) container.scrollTop += 10;
+ const rows = Array.from(container.children);
+ let nearest = 0,
+ distance = Infinity;
+ rows.forEach((row, index) => {
+ const box = row.getBoundingClientRect(),
+ gap = Math.abs(value.y - (box.top + box.height / 2));
+ if (gap < distance) {
+ nearest = index;
+ distance = gap;
+ }
+ });
+ move(current.current.groups.indexOf(value.group), nearest);
+ frame.current = requestAnimationFrame(track);
+ }
+ function finish(cancel = false) {
+ const value = drag.current;
+ if (!value) return;
+ drag.current = null;
+ if (frame.current !== null) cancelAnimationFrame(frame.current);
+ frame.current = null;
+ if (cancel) {
+ current.current.groups = value.initial;
+ current.current.onChange(value.initial);
+ setAnnouncement(tr('已取消拖拽'));
+ }
+ setActive(null);
+ if (list.current?.hasPointerCapture(value.pointer))
+ list.current.releasePointerCapture(value.pointer);
+ }
+ function start(e: PointerEvent, group: string) {
+ if (busy || !e.isPrimary || e.button !== 0 || drag.current || groups.length < 2) return;
+ e.preventDefault();
+ e.currentTarget.focus();
+ drag.current = { group, pointer: e.pointerId, initial: [...groups], y: e.clientY };
+ list.current?.setPointerCapture(e.pointerId);
+ setActive(group);
+ frame.current = requestAnimationFrame(track);
+ }
+ return (
+ <>
+
+ {tr('拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。')}
+
+ {
+ if (drag.current?.pointer === e.pointerId) drag.current.y = e.clientY;
+ }}
+ onPointerUp={(e) => {
+ if (drag.current?.pointer === e.pointerId) finish();
+ }}
+ onPointerCancel={(e) => {
+ if (drag.current?.pointer === e.pointerId) finish(true);
+ }}
+ onLostPointerCapture={(e) => {
+ if (drag.current?.pointer === e.pointerId) finish(true);
+ }}
+ >
+ {groups.map((group, index) => (
+ -
+ start(e, group)}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape' && drag.current) {
+ e.preventDefault();
+ finish(true);
+ return;
+ }
+ if (drag.current || busy) return;
+ const targets: Record = {
+ ArrowUp: index - 1,
+ ArrowDown: index + 1,
+ Home: 0,
+ End: groups.length - 1,
+ };
+ if (e.key in targets) {
+ e.preventDefault();
+ move(index, targets[e.key]);
+ }
+ }}
+ >
+
+
+ {group || tr('未分组')}
+
+ {index + 1}
+
+
+ ))}
+
+
+ {announcement}
+
+ >
+ );
+}
diff --git a/apps/web/src/group-order.ts b/apps/web/src/group-order.ts
index 01542cd..fa4a6e1 100644
--- a/apps/web/src/group-order.ts
+++ b/apps/web/src/group-order.ts
@@ -6,3 +6,12 @@ export function orderedGroups(groups: string[], order: string[] = []) {
export function mergeGroupOrder(visible: string[], saved: string[]) {
return [...visible, ...saved.filter((g) => !visible.includes(g))];
}
+
+export function moveGroup(groups: string[], from: number, to: number) {
+ if (from === to || from < 0 || to < 0 || from >= groups.length || to >= groups.length)
+ return groups;
+ const next = [...groups],
+ [group] = next.splice(from, 1);
+ next.splice(to, 0, group);
+ return next;
+}
diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json
index ff9dc4a..27bba13 100644
--- a/apps/web/src/locales/en.json
+++ b/apps/web/src/locales/en.json
@@ -473,5 +473,9 @@
"下移": "Move down",
"分组顺序已保存": "Group order saved",
"分组不可重复": "Groups must be unique",
- "调整账户分组顺序,账户页和选择弹窗会同步使用。": "Change the group order for both accounts and account selection."
+ "调整账户分组顺序,账户页和选择弹窗会同步使用。": "Change the group order for both accounts and account selection.",
+ "拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。": "Drag a handle to reorder, or focus it and use the arrow keys. Save to apply the order to accounts and account selection.",
+ "账户分组顺序": "Account group order",
+ "拖动分组": "Drag group",
+ "已取消拖拽": "Drag cancelled"
}
diff --git a/apps/web/src/locales/zh-Hant.json b/apps/web/src/locales/zh-Hant.json
index 5e1e815..1d2daa6 100644
--- a/apps/web/src/locales/zh-Hant.json
+++ b/apps/web/src/locales/zh-Hant.json
@@ -473,5 +473,9 @@
"下移": "下移",
"分组顺序已保存": "分組順序已儲存",
"分组不可重复": "分組不可重複",
- "调整账户分组顺序,账户页和选择弹窗会同步使用。": "調整帳戶分組順序,帳戶頁和選擇彈窗會同步使用。"
+ "调整账户分组顺序,账户页和选择弹窗会同步使用。": "調整帳戶分組順序,帳戶頁和選擇彈窗會同步使用。",
+ "拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。": "拖動手柄調整順序,也可聚焦手柄後使用方向鍵。儲存後同步到帳戶頁和選擇彈窗。",
+ "账户分组顺序": "帳戶分組順序",
+ "拖动分组": "拖動分組",
+ "已取消拖拽": "已取消拖曳"
}
diff --git a/apps/web/src/style.css b/apps/web/src/style.css
index 9671961..9dff229 100644
--- a/apps/web/src/style.css
+++ b/apps/web/src/style.css
@@ -1497,24 +1497,84 @@ textarea,
.group-order-list {
list-style: none;
- padding: 0;
- max-height: 55vh;
+ padding: 4px;
+ margin: 16px -4px;
+ max-height: 50vh;
overflow-y: auto;
+ overscroll-behavior: contain;
}
.group-order-list li {
display: flex;
align-items: center;
- justify-content: space-between;
gap: 12px;
- padding: 10px 0;
- border-bottom: 1px solid #e5e9e5;
+ margin: 8px 0;
+ padding: 10px 12px;
+ border: 1px solid #e1e8e3;
+ border-radius: 12px;
+ background: #f8faf8;
}
-.group-order-list li > span {
+.group-order-list li.drag-active {
+ background: #e6f2eb;
+ border-color: #79a38a;
+ box-shadow: 0 3px 12px #234c3b18;
+}
+.group-order-name {
+ flex: 1;
min-width: 0;
overflow-wrap: anywhere;
+ font-weight: 500;
}
-.group-order-list li > div {
- display: flex;
- gap: 6px;
+.group-order-index {
+ font-size: 12px;
+ color: #718279;
+ font-variant-numeric: tabular-nums;
+}
+.group-drag-handle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ padding: 0;
flex-shrink: 0;
+ color: #718279;
+ border: 0;
+ background: transparent;
+ border-radius: 8px;
+ cursor: grab;
+ touch-action: none;
+}
+.group-drag-handle:hover {
+ background: #e8eee9;
+ color: #234c3b;
+}
+.group-drag-handle:focus-visible {
+ outline: 2px solid #39785b;
+ outline-offset: 2px;
+}
+.group-order-list.is-dragging,
+.group-order-list.is-dragging .group-drag-handle {
+ cursor: grabbing;
+}
+.group-order-help {
+ line-height: 1.6;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.position-grid > .account-group-title {
+ grid-column: 1 / -1;
+ margin: 8px 0 0;
+ font-size: 14px;
+ color: #456352;
}
diff --git a/docs/acceptance.md b/docs/acceptance.md
index 8face57..ec97099 100644
--- a/docs/acceptance.md
+++ b/docs/acceptance.md
@@ -78,3 +78,7 @@
## 2026-10-03 图库补充
内置图库扩充为 36 项,新增“抖音月付”和“东方财富”。真实数据库与已认证接口验证名称搜索、共享范围、图片读取和透明 PNG 规范;已有财务数据保持不变。资源来源与验收见 [更新说明](update-icons-2026-10-03.md)。
+
+## 2026-10-03 拖拽排序与分组分行
+
+33 项单元测试、前端类型检查和生产构建通过;真实数据库分组排序专项检查通过。浏览器验证实际拖拽、取消、键盘调整、保存刷新和弹窗联动,确认全部账户页按分组顺序分行显示。无新增数据库字段。详见 [更新说明](update-group-drag-2026-10-03.md)。
diff --git a/docs/account-group-rows-preview.png b/docs/account-group-rows-preview.png
new file mode 100644
index 0000000..c34e76d
Binary files /dev/null and b/docs/account-group-rows-preview.png differ
diff --git a/docs/group-drag-preview.png b/docs/group-drag-preview.png
new file mode 100644
index 0000000..36fac4b
Binary files /dev/null and b/docs/group-drag-preview.png differ
diff --git a/docs/update-group-drag-2026-10-03.md b/docs/update-group-drag-2026-10-03.md
new file mode 100644
index 0000000..44c082c
--- /dev/null
+++ b/docs/update-group-drag-2026-10-03.md
@@ -0,0 +1,26 @@
+# 2026-10-03 分组拖拽与账户分行
+
+## 使用方式
+
+分组排序弹窗移除上下移动按钮,改为带拖拽手柄、分组名称和序号的列表。按住手柄拖到目标位置即可调整顺序,拖拽时高亮当前分组,靠近列表边缘自动滚动。点击保存后,账户页、分组筛选及账户选择弹窗同步使用新顺序;取消弹窗不会保存。
+
+手柄支持鼠标、触屏和触控笔的 Pointer Events;44 像素操作区域与 touch-action 防止拖动手柄时页面误滚动。键盘可聚焦手柄后使用上下方向键调整、Home 置顶、End 置底;拖动过程中 Esc 或指针取消会恢复本次拖动前的顺序。辅助状态提示当前位置。
+
+“全部账户”按保存顺序显示分组标题;标题横跨卡片网格,因此每个分组从新行开始。相同分组内仍使用响应式卡片排列;选择单一分组时只显示该组账户。未分组仍为独立分组。
+
+复用现有 User.accountGroupOrder、settings 和备份协议,未增加数据库表或字段,不改变余额和财务记录,无需新增数据库迁移或依赖。
+
+## 验证
+
+- 前端类型检查和生产构建通过;33 项单元测试通过,新增重排测试覆盖跨多项插入、保持相邻顺序、不修改输入及边界处理。
+- 真实 MySQL 分组排序专项检查通过:顺序持久化、用户隔离、非法输入拒绝、ZIP 恢复及旧备份兼容。此次未重复执行其他 12 项后端专项,上轮完整 13 项回归记录保留。
+- 浏览器专用虚构账号:将“日常”从末尾实际拖到首位;取消后重新打开仍为原顺序;验证 Home、End 和方向键移动;再次拖拽并保存后刷新,账户页与选择弹窗保持“日常、未分组、消费、理财”顺序。
+- 浏览器读取各分组标题和卡片的实际位置,确认不同组的卡片纵向分离,未共享同一行。
+- 已检查桌面截图;触屏与触控笔使用统一事件实现,未在真实手机硬件上实测。
+- 验收临时账号和脚本清理,保留用户原有 Vite 配置改动;仅本地 Git 提交,不 push。
+
+完成时间:2026-10-03 17:04。
+
+
+
+
diff --git a/docs/update-group-order-2026-10-03.md b/docs/update-group-order-2026-10-03.md
index 857b043..03a419a 100644
--- a/docs/update-group-order-2026-10-03.md
+++ b/docs/update-group-order-2026-10-03.md
@@ -1,5 +1,7 @@
# 2026-10-03 账户分组排序
+当前排序界面已改为拖拽;最新操作及全部账户分行行为见 [后续更新](update-group-drag-2026-10-03.md)。以下保留首轮验收记录。
+
## 使用方式
账户页点击“分组排序”,使用“上移/下移”调整分组后保存。顺序同步用于账户分组筛选、全部账户卡片、分组输入建议以及转账、借贷操作和定时计划中的账户选择弹窗。刷新网页或重新登录后仍保留;取消不会保存。只有一个分组时排序按钮禁用,首尾按钮防止越界。
diff --git a/update.md b/update.md
index 69081c5..f002b91 100644
--- a/update.md
+++ b/update.md
@@ -35,6 +35,8 @@
- ~~负债(如信用卡)也可以显示为正数变成存款~~ — 已完成并验证:2026-10-03 12:05
- ~~分组可以排序~~ — 已完成并验证:2026-10-03 12:27
- ~~“抖音月付”“东方财富”添加图标~~ — 已完成并验证:2026-10-03 12:36
+- ~~优化分组排序UI,使用拖拽排序~~ — 已完成并验证:2026-10-03 17:04
+- ~~在全部账号时,也要按照分组顺序显示,并且不太组不要在同一行~~ — 已完成并验证:2026-10-03 17:04
验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。
@@ -49,3 +51,5 @@
分组排序验收完成:2026-10-03 12:27。32 项单元测试、13 项真实 MySQL 集成检查、类型检查与构建通过;14 个迁移已应用,9 张表与 89 个字段注释已核验。账户页、筛选与选择弹窗顺序同步并持久化,备份恢复包含分组顺序。详见 [更新说明](docs/update-group-order-2026-10-03.md)。
图库补充验收完成:2026-10-03 12:36。新增“抖音月付”和“东方财富”图标,36 项内置图库已初始化,两个图标的已认证搜索和图片读取通过。详见 [更新说明](docs/update-icons-2026-10-03.md)。
+
+分组界面验收完成:2026-10-03 17:04。支持拖拽与键盘调整顺序,全部账户按分组顺序分行显示;33 项单元测试、前端类型检查、生产构建和真实数据库分组排序专项检查通过。详见 [更新说明](docs/update-group-drag-2026-10-03.md)。