Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/index.tsx
T
kdletters 676c4beb60
Project CI / Backend tests (push) Successful in 5m35s
Project CI / Native shell tests (push) Failing after 11m9s
Project CI / Repository checks (push) Successful in 2m23s
Project CI / Frontend tests (push) Successful in 2m16s
补齐Gitea测试门禁
拆分前端、后端与原生壳测试任务
接入server-rs workspace正式全量测试
将AI游戏创作分支及独立依赖纳入CI
修复既有测试断言、格式与异步稳定性问题
稳定AI Tauri并发回执测试
更新开发运维和共享流程文档
2026-07-21 19:40:38 +08:00

1498 lines
48 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 {
ChevronLeft,
ChevronRight,
FileText,
FolderTree,
Gamepad2,
Image,
Info,
ListFilter,
Music2,
Pause,
Play,
Search,
Settings2,
SlidersHorizontal,
Sparkles,
X,
} from 'lucide-react';
import {
type CSSProperties,
type DragEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type {
GameCreationAppAgentGroup,
GameCreationAppManifest,
GameCreationAppPreviewState,
GameCreationAppTaskState,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
type AttachmentResult = {
fileName: string;
mediaType: string;
localPath?: string;
status: 'imported' | 'failed';
error?: string;
};
type ResourceCategory = 'document' | 'version' | 'art' | 'audio';
type ResourceSortMode = 'dependency' | 'type';
type WorkbenchMode = 'resources' | 'run';
type ApprovalMode = 'strict' | 'risk' | 'none';
type ResourceOrderState = Record<
ResourceSortMode,
Record<ResourceCategory, string[]>
>;
type ResourceDropTarget = {
resourceId: string;
placement: 'before' | 'after';
};
type Point = {
x: number;
y: number;
};
type ProjectResource = {
id: string;
category: ResourceCategory;
label: string;
path: string;
mediaType: string;
sourceLabel: string;
taskTitle: string | null;
dependencies: string[];
dependencyDepth: number;
content?: string;
};
type LocalProjectImagePreview = {
path: string;
mediaType: string;
byteLen: number;
dataUrl: string;
};
type ImagePreviewState =
| { status: 'idle'; resourceId: null }
| { status: 'loading'; resourceId: string }
| {
status: 'loaded';
resourceId: string;
preview: LocalProjectImagePreview;
}
| { status: 'failed'; resourceId: string; error: string };
export type ProjectAgentResultSummary = {
agentId: string;
runId: string;
label: string;
title: string;
content: string;
updatedAt: number;
};
export type ProjectAgentRuntimeSummary = {
group: GameCreationAppAgentGroup;
label: string;
status: 'pending' | 'running' | 'waiting' | 'completed' | 'failed';
statusLabel: string;
currentTask: string;
currentAction: string | null;
waitingOn: string | null;
completedCount: number;
totalCount: number;
};
const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = [];
const emptyProjectAgentResults: ProjectAgentResultSummary[] = [];
type AgentSummary = ProjectAgentRuntimeSummary;
export type ProjectDevelopmentViewProps = {
projectName: string;
projectPath: string;
manifest: GameCreationAppManifest;
attachments: AttachmentResult[];
recentRunStatus: string | null;
recentRunStopReason: string | null;
preview?: GameCreationAppPreviewState | null;
agentRuntimeSummaries?: ProjectAgentRuntimeSummary[];
agentResults?: ProjectAgentResultSummary[];
supervisor: ReactNode;
onHomeOpen: () => void;
onProjectsOpen: () => void;
};
const categoryOrder: ResourceCategory[] = [
'document',
'version',
'art',
'audio',
];
const categoryLabels: Record<ResourceCategory, string> = {
document: '文档',
version: '项目版本',
art: '美术资源',
audio: '音乐音效资源',
};
const categoryIcons: Record<ResourceCategory, typeof FileText> = {
document: FileText,
version: Gamepad2,
art: Image,
audio: Music2,
};
const approvalOptions: Array<{
id: ApprovalMode;
label: string;
detail: string;
available: boolean;
}> = [
{
id: 'strict',
label: '严格审批',
detail: '所有消耗泥点的生成事务与 Agent 请求均需确认',
available: true,
},
{
id: 'risk',
label: '风险审批',
detail: 'Rank 规则待定,当前暂不可用',
available: false,
},
{
id: 'none',
label: '无需审批',
detail: 'Runtime 安全合同尚未完成,当前暂不可用',
available: false,
},
];
function fileName(path: string) {
return path.split(/[\\/]/).filter(Boolean).pop() || path;
}
function resolveEmbeddedPreviewUrl(
preview: GameCreationAppPreviewState | null,
) {
if (preview?.status !== 'running' || !preview.url) {
return null;
}
try {
const url = new URL(preview.url);
if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1') {
return null;
}
return url.toString();
} catch {
return null;
}
}
function categoryFromResource(path: string, mediaType: string) {
const normalizedPath = path.toLowerCase();
const normalizedMediaType = mediaType.toLowerCase();
if (
normalizedMediaType.startsWith('audio/') ||
/\.(mp3|wav|ogg|m4a|aac|flac)$/u.test(normalizedPath)
) {
return 'audio' as const;
}
if (
normalizedMediaType.startsWith('image/') ||
normalizedMediaType.startsWith('video/') ||
/\.(png|jpe?g|webp|gif|svg|mp4|webm)$/u.test(normalizedPath)
) {
return 'art' as const;
}
if (
normalizedMediaType.includes('json') ||
normalizedMediaType.startsWith('text/') ||
/\.(md|txt|json|ya?ml|toml)$/u.test(normalizedPath)
) {
return 'document' as const;
}
return 'version' as const;
}
function isRasterImageResource(resource: ProjectResource) {
const mediaType = resource.mediaType.toLowerCase();
return (
['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(
mediaType,
) || /\.(png|jpe?g|webp)$/iu.test(resource.path)
);
}
function imagePreviewErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('项目权限策略要求用户确认')) {
return '当前项目策略要求先确认读取图片';
}
if (message.includes('项目权限策略拒绝执行')) {
return '当前项目策略不允许读取图片';
}
if (message.includes('单张图片不能超过')) {
return message.replace('image.inspect', '图片预览');
}
if (message.includes('只支持 PNG、JPEG 或 WEBP')) {
return '暂时只能预览 PNG、JPEG 或 WEBP 图片';
}
if (message.includes('不能为空')) {
return '图片文件为空,无法预览';
}
if (message.includes('发生漂移') || message.includes('发生替换')) {
return '图片读取期间发生变化,请关闭后重试';
}
if (message.includes('图片尺寸过大')) {
return '图片尺寸过大,暂时无法在客户端预览';
}
if (message.includes('图片结构无效')) {
return '图片内容损坏,无法预览';
}
return '图片暂时无法读取,请关闭后重试';
}
function taskDependencyDepth(
task: GameCreationAppTaskState,
taskById: Map<string, GameCreationAppTaskState>,
seen = new Set<string>(),
): number {
if (seen.has(task.id) || task.dependencies.length === 0) {
return 0;
}
const nextSeen = new Set(seen).add(task.id);
return (
1 +
Math.max(
0,
...task.dependencies.map((dependency) => {
const dependencyTask = taskById.get(dependency);
return dependencyTask
? taskDependencyDepth(dependencyTask, taskById, nextSeen)
: 0;
}),
)
);
}
function resourcesFromProject(
manifest: GameCreationAppManifest,
attachments: AttachmentResult[],
agentResults: ProjectAgentResultSummary[],
) {
const taskById = new Map(manifest.tasks.map((task) => [task.id, task]));
const resources: ProjectResource[] = [];
for (const task of manifest.tasks) {
if (task.status !== 'completed') {
continue;
}
for (const path of task.artifacts) {
const category = categoryFromResource(path, '');
resources.push({
id: `task:${task.id}:${path}`,
category,
label: fileName(path),
path,
mediaType: category === 'document' ? '项目文档' : '项目产物',
sourceLabel: '任务产物',
taskTitle: task.title,
dependencies: task.dependencies,
dependencyDepth: taskDependencyDepth(task, taskById),
});
}
}
for (const asset of manifest.assets) {
const task = asset.source.taskId
? taskById.get(asset.source.taskId)
: undefined;
const isPendingUiPrototype =
asset.kind === 'ui-prototype' &&
taskById.get('design-foundation')?.status !== 'completed';
resources.push({
id: `asset:${asset.id}`,
category: categoryFromResource(asset.localPath, asset.mediaType),
label: `${fileName(asset.localPath)}${
isPendingUiPrototype ? '(待视觉验收)' : ''
}`,
path: asset.localPath,
mediaType: asset.mediaType,
sourceLabel:
isPendingUiPrototype && asset.source.kind === 'canvas'
? '画板 · 候选界面图'
: asset.source.kind === 'canvas'
? '画板'
: asset.source.kind === 'generated'
? 'Agent 生成'
: '用户上传',
taskTitle: task?.title ?? null,
dependencies: task?.dependencies ?? [],
dependencyDepth: task ? taskDependencyDepth(task, taskById) : 0,
});
}
for (const attachment of attachments) {
if (attachment.status !== 'imported' || !attachment.localPath) {
continue;
}
resources.push({
id: `attachment:${attachment.localPath}`,
category: categoryFromResource(
attachment.localPath,
attachment.mediaType,
),
label: attachment.fileName,
path: attachment.localPath,
mediaType: attachment.mediaType || '未知媒体类型',
sourceLabel: '用户上传',
taskTitle: null,
dependencies: [],
dependencyDepth: 0,
});
}
for (const result of agentResults) {
resources.push({
id: `agent-result:${result.agentId}:${result.runId}`,
category: 'document',
label: result.title,
path: `专业 Agent 文本回执 · ${result.label}`,
mediaType: 'Agent 历史文本回执',
sourceLabel: `历史成果 · ${result.label}`,
taskTitle: null,
dependencies: [],
dependencyDepth: 0,
content: result.content,
});
}
const uniqueByPath = new Map<string, ProjectResource>();
for (const resource of resources) {
const existing = uniqueByPath.get(resource.path);
if (!existing || resource.id.startsWith('asset:')) {
uniqueByPath.set(resource.path, resource);
}
}
return Array.from(uniqueByPath.values());
}
function sortCategoryResources(
resources: ProjectResource[],
mode: ResourceSortMode,
) {
return [...resources].sort((left, right) => {
if (mode === 'dependency') {
return (
left.dependencyDepth - right.dependencyDepth ||
left.label.localeCompare(right.label, 'zh-CN')
);
}
return left.label.localeCompare(right.label, 'zh-CN');
});
}
function buildResourceOrder(resources: ProjectResource[]): ResourceOrderState {
return {
dependency: Object.fromEntries(
categoryOrder.map((category) => [
category,
sortCategoryResources(
resources.filter((resource) => resource.category === category),
'dependency',
).map((resource) => resource.id),
]),
) as Record<ResourceCategory, string[]>,
type: Object.fromEntries(
categoryOrder.map((category) => [
category,
sortCategoryResources(
resources.filter((resource) => resource.category === category),
'type',
).map((resource) => resource.id),
]),
) as Record<ResourceCategory, string[]>,
};
}
function reconcileResourceOrder(
current: ResourceOrderState,
resources: ProjectResource[],
) {
const next: ResourceOrderState = {
dependency: {
document: [...current.dependency.document],
version: [...current.dependency.version],
art: [...current.dependency.art],
audio: [...current.dependency.audio],
},
type: {
document: [...current.type.document],
version: [...current.type.version],
art: [...current.type.art],
audio: [...current.type.audio],
},
};
for (const mode of ['dependency', 'type'] as const) {
for (const category of categoryOrder) {
const categoryResources = resources.filter(
(resource) => resource.category === category,
);
const validIds = new Set(
categoryResources.map((resource) => resource.id),
);
const preservedIds = current[mode][category].filter((id) =>
validIds.has(id),
);
const preservedSet = new Set(preservedIds);
const appendedIds = sortCategoryResources(categoryResources, mode)
.map((resource) => resource.id)
.filter((id) => !preservedSet.has(id));
next[mode][category] = [...preservedIds, ...appendedIds];
}
}
return next;
}
function summarizeAgent(
manifest: GameCreationAppManifest,
group: AgentSummary['group'],
label: string,
): AgentSummary {
const tasks = manifest.tasks.filter((task) => task.group === group);
const activeTask =
tasks.find((task) =>
['running', 'waiting-for-confirmation'].includes(task.status),
) ?? tasks.find((task) => task.status === 'pending');
const completedCount = tasks.filter(
(task) => task.status === 'completed',
).length;
const status = tasks.some((task) => task.status === 'failed')
? 'failed'
: tasks.some((task) => task.status === 'waiting-for-confirmation')
? 'waiting'
: tasks.some((task) => task.status === 'running')
? 'running'
: tasks.length > 0 && completedCount === tasks.length
? 'completed'
: 'pending';
return {
group,
label,
status,
statusLabel: {
pending: '等待中',
running: '工作中',
waiting: '待确认',
completed: '已完成',
failed: '需处理',
}[status],
currentTask:
activeTask?.title ??
(completedCount > 0 ? '本轮任务已完成' : '等待项目总控分配任务'),
currentAction: null,
waitingOn: null,
completedCount,
totalCount: tasks.length,
};
}
function ResourceCard({
resource,
selected,
dragging,
dropPlacement,
onSelect,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
}: {
resource: ProjectResource;
selected: boolean;
dragging: boolean;
dropPlacement: ResourceDropTarget['placement'] | null;
onSelect: () => void;
onDragStart: (event: DragEvent<HTMLButtonElement>) => void;
onDragOver: (event: DragEvent<HTMLButtonElement>) => void;
onDrop: (event: DragEvent<HTMLButtonElement>) => void;
onDragEnd: () => void;
}) {
const Icon = categoryIcons[resource.category];
return (
<button
type="button"
className={`game-resource-card${selected ? ' is-selected' : ''}${
dragging ? ' is-dragging' : ''
}${dropPlacement ? ` is-drop-${dropPlacement}` : ''}`}
aria-pressed={selected}
draggable
data-resource-id={resource.id}
title="拖动可调整当前会话中的排列"
style={
{
'--resource-depth': Math.min(6, resource.dependencyDepth),
} as CSSProperties
}
onClick={onSelect}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
>
<span className="game-resource-card-icon">
<Icon size={20} aria-hidden="true" />
</span>
<strong>{resource.label}</strong>
<small>{resource.sourceLabel}</small>
<small>{resource.path}</small>
</button>
);
}
export default function ProjectDevelopmentView({
projectName,
projectPath,
manifest,
attachments,
preview: previewOverride,
agentRuntimeSummaries = emptyProjectAgentRuntimeSummaries,
agentResults = emptyProjectAgentResults,
supervisor,
}: ProjectDevelopmentViewProps) {
const [mode, setMode] = useState<WorkbenchMode>('resources');
const [sortMode, setSortMode] = useState<ResourceSortMode>('dependency');
const [searchText, setSearchText] = useState('');
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(
null,
);
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
const [approvalNotice, setApprovalNotice] = useState('');
const [showAllAgentGroups, setShowAllAgentGroups] = useState(false);
const [runPlaying, setRunPlaying] = useState(false);
const [activeSlice, setActiveSlice] = useState(0);
const [draggedResourceId, setDraggedResourceId] = useState<string | null>(
null,
);
const [resourceDropTarget, setResourceDropTarget] =
useState<ResourceDropTarget | null>(null);
const [reorderNotice, setReorderNotice] = useState('');
const [resourceDialogPosition, setResourceDialogPosition] =
useState<Point | null>(null);
const [imagePreview, setImagePreview] = useState<ImagePreviewState>({
status: 'idle',
resourceId: null,
});
const workbenchRef = useRef<HTMLElement>(null);
const stageRef = useRef<HTMLElement>(null);
const dockRef = useRef<HTMLElement>(null);
const resourceDialogRef = useRef<HTMLElement>(null);
const orderedProjectPathRef = useRef(projectPath);
const resourceDialogDragRef = useRef<{
pointerId: number;
offsetX: number;
offsetY: number;
} | null>(null);
const preview = previewOverride ?? manifest.preview ?? null;
const embeddedPreviewUrl = resolveEmbeddedPreviewUrl(preview);
const runAvailable =
embeddedPreviewUrl !== null ||
manifest.tasks.some(
(task) => task.id === 'code-prototype' && task.status === 'completed',
);
const resources = useMemo(
() => resourcesFromProject(manifest, attachments, agentResults),
[agentResults, attachments, manifest],
);
const [resourceOrder, setResourceOrder] = useState<ResourceOrderState>(() =>
buildResourceOrder(resources),
);
const normalizedSearch = searchText.trim().toLowerCase();
const resourceOrderIndex = new Map(
categoryOrder.flatMap((category) =>
resourceOrder[sortMode][category].map(
(id, index) => [id, index] as const,
),
),
);
const visibleResources = [...resources]
.filter((resource) =>
normalizedSearch
? [
resource.label,
resource.path,
resource.mediaType,
resource.taskTitle ?? '',
].some((value) => value.toLowerCase().includes(normalizedSearch))
: true,
)
.sort(
(left, right) =>
categoryOrder.indexOf(left.category) -
categoryOrder.indexOf(right.category) ||
(resourceOrderIndex.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
(resourceOrderIndex.get(right.id) ?? Number.MAX_SAFE_INTEGER),
);
const selectedResource =
resources.find((resource) => resource.id === selectedResourceId) ?? null;
const selectedResourceIsImage = Boolean(
selectedResource && isRasterImageResource(selectedResource),
);
const hasRegisteredArtImageAssets = manifest.assets.some(
(asset) =>
asset.kind === 'art-spritesheet' && asset.mediaType.startsWith('image/'),
);
const allAgentSummaries = [
summarizeAgent(manifest, 'design', '策划 Agent'),
summarizeAgent(manifest, 'art', '美术 Agent'),
summarizeAgent(manifest, 'code', '程序 Agent'),
summarizeAgent(manifest, 'balance', '数值 Agent'),
summarizeAgent(manifest, 'audio', '音频 Agent'),
summarizeAgent(manifest, 'publishing', '发布 Agent'),
].map((manifestSummary) => {
const runtimeSummary =
agentRuntimeSummaries.find(
(runtimeSummary) => runtimeSummary.group === manifestSummary.group,
) ?? manifestSummary;
if (
runtimeSummary.group === 'art' &&
runtimeSummary.status === 'completed' &&
!hasRegisteredArtImageAssets
) {
return {
...runtimeSummary,
statusLabel: '仅完成计划',
currentTask: '美术资源计划已完成,尚未生成或登记图片',
};
}
return runtimeSummary;
});
const agentSummaries = showAllAgentGroups
? allAgentSummaries
: allAgentSummaries.slice(0, 3);
const currentApprovalLabel =
approvalOptions.find((option) => option.id === approvalMode)?.label ??
'严格审批';
useEffect(() => {
if (embeddedPreviewUrl) {
setMode('run');
}
}, [embeddedPreviewUrl]);
useEffect(() => {
if (orderedProjectPathRef.current !== projectPath) {
orderedProjectPathRef.current = projectPath;
setResourceOrder(buildResourceOrder(resources));
setSelectedResourceId(null);
clearResourceDrag();
return;
}
setResourceOrder((current) => reconcileResourceOrder(current, resources));
}, [projectPath, resources]);
useEffect(() => {
if (!reorderNotice) {
return undefined;
}
const timeout = window.setTimeout(() => setReorderNotice(''), 2400);
return () => window.clearTimeout(timeout);
}, [reorderNotice]);
useEffect(() => {
if (!selectedResourceId) {
return undefined;
}
function closeOnEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
setSelectedResourceId(null);
}
}
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [selectedResourceId]);
useEffect(() => {
if (!selectedResource || !selectedResourceIsImage) {
setImagePreview({ status: 'idle', resourceId: null });
return undefined;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setImagePreview({
status: 'failed',
resourceId: selectedResource.id,
error: '图片预览需要在客户端内打开',
});
return undefined;
}
let cancelled = false;
setImagePreview({ status: 'loading', resourceId: selectedResource.id });
void invoke<LocalProjectImagePreview>('read_local_project_image_preview', {
projectPath,
relativePath: selectedResource.path,
})
.then((preview) => {
if (!cancelled) {
setImagePreview({
status: 'loaded',
resourceId: selectedResource.id,
preview,
});
}
})
.catch((error: unknown) => {
if (!cancelled) {
setImagePreview({
status: 'failed',
resourceId: selectedResource.id,
error: imagePreviewErrorMessage(error),
});
}
});
return () => {
cancelled = true;
};
}, [projectPath, selectedResource, selectedResourceIsImage]);
const clampResourceDialogPosition = useCallback((x: number, y: number) => {
const dialog = resourceDialogRef.current;
const workbench = workbenchRef.current;
const dock = dockRef.current;
if (!dialog) {
return { x, y };
}
const dialogRect = dialog.getBoundingClientRect();
const workbenchRect = workbench?.getBoundingClientRect();
const dockRect = dock?.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const minX = Math.max(12, (workbenchRect?.left ?? 0) + 12);
const maxRight = Math.min(
viewportWidth - 12,
(workbenchRect?.right ?? viewportWidth) - 12,
);
const minY = Math.max(12, (workbenchRect?.top ?? 0) + 12);
const maxBottom = Math.min(
viewportHeight - 12,
dockRect ? dockRect.top - 12 : viewportHeight - 12,
);
const maxX = Math.max(minX, maxRight - dialogRect.width);
const maxY = Math.max(minY, maxBottom - dialogRect.height);
return {
x: Math.min(Math.max(x, minX), maxX),
y: Math.min(Math.max(y, minY), maxY),
};
}, []);
useLayoutEffect(() => {
if (!selectedResource) {
setResourceDialogPosition(null);
return;
}
const dialog = resourceDialogRef.current;
const stage = stageRef.current;
if (!dialog || !stage) {
return;
}
const dialogRect = dialog.getBoundingClientRect();
const stageRect = stage.getBoundingClientRect();
setResourceDialogPosition(
clampResourceDialogPosition(
stageRect.left + (stageRect.width - dialogRect.width) / 2,
stageRect.top + (stageRect.height - dialogRect.height) / 2,
),
);
dialog.focus({ preventScroll: true });
}, [clampResourceDialogPosition, selectedResource]);
useEffect(() => {
if (!selectedResource) {
return undefined;
}
function clampOnResize() {
setResourceDialogPosition((current) =>
current ? clampResourceDialogPosition(current.x, current.y) : current,
);
}
window.addEventListener('resize', clampOnResize);
return () => window.removeEventListener('resize', clampOnResize);
}, [clampResourceDialogPosition, selectedResource]);
function handleResourceDragStart(
event: DragEvent<HTMLButtonElement>,
resource: ProjectResource,
) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', resource.id);
setDraggedResourceId(resource.id);
setResourceDropTarget(null);
}
function handleResourceDragOver(
event: DragEvent<HTMLButtonElement>,
targetResource: ProjectResource,
) {
const draggedResource = resources.find(
(resource) => resource.id === draggedResourceId,
);
if (
!draggedResource ||
draggedResource.id === targetResource.id ||
draggedResource.category !== targetResource.category
) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const targetRect = event.currentTarget.getBoundingClientRect();
setResourceDropTarget({
resourceId: targetResource.id,
placement:
event.clientX < targetRect.left + targetRect.width / 2
? 'before'
: 'after',
});
}
function clearResourceDrag() {
setDraggedResourceId(null);
setResourceDropTarget(null);
}
function handleResourceDrop(
event: DragEvent<HTMLButtonElement>,
targetResource: ProjectResource,
) {
event.preventDefault();
const sourceId =
draggedResourceId || event.dataTransfer.getData('text/plain');
const sourceResource = resources.find(
(resource) => resource.id === sourceId,
);
if (
!sourceResource ||
sourceResource.category !== targetResource.category ||
sourceResource.id === targetResource.id
) {
clearResourceDrag();
return;
}
const placement =
resourceDropTarget?.resourceId === targetResource.id
? resourceDropTarget.placement
: 'before';
setResourceOrder((current) => {
const categoryOrderIds = current[sortMode][targetResource.category];
const nextOrder = categoryOrderIds.filter((id) => id !== sourceId);
const targetIndex = nextOrder.indexOf(targetResource.id);
nextOrder.splice(
Math.max(0, targetIndex + (placement === 'after' ? 1 : 0)),
0,
sourceId,
);
return {
...current,
[sortMode]: {
...current[sortMode],
[targetResource.category]: nextOrder,
},
};
});
setReorderNotice('已调整当前会话排列');
clearResourceDrag();
}
function handleResourceDialogPointerDown(
event: ReactPointerEvent<HTMLElement>,
) {
if (event.button !== 0 || (event.target as HTMLElement).closest('button')) {
return;
}
const dialogRect = resourceDialogRef.current?.getBoundingClientRect();
if (!dialogRect) {
return;
}
resourceDialogDragRef.current = {
pointerId: event.pointerId,
offsetX: event.clientX - dialogRect.left,
offsetY: event.clientY - dialogRect.top,
};
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
}
function handleResourceDialogPointerMove(
event: ReactPointerEvent<HTMLElement>,
) {
const drag = resourceDialogDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
setResourceDialogPosition(
clampResourceDialogPosition(
event.clientX - drag.offsetX,
event.clientY - drag.offsetY,
),
);
}
function handleResourceDialogPointerEnd(
event: ReactPointerEvent<HTMLElement>,
) {
if (resourceDialogDragRef.current?.pointerId !== event.pointerId) {
return;
}
resourceDialogDragRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
}
function showRunView() {
if (!runAvailable) {
return;
}
setMode('run');
}
return (
<section
ref={workbenchRef}
className="launcher-page launcher-project-development game-project-workbench"
aria-label="项目开发工作台"
>
<div className="game-workbench-layout">
<section
ref={stageRef}
className="game-workbench-stage"
aria-label="项目主视窗"
>
<div className="game-workbench-toolbar">
<div className="game-workbench-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={mode === 'resources'}
className={mode === 'resources' ? 'is-active' : ''}
onClick={() => setMode('resources')}
>
资源管理
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'run'}
aria-describedby={
!runAvailable ? 'run-unavailable-hint' : undefined
}
data-unavailable={!runAvailable || undefined}
className={mode === 'run' ? 'is-active' : ''}
onClick={showRunView}
>
运行
</button>
</div>
<div className="game-workbench-view-actions">
{mode === 'resources' ? (
<>
<button
type="button"
className={sortMode === 'dependency' ? 'is-active' : ''}
aria-pressed={sortMode === 'dependency'}
onClick={() => setSortMode('dependency')}
>
<FolderTree size={15} aria-hidden="true" />
按依赖
</button>
<span
className="game-resource-reorder-status"
role="status"
aria-live="polite"
>
{reorderNotice}
</span>
<button
type="button"
className={sortMode === 'type' ? 'is-active' : ''}
aria-pressed={sortMode === 'type'}
onClick={() => setSortMode('type')}
>
<ListFilter size={15} aria-hidden="true" />
按类型
</button>
</>
) : null}
</div>
</div>
{!runAvailable ? (
<p
id="run-unavailable-hint"
className="game-run-unavailable"
role="status"
>
首个可运行原型尚未完成,运行视图暂不可用
</p>
) : null}
{mode === 'resources' ? (
<div className="game-resource-manager">
<label className="game-resource-search">
<Search size={16} aria-hidden="true" />
<input
type="search"
aria-label="搜索项目资源"
value={searchText}
placeholder="搜索资源名称、路径或类型"
onChange={(event) => setSearchText(event.currentTarget.value)}
/>
</label>
{attachments.some(
(attachment) => attachment.status === 'failed',
) ? (
<section
className="game-attachment-errors"
aria-label="附件导入失败"
role="status"
>
{attachments
.filter((attachment) => attachment.status === 'failed')
.map((attachment) => (
<p key={`${attachment.fileName}:${attachment.error}`}>
<strong>{attachment.fileName}</strong>
<span>{attachment.error || '附件导入失败'}</span>
</p>
))}
</section>
) : null}
<div
className={`game-resource-canvas game-resource-canvas--${sortMode}`}
aria-label={
sortMode === 'dependency' ? '资源依赖视图' : '资源类型视图'
}
>
{categoryOrder.map((category) => {
const categoryResources = visibleResources.filter(
(resource) => resource.category === category,
);
const Icon = categoryIcons[category];
return (
<section
className="game-resource-section"
aria-label={categoryLabels[category]}
key={category}
>
<header>
<span>
<Icon size={16} aria-hidden="true" />
{categoryLabels[category]}
</span>
<small>{categoryResources.length}</small>
</header>
{categoryResources.length > 0 ? (
<div className="game-resource-row">
{categoryResources.map((resource) => (
<ResourceCard
key={resource.id}
resource={resource}
selected={resource.id === selectedResourceId}
dragging={resource.id === draggedResourceId}
dropPlacement={
resourceDropTarget?.resourceId === resource.id
? resourceDropTarget.placement
: null
}
onSelect={() =>
setSelectedResourceId(resource.id)
}
onDragStart={(event) =>
handleResourceDragStart(event, resource)
}
onDragOver={(event) =>
handleResourceDragOver(event, resource)
}
onDrop={(event) =>
handleResourceDrop(event, resource)
}
onDragEnd={clearResourceDrag}
/>
))}
</div>
) : (
<p>暂无已登记资源</p>
)}
</section>
);
})}
</div>
</div>
) : (
<section className="game-run-surface" aria-label="运行表现层">
<div className="game-run-preview">
{embeddedPreviewUrl ? (
<iframe
title={`${projectName} 游戏运行画面`}
src={embeddedPreviewUrl}
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"
allow="autoplay; fullscreen; gamepad"
/>
) : (
<div className="game-run-preview-empty">
<Sparkles size={28} aria-hidden="true" />
<strong>客户端运行画面尚未载入</strong>
<span>发送 /run /preview 后将在这里直接运行游戏</span>
</div>
)}
</div>
<div
className="game-run-slice-controls"
aria-label="测试切片控件"
>
<button
type="button"
aria-label="上一个测试切片"
onClick={() =>
setActiveSlice((current) => Math.max(0, current - 1))
}
>
<ChevronLeft size={16} aria-hidden="true" />
上一项
</button>
<button
type="button"
aria-label={runPlaying ? '暂停测试' : '播放测试'}
onClick={() => setRunPlaying((playing) => !playing)}
>
{runPlaying ? (
<Pause size={18} aria-hidden="true" />
) : (
<Play size={18} aria-hidden="true" />
)}
</button>
<span>{`测试切片 ${activeSlice + 1}`}</span>
<button
type="button"
aria-label="下一个测试切片"
onClick={() => setActiveSlice((current) => current + 1)}
>
下一项
<ChevronRight size={16} aria-hidden="true" />
</button>
</div>
<div className="game-run-panels">
<section aria-label="资源信息面板">
<header>
<Info size={16} aria-hidden="true" />
信息展示
</header>
{selectedResource ? (
<dl>
<div>
<dt>名称</dt>
<dd>{selectedResource.label}</dd>
</div>
<div>
<dt>路径</dt>
<dd>{selectedResource.path}</dd>
</div>
<div>
<dt>类型</dt>
<dd>{selectedResource.mediaType}</dd>
</div>
</dl>
) : (
<p>暂停后选择资源可查看已登记信息</p>
)}
</section>
<section aria-label="数值微调面板">
<header>
<SlidersHorizontal size={16} aria-hidden="true" />
数值微调
</header>
<label>
角色移动速度
<input type="number" disabled placeholder="未载入" />
</label>
<label>
跳跃高度
<input type="number" disabled placeholder="未载入" />
</label>
<input
type="text"
disabled
aria-label="新增微调数值项"
placeholder="用自然语言新增微调项(尚未接入)"
/>
</section>
</div>
</section>
)}
</section>
<aside className="game-workbench-chat" aria-label="陶泥儿 Agent 对话">
<header>
<span className="game-workbench-chat-avatar"></span>
<div>
<strong>与陶泥儿的对话</strong>
<small>项目总控 Agent</small>
</div>
<button
type="button"
className="game-workbench-approval-trigger"
onClick={() => setApprovalDialogOpen(true)}
aria-label={`审批配置,当前${currentApprovalLabel}`}
>
<Settings2 size={14} aria-hidden="true" />
{currentApprovalLabel}
</button>
</header>
{supervisor}
</aside>
</div>
<footer
ref={dockRef}
className="game-agent-dock"
aria-label="子 Agent 状态栏"
>
{agentSummaries.map((agent) => (
<article
className={`game-agent-dock-item is-${agent.status}`}
key={agent.group}
aria-label={`${agent.label}${agent.statusLabel}`}
aria-describedby={`agent-detail-${agent.group}`}
>
<span className="game-agent-avatar">
{{
design: '策',
art: '美',
code: '程',
balance: '数',
audio: '音',
publishing: '发',
}[agent.group]}
</span>
<span>
<strong>{agent.label}</strong>
<small>{agent.statusLabel}</small>
</span>
<span
className="game-agent-dock-detail"
id={`agent-detail-${agent.group}`}
role="tooltip"
>
<strong>{agent.currentTask}</strong>
{agent.currentAction ? (
<small>{agent.currentAction}</small>
) : null}
{agent.waitingOn ? (
<small>{`等待:${agent.waitingOn}`}</small>
) : null}
<small>{`任务完成 ${agent.completedCount}/${agent.totalCount}`}</small>
</span>
</article>
))}
<button
type="button"
className="game-agent-dock-more"
aria-expanded={showAllAgentGroups}
onClick={() => setShowAllAgentGroups((current) => !current)}
>
{showAllAgentGroups ? '收起小组' : '更多小组'}
</button>
</footer>
{selectedResource ? (
<div className="game-resource-focus-layer">
<aside
ref={resourceDialogRef}
className={`game-resource-focus game-resource-focus--${selectedResource.category}`}
aria-label="资源焦点"
aria-labelledby="game-resource-focus-title"
role="dialog"
tabIndex={-1}
style={
resourceDialogPosition
? {
left: resourceDialogPosition.x,
top: resourceDialogPosition.y,
}
: undefined
}
>
<header
className="game-resource-focus-titlebar"
onPointerDown={handleResourceDialogPointerDown}
onPointerMove={handleResourceDialogPointerMove}
onPointerUp={handleResourceDialogPointerEnd}
onPointerCancel={handleResourceDialogPointerEnd}
>
<span className="game-resource-focus-heading">
<span className="game-resource-focus-icon">
{selectedResource.category === 'document' ? (
<FileText size={22} aria-hidden="true" />
) : selectedResource.category === 'audio' ? (
<Music2 size={22} aria-hidden="true" />
) : (
<Image size={22} aria-hidden="true" />
)}
</span>
<strong id="game-resource-focus-title">
{selectedResource.label}
</strong>
</span>
<button
type="button"
className="game-resource-focus-close"
aria-label="收起资源"
onClick={() => setSelectedResourceId(null)}
>
<X size={16} aria-hidden="true" />
</button>
</header>
<div className="game-resource-focus-body">
{selectedResourceIsImage ? (
<div
className="game-resource-image-preview"
aria-label={`${selectedResource.label} 图片预览`}
>
{imagePreview.status === 'loaded' &&
imagePreview.resourceId === selectedResource.id ? (
<img
src={imagePreview.preview.dataUrl}
alt={`${selectedResource.label} 图片预览`}
onError={() =>
setImagePreview({
status: 'failed',
resourceId: selectedResource.id,
error: '图片内容无法解码,请重新生成或替换该资源',
})
}
/>
) : imagePreview.status === 'failed' &&
imagePreview.resourceId === selectedResource.id ? (
<p role="alert">{imagePreview.error}</p>
) : (
<p role="status">正在载入图片…</p>
)}
</div>
) : null}
<span>{selectedResource.path}</span>
<small>{selectedResource.mediaType}</small>
<small>{selectedResource.sourceLabel}</small>
{selectedResource.taskTitle ? (
<small>{`来源任务:${selectedResource.taskTitle}`}</small>
) : null}
{selectedResource.category === 'document' ? (
<div className="game-resource-document-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{selectedResource.content ?? '文档内容尚未载入'}
</ReactMarkdown>
</div>
) : null}
</div>
</aside>
</div>
) : null}
{approvalDialogOpen ? (
<div
className="game-approval-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setApprovalDialogOpen(false);
}
}}
>
<section
className="game-approval-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="game-approval-title"
>
<header>
<div>
<h2 id="game-approval-title">陶泥儿的操作权限</h2>
<p>P0 仅开放严格审批</p>
</div>
<button
type="button"
aria-label="关闭审批配置"
onClick={() => setApprovalDialogOpen(false)}
>
<X size={16} aria-hidden="true" />
</button>
</header>
<div className="game-approval-options" role="radiogroup">
{approvalOptions.map((option) => (
<button
type="button"
role="radio"
aria-checked={approvalMode === option.id}
data-unavailable={!option.available || undefined}
className={`${approvalMode === option.id ? 'is-selected' : ''}${
option.available ? '' : ' is-unavailable'
}`}
key={option.id}
onClick={() => {
if (!option.available) {
setApprovalNotice(option.detail);
return;
}
setApprovalMode(option.id);
setApprovalNotice('');
}}
>
<span>
<strong>{option.label}</strong>
<small>{option.detail}</small>
</span>
<span className="game-approval-check" aria-hidden="true">
{approvalMode === option.id ? '✓' : ''}
</span>
</button>
))}
</div>
{approvalNotice ? (
<p className="game-approval-notice" role="status">
{approvalNotice}
</p>
) : null}
<button
type="button"
className="game-approval-done"
onClick={() => setApprovalDialogOpen(false)}
>
完成
</button>
</section>
</div>
) : null}
</section>
);
}