a344686b59
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
550 lines
18 KiB
TypeScript
550 lines
18 KiB
TypeScript
import type { AdminStatusTone } from '@genarrative/shared/components';
|
|
import {
|
|
AdminActionRow,
|
|
AdminAlert,
|
|
AdminButton,
|
|
AdminField,
|
|
AdminListPanel,
|
|
AdminPage,
|
|
AdminPageHeading,
|
|
AdminPagination,
|
|
AdminStatusPill,
|
|
} from '@genarrative/shared/components';
|
|
import { ArrowDownWideNarrow, Download, RefreshCcw, X } from 'lucide-react';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
import {
|
|
downloadAdminProjectSnapshot,
|
|
getAdminProjectSnapshotChannels,
|
|
listAdminProjectSnapshots,
|
|
} from '../api/adminApiClient';
|
|
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
|
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
|
import { handlePageError } from './pageUtils';
|
|
|
|
interface AdminProjectSnapshotsPageProps {
|
|
token: string;
|
|
onUnauthorized: (message?: string) => void;
|
|
}
|
|
|
|
const snapshotStatuses: Record<
|
|
AdminProjectSnapshotEntry['status'],
|
|
{ label: string; tone: AdminStatusTone; action: string }
|
|
> = {
|
|
ready: {
|
|
label: '已同步',
|
|
tone: 'ok',
|
|
action: '下载完整工程',
|
|
},
|
|
partial: {
|
|
label: '同步未完成',
|
|
tone: 'pending',
|
|
action: '同步未完成',
|
|
},
|
|
unverified: {
|
|
label: '完整性未知',
|
|
tone: 'pending',
|
|
action: '下载已存文件',
|
|
},
|
|
};
|
|
|
|
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,
|
|
onUnauthorized,
|
|
}: AdminProjectSnapshotsPageProps) {
|
|
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
const [pageIndex, setPageIndex] = useState(1);
|
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
|
// null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。
|
|
const [channel, setChannel] = useState<string | null>(null);
|
|
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);
|
|
const downloadController = useRef<AbortController | null>(null);
|
|
// 远端按游标分页且不给总数:第 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,
|
|
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;
|
|
setIsLoading(true);
|
|
setErrorMessage('');
|
|
try {
|
|
const response = await listAdminProjectSnapshots(
|
|
token,
|
|
{ cursor, limit, channel },
|
|
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);
|
|
setHasLoaded(true);
|
|
} catch (error: unknown) {
|
|
// 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。
|
|
if (!controller.signal.aborted)
|
|
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
} finally {
|
|
if (listController.current === controller) {
|
|
listController.current = null;
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
},
|
|
[token, onUnauthorized, channel],
|
|
);
|
|
|
|
useEffect(() => {
|
|
// 换令牌或首次进入时取一次可选渠道;已选渠道保持不变,只在还没选时落到本部署渠道。
|
|
const controller = new AbortController();
|
|
void (async () => {
|
|
try {
|
|
const response = await getAdminProjectSnapshotChannels(
|
|
token,
|
|
controller.signal,
|
|
);
|
|
if (controller.signal.aborted) return;
|
|
setChannelOptions(response.channels);
|
|
setChannel((current) => current ?? response.defaultChannel);
|
|
} catch (error: unknown) {
|
|
if (controller.signal.aborted) return;
|
|
// 渠道列表失败不阻塞查询:不带渠道按本部署渠道查询,并提示失败原因。
|
|
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
setChannel((current) => current ?? '');
|
|
}
|
|
})();
|
|
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);
|
|
setPageIndex(1);
|
|
setHasLoaded(false);
|
|
setDownloadingKey(null);
|
|
void loadPage(null, pageSize, 1);
|
|
return () => {
|
|
listController.current?.abort();
|
|
listController.current = null;
|
|
downloadController.current?.abort();
|
|
downloadController.current = null;
|
|
};
|
|
}, [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);
|
|
}
|
|
|
|
function goToPreviousPage() {
|
|
if (pageIndex <= 1) return;
|
|
void loadPage(
|
|
pageCursors.current[pageIndex - 2] ?? null,
|
|
pageSize,
|
|
pageIndex - 1,
|
|
);
|
|
}
|
|
|
|
function refreshCurrentPage() {
|
|
if (sortedItems.current !== null) {
|
|
void sortAllProjects();
|
|
return;
|
|
}
|
|
void loadPage(
|
|
pageCursors.current[pageIndex - 1] ?? null,
|
|
pageSize,
|
|
pageIndex,
|
|
);
|
|
}
|
|
|
|
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();
|
|
downloadController.current = controller;
|
|
setDownloadingKey(snapshotKey(entry));
|
|
setErrorMessage('');
|
|
try {
|
|
const archive = await downloadAdminProjectSnapshot(
|
|
token,
|
|
channel ?? '',
|
|
entry.userId,
|
|
entry.projectId,
|
|
controller.signal,
|
|
);
|
|
if (controller.signal.aborted) return;
|
|
const objectUrl = URL.createObjectURL(archive.blob);
|
|
const link = document.createElement('a');
|
|
link.href = objectUrl;
|
|
link.download = archive.filename;
|
|
document.body.append(link);
|
|
try {
|
|
link.click();
|
|
} finally {
|
|
link.remove();
|
|
// 给浏览器时间接管下载,随后释放临时 URL。
|
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
|
}
|
|
} catch (error: unknown) {
|
|
if (!controller.signal.aborted)
|
|
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
} finally {
|
|
if (downloadController.current === controller) {
|
|
downloadController.current = null;
|
|
setDownloadingKey(null);
|
|
}
|
|
}
|
|
}
|
|
|
|
function cancelDownload() {
|
|
downloadController.current?.abort();
|
|
downloadController.current = null;
|
|
setDownloadingKey(null);
|
|
}
|
|
|
|
// 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。
|
|
const visibleChannelOptions = channelOptions.length
|
|
? channelOptions
|
|
: channel
|
|
? [channel]
|
|
: [];
|
|
|
|
return (
|
|
<AdminPage wide>
|
|
<AdminPageHeading
|
|
title="项目工程"
|
|
actions={
|
|
<AdminActionRow>
|
|
<AdminField label="渠道" compact>
|
|
<select
|
|
aria-label="项目工程渠道"
|
|
value={channel ?? ''}
|
|
onChange={(event) => setChannel(event.target.value)}
|
|
>
|
|
{visibleChannelOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</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}
|
|
type="button"
|
|
onClick={refreshCurrentPage}
|
|
>
|
|
<RefreshCcw size={17} aria-hidden="true" />
|
|
<span>{isLoading ? '加载中' : '刷新'}</span>
|
|
</AdminButton>
|
|
</AdminActionRow>
|
|
}
|
|
/>
|
|
{errorMessage ? (
|
|
<AdminAlert role="alert">{errorMessage}</AdminAlert>
|
|
) : null}
|
|
<AdminListPanel
|
|
ariaLabel="项目工程列表"
|
|
busy={isLoading}
|
|
className="admin-stack"
|
|
emptyText="暂无已上传项目"
|
|
emptyVariant="text"
|
|
rows={items}
|
|
showEmpty={hasLoaded && items.length === 0 && !errorMessage}
|
|
pagination={
|
|
hasLoaded ? (
|
|
<AdminPagination
|
|
ariaLabel="项目工程分页"
|
|
className="admin-project-snapshot-pagination"
|
|
infoClassName="admin-project-snapshot-pagination-info"
|
|
info={
|
|
<>
|
|
第 {pageIndex} 页
|
|
{items.length ? `,本页 ${items.length} 个项目` : ''}
|
|
{isSorted
|
|
? `,共 ${sortedItems.current?.length ?? 0} 个,按同步时间从新到旧`
|
|
: ''}
|
|
</>
|
|
}
|
|
pageSize={{
|
|
value: pageSize,
|
|
options: PAGE_SIZE_OPTIONS,
|
|
ariaLabel: '每页条数',
|
|
onChange: setPageSize,
|
|
}}
|
|
busy={isLoading}
|
|
previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }}
|
|
next={{
|
|
disabled: isSorted
|
|
? pageIndex * pageSize >= (sortedItems.current?.length ?? 0)
|
|
: !nextCursor,
|
|
onClick: goToNextPage,
|
|
}}
|
|
/>
|
|
) : null
|
|
}
|
|
columns={[
|
|
{ key: 'project', label: '项目' },
|
|
{ key: 'user', label: '用户' },
|
|
{ key: 'syncedAt', label: '同步时间' },
|
|
{ key: 'fileCount', label: '文件数' },
|
|
{ key: 'totalBytes', label: '体积' },
|
|
{ key: 'status', label: '完整性' },
|
|
{ key: 'actions', label: '操作' },
|
|
]}
|
|
renderRow={(entry) => {
|
|
const status = snapshotStatuses[entry.status];
|
|
const isDownloading = downloadingKey === snapshotKey(entry);
|
|
return (
|
|
<tr key={snapshotKey(entry)}>
|
|
<td data-label="项目">
|
|
<strong>{entry.projectName || entry.projectId}</strong>
|
|
<small>{entry.projectId}</small>
|
|
</td>
|
|
<td data-label="用户">
|
|
<div className="admin-inline-identity">
|
|
<div>
|
|
{projectOwnerDisplayName(entry)}
|
|
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
|
|
</div>
|
|
<AdminUserReferenceButton
|
|
token={token}
|
|
userId={entry.userId}
|
|
publicUserCode={entry.authorPublicUserCode}
|
|
onUnauthorized={onUnauthorized}
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td data-label="同步时间">
|
|
<span>
|
|
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
|
|
hour12: false,
|
|
})}
|
|
<small>版本 {entry.syncRevision}</small>
|
|
</span>
|
|
</td>
|
|
<td data-label="文件数">
|
|
{entry.fileCount.toLocaleString('zh-CN')}
|
|
</td>
|
|
<td data-label="体积">{formatBytes(entry.totalBytes)}</td>
|
|
<td data-label="完整性">
|
|
<AdminStatusPill tone={status.tone}>
|
|
{status.label}
|
|
</AdminStatusPill>
|
|
</td>
|
|
<td data-label="操作">
|
|
{isDownloading ? (
|
|
<AdminButton
|
|
variant="secondary"
|
|
type="button"
|
|
onClick={cancelDownload}
|
|
>
|
|
<X size={16} aria-hidden="true" />
|
|
<span>取消下载</span>
|
|
</AdminButton>
|
|
) : (
|
|
<AdminButton
|
|
variant="secondary"
|
|
disabled={
|
|
entry.status === 'partial' || downloadingKey !== null
|
|
}
|
|
type="button"
|
|
onClick={() => void downloadProject(entry)}
|
|
>
|
|
<Download size={16} aria-hidden="true" />
|
|
<span>{status.action}</span>
|
|
</AdminButton>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}}
|
|
tableClassName="admin-project-snapshot-table"
|
|
/>
|
|
</AdminPage>
|
|
);
|
|
}
|
|
|
|
function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) {
|
|
return (
|
|
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
|
|
);
|
|
}
|
|
|
|
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(
|
|
Math.floor(Math.log2(Math.max(1, bytes)) / 10),
|
|
units.length - 1,
|
|
);
|
|
return `${(bytes / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} ${units[unit]}`;
|
|
}
|