15774a1c02
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m24s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m25s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m28s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m31s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 2m41s
Project CI / Repository checks (push) Successful in 4m10s
Project CI / Native shell tests (push) Successful in 11m20s
Project CI / Frontend tests (push) Successful in 10m30s
Project CI / AI game creator shell web tests (push) Successful in 5m41s
Project CI / Backend tests (push) Successful in 14m9s
接入真实项目打开、切换和关闭生命周期并修复退出等待竞态 新增后台项目列表及按原目录校验导出的 ZIP 下载 补充快照完整性元数据、后台权限和取消清理 固定默认 AGC 存储目标并支持空文件与特殊字符路径 补充自动化测试及真实 OSS 只读导出验证 记录本地数据库 404 导致完整 HTTP 联调和安装版实机验证待补
271 lines
8.9 KiB
TypeScript
271 lines
8.9 KiB
TypeScript
import { Download, RefreshCcw, X } from 'lucide-react';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
import {
|
|
downloadAdminProjectSnapshot,
|
|
listAdminProjectSnapshots,
|
|
} from '../api/adminApiClient';
|
|
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
|
import { handlePageError } from './pageUtils';
|
|
|
|
interface AdminProjectSnapshotsPageProps {
|
|
token: string;
|
|
onUnauthorized: (message?: string) => void;
|
|
}
|
|
|
|
const snapshotStatuses = {
|
|
ready: {
|
|
label: '已同步',
|
|
className: 'admin-status-ok',
|
|
action: '下载完整工程',
|
|
},
|
|
partial: {
|
|
label: '同步未完成',
|
|
className: 'admin-status-pending',
|
|
action: '同步未完成',
|
|
},
|
|
unverified: {
|
|
label: '完整性未知',
|
|
className: 'admin-status-pending',
|
|
action: '下载已存文件',
|
|
},
|
|
};
|
|
|
|
export function AdminProjectSnapshotsPage({
|
|
token,
|
|
onUnauthorized,
|
|
}: AdminProjectSnapshotsPageProps) {
|
|
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [hasLoaded, setHasLoaded] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
|
const listController = useRef<AbortController | null>(null);
|
|
const downloadController = useRef<AbortController | null>(null);
|
|
|
|
const loadPage = useCallback(
|
|
async (cursor: string | null = null) => {
|
|
listController.current?.abort();
|
|
const controller = new AbortController();
|
|
listController.current = controller;
|
|
setIsLoading(true);
|
|
setErrorMessage('');
|
|
try {
|
|
const response = await listAdminProjectSnapshots(
|
|
token,
|
|
{ cursor, limit: 20 },
|
|
controller.signal,
|
|
);
|
|
if (controller.signal.aborted) return;
|
|
setItems((current) => {
|
|
if (!cursor) return response.items;
|
|
const entries = new Map(
|
|
current.map((entry) => [snapshotKey(entry), entry]),
|
|
);
|
|
response.items.forEach((entry) =>
|
|
entries.set(snapshotKey(entry), entry),
|
|
);
|
|
return [...entries.values()];
|
|
});
|
|
setNextCursor(response.nextCursor);
|
|
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],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setItems([]);
|
|
setNextCursor(null);
|
|
setHasLoaded(false);
|
|
setDownloadingKey(null);
|
|
void loadPage();
|
|
return () => {
|
|
listController.current?.abort();
|
|
listController.current = null;
|
|
downloadController.current?.abort();
|
|
downloadController.current = null;
|
|
};
|
|
}, [loadPage]);
|
|
|
|
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,
|
|
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);
|
|
}
|
|
|
|
return (
|
|
<section className="admin-page admin-page-wide">
|
|
<div className="admin-page-heading">
|
|
<h2>项目工程</h2>
|
|
<button
|
|
className="admin-secondary-button"
|
|
disabled={isLoading}
|
|
type="button"
|
|
onClick={() => void loadPage()}
|
|
>
|
|
<RefreshCcw size={17} aria-hidden="true" />
|
|
<span>{isLoading ? '加载中' : '刷新'}</span>
|
|
</button>
|
|
</div>
|
|
{errorMessage ? (
|
|
<div className="admin-alert" role="alert">
|
|
{errorMessage}
|
|
</div>
|
|
) : null}
|
|
<section
|
|
className="admin-panel admin-stack"
|
|
aria-label="项目工程列表"
|
|
aria-busy={isLoading}
|
|
>
|
|
<div className="admin-table-wrap">
|
|
<table className="admin-table admin-project-snapshot-table">
|
|
<thead>
|
|
<tr>
|
|
<th>项目</th>
|
|
<th>用户 ID</th>
|
|
<th>同步时间</th>
|
|
<th>文件数</th>
|
|
<th>体积</th>
|
|
<th>完整性</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((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="用户 ID">{entry.userId}</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="完整性">
|
|
<span className={`admin-status ${status.className}`}>
|
|
{status.label}
|
|
</span>
|
|
</td>
|
|
<td data-label="操作">
|
|
{isDownloading ? (
|
|
<button
|
|
className="admin-secondary-button"
|
|
type="button"
|
|
onClick={cancelDownload}
|
|
>
|
|
<X size={16} aria-hidden="true" />
|
|
<span>取消下载</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="admin-secondary-button"
|
|
disabled={
|
|
entry.status === 'partial' ||
|
|
downloadingKey !== null
|
|
}
|
|
type="button"
|
|
onClick={() => void downloadProject(entry)}
|
|
>
|
|
<Download size={16} aria-hidden="true" />
|
|
<span>{status.action}</span>
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{hasLoaded && items.length === 0 && !errorMessage ? (
|
|
<p className="admin-muted-text">暂无已上传项目</p>
|
|
) : null}
|
|
{nextCursor ? (
|
|
<div className="admin-action-row">
|
|
<button
|
|
className="admin-secondary-button"
|
|
disabled={isLoading}
|
|
type="button"
|
|
onClick={() => void loadPage(nextCursor)}
|
|
>
|
|
{isLoading ? '加载中' : '加载更多'}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
|
return `${entry.userId}/${entry.projectId}`;
|
|
}
|
|
|
|
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]}`;
|
|
}
|