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:
cita
2026-02-28 23:45:30 +08:00
parent 08eaf11ea2
commit 44af9a91cf
17 changed files with 647 additions and 67 deletions
+144
View File
@@ -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;
}
+83 -7
View File
@@ -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
View File
@@ -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"
+61
View File
@@ -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 },
]);
});
});
+76
View File
@@ -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;
}