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

+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;
}