为后台项目工程新增按需全量同步时间排序 (#546)
Project CI / AI game creator shell Rust smoke (push) Successful in 1m44s
Project CI / AI game creator shell Rust crates (push) Successful in 1m9s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Successful in 1m44s
Project CI / AI game creator shell Rust crates (push) Successful in 1m9s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
新增按钮读取所选渠道全部项目并按同步时间降序、用户与项目 ID 升序展示 复用本地分页并支持全量刷新、恢复默认顺序、进度提示与取消读取 为全量读取增加超时、数量和游标边界,失败时保留原结果 补充三项关键交互测试并同步技术方案、运维说明与共享记忆 Reviewed-on: #546
This commit was merged in pull request #546.
This commit is contained in:
@@ -16,7 +16,10 @@ import {
|
||||
getAdminProjectSnapshotChannels,
|
||||
listAdminProjectSnapshots,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||
import type {
|
||||
AdminProjectSnapshotEntry,
|
||||
AdminProjectSnapshotListResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', async () => ({
|
||||
@@ -58,6 +61,241 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('全量读取后按同步时间和身份排序,本地分页并支持刷新和恢复默认', async () => {
|
||||
const olderProjects = Array.from({ length: 22 }, (_, index) => ({
|
||||
...entry,
|
||||
projectId: `older-${index}`,
|
||||
projectName: `旧项目 ${index}`,
|
||||
syncedAtMs: entry.syncedAtMs + index,
|
||||
}));
|
||||
const tiedProjects = [
|
||||
{ ...entry, userId: 'user-2', projectId: 'p-1', projectName: '同秒乙' },
|
||||
{ ...entry, userId: 'user-1', projectId: 'p-2', projectName: '同秒甲二' },
|
||||
{ ...entry, userId: 'user-1', projectId: 'p-1', projectName: '同秒甲一' },
|
||||
].map((project) => ({ ...project, syncedAtMs: entry.syncedAtMs + 100 }));
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
|
||||
.mockResolvedValueOnce({ items: olderProjects, nextCursor: 'scan-2' })
|
||||
// 后端有界扫描可能返回带下一游标的空页,不能提前结束。
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'scan-3' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
...tiedProjects,
|
||||
{
|
||||
...olderProjects[0]!,
|
||||
projectName: '更新项目',
|
||||
syncedAtMs: entry.syncedAtMs + 200,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
await screen.findByRole('button', { name: '恢复默认顺序' });
|
||||
const rowNames = () =>
|
||||
screen
|
||||
.getAllByRole('row')
|
||||
.map((row) => row.querySelector('td strong')?.textContent)
|
||||
.filter(Boolean);
|
||||
expect(rowNames().slice(0, 4)).toEqual([
|
||||
'更新项目',
|
||||
'同秒甲一',
|
||||
'同秒甲二',
|
||||
'同秒乙',
|
||||
]);
|
||||
expect(rowNames()).toHaveLength(20);
|
||||
expect(screen.getByRole('navigation').textContent).toContain('共 25 个');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'token',
|
||||
{ cursor: 'scan-3', limit: 100, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
expect(rowNames()).toHaveLength(5);
|
||||
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
||||
expect(rowNames()[0]).toBe('更新项目');
|
||||
fireEvent.change(screen.getByLabelText('每页条数'), {
|
||||
target: { value: '50' },
|
||||
});
|
||||
expect(rowNames()).toHaveLength(25);
|
||||
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(4);
|
||||
|
||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectName: '刷新后的项目' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('刷新后的项目');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
'token',
|
||||
{ cursor: null, limit: 100, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
|
||||
await screen.findByText('三消工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
'token',
|
||||
{ cursor: null, limit: 50, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
test('恢复默认顺序失败保留排序分页,重试成功后切换远端第一页及游标', async () => {
|
||||
const projects = Array.from({ length: 45 }, (_, index) => ({
|
||||
...entry,
|
||||
projectId: `sorted-${index}`,
|
||||
projectName: `排序项目 ${index}`,
|
||||
syncedAtMs: entry.syncedAtMs - index,
|
||||
}));
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
|
||||
.mockResolvedValueOnce({ items: projects, nextCursor: null })
|
||||
.mockRejectedValueOnce(new Error('恢复失败'))
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectName: '默认第一页' }],
|
||||
nextCursor: 'default-next',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectName: '默认第二页' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
await screen.findByText('排序项目 0');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
|
||||
await screen.findByText('恢复失败');
|
||||
expect(screen.getByText('排序项目 20')).toBeTruthy();
|
||||
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
|
||||
expect(screen.getByRole('navigation').textContent).toContain('共 45 个');
|
||||
expect(screen.getByRole('button', { name: '恢复默认顺序' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
expect(screen.getByText('排序项目 40')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
||||
expect(screen.getByText('排序项目 20')).toBeTruthy();
|
||||
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(3);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
|
||||
await screen.findByText('默认第一页');
|
||||
expect(screen.getByRole('navigation').textContent).toContain('第 1 页');
|
||||
expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('默认第二页');
|
||||
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
'token',
|
||||
{ cursor: 'default-next', limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
test('全量排序中途失败不发布部分结果,重试刷新失败保留完整排序', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectName: '部分读取项目' }],
|
||||
nextCursor: 'scan-next',
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('全量读取失败'));
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
await screen.findByText('全量读取失败');
|
||||
expect(screen.getByText('三消工程')).toBeTruthy();
|
||||
expect(screen.queryByText('部分读取项目')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '恢复默认顺序' })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
|
||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectName: '完整排序项目' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
await screen.findByText('完整排序项目');
|
||||
vi.mocked(listAdminProjectSnapshots).mockRejectedValueOnce(
|
||||
new Error('刷新读取失败'),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('刷新读取失败');
|
||||
expect(screen.getByText('完整排序项目')).toBeTruthy();
|
||||
expect(screen.getByRole('navigation').textContent).toContain('共 1 个');
|
||||
});
|
||||
|
||||
test('取消或切换渠道时中断全量读取,忽略随后到达的旧响应', async () => {
|
||||
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
|
||||
defaultChannel: 'dev',
|
||||
channels: ['dev', 'release'],
|
||||
});
|
||||
let finishCancelled!: (value: AdminProjectSnapshotListResponse) => void;
|
||||
let finishOldChannel!: (value: AdminProjectSnapshotListResponse) => void;
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AdminProjectSnapshotListResponse>((resolve) => {
|
||||
finishCancelled = resolve;
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AdminProjectSnapshotListResponse>((resolve) => {
|
||||
finishOldChannel = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, channel: 'release', projectName: '正式渠道项目' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
const cancelledSignal = vi.mocked(listAdminProjectSnapshots).mock
|
||||
.calls[1]![2]!;
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消排序' }));
|
||||
expect(cancelledSignal.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
finishCancelled({
|
||||
items: [{ ...entry, projectName: '已取消项目' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('三消工程')).toBeTruthy();
|
||||
expect(screen.queryByText('已取消项目')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
const oldChannelSignal = vi.mocked(listAdminProjectSnapshots).mock
|
||||
.calls[2]![2]!;
|
||||
fireEvent.change(screen.getByLabelText('项目工程渠道'), {
|
||||
target: { value: 'release' },
|
||||
});
|
||||
await screen.findByText('正式渠道项目');
|
||||
expect(oldChannelSignal.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
finishOldChannel({
|
||||
items: [{ ...entry, projectName: '旧渠道项目' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('正式渠道项目')).toBeTruthy();
|
||||
expect(screen.queryByText('旧渠道项目')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('按项目展示完整性并限制未完成工程下载', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValue({
|
||||
items: [
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
AdminPagination,
|
||||
AdminStatusPill,
|
||||
} from '@genarrative/shared/components';
|
||||
import { Download, RefreshCcw, X } from 'lucide-react';
|
||||
import { ArrowDownWideNarrow, Download, RefreshCcw, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -50,6 +50,9 @@ const snapshotStatuses: Record<
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
||||
const MAX_SORT_PAGES = 200;
|
||||
const MAX_SORT_PROJECTS = 10_000;
|
||||
const SORT_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function AdminProjectSnapshotsPage({
|
||||
token,
|
||||
@@ -64,6 +67,9 @@ export function AdminProjectSnapshotsPage({
|
||||
const [channelOptions, setChannelOptions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [isSorted, setIsSorted] = useState(false);
|
||||
const [isSorting, setIsSorting] = useState(false);
|
||||
const [sortProgress, setSortProgress] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||||
const listController = useRef<AbortController | null>(null);
|
||||
@@ -71,9 +77,23 @@ export function AdminProjectSnapshotsPage({
|
||||
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
|
||||
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
|
||||
const pageCursors = useRef<(string | null)[]>([null]);
|
||||
// 全量读取完成后才发布;分页只切片本次读到的完整集合。
|
||||
const sortedItems = useRef<AdminProjectSnapshotEntry[] | null>(null);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (cursor: string | null, limit: number, page: number) => {
|
||||
async (
|
||||
cursor: string | null,
|
||||
limit: number,
|
||||
page: number,
|
||||
restoreDefault = false,
|
||||
) => {
|
||||
if (!restoreDefault && sortedItems.current !== null) {
|
||||
setItems(sortedItems.current.slice((page - 1) * limit, page * limit));
|
||||
setPageIndex(page);
|
||||
setHasLoaded(true);
|
||||
setErrorMessage('');
|
||||
return;
|
||||
}
|
||||
listController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
listController.current = controller;
|
||||
@@ -86,6 +106,12 @@ export function AdminProjectSnapshotsPage({
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
// 远端第一页读取成功后再切换模式,失败时保留完整排序和分页。
|
||||
if (restoreDefault) {
|
||||
sortedItems.current = null;
|
||||
setIsSorted(false);
|
||||
pageCursors.current = [null];
|
||||
}
|
||||
setItems(response.items);
|
||||
setNextCursor(response.nextCursor);
|
||||
setPageIndex(page);
|
||||
@@ -126,8 +152,16 @@ export function AdminProjectSnapshotsPage({
|
||||
return () => controller.abort();
|
||||
}, [token, onUnauthorized]);
|
||||
|
||||
useEffect(() => {
|
||||
sortedItems.current = null;
|
||||
setIsSorted(false);
|
||||
}, [token, channel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (channel === null) return undefined;
|
||||
setIsSorting(false);
|
||||
setIsLoading(false);
|
||||
setSortProgress(0);
|
||||
pageCursors.current = [null];
|
||||
setItems([]);
|
||||
setNextCursor(null);
|
||||
@@ -144,6 +178,10 @@ export function AdminProjectSnapshotsPage({
|
||||
}, [loadPage, pageSize, channel]);
|
||||
|
||||
function goToNextPage() {
|
||||
if (sortedItems.current !== null) {
|
||||
void loadPage(null, pageSize, pageIndex + 1);
|
||||
return;
|
||||
}
|
||||
if (!nextCursor) return;
|
||||
pageCursors.current[pageIndex] = nextCursor;
|
||||
void loadPage(nextCursor, pageSize, pageIndex + 1);
|
||||
@@ -159,6 +197,10 @@ export function AdminProjectSnapshotsPage({
|
||||
}
|
||||
|
||||
function refreshCurrentPage() {
|
||||
if (sortedItems.current !== null) {
|
||||
void sortAllProjects();
|
||||
return;
|
||||
}
|
||||
void loadPage(
|
||||
pageCursors.current[pageIndex - 1] ?? null,
|
||||
pageSize,
|
||||
@@ -166,6 +208,90 @@ export function AdminProjectSnapshotsPage({
|
||||
);
|
||||
}
|
||||
|
||||
async function sortAllProjects() {
|
||||
if (channel === null) return;
|
||||
listController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
listController.current = controller;
|
||||
setIsLoading(true);
|
||||
setIsSorting(true);
|
||||
setSortProgress(0);
|
||||
setErrorMessage('');
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, SORT_TIMEOUT_MS);
|
||||
try {
|
||||
const projects = new Map<string, AdminProjectSnapshotEntry>();
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
for (let page = 0; page < MAX_SORT_PAGES; page += 1) {
|
||||
const response = await listAdminProjectSnapshots(
|
||||
token,
|
||||
{ cursor, limit: 100, channel },
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
for (const project of response.items) {
|
||||
const key = snapshotKey(project);
|
||||
const previous = projects.get(key);
|
||||
if (!previous || project.syncedAtMs >= previous.syncedAtMs) {
|
||||
projects.set(key, project);
|
||||
}
|
||||
}
|
||||
if (projects.size > MAX_SORT_PROJECTS) {
|
||||
throw new Error('项目超过 10,000 个,无法完成全量排序');
|
||||
}
|
||||
setSortProgress(projects.size);
|
||||
if (!response.nextCursor) {
|
||||
const result = [...projects.values()].sort(
|
||||
(left, right) =>
|
||||
right.syncedAtMs - left.syncedAtMs ||
|
||||
compareIds(left.userId, right.userId) ||
|
||||
compareIds(left.projectId, right.projectId),
|
||||
);
|
||||
sortedItems.current = result;
|
||||
setIsSorted(true);
|
||||
setItems(result.slice(0, pageSize));
|
||||
setPageIndex(1);
|
||||
setNextCursor(null);
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
if (seenCursors.has(response.nextCursor)) {
|
||||
throw new Error('项目列表游标重复,无法完成全量排序,请重试');
|
||||
}
|
||||
seenCursors.add(response.nextCursor);
|
||||
cursor = response.nextCursor;
|
||||
}
|
||||
throw new Error('项目读取超过 200 页,无法完成全量排序');
|
||||
} catch (error: unknown) {
|
||||
if (!controller.signal.aborted) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (listController.current === controller) {
|
||||
listController.current = null;
|
||||
setIsLoading(false);
|
||||
setIsSorting(false);
|
||||
if (timedOut) setErrorMessage('全量项目读取超时,请重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSorting() {
|
||||
listController.current?.abort();
|
||||
listController.current = null;
|
||||
setIsLoading(false);
|
||||
setIsSorting(false);
|
||||
}
|
||||
|
||||
function restoreDefaultOrder() {
|
||||
void loadPage(null, pageSize, 1, true);
|
||||
}
|
||||
|
||||
async function downloadProject(entry: AdminProjectSnapshotEntry) {
|
||||
if (downloadController.current || entry.status === 'partial') return;
|
||||
const controller = new AbortController();
|
||||
@@ -236,6 +362,32 @@ export function AdminProjectSnapshotsPage({
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
disabled={isLoading || !hasLoaded}
|
||||
type="button"
|
||||
onClick={
|
||||
isSorted ? restoreDefaultOrder : () => void sortAllProjects()
|
||||
}
|
||||
>
|
||||
<ArrowDownWideNarrow size={17} aria-hidden="true" />
|
||||
<span>{isSorted ? '恢复默认顺序' : '按同步时间排序'}</span>
|
||||
</AdminButton>
|
||||
{isSorting ? (
|
||||
<>
|
||||
<span role="status">
|
||||
正在读取全部项目,已读取 {sortProgress} 个
|
||||
</span>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={cancelSorting}
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
<span>取消排序</span>
|
||||
</AdminButton>
|
||||
</>
|
||||
) : null}
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
disabled={isLoading}
|
||||
@@ -269,6 +421,9 @@ export function AdminProjectSnapshotsPage({
|
||||
<>
|
||||
第 {pageIndex} 页
|
||||
{items.length ? `,本页 ${items.length} 个项目` : ''}
|
||||
{isSorted
|
||||
? `,共 ${sortedItems.current?.length ?? 0} 个,按同步时间从新到旧`
|
||||
: ''}
|
||||
</>
|
||||
}
|
||||
pageSize={{
|
||||
@@ -279,7 +434,12 @@ export function AdminProjectSnapshotsPage({
|
||||
}}
|
||||
busy={isLoading}
|
||||
previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }}
|
||||
next={{ disabled: !nextCursor, onClick: goToNextPage }}
|
||||
next={{
|
||||
disabled: isSorted
|
||||
? pageIndex * pageSize >= (sortedItems.current?.length ?? 0)
|
||||
: !nextCursor,
|
||||
onClick: goToNextPage,
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
@@ -375,6 +535,10 @@ function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
||||
return `${entry.userId}/${entry.projectId}`;
|
||||
}
|
||||
|
||||
function compareIds(left: string, right: string) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
||||
const unit = Math.min(
|
||||
|
||||
Reference in New Issue
Block a user