下载失败改为弹窗交代并提供在文件管理器中打开

src-tauri/src/project/asset_export.rs:新增按保存同一条路径门禁解析产物位置的能力,供文件管理器定位复用。
src-tauri/src/commands/desktop.rs:新增 reveal_local_project_asset_file 命令,用系统文件管理器定位项目内产物。
src-tauri/src/desktop.rs:注册新命令。
view/project-development/export/state/xhsMinitoolDownload.ts:保存结果改为带失败原因,新增在文件管理器里定位原件的兜底调用。
view/project-development/export/tabs/xiaohongshu/DownloadFailureDialog.tsx:新增下载失败弹窗,给「关闭」与「在文件管理器中打开」两条路。
view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx:下载失败改用弹窗与兜底出口,去掉会被滚出视野的一行小字。
tests/xhsMinitoolExport.test.tsx:覆盖失败弹窗、定位命令参数与定位失败留在弹窗。
This commit is contained in:
2026-10-06 22:13:10 +08:00
parent b9d0276a46
commit ab8a6d1321
7 changed files with 288 additions and 37 deletions
@@ -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>,
@@ -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,
@@ -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<PathBuf, String> {
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();
@@ -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<XhsMinitoolDownloadResult> {
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<XhsMinitoolRevealResult> {
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 '写入所选位置失败';
}
@@ -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<XhsMinitoolDownloadFailure | null>(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="导出内容区"
>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3">
{downloadNotice ? (
<p role="status" className="text-xs text-[#b4562f]">
{downloadNotice}
</p>
) : null}
{formSlot}
<section
@@ -200,6 +235,13 @@ export function ArtifactsPane({
onRepair={minitool.repair}
onDismiss={minitool.dismissFailure}
/>
<DownloadFailureDialog
failure={downloadFailure}
revealFailure={revealFailure}
onReveal={revealDownloadedFile}
onDismiss={dismissDownloadFailure}
/>
</div>
);
}
@@ -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 (
<ThemedModal
open={failure !== null}
ariaLabel="下载失败"
onClose={onDismiss}
closeOnBackdrop={false}
closeOnEscape={false}
panelClassName="w-full max-w-md rounded-2xl border border-[#e2cbb8] p-4"
>
<p className="text-sm leading-relaxed text-[#3d1f10]">
{failure
? `没能把${failure.kind === 'icon' ? '图标' : '代码包'}保存到你选的位置。`
: null}
</p>
{failure ? (
<p className="mt-2 text-xs leading-relaxed text-[#6f5848]">
{failure.reason}
</p>
) : null}
<p className="mt-2 text-xs leading-relaxed text-[#6f5848]">
也可以让系统在文件管理器里打开它原来的位置,自己复制过去。
</p>
{revealFailure ? (
<p role="status" className="mt-2 text-xs text-[#b4562f]">
{revealFailure}
</p>
) : null}
<div className="mt-4 flex flex-wrap justify-end gap-2">
<PaneButton onClick={onDismiss}>关闭</PaneButton>
<PaneButton variant="primary" onClick={onReveal}>
在文件管理器中打开
</PaneButton>
</div>
</ThemedModal>
);
}
@@ -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<string, unknown>) => {