Files
makelore/src/pages/Chat/GameAssetBrowser.tsx
T

252 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react';
import { Archive, Check, Headphones, Image as ImageIcon, Loader2, RefreshCw, Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { hostApiFetch } from '@/lib/host-api';
import type { GameAssetReviewAction } from '@/lib/game-asset-selection-message';
import {
classifyGameAssetReviewLoadFailure,
isGameAssetReviewResponse,
type GameAssetCandidate,
type GameAssetReviewLoadFailure,
type GameAssetReviewSnapshot,
} from './game-asset-review-recovery';
export type { GameAssetCandidate } from './game-asset-review-recovery';
type GameAssetDecisionDraft = Record<string, GameAssetReviewAction>;
export function GameAssetBrowser(props: {
invocationId: string;
candidateIds?: string[];
onSubmit?: (assets: GameAssetCandidate[], decisions: GameAssetDecisionDraft) => void | Promise<void>;
}) {
const [assets, setAssets] = useState<GameAssetCandidate[]>([]);
const [review, setReview] = useState<GameAssetReviewSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [loadFailure, setLoadFailure] = useState<GameAssetReviewLoadFailure | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewAsset, setPreviewAsset] = useState<GameAssetCandidate | null>(null);
const [draftDecisions, setDraftDecisions] = useState<GameAssetDecisionDraft>({});
const [showSubmitDialog, setShowSubmitDialog] = useState(false);
const [submitting, setSubmitting] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setLoadFailure(null);
setError(null);
const params = new URLSearchParams({ invocationId: props.invocationId });
if (props.candidateIds?.length) params.set('candidateIds', JSON.stringify(props.candidateIds));
const path = `/api/files/game-asset-review?${params.toString()}`;
try {
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const response = await hostApiFetch<unknown>(path);
if (!isGameAssetReviewResponse(response)) {
setLoadFailure('stale');
return;
}
setReview(response.review);
setAssets(response.assets);
setDraftDecisions({});
setShowSubmitDialog(false);
return;
} catch (loadError) {
const failure = classifyGameAssetReviewLoadFailure(loadError);
if (failure === 'stale' || attempt === 1) {
setLoadFailure(failure);
return;
}
}
}
} finally {
setLoading(false);
}
}, [props.candidateIds, props.invocationId]);
useEffect(() => {
void load();
}, [load]);
const chooseAction = (assetId: string, action: GameAssetReviewAction) => {
if (submitting) return;
setDraftDecisions((current) => ({ ...current, [assetId]: action }));
setError(null);
};
const selectedCount = assets.filter((asset) => Boolean(draftDecisions[asset.id])).length;
const allAssetsSelected = assets.length > 0 && selectedCount === assets.length;
const actionLabel = (action: GameAssetReviewAction): string => (
action === 'approve' ? '纳入开发' : action === 'discard' ? '舍弃' : '重新寻找'
);
const submitBatch = async () => {
if (!review || !allAssetsSelected || submitting) return;
setSubmitting(true);
setError(null);
const decisions = assets.flatMap((asset) => {
const action = draftDecisions[asset.id];
return action ? [{ assetId: asset.id, action }] : [];
});
try {
const response = await hostApiFetch<{ success: boolean; review: GameAssetReviewSnapshot }>('/api/files/game-asset-review', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invocationId: props.invocationId,
candidateIds: review.candidateIds,
decisions,
}),
});
setReview(response.review);
setAssets([]);
setDraftDecisions({});
setShowSubmitDialog(false);
try {
await props.onSubmit?.(assets, Object.fromEntries(decisions.map((item) => [item.assetId, item.action])));
} catch {
setError('审核结果已保存,但暂时无法通知当前智能体,请稍后继续对话');
}
} catch {
setShowSubmitDialog(false);
setError('素材审核结果暂时无法保存,请稍后重试');
} finally {
setSubmitting(false);
}
};
const approvedCount = review ? Object.values(review.decisions).filter((decision) => decision === 'approved').length : 0;
const discardedCount = review ? Object.values(review.decisions).filter((decision) => decision !== 'approved').length : 0;
if (loading) {
return <section data-testid="game-asset-browser" className="surface-card flex min-h-28 w-full items-center justify-center rounded-2xl border border-border/70 bg-background text-foreground shadow-none"><Loader2 className="h-5 w-5 animate-spin" /></section>;
}
if (loadFailure === 'stale') {
return (
<section
data-testid="game-asset-browser-stale"
className="flex min-h-24 w-full items-center rounded-md border border-foreground/15 bg-background px-4 py-3 text-foreground shadow-soft"
>
<p className="text-pretty text-sm font-medium">旧素材卡片已失效,已自动跳过,不影响继续对话</p>
</section>
);
}
if (loadFailure === 'retryable') {
return (
<section
data-testid="game-asset-browser-retryable"
className="flex min-h-24 w-full items-center justify-between gap-3 rounded-md border border-foreground/15 bg-background px-4 py-3 text-foreground shadow-soft"
>
<p className="text-pretty text-sm font-medium">素材卡片暂时无法加载,不影响继续对话</p>
<Button
type="button"
className="h-10 shrink-0 border border-foreground/15 bg-brand-soft font-semibold text-foreground"
onClick={() => void load()}
>
<RefreshCw className="mr-1 h-4 w-4" />重新加载
</Button>
</section>
);
}
if (review?.status === 'resolved' && review.candidateIds.length > 0) {
return (
<section data-testid="game-asset-review-summary" className="surface-card w-full rounded-2xl border border-border/70 bg-background px-3 py-3 text-foreground shadow-none">
<div className="flex items-start gap-2">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-700" />
<div>
<p className="text-sm font-semibold">本轮审核已完成</p>
<p className="mt-1 text-xs font-medium leading-5 text-muted-foreground">纳入开发 {approvedCount} 个,舍弃 {discardedCount} 个。未处理素材不会自动带入下一轮。</p>
</div>
</div>
</section>
);
}
return (
<section data-testid="game-asset-browser" className="surface-card w-full rounded-2xl border border-border/70 bg-background px-3 py-3 text-foreground shadow-none">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">素材审核台</h3>
<p className="text-xs font-medium text-muted-foreground">先逐项选择,全部选完后一次性提交审核结果。</p>
</div>
{review ? <span className="rounded border border-foreground/15 bg-white px-2 py-1 text-[10px] font-semibold">待处理 {review.pendingAssetIds.length} 个</span> : null}
</div>
{error ? <p className="mt-3 rounded border border-red-400 bg-red-50 p-2 text-xs font-medium text-red-700">{error}</p> : null}
{!error && assets.length === 0 ? (
<p className="mt-3 rounded border border-dashed border-foreground/15 bg-white p-3 text-xs font-medium">Agent 本次没有提交可审核的素材。未处理内容不会自动从旧批次带入。</p>
) : null}
{assets.length > 0 ? (
<div className="mt-3 flex gap-3 overflow-x-auto pb-2" data-testid="game-asset-candidate-list">
{assets.map((asset) => {
const selectedAction = draftDecisions[asset.id];
return (
<article key={asset.id} className={`w-56 shrink-0 overflow-hidden rounded-md border border-foreground/15 bg-white shadow-soft ${selectedAction ? 'ring-4 ring-accent/40' : ''}`}>
<button type="button" className="flex h-28 w-full items-center justify-center overflow-hidden border-b border-foreground/15 bg-background" onClick={() => setPreviewAsset(asset)}>
{asset.previewDataUrl ? <img src={asset.previewDataUrl} alt={asset.name} className="h-full w-full object-contain" />
: asset.mediaKind === 'audio' ? <Headphones className="h-10 w-10 text-brand" />
: asset.mediaKind === 'bundle' ? <Archive className="h-10 w-10 text-accent" />
: <ImageIcon className="h-10 w-10 text-slate-400" />}
</button>
<div className="space-y-2 p-3">
<div className="flex items-start justify-between gap-2"><strong className="line-clamp-2 text-xs">{asset.name}</strong><Badge className="border border-foreground/15 bg-surface-subtle text-[9px] text-foreground">{selectedAction ? actionLabel(selectedAction) : asset.status}</Badge></div>
<p className="line-clamp-2 text-[11px] font-medium">{asset.purpose || '未填写用途'}</p>
<p className="truncate text-[10px] text-muted-foreground">来源:{asset.source || '未填写'}</p>
<p className="truncate text-[10px] text-muted-foreground">授权:{asset.license || '未填写'}</p>
{asset.audioDataUrl ? <audio controls preload="metadata" className="h-8 w-full" src={asset.audioDataUrl} /> : null}
<div className="grid grid-cols-2 gap-2">
<Button type="button" size="sm" disabled={submitting} className={`h-8 border border-foreground/15 text-[11px] font-semibold text-foreground ${selectedAction === 'approve' ? 'bg-brand-soft' : 'bg-white'}`} aria-pressed={selectedAction === 'approve'} aria-label={`纳入开发 ${asset.name}`} onClick={() => chooseAction(asset.id, 'approve')}>纳入开发</Button>
<Button type="button" size="sm" variant="outline" disabled={submitting} className={`h-8 border border-foreground/15 text-[11px] font-semibold ${selectedAction === 'discard' ? 'bg-accent-soft' : ''}`} aria-pressed={selectedAction === 'discard'} aria-label={`舍弃 ${asset.name}`} onClick={() => chooseAction(asset.id, 'discard')}>舍弃</Button>
</div>
<Button type="button" size="sm" variant="outline" disabled={submitting} className={`h-8 w-full border border-foreground/15 text-[11px] font-semibold ${selectedAction === 'replace' ? 'bg-surface-subtle' : ''}`} aria-pressed={selectedAction === 'replace'} aria-label={`重新寻找 ${asset.name}`} onClick={() => chooseAction(asset.id, 'replace')}><Search className="mr-1 h-3 w-3" />不合适,重新寻找</Button>
</div>
</article>
);
})}
</div>
) : null}
{assets.length > 0 ? (
<div className="mt-2 flex items-center justify-between gap-3 border-t border-foreground/15 pt-3">
<p className="text-[10px] font-medium text-muted-foreground">已选择 {selectedCount} / {assets.length} 个{allAssetsSelected ? ',可以提交' : `,还需选择 ${assets.length - selectedCount} 个`}</p>
<Button type="button" size="sm" disabled={!allAssetsSelected || submitting} className="border border-foreground/15 bg-brand-soft text-[11px] font-semibold text-foreground" onClick={() => setShowSubmitDialog(true)}>查看并提交本轮结果</Button>
</div>
) : null}
{review && (approvedCount > 0 || discardedCount > 0) ? <p className="mt-2 text-[10px] font-medium text-muted-foreground">本轮已提交:纳入 {approvedCount} 个,舍弃 {discardedCount} 个。未处理素材不会自动带入下一轮。</p> : null}
{showSubmitDialog ? (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-foreground/20 p-5" role="dialog" aria-modal="true" aria-label="确认提交本轮素材审核结果">
<div className="max-h-[85vh] w-full max-w-2xl overflow-auto rounded-lg border border-foreground/15 bg-background p-5 shadow-soft">
<div className="flex items-start justify-between gap-3"><div><h3 className="text-lg font-semibold">确认提交本轮审核</h3><p className="text-sm font-medium text-muted-foreground">将一次性保存全部决定,并只发送一条汇总对话。</p></div><Button type="button" size="icon" variant="outline" aria-label="关闭提交确认" className="border border-foreground/15" onClick={() => setShowSubmitDialog(false)}><X className="h-4 w-4" /></Button></div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
{(['approve', 'discard', 'replace'] as const).map((action) => {
const selected = assets.filter((asset) => draftDecisions[asset.id] === action);
return <div key={action} className="rounded border border-foreground/15 bg-white p-3 text-xs"><p className="font-semibold">{actionLabel(action)}:{selected.length} 个</p><p className="mt-1 line-clamp-3 text-muted-foreground">{selected.map((asset) => asset.name).join('、') || '无'}</p></div>;
})}
</div>
<div className="mt-5 flex justify-end gap-2"><Button type="button" variant="outline" disabled={submitting} className="border border-foreground/15 font-semibold" onClick={() => setShowSubmitDialog(false)}>返回修改</Button><Button type="button" disabled={submitting} className="border border-foreground/15 bg-brand-soft font-semibold text-foreground" onClick={() => void submitBatch()}>{submitting ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null}确认并一次性提交</Button></div>
</div>
</div>
) : null}
{previewAsset ? (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-foreground/20 p-5" role="dialog" aria-modal="true" aria-label={`${previewAsset.name} 素材预览`}>
<div className="max-h-[85vh] w-full max-w-3xl overflow-auto rounded-lg border border-foreground/15 bg-background p-5 shadow-soft">
<div className="flex items-start justify-between gap-3"><div><h3 className="text-lg font-semibold">{previewAsset.name}</h3><p className="text-sm font-medium text-muted-foreground">{previewAsset.purpose}</p></div><Button type="button" size="icon" variant="outline" aria-label="关闭素材预览" className="border border-foreground/15" onClick={() => setPreviewAsset(null)}><X className="h-4 w-4" /></Button></div>
{previewAsset.previewDataUrl ? <img src={previewAsset.previewDataUrl} alt={previewAsset.name} className="mt-4 max-h-[50vh] w-full rounded border border-foreground/15 bg-white object-contain" /> : null}
{previewAsset.audioDataUrl ? <audio controls autoPlay className="mt-4 w-full" src={previewAsset.audioDataUrl} /> : null}
{previewAsset.manifest.length > 0 ? <div className="mt-4"><h4 className="text-sm font-semibold">文件清单</h4><ul className="mt-2 max-h-52 overflow-auto rounded border border-foreground/15 bg-white p-3 text-xs">{previewAsset.manifest.map((item) => <li key={item} className="py-0.5">{item}</li>)}</ul></div> : null}
{previewAsset.unavailableReason ? <p className="mt-4 rounded border border-amber-400 bg-amber-50 p-3 text-xs font-medium">{previewAsset.unavailableReason}</p> : null}
</div>
</div>
) : null}
</section>
);
}