From 77012b93c6b0314e82e09bffa5c80c2c8c456f20 Mon Sep 17 00:00:00 2001 From: Longtail Date: Sat, 31 Oct 2026 17:55:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=89=8D=E7=AB=AF=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E5=89=8D=E7=9A=84=E5=BD=93=E5=89=8D=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + app.py | 2 ++ static/app.js | 42 +++++++++++++++++++++++- static/index.html | 2 +- static/style.css | 4 ++- store.py | 19 +++++++++++ test/README.md | 1 + test/queue-ui.test.cjs | 73 +++++++++++++++++++++++++++++++++++++++++ test/test_controller.py | 13 ++++++++ test/test_queue.py | 50 ++++++++++++++++++++++++++++ 10 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 test/queue-ui.test.cjs 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 @@

每个账号只抽取一次目标篇数;只有明确受理的文章才计数。历史演示去重记录会自动读取。

正在连接本地服务…

等待状态

-
ARTICLE QUEUE

文章队列

文章状态账号 / 版块结果与操作
+从第一篇文章开始

下载新闻,或检查已有文章目录。准备好的文章会出现在这里。

+
ARTICLE QUEUE

文章队列

已选 0 篇

移除仅删除队列记录,保留原始文章、图片和已受理去重记录。运行中请先暂停;当前任务或提交结果未核对的文章,请先在“人工核对 / 处理”中处理。

文章状态账号 / 版块结果与操作
+从第一篇文章开始

下载新闻,或检查已有文章目录。准备好的文章会出现在这里。

CONNECTION

连接你的浏览器

尚未连接
  1. 在油猴中安装以下两个脚本,并停用原导入、排版脚本。
  2. 重新加载原目录中的换号插件,然后刷新网站。
  3. 在网站左下角展开连接设置,填入本地端口和连接码。
服务重启后需要更新连接码。仅复制到你安装的本地连接脚本。

没有在线执行标签

下载日志
暂无日志
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):