feat: drag account groups and separate grouped card rows

This commit is contained in:
陈煜 committed 2026-10-03 17:12:04 +08:00
1 parent 59837cf09c
commit 9a133e82e9
13 files changed
+360 -94

No files matched your search

+11
View File
@@ -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);
});
+78 -83
View File
@@ -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) => (
<button
className="position-card"
key={p.id}
onClick={() =>
page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p })
: setSelected(p.id)
}
>
<div>
<span className="position-icon">
{p.iconId ? (
<img src={iconUrl(p.iconId)} alt={tr('账户图标')} />
) : p.kind === 'asset' ? (
<Landmark />
) : p.kind === 'debt' ? (
<HandCoins />
) : (
<Wallet />
)}
</span>
<ArrowUpRight size={18} />
</div>
<h3>{p.name}</h3>
{p.groupName && <span className="badge">{p.groupName}</span>}
<p>
{categoryLabel(p)} ·{' '}
{p.kind === 'debt'
? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')
: p.side === 'asset'
? tr('资产')
: tr(
p.kind === 'account' && p.amount.startsWith('-')
? '溢缴存款'
: '负债',
)}
{p.archived ? tr(' · 已归档') : ''}
</p>
<strong
className={
positionAmount(p).startsWith('-') ? 'danger-text' : undefined
.map((p, index, rows) => (
<Fragment key={p.id}>
{page === 'account' &&
accountGroup === null &&
(index === 0 ||
(rows[index - 1].groupName || '') !== (p.groupName || '')) && (
<h3 className="account-group-title">{p.groupName || tr('未分组')}</h3>
)}
<button
className="position-card"
key={p.id}
onClick={() =>
page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p })
: setSelected(p.id)
}
>
{money(positionAmount(p), p.currency)}
</strong>
<small>
{tr('折合')}{' '}
{p.converted === null
? tr('缺少汇率')
: money(positionAmount(p, p.converted || '0'), user.baseCurrency)}
</small>
</button>
<div>
<span className="position-icon">
{p.iconId ? (
<img src={iconUrl(p.iconId)} alt={tr('账户图标')} />
) : p.kind === 'asset' ? (
<Landmark />
) : p.kind === 'debt' ? (
<HandCoins />
) : (
<Wallet />
)}
</span>
<ArrowUpRight size={18} />
</div>
<h3>{p.name}</h3>
{p.groupName && <span className="badge">{p.groupName}</span>}
<p>
{categoryLabel(p)} ·{' '}
{p.kind === 'debt'
? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')
: p.side === 'asset'
? tr('资产')
: tr(
p.kind === 'account' && p.amount.startsWith('-')
? '溢缴存款'
: '负债',
)}
{p.archived ? tr(' · 已归档') : ''}
</p>
<strong
className={
positionAmount(p).startsWith('-') ? 'danger-text' : undefined
}
>
{money(positionAmount(p), p.currency)}
</strong>
<small>
{tr('折合')}{' '}
{p.converted === null
? tr('缺少汇率')
: money(positionAmount(p, p.converted || '0'), user.baseCurrency)}
</small>
</button>
</Fragment>
))}
</div>
) : (
@@ -2072,37 +2089,15 @@ export default function App() {
)}
{modal?.kind === 'group-order' && modal.groups && (
<Modal title={tr('分组排序')} close={() => !busy && setModal(null)}>
<p className="muted">{tr('调整账户分组顺序,账户页和选择弹窗会同步使用。')}</p>
<ol className="group-order-list">
{modal.groups.map((g, index) => (
<li key={g}>
<span>{g || tr('未分组')}</span>
<div>
{[-1, 1].map((offset) => (
<button
key={offset}
type="button"
className="secondary"
disabled={
busy || index + offset < 0 || index + 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 ? '上移' : '下移')}
</button>
))}
</div>
</li>
))}
</ol>
<GroupOrderList
groups={modal.groups}
busy={busy}
onChange={(groups) =>
setModal((current) =>
current?.kind === 'group-order' ? { ...current, groups } : current,
)
}
/>
<div className="modal-actions">
<button className="secondary" disabled={busy} onClick={() => setModal(null)}>
{tr('取消')}
+147
View File
@@ -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<HTMLOListElement>(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<number | null>(null);
const [active, setActive] = useState<string | null>(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<HTMLButtonElement>, 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 (
<>
<p id={help} className="muted group-order-help">
{tr('拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。')}
</p>
<ol
ref={list}
className={'group-order-list' + (active !== null ? ' is-dragging' : '')}
aria-label={tr('账户分组顺序')}
onPointerMove={(e) => {
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) => (
<li key={group} className={active === group ? 'drag-active' : undefined}>
<button
type="button"
className="group-drag-handle"
aria-label={tr('拖动分组') + ' ' + (group || tr('未分组'))}
aria-describedby={help}
disabled={busy || groups.length < 2}
onPointerDown={(e) => start(e, group)}
onKeyDown={(e) => {
if (e.key === 'Escape' && drag.current) {
e.preventDefault();
finish(true);
return;
}
if (drag.current || busy) return;
const targets: Record<string, number> = {
ArrowUp: index - 1,
ArrowDown: index + 1,
Home: 0,
End: groups.length - 1,
};
if (e.key in targets) {
e.preventDefault();
move(index, targets[e.key]);
}
}}
>
<GripVertical size={20} aria-hidden="true" />
</button>
<span className="group-order-name">{group || tr('未分组')}</span>
<span className="group-order-index" aria-hidden="true">
{index + 1}
</span>
</li>
))}
</ol>
<span className="sr-only" role="status" aria-live="polite">
{announcement}
</span>
</>
);
}
+9
View File
@@ -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;
}
+5 -1
View File
@@ -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"
}
+5 -1
View File
@@ -473,5 +473,9 @@
"下移": "下移",
"分组顺序已保存": "分組順序已儲存",
"分组不可重复": "分組不可重複",
"调整账户分组顺序,账户页和选择弹窗会同步使用。": "調整帳戶分組順序,帳戶頁和選擇彈窗會同步使用。"
"调整账户分组顺序,账户页和选择弹窗会同步使用。": "調整帳戶分組順序,帳戶頁和選擇彈窗會同步使用。",
"拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。": "拖動手柄調整順序,也可聚焦手柄後使用方向鍵。儲存後同步到帳戶頁和選擇彈窗。",
"账户分组顺序": "帳戶分組順序",
"拖动分组": "拖動分組",
"已取消拖拽": "已取消拖曳"
}
+69 -9
View File
@@ -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;
}
+4
View File
@@ -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)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+26
View File
@@ -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。
![拖拽分组排序](group-drag-preview.png)
![全部账户按分组分行](account-group-rows-preview.png)
+2
View File
@@ -1,5 +1,7 @@
# 2026-10-03 账户分组排序
当前排序界面已改为拖拽;最新操作及全部账户分行行为见 [后续更新](update-group-drag-2026-10-03.md)。以下保留首轮验收记录。
## 使用方式
账户页点击“分组排序”,使用“上移/下移”调整分组后保存。顺序同步用于账户分组筛选、全部账户卡片、分组输入建议以及转账、借贷操作和定时计划中的账户选择弹窗。刷新网页或重新登录后仍保留;取消不会保存。只有一个分组时排序按钮禁用,首尾按钮防止越界。
+4
View File
@@ -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)。