导出产物保存收敛成共享 exportArtifactDownload

- 新增 exportArtifactDownload.ts,承载保存对话框 + save_local_project_asset_file + 文件管理器定位
- 删掉逐字重复的 taonierDownload.ts 与 xhsMinitoolDownload.ts
- 两个 ArtifactsPane 改用通用的 saveExportArtifactToDisk / revealExportArtifactInManager
This commit is contained in:
2026-10-07 11:53:18 +08:00
parent 6f0ea03106
commit 680503d2a5
4 changed files with 22 additions and 134 deletions
@@ -1,14 +1,15 @@
/**
* 把陶泥儿导出产物经原生保存对话框落到用户选定的位置。
* 把导出 tab 的产物(zip / icon / 代码包)经原生保存对话框落到用户选定的位置。
*
* 复用素材保存的既有链路 `save_local_project_asset_file`:路径门禁、敏感文件拒绝、自我覆盖保护
* 都在宿主那一侧,前端不自己读文件、不拼绝对路径、也不写第二份保存实现。
* 都在宿主那一侧,前端不自己读文件、不拼绝对路径、也不写第二份保存实现。陶泥儿与小红书小工具
* 的下载行为完全一致,这里收敛成一份,避免两边各改各的。
*
* 返回值不只说明这次存没存下来,失败还带一句原因:`saved`(宿主写完)、`cancelled`(用户关掉
* 对话框)、`failed`(对话框打不开 / 宿主拒绝写)。成功不必在面板上复述——落点是用户亲手选的,
* 原生对话框就是回执;失败则由调用方决定怎么告知。
*
* 同一条保存链路上还有 [`revealTaonierFileInManager`]:保存失败时用系统文件管理器打开原件
* 同一条保存链路上还有 [`revealExportArtifactInManager`]:保存失败时用系统文件管理器打开原件
* 所在位置,作为用户自己把文件拿出去的兜底出口。
*/
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
@@ -17,24 +18,24 @@ import { resolveTauriInvoke } from '../../../../app/tauri';
import { resourceFileNameFromPath } from '../../../../features/resource-canvas/resourceCanvasAssetTransferModel';
/** 一次导出产物保存的结局。失败带一句可展示的原因(宿主给的是中文人话)。 */
export type TaonierDownloadResult =
export type ExportArtifactDownloadResult =
| { status: 'saved' }
| { status: 'cancelled' }
| { status: 'failed'; reason: string };
/** 在系统文件管理器里定位一份导出产物的结局。 */
export type TaonierRevealResult =
export type ExportArtifactRevealResult =
| { status: 'revealed' }
| { status: 'failed'; reason: string };
export async function saveTaonierFileToDisk(input: {
export async function saveExportArtifactToDisk(input: {
projectPath: string;
/** 项目内相对路径,交给宿主的既有门禁复核。 */
relativePath: string;
/** 路径里取不出文件名时用的兜底名。 */
fallbackFileName: string;
title: string;
}): Promise<TaonierDownloadResult> {
}): Promise<ExportArtifactDownloadResult> {
const invoke = resolveTauriInvoke();
if (!invoke) {
return { status: 'failed', reason: '需要在应用内运行才能保存文件' };
@@ -75,10 +76,10 @@ export async function saveTaonierFileToDisk(input: {
* 保存失败后的兜底出口:不去替用户挑落点,只把原件所在位置打开,让他自己复制。与保存共用同一条
* 宿主路径门禁——能保存的原件才能被定位,打开的一定是项目里真实存在的那份文件。
*/
export async function revealTaonierFileInManager(input: {
export async function revealExportArtifactInManager(input: {
projectPath: string;
relativePath: string;
}): Promise<TaonierRevealResult> {
}): Promise<ExportArtifactRevealResult> {
const invoke = resolveTauriInvoke();
if (!invoke) {
return { status: 'failed', reason: '需要在应用内运行才能打开文件管理器' };
@@ -1,113 +0,0 @@
/**
* 把导出产物(icon / zip)经原生保存对话框落到用户选定的位置。
*
* 复用素材保存的既有链路 `save_local_project_asset_file`:路径门禁、敏感文件拒绝、自我覆盖保护
* 都在宿主那一侧,前端不自己读文件、不拼绝对路径、也不写第二份保存实现。
*
* 返回值不只说明这次存没存下来,失败还带一句原因:`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 =
| { status: 'saved' }
| { status: 'cancelled' }
| { status: 'failed'; reason: string };
/** 在系统文件管理器里定位一份导出产物的结局。 */
export type XhsMinitoolRevealResult =
| { status: 'revealed' }
| { status: 'failed'; reason: string };
export async function saveXhsMinitoolFileToDisk(input: {
projectPath: string;
/** 项目内相对路径,交给宿主的既有门禁复核。 */
relativePath: string;
/** 路径里取不出文件名时用的兜底名。 */
fallbackFileName: string;
title: string;
}): Promise<XhsMinitoolDownloadResult> {
const invoke = resolveTauriInvoke();
if (!invoke) {
return { status: 'failed', reason: '需要在应用内运行才能保存文件' };
}
let destination: string | null = null;
try {
destination = await saveNativeFileDialog({
// 默认文件名取路径最后一段(`/` 与 `\` 都认),取不到就用调用方给的兜底名。
defaultPath:
resourceFileNameFromPath(input.relativePath.trim()) ||
input.fallbackFileName,
title: input.title,
});
} catch (error) {
console.warn('导出产物保存对话框没能打开', error);
return { status: 'failed', reason: '保存对话框没能打开' };
}
// 用户取消对话框:什么都没发生,也没有话要说。
if (!destination) return { status: 'cancelled' };
try {
await invoke('save_local_project_asset_file', {
input: {
projectPath: input.projectPath,
relativePath: input.relativePath,
destinationPath: destination,
},
});
return { status: 'saved' };
} catch (error) {
console.warn('导出产物没能写到你选的位置', error);
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,9 +2,9 @@ import { type ReactNode, useState } from 'react';
import type { GameDistributionPublishResult } from '../../../../../services/gameDistributionPublish';
import {
revealTaonierFileInManager,
saveTaonierFileToDisk,
} from '../../state/taonierDownload';
revealExportArtifactInManager,
saveExportArtifactToDisk,
} from '../../state/exportArtifactDownload';
import { TAONIER_ARTIFACT_RELATIVE_PATH } from '../../state/taonierInstruction';
import type { TaonierExportState } from '../../state/useTaonierExport';
import { PaneButton } from '../common/PaneButton';
@@ -77,7 +77,7 @@ export function ArtifactsPane({
const downloadArtifact = (): void => {
if (!projectPath) return;
const relativePath = TAONIER_ARTIFACT_RELATIVE_PATH;
void saveTaonierFileToDisk({
void saveExportArtifactToDisk({
projectPath,
relativePath,
fallbackFileName: 'taonier.zip',
@@ -96,7 +96,7 @@ export function ArtifactsPane({
/** 兜底出口:让系统文件管理器定位原件;打不开就把原因留在弹窗里。 */
const revealDownloadedFile = (): void => {
if (!projectPath || !downloadFailure) return;
void revealTaonierFileInManager({
void revealExportArtifactInManager({
projectPath,
relativePath: downloadFailure.relativePath,
}).then((result) => {
@@ -1,11 +1,11 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { type ReactNode, useState } from 'react';
import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport';
import {
revealXhsMinitoolFileInManager,
saveXhsMinitoolFileToDisk,
} from '../../state/xhsMinitoolDownload';
revealExportArtifactInManager,
saveExportArtifactToDisk,
} from '../../state/exportArtifactDownload';
import type { XhsMinitoolExportState } from '../../state/useXhsMinitoolExport';
import { XHS_MINITOOL_ARTIFACT_RELATIVE_PATH } from '../../state/xhsMinitoolInstruction';
import { PaneButton } from '../common/PaneButton';
import { AdaptPromptDialog } from './AdaptPromptDialog';
@@ -73,7 +73,7 @@ export function ArtifactsPane({
const downloadIcon = (): void => {
if (!projectPath || !form) return;
const relativePath = form.iconPath.trim();
void saveXhsMinitoolFileToDisk({
void saveExportArtifactToDisk({
projectPath,
relativePath,
fallbackFileName: 'icon.png',
@@ -88,7 +88,7 @@ export function ArtifactsPane({
const downloadArtifact = (): void => {
if (!projectPath) return;
const relativePath = XHS_MINITOOL_ARTIFACT_RELATIVE_PATH;
void saveXhsMinitoolFileToDisk({
void saveExportArtifactToDisk({
projectPath,
relativePath,
fallbackFileName: 'xhs-minitool.zip',
@@ -107,7 +107,7 @@ export function ArtifactsPane({
/** 兜底出口:让系统文件管理器定位原件;打不开就把原因留在弹窗里。 */
const revealDownloadedFile = (): void => {
if (!projectPath || !downloadFailure) return;
void revealXhsMinitoolFileInManager({
void revealExportArtifactInManager({
projectPath,
relativePath: downloadFailure.relativePath,
}).then((result) => {