diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index bac8a64d2..378ad7c2a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -6959,7 +6959,7 @@ export function App({ void refreshManifest(generatedProjectPath); // 启动预览的成功反馈走 toast,不再往对话区写一条「已保存并…启动预览:URL」; // run trace 摘要本身是对话内容,继续按消息发出。 - onRunNotice?.('运行通过,已载入客户端运行视图'); + onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); const completionText = completionSummary?.text; if (completionText) { setMessages((current) => [ @@ -8816,7 +8816,7 @@ export function App({ void refreshManifest(nextProjectPath); setCommandLog((current) => [...current, 'preview.start']); // 成功反馈走 toast + 运行区域上方的小字,不在对话区留行。 - onRunNotice?.('运行通过,已载入客户端运行视图'); + onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); } catch (error) { const message = error instanceof Error ? error.message : String(error); setPreviewStatus(message); @@ -8882,7 +8882,7 @@ export function App({ setCommandLog((current) => [...current, 'preview.open']); // 只有真的切到了运行视图才给反馈;没有活预览时不编一条成功提示。 if (activatedPreview) { - onRunNotice?.('已载入客户端运行视图'); + onRunNotice?.({ message: '已载入客户端运行视图' }); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -9517,7 +9517,7 @@ export function App({ } // 运行成功的提示不再写进聊天(会一直堆在对话底部遮挡运行画面):反馈走工作台壳的 // toast,预览地址由运行区域上方的小字常驻。失败仍按下面的分支回报。 - onRunNotice?.('运行通过,已载入客户端运行视图'); + onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); } catch (error) { const message = error instanceof Error ? error.message : String(error); setLimitedCommandStatus(message); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx b/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx index e76f6de99..39ad520ea 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx @@ -2,19 +2,20 @@ import { PlatformRuntimeStatusToast } from '@genarrative/shared/components'; import { useEffect } from 'react'; import { createPortal } from 'react-dom'; +import type { ProjectRunNotice } from './model'; + const RUN_NOTICE_MILLIS = 2600; -export type RunNotice = { +export type RunNotice = ProjectRunNotice & { /** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */ id: number; - message: string; }; /** * 运行 / 预览类动作的浮层提示。 * * 这类过程反馈以前以 assistant 消息写进对话区,会一直堆在对话底部挡住运行画面; - * 现在统一走 toast,对话区只保留对话内容,预览地址另由运行区域上方的小字常驻。 + * 现在统一走 toast,对话区只保留对话内容——运行页的预览地址改用顶栏的「在浏览器打开」。 */ export function RunNoticeToast({ notice, @@ -43,7 +44,7 @@ export function RunNoticeToast({ data-project-run-notice-toast="true" > { - setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, message })); + const handleRunNotice = useCallback((notice: ProjectRunNotice) => { + setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, ...notice })); }, []); function showLauncherNotice(title: string) { @@ -723,6 +723,7 @@ export function WorkspaceLauncherShell({ currentProjectContext.projectPath, ) } + onNotice={handleRunNotice} onManifestChange={syncActiveProjectManifest} onHomeOpen={() => setLauncherView('home')} onProjectsOpen={() => setLauncherView('projects')} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 6db4d2a11..73837f03e 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -36,6 +36,17 @@ export type WorkspaceLauncherProps = { initialView?: LauncherView; }; +/** + * 运行 / 预览类动作的一次性浮层提示。 + * + * `tone` 只区分观感(成功绿 / 失败红),文案由发出方给出:运行成功、切到运行视图、 + * 在浏览器打开失败都走这一条通道。 + */ +export type ProjectRunNotice = { + message: string; + tone?: 'success' | 'error'; +}; + export type ProjectSupervisorComponentProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; @@ -64,12 +75,12 @@ export type ProjectSupervisorComponentProps = { ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; /** - * 运行 / 预览类动作的一次性浮层提示(成功态)。 + * 运行 / 预览类动作的一次性浮层提示。 * - * 「跑起来了」「已切到运行视图」属于过程反馈,不进对话区——对话区只保留对话内容。 - * 工作台壳收到后弹 toast;预览地址由运行区域上方的小字常驻,不再占对话位置。 + * 「跑起来了」「已切到运行视图」「在浏览器打开失败」属于过程反馈,不进对话区—— + * 对话区只保留对话内容。工作台壳收到后弹 toast,`tone` 决定成功还是失败观感。 */ - onRunNotice?: (message: string) => void; + onRunNotice?: (notice: ProjectRunNotice) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 89fce1944..456eb928a 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -8397,10 +8397,10 @@ iframe.preview-frame { .game-run-surface { position: relative; display: grid; - /* 三行:状态行(预览地址小字 + 版本入口,都在游戏画面之外)、运行画面、信息面板。 */ - grid-template-rows: auto minmax(300px, 1fr) auto; + grid-template-rows: minmax(300px, 1fr) auto; grid-row: 2 / -1; - /* 显式钉第 1 列,避免画面被自动列放置挤到隐式列里。 */ + /* 显式钉第 1 列(见 resourceCanvasAssetGenerationTasksSidebar.css),避免画面被自动列放置 + 挤到隐式列里。 */ grid-column: 1; gap: 12px; height: 100%; @@ -8410,64 +8410,25 @@ iframe.preview-frame { } /* - * 运行区域上方的状态行:左边是预览地址小字,右边是版本入口(没有版本时不渲染)。 + * C7 版本入口:住在工作台顶栏动作区(`.game-workbench-view-actions`),外观由那一组的基础 + * 规则给(描边 + secondary 填充 + 12px/700),这里只补「版本名可能很长」这一件事。 * - * 两者必须在**同一层级、同一个画外侧**:版本入口以前是绝对定位压在游戏画面上 - * (top 20px / right 20px),小字在画面外,两者既不对齐又各自压着画面。 + * 它曾经是运行画面里的绝对定位浮层(top 20px / right 20px):压在游戏画面上,还与顶栏其他 + * 按钮分成两套皮。**不要再给它加 border / background / color / hover**——那就是第二套皮。 */ -.game-run-status-bar { - display: flex; - min-width: 0; - min-height: 30px; - align-items: center; - gap: 12px; -} - -.game-run-status-hint { - flex: 1 1 auto; - min-width: 0; - margin: 0; - padding: 0 2px; - color: #96796d; - font-size: 11px; - line-height: 16px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* C7 版本入口:与预览地址小字同排,靠右;没有版本时不渲染。 */ .game-run-version-picker { flex: 0 0 auto; - /* 没有小字时也要贴右,不能因为 flex 里只剩一个条目就跑到左边。 */ - margin-left: auto; + display: inline-flex; + align-items: center; } .game-run-version-trigger { - display: inline-flex; max-width: min(18rem, 60vw); - min-height: 30px; - align-items: center; - padding: 0 12px; - border: 1px solid #e5cfc4; - border-radius: 999px; - background: rgb(255 250 246 / 92%); - color: #8a4a30; - cursor: pointer; - font-size: 12px; - font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.game-run-version-trigger:hover, -.game-run-version-trigger:focus-visible { - border-color: #cc8060; - outline: 0; - box-shadow: 0 3px 10px rgb(158 87 57 / 14%); -} - .game-run-version-menu { display: grid; gap: 2px; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 048a11597..5d158a5d7 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -16,10 +16,12 @@ import { } from '@genarrative/image-canvas-react'; import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; +import { openUrl } from '@tauri-apps/plugin-opener'; import { AtSign, Box, Crosshair, + ExternalLink, Eye, FileCode2, FileText, @@ -107,6 +109,7 @@ import { isFloatingOverlayWheelEvent, useImageCanvasFloatingOptionDismiss, } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; +import type { ProjectRunNotice } from '../../features/app-shell/model'; import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel'; import { LocalGamePreviewFrame, @@ -785,6 +788,13 @@ export type ProjectDevelopmentViewProps = { onPlay?: () => void; onMakeGame?: () => void; onRevealProjectDirectory?: () => void | Promise; + /** + * 过程提示(成功 / 失败)的唯一出口。 + * + * 「运行起来了」「在浏览器打开失败」这类反馈不进对话区,交给工作台壳弹 toast; + * 本视图不自己造第二套提示位。 + */ + onNotice?: (notice: ProjectRunNotice) => void; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, @@ -1790,6 +1800,7 @@ export default function ProjectDevelopmentView({ onPlay, onMakeGame, onRevealProjectDirectory, + onNotice, }: ProjectDevelopmentViewProps) { const professionalDagVisible = orchestrationMode === 'professional-dag'; const [mode, setMode] = useState('resources'); @@ -2703,6 +2714,28 @@ export default function ProjectDevelopmentView({ const preview = previewOverride ?? manifest.preview ?? null; const embeddedPreviewUrl = resolveEmbeddedPreviewUrl(preview); + /** + * 「在浏览器打开」:把当前预览地址交给系统默认浏览器。 + * + * 这是**用户主动**动作。预览的启动与切换仍然只走内置运行画面、不自动开外部浏览器 + * (那条边界由 `scripts/check-native-shells.mjs` 的守卫钉着);失败时把原因交给 + * `onNotice` 弹 toast,不在工具条里另造一条提示位、也不写进对话区。 + */ + const openPreviewInBrowser = useCallback(async () => { + if (!embeddedPreviewUrl) { + return; + } + try { + await openUrl(embeddedPreviewUrl); + } catch (error) { + onNotice?.({ + tone: 'error', + message: `在浏览器打开失败:${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + }, [embeddedPreviewUrl, onNotice]); const runAvailable = embeddedPreviewUrl !== null || manifest.tasks.some( @@ -9611,6 +9644,22 @@ export default function ProjectDevelopmentView({ {runtimeInspectMode ? '退出点选' : '点选素材'} ) : null} + {/* + 当前预览地址的出口。以前这里是一条「已载入客户端运行视图:http://…」小字, + 长地址既占位又不可点:地址本身对用户没用,能一步跳浏览器才有用。 + 只在有活预览时出现,外观与其他动作按钮同款(见 `.game-workbench-view-actions button`)。 + */} + {mode === 'run' && embeddedPreviewUrl ? ( + + ) : null} {mode === 'resources' && !uiEditorRoute ? ( <>