Commit 70ff426e696e91d80958029eb0ff75009d08eca2

Authored by yibo
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>
wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html
@@ -4,66 +4,121 @@ @@ -4,66 +4,121 @@
4 <style> 4 <style>
5 body{overflow-y:auto!important;height:auto!important;} 5 body{overflow-y:auto!important;height:auto!important;}
6 .wl-container{max-width:760px;margin:0 auto;padding:20px;} 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 .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;} 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 .kb.contact{background:#d6eaf8;color:#1a5276}.kb.group{background:#fdebd0;color:#b9770e} 13 .kb.contact{background:#d6eaf8;color:#1a5276}.kb.group{background:#fdebd0;color:#b9770e}
17 .wl-name{font-weight:600;font-size:14px;} 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 .empty-state{text-align:center;padding:50px 20px;color:#999;} 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 </style> 39 </style>
22 <div class="wl-container"> 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 <div id="list"></div> 43 <div id="list"></div>
31 </div> 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 <script> 57 <script>
33 function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');} 58 function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
  59 +var selected={}; // wxid -> {wxid,name,kind}
  60 +var searchTimer=null;
  61 +
34 function load(){ 62 function load(){
35 var el=document.getElementById('list'); 63 var el=document.getElementById('list');
36 fetch('/api/whitelist').then(function(r){return r.json()}).then(function(j){ 64 fetch('/api/whitelist').then(function(r){return r.json()}).then(function(j){
37 if(!j.ok){el.innerHTML='<div class="empty-state">加载失败</div>';return;} 65 if(!j.ok){el.innerHTML='<div class="empty-state">加载失败</div>';return;}
38 if(!j.data.length){el.innerHTML='<div class="empty-state"><p>白名单为空 — 当前所有回复都会进人工审批</p></div>';return;} 66 if(!j.data.length){el.innerHTML='<div class="empty-state"><p>白名单为空 — 当前所有回复都会进人工审批</p></div>';return;}
39 var h=''; 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 el.innerHTML=h; 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,'&quot;')+')">'+
  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 load(); 122 load();
68 </script> 123 </script>
69 {% endblock %} 124 {% endblock %}
wechat_plain/scripts/auto-reply/wechat-viewer-changes.md
@@ -31,6 +31,13 @@ CREATE TABLE reply_queue ( -- 策略判定后落地 @@ -31,6 +31,13 @@ CREATE TABLE reply_queue ( -- 策略判定后落地
31 | `GET /api/replies?status=` | 队列列表(pending / sent / auto_sent / cancelled / 全部) | 31 | `GET /api/replies?status=` | 队列列表(pending / sent / auto_sent / cancelled / 全部) |
32 | `POST /api/reply/<id>/send` | 用(可编辑的)草稿发到会话 → `send_text_via_hook(talker,text)` → 标 sent/failed | 32 | `POST /api/reply/<id>/send` | 用(可编辑的)草稿发到会话 → `send_text_via_hook(talker,text)` → 标 sent/failed |
33 | `POST /api/reply/<id>/cancel` | 标 cancelled | 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 发送复用 `~/.hermes/event_bus/wechat_hook_send.py` 的 `send_text_via_hook`(LSPosed 内部 API 广播 `SEND_MSG_INTERNAL`)。 42 发送复用 `~/.hermes/event_bus/wechat_hook_send.py` 的 `send_text_via_hook`(LSPosed 内部 API 广播 `SEND_MSG_INTERNAL`)。
36 43