feat: add sorting and pinning functionality for accounts and sites
- Introduced `isPinned` and `sortOrder` fields in the `accounts` and `sites` tables to allow users to pin and sort their accounts and sites. - Updated API routes to handle pinning and sorting operations for accounts and sites. - Enhanced the frontend to support sorting by balance and custom order, as well as toggling the pin status of accounts and sites. - Added tests for sorting and reordering logic to ensure correct functionality. - Updated documentation to reflect changes in token management and new features.
This commit is contained in:
@@ -1196,6 +1196,45 @@ body {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.accounts-actions-col {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.accounts-actions-cell {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.accounts-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sites-actions-col {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.sites-actions-cell {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.sites-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sites-url-cell {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.sites-url-link {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ===== Status Badge ===== */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
@@ -1454,6 +1493,16 @@ textarea {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.accounts-page-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sites-page-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ===== Chart Placeholder ===== */
|
||||
.chart-container {
|
||||
background: var(--color-bg-card);
|
||||
@@ -2681,8 +2730,103 @@ textarea {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.accounts-page-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.accounts-page-actions .btn-soft-primary,
|
||||
.accounts-page-actions .btn-primary {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.accounts-actions-col {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.accounts-table th,
|
||||
.accounts-table td {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.accounts-row-actions .btn-link {
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sites-actions-col {
|
||||
min-width: 165px;
|
||||
}
|
||||
|
||||
.sites-table th,
|
||||
.sites-table td {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.sites-row-actions .btn-link {
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Responsive Adjustments ===== */
|
||||
@media (max-width: 900px) {
|
||||
.accounts-page-actions {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.accounts-page-actions .accounts-sort-select {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.accounts-page-actions .btn-soft-primary,
|
||||
.accounts-page-actions .btn-primary {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.accounts-actions-col {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.accounts-table th:nth-child(5),
|
||||
.accounts-table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sites-page-actions {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.sites-page-actions .sites-sort-select {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.sites-page-actions .btn-primary {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sites-actions-col {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.sites-table th:nth-child(2),
|
||||
.sites-table td:nth-child(2),
|
||||
.sites-table th:nth-child(6),
|
||||
.sites-table td:nth-child(6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
|
||||
export default function Accounts() {
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [sites, setSites] = useState<any[]>([]);
|
||||
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
||||
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' });
|
||||
@@ -24,6 +26,11 @@ export default function Accounts() {
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const sortedAccounts = useMemo(
|
||||
() => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0),
|
||||
[accounts, sortMode],
|
||||
);
|
||||
|
||||
const handleLoginAdd = async () => {
|
||||
if (!loginForm.siteId || !loginForm.username || !loginForm.password) return;
|
||||
setSaving(true);
|
||||
@@ -171,11 +178,55 @@ export default function Accounts() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePin = async (account: any) => {
|
||||
const key = `pin-toggle-${account.id}`;
|
||||
const nextPinned = !account.isPinned;
|
||||
setActionLoading((s) => ({ ...s, [key]: true }));
|
||||
try {
|
||||
await api.updateAccount(account.id, { isPinned: nextPinned });
|
||||
toast.success(nextPinned ? '账号已置顶' : '账号已取消置顶');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '切换账号置顶失败');
|
||||
} finally {
|
||||
setActionLoading((s) => ({ ...s, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCustomOrder = async (account: any, direction: 'up' | 'down') => {
|
||||
const key = `reorder-${account.id}`;
|
||||
const updates = buildCustomReorderUpdates(accounts, account.id, direction);
|
||||
if (updates.length === 0) return;
|
||||
|
||||
setActionLoading((s) => ({ ...s, [key]: true }));
|
||||
try {
|
||||
await Promise.all(updates.map((update) => api.updateAccount(update.id, { sortOrder: update.sortOrder })));
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '更新账号排序失败');
|
||||
} finally {
|
||||
setActionLoading((s) => ({ ...s, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{tr('账号管理')}</h2>
|
||||
<div className="page-actions">
|
||||
<div className="page-actions accounts-page-actions">
|
||||
<div className="accounts-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={sortMode}
|
||||
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
|
||||
options={[
|
||||
{ value: 'custom', label: '自定义排序' },
|
||||
{ value: 'balance-desc', label: '余额高到低' },
|
||||
{ value: 'balance-asc', label: '余额低到高' },
|
||||
]}
|
||||
placeholder="自定义排序"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到')} disabled={actionLoading['checkin-all']}
|
||||
className="btn btn-soft-primary">
|
||||
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')}
|
||||
@@ -342,7 +393,7 @@ export default function Accounts() {
|
||||
{/* Accounts Table */}
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
{accounts.length > 0 ? (
|
||||
<table className="data-table">
|
||||
<table className="data-table accounts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
@@ -351,11 +402,11 @@ export default function Accounts() {
|
||||
<th>余额</th>
|
||||
<th>已用</th>
|
||||
<th>签到</th>
|
||||
<th style={{ textAlign: 'right' }}>操作</th>
|
||||
<th className="accounts-actions-col" style={{ textAlign: 'right' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accounts.map((a: any, i: number) => (
|
||||
{sortedAccounts.map((a: any, i: number) => (
|
||||
<tr key={a.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
|
||||
<td style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>{a.username || '未命名'}</td>
|
||||
<td>
|
||||
@@ -427,8 +478,33 @@ export default function Accounts() {
|
||||
: (a.checkinEnabled ? '开启' : '关闭')}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<td className="accounts-actions-cell" style={{ textAlign: 'right' }}>
|
||||
<div className="accounts-row-actions">
|
||||
<button
|
||||
onClick={() => handleTogglePin(a)}
|
||||
disabled={!!actionLoading[`pin-toggle-${a.id}`]}
|
||||
className={`btn btn-link ${a.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||
>
|
||||
{actionLoading[`pin-toggle-${a.id}`] ? <span className="spinner spinner-sm" /> : (a.isPinned ? '取消置顶' : '置顶')}
|
||||
</button>
|
||||
{sortMode === 'custom' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(a, 'up')}
|
||||
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(a, 'down')}
|
||||
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => withLoading(`refresh-${a.id}`, () => api.refreshBalance(a.id), '余额已刷新')} disabled={actionLoading[`refresh-${a.id}`]}
|
||||
className="btn btn-link btn-link-primary">
|
||||
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||
|
||||
+97
-10
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
import {
|
||||
buildSiteSaveAction,
|
||||
emptySiteForm,
|
||||
@@ -18,6 +20,9 @@ type SiteRow = {
|
||||
platform?: string;
|
||||
status?: string;
|
||||
apiKey?: string;
|
||||
isPinned?: boolean;
|
||||
sortOrder?: number;
|
||||
totalBalance?: number;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
@@ -32,12 +37,15 @@ const platformColors: Record<string, string> = {
|
||||
|
||||
export default function Sites() {
|
||||
const [sites, setSites] = useState<SiteRow[]>([]);
|
||||
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
||||
const [editor, setEditor] = useState<SiteEditorState | null>(null);
|
||||
const [form, setForm] = useState<SiteForm>(emptySiteForm());
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState<number | null>(null);
|
||||
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
|
||||
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
|
||||
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const isEditing = editor?.mode === 'edit';
|
||||
@@ -56,6 +64,11 @@ export default function Sites() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const sortedSites = useMemo(
|
||||
() => sortItemsForDisplay(sites, sortMode, (site) => site.totalBalance || 0),
|
||||
[sites, sortMode],
|
||||
);
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditor(null);
|
||||
setForm(emptySiteForm());
|
||||
@@ -155,13 +168,57 @@ export default function Sites() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePin = async (site: SiteRow) => {
|
||||
const nextPinned = !site.isPinned;
|
||||
setPinningSiteId(site.id);
|
||||
try {
|
||||
await api.updateSite(site.id, { isPinned: nextPinned });
|
||||
toast.success(nextPinned ? `站点 "${site.name}" 已置顶` : `站点 "${site.name}" 已取消置顶`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '切换置顶失败');
|
||||
} finally {
|
||||
setPinningSiteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCustomOrder = async (site: SiteRow, direction: 'up' | 'down') => {
|
||||
const updates = buildCustomReorderUpdates(sites, site.id, direction);
|
||||
if (updates.length === 0) return;
|
||||
|
||||
setOrderingSiteId(site.id);
|
||||
try {
|
||||
await Promise.all(updates.map((update) => api.updateSite(update.id, { sortOrder: update.sortOrder })));
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '更新排序失败');
|
||||
} finally {
|
||||
setOrderingSiteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{tr('站点管理')}</h2>
|
||||
<button onClick={openAdd} className="btn btn-primary">
|
||||
{isAdding ? '取消' : '+ 添加站点'}
|
||||
</button>
|
||||
<div className="page-actions sites-page-actions">
|
||||
<div className="sites-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={sortMode}
|
||||
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
|
||||
options={[
|
||||
{ value: 'custom', label: '自定义排序' },
|
||||
{ value: 'balance-desc', label: '余额高到低' },
|
||||
{ value: 'balance-asc', label: '余额低到高' },
|
||||
]}
|
||||
placeholder="自定义排序"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={openAdd} className="btn btn-primary">
|
||||
{isAdding ? '取消' : '+ 添加站点'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editor && (
|
||||
@@ -265,19 +322,20 @@ export default function Sites() {
|
||||
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
{sites.length > 0 ? (
|
||||
<table className="data-table">
|
||||
<table className="data-table sites-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>URL</th>
|
||||
<th>总余额</th>
|
||||
<th>状态</th>
|
||||
<th>平台</th>
|
||||
<th>创建时间</th>
|
||||
<th style={{ textAlign: 'right' }}>操作</th>
|
||||
<th className="sites-actions-col" style={{ textAlign: 'right' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sites.map((site, i) => (
|
||||
{sortedSites.map((site, i) => (
|
||||
<tr key={site.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<a
|
||||
@@ -292,11 +350,12 @@ export default function Sites() {
|
||||
{site.name}
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ maxWidth: 300 }}>
|
||||
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sites-url-link"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
@@ -308,6 +367,9 @@ export default function Sites() {
|
||||
{site.url}
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
${(site.totalBalance || 0).toFixed(2)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
|
||||
{site.status === 'disabled' ? '禁用' : '启用'}
|
||||
@@ -335,8 +397,33 @@ export default function Sites() {
|
||||
{formatDateTimeLocal(site.createdAt)}
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<td className="sites-actions-cell" style={{ textAlign: 'right' }}>
|
||||
<div className="sites-row-actions">
|
||||
<button
|
||||
onClick={() => handleTogglePin(site)}
|
||||
disabled={pinningSiteId === site.id}
|
||||
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||
>
|
||||
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
|
||||
</button>
|
||||
{sortMode === 'custom' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(site, 'up')}
|
||||
disabled={orderingSiteId === site.id}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(site, 'down')}
|
||||
disabled={orderingSiteId === site.id}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEdit(site)}
|
||||
className="btn btn-link btn-link-primary"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './listSorting.js';
|
||||
|
||||
type Item = {
|
||||
id: number;
|
||||
isPinned?: boolean | null;
|
||||
sortOrder?: number | null;
|
||||
balance?: number | null;
|
||||
};
|
||||
|
||||
function ids(items: Item[]): number[] {
|
||||
return items.map((item) => item.id);
|
||||
}
|
||||
|
||||
describe('sortItemsForDisplay', () => {
|
||||
const base: Item[] = [
|
||||
{ id: 1, isPinned: false, sortOrder: 2, balance: 5 },
|
||||
{ id: 2, isPinned: true, sortOrder: 1, balance: 1 },
|
||||
{ id: 3, isPinned: false, sortOrder: 0, balance: 20 },
|
||||
{ id: 4, isPinned: true, sortOrder: 0, balance: 10 },
|
||||
];
|
||||
|
||||
it('keeps pinned items first in custom mode', () => {
|
||||
const mode: SortMode = 'custom';
|
||||
const sorted = sortItemsForDisplay(base, mode, (item) => item.balance || 0);
|
||||
expect(ids(sorted)).toEqual([4, 2, 3, 1]);
|
||||
});
|
||||
|
||||
it('sorts by balance desc while keeping pinned items first', () => {
|
||||
const sorted = sortItemsForDisplay(base, 'balance-desc', (item) => item.balance || 0);
|
||||
expect(ids(sorted)).toEqual([4, 2, 3, 1]);
|
||||
});
|
||||
|
||||
it('sorts by balance asc while keeping pinned items first', () => {
|
||||
const sorted = sortItemsForDisplay(base, 'balance-asc', (item) => item.balance || 0);
|
||||
expect(ids(sorted)).toEqual([2, 4, 1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCustomReorderUpdates', () => {
|
||||
const list: Item[] = [
|
||||
{ id: 10, isPinned: true, sortOrder: 0 },
|
||||
{ id: 11, isPinned: true, sortOrder: 1 },
|
||||
{ id: 20, isPinned: false, sortOrder: 0 },
|
||||
{ id: 21, isPinned: false, sortOrder: 1 },
|
||||
];
|
||||
|
||||
it('reorders only inside the same pinned group', () => {
|
||||
const updates = buildCustomReorderUpdates(list, 20, 'up');
|
||||
// First unpinned item cannot move above pinned group.
|
||||
expect(updates).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns normalized sortOrder updates after moving down', () => {
|
||||
const updates = buildCustomReorderUpdates(list, 20, 'down');
|
||||
expect(updates).toEqual([
|
||||
{ id: 21, sortOrder: 0 },
|
||||
{ id: 20, sortOrder: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
export type SortMode = 'custom' | 'balance-desc' | 'balance-asc';
|
||||
|
||||
type SortableBase = {
|
||||
id: number;
|
||||
isPinned?: boolean | null;
|
||||
sortOrder?: number | null;
|
||||
};
|
||||
|
||||
export function sortItemsForDisplay<T extends SortableBase>(
|
||||
items: T[],
|
||||
mode: SortMode,
|
||||
getBalance: (item: T) => number,
|
||||
): T[] {
|
||||
const list = [...items];
|
||||
const customComparator = (a: T, b: T) => {
|
||||
const aPinned = a.isPinned ? 1 : 0;
|
||||
const bPinned = b.isPinned ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
|
||||
const aOrder = Number.isFinite(a.sortOrder as number) ? Number(a.sortOrder) : Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = Number.isFinite(b.sortOrder as number) ? Number(b.sortOrder) : Number.MAX_SAFE_INTEGER;
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
return a.id - b.id;
|
||||
};
|
||||
|
||||
if (mode === 'custom') {
|
||||
return list.sort(customComparator);
|
||||
}
|
||||
|
||||
return list.sort((a, b) => {
|
||||
const aPinned = a.isPinned ? 1 : 0;
|
||||
const bPinned = b.isPinned ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
|
||||
const aBalance = Number.isFinite(getBalance(a)) ? getBalance(a) : 0;
|
||||
const bBalance = Number.isFinite(getBalance(b)) ? getBalance(b) : 0;
|
||||
if (aBalance !== bBalance) {
|
||||
return mode === 'balance-desc' ? bBalance - aBalance : aBalance - bBalance;
|
||||
}
|
||||
|
||||
return customComparator(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCustomReorderUpdates<T extends SortableBase>(
|
||||
items: T[],
|
||||
targetId: number,
|
||||
direction: 'up' | 'down',
|
||||
): Array<{ id: number; sortOrder: number }> {
|
||||
const sorted = sortItemsForDisplay(items, 'custom', () => 0);
|
||||
const target = sorted.find((item) => item.id === targetId);
|
||||
if (!target) return [];
|
||||
|
||||
const targetPinned = !!target.isPinned;
|
||||
const group = sorted.filter((item) => !!item.isPinned === targetPinned);
|
||||
const index = group.findIndex((item) => item.id === targetId);
|
||||
if (index < 0) return [];
|
||||
|
||||
const swapIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= group.length) return [];
|
||||
|
||||
const next = [...group];
|
||||
const temp = next[index];
|
||||
next[index] = next[swapIndex];
|
||||
next[swapIndex] = temp;
|
||||
|
||||
const updates: Array<{ id: number; sortOrder: number }> = [];
|
||||
next.forEach((item, idx) => {
|
||||
const prev = Number.isFinite(item.sortOrder as number) ? Number(item.sortOrder) : Number.MAX_SAFE_INTEGER;
|
||||
if (prev !== idx) {
|
||||
updates.push({ id: item.id, sortOrder: idx });
|
||||
}
|
||||
});
|
||||
|
||||
return updates;
|
||||
}
|
||||
Reference in New Issue
Block a user