diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index ff2565b84..560b839d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -830,6 +830,22 @@ pub(crate) fn save_local_project_asset_file( save_local_project_asset_file_at(input) } +/// 在系统文件管理器里定位一份项目内产物。 +/// +/// 保存到用户选定位置失败时的兜底出口:原件还在项目里,把它的位置打开,用户自己复制。 +/// 路径由 [`reveal_local_project_asset_file_at`] 按「能保存就能定位」的同一条门禁复核。 +#[tauri::command] +pub(crate) fn reveal_local_project_asset_file( + app: tauri::AppHandle, + project_path: String, + relative_path: String, +) -> Result<(), String> { + let path = reveal_local_project_asset_file_at(project_path.trim(), relative_path.trim())?; + app.opener() + .reveal_item_in_dir(path) + .map_err(|error| format!("打开文件管理器失败:{error}")) +} + #[tauri::command] pub(crate) async fn read_local_project_image_preview( preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index e2e7080a1..7f4e1aadf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -668,6 +668,7 @@ pub(super) fn run() { read_local_project_file, read_local_project_image_preview, save_local_project_asset_file, + reveal_local_project_asset_file, read_local_project_text_preview, read_local_project_structured_preview, read_local_project_media_preview, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs index d63fef865..27f5b4226 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs @@ -210,6 +210,21 @@ pub(crate) fn save_local_project_asset_file_at( }) } +/// 解析要在系统文件管理器里定位的那份导出产物。 +/// +/// 与保存共用 [`resolve_export_source_file`]:只有项目根内真实存在的普通文件才会被打开,路径 +/// 门禁、敏感文件与控制面拒绝口径完全一致。它是"保存失败后的兜底出口"——不替用户挑落点,只把 +/// 原件所在位置打开,让他自己复制。 +pub(crate) fn reveal_local_project_asset_file_at( + project_path: &str, + relative_path: &str, +) -> Result { + let root = Path::new(project_path); + validate_project_root(root)?; + enforce_project_auto_permission_policy(root, "file.read")?; + resolve_export_source_file(root, relative_path) +} + #[cfg(test)] mod tests { use super::*; @@ -285,6 +300,23 @@ mod tests { let _ = std::fs::remove_dir_all(&destination_directory); } + #[test] + fn reveal_resolves_existing_project_file_and_rejects_missing() { + let (root, assets) = create_export_project(); + std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset"); + + let path = reveal_local_project_asset_file_at(&root.to_string_lossy(), "assets/hero.png") + .expect("reveal existing project file"); + assert_eq!(path, assets.join("hero.png")); + + let error = + reveal_local_project_asset_file_at(&root.to_string_lossy(), "assets/missing.png") + .expect_err("missing file must fail"); + assert!(error.contains("素材文件不存在或不可读"), "{error}"); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn rejects_directory_as_source_asset() { let (root, assets) = create_export_project(); diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts index f350ff7c7..e7879114b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolDownload.ts @@ -4,17 +4,29 @@ * 复用素材保存的既有链路 `save_local_project_asset_file`:路径门禁、敏感文件拒绝、自我覆盖保护 * 都在宿主那一侧,前端不自己读文件、不拼绝对路径、也不写第二份保存实现。 * - * 返回值说明这次到底存没存下来:`saved`(宿主写完)、`cancelled`(用户关掉对话框)、`failed` - * (对话框打不开 / 宿主拒绝写)。成功不必在面板上复述——落点是用户亲手选的,原生对话框就是回执; - * 失败则由调用方决定怎么告知,不能像以前那样只留一条控制台痕迹。 + * 返回值不只说明这次存没存下来,失败还带一句原因:`saved`(宿主写完)、`cancelled`(用户关掉 + * 对话框)、`failed`(对话框打不开 / 宿主拒绝写)。成功不必在面板上复述——落点是用户亲手选的, + * 原生对话框就是回执;失败则由调用方决定怎么告知,不能像以前那样只留一条控制台痕迹,也不能只给 + * 一句没有下文的「请重试」。 + * + * 同一条保存链路上还有 [`revealXhsMinitoolFileInManager`]:保存失败时用系统文件管理器打开原件 + * 所在位置,作为用户自己把文件拿出去的兜底出口。 */ import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { resolveTauriInvoke } from '../../../../app/tauri'; import { resourceFileNameFromPath } from '../../../../features/resource-canvas/resourceCanvasAssetTransferModel'; -/** 一次导出产物保存的结局。 */ -export type XhsMinitoolDownloadResult = 'saved' | 'cancelled' | 'failed'; +/** 一次导出产物保存的结局。失败带一句可展示的原因(宿主给的是中文人话)。 */ +export type XhsMinitoolDownloadResult = + | { status: 'saved' } + | { status: 'cancelled' } + | { status: 'failed'; reason: string }; + +/** 在系统文件管理器里定位一份导出产物的结局。 */ +export type XhsMinitoolRevealResult = + | { status: 'revealed' } + | { status: 'failed'; reason: string }; export async function saveXhsMinitoolFileToDisk(input: { projectPath: string; @@ -25,7 +37,9 @@ export async function saveXhsMinitoolFileToDisk(input: { title: string; }): Promise { const invoke = resolveTauriInvoke(); - if (!invoke) return 'failed'; + if (!invoke) { + return { status: 'failed', reason: '需要在应用内运行才能保存文件' }; + } let destination: string | null = null; try { destination = await saveNativeFileDialog({ @@ -37,10 +51,10 @@ export async function saveXhsMinitoolFileToDisk(input: { }); } catch (error) { console.warn('导出产物保存对话框没能打开', error); - return 'failed'; + return { status: 'failed', reason: '保存对话框没能打开' }; } // 用户取消对话框:什么都没发生,也没有话要说。 - if (!destination) return 'cancelled'; + if (!destination) return { status: 'cancelled' }; try { await invoke('save_local_project_asset_file', { input: { @@ -49,9 +63,51 @@ export async function saveXhsMinitoolFileToDisk(input: { destinationPath: destination, }, }); - return 'saved'; + return { status: 'saved' }; } catch (error) { console.warn('导出产物没能写到你选的位置', error); - return 'failed'; + return { status: 'failed', reason: exportFailureReason(error) }; } } + +/** + * 在系统文件管理器里定位这份导出产物。 + * + * 保存失败后的兜底出口:不去替用户挑落点,只把原件所在位置打开,让他自己复制。与保存共用同一条 + * 宿主路径门禁——能保存的原件才能被定位,打开的一定是项目里真实存在的那份文件。 + */ +export async function revealXhsMinitoolFileInManager(input: { + projectPath: string; + relativePath: string; +}): Promise { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return { status: 'failed', reason: '需要在应用内运行才能打开文件管理器' }; + } + try { + await invoke('reveal_local_project_asset_file', { + projectPath: input.projectPath, + relativePath: input.relativePath, + }); + return { status: 'revealed' }; + } catch (error) { + console.warn('导出产物没能在文件管理器里打开', error); + return { status: 'failed', reason: exportFailureReason(error) }; + } +} + +/** + * 把宿主或对话框抛出的原因收成一句能给用户看的话。 + * + * 宿主这条链路的拒绝都是中文人话(`Result<_, String>`),原样带出去;认不出形状时退回一句通用 + * 说明,不把 `[object Object]` 之类的内部形状糊到界面上。 + */ +function exportFailureReason(error: unknown): string { + if (typeof error === 'string') { + return error.trim() || '写入所选位置失败'; + } + if (error instanceof Error) { + return error.message.trim() || '写入所选位置失败'; + } + return '写入所选位置失败'; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index 0cb15f7a1..0583e9fbe 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -2,11 +2,18 @@ import { openUrl } from '@tauri-apps/plugin-opener'; import { type ReactNode, useState } from 'react'; import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport'; -import { saveXhsMinitoolFileToDisk } from '../../state/xhsMinitoolDownload'; +import { + revealXhsMinitoolFileInManager, + saveXhsMinitoolFileToDisk, +} from '../../state/xhsMinitoolDownload'; import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH } from '../../state/xhsMinitoolInstruction'; import { PaneButton } from '../common/PaneButton'; import { AdaptPromptDialog } from './AdaptPromptDialog'; import { ConflictCard } from './ConflictCard'; +import { + DownloadFailureDialog, + type XhsMinitoolDownloadFailure, +} from './DownloadFailureDialog'; import { FailureDialog } from './FailureDialog'; import { FormCard } from './FormCard'; @@ -23,9 +30,10 @@ const XHS_PUBLISH_URL = 'https://creator.xiaohongshu.com/new/red-app'; * 内容与脚本判据按固定间隔重读(所以没有刷新按钮)。 * * 「还没配置好导出」不再是一张常驻卡片,而是阻断弹窗:重读每 2 秒一次,卡片会跟着重读闪,弹窗则 - * 只在用户真的需要做选择时出现一次。失败现场同理走阻断弹窗(`FailureDialog`):左半本来就矮, - * 卡片会被滚出视野,而失败是用户必须看见的东西。可见文案说用户的话,不出现脚本名、注册表、zip - * 这些实现词。 + * 只在用户真的需要做选择时出现一次。失败现场同理走阻断弹窗:构建 / 读取失败走 `FailureDialog`, + * 下载失败走 `DownloadFailureDialog`(后者多给一条「在文件管理器中打开原件」的自助出口)。左半 + * 本来就矮,卡片会被滚出视野,而失败是用户必须看见的东西。可见文案说用户的话,不出现脚本名、 + * 注册表、zip 这些实现词。 */ export function ArtifactsPane({ minitool, @@ -35,8 +43,14 @@ export function ArtifactsPane({ projectPath: string | null; }) { const { form, conflict, failure } = minitool; - /** 下载失败的一句话现场:成功 / 用户取消都不说,只有宿主真的没写下来才占这一行。 */ - const [downloadNotice, setDownloadNotice] = useState(''); + /** + * 下载失败现场:宿主拒绝写盘或对话框打不开时,用弹窗把「哪一份、为什么、下一步」一次说清, + * 并给出「在文件管理器中打开原件」这条兜底出口。 + */ + const [downloadFailure, setDownloadFailure] = + useState(null); + /** 「在文件管理器中打开」自身失败时的一句话;没失败时为空。 */ + const [revealFailure, setRevealFailure] = useState(''); /** * 未适配:`hasScript` 的初值是 `false`,注册表还没读回来(或读失败)时我们不能替项目下 * 「还没适配」的结论——那种情况只该显示读取中或失败现场。 @@ -55,37 +69,63 @@ export function ArtifactsPane({ const showAdaptPrompt = needsAdaptation && !minitool.adaptPromptDismissed; // 成功不打回执:用户刚在原生对话框里选完落点,磁盘上有没有那个文件是他自己看得见的事实。 - // 失败则必须说一句话——宿主拒绝落盘不能只留一条控制台痕迹。 + // 失败则必须让用户看见——宿主拒绝落盘不能只留一条控制台痕迹,也不能只给一句「请重试」。 const downloadIcon = (): void => { if (!projectPath || !form) return; - setDownloadNotice(''); + const relativePath = form.iconPath.trim(); void saveXhsMinitoolFileToDisk({ projectPath, - relativePath: form.iconPath.trim(), + relativePath, fallbackFileName: 'icon.png', title: '保存图标文件', }).then((result) => { - if (result === 'failed') { - setDownloadNotice('没能把图标保存到所选位置,请重试'); - } + if (result.status !== 'failed') return; + setRevealFailure(''); + setDownloadFailure({ kind: 'icon', relativePath, reason: result.reason }); }); }; const downloadArtifact = (): void => { if (!projectPath) return; - setDownloadNotice(''); + const relativePath = XHS_MINITOOL_ARTIFACT_RELATIVE_PATH; void saveXhsMinitoolFileToDisk({ projectPath, - relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, + relativePath, fallbackFileName: 'xhs-minitool.zip', title: '保存代码包', }).then((result) => { - if (result === 'failed') { - setDownloadNotice('没能把代码包保存到所选位置,请重试'); - } + if (result.status !== 'failed') return; + setRevealFailure(''); + setDownloadFailure({ + kind: 'artifact', + relativePath, + reason: result.reason, + }); }); }; + /** 兜底出口:让系统文件管理器定位原件;打不开就把原因留在弹窗里。 */ + const revealDownloadedFile = (): void => { + if (!projectPath || !downloadFailure) return; + void revealXhsMinitoolFileInManager({ + projectPath, + relativePath: downloadFailure.relativePath, + }).then((result) => { + if (result.status === 'failed') { + setRevealFailure(result.reason); + return; + } + // 文件管理器已经弹出:收起弹窗,别挡着用户去看。 + setDownloadFailure(null); + setRevealFailure(''); + }); + }; + + const dismissDownloadFailure = (): void => { + setDownloadFailure(null); + setRevealFailure(''); + }; + /** * 左半顶部槽位:整张表单 / 一行读取中 / 什么都不放(读失败由弹窗拿着现场)/ 一行自动重试兜底。 * 四种形状差得远,用 if/else 摊开算好,JSX 里只留一个 `{formSlot}`,别把控制流埋进链式三元。 @@ -117,11 +157,6 @@ export function ArtifactsPane({ aria-label="导出内容区" >
- {downloadNotice ? ( -

- {downloadNotice} -

- ) : null} {formSlot}
+ +
); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx new file mode 100644 index 000000000..d1eb2ff01 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx @@ -0,0 +1,71 @@ +import { ThemedModal } from '../../../../../components/modal/ThemedModal'; +import { PaneButton } from '../common/PaneButton'; + +/** 哪一份产物没存下来:文案与「在文件管理器中打开」的目标都跟着它走。 */ +export type XhsMinitoolDownloadFailure = { + kind: 'icon' | 'artifact'; + /** 项目内相对路径:用户要在文件管理器里打开的就是它。 */ + relativePath: string; + /** 保存失败的原因(宿主给的中文说明,或一句通用说明)。 */ + reason: string; +}; + +/** + * 下载失败弹窗:一句「哪一份没存下来」+ 原因 + 兜底出口。 + * + * 保存失败不再只留一行会被滚出视野的小字:左半本来就矮,而这是用户点过下载之后必须看见的结果。 + * 弹窗给两条路:关掉,或者让系统文件管理器打开原件所在位置自己复制——宿主拒绝写盘时(权限、 + * 目标不存在、路径被门禁拦下),用户至少还能把项目里那份原件拿出去。 + * + * 与失败弹窗(`FailureDialog`)同一种阻断层:背景点击与 Esc 不生效。宿主的原始错误不在这里展开, + * `reason` 只用调用方收好的那一句人话。 + */ +export function DownloadFailureDialog({ + failure, + revealFailure, + onReveal, + onDismiss, +}: { + /** 没有下载失败现场时为 `null`,弹窗关着。 */ + failure: XhsMinitoolDownloadFailure | null; + /** 「在文件管理器中打开」自身失败时的原因;空串表示没有。 */ + revealFailure: string; + onReveal: () => void; + onDismiss: () => void; +}) { + return ( + +

+ {failure + ? `没能把${failure.kind === 'icon' ? '图标' : '代码包'}保存到你选的位置。` + : null} +

+ {failure ? ( +

+ {failure.reason} +

+ ) : null} +

+ 也可以让系统在文件管理器里打开它原来的位置,自己复制过去。 +

+ {revealFailure ? ( +

+ {revealFailure} +

+ ) : null} +
+ 关闭 + + 在文件管理器中打开 + +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index ad32a5855..363bc9446 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -298,13 +298,13 @@ describe('面板左半', () => { ); }); - it('宿主拒绝落盘时不静默:面板给出重试提示', async () => { + it('宿主拒绝落盘时弹出失败面板,并给出「在文件管理器中打开」的兜底出口', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'read_local_project_image_preview') { return { dataUrl: 'data:image/png;base64,icon' }; } if (command === 'save_local_project_asset_file') { - throw new Error('目标位置不可写'); + throw '目标位置不可写'; } return {}; }); @@ -313,13 +313,46 @@ describe('面板左半', () => { fireEvent.click(screen.getByRole('button', { name: '下载代码包' })); + // 弹窗:哪一份没存下来、为什么,以及用户自己把原件拿出去的出口。 + await screen.findByText('没能把代码包保存到你选的位置。'); + screen.getByText('目标位置不可写'); + screen.getByText(/在文件管理器里打开它原来的位置/u); + + // 兜底出口走宿主与保存同一条门禁的定位命令。 + fireEvent.click(screen.getByRole('button', { name: '在文件管理器中打开' })); await waitFor(() => - expect( - screen.getByText('没能把代码包保存到所选位置,请重试'), - ).toBeTruthy(), + expect(invoke).toHaveBeenCalledWith('reveal_local_project_asset_file', { + projectPath: PROJECT_PATH, + relativePath: XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, + }), ); }); + it('文件管理器也打不开时,弹窗留在原地并说清原因', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_local_project_image_preview') { + return { dataUrl: 'data:image/png;base64,icon' }; + } + if (command === 'save_local_project_asset_file') { + throw '目标位置不可写'; + } + if (command === 'reveal_local_project_asset_file') { + throw '系统没有可用的文件管理器'; + } + return {}; + }); + installTauriRuntime({ core: { invoke } }); + renderPane(fakeMinitool({ artifactReady: true })); + + fireEvent.click(screen.getByRole('button', { name: '下载代码包' })); + await screen.findByText('没能把代码包保存到你选的位置。'); + fireEvent.click(screen.getByRole('button', { name: '在文件管理器中打开' })); + + await screen.findByText('系统没有可用的文件管理器'); + // 弹窗还在:用户可以改关弹窗、或在文件管理器恢复后重试。 + screen.getByRole('button', { name: '在文件管理器中打开' }); + }); + it('图标先出缩略图,选择图标走公共素材导入器并把路径写回表单', async () => { const invoke = vi.fn( async (command: string, args?: Record) => {