676c4beb60
拆分前端、后端与原生壳测试任务 接入server-rs workspace正式全量测试 将AI游戏创作分支及独立依赖纳入CI 修复既有测试断言、格式与异步稳定性问题 稳定AI Tauri并发回执测试 更新开发运维和共享流程文档
1498 lines
48 KiB
TypeScript
1498 lines
48 KiB
TypeScript
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>
|
||
);
|
||
}
|