feat(工作流):支持本地图编排策略包导入导出 (#195)

* docs: define local workflow package mvp

* docs: fix workflow package api contract

* feat: add local workflow package mvp

* feat(workflow): add package API client

* feat(workflow): add package import interface

* feat(workflow): connect package import flow

* fix(workflow): map package validation errors

* fix(workflow): handle import key generation errors

* feat(workflow): localize package import states

* fix(workflow): reset package import modal on open

---------

Co-authored-by: ruanmingchen <“ruanm@chenchen”>
This commit is contained in:
chenchen
2026-07-14 11:28:37 +08:00
committed by GitHub
parent 2e5c1ff286
commit acfacfe1b3
24 changed files with 3154 additions and 3 deletions
+234
View File
@@ -10544,6 +10544,240 @@ html[data-theme="dark"] .robot-binding-one-time-badge {
}
}
/* Workflow package import */
.workflow-package-modal-content {
width: min(1040px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
margin: 24px auto;
}
.workflow-package-modal-header {
align-items: flex-start;
}
.workflow-package-modal-header h2 {
margin: 0;
}
.workflow-package-subtitle,
.workflow-package-file-hint {
margin: 4px 0 0;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
}
.workflow-package-steps {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
padding: 12px 22px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.workflow-package-steps > span {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
}
.workflow-package-steps b {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 1px solid var(--border-color);
border-radius: 999px;
font-size: 12px;
}
.workflow-package-steps .is-active {
color: var(--text-primary);
}
.workflow-package-steps .is-active b {
border-color: var(--accent-color);
background: var(--accent-color);
color: #fff;
}
.workflow-package-modal-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
padding: 18px 22px;
}
.workflow-package-pane {
min-width: 0;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
}
.workflow-package-pane h3 {
margin: 0 0 12px;
font-size: 14px;
}
.workflow-package-file-row {
display: flex;
align-items: center;
gap: 10px;
}
.workflow-package-file-name {
min-width: 0;
overflow: hidden;
color: var(--text-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-package-pane--inspection > .btn-primary {
margin-top: 12px;
}
.workflow-package-summary,
.workflow-package-resolution {
display: grid;
gap: 10px;
margin-top: 16px;
}
.workflow-package-summary:empty,
.workflow-package-resolution:empty {
display: none;
}
.workflow-package-summary-row {
display: grid;
grid-template-columns: minmax(88px, 0.65fr) minmax(0, 1fr);
gap: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
font-size: 12px;
}
.workflow-package-summary-row span {
color: var(--text-secondary);
}
.workflow-package-summary-row code {
overflow: hidden;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-package-status {
padding: 10px 12px;
border: 1px solid rgba(59, 130, 246, 0.35);
border-radius: 7px;
background: rgba(59, 130, 246, 0.08);
color: var(--text-primary);
font-size: 13px;
line-height: 1.55;
}
.workflow-package-status.is-warning {
border-color: rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.1);
}
.workflow-package-status.is-success {
border-color: rgba(34, 197, 94, 0.45);
background: rgba(34, 197, 94, 0.1);
}
.workflow-package-choice,
.workflow-package-reveal {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 7px;
background: var(--bg-secondary);
cursor: pointer;
}
.workflow-package-choice.is-selected {
border-color: var(--accent-color);
background: rgba(59, 130, 246, 0.08);
}
.workflow-package-choice input,
.workflow-package-confirm-check input {
margin-top: 3px;
}
.workflow-package-choice strong,
.workflow-package-choice small {
display: block;
}
.workflow-package-choice small {
margin-top: 4px;
color: var(--text-secondary);
line-height: 1.45;
}
.workflow-package-reveal {
width: 100%;
color: var(--text-secondary);
font: inherit;
text-align: left;
}
.workflow-package-rename-field {
display: grid;
gap: 6px;
font-size: 12px;
font-weight: 600;
}
.workflow-package-rename-field input {
width: 100%;
box-sizing: border-box;
}
.workflow-package-confirm-check {
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 1.5;
}
.workflow-package-overwrite-content {
width: min(480px, calc(100vw - 32px));
margin: 18vh auto auto;
}
@media (max-width: 760px) {
.workflow-package-modal-content {
width: calc(100vw - 20px);
margin: 10px auto;
}
.workflow-package-modal-body {
grid-template-columns: minmax(0, 1fr);
padding: 14px;
}
.workflow-package-steps {
gap: 8px;
padding: 10px 14px;
}
}
/* RBAC workspace: keep navigation stable and long resource collections bounded. */
.platform-rbac-page > .page-content {
display: flex;
+87
View File
@@ -3131,6 +3131,93 @@
"enabledUpdateFailed": "Failed to update enabled status",
"namePlaceholder": "Basic Web scan",
"descriptionPlaceholder": "Optional",
"package": {
"importLocal": "Import local package",
"export": "Export",
"title": "Import local package",
"preflightHint": "Preflight does not modify local workflows",
"stepInspection": "Upload and preflight",
"stepImport": "Resolve conflict and import",
"stepsLabel": "Import steps",
"selectFile": "Choose local package",
"noFile": "No file selected",
"fileHint": "One .csapkg.zip package only, up to 10 MiB.",
"inspect": "Upload and preflight",
"resolutionTitle": "Resolution",
"confirmImport": "Confirm import",
"overwriteTitle": "Overwrite local workflow?",
"overwriteWarning": "This replaces the name, description, graph, and enabled state. Rollback is not available in this release.",
"overwriteCheck": "I confirm overwriting the current local workflow",
"overwriteConfirm": "Overwrite and import",
"errors": {
"fileRequired": "Choose a local workflow package before preflight.",
"fileTooLarge": "The file exceeds the 10 MiB limit. Choose a smaller local package.",
"invalidArchive": "The local package is not a valid Zip archive and cannot be preflighted.",
"unsupportedFormat": "This local package format or version is not supported.",
"invalidManifest": "The local package manifest is invalid and cannot be preflighted.",
"checksumMismatch": "Local package verification failed; the file may be damaged or modified.",
"multipleWorkflows": "This release supports local packages containing one workflow only.",
"workflowInvalid": "The workflow definition in the local package is invalid and cannot be preflighted.",
"inspectionNotFound": "The preflight record no longer exists. Upload and preflight again.",
"inspectionExpired": "The preflight has expired. Upload and preflight again.",
"inspectionConsumed": "This preflight has already been imported and cannot be reused.",
"conflictChanged": "The local workflow changed after preflight. Run preflight again before importing.",
"idConflict": "The selected resolution no longer matches the current conflict state. Run preflight again.",
"invalidAction": "The import resolution is invalid. Choose it again.",
"invalidRenameId": "Enter an available new workflow ID.",
"overwriteConfirmationRequired": "Confirm the overwrite before replacing the local workflow.",
"idempotencyKeyRequired": "The browser cannot create an import request identifier. Refresh and try again.",
"idempotencyKeyReused": "The import request state is invalid. Run preflight again and retry.",
"importFailed": "Import failed; the local workflow was not changed.",
"exportFailed": "Export failed. Try again later.",
"workflowNotFound": "The workflow was not found and cannot be exported.",
"generic": "The operation was not completed. Try again later.",
"serviceUnavailable": "The import service is not ready. Refresh the page and try again."
},
"conflict": {
"identical": "A local workflow with the same ID and content was found; import will be skipped.",
"idConflict": "A local workflow with the same ID but different content was found. The local version will be kept by default.",
"none": "No local workflow with the same ID was found. A new workflow can be created."
},
"summary": {
"workflowName": "Workflow name",
"unnamedWorkflow": "Untitled workflow",
"sourceId": "Source workflow ID",
"sourceRevision": "Source revision",
"graphSize": "Graph size",
"graphSizeValue": "{{nodes}} nodes · {{edges}} edges",
"contentHash": "Content hash",
"expiresAt": "Preflight expires"
},
"resolution": {
"needInspection": "Choose a local package and complete preflight first.",
"createHint": "Import will create a new local workflow.",
"identicalHint": "The same content already exists. No duplicate import is needed.",
"keepExisting": "Keep local version",
"keepExistingHint": "Do not change the current local workflow; the import record is retained.",
"revealRiskChoices": "Change resolution",
"overwrite": "Overwrite local version",
"overwriteHint": "Replace the name, description, graph, and enabled state; a second confirmation is required.",
"rename": "Save under a new ID",
"renameHint": "Keep the current local workflow and create a copy under a new ID.",
"newWorkflowId": "New workflow ID",
"newWorkflowIdPlaceholder": "For example: web-scan-basic-copy",
"continueOverwrite": "Continue to overwrite confirmation",
"confirmRename": "Confirm save as new ID",
"confirmCreate": "Confirm create",
"confirmComplete": "Confirm completion"
},
"result": {
"created": "Import completed: a new local workflow was created.",
"overwritten": "Import completed: the local workflow was overwritten.",
"renamed": "Import completed: a local workflow was created under the new ID.",
"keptExisting": "The local workflow was kept; no changes were made.",
"skippedIdentical": "The same local content already exists; import was skipped.",
"complete": "Import completed.",
"completedAction": "Import completed"
},
"exportSelectSaved": "Choose a saved workflow before exporting."
},
"connect": "Connect",
"connecting": "Connecting",
"deleteSelected": "Delete selected",
+87
View File
@@ -3119,6 +3119,93 @@
"enabledUpdateFailed": "更新启用状态失败",
"namePlaceholder": "基础 Web 扫描",
"descriptionPlaceholder": "可选",
"package": {
"importLocal": "导入本地包",
"export": "导出",
"title": "导入本地包",
"preflightHint": "预检不会修改本地工作流",
"stepInspection": "上传与预检",
"stepImport": "处理冲突与导入",
"stepsLabel": "导入步骤",
"selectFile": "选择本地包",
"noFile": "未选择文件",
"fileHint": "仅支持单个 .csapkg.zip,文件不得超过 10 MiB。",
"inspect": "上传并预检",
"resolutionTitle": "处理方式",
"confirmImport": "确认导入",
"overwriteTitle": "确认覆盖本地工作流?",
"overwriteWarning": "覆盖将替换名称、描述、图定义和启用状态。本期不提供回滚。",
"overwriteCheck": "我已确认覆盖当前本地工作流",
"overwriteConfirm": "确认覆盖并导入",
"errors": {
"fileRequired": "请选择本地图编排包后再预检。",
"fileTooLarge": "文件超过 10 MiB 限制,请选择更小的本地包。",
"invalidArchive": "本地包不是有效的 Zip 文件,无法完成预检。",
"unsupportedFormat": "本地包格式或版本不受支持。",
"invalidManifest": "本地包清单无效,无法完成预检。",
"checksumMismatch": "本地包校验失败,文件可能已损坏或被修改。",
"multipleWorkflows": "本期仅支持导入包含一个工作流的本地包。",
"workflowInvalid": "本地包中的工作流定义无效,无法完成预检。",
"inspectionNotFound": "预检记录不存在,请重新上传并预检。",
"inspectionExpired": "预检已过期,请重新上传并预检。",
"inspectionConsumed": "该预检已完成导入,不能重复使用。",
"conflictChanged": "本地工作流在预检后发生变化,请重新预检后再导入。",
"idConflict": "当前处理方式与最新冲突状态不匹配,请重新预检。",
"invalidAction": "导入处理方式无效,请重新选择。",
"invalidRenameId": "请输入可用的新工作流 ID。",
"overwriteConfirmationRequired": "请勾选确认后再覆盖本地工作流。",
"idempotencyKeyRequired": "浏览器无法生成导入请求标识,请刷新页面后重试。",
"idempotencyKeyReused": "导入请求状态异常,请重新预检后再试。",
"importFailed": "导入失败,本地工作流未被修改。",
"exportFailed": "导出失败,请稍后重试。",
"workflowNotFound": "未找到该工作流,无法导出。",
"generic": "操作未完成,请稍后重试。",
"serviceUnavailable": "导入服务尚未准备好,请刷新页面后重试。"
},
"conflict": {
"identical": "检测到同 ID 且内容相同的本地工作流;本次将跳过导入。",
"idConflict": "检测到同 ID 但内容不同的本地工作流。默认保留本地版本。",
"none": "未发现同 ID 的本地工作流,可创建导入。"
},
"summary": {
"workflowName": "工作流名称",
"unnamedWorkflow": "未命名工作流",
"sourceId": "源工作流 ID",
"sourceRevision": "源版本",
"graphSize": "图规模",
"graphSizeValue": "{{nodes}} 个节点 · {{edges}} 条连线",
"contentHash": "内容摘要",
"expiresAt": "预检有效期"
},
"resolution": {
"needInspection": "请先选择本地包并完成预检。",
"createHint": "导入后会创建一个新的本地工作流。",
"identicalHint": "内容已经存在,无需重复导入。",
"keepExisting": "保留本地版本",
"keepExistingHint": "不修改当前本地工作流;导入记录会保留。",
"revealRiskChoices": "更改处理方式",
"overwrite": "覆盖本地版本",
"overwriteHint": "替换名称、描述、图定义和启用状态;需要再次确认。",
"rename": "另存为新 ID",
"renameHint": "保留当前本地工作流,并以新的 ID 创建副本。",
"newWorkflowId": "新的工作流 ID",
"newWorkflowIdPlaceholder": "例如:web-scan-basic-copy",
"continueOverwrite": "继续确认覆盖",
"confirmRename": "确认另存",
"confirmCreate": "确认创建",
"confirmComplete": "确认完成"
},
"result": {
"created": "导入成功,已创建新的本地工作流。",
"overwritten": "导入成功,已覆盖本地工作流。",
"renamed": "导入成功,已按新 ID 创建本地工作流。",
"keptExisting": "已保留本地工作流,未写入任何更改。",
"skippedIdentical": "本地已存在相同内容,已跳过导入。",
"complete": "导入已完成。",
"completedAction": "导入已完成"
},
"exportSelectSaved": "请先选择已保存的工作流后再导出。"
},
"connect": "连线",
"connecting": "连线中",
"deleteSelected": "删除选中",
+117
View File
@@ -0,0 +1,117 @@
(function (root, factory) {
const client = factory(root);
if (typeof module === 'object' && module.exports) module.exports = client;
if (root) root.WorkflowPackageClient = client;
})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
const ACTIONS_BY_CONFLICT = {
none: ['create'],
identical: ['keep_existing'],
id_conflict: ['keep_existing', 'overwrite', 'rename']
};
class WorkflowPackageError extends Error {
constructor(code, message, details) {
super(message || code || 'WFPKG_REQUEST_FAILED');
this.name = 'WorkflowPackageError';
this.code = code || '';
this.details = details || {};
}
}
function allowedActions(conflictState) {
return (ACTIONS_BY_CONFLICT[conflictState] || []).slice();
}
function buildImportRequest(input) {
const data = input || {};
const inspectionId = String(data.inspectionId || '').trim();
const action = String(data.action || '').trim();
const newWorkflowId = String(data.newWorkflowId || '').trim();
const confirmOverwrite = data.confirmOverwrite === true;
if (!inspectionId) throw new WorkflowPackageError('WFPKG_INSPECTION_REQUIRED', '缺少预检记录');
if (!['create', 'keep_existing', 'overwrite', 'rename'].includes(action)) {
throw new WorkflowPackageError('WFPKG_INVALID_ACTION', '导入处理方式无效');
}
if (action === 'rename' && !newWorkflowId) {
throw new WorkflowPackageError('WFPKG_INVALID_RENAME_ID', '请输入新的工作流 ID');
}
if (action !== 'rename' && newWorkflowId) {
throw new WorkflowPackageError('WFPKG_INVALID_ACTION', '当前处理方式不允许填写新的工作流 ID');
}
if (action === 'overwrite' && !confirmOverwrite) {
throw new WorkflowPackageError('WFPKG_OVERWRITE_CONFIRMATION_REQUIRED', '请确认覆盖本地工作流');
}
return {
inspection_id: inspectionId,
resolution: {
action: action,
new_workflow_id: action === 'rename' ? newWorkflowId : ''
},
confirm_overwrite: action === 'overwrite'
};
}
function createIdempotencyKey() {
const cryptoApi = root && root.crypto;
if (!cryptoApi || typeof cryptoApi.randomUUID !== 'function') {
throw new WorkflowPackageError('WFPKG_IDEMPOTENCY_KEY_REQUIRED', '浏览器无法生成导入请求标识');
}
return cryptoApi.randomUUID();
}
async function readApiError(response) {
const payload = await response.json().catch(function () { return {}; });
const error = payload && payload.error ? payload.error : {};
return {
code: error.code || '',
message: error.message || '',
details: error.details || {}
};
}
async function readJsonOrThrow(response) {
if (response.ok) return response.json();
const error = await readApiError(response);
throw new WorkflowPackageError(error.code, error.message, error.details);
}
async function createInspection(apiFetch, file) {
if (!file) throw new WorkflowPackageError('WFPKG_FILE_REQUIRED', '请选择本地图编排包');
const body = new FormData();
body.append('file', file, file.name || 'workflow.csapkg.zip');
const response = await apiFetch('/api/workflow-package-inspections', { method: 'POST', body: body });
return readJsonOrThrow(response);
}
async function getInspection(apiFetch, inspectionId) {
const response = await apiFetch('/api/workflow-package-inspections/' + encodeURIComponent(inspectionId));
return readJsonOrThrow(response);
}
async function applyImport(apiFetch, request, idempotencyKey) {
if (!idempotencyKey) throw new WorkflowPackageError('WFPKG_IDEMPOTENCY_KEY_REQUIRED', '缺少导入请求标识');
const response = await apiFetch('/api/workflow-package-imports', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
body: JSON.stringify(request)
});
return readJsonOrThrow(response);
}
async function getImport(apiFetch, importId) {
const response = await apiFetch('/api/workflow-package-imports/' + encodeURIComponent(importId));
return readJsonOrThrow(response);
}
return {
WorkflowPackageError: WorkflowPackageError,
allowedActions: allowedActions,
buildImportRequest: buildImportRequest,
createIdempotencyKey: createIdempotencyKey,
readApiError: readApiError,
createInspection: createInspection,
getInspection: getInspection,
applyImport: applyImport,
getImport: getImport
};
});
@@ -0,0 +1,51 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const client = require('./workflow-package-client.js');
test('id 冲突默认仅允许保留、本地覆盖或另存', () => {
assert.deepEqual(client.allowedActions('id_conflict'), ['keep_existing', 'overwrite', 'rename']);
assert.deepEqual(client.allowedActions('identical'), ['keep_existing']);
assert.deepEqual(client.allowedActions('none'), ['create']);
});
test('覆盖导入请求必须带确认和空的新 ID', () => {
assert.deepEqual(client.buildImportRequest({
inspectionId: 'wpi_1',
action: 'overwrite',
newWorkflowId: '',
confirmOverwrite: true
}), {
inspection_id: 'wpi_1',
resolution: { action: 'overwrite', new_workflow_id: '' },
confirm_overwrite: true
});
});
test('预检以 multipart file 请求并保留 API 返回体', async () => {
let observed;
const apiFetch = async (url, options) => {
observed = { url, options };
return new Response(JSON.stringify({ inspection: { id: 'wpi_1', status: 'ready' } }), { status: 201 });
};
const data = await client.createInspection(apiFetch, new Blob(['zip']));
assert.equal(observed.url, '/api/workflow-package-inspections');
assert.equal(observed.options.method, 'POST');
assert.equal(observed.options.body instanceof FormData, true);
assert.equal(data.inspection.id, 'wpi_1');
});
test('导入以稳定幂等键发送规范请求体', async () => {
let observed;
const apiFetch = async (url, options) => {
observed = { url, options };
return new Response(JSON.stringify({ import: { id: 'wpii_1', status: 'succeeded' } }), { status: 201 });
};
const request = client.buildImportRequest({
inspectionId: 'wpi_1', action: 'keep_existing', newWorkflowId: '', confirmOverwrite: false
});
const data = await client.applyImport(apiFetch, request, 'f1d13d55-c35d-4693-b507-d2e2ea5703f9');
assert.equal(observed.url, '/api/workflow-package-imports');
assert.equal(observed.options.headers['Idempotency-Key'], 'f1d13d55-c35d-4693-b507-d2e2ea5703f9');
assert.deepEqual(JSON.parse(observed.options.body), request);
assert.equal(data.import.id, 'wpii_1');
});
@@ -0,0 +1,70 @@
const fs = require('node:fs');
const test = require('node:test');
const assert = require('node:assert/strict');
test('图编排提供导入、导出和覆盖确认容器', () => {
const html = fs.readFileSync('web/templates/index.html', 'utf8');
const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8'));
assert.match(html, /onclick="openWorkflowPackageImportModal\(\)"/);
assert.match(html, /onclick="exportCurrentWorkflowPackage\(\)"/);
assert.match(html, /id="workflow-package-import-modal"/);
assert.match(html, /id="workflow-package-overwrite-modal"/);
assert.equal(zh.workflows.package.importLocal, '导入本地包');
});
test('工作流脚本调用包契约的全部端点与冲突错误码', () => {
const workflows = fs.readFileSync('web/static/js/workflows.js', 'utf8');
const client = fs.readFileSync('web/static/js/workflow-package-client.js', 'utf8');
assert.match(workflows, /\/api\/workflows\/\$\{encodeURIComponent\(id\)\}\/package/);
assert.match(client, /\/api\/workflow-package-inspections/);
assert.match(client, /\/api\/workflow-package-imports/);
assert.match(workflows, /WFPKG_CONFLICT_CHANGED/);
assert.match(workflows, /WFPKG_INSPECTION_EXPIRED/);
});
test('预检无效包的契约错误码都有中文状态分支', () => {
const workflows = fs.readFileSync('web/static/js/workflows.js', 'utf8');
[
'WFPKG_INVALID_ARCHIVE',
'WFPKG_UNSUPPORTED_FORMAT',
'WFPKG_INVALID_MANIFEST',
'WFPKG_CHECKSUM_MISMATCH',
'WFPKG_MULTIPLE_WORKFLOWS',
'WFPKG_WORKFLOW_INVALID'
].forEach((code) => assert.match(workflows, new RegExp(code)));
});
test('导入请求标识生成失败会进入中文错误处理', () => {
const workflows = fs.readFileSync('web/static/js/workflows.js', 'utf8');
assert.match(workflows, /async function performWorkflowPackageImport\(request\)[\s\S]*?try\s*\{[\s\S]*?client\.createIdempotencyKey\(\)[\s\S]*?catch \(error\) \{\s*displayWorkflowPackageError\(error\)/);
});
test('工作流包动态状态同时提供中文和英文词条', () => {
const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8'));
const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8'));
const keys = [
['errors', 'invalidArchive'],
['conflict', 'idConflict'],
['summary', 'workflowName'],
['resolution', 'keepExisting'],
['result', 'overwritten']
];
keys.forEach(([section, key]) => {
assert.equal(typeof zh.workflows.package[section][key], 'string');
assert.equal(typeof en.workflows.package[section][key], 'string');
});
});
test('语言切换会刷新工作流包弹窗及其动态状态', () => {
const workflows = fs.readFileSync('web/static/js/workflows.js', 'utf8');
assert.match(workflows, /function refreshWorkflowsI18n\(\)[\s\S]*?workflow-package-import-modal[\s\S]*?workflow-package-overwrite-modal[\s\S]*?renderWorkflowPackageInspection\(\)[\s\S]*?renderWorkflowPackageResolution\(\)/);
});
test('每次打开导入弹窗都会开始新的导入会话', () => {
const workflows = fs.readFileSync('web/static/js/workflows.js', 'utf8');
const start = workflows.indexOf('window.openWorkflowPackageImportModal = async function ()');
const end = workflows.indexOf('window.closeWorkflowPackageImportModal = function ()', start);
const openHandler = workflows.slice(start, end);
assert.match(openHandler, /resetWorkflowPackageImport\(\);/);
assert.doesNotMatch(openHandler, /restoreWorkflowPackageState\(\)/);
});
+443
View File
@@ -26,6 +26,18 @@
const WORKFLOW_TOOL_SELECT_ID = 'workflow-tool-name';
let workflowToolSelectRegistry = null;
let workflowToolSelectDocBound = false;
const workflowPackageState = {
file: null,
inspection: null,
importRecord: null,
resolutionAction: '',
newWorkflowId: '',
riskChoicesVisible: false,
idempotencyKey: '',
requestSignature: ''
};
const WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY = 'csai.workflow-package.inspection-id';
const WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY = 'csai.workflow-package.import-id';
const KNOWN_NODE_LABELS = {
start: ['开始', 'Start'],
@@ -1863,6 +1875,427 @@
}
};
function workflowPackageClient() {
return window.WorkflowPackageClient || null;
}
function workflowPackageText(key, fallback, opts) {
const translated = _t(key, opts);
return translated === key ? fallback : translated;
}
function workflowPackageStorageGet(key) {
try { return window.sessionStorage.getItem(key) || ''; } catch (_) { return ''; }
}
function workflowPackageStorageSet(key, value) {
try {
if (value) window.sessionStorage.setItem(key, value);
else window.sessionStorage.removeItem(key);
} catch (_) { /* session restore is best effort */ }
}
function workflowPackageInspectionEl() {
return document.getElementById('workflow-package-inspection');
}
function workflowPackageResolutionEl() {
return document.getElementById('workflow-package-resolution');
}
function workflowPackageSubmitBtn() {
return document.getElementById('workflow-package-submit-btn');
}
function workflowPackageSetStep(step) {
const inspectionStep = document.getElementById('workflow-package-step-inspection');
const importStep = document.getElementById('workflow-package-step-import');
if (inspectionStep) inspectionStep.classList.toggle('is-active', step === 'inspection');
if (importStep) importStep.classList.toggle('is-active', step === 'import');
}
function workflowPackageSetStatus(target, message, type) {
if (!target) return;
target.innerHTML = `<div class="workflow-package-status${type ? ' is-' + esc(type) : ''}">${esc(message)}</div>`;
}
function workflowPackageErrorMessage(error) {
const code = error && error.code ? error.code : '';
const errorKeys = {
WFPKG_FILE_REQUIRED: 'fileRequired',
WFPKG_FILE_TOO_LARGE: 'fileTooLarge',
WFPKG_INVALID_ARCHIVE: 'invalidArchive',
WFPKG_UNSUPPORTED_FORMAT: 'unsupportedFormat',
WFPKG_INVALID_MANIFEST: 'invalidManifest',
WFPKG_CHECKSUM_MISMATCH: 'checksumMismatch',
WFPKG_MULTIPLE_WORKFLOWS: 'multipleWorkflows',
WFPKG_WORKFLOW_INVALID: 'workflowInvalid',
WFPKG_INSPECTION_NOT_FOUND: 'inspectionNotFound',
WFPKG_INSPECTION_EXPIRED: 'inspectionExpired',
WFPKG_INSPECTION_CONSUMED: 'inspectionConsumed',
WFPKG_CONFLICT_CHANGED: 'conflictChanged',
WFPKG_ID_CONFLICT: 'idConflict',
WFPKG_INVALID_ACTION: 'invalidAction',
WFPKG_INVALID_RENAME_ID: 'invalidRenameId',
WFPKG_OVERWRITE_CONFIRMATION_REQUIRED: 'overwriteConfirmationRequired',
WFPKG_IDEMPOTENCY_KEY_REQUIRED: 'idempotencyKeyRequired',
WFPKG_IDEMPOTENCY_KEY_REUSED: 'idempotencyKeyReused',
WFPKG_IMPORT_FAILED: 'importFailed',
WFPKG_EXPORT_FAILED: 'exportFailed',
WFPKG_WORKFLOW_NOT_FOUND: 'workflowNotFound'
};
const key = errorKeys[code];
if (key) return workflowPackageText('workflows.package.errors.' + key, '操作未完成,请稍后重试。');
return (error && error.message) || workflowPackageText('workflows.package.errors.generic', '操作未完成,请稍后重试。');
}
function workflowPackageConflictCopy(conflict) {
const state = (conflict && conflict.state) || 'none';
if (state === 'identical') return { message: workflowPackageText('workflows.package.conflict.identical', '检测到同 ID 且内容相同的本地工作流;本次将跳过导入。'), type: 'success' };
if (state === 'id_conflict') return { message: workflowPackageText('workflows.package.conflict.idConflict', '检测到同 ID 但内容不同的本地工作流。默认保留本地版本。'), type: 'warning' };
return { message: workflowPackageText('workflows.package.conflict.none', '未发现同 ID 的本地工作流,可创建导入。'), type: 'success' };
}
function renderWorkflowPackageInspection() {
const target = workflowPackageInspectionEl();
const inspection = workflowPackageState.inspection;
if (!inspection) {
if (target) target.innerHTML = '';
return;
}
const workflow = inspection.workflow || {};
const conflict = inspection.conflict || {};
const conflictCopy = workflowPackageConflictCopy(conflict);
const rows = [
[workflowPackageText('workflows.package.summary.workflowName', '工作流名称'), workflow.name || workflowPackageText('workflows.package.summary.unnamedWorkflow', '未命名工作流')],
[workflowPackageText('workflows.package.summary.sourceId', '源工作流 ID'), workflow.source_id || '—'],
[workflowPackageText('workflows.package.summary.sourceRevision', '源版本'), workflow.source_revision || '—'],
[workflowPackageText('workflows.package.summary.graphSize', '图规模'), workflowPackageText('workflows.package.summary.graphSizeValue', `${workflow.node_count || 0} 个节点 · ${workflow.edge_count || 0} 条连线`, { nodes: workflow.node_count || 0, edges: workflow.edge_count || 0 })],
[workflowPackageText('workflows.package.summary.contentHash', '内容摘要'), workflow.content_hash || '—'],
[workflowPackageText('workflows.package.summary.expiresAt', '预检有效期'), inspection.expires_at || '—']
];
if (!target) return;
target.innerHTML = `<div class="workflow-package-status is-${conflictCopy.type}">${esc(conflictCopy.message)}</div>` + rows.map(function (row) {
return `<div class="workflow-package-summary-row"><span>${esc(row[0])}</span><code title="${esc(row[1])}">${esc(row[1])}</code></div>`;
}).join('');
}
function workflowPackageResolutionCard(action, title, description, selected) {
return `<label class="workflow-package-choice${selected ? ' is-selected' : ''}">
<input type="radio" name="workflow-package-resolution-action" value="${esc(action)}" ${selected ? 'checked' : ''} onchange="selectWorkflowPackageResolution('${esc(action)}')">
<span><strong>${esc(title)}</strong><small>${esc(description)}</small></span>
</label>`;
}
function renderWorkflowPackageResolution(message, type) {
const target = workflowPackageResolutionEl();
const submit = workflowPackageSubmitBtn();
const inspection = workflowPackageState.inspection;
if (!inspection) {
if (target) workflowPackageSetStatus(target, message || workflowPackageText('workflows.package.resolution.needInspection', '请先选择本地包并完成预检。'), type || '');
if (submit) submit.disabled = true;
return;
}
const client = workflowPackageClient();
const conflictState = (inspection.conflict && inspection.conflict.state) || 'none';
const allowed = client ? client.allowedActions(conflictState) : [];
if (allowed.indexOf(workflowPackageState.resolutionAction) === -1) {
workflowPackageState.resolutionAction = conflictState === 'none' ? 'create' : 'keep_existing';
}
const action = workflowPackageState.resolutionAction;
let html = '';
if (message) html += `<div class="workflow-package-status${type ? ' is-' + esc(type) : ''}">${esc(message)}</div>`;
if (conflictState === 'none') {
html += `<div class="workflow-package-status is-success">${esc(workflowPackageText('workflows.package.resolution.createHint', '导入后会创建一个新的本地工作流。'))}</div>`;
} else if (conflictState === 'identical') {
html += `<div class="workflow-package-status is-success">${esc(workflowPackageText('workflows.package.resolution.identicalHint', '内容已经存在,无需重复导入。'))}</div>`;
} else {
html += workflowPackageResolutionCard('keep_existing', workflowPackageText('workflows.package.resolution.keepExisting', '保留本地版本'), workflowPackageText('workflows.package.resolution.keepExistingHint', '不修改当前本地工作流;导入记录会保留。'), action === 'keep_existing');
if (!workflowPackageState.riskChoicesVisible) {
html += `<button type="button" class="workflow-package-reveal" onclick="revealWorkflowPackageRiskChoices()">${esc(workflowPackageText('workflows.package.resolution.revealRiskChoices', '更改处理方式'))}</button>`;
} else {
html += workflowPackageResolutionCard('overwrite', workflowPackageText('workflows.package.resolution.overwrite', '覆盖本地版本'), workflowPackageText('workflows.package.resolution.overwriteHint', '替换名称、描述、图定义和启用状态;需要再次确认。'), action === 'overwrite');
html += workflowPackageResolutionCard('rename', workflowPackageText('workflows.package.resolution.rename', '另存为新 ID'), workflowPackageText('workflows.package.resolution.renameHint', '保留当前本地工作流,并以新的 ID 创建副本。'), action === 'rename');
if (action === 'rename') {
html += `<label class="workflow-package-rename-field">${esc(workflowPackageText('workflows.package.resolution.newWorkflowId', '新的工作流 ID'))}<input id="workflow-package-new-id" class="form-input" type="text" value="${esc(workflowPackageState.newWorkflowId)}" placeholder="${esc(workflowPackageText('workflows.package.resolution.newWorkflowIdPlaceholder', '例如:web-scan-basic-copy'))}" oninput="updateWorkflowPackageRenameId()" autocomplete="off"></label>`;
}
}
}
if (target) target.innerHTML = html;
if (submit) {
const renameIncomplete = action === 'rename' && !workflowPackageState.newWorkflowId.trim();
submit.disabled = !action || renameIncomplete;
if (action === 'overwrite') submit.textContent = workflowPackageText('workflows.package.resolution.continueOverwrite', '继续确认覆盖');
else if (action === 'rename') submit.textContent = workflowPackageText('workflows.package.resolution.confirmRename', '确认另存');
else submit.textContent = action === 'create' ? workflowPackageText('workflows.package.resolution.confirmCreate', '确认创建') : workflowPackageText('workflows.package.resolution.confirmComplete', '确认完成');
}
workflowPackageSetStep('import');
}
function resetWorkflowPackageImport(options) {
const keepInspection = options && options.keepInspection;
workflowPackageState.file = null;
workflowPackageState.importRecord = null;
workflowPackageState.newWorkflowId = '';
workflowPackageState.riskChoicesVisible = false;
workflowPackageState.idempotencyKey = '';
workflowPackageState.requestSignature = '';
if (!keepInspection) {
workflowPackageState.inspection = null;
workflowPackageState.resolutionAction = '';
workflowPackageStorageSet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY, '');
}
workflowPackageStorageSet(WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY, '');
const input = document.getElementById('workflow-package-file-input');
const name = document.getElementById('workflow-package-file-name');
if (input) input.value = '';
if (name) name.textContent = workflowPackageText('workflows.package.noFile', '未选择文件');
}
function displayWorkflowPackageError(error) {
const message = workflowPackageErrorMessage(error);
if (error && (error.code === 'WFPKG_INSPECTION_EXPIRED' || error.code === 'WFPKG_CONFLICT_CHANGED' || error.code === 'WFPKG_INSPECTION_CONSUMED')) {
workflowPackageState.inspection = null;
workflowPackageState.resolutionAction = '';
workflowPackageStorageSet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY, '');
workflowPackageSetStep('inspection');
renderWorkflowPackageInspection();
}
renderWorkflowPackageResolution(message, 'warning');
if (typeof showNotification === 'function') showNotification(message, 'error');
}
function renderWorkflowPackageImportResult(importRecord) {
const result = importRecord && importRecord.result;
const resultKeys = {
created: 'created',
overwritten: 'overwritten',
renamed: 'renamed',
kept_existing: 'keptExisting',
skipped_identical: 'skippedIdentical'
};
const resultKey = resultKeys[result];
const message = resultKey
? workflowPackageText('workflows.package.result.' + resultKey, '导入已完成。')
: workflowPackageText('workflows.package.result.complete', '导入已完成。');
renderWorkflowPackageResolution(message, result === 'kept_existing' || result === 'skipped_identical' ? '' : 'success');
const submit = workflowPackageSubmitBtn();
if (submit) {
submit.disabled = true;
submit.textContent = workflowPackageText('workflows.package.result.completedAction', '导入已完成');
}
}
async function restoreWorkflowPackageState() {
const client = workflowPackageClient();
if (!client || typeof apiFetch !== 'function') return;
const importId = workflowPackageStorageGet(WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY);
if (importId) {
try {
const data = await client.getImport(apiFetch, importId);
workflowPackageState.importRecord = data.import || data;
renderWorkflowPackageImportResult(workflowPackageState.importRecord);
return;
} catch (_) {
workflowPackageStorageSet(WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY, '');
}
}
const inspectionId = workflowPackageStorageGet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY);
if (!inspectionId) return;
try {
const data = await client.getInspection(apiFetch, inspectionId);
workflowPackageState.inspection = data.inspection || data;
renderWorkflowPackageInspection();
renderWorkflowPackageResolution();
} catch (error) {
workflowPackageStorageSet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY, '');
displayWorkflowPackageError(error);
}
}
window.openWorkflowPackageImportModal = async function () {
if (typeof requirePermission === 'function' && !requirePermission('workflow:write')) return;
resetWorkflowPackageImport();
renderWorkflowPackageInspection();
renderWorkflowPackageResolution();
workflowPackageSetStep('inspection');
if (typeof openAppModal === 'function') openAppModal('workflow-package-import-modal', { focusEl: document.getElementById('workflow-package-inspect-btn') });
if (typeof window.applyTranslations === 'function') window.applyTranslations(document.getElementById('workflow-package-import-modal'));
};
window.closeWorkflowPackageImportModal = function () {
if (typeof closeAppModal === 'function') closeAppModal('workflow-package-import-modal');
resetWorkflowPackageImport();
};
window.onWorkflowPackageFileSelected = function () {
const input = document.getElementById('workflow-package-file-input');
const name = document.getElementById('workflow-package-file-name');
const file = input && input.files ? input.files[0] : null;
resetWorkflowPackageImport();
workflowPackageState.file = file || null;
if (name) name.textContent = file ? file.name : workflowPackageText('workflows.package.noFile', '未选择文件');
renderWorkflowPackageResolution();
workflowPackageSetStep('inspection');
};
window.inspectWorkflowPackage = async function () {
if (typeof requirePermission === 'function' && !requirePermission('workflow:write')) return;
const client = workflowPackageClient();
const button = document.getElementById('workflow-package-inspect-btn');
if (!client || typeof apiFetch !== 'function') {
displayWorkflowPackageError({ code: 'WFPKG_REQUEST_FAILED', message: workflowPackageText('workflows.package.errors.serviceUnavailable', '导入服务尚未准备好,请刷新页面后重试。') });
return;
}
if (button) button.disabled = true;
try {
const data = await client.createInspection(apiFetch, workflowPackageState.file);
workflowPackageState.inspection = data.inspection || data;
workflowPackageState.importRecord = null;
workflowPackageState.riskChoicesVisible = false;
workflowPackageState.newWorkflowId = '';
workflowPackageState.resolutionAction = '';
workflowPackageStorageSet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY, workflowPackageState.inspection.id || '');
renderWorkflowPackageInspection();
renderWorkflowPackageResolution();
} catch (error) {
displayWorkflowPackageError(error);
} finally {
if (button) button.disabled = false;
}
};
window.revealWorkflowPackageRiskChoices = function () {
workflowPackageState.riskChoicesVisible = true;
renderWorkflowPackageResolution();
};
window.selectWorkflowPackageResolution = function (action) {
const client = workflowPackageClient();
const conflict = workflowPackageState.inspection && workflowPackageState.inspection.conflict;
const allowed = client ? client.allowedActions((conflict && conflict.state) || 'none') : [];
if (allowed.indexOf(action) === -1) return;
workflowPackageState.resolutionAction = action;
if (action !== 'rename') workflowPackageState.newWorkflowId = '';
renderWorkflowPackageResolution();
};
window.updateWorkflowPackageRenameId = function () {
const input = document.getElementById('workflow-package-new-id');
workflowPackageState.newWorkflowId = input ? input.value : '';
const submit = workflowPackageSubmitBtn();
if (submit) submit.disabled = !workflowPackageState.newWorkflowId.trim();
};
async function performWorkflowPackageImport(request) {
const client = workflowPackageClient();
const submit = workflowPackageSubmitBtn();
if (submit) submit.disabled = true;
try {
const signature = JSON.stringify(request);
if (!workflowPackageState.idempotencyKey || workflowPackageState.requestSignature !== signature) {
workflowPackageState.idempotencyKey = client.createIdempotencyKey();
workflowPackageState.requestSignature = signature;
}
const data = await client.applyImport(apiFetch, request, workflowPackageState.idempotencyKey);
workflowPackageState.importRecord = data.import || data;
workflowPackageStorageSet(WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY, workflowPackageState.importRecord.id || '');
workflowPackageStorageSet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY, '');
workflowPackageState.inspection = null;
renderWorkflowPackageImportResult(workflowPackageState.importRecord);
if (typeof window.refreshWorkflows === 'function') await window.refreshWorkflows();
} catch (error) {
displayWorkflowPackageError(error);
} finally {
if (submit && !workflowPackageState.importRecord) submit.disabled = false;
}
}
window.submitWorkflowPackageImport = async function () {
const client = workflowPackageClient();
if (!client || !workflowPackageState.inspection) return;
if (workflowPackageState.resolutionAction === 'overwrite') {
const checkbox = document.getElementById('workflow-package-overwrite-confirm');
const submit = document.getElementById('workflow-package-overwrite-submit-btn');
if (checkbox) checkbox.checked = false;
if (submit) submit.disabled = true;
if (typeof openAppModal === 'function') openAppModal('workflow-package-overwrite-modal', { focusEl: checkbox });
return;
}
let request;
try {
request = client.buildImportRequest({
inspectionId: workflowPackageState.inspection.id,
action: workflowPackageState.resolutionAction,
newWorkflowId: workflowPackageState.newWorkflowId,
confirmOverwrite: false
});
} catch (error) {
displayWorkflowPackageError(error);
return;
}
await performWorkflowPackageImport(request);
};
window.closeWorkflowPackageOverwriteModal = function () {
if (typeof closeAppModal === 'function') closeAppModal('workflow-package-overwrite-modal');
};
window.updateWorkflowPackageOverwriteConfirmation = function () {
const checkbox = document.getElementById('workflow-package-overwrite-confirm');
const submit = document.getElementById('workflow-package-overwrite-submit-btn');
if (submit) submit.disabled = !checkbox || !checkbox.checked;
};
window.confirmWorkflowPackageOverwrite = async function () {
const checkbox = document.getElementById('workflow-package-overwrite-confirm');
const client = workflowPackageClient();
if (!client || !checkbox || !checkbox.checked || !workflowPackageState.inspection) return;
try {
const request = client.buildImportRequest({
inspectionId: workflowPackageState.inspection.id,
action: 'overwrite',
newWorkflowId: '',
confirmOverwrite: true
});
window.closeWorkflowPackageOverwriteModal();
await performWorkflowPackageImport(request);
} catch (error) {
displayWorkflowPackageError(error);
}
};
window.exportCurrentWorkflowPackage = async function () {
if (typeof requirePermission === 'function' && !requirePermission('workflow:read')) return;
const id = currentWorkflowId || readWorkflowMetaFromForm().id;
if (!id) {
if (typeof showNotification === 'function') showNotification(workflowPackageText('workflows.package.exportSelectSaved', '请先选择已保存的工作流后再导出。'), 'warning');
return;
}
try {
const response = await apiFetch(`/api/workflows/${encodeURIComponent(id)}/package`, { method: 'GET' });
if (!response.ok) {
const error = workflowPackageClient() ? await workflowPackageClient().readApiError(response) : {};
throw error;
}
const blob = await response.blob();
const disposition = response.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename\*?=(?:UTF-8''|\")?([^;\"]+)/i);
const filename = match && match[1] ? decodeURIComponent(match[1].trim()) : `${id}.csapkg.zip`;
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(function () { URL.revokeObjectURL(url); }, 0);
} catch (error) {
const message = workflowPackageErrorMessage(error);
if (typeof showNotification === 'function') showNotification(message, 'error');
}
};
function refreshCanvasLabels() {
if (!cy) return;
cy.nodes().forEach(function (node) {
@@ -1888,6 +2321,10 @@
if (page && typeof window.applyTranslations === 'function') {
window.applyTranslations(page);
}
['workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) {
const modal = document.getElementById(id);
if (modal && typeof window.applyTranslations === 'function') window.applyTranslations(modal);
});
const connectBtn = document.getElementById('workflow-connect-btn');
if (connectBtn) {
connectBtn.textContent = connectMode ? _t('workflows.connecting') : _t('workflows.connect');
@@ -1903,6 +2340,12 @@
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
loadWorkflowOptionsForRoleModal();
}
if (workflowPackageState.importRecord) {
renderWorkflowPackageImportResult(workflowPackageState.importRecord);
} else {
renderWorkflowPackageInspection();
renderWorkflowPackageResolution();
}
}
document.addEventListener('languagechange', function () {