保存前端调整前的当前状态
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
10 files changed
+204
-3
No files matched your search
@@ -42,6 +42,7 @@ uv run --no-project --python 3.12 --with-requirements requirements.txt python ap
|
||||
|
||||
## 终止、恢复与冲突
|
||||
|
||||
- 队列支持勾选单篇或全选可移除文章,然后点击“移除所选文章”。请先暂停流程;移除只删除队列记录,原始文章、图片、已受理去重记录和计数保留。当前任务或提交结果未核对的文章须先核对并处理;未受理文章移除后可通过扫描目录重新入队。
|
||||
- 网站左下角悬浮窗提供“跳过本文章”:将当前未提交文章标记为“已跳过”,保留队列记录、不计发表篇数,并打开新的发帖页继续导入排版下一篇。导入中会先取消并等待回滚;已上传附件可能仍保留在网站。最后一篇跳过后流程结束。已点击发表或结果不明的文章不能直接跳过,须先人工核对。已跳过文章不会自动重新入队,需要重试时可在暂停后通过队列“人工核对 / 处理”重新排队。
|
||||
- 网站左下角常驻“终止流程”,点击后立即取消本标签可取消的导入,并通知其他安装脚本的标签和换号插件;持久状态禁止继续领取任务。
|
||||
- 控制台“终止流程”还会停止下载,浏览器在下一次连接检查时停止(正常轮询约 2 秒)。网络断开时无法承诺固定延迟;网站按钮可立即停止本标签。已到达网站的图片、登录或发帖请求无法撤回。
|
||||
|
||||
@@ -91,6 +91,8 @@ class Controller:
|
||||
return self.store.reconcile(body)
|
||||
if route == 'resolve':
|
||||
return self.store.resolve(body)
|
||||
if route == 'remove':
|
||||
return self.store.remove(body)
|
||||
if route == 'history':
|
||||
records = body.get('records')
|
||||
if not isinstance(records, list):
|
||||
|
||||
+41
-1
@@ -1,6 +1,24 @@
|
||||
'use strict';
|
||||
const $=id=>document.getElementById(id);
|
||||
let token='',state=null,loadedKey='',refreshing=false;
|
||||
const selectedJobs=new Set();
|
||||
let removing=false;
|
||||
function removalBlocked(job,run){
|
||||
if(job.id===run.active)return '当前任务请先通过“人工核对 / 处理”核对并移出';
|
||||
if(['user_submitting','submit_unknown'].includes(job.state)||(job.submitted_at&&!['published','accepted_pending_moderation'].includes(job.state)))return '请先核对提交结果并处理';
|
||||
return '';
|
||||
}
|
||||
function updateSelection(){
|
||||
if(!state)return;
|
||||
const eligible=state.jobs.filter(job=>!removalBlocked(job,state.run));
|
||||
const eligibleIds=new Set(eligible.map(job=>job.id));
|
||||
$('selected-count').textContent=`已选 ${selectedJobs.size} 篇`;
|
||||
$('remove-selected').disabled=removing||state.run.running||!selectedJobs.size;
|
||||
$('select-all').disabled=removing||!eligible.length;
|
||||
$('select-all').checked=eligible.length>0&&eligible.every(job=>selectedJobs.has(job.id));
|
||||
$('select-all').indeterminate=selectedJobs.size>0&&!$('select-all').checked;
|
||||
document.querySelectorAll('.job-select').forEach(input=>{input.checked=selectedJobs.has(input.value);input.disabled=removing||!eligibleIds.has(input.value);});
|
||||
}
|
||||
const labels={queued:'待处理',preparing:'准备中',importing:'上传与导入',formatting:'排版中',awaiting_user_review:'等待人工审核',user_submitting:'已观察到人工提交',submit_unknown:'发布结果待核对',accepted_pending_moderation:'已受理 · 审核中',published:'已确认公开',needs_attention:'需要处理',cancelled:'已移出',skipped:'已跳过',rejected:'网站明确拒绝'};
|
||||
function notice(message){$('notice').hidden=!message;$('notice').textContent=message;}
|
||||
async function api(route,body={}) {
|
||||
@@ -11,12 +29,18 @@ function action(id,fn){$(id).addEventListener('click',async()=>{const button=$(i
|
||||
function text(tag,value,className){const e=document.createElement(tag);e.textContent=value;if(className)e.className=className;return e;}
|
||||
function render(data){
|
||||
state=data;const {jobs,run,download}=data;
|
||||
const eligibleIds=new Set(jobs.filter(job=>!removalBlocked(job,run)).map(job=>job.id));
|
||||
for(const id of selectedJobs)if(!eligibleIds.has(id))selectedJobs.delete(id);
|
||||
$('total').textContent=jobs.length;$('review').textContent=jobs.filter(j=>j.state==='awaiting_user_review').length;
|
||||
$('accepted').textContent=jobs.filter(j=>['published','accepted_pending_moderation'].includes(j.state)).length;
|
||||
$('batch').textContent=run.batch?`${run.batch.count} / ${run.batch.target}`:'—';$('account').textContent=run.batch?run.batch.account.name:'尚未绑定账号';
|
||||
$('run-state').textContent=run.running?'流程运行中':'流程已暂停';$('run-reason').textContent=run.reason;
|
||||
$('run-light').classList.toggle('gray',!run.running);$('empty').hidden=jobs.length>0;
|
||||
$('jobs').replaceChildren(...jobs.map(job=>{
|
||||
const selection=document.createElement('td'),checkbox=document.createElement('input');
|
||||
checkbox.type='checkbox';checkbox.className='job-select';checkbox.value=job.id;
|
||||
checkbox.setAttribute('aria-label','选择文章:'+job.title);checkbox.title=removalBlocked(job,run)||'选择此文章';
|
||||
checkbox.onchange=()=>{checkbox.checked?selectedJobs.add(job.id):selectedJobs.delete(job.id);updateSelection();};selection.append(checkbox);
|
||||
const tr=document.createElement('tr'),title=document.createElement('td');title.append(text('div',job.title),text('small',`${job.image_count} 张配图 · ${job.source}`));if(job.original_title!==job.title)title.append(text('small','已缩短标题,原题:'+job.original_title));
|
||||
const status=document.createElement('td');status.append(text('span',labels[job.state]||job.state,'badge'));if(job.error)status.append(text('small',job.error));
|
||||
for(const issue of job.integrity_issues||[])status.append(text('small',issue));
|
||||
@@ -45,8 +69,9 @@ function render(data){
|
||||
try{await api('resolve',{id:job.id,confirmed_not_accepted:true,retry:again});await refresh();}catch(error){notice(error.message);}
|
||||
};result.append(retry);
|
||||
}
|
||||
tr.append(title,status,account,result);return tr;
|
||||
tr.append(selection,title,status,account,result);return tr;
|
||||
}));
|
||||
updateSelection();
|
||||
const dlLabels={idle:'等待开始',running:'下载中',completed:'下载完成',partial:'未达到目标',cancelled:'已停止',error:'下载出错'};
|
||||
$('download-state').textContent=dlLabels[download.status]||download.status;$('download-progress').textContent=`已保存 ${download.saved}/${download.target} 篇 · 跳过 ${download.skipped} · 失败 ${download.failed}`;
|
||||
$('progress').value=download.target?Math.floor(download.saved/download.target*100):0;
|
||||
@@ -55,6 +80,21 @@ function render(data){
|
||||
$('clients').textContent=data.clients.map(c=>`${c.account?.name||'未识别账号'} · 换号插件${c.extension?.ready?'已就绪':'未连接或需更新'}`).join('\n')||'没有在线执行标签';
|
||||
}
|
||||
async function refresh(){if(refreshing||!token)return;refreshing=true;try{render(await api('state'));}catch(error){notice(error.message);$('run-state').textContent='本地服务连接中断';}finally{refreshing=false;}}
|
||||
$('select-all').onchange=()=>{
|
||||
const checked=$('select-all').checked;
|
||||
for(const job of state?.jobs||[])if(!removalBlocked(job,state.run)){checked?selectedJobs.add(job.id):selectedJobs.delete(job.id);}
|
||||
updateSelection();
|
||||
};
|
||||
$('remove-selected').onclick=async()=>{
|
||||
if(removing||!selectedJobs.size)return;
|
||||
if(state.run.running){notice('请先暂停流程,再移除文章');return;}
|
||||
const ids=[...selectedJobs];
|
||||
if(!confirm(`确定从文章队列移除所选的 ${ids.length} 篇文章?\n原始文章和图片文件、已受理去重记录会保留。未受理文章以后可通过扫描目录重新加入。`))return;
|
||||
removing=true;updateSelection();
|
||||
try{const result=await api('remove',{ids});for(const id of ids)selectedJobs.delete(id);notice(`已从队列移除 ${result.removed} 篇文章`);await refresh();}
|
||||
catch(error){notice(error.message);}
|
||||
finally{removing=false;updateSelection();}
|
||||
};
|
||||
action('pair',async()=>{const value=await api('pair');$('pair-code').hidden=false;$('pair-code').type='text';$('pair-code').value=value.token;$('pair-code').select();});
|
||||
action('channels',async()=>{const value=await api('channels',{key:$('key').value.trim()});loadedKey=$('key').value.trim();$('channel').replaceChildren(...value.channels.map(c=>new Option(c,c)));});
|
||||
$('key').oninput=()=>{loadedKey='';$('channel').replaceChildren(new Option('请重新获取频道',''));};
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
<form id="config-form"><label>目标版块 ID(fid)<input id="fid" type="number" min="1" placeholder="从新帖页地址中的 fid 获取" required></label><div class="row"><label class="grow">每账号最少篇数<input id="minimum" type="number" min="1" max="1000" placeholder="最小值" required></label><label class="grow">每账号最多篇数<input id="maximum" type="number" min="1" max="1000" placeholder="最大值" required></label></div><label>两篇之间间隔(秒)<input id="interval" type="number" min="0" max="86400" placeholder="请输入间隔" required></label><label class="check"><input id="history-checked" type="checkbox" required><span>我已核对待处理文章与历史发布记录,避免重复发表。</span></label><button type="submit" class="secondary">保存处理规则</button></form><p class="muted">每个账号只抽取一次目标篇数;只有明确受理的文章才计数。历史演示去重记录会自动读取。</p>
|
||||
</section></div>
|
||||
<section class="runbar"><div><span id="run-light" class="dot gray"></span><b id="run-state">正在连接本地服务…</b><p id="run-reason">等待状态</p></div><div class="actions"><button id="start">开始 / 继续</button><button id="pause" class="secondary">暂停</button></div></section>
|
||||
<section id="queue" class="card"><div class="section-head"><div><span class="eyebrow">ARTICLE QUEUE</span><h2>文章队列</h2></div><label class="file-button">导入历史去重记录<input id="history-file" type="file" accept=".json,application/json" hidden></label></div><div class="table-wrap"><table><thead><tr><th>文章</th><th>状态</th><th>账号 / 版块</th><th>结果与操作</th></tr></thead><tbody id="jobs"></tbody></table><div id="empty" class="empty"><span>+</span><b>从第一篇文章开始</b><p>下载新闻,或检查已有文章目录。准备好的文章会出现在这里。</p></div></div></section>
|
||||
<section id="queue" class="card"><div class="section-head"><div><span class="eyebrow">ARTICLE QUEUE</span><h2>文章队列</h2></div><label class="file-button">导入历史去重记录<input id="history-file" type="file" accept=".json,application/json" hidden></label></div><div class="queue-toolbar"><div class="actions"><button id="remove-selected" class="danger" disabled>移除所选文章</button><span id="selected-count" role="status" aria-live="polite">已选 0 篇</span></div><p class="muted">移除仅删除队列记录,保留原始文章、图片和已受理去重记录。运行中请先暂停;当前任务或提交结果未核对的文章,请先在“人工核对 / 处理”中处理。</p></div><div class="table-wrap"><table><thead><tr><th class="selection-cell"><input id="select-all" type="checkbox" aria-label="全选可移除文章"></th><th>文章</th><th>状态</th><th>账号 / 版块</th><th>结果与操作</th></tr></thead><tbody id="jobs"></tbody></table><div id="empty" class="empty"><span>+</span><b>从第一篇文章开始</b><p>下载新闻,或检查已有文章目录。准备好的文章会出现在这里。</p></div></div></section>
|
||||
<section id="connection" class="card"><div class="section-head"><div><span class="eyebrow">CONNECTION</span><h2>连接你的浏览器</h2></div><span id="browser-state" class="badge">尚未连接</span></div><div class="connection-grid"><div><ol><li>在油猴中安装以下两个脚本,并停用原导入、排版脚本。</li><li>重新加载原目录中的换号插件,然后刷新网站。</li><li>在网站左下角展开连接设置,填入本地端口和连接码。</li></ol><div class="actions"><a class="button secondary" href="/browser/local-bridge.user.js">安装本地连接脚本</a><a class="button secondary" href="/browser/auto-news.user.js">安装导入排版脚本</a></div></div><div><label>本地端口<input id="port" readonly></label><button id="pair" class="secondary">显示本次连接码</button><input id="pair-code" type="password" readonly hidden aria-label="本次连接码"><small class="muted">服务重启后需要更新连接码。仅复制到你安装的本地连接脚本。</small><p id="clients" class="muted">没有在线执行标签</p></div></div></section>
|
||||
<details class="card"><summary>下载日志</summary><pre id="logs">暂无日志</pre></details><footer>新闻工作台 · 本机运行 · v0.2.0 / 终止后不会自动恢复,已发出的请求可能仍已完成。</footer>
|
||||
</main><script src="/app.js"></script>
|
||||
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:#203b38;background:#f3f6f4;font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}aside{position:fixed;inset:0 auto 0 0;width:220px;background:#113e37;color:#f4fbf7;padding:32px 22px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;font-size:20px;font-weight:650;letter-spacing:.5px}.mark{display:grid;place-items:center;background:#d8efb9;color:#17483e;width:42px;height:42px;border-radius:13px}.brand small{display:block;font-size:11px;font-weight:400;color:#b3cdc5;margin-top:5px}nav{display:grid;gap:9px;margin-top:54px}nav a{color:#bad1c9;text-decoration:none;padding:12px 16px;border-radius:8px}nav a:hover,nav .selected{background:#28534a;color:#fff}.aside-note{margin-top:auto;color:#bad1c9;font-size:12px;line-height:1.9}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#a3db80;margin-right:7px}.dot.gray{background:#a1ada7}main{max-width:1660px;margin-left:220px;padding:42px 40px}header{display:flex;align-items:center;justify-content:space-between;gap:20px}.eyebrow{font-size:10px;letter-spacing:2px;color:#6b8980;font-weight:700}h1{font-size:28px;letter-spacing:-.6px;margin:12px 0}header p{color:#73857e;margin:0;font-size:13px}button,.button,.file-button{font:inherit;font-weight:550;border:1px solid #1c6554;border-radius:7px;background:#1c6554;color:white;padding:10px 15px;cursor:pointer;white-space:nowrap;text-decoration:none;display:inline-block}button:hover,.button:hover{filter:brightness(.94)}button:disabled{opacity:.45;cursor:wait}.secondary,.file-button{background:white;color:#36574d;border-color:#d4dfd7}.danger{background:#fff8f6;border-color:#e7c5bb;color:#a34d37}.flow{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:34px;background:white;border:1px solid #e2e9e2;padding:22px 25px;border-radius:12px;font-size:12px}.flow span{background:#edf3ef;color:#5f7d70;padding:5px 7px;border-radius:5px;font-size:10px;margin-right:8px}.flow i{color:#b1c0b6;font-style:normal}.flow .highlight{font-weight:650;color:#2e6947}.flow .highlight span{background:#e3efcc;color:#476635}.pending-note{background:#f8f2df;border:1px solid #eae0bd;color:#7b6938;padding:12px 16px;border-radius:8px;margin:16px 0;font-size:12px;line-height:1.8}.pending-note b{margin-right:10px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:22px 0}.metrics article{padding:20px 23px;background:white;border:1px solid #e0e8e0;border-radius:10px}.metrics small{font-size:12px;color:#6b7f73}.metrics strong{display:block;font-size:30px;font-weight:550;margin:12px 0 8px}.metrics span{font-size:11px;color:#8a988f}.workspace-grid{display:grid;grid-template-columns:1.1fr 1fr;gap:20px}.card{background:white;border:1px solid #e0e8e0;border-radius:12px;padding:24px;margin-bottom:20px;min-width:0}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:20px}h2{font-size:17px;margin:6px 0 0;font-weight:600}.badge{font-size:11px;border-radius:20px;background:#f0f5ef;color:#6c826d;padding:5px 10px}.row{display:flex;gap:12px;align-items:flex-end}.grow{flex:1;min-width:0}label{display:block;font-size:12px;color:#61756b;margin-bottom:14px}input,select{display:block;width:100%;font:inherit;color:#304a3f;padding:10px 12px;margin-top:7px;border:1px solid #dbe4dd;border-radius:6px;background:#fcfdfb;outline:none}input:focus,select:focus{border-color:#659981;box-shadow:0 0 0 3px #6599811a}input::placeholder{color:#a4afa8}.row button{margin-bottom:14px}.check{display:flex;align-items:flex-start;gap:8px;line-height:1.7}.check input{width:15px;height:15px;margin:2px 0 0;accent-color:#276b54}.muted{font-size:12px;line-height:1.8;color:#8b988e}.progress{margin:12px 0 20px}progress{appearance:none;width:100%;height:5px;border:0;border-radius:4px;display:block;background:#edf2ec}progress::-webkit-progress-bar{background:#edf2ec}progress::-webkit-progress-value{background:#76a77f;border-radius:4px}.progress small{display:block;color:#82917f;margin-top:8px;font-size:11px}summary{cursor:pointer;color:#617d6d;font-size:12px}details label{margin-top:12px}.runbar{display:flex;align-items:center;justify-content:space-between;border-radius:10px;background:#e5eee5;padding:19px 23px;margin-bottom:22px;gap:16px}.runbar b{font-weight:550}.runbar p{margin:7px 0 0 15px;font-size:12px;color:#758b76}.actions{display:flex;gap:10px;flex-wrap:wrap}table{border-collapse:collapse;width:100%;text-align:left;font-size:12px}th{color:#8a998f;font-weight:500;background:#f7f9f5;padding:12px}td{padding:16px 12px;border-bottom:1px solid #eef1eb;vertical-align:top;line-height:1.8}td:first-child{max-width:350px}td small{display:block;color:#91a093;font-size:11px}td button{font-size:11px;padding:4px 8px}td a{color:#1c6554}.table-wrap{overflow-x:auto}.empty{padding:44px 16px;text-align:center;color:#81927f}.empty span{display:block;foLine truncated
|
||||
:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:#203b38;background:#f3f6f4;font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}aside{position:fixed;inset:0 auto 0 0;width:220px;background:#113e37;color:#f4fbf7;padding:32px 22px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;font-size:20px;font-weight:650;letter-spacing:.5px}.mark{display:grid;place-items:center;background:#d8efb9;color:#17483e;width:42px;height:42px;border-radius:13px}.brand small{display:block;font-size:11px;font-weight:400;color:#b3cdc5;margin-top:5px}nav{display:grid;gap:9px;margin-top:54px}nav a{color:#bad1c9;text-decoration:none;padding:12px 16px;border-radius:8px}nav a:hover,nav .selected{background:#28534a;color:#fff}.aside-note{margin-top:auto;color:#bad1c9;font-size:12px;line-height:1.9}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#a3db80;margin-right:7px}.dot.gray{background:#a1ada7}main{max-width:1660px;margin-left:220px;padding:42px 40px}header{display:flex;align-items:center;justify-content:space-between;gap:20px}.eyebrow{font-size:10px;letter-spacing:2px;color:#6b8980;font-weight:700}h1{font-size:28px;letter-spacing:-.6px;margin:12px 0}header p{color:#73857e;margin:0;font-size:13px}button,.button,.file-button{font:inherit;font-weight:550;border:1px solid #1c6554;border-radius:7px;background:#1c6554;color:white;padding:10px 15px;cursor:pointer;white-space:nowrap;text-decoration:none;display:inline-block}button:hover,.button:hover{filter:brightness(.94)}button:disabled{opacity:.45;cursor:wait}.secondary,.file-button{background:white;color:#36574d;border-color:#d4dfd7}.danger{background:#fff8f6;border-color:#e7c5bb;color:#a34d37}.flow{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:34px;background:white;border:1px solid #e2e9e2;padding:22px 25px;border-radius:12px;font-size:12px}.flow span{background:#edf3ef;color:#5f7d70;padding:5px 7px;border-radius:5px;font-size:10px;margin-right:8px}.flow i{color:#b1c0b6;font-style:normal}.flow .highlight{font-weight:650;color:#2e6947}.flow .highlight span{background:#e3efcc;color:#476635}.pending-note{background:#f8f2df;border:1px solid #eae0bd;color:#7b6938;padding:12px 16px;border-radius:8px;margin:16px 0;font-size:12px;line-height:1.8}.pending-note b{margin-right:10px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:22px 0}.metrics article{padding:20px 23px;background:white;border:1px solid #e0e8e0;border-radius:10px}.metrics small{font-size:12px;color:#6b7f73}.metrics strong{display:block;font-size:30px;font-weight:550;margin:12px 0 8px}.metrics span{font-size:11px;color:#8a988f}.workspace-grid{display:grid;grid-template-columns:1.1fr 1fr;gap:20px}.card{background:white;border:1px solid #e0e8e0;border-radius:12px;padding:24px;margin-bottom:20px;min-width:0}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:20px}h2{font-size:17px;margin:6px 0 0;font-weight:600}.badge{font-size:11px;border-radius:20px;background:#f0f5ef;color:#6c826d;padding:5px 10px}.row{display:flex;gap:12px;align-items:flex-end}.grow{flex:1;min-width:0}label{display:block;font-size:12px;color:#61756b;margin-bottom:14px}input,select{display:block;width:100%;font:inherit;color:#304a3f;padding:10px 12px;margin-top:7px;border:1px solid #dbe4dd;border-radius:6px;background:#fcfdfb;outline:none}input:focus,select:focus{border-color:#659981;box-shadow:0 0 0 3px #6599811a}input::placeholder{color:#a4afa8}.row button{margin-bottom:14px}.check{display:flex;align-items:flex-start;gap:8px;line-height:1.7}.check input{width:15px;height:15px;margin:2px 0 0;accent-color:#276b54}.muted{font-size:12px;line-height:1.8;color:#8b988e}.progress{margin:12px 0 20px}progress{appearance:none;width:100%;height:5px;border:0;border-radius:4px;display:block;background:#edf2ec}progress::-webkit-progress-bar{background:#edf2ec}progress::-webkit-progress-value{background:#76a77f;border-radius:4px}.progress small{display:block;color:#82917f;margin-top:8px;font-size:11px}summary{cursor:pointer;color:#617d6d;font-size:12px}details label{margin-top:12px}.runbar{display:flex;align-items:center;justify-content:space-between;border-radius:10px;background:#e5eee5;padding:19px 23px;margin-bottom:22px;gap:16px}.runbar b{font-weight:550}.runbar p{margin:7px 0 0 15px;font-size:12px;color:#758b76}.actions{display:flex;gap:10px;flex-wrap:wrap}table{border-collapse:collapse;width:100%;text-align:left;font-size:12px}th{color:#8a998f;font-weight:500;background:#f7f9f5;padding:12px}td{padding:16px 12px;border-bottom:1px solid #eef1eb;vertical-align:top;line-height:1.8}td:nth-child(2){max-width:350px}td small{display:block;color:#91a093;font-size:11px}td button{font-size:11px;padding:4px 8px}td a{color:#1c6554}.table-wrap{overflow-x:auto}.empty{padding:44px 16px;text-align:center;color:#81927f}.empty span{display:block;fLine truncated
|
||||
|
||||
.queue-toolbar .actions{align-items:center}.queue-toolbar #selected-count{font-size:12px;color:#61756b}.queue-toolbar .muted{margin:10px 0 16px}.selection-cell{width:42px}.selection-cell input,.job-select{width:16px;height:16px;margin:5px 0;accent-color:#276b54;cursor:pointer}.job-select:disabled{cursor:default;opacity:.45}.queue-toolbar button:disabled{cursor:default}
|
||||
@@ -366,6 +366,25 @@ class Store:
|
||||
self._persist(db)
|
||||
return {'ok': True}
|
||||
|
||||
def remove(self, data):
|
||||
"""Remove queue rows only; retain files, acceptance ledger and post bindings."""
|
||||
ids = data.get('ids')
|
||||
if not isinstance(ids, list) or not ids or not all(isinstance(id, str) and id for id in ids):
|
||||
raise ValueError('请选择要移除的文章')
|
||||
ids = list(dict.fromkeys(ids))
|
||||
with self.lock, self.db() as db:
|
||||
if self.run['running']:
|
||||
raise ValueError('请先暂停流程,再移除文章')
|
||||
# Validate the entire selection before deleting anything.
|
||||
for id in ids:
|
||||
job = self._job(db, id)
|
||||
if id == self.run['active']:
|
||||
raise ValueError('当前任务不能直接移除,请先通过“人工核对 / 处理”核对并移出')
|
||||
if job['state'] in SUBMITTED or (job.get('submitted_at') and job['state'] not in ACCEPTED):
|
||||
raise ValueError('文章提交结果尚未核对,请先核对并处理后再移除')
|
||||
db.executemany('DELETE FROM jobs WHERE id=?', [(id,) for id in ids])
|
||||
return {'ok': True, 'removed': len(ids)}
|
||||
|
||||
def takeover(self, data):
|
||||
with self.lock, self.db() as db:
|
||||
if self.run['running']:
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
- `test_download_core.py`:复制的下载器原有离线测试,针对合并项目中的下载器副本运行。
|
||||
- `importer.test.cjs`:导入器回归与自动模式的停止/冲突保护。
|
||||
- `browser.test.cjs`:本地页面、移动布局与模拟编辑器;从不请求真实论坛。
|
||||
- `queue-ui.test.cjs`:独立模拟队列的选择、全选、轮询保留选择、取消/失败、批量移除、运行与当前任务保护、空列表及手机布局。Node 环境可加载 Playwright 后运行 `node test/queue-ui.test.cjs`;不启动本地服务、不访问真实队列。
|
||||
- `artifacts/`:本轮页面截图,属于生成产物。
|
||||
|
||||
原始受理去重记录继续保存在 `../发布去重记录.json`,服务启动时自动导入;没有作为测试残留移走或删除。
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
// Isolated UI regression: all requests use fixture data, never the user's queue.
|
||||
const {chromium}=require('playwright');
|
||||
const fs=require('node:fs');
|
||||
const path=require('node:path');
|
||||
const assert=require('node:assert/strict');
|
||||
const ROOT=path.resolve(__dirname,'..');
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{
|
||||
const page=await browser.newPage({viewport:{width:1440,height:1000}}),errors=[],requests=[];
|
||||
page.on('pageerror',error=>errors.push(error.message));
|
||||
const data={run:{running:false,active:'active',config:{},reason:'已暂停',batch:null},clients:[],
|
||||
jobs:['a','b','active'].map(id=>({id,title:'测试文章 '+id,original_title:'测试文章 '+id,image_count:0,source:'测试来源',state:id==='active'?'awaiting_user_review':'queued'})),
|
||||
download:{status:'idle',saved:0,target:0,skipped:0,failed:0,logs:[]}};
|
||||
let failRemoval=false;
|
||||
await page.route('**/*',async route=>{
|
||||
const url=new URL(route.request().url());
|
||||
if(url.hostname!=='queue.test')return route.abort();
|
||||
if(url.pathname==='/api/session')return route.fulfill({json:{token:'fixture'}});
|
||||
if(url.pathname==='/api/state')return route.fulfill({json:data});
|
||||
if(url.pathname==='/api/remove'){
|
||||
const body=route.request().postDataJSON();requests.push(body);
|
||||
if(failRemoval)return route.fulfill({status:400,json:{error:'模拟移除失败'}});
|
||||
data.jobs=data.jobs.filter(job=>!body.ids.includes(job.id));
|
||||
return route.fulfill({json:{ok:true,removed:body.ids.length}});
|
||||
}
|
||||
const files={'/':'index.html','/app.js':'app.js','/style.css':'style.css'};
|
||||
if(files[url.pathname])return route.fulfill({path:path.join(ROOT,'static',files[url.pathname])});
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto('http://queue.test/');
|
||||
await page.waitForFunction(()=>document.querySelectorAll('.job-select').length===3);
|
||||
const box=id=>page.locator(`.job-select[value="${id}"]`);
|
||||
assert.equal(await page.locator('#remove-selected').isDisabled(),true);
|
||||
assert.equal(await box('active').isDisabled(),true);
|
||||
await box('a').check();
|
||||
assert.equal(await page.locator('#select-all').evaluate(el=>el.indeterminate),true);
|
||||
await page.evaluate(()=>refresh());
|
||||
assert.equal(await box('a').isChecked(),true,'polling preserves selection');
|
||||
page.once('dialog',dialog=>dialog.dismiss());
|
||||
await page.locator('#remove-selected').click();
|
||||
assert.equal(requests.length,0,'cancel does not delete');
|
||||
failRemoval=true;
|
||||
page.once('dialog',dialog=>dialog.accept());
|
||||
await page.locator('#remove-selected').click();
|
||||
await page.getByText('模拟移除失败',{exact:true}).waitFor();
|
||||
assert.equal(await box('a').isChecked(),true,'failure preserves selection');
|
||||
failRemoval=false;
|
||||
page.once('dialog',dialog=>dialog.accept());
|
||||
await page.locator('#remove-selected').click();
|
||||
await page.waitForFunction(()=>document.querySelectorAll('.job-select').length===2);
|
||||
assert.deepEqual(requests.at(-1),{ids:['a']});
|
||||
assert.equal(await box('b').count(),1,'unselected article remains');
|
||||
await page.locator('#select-all').check();
|
||||
assert.equal(await box('b').isChecked(),true);
|
||||
assert.equal(await box('active').isChecked(),false);
|
||||
data.run.running=true;await page.evaluate(()=>refresh());
|
||||
assert.equal(await page.locator('#remove-selected').isDisabled(),true,'running flow blocks removal');
|
||||
data.run.running=false;data.run.active=null;await page.evaluate(()=>refresh());
|
||||
await page.locator('#select-all').check();
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'mobile has no horizontal overflow');
|
||||
page.once('dialog',dialog=>dialog.accept());
|
||||
await page.locator('#remove-selected').click();
|
||||
await page.locator('#empty').waitFor({state:'visible'});
|
||||
assert.equal(await page.locator('#total').textContent(),'0');
|
||||
assert.equal(await page.locator('#select-all').isDisabled(),true);
|
||||
assert.equal(await page.locator('#remove-selected').isDisabled(),true);
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS queue UI: selection, polling, cancellation, failure, selected deletion, protected task, running state, select all, empty state, mobile layout');
|
||||
}finally{await browser.close();}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
@@ -84,6 +84,19 @@ class ControllerTests(unittest.TestCase):
|
||||
self.assertIsNone(store.run['active'])
|
||||
|
||||
|
||||
def test_ui_remove_selected_articles(self):
|
||||
store = self.controller.store
|
||||
store.enqueue(ROOT / 'test/fixtures/article')
|
||||
ids = [job['id'] for job in store.state()['jobs']]
|
||||
with self.assertRaises(HTTPError):
|
||||
self.request('/api/remove', {'ids': ids}, token=self.controller.bridge_token)
|
||||
with self.assertRaises(HTTPError):
|
||||
self.request('/bridge/remove', {'ids': ids}, token=self.controller.bridge_token)
|
||||
code, body = self.request('/api/remove', {'ids': ids}, token=self.controller.ui_token)
|
||||
self.assertEqual(code, 200)
|
||||
self.assertEqual(json.loads(body)['removed'], 1)
|
||||
self.assertEqual(store.state()['jobs'], [])
|
||||
|
||||
def test_ui_historical_result_reconciliation(self):
|
||||
store = self.controller.store
|
||||
store.enqueue(ROOT / 'test/fixtures/article')
|
||||
|
||||
@@ -278,6 +278,56 @@ class QueueTests(unittest.TestCase):
|
||||
self.assertTrue(saved['identity_needs_reconciliation'])
|
||||
self.assertFalse(restarted.run['running'])
|
||||
|
||||
def test_remove_selected_preserves_files_and_survives_restart(self):
|
||||
self.store.enqueue(self.article)
|
||||
other = self.root / 'other'
|
||||
shutil.copytree(self.article, other)
|
||||
(other / 'a.md').write_text(self.md.read_text().replace('正文内容', '另一篇内容'))
|
||||
self.store.enqueue(other)
|
||||
jobs = self.store.state()['jobs']
|
||||
result = self.store.remove({'ids': [jobs[0]['id'], jobs[0]['id']]})
|
||||
self.assertEqual(result['removed'], 1)
|
||||
self.assertTrue(self.md.is_file())
|
||||
restarted = Store(self.root / 'queue.sqlite3')
|
||||
self.assertEqual([j['id'] for j in restarted.state()['jobs']], [jobs[1]['id']])
|
||||
self.assertEqual(restarted.enqueue(self.article), '已入队')
|
||||
self.assertEqual(restarted.remove({'ids': [j['id'] for j in restarted.state()['jobs']]})['removed'], 2)
|
||||
self.assertEqual(restarted.state()['jobs'], [])
|
||||
|
||||
def test_remove_is_atomic_and_protects_current_task(self):
|
||||
job = self.begin()
|
||||
with self.assertRaisesRegex(ValueError, '暂停'):
|
||||
self.store.remove({'ids': [job['id']]})
|
||||
self.store.control('pause')
|
||||
with self.assertRaisesRegex(ValueError, '当前任务'):
|
||||
self.store.remove({'ids': [job['id']]})
|
||||
self.store.resolve(dict(id=job['id'], confirmed_not_accepted=True, retry=False))
|
||||
for ids in ([], 'invalid', [None], [job['id'], 'missing']):
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.remove({'ids': ids})
|
||||
self.assertEqual(len(self.store.state()['jobs']), 1)
|
||||
self.store.remove({'ids': [job['id']]})
|
||||
self.assertEqual(self.store.state()['jobs'], [])
|
||||
|
||||
def test_remove_accepted_retains_deduplication_and_counts(self):
|
||||
job = self.begin(); self.submit(job); self.accept(job)
|
||||
self.store.control('pause')
|
||||
before = self.store.state()['history_count']
|
||||
self.store.remove({'ids': [job['id']]})
|
||||
self.assertEqual(self.store.state()['jobs'], [])
|
||||
self.assertEqual(self.store.state()['history_count'], before)
|
||||
self.assertEqual(self.store.run['batch']['count'], 1)
|
||||
self.assertIn('已受理', self.store.enqueue(self.article))
|
||||
with self.store.db() as db:
|
||||
self.assertEqual(db.execute('SELECT count(*) FROM posts').fetchone()[0], 1)
|
||||
|
||||
def test_remove_unresolved_submission_is_blocked_even_without_active_pointer(self):
|
||||
job = self.begin(); self.submit(job); self.store.control('pause')
|
||||
self.store.run['active'] = None
|
||||
with self.assertRaisesRegex(ValueError, '尚未核对'):
|
||||
self.store.remove({'ids': [job['id']]})
|
||||
self.assertEqual(len(self.store.state()['jobs']), 1)
|
||||
|
||||
def test_bad_result_does_not_increment(self):
|
||||
job = self.begin();self.submit(job)
|
||||
with self.assertRaises(ValueError):
|
||||
|
||||
Reference in new issue
Block a user