Commit 70ff426e696e91d80958029eb0ff75009d08eca2
1 parent
acdcc2b3
feat(wechat-auto-reply): 白名单改选人弹窗(可搜索多选),解决不知道 wxid
/whitelist 添加改为弹窗:默认列会话过的联系人/群(带昵称),搜索框查全量通讯录, 复选框多选批量入库。新增 /api/contacts(会话列表) + /api/contacts/search(全量搜), /api/whitelist POST 支持批量 items。白名单存储/显示昵称+wxid/群id+类型。真机验证 33 条默认列表、搜索、批量加(added:2)全通。viewer 改动全文见 wechat-viewer-changes.md。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
2 changed files
with
99 additions
and
37 deletions
wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html
| ... | ... | @@ -4,66 +4,121 @@ |
| 4 | 4 | <style> |
| 5 | 5 | body{overflow-y:auto!important;height:auto!important;} |
| 6 | 6 | .wl-container{max-width:760px;margin:0 auto;padding:20px;} |
| 7 | -.wl-header h1{font-size:20px;color:#2c3e50;margin-bottom:6px;} | |
| 8 | -.wl-hint{font-size:12px;color:#888;margin-bottom:16px;line-height:1.5;} | |
| 9 | -.add-box{background:white;border-radius:8px;padding:14px 16px;margin-bottom:18px;box-shadow:0 1px 2px rgba(0,0,0,0.05);display:flex;gap:8px;flex-wrap:wrap;align-items:center;} | |
| 10 | -.add-box input{border:1px solid #cfd8e3;border-radius:6px;padding:8px 10px;font-size:13px;} | |
| 11 | -.add-box input.wxid{flex:1;min-width:180px;} | |
| 12 | -.add-box input.name{width:140px;} | |
| 13 | -.btn-add{background:#27ae60;color:white;border:none;border-radius:6px;padding:8px 16px;font-size:13px;cursor:pointer;} | |
| 7 | +.wl-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;} | |
| 8 | +.wl-header h1{font-size:20px;color:#2c3e50;} | |
| 9 | +.btn-add{background:#27ae60;color:white;border:none;border-radius:6px;padding:8px 16px;font-size:14px;cursor:pointer;} | |
| 10 | +.wl-hint{font-size:12px;color:#888;margin:8px 0 16px;line-height:1.5;} | |
| 14 | 11 | .wl-card{background:white;border-radius:8px;padding:12px 16px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,0.05);display:flex;align-items:center;gap:10px;} |
| 15 | -.kb{font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600;} | |
| 12 | +.kb{font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600;flex-shrink:0;} | |
| 16 | 13 | .kb.contact{background:#d6eaf8;color:#1a5276}.kb.group{background:#fdebd0;color:#b9770e} |
| 17 | 14 | .wl-name{font-weight:600;font-size:14px;} |
| 18 | -.wl-wxid{font-size:11px;color:#999;} | |
| 19 | -.btn-del{margin-left:auto;background:#e74c3c;color:white;border:none;border-radius:6px;padding:6px 12px;font-size:12px;cursor:pointer;} | |
| 15 | +.wl-wxid{font-size:11px;color:#999;word-break:break-all;} | |
| 16 | +.btn-del{margin-left:auto;background:#e74c3c;color:white;border:none;border-radius:6px;padding:6px 12px;font-size:12px;cursor:pointer;flex-shrink:0;} | |
| 20 | 17 | .empty-state{text-align:center;padding:50px 20px;color:#999;} |
| 18 | +/* modal */ | |
| 19 | +.modal-mask{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:1000;} | |
| 20 | +.modal-mask.show{display:flex;align-items:center;justify-content:center;} | |
| 21 | +.modal{background:white;border-radius:12px;width:92%;max-width:560px;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;} | |
| 22 | +.modal-hd{padding:14px 18px;border-bottom:1px solid #eee;display:flex;align-items:center;gap:10px;} | |
| 23 | +.modal-hd h3{font-size:16px;flex:1;} | |
| 24 | +.modal-hd .x{background:none;border:none;font-size:22px;color:#999;cursor:pointer;} | |
| 25 | +.modal-search{padding:10px 18px;border-bottom:1px solid #f0f0f0;} | |
| 26 | +.modal-search input{width:100%;box-sizing:border-box;border:1px solid #cfd8e3;border-radius:8px;padding:9px 12px;font-size:14px;} | |
| 27 | +.modal-body{flex:1;overflow-y:auto;padding:6px 0;} | |
| 28 | +.pick-row{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;} | |
| 29 | +.pick-row:hover{background:#f5f8fb;} | |
| 30 | +.pick-row input{width:17px;height:17px;flex-shrink:0;} | |
| 31 | +.pick-row .nm{font-size:14px;flex:1;} | |
| 32 | +.pick-row .kb2{font-size:9px;padding:1px 6px;border-radius:8px;flex-shrink:0;} | |
| 33 | +.kb2.contact{background:#d6eaf8;color:#1a5276}.kb2.group{background:#fdebd0;color:#b9770e} | |
| 34 | +.modal-ft{padding:12px 18px;border-top:1px solid #eee;display:flex;align-items:center;gap:10px;} | |
| 35 | +.modal-ft .cnt{font-size:13px;color:#666;flex:1;} | |
| 36 | +.btn-confirm{background:#27ae60;color:white;border:none;border-radius:8px;padding:9px 20px;font-size:14px;cursor:pointer;} | |
| 37 | +.btn-confirm:disabled{opacity:.5;cursor:not-allowed;} | |
| 38 | +.loading{text-align:center;padding:30px;color:#999;} | |
| 21 | 39 | </style> |
| 22 | 40 | <div class="wl-container"> |
| 23 | -<div class="wl-header"><h1>⭐ 回复白名单</h1></div> | |
| 24 | -<div class="wl-hint">在白名单里的联系人 / 群,其消息可被<b>自动回复</b>(仍受敏感词等安全规则拦截,命中会转人工审批)。不在名单里的一律进「自动回复审批」等你手动发。<br>群 id 以 <code>@chatroom</code> 结尾会自动识别为群。</div> | |
| 25 | -<div class="add-box"> | |
| 26 | -<input class="wxid" id="wxid" placeholder="wxid 或 群id(xxx@chatroom)"> | |
| 27 | -<input class="name" id="name" placeholder="备注名(可选)"> | |
| 28 | -<button class="btn-add" onclick="add()">➕ 添加</button> | |
| 29 | -</div> | |
| 41 | +<div class="wl-header"><h1>⭐ 回复白名单</h1><button class="btn-add" onclick="openPicker()">➕ 添加</button></div> | |
| 42 | +<div class="wl-hint">白名单里的联系人 / 群,消息可被<b>自动回复</b>(仍受敏感词等安全规则拦截,命中转人工)。不在名单里的一律进「自动回复审批」等你手动发。</div> | |
| 30 | 43 | <div id="list"></div> |
| 31 | 44 | </div> |
| 45 | + | |
| 46 | +<!-- 选人弹窗 --> | |
| 47 | +<div class="modal-mask" id="mask"> | |
| 48 | + <div class="modal"> | |
| 49 | + <div class="modal-hd"><h3>选择联系人 / 群</h3><button class="x" onclick="closePicker()">×</button></div> | |
| 50 | + <div class="modal-search"><input id="q" placeholder="搜索昵称 / 备注 / 群名..." oninput="onSearch()"></div> | |
| 51 | + <div class="modal-body" id="picklist"><div class="loading">加载中...</div></div> | |
| 52 | + <div class="modal-ft"><span class="cnt" id="cnt">已选 0</span> | |
| 53 | + <button class="btn-confirm" id="btnConfirm" onclick="confirmAdd()" disabled>添加所选</button></div> | |
| 54 | + </div> | |
| 55 | +</div> | |
| 56 | + | |
| 32 | 57 | <script> |
| 33 | 58 | function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} |
| 59 | +var selected={}; // wxid -> {wxid,name,kind} | |
| 60 | +var searchTimer=null; | |
| 61 | + | |
| 34 | 62 | function load(){ |
| 35 | 63 | var el=document.getElementById('list'); |
| 36 | 64 | fetch('/api/whitelist').then(function(r){return r.json()}).then(function(j){ |
| 37 | 65 | if(!j.ok){el.innerHTML='<div class="empty-state">加载失败</div>';return;} |
| 38 | 66 | if(!j.data.length){el.innerHTML='<div class="empty-state"><p>白名单为空 — 当前所有回复都会进人工审批</p></div>';return;} |
| 39 | 67 | var h=''; |
| 40 | - for(var i=0;i<j.data.length;i++){ | |
| 41 | - var t=j.data[i]; | |
| 42 | - h+='<div class="wl-card">'; | |
| 43 | - h+='<span class="kb '+t.kind+'">'+(t.kind==='group'?'群':'联系人')+'</span>'; | |
| 44 | - h+='<span class="wl-name">'+esc(t.name||'(未命名)')+'</span>'; | |
| 45 | - h+='<span class="wl-wxid">'+esc(t.wxid)+'</span>'; | |
| 46 | - h+='<button class="btn-del" onclick="del('+t.id+')">删除</button>'; | |
| 47 | - h+='</div>'; | |
| 68 | + for(var i=0;i<j.data.length;i++){var t=j.data[i]; | |
| 69 | + h+='<div class="wl-card"><span class="kb '+t.kind+'">'+(t.kind==='group'?'群':'联系人')+'</span>'; | |
| 70 | + h+='<div><div class="wl-name">'+esc(t.name||'(未命名)')+'</div><div class="wl-wxid">'+esc(t.wxid)+'</div></div>'; | |
| 71 | + h+='<button class="btn-del" onclick="del('+t.id+')">删除</button></div>'; | |
| 48 | 72 | } |
| 49 | 73 | el.innerHTML=h; |
| 50 | 74 | }); |
| 51 | 75 | } |
| 52 | -function add(){ | |
| 53 | - var wxid=document.getElementById('wxid').value.trim(); | |
| 54 | - var name=document.getElementById('name').value.trim(); | |
| 55 | - if(!wxid){alert('请输入 wxid 或群id');return;} | |
| 56 | - fetch('/api/whitelist',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({wxid:wxid,name:name})}) | |
| 57 | - .then(function(r){return r.json()}).then(function(j){ | |
| 58 | - if(j.ok){document.getElementById('wxid').value='';document.getElementById('name').value='';load();} | |
| 59 | - else{alert('失败: '+(j.error||''));} | |
| 60 | - }); | |
| 76 | +function del(id){fetch('/api/whitelist/'+id+'/delete',{method:'POST'}).then(function(r){return r.json()}).then(function(j){if(j.ok)load();else alert('失败');});} | |
| 77 | + | |
| 78 | +function openPicker(){ | |
| 79 | + selected={};updateCnt(); | |
| 80 | + document.getElementById('q').value=''; | |
| 81 | + document.getElementById('mask').classList.add('show'); | |
| 82 | + fetchList('/api/contacts'); | |
| 83 | +} | |
| 84 | +function closePicker(){document.getElementById('mask').classList.remove('show');} | |
| 85 | +function onSearch(){ | |
| 86 | + clearTimeout(searchTimer); | |
| 87 | + var q=document.getElementById('q').value.trim(); | |
| 88 | + searchTimer=setTimeout(function(){ | |
| 89 | + fetchList(q?('/api/contacts/search?q='+encodeURIComponent(q)):'/api/contacts'); | |
| 90 | + },250); | |
| 61 | 91 | } |
| 62 | -function del(id){ | |
| 63 | - fetch('/api/whitelist/'+id+'/delete',{method:'POST'}).then(function(r){return r.json()}).then(function(j){ | |
| 64 | - if(j.ok){load();}else{alert('失败: '+(j.error||''));} | |
| 92 | +function fetchList(url){ | |
| 93 | + var el=document.getElementById('picklist');el.innerHTML='<div class="loading">加载中...</div>'; | |
| 94 | + fetch(url).then(function(r){return r.json()}).then(function(j){ | |
| 95 | + if(!j.ok){el.innerHTML='<div class="loading">加载失败: '+esc(j.error)+'</div>';return;} | |
| 96 | + if(!j.data.length){el.innerHTML='<div class="loading">无结果</div>';return;} | |
| 97 | + var h=''; | |
| 98 | + for(var i=0;i<j.data.length;i++){var t=j.data[i]; | |
| 99 | + var ck=selected[t.wxid]?'checked':''; | |
| 100 | + h+='<label class="pick-row"><input type="checkbox" '+ck+ | |
| 101 | + ' onchange="toggle(this,'+esc(JSON.stringify(t)).replace(/"/g,'"')+')">'+ | |
| 102 | + '<span class="nm">'+esc(t.name)+'</span>'+ | |
| 103 | + '<span class="kb2 '+t.kind+'">'+(t.kind==='group'?'群':'联系人')+'</span></label>'; | |
| 104 | + } | |
| 105 | + el.innerHTML=h; | |
| 65 | 106 | }); |
| 66 | 107 | } |
| 108 | +function toggle(cb,t){if(cb.checked)selected[t.wxid]=t;else delete selected[t.wxid];updateCnt();} | |
| 109 | +function updateCnt(){ | |
| 110 | + var n=Object.keys(selected).length; | |
| 111 | + document.getElementById('cnt').textContent='已选 '+n; | |
| 112 | + document.getElementById('btnConfirm').disabled=(n===0); | |
| 113 | +} | |
| 114 | +function confirmAdd(){ | |
| 115 | + var items=Object.keys(selected).map(function(k){return selected[k];}); | |
| 116 | + if(!items.length)return; | |
| 117 | + fetch('/api/whitelist',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({items:items})}) | |
| 118 | + .then(function(r){return r.json()}).then(function(j){ | |
| 119 | + if(j.ok){closePicker();load();}else{alert('失败: '+(j.error||''));} | |
| 120 | + }); | |
| 121 | +} | |
| 67 | 122 | load(); |
| 68 | 123 | </script> |
| 69 | 124 | {% endblock %} | ... | ... |
wechat_plain/scripts/auto-reply/wechat-viewer-changes.md
| ... | ... | @@ -31,6 +31,13 @@ CREATE TABLE reply_queue ( -- 策略判定后落地 |
| 31 | 31 | | `GET /api/replies?status=` | 队列列表(pending / sent / auto_sent / cancelled / 全部) | |
| 32 | 32 | | `POST /api/reply/<id>/send` | 用(可编辑的)草稿发到会话 → `send_text_via_hook(talker,text)` → 标 sent/failed | |
| 33 | 33 | | `POST /api/reply/<id>/cancel` | 标 cancelled | |
| 34 | +| `GET /api/contacts` | 弹窗默认列表:会话过的联系人+群(排除公众号),去重带昵称,最近排序 | | |
| 35 | +| `GET /api/contacts/search?q=` | 按名字/wxid 搜全量 rcontact+群(`wechat_db.search_talker`) | | |
| 36 | +| `POST /api/whitelist`(升级) | 支持批量 `{items:[{wxid,name,kind}]}`(弹窗多选) | | |
| 37 | + | |
| 38 | +**白名单选人弹窗(2026-07-10 升级)**:`/whitelist` 页「添加」不再手填 wxid,改为弹窗: | |
| 39 | +默认列出你会话过的联系人/群(带昵称),顶部搜索框可查全量通讯录/群,复选框**多选**, | |
| 40 | +【添加所选】批量入库。白名单列表显示 昵称 + wxid/群id + 类型徽章。解决「不知道 wxid」的问题。 | |
| 34 | 41 | |
| 35 | 42 | 发送复用 `~/.hermes/event_bus/wechat_hook_send.py` 的 `send_text_via_hook`(LSPosed 内部 API 广播 `SEND_MSG_INTERNAL`)。 |
| 36 | 43 | ... | ... |