feat: save/load workflow plans + run all sequentially + step reorder (manual workflow builder)
This commit is contained in:
+178
-75
@@ -4,24 +4,29 @@
|
||||
<style>
|
||||
.step-card {
|
||||
border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem; margin: 0.75rem 0;
|
||||
background: #fafafa;
|
||||
background: #fafafa; transition: opacity 0.3s;
|
||||
}
|
||||
.step-card.executing { opacity: 0.6; pointer-events: none; }
|
||||
.step-card.done { border-left: 4px solid #43aa8b; }
|
||||
.step-card.failed { border-left: 4px solid #dc3545; }
|
||||
.step-result {
|
||||
background: #1a1a2e; color: #e0e0e0; padding: 0.75rem; border-radius: 6px;
|
||||
margin-top: 0.5rem; font-size: 0.8rem; max-height: 300px; overflow: auto; white-space: pre-wrap;
|
||||
display: none;
|
||||
margin-top: 0.5rem; font-size: 0.8rem; max-height: 300px; overflow: auto;
|
||||
white-space: pre-wrap; display: none;
|
||||
}
|
||||
.step-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||
.step-num {
|
||||
background: #4361ee; color: #fff; border-radius: 50%; width: 24px; height: 24px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 0.8rem; font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-num.done { background: #43aa8b; }
|
||||
.step-num.failed { background: #dc3545; }
|
||||
.step-name { font-weight: 600; font-size: 1rem; }
|
||||
.param-row { margin: 0.4rem 0; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.param-row label { min-width: 120px; font-size: 0.85rem; color: #555; }
|
||||
.param-row input, .param-row select { flex: 1; padding: 0.35rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.85rem; }
|
||||
.remove-step { color: #dc3545; cursor: pointer; font-size: 1.2rem; margin-left: auto; }
|
||||
.remove-step { color: #dc3545; cursor: pointer; font-size: 1.2rem; margin-left: auto; padding:0 0.25rem; }
|
||||
.badge-tool {
|
||||
display: inline-block; padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||
font-size: 0.7rem; font-weight: 600; margin-right: 0.3rem;
|
||||
@@ -29,31 +34,49 @@
|
||||
.badge-core { background: #4361ee; color: #fff; }
|
||||
.badge-analysis { background: #43aa8b; color: #fff; }
|
||||
.badge-diag { background: #f72585; color: #fff; }
|
||||
.save-load-bar { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:4px; }
|
||||
.status-dot.pending { background:#ccc; }
|
||||
.status-dot.running { background:#4361ee; animation:pulse 0.8s infinite; }
|
||||
.status-dot.done { background:#43aa8b; }
|
||||
.status-dot.failed { background:#dc3545; }
|
||||
@keyframes pulse { 0%{opacity:1;} 50%{opacity:0.3;} 100%{opacity:1;} }
|
||||
</style>
|
||||
|
||||
<div class="card">
|
||||
<h2>🔧 手动分析工作台</h2>
|
||||
<p style="color:#666;">构建你的分析流程:选择数据集 → 添加分析步骤 → 逐条执行</p>
|
||||
<p style="color:#666;">构建分析方案(带参数的执行流)→ 保存/加载 → 逐步或一键运行</p>
|
||||
<!-- Save/Load bar -->
|
||||
<div class="save-load-bar" style="margin-top:0.75rem;padding-top:0.75rem;border-top:1px solid #eee;">
|
||||
<input type="text" id="planName" placeholder="方案名称" style="padding:0.35rem;border:1px solid #ccc;border-radius:4px;width:200px;">
|
||||
<button class="btn" onclick="savePlan()" style="background:#43aa8b;color:#fff;font-size:0.85rem;">💾 保存</button>
|
||||
<select id="loadPlanSelect" style="padding:0.35rem;border:1px solid #ccc;border-radius:4px;">
|
||||
<option value="">-- 加载方案 --</option>
|
||||
</select>
|
||||
<button class="btn" onclick="loadPlan()" style="font-size:0.85rem;">📂 加载</button>
|
||||
<button class="btn" onclick="deletePlan()" style="background:#dc3545;color:#fff;font-size:0.85rem;">🗑 删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 0: Dataset selector -->
|
||||
<!-- Dataset selector + Run All -->
|
||||
<div class="card">
|
||||
<h3>📁 选择数据集</h3>
|
||||
{% if datasets %}
|
||||
<select id="datasetSelect" style="width:100%;padding:0.5rem;border:1px solid #ccc;border-radius:4px;">
|
||||
<option value="">-- 选择数据集 --</option>
|
||||
{% for ds in datasets %}
|
||||
<option value="{{ ds.id }}">{{ ds.id }} ({{ ds.metadata.row_count|default:"?" }} rows, {{ ds.metadata.file_count|default:"?" }} files)</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<p style="color:#999;">暂无数据集。先去 <a href="/upload/">上传页面</a> 上传 CSV。</p>
|
||||
{% endif %}
|
||||
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||
<div style="flex:1;min-width:200px;">
|
||||
<label style="font-weight:600;font-size:0.9rem;">📁 数据集</label>
|
||||
<select id="datasetSelect" style="width:100%;padding:0.4rem;border:1px solid #ccc;border-radius:4px;margin-top:0.25rem;">
|
||||
<option value="">-- 选择 --</option>
|
||||
{% for ds in datasets %}
|
||||
<option value="{{ ds.id }}">{{ ds.id }} ({{ ds.metadata.row_count|default:"?" }} rows)</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="runAllBtn" onclick="runAll()" style="margin-top:1rem;">▶▶ 全部执行</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workflow builder -->
|
||||
<div class="card">
|
||||
<h3>📋 分析流程 <span style="font-weight:400;font-size:0.85rem;color:#888;">依次添加分析步骤</span></h3>
|
||||
<h3>📋 分析步骤 <span style="font-weight:400;font-size:0.85rem;color:#888;">共 <span id="stepCount">0</span> 步</span></h3>
|
||||
<div id="workflowSteps"></div>
|
||||
<button class="btn" onclick="showToolPicker()" style="margin-top:0.5rem;background:#e8f0fe;color:#4361ee;">+ 添加步骤</button>
|
||||
</div>
|
||||
@@ -63,9 +86,9 @@
|
||||
<div class="card">
|
||||
<h4>选择工具</h4>
|
||||
<div style="display:flex;gap:0.5rem;margin:0.5rem 0;flex-wrap:wrap;">
|
||||
<button class="btn" onclick="filterTools('core')" style="background:#4361ee;color:#fff;font-size:0.8rem;">核心</button>
|
||||
<button class="btn" onclick="filterTools('analysis')" style="background:#43aa8b;color:#fff;font-size:0.8rem;">分析</button>
|
||||
<button class="btn" onclick="filterTools('diag')" style="background:#f72585;color:#fff;font-size:0.8rem;">诊断</button>
|
||||
<button class="btn" onclick="filterTools('core')" style="background:#4361ee;color:#fff;font-size:0.8rem;">⚡核心</button>
|
||||
<button class="btn" onclick="filterTools('analysis')" style="background:#43aa8b;color:#fff;font-size:0.8rem;">🔍分析</button>
|
||||
<button class="btn" onclick="filterTools('diag')" style="background:#f72585;color:#fff;font-size:0.8rem;">🩺诊断</button>
|
||||
<button class="btn" onclick="filterTools('all')" style="font-size:0.8rem;">全部</button>
|
||||
</div>
|
||||
<div id="toolList" style="max-height:300px;overflow-y:auto;"></div>
|
||||
@@ -73,7 +96,6 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── All 27 tools metadata (embedded from server) ──
|
||||
const TOOLS = {{ tools_meta|safe }};
|
||||
const CATEGORIES = {
|
||||
{% for t in core_tools %}'{{ t.name }}': 'core', {% endfor %}
|
||||
@@ -82,6 +104,7 @@ const CATEGORIES = {
|
||||
};
|
||||
let stepCounter = 0;
|
||||
const workflow = [];
|
||||
let running = false;
|
||||
|
||||
// ── Tool picker ──
|
||||
function showToolPicker() {
|
||||
@@ -100,29 +123,35 @@ function filterTools(cat) {
|
||||
div.style.cssText = 'padding:0.5rem;cursor:pointer;border-bottom:1px solid #eee;display:flex;align-items:center;gap:0.5rem;';
|
||||
div.innerHTML = `<span style="background:${color};color:#fff;padding:0.1rem 0.4rem;border-radius:3px;font-size:0.7rem;font-weight:600;">${badge}</span>
|
||||
<span style="font-weight:600;">${t.name}</span>
|
||||
<span style="color:#888;font-size:0.8rem;flex:1;">${t.description.substring(0, 80)}...</span>`;
|
||||
<span style="color:#888;font-size:0.8rem;flex:1;">${t.description.substring(0,80)}...</span>`;
|
||||
div.onclick = () => addStep(t);
|
||||
list.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Add a step to the workflow ──
|
||||
function addStep(tool) {
|
||||
// ── Workflow steps ──
|
||||
function addStep(tool, savedParams) {
|
||||
document.getElementById('toolPicker').style.display = 'none';
|
||||
const idx = stepCounter++;
|
||||
const step = { idx, tool, params: {} };
|
||||
const step = { idx, tool, params: savedParams || {}, status: 'pending' };
|
||||
workflow.push(step);
|
||||
renderSteps();
|
||||
}
|
||||
|
||||
function removeStep(idx) {
|
||||
const pos = workflow.findIndex(s => s.idx === idx);
|
||||
if (pos >= 0) workflow.splice(pos, 1);
|
||||
renderSteps();
|
||||
}
|
||||
function moveStep(idx, dir) {
|
||||
const pos = workflow.findIndex(s => s.idx === idx);
|
||||
if (pos < 0) return;
|
||||
const target = pos + dir;
|
||||
if (target < 0 || target >= workflow.length) return;
|
||||
[workflow[pos], workflow[target]] = [workflow[target], workflow[pos]];
|
||||
renderSteps();
|
||||
}
|
||||
|
||||
// ── Build parameter form for a tool ──
|
||||
function buildParamForm(tool, container, stepIdx) {
|
||||
function buildParamForm(tool, container, stepIdx, savedParams) {
|
||||
const props = tool.inputSchema && tool.inputSchema.properties;
|
||||
if (!props) { container.innerHTML = '<p style="color:#888;font-size:0.85rem;">无需参数</p>'; return; }
|
||||
const required = tool.inputSchema.required || [];
|
||||
@@ -130,118 +159,192 @@ function buildParamForm(tool, container, stepIdx) {
|
||||
for (const [key, prop] of Object.entries(props)) {
|
||||
const isReq = required.includes(key);
|
||||
const label = prop.description || key;
|
||||
const saved = savedParams && savedParams[key] !== undefined ? savedParams[key] : null;
|
||||
let input;
|
||||
if (prop.type === 'boolean') {
|
||||
input = `<select class="param-${stepIdx}" data-key="${key}"><option value="true">true</option><option value="false" selected>false</option></select>`;
|
||||
const val = saved !== null ? saved : true;
|
||||
input = `<select class="p-${stepIdx}" data-key="${key}"><option value="true" ${val===true?'selected':''}>true</option><option value="false" ${val===false?'selected':''}>false</option></select>`;
|
||||
} else if (prop.type === 'array') {
|
||||
input = `<input class="param-${stepIdx}" data-key="${key}" placeholder='["val1","val2"]' style="width:100%;">`;
|
||||
const val = saved !== null ? JSON.stringify(saved) : '';
|
||||
input = `<input class="p-${stepIdx}" data-key="${key}" value='${val}' placeholder='["val1","val2"]' style="width:100%;">`;
|
||||
} else if (prop.type === 'number' || prop.type === 'integer') {
|
||||
input = `<input class="param-${stepIdx}" data-key="${key}" type="number" value="${prop.default || ''}" step="${prop.type==='integer'?1:0.01}" style="width:100%;">`;
|
||||
const val = saved !== null ? saved : (prop.default || '');
|
||||
input = `<input class="p-${stepIdx}" data-key="${key}" type="number" value="${val}" step="${prop.type==='integer'?1:0.01}" style="width:100%;">`;
|
||||
} else {
|
||||
// Check if this is a dataset_id field — offer dataset list
|
||||
const val = saved !== null ? saved : '';
|
||||
if (key.includes('dataset_id')) {
|
||||
let opts = '<option value="">-- select --</option>';
|
||||
document.querySelectorAll('#datasetSelect option').forEach(o => {
|
||||
if (o.value) opts += `<option value="${o.value}">${o.value}</option>`;
|
||||
});
|
||||
input = `<select class="param-${stepIdx}" data-key="${key}" style="width:100%;">${opts}</select>`;
|
||||
document.querySelectorAll('#datasetSelect option').forEach(o => { if (o.value) opts += `<option value="${o.value}" ${o.value===val?'selected':''}>${o.value}</option>`; });
|
||||
input = `<select class="p-${stepIdx}" data-key="${key}" style="width:100%;">${opts}</select>`;
|
||||
} else if (key === 'algorithm') {
|
||||
input = `<select class="param-${stepIdx}" data-key="${key}" style="width:100%;">
|
||||
<option value="hdbscan">HDBSCAN</option><option value="kmeans">KMeans</option></select>`;
|
||||
input = `<select class="p-${stepIdx}" data-key="${key}" style="width:100%;"><option value="hdbscan" ${val==='hdbscan'?'selected':''}>HDBSCAN</option><option value="kmeans" ${val==='kmeans'?'selected':''}>KMeans</option></select>`;
|
||||
} else if (key === 'method') {
|
||||
input = `<select class="param-${stepIdx}" data-key="${key}" style="width:100%;">
|
||||
<option value="zscore">zscore</option><option value="anova">anova</option></select>`;
|
||||
input = `<select class="p-${stepIdx}" data-key="${key}" style="width:100%;"><option value="zscore" ${val==='zscore'?'selected':''}>zscore</option><option value="anova" ${val==='anova'?'selected':''}>anova</option></select>`;
|
||||
} else {
|
||||
input = `<input class="param-${stepIdx}" data-key="${key}" placeholder="${label}" style="width:100%;">`;
|
||||
input = `<input class="p-${stepIdx}" data-key="${key}" value="${val}" placeholder="${label}" style="width:100%;">`;
|
||||
}
|
||||
}
|
||||
html += `<div class="param-row"><label>${key}${isReq ? ' <span style="color:#dc3545;">*</span>' : ''}</label>${input}</div>`;
|
||||
html += `<div class="param-row"><label>${key}${isReq?' <span style="color:#dc3545;">*</span>':''}</label>${input}</div>`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Collect params from form ──
|
||||
function collectParams(stepIdx) {
|
||||
const params = {};
|
||||
document.querySelectorAll(`.param-${stepIdx}`).forEach(el => {
|
||||
let val = el.value;
|
||||
const key = el.dataset.key;
|
||||
document.querySelectorAll(`.p-${stepIdx}`).forEach(el => {
|
||||
let val = el.value; const key = el.dataset.key;
|
||||
if (!key || val === '' || val === null) return;
|
||||
// Determine type from input type
|
||||
if (el.type === 'number') val = parseFloat(val);
|
||||
else if (el.tagName === 'SELECT' && el.dataset.key === 'algorithm') { /* keep string */ }
|
||||
else if (val.startsWith('[') || val.startsWith('{')) { try { val = JSON.parse(val); } catch(e) {} }
|
||||
params[key] = val;
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
// ── Execute a single step ──
|
||||
// ── Execute single step ──
|
||||
async function executeStep(idx) {
|
||||
const step = workflow.find(s => s.idx === idx);
|
||||
if (!step) return;
|
||||
step.status = 'running'; renderSteps();
|
||||
const card = document.getElementById(`step-${idx}`);
|
||||
const resultDiv = document.getElementById(`result-${idx}`);
|
||||
const btn = document.getElementById(`exec-${idx}`);
|
||||
btn.disabled = true; card.classList.add('executing');
|
||||
resultDiv.style.display = 'none';
|
||||
|
||||
const params = collectParams(idx);
|
||||
try {
|
||||
const resp = await fetch('/tools/run/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token }}' },
|
||||
headers: { 'Content-Type':'application/json', 'X-CSRFToken':'{{ csrf_token }}' },
|
||||
body: JSON.stringify({ tool: step.tool.name, params }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
resultDiv.textContent = JSON.stringify(data, null, 2);
|
||||
resultDiv.style.display = 'block';
|
||||
} catch (err) {
|
||||
step.status = data.error ? 'failed' : 'done';
|
||||
// update dataset select from result
|
||||
if (data && data.result && data.result.dataset_id) {
|
||||
const sel = document.getElementById('datasetSelect');
|
||||
if (!Array.from(sel.options).some(o => o.value === data.result.dataset_id)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = data.result.dataset_id;
|
||||
opt.text = data.result.dataset_id;
|
||||
sel.add(opt);
|
||||
}
|
||||
}
|
||||
} catch(err) {
|
||||
resultDiv.textContent = 'Error: ' + err.message;
|
||||
resultDiv.style.display = 'block';
|
||||
step.status = 'failed';
|
||||
}
|
||||
btn.disabled = false; card.classList.remove('executing');
|
||||
renderSteps();
|
||||
}
|
||||
|
||||
// Update dataset_id suggestions from results
|
||||
if (data && data.result && data.result.dataset_id) {
|
||||
const sel = document.getElementById('datasetSelect');
|
||||
const exists = Array.from(sel.options).some(o => o.value === data.result.dataset_id);
|
||||
if (!exists) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = data.result.dataset_id;
|
||||
opt.text = data.result.dataset_id + ' (from step ' + (stepCounter) + ')';
|
||||
sel.add(opt);
|
||||
}
|
||||
// ── Run All (sequential) ──
|
||||
async function runAll() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
document.getElementById('runAllBtn').disabled = true;
|
||||
for (const step of workflow) {
|
||||
if (step.status === 'done') continue; // skip already-done
|
||||
await executeStep(step.idx);
|
||||
}
|
||||
running = false;
|
||||
document.getElementById('runAllBtn').disabled = false;
|
||||
}
|
||||
|
||||
// ── Save / Load ──
|
||||
async function savePlan() {
|
||||
const name = document.getElementById('planName').value.trim();
|
||||
if (!name) { alert('请输入方案名称'); return; }
|
||||
const steps = workflow.map(s => ({
|
||||
tool_name: s.tool.name,
|
||||
params: collectParams(s.idx),
|
||||
}));
|
||||
const resp = await fetch('/tools/plan/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type':'application/json', 'X-CSRFToken':'{{ csrf_token }}' },
|
||||
body: JSON.stringify({ name, steps }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.status === 'saved') {
|
||||
alert('方案已保存: ' + name);
|
||||
loadPlanList();
|
||||
} else {
|
||||
alert('保存失败: ' + (data.error || ''));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render all workflow steps ──
|
||||
async function loadPlanList() {
|
||||
const resp = await fetch('/tools/plan/');
|
||||
const data = await resp.json();
|
||||
const sel = document.getElementById('loadPlanSelect');
|
||||
sel.innerHTML = '<option value="">-- 加载方案 --</option>';
|
||||
(data.plans || []).forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.name; opt.text = p.name + (p.steps ? ' (' + p.steps + ' steps)' : '');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPlan() {
|
||||
const name = document.getElementById('loadPlanSelect').value;
|
||||
if (!name) return;
|
||||
const resp = await fetch('/tools/plan/?name=' + encodeURIComponent(name));
|
||||
const data = await resp.json();
|
||||
if (data.error) { alert(data.error); return; }
|
||||
// Clear current workflow
|
||||
workflow.length = 0;
|
||||
// Load steps
|
||||
for (const s of data.steps || []) {
|
||||
const tool = TOOLS.find(t => t.name === s.tool_name);
|
||||
if (tool) addStep(tool, s.params || {});
|
||||
}
|
||||
document.getElementById('planName').value = name;
|
||||
}
|
||||
|
||||
async function deletePlan() {
|
||||
const name = document.getElementById('loadPlanSelect').value;
|
||||
if (!name || !confirm('删除方案 "' + name + '" ?')) return;
|
||||
const resp = await fetch('/tools/plan/?name=' + encodeURIComponent(name), { method: 'DELETE' });
|
||||
const data = await resp.json();
|
||||
if (data.status === 'deleted') loadPlanList();
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
function renderSteps() {
|
||||
const container = document.getElementById('workflowSteps');
|
||||
container.innerHTML = '';
|
||||
workflow.forEach((step, i) => {
|
||||
const c = CATEGORIES[step.tool.name] || 'other';
|
||||
const color = c === 'core' ? '#4361ee' : c === 'analysis' ? '#43aa8b' : '#f72585';
|
||||
const badge = c === 'core' ? 'CORE' : c === 'analysis' ? 'ANALYSIS' : 'DIAG';
|
||||
const color = c==='core' ? '#4361ee' : c==='analysis' ? '#43aa8b' : '#f72585';
|
||||
const badge = c==='core' ? 'CORE' : c==='analysis' ? 'ANALYSIS' : 'DIAG';
|
||||
const statusColor = step.status==='done' ? '#43aa8b' : step.status==='failed' ? '#dc3545' : step.status==='running' ? '#4361ee' : '#ccc';
|
||||
const statusText = step.status==='done' ? '✓' : step.status==='failed' ? '✗' : step.status==='running' ? '⟳' : '';
|
||||
const statusPulse = step.status==='running' ? 'animation:pulse 0.8s infinite;' : '';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'step-card';
|
||||
div.className = 'step-card' + (step.status==='done' ? ' done' : step.status==='failed' ? ' failed' : '');
|
||||
div.id = `step-${step.idx}`;
|
||||
div.innerHTML = `
|
||||
<div class="step-header">
|
||||
<span class="step-num">${i+1}</span>
|
||||
<span class="step-num ${step.status}" style="${statusPulse}">${statusText || (i+1)}</span>
|
||||
<span class="step-name">${step.tool.name}</span>
|
||||
<span class="badge-tool" style="background:${color};">${badge}</span>
|
||||
<span style="font-size:0.8rem;color:#888;flex:1;">${step.tool.description.substring(0, 60)}</span>
|
||||
<span class="remove-step" onclick="removeStep(${step.idx})" title="移除此步骤">✕</span>
|
||||
<span style="font-size:0.8rem;color:#888;flex:1;">${step.tool.description.substring(0,50)}</span>
|
||||
<span style="cursor:pointer;color:#888;font-size:0.9rem;" onclick="moveStep(${step.idx},-1)" title="上移">↑</span>
|
||||
<span style="cursor:pointer;color:#888;font-size:0.9rem;" onclick="moveStep(${step.idx},1)" title="下移">↓</span>
|
||||
<span class="remove-step" onclick="removeStep(${step.idx})">✕</span>
|
||||
</div>
|
||||
<div id="params-${step.idx}"></div>
|
||||
<button class="btn btn-primary" id="exec-${step.idx}" onclick="executeStep(${step.idx})" style="font-size:0.85rem;padding:0.35rem 0.75rem;margin-top:0.3rem;">▶ 执行</button>
|
||||
<button class="btn btn-primary" onclick="executeStep(${step.idx})" style="font-size:0.85rem;padding:0.35rem 0.75rem;margin-top:0.3rem;">▶ 执行</button>
|
||||
<div class="step-result" id="result-${step.idx}"></div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
buildParamForm(step.tool, document.getElementById(`params-${step.idx}`), step.idx);
|
||||
buildParamForm(step.tool, document.getElementById(`params-${step.idx}`), step.idx, step.params);
|
||||
});
|
||||
document.getElementById('stepCount').textContent = workflow.length;
|
||||
}
|
||||
|
||||
// ── Init: load plan list ──
|
||||
loadPlanList();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user