feat: quick execute panel in scripts.html (no script save required)

Add collapsible '直接执行命令' card at top of scripts page:
- Textarea for ad-hoc command (no script_id, not saved to library)
- Multi-select server list (loaded on first expand, same batch size)
- Options: timeout, DB credential (* substitution), long_task nohup
- Ctrl+Enter shortcut to submit
- Uses same batch tracking + status panel + polling as saved scripts
- Clear button to reset command
- Batch hint shows server count and batch size
- 全选 button for quick server selection

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-05-23 17:23:25 +08:00
parent d2832567c7
commit 4ca07fa252
+163 -2
View File
@@ -2,11 +2,80 @@
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">脚本库</h1></div><div class="flex items-center gap-2"><a href="/app/script-executions.html" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg">执行历史</a><button onclick="showNewScript()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">+ 新建脚本</button></div></div></header>
<main class="flex-1 overflow-y-auto p-6">
<div id="execStatusPanel" class="hidden mb-6 bg-slate-900 rounded-xl border border-slate-800 p-4">
<main class="flex-1 overflow-y-auto p-6 space-y-4">
<!-- ── 直接执行(无需保存到脚本库)── -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<button onclick="toggleQuickExec()" id="quickExecToggleBtn"
class="w-full flex items-center justify-between px-5 py-3 text-left hover:bg-slate-800/50 transition group">
<div class="flex items-center gap-2">
<span class="text-brand-light font-mono text-sm"></span>
<span class="text-white font-medium text-sm">直接执行命令</span>
<span class="text-slate-500 text-xs">(临时命令,不保存到脚本库)</span>
</div>
<svg id="quickExecChevron" class="w-4 h-4 text-slate-500 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="quickExecPanel" class="hidden border-t border-slate-800 p-5 space-y-4">
<!-- Command input -->
<div>
<label class="block text-slate-400 text-xs mb-1.5">命令内容 <span class="text-slate-600">(支持多行 Shell 脚本)</span></label>
<textarea id="quickCmd" rows="4" placeholder="echo 'hello world'&#10;df -h&#10;uptime"
class="w-full px-4 py-3 bg-slate-950 border border-slate-700 rounded-xl text-green-400 text-sm font-mono
focus:outline-none focus:ring-2 focus:ring-brand resize-y placeholder-slate-700"
onkeydown="if(event.ctrlKey&&event.key==='Enter'){event.preventDefault();doQuickExec()}"></textarea>
<p class="text-slate-600 text-xs mt-1">Ctrl + Enter 快捷执行</p>
</div>
<!-- Servers + options row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="flex items-center justify-between mb-1.5">
<label class="text-slate-400 text-xs">目标服务器</label>
<button onclick="selectAllQuickServers()" class="text-brand-light text-xs hover:underline">全选</button>
</div>
<select id="quickServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-28"></select>
<p id="quickBatchHint" class="text-slate-600 text-xs mt-1"></p>
</div>
<div class="space-y-3">
<div>
<label class="block text-slate-400 text-xs mb-1">超时 (秒)</label>
<input id="quickTimeout" type="number" value="60" min="10" max="600"
class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
</div>
<div>
<label class="block text-slate-400 text-xs mb-1">DB 凭据(可选)</label>
<select id="quickCredential" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<option value="">不使用</option>
</select>
</div>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" id="quickLongTask" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand">
<span class="text-xs text-slate-300">长任务(后台 nohup</span>
</label>
</div>
</div>
<!-- Execute button -->
<div class="flex items-center gap-3">
<button onclick="doQuickExec()" id="quickExecBtn"
class="px-5 py-2.5 bg-brand hover:bg-brand-dark text-white text-sm font-medium rounded-lg transition disabled:opacity-50">
⚡ 执行
</button>
<button onclick="document.getElementById('quickCmd').value=''" class="px-3 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded-lg transition">
清空
</button>
<span class="text-slate-600 text-xs">命令不会被保存;执行结果在下方「批量执行状态」查看</span>
</div>
</div>
</div>
<!-- ── 执行状态 ── -->
<div id="execStatusPanel" class="hidden bg-slate-900 rounded-xl border border-slate-800 p-4">
<h2 class="text-white font-semibold text-sm mb-3">批量执行状态</h2>
<div id="execStatusList" class="space-y-2"></div>
</div>
<!-- ── 脚本库 ── -->
<div id="scriptList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
<!-- Add/Edit Script Form -->
@@ -54,6 +123,98 @@
let _trackedExecutions=[];
let _execPollTimer=null;
// ── Quick Execute ──
let _quickServersLoaded = false;
function toggleQuickExec(){
const panel=document.getElementById('quickExecPanel');
const chevron=document.getElementById('quickExecChevron');
const open=panel.classList.toggle('hidden');
chevron.style.transform=open?'':'rotate(180deg)';
if(!open&&!_quickServersLoaded) loadQuickServers();
}
async function loadQuickServers(){
_quickServersLoaded=true;
try{
const [srvR,credR]=await Promise.all([apiFetch(API+'/servers/?per_page=200'),apiFetch(API+'/scripts/credentials')]);
if(!srvR)return;
const data=await srvR.json();
const servers=data.items||data;
document.getElementById('quickServers').innerHTML=servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
const hint=`已加载 ${servers.length} 台服务器 · 每批 ${_execBatchSize} 台并发`;
document.getElementById('quickBatchHint').textContent=hint;
if(credR&&credR.ok){
const creds=await credR.json();
const sel=document.getElementById('quickCredential');
(Array.isArray(creds)?creds:[]).forEach(c=>{const o=document.createElement('option');o.value=c.id;o.textContent=`${esc(c.name)} (${esc(c.host)})`;sel.appendChild(o)});
}
}catch(e){}
}
function selectAllQuickServers(){
Array.from(document.getElementById('quickServers').options).forEach(o=>o.selected=true);
}
async function doQuickExec(){
const cmd=document.getElementById('quickCmd').value.trim();
if(!cmd){toast('warning','请输入命令');return}
const opts=document.getElementById('quickServers').selectedOptions;
const ids=Array.from(opts).map(o=>parseInt(o.value));
if(!ids.length){toast('warning','请选择服务器');return}
const timeout=parseInt(document.getElementById('quickTimeout').value)||60;
const longTask=document.getElementById('quickLongTask').checked;
const credEl=document.getElementById('quickCredential');
const credentialId=credEl&&credEl.value?parseInt(credEl.value,10):null;
const btn=document.getElementById('quickExecBtn');
btn.disabled=true;btn.textContent='提交中...';
// Reuse the same batch tracking / status panel logic
const batches=[];
for(let i=0;i<ids.length;i+=_execBatchSize)batches.push(ids.slice(i,i+_execBatchSize));
document.getElementById('execStatusPanel').classList.remove('hidden');
const jobId='quick-'+Date.now();
const cmdPreview=cmd.length>40?cmd.substring(0,40)+'...':cmd;
const merged={};
const job={
id:jobId,
scriptName:`[临时] ${cmdPreview}`,
longTask,totalServers:ids.length,batchTotal:batches.length,batchCur:0,
executionIds:[],submitting:true,data:{status:'running',results:'{}'},
};
_trackedExecutions.unshift(job);
if(_trackedExecutions.length>20)_trackedExecutions.pop();
renderExecStatus();
let failBatches=0;
const batchStatuses=[];
for(let b=0;b<batches.length;b++){
const batchIds=batches[b];
job.batchCur=b+1;job.submitting=true;job.data.status='running';
renderExecStatus();
const body={command:cmd,server_ids:batchIds,timeout,long_task:longTask};
if(credentialId)body.credential_id=credentialId;
const r=await apiFetch(API+'/scripts/exec',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
job.submitting=false;
if(!r||!r.ok){failBatches++;batchStatuses.push('failed');toast('error',`批次 ${b+1} 失败`);continue}
const d=await r.json();
job.executionIds.push(d.id);
batchStatuses.push(d.status||'running');
mergeExecResults(merged,d.results);
job.data.results=JSON.stringify(merged);
job.data.status=aggregateExecStatus(batchStatuses);
renderExecStatus();
}
job.data.status=aggregateExecStatus(batchStatuses)||'completed';
renderExecStatus();
btn.disabled=false;btn.textContent='⚡ 执行';
if(failBatches===batches.length){toast('error','所有批次失败')}
else{toast('success',`执行完成: ${ids.length} 台服务器`)}
// Start polling if any long tasks
if(longTask&&!_execPollTimer)startExecPoll();
}
const EXEC_STATUS_LABEL={running:'执行中',completed:'执行完',failed:'执行完',partial:'执行完'};
const EXEC_STATUS_CLASS={
running:'bg-amber-500/20 text-amber-300 border-amber-500/40',