Files
Genarrative/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx
T
kdletters 4951b71d71
Project CI / AI game creator shell Rust crates (push) Successful in 2m46s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m37s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m5s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m40s
Project CI / Repository checks (push) Successful in 5m37s
Project CI / Frontend tests (push) Successful in 6m21s
Project CI / Backend tests (push) Successful in 9m56s
Project CI / Native shell tests (push) Successful in 10m57s
Project CI / AI game creator shell web tests (push) Successful in 5m29s
项目快照按渠道分区,后台项目工程支持渠道筛选与游标分页
项目快照对象键升级为 agc/project-snapshots/v2/{channel}/{user}/{project}/,渠道取部署配置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL(缺省沿用客户端下载渠道),非法渠道在写入处失败关闭
后台新增渠道列表接口,项目工程列表与下载接受 channel,游标绑定渠道并拒绝跨渠道复用
后台项目工程用户列改为素材查询口径:昵称 + 陶泥号 + 用户详情入口,由 api-server 解析作者信息
后台项目工程改为游标分页:每页 20/50/100、上一页/下一页与当前页提示,翻页失败保留当前页
部署环境示例补充快照渠道配置,并同步运维、技术方案、里程碑与决策记录文档
2026-09-21 16:57:16 +08:00

413 lines
14 KiB
TypeScript

import {
ChevronLeft,
ChevronRight,
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 = {
ready: {
label: '已同步',
className: 'admin-status-ok',
action: '下载完整工程',
},
partial: {
label: '同步未完成',
className: 'admin-status-pending',
action: '同步未完成',
},
unverified: {
label: '完整性未知',
className: 'admin-status-pending',
action: '下载已存文件',
},
};
const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
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 [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 loadPage = useCallback(
async (cursor: string | null, limit: number, page: number) => {
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;
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(() => {
if (channel === null) return undefined;
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 (!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() {
void loadPage(
pageCursors.current[pageIndex - 1] ?? null,
pageSize,
pageIndex,
);
}
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 (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h2>项目工程</h2>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span>渠道</span>
<select
aria-label="项目工程渠道"
value={channel ?? ''}
onChange={(event) => setChannel(event.target.value)}
>
{visibleChannelOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshCurrentPage}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '加载中' : '刷新'}</span>
</button>
</div>
</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>用户</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="用户">
<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="完整性">
<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}
{hasLoaded ? (
<nav
className="admin-action-row admin-project-snapshot-pagination"
aria-label="项目工程分页"
>
<span className="admin-project-snapshot-pagination-info">
{pageIndex}
{items.length ? `,本页 ${items.length} 个项目` : ''}
</span>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span>每页</span>
<select
aria-label="每页条数"
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
aria-label="上一页"
className="admin-secondary-button"
disabled={isLoading || pageIndex <= 1}
title="上一页"
type="button"
onClick={goToPreviousPage}
>
<ChevronLeft size={17} aria-hidden="true" />
<span>上一页</span>
</button>
<button
aria-label="下一页"
className="admin-secondary-button"
disabled={isLoading || !nextCursor}
title="下一页"
type="button"
onClick={goToNextPage}
>
<span>下一页</span>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</nav>
) : null}
</section>
</section>
);
}
function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) {
return (
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
);
}
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]}`;
}