diff --git a/README.md b/README.md
index a01dfeb..3148ec8 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,7 @@ uv run --no-project --python 3.12 --with-requirements requirements.txt python ap
## 终止、恢复与冲突
+- 队列支持勾选单篇或全选可移除文章,然后点击“移除所选文章”。请先暂停流程;移除只删除队列记录,原始文章、图片、已受理去重记录和计数保留。当前任务或提交结果未核对的文章须先核对并处理;未受理文章移除后可通过扫描目录重新入队。
- 网站左下角悬浮窗提供“跳过本文章”:将当前未提交文章标记为“已跳过”,保留队列记录、不计发表篇数,并打开新的发帖页继续导入排版下一篇。导入中会先取消并等待回滚;已上传附件可能仍保留在网站。最后一篇跳过后流程结束。已点击发表或结果不明的文章不能直接跳过,须先人工核对。已跳过文章不会自动重新入队,需要重试时可在暂停后通过队列“人工核对 / 处理”重新排队。
- 网站左下角常驻“终止流程”,点击后立即取消本标签可取消的导入,并通知其他安装脚本的标签和换号插件;持久状态禁止继续领取任务。
- 控制台“终止流程”还会停止下载,浏览器在下一次连接检查时停止(正常轮询约 2 秒)。网络断开时无法承诺固定延迟;网站按钮可立即停止本标签。已到达网站的图片、登录或发帖请求无法撤回。
diff --git a/app.py b/app.py
index 82b073e..fabfbf1 100644
--- a/app.py
+++ b/app.py
@@ -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):
diff --git a/static/app.js b/static/app.js
index 74923f8..4394c6a 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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('请重新获取频道',''));};
diff --git a/static/index.html b/static/index.html
index 9c6d6bb..9f07535 100644
--- a/static/index.html
+++ b/static/index.html
@@ -19,7 +19,7 @@
每个账号只抽取一次目标篇数;只有明确受理的文章才计数。历史演示去重记录会自动读取。
-+从第一篇文章开始下载新闻,或检查已有文章目录。准备好的文章会出现在这里。
++从第一篇文章开始下载新闻,或检查已有文章目录。准备好的文章会出现在这里。
- 在油猴中安装以下两个脚本,并停用原导入、排版脚本。
- 重新加载原目录中的换号插件,然后刷新网站。
- 在网站左下角展开连接设置,填入本地端口和连接码。
下载日志
暂无日志
diff --git a/static/style.css b/static/style.css
index 7d1e2e2..fb04c4f 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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;font-size:30px;margin-bottom:14px;color:#a5b69e}.empty b{font-size:14px;font-weight:500}.empty p{font-size:12px;color:#a0ad9c}.connection-grid{display:grid;grid-template-columns:1.5fr 1fr;gap:36px}.connection-grid ol{padding-left:18px;font-size:12px;color:#738673;line-height:2.3;margin:0 0 16px}.connection-grid small{display:block;margin-top:8px}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:220px;overflow:auto;font:12px/1.8 ui-monospace,monospace;color:#73816f}.notice{padding:14px 18px;border:1px solid #dcccb0;background:#fff9e9;color:#876c40;border-radius:8px;margin-top:20px;line-height:1.7}footer{font-size:11px;color:#9ba697;text-align:center;margin-top:30px}a:focus-visible,button:focus-visible,summary:focus-visible{outline:3px solid #91ba80;outline-offset:3px}[hidden]{display:none!important}@media(max-width:1150px){aside{width:180px;padding:28px 15px}main{margin-left:180px;padding:30px 24px}.brand{font-size:17px}.flow{padding:18px 15px;flex-wrap:wrap}.metrics{gap:10px}.metrics article{padding:18px 15px}.row{flex-wrap:wrap}.row .grow{min-width:100px}h1{font-size:24px}}@media(max-width:800px){aside{position:relative;width:100%;padding:16px 22px}.brand small,.aside-note,nav{display:none}main{margin-left:0;padding:24px 16px}.workspace-grid,.connection-grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}header{align-items:flex-start}h1{font-size:22px}.flow{line-height:2.5}.flow i{display:none}.runbar{flex-wrap:wrap}.card{padding:20px}header .danger{font-size:12px;padding:9px}.connection-grid{gap:20px}}
+: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;font-size:30px;margin-bottom:14px;color:#a5b69e}.empty b{font-size:14px;font-weight:500}.empty p{font-size:12px;color:#a0ad9c}.connection-grid{display:grid;grid-template-columns:1.5fr 1fr;gap:36px}.connection-grid ol{padding-left:18px;font-size:12px;color:#738673;line-height:2.3;margin:0 0 16px}.connection-grid small{display:block;margin-top:8px}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:220px;overflow:auto;font:12px/1.8 ui-monospace,monospace;color:#73816f}.notice{padding:14px 18px;border:1px solid #dcccb0;background:#fff9e9;color:#876c40;border-radius:8px;margin-top:20px;line-height:1.7}footer{font-size:11px;color:#9ba697;text-align:center;margin-top:30px}a:focus-visible,button:focus-visible,summary:focus-visible{outline:3px solid #91ba80;outline-offset:3px}[hidden]{display:none!important}@media(max-width:1150px){aside{width:180px;padding:28px 15px}main{margin-left:180px;padding:30px 24px}.brand{font-size:17px}.flow{padding:18px 15px;flex-wrap:wrap}.metrics{gap:10px}.metrics article{padding:18px 15px}.row{flex-wrap:wrap}.row .grow{min-width:100px}h1{font-size:24px}}@media(max-width:800px){aside{position:relative;width:100%;padding:16px 22px}.brand small,.aside-note,nav{display:none}main{margin-left:0;padding:24px 16px}.workspace-grid,.connection-grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}header{align-items:flex-start}h1{font-size:22px}.flow{line-height:2.5}.flow i{display:none}.runbar{flex-wrap:wrap}.card{padding:20px}header .danger{font-size:12px;padding:9px}.connection-grid{gap:20px}}
+
+.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}
diff --git a/store.py b/store.py
index fa9b634..44663c3 100644
--- a/store.py
+++ b/store.py
@@ -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']:
diff --git a/test/README.md b/test/README.md
index d0e8b34..3a11a14 100644
--- a/test/README.md
+++ b/test/README.md
@@ -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`,服务启动时自动导入;没有作为测试残留移走或删除。
diff --git a/test/queue-ui.test.cjs b/test/queue-ui.test.cjs
new file mode 100644
index 0000000..995d370
--- /dev/null
+++ b/test/queue-ui.test.cjs
@@ -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;});
diff --git a/test/test_controller.py b/test/test_controller.py
index a0db856..851a550 100644
--- a/test/test_controller.py
+++ b/test/test_controller.py
@@ -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')
diff --git a/test/test_queue.py b/test/test_queue.py
index 1e6f64e..908172d 100644
--- a/test/test_queue.py
+++ b/test/test_queue.py
@@ -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):