新增任务分享/导入功能并修复只读文件保存 EPERM
- flows: writeFlowFile 覆盖写加固(清只读属性->删除后 rename->覆盖写), 外部复制导入的只读任务文件此前导致 flows:save EPERM 崩溃 - flows: exportFlowToPath/importFlowFromPath, 分享文件为单个 FlowDefinition JSON(.rpaflow.json); 导入校验 schemaVersion/nodes/edges, id 冲突时生成 imported_ 前缀新 id 绝不覆盖既有任务 - IPC: flows:export 弹保存对话框 / flows:import 弹多选打开对话框, 失败文件逐个汇报不阻塞其余 - preload: flows.export/flows.import; shared: FlowsImportResult 类型 - TasksPage: 头部「导入任务」按钮 + 每行「分享」按钮; App 接线与 toast - 测试: 导出/导入往返、id 冲突、非法文件原因、只读覆盖共 4 例(7/7 绿)
这个提交包含在:
1 父节点
22a7ee1595
当前提交
f95a24458e
8 个文件已更改
+303
-10
没有与搜索条件相匹配的文件
+114
-3
@@ -242,11 +242,61 @@ export function saveFlow(input: FlowDefinition): FlowDefinition {
|
||||
appVersion: app.getVersion()
|
||||
}
|
||||
};
|
||||
const target = fileOf(flow.id);
|
||||
writeFlowFile(fileOf(flow.id), flow);
|
||||
return flow;
|
||||
}
|
||||
|
||||
/** 覆盖写任务文件。Windows 上目标文件带只读属性(外部导入/复制来的文件常见)或被占用时,
|
||||
* rename 会抛 EPERM——这里依次尝试:清除只读属性后 rename → 删除后 rename → 直接覆盖写。 */
|
||||
function writeFlowFile(target: string, flow: FlowDefinition): void {
|
||||
const temp = `${target}.tmp`;
|
||||
fs.writeFileSync(temp, JSON.stringify(flow, null, 2), "utf8");
|
||||
fs.renameSync(temp, target);
|
||||
return flow;
|
||||
const retryable = (error: unknown): boolean => {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
return code === "EPERM" || code === "EACCES" || code === "EBUSY";
|
||||
};
|
||||
const cleanupTemp = () => {
|
||||
try {
|
||||
fs.unlinkSync(temp);
|
||||
} catch {
|
||||
// 清理失败不影响主流程
|
||||
}
|
||||
};
|
||||
try {
|
||||
fs.renameSync(temp, target);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!retryable(error)) {
|
||||
cleanupTemp();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.chmodSync(target, 0o666);
|
||||
} catch {
|
||||
// 清只读属性失败不阻塞后续删除/覆盖尝试
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(target);
|
||||
} catch {
|
||||
// 目标可能被其他进程占用,删除失败继续走覆盖写
|
||||
}
|
||||
try {
|
||||
fs.renameSync(temp, target);
|
||||
return;
|
||||
} catch {
|
||||
// rename 仍失败(目标被锁定),尝试直接覆盖
|
||||
}
|
||||
try {
|
||||
fs.copyFileSync(temp, target);
|
||||
cleanupTemp();
|
||||
} catch (error) {
|
||||
cleanupTemp();
|
||||
throw new Error(
|
||||
`任务文件被占用或无写入权限(${path.basename(target)})。请关闭可能打开该文件的编辑器/网盘同步后再试。`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteFlow(id: string): void {
|
||||
@@ -254,3 +304,64 @@ export function deleteFlow(id: string): void {
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target);
|
||||
}
|
||||
|
||||
/** 任务分享文件扩展名:单文件就是一个 FlowDefinition JSON,便于直接审阅/版本管理 */
|
||||
const SHARE_EXTENSION = "rpaflow";
|
||||
|
||||
function normalizeImportedName(raw: unknown): string {
|
||||
const name = String(raw ?? "").trim();
|
||||
return name || "导入的任务";
|
||||
}
|
||||
|
||||
/** 校验并归一化外部任务文件内容,不合法时抛出带原因的 Error */
|
||||
function parseSharedFlow(raw: string, source: string): FlowDefinition {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`${source}:不是有效的 JSON 文件`);
|
||||
}
|
||||
const flow = data as FlowDefinition;
|
||||
if (!flow || typeof flow !== "object") throw new Error(`${source}:文件内容不是任务结构`);
|
||||
if (flow.schemaVersion !== 1) throw new Error(`${source}:schemaVersion 不是 1(版本不兼容)`);
|
||||
if (!Array.isArray(flow.nodes) || !Array.isArray(flow.edges)) {
|
||||
throw new Error(`${source}:缺少 nodes/edges 结构`);
|
||||
}
|
||||
if (flow.nodes.length > 2000) throw new Error(`${source}:节点数超过上限 2000`);
|
||||
return flow;
|
||||
}
|
||||
|
||||
/** 从分享文件导入任务:id 冲突时生成新 id,避免覆盖既有任务 */
|
||||
export function importFlowFromPath(filePath: string): FlowDefinition {
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const flow = parseSharedFlow(raw, path.basename(filePath));
|
||||
const now = new Date().toISOString();
|
||||
let id = typeof flow.id === "string" && ID_PATTERN.test(flow.id) ? flow.id : "";
|
||||
if (!id || fs.existsSync(fileOf(id))) {
|
||||
// 冲突/缺 id:基于名称生成可读 id,追加随机尾巴保证唯一
|
||||
const seed = normalizeImportedName(flow.name)
|
||||
.replace(/[^A-Za-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 40)
|
||||
.toLowerCase();
|
||||
const base = `imported_${seed || "task"}`;
|
||||
id = `${base}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
while (!ID_PATTERN.test(id)) id = `imported_task_${Date.now().toString(36)}`;
|
||||
}
|
||||
const imported: FlowDefinition = {
|
||||
...flow,
|
||||
id,
|
||||
name: normalizeImportedName(flow.name),
|
||||
meta: { ...flow.meta, createdAt: flow.meta?.createdAt || now }
|
||||
};
|
||||
return saveFlow(imported);
|
||||
}
|
||||
|
||||
/** 导出任务到指定路径(main 侧先用 dialog 拿到路径) */
|
||||
export function exportFlowToPath(id: string, targetPath: string): string {
|
||||
const flow = getFlow(id);
|
||||
fs.writeFileSync(targetPath, JSON.stringify(flow, null, 2), "utf8");
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export { SHARE_EXTENSION };
|
||||
|
||||
在新工单中引用
屏蔽一个用户