whitelist.html 13.2 KB
{% extends "base.html" %}
{% block title %}回复白名单 - WeChat Viewer{% endblock %}
{% block body %}
<style>
body{overflow-y:auto!important;height:auto!important;}
.wl-container{max-width:760px;margin:0 auto;padding:20px;}
.wl-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;}
.wl-header h1{font-size:20px;color:#2c3e50;}
.btn-add{background:#27ae60;color:white;border:none;border-radius:6px;padding:8px 16px;font-size:14px;cursor:pointer;}
.wl-hint{font-size:12px;color:#888;margin:8px 0 16px;line-height:1.5;}
.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;}
.kb{font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600;flex-shrink:0;}
.kb.contact{background:#d6eaf8;color:#1a5276}.kb.group{background:#fdebd0;color:#b9770e}
.wl-name{font-weight:600;font-size:14px;}
.wl-wxid{font-size:11px;color:#999;word-break:break-all;}
.wl-right{display:flex;align-items:center;gap:8px;margin-left:auto;flex-shrink:0;}
.btn-del{background:#e74c3c;color:white;border:none;border-radius:6px;padding:6px 12px;font-size:12px;cursor:pointer;}
/* role badge */
.role-badge{font-size:11px;padding:3px 10px;border-radius:10px;font-weight:600;white-space:nowrap;}
.role-badge.admin{background:#fef3c7;color:#b45309;}
.role-badge.member{background:#e8e8e8;color:#666;}
/* role select */
.role-sel{border:1px solid #cfd8e3;border-radius:6px;padding:5px 8px;font-size:12px;background:white;cursor:pointer;}
.role-sel:focus{outline:none;border-color:#27ae60;}
.empty-state{text-align:center;padding:50px 20px;color:#999;}
/* modal */
.modal-mask{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:1000;}
.modal-mask.show{display:flex;align-items:center;justify-content:center;}
.modal{background:white;border-radius:12px;width:92%;max-width:560px;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;}
.modal-hd{padding:14px 18px;border-bottom:1px solid #eee;display:flex;align-items:center;gap:10px;}
.modal-hd h3{font-size:16px;flex:1;}
.modal-hd .x{background:none;border:none;font-size:22px;color:#999;cursor:pointer;}
.modal-search{padding:10px 18px;border-bottom:1px solid #f0f0f0;}
.modal-search input{width:100%;box-sizing:border-box;border:1px solid #cfd8e3;border-radius:8px;padding:9px 12px;font-size:14px;}
.modal-body{flex:1;overflow-y:auto;padding:6px 0;}
.modal-role-row{padding:8px 18px;display:flex;align-items:center;gap:10px;font-size:13px;color:#555;border-bottom:1px solid #f0f0f0;}
.modal-role-row select{flex:1;border:1px solid #cfd8e3;border-radius:6px;padding:6px 10px;font-size:13px;max-width:200px;}
.pick-row{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;}
.pick-row:hover{background:#f5f8fb;}
.pick-row input{width:17px;height:17px;flex-shrink:0;}
.pick-row .nm{font-size:14px;flex:1;}
.pick-row .kb2{font-size:9px;padding:1px 6px;border-radius:8px;flex-shrink:0;}
.kb2.contact{background:#d6eaf8;color:#1a5276}.kb2.group{background:#fdebd0;color:#b9770e}
.modal-ft{padding:12px 18px;border-top:1px solid #eee;display:flex;align-items:center;gap:10px;}
.modal-ft .cnt{font-size:13px;color:#666;flex:1;}
.btn-confirm{background:#27ae60;color:white;border:none;border-radius:8px;padding:9px 20px;font-size:14px;cursor:pointer;}
.btn-confirm:disabled{opacity:.5;cursor:not-allowed;}
.loading{text-align:center;padding:30px;color:#999;}
/* 总开关卡片 */
.as-card{background:white;border-radius:8px;padding:14px 16px;margin-bottom:14px;box-shadow:0 1px 2px rgba(0,0,0,0.05);}
.as-row{display:flex;align-items:center;gap:12px;}
.as-txt{flex:1;}
.as-title{font-weight:600;font-size:15px;color:#2c3e50;}
.as-state{font-size:12px;margin-top:2px;}
.as-state.on{color:#27ae60;font-weight:600;}
.as-state.off{color:#999;}
.switch{position:relative;display:inline-block;width:52px;height:30px;flex-shrink:0;}
.switch input{opacity:0;width:0;height:0;}
.slider{position:absolute;cursor:pointer;inset:0;background:#ccc;border-radius:30px;transition:.2s;}
.slider:before{content:"";position:absolute;height:24px;width:24px;left:3px;bottom:3px;background:white;border-radius:50%;transition:.2s;}
.switch input:checked+.slider{background:#27ae60;}
.switch input:checked+.slider:before{transform:translateX(22px);}
.switch input:disabled+.slider{opacity:.5;cursor:not-allowed;}
.as-warn{font-size:12px;color:#b9770e;background:#fef9e7;border-radius:6px;padding:8px 10px;margin-top:10px;line-height:1.5;}
</style>
<div class="wl-container">
<div class="wl-header"><h1>⭐ 回复白名单</h1>
<div style="display:flex;gap:8px;align-items:center;">
<input type="text" id="searchInput" placeholder="搜索白名单..." oninput="load()" style="padding:7px 10px;border:1px solid #ddd;border-radius:6px;font-size:13px;min-width:140px;">
<select id="roleFilter" onchange="load()" style="padding:7px 10px;border:1px solid #ddd;border-radius:6px;font-size:13px;background:white;cursor:pointer;">
  <option value="">全部角色</option>
  <option value="admin">⭐ admin</option>
  <option value="member">👤 member</option>
</select>
<select id="sortBy" onchange="load()" style="padding:7px 10px;border:1px solid #ddd;border-radius:6px;font-size:13px;background:white;cursor:pointer;">
  <option value="added">按添加时间↓</option>
  <option value="name">按名称</option>
  <option value="role">按角色</option>
</select>
<button class="btn-add" onclick="openPicker()">➕ 添加</button>
</div>
</div>

<div class="as-card">
  <div class="as-row">
    <div class="as-txt">
      <div class="as-title">自动发送总开关</div>
      <div class="as-state off" id="asState">加载中…</div>
    </div>
    <label class="switch"><input type="checkbox" id="asToggle" onchange="toggleAuto(this)" disabled><span class="slider"></span></label>
  </div>
  <div class="as-warn">
    ⭐<b>admin</b>:完整 Hermes CLI,可调所有工具/skill,执行复杂任务(总结群内容、查数据等)。<br>
    👤<b>member</b>:简单文本回复,零工具纯补全。<br>
    开=白名单里的联系人/群<b>直接自动发</b>(敏感词仍拦截转人工)。关=一切都进「审批」等你手动发。
  </div>
</div>

<div class="wl-hint">白名单里的联系人 / 群,消息可被<b>自动回复</b>。admin 角色可执行复杂任务,member 仅简单闲聊回复。</div>
<div id="list"></div>
</div>

<!-- 选人弹窗 -->
<div class="modal-mask" id="mask">
  <div class="modal">
    <div class="modal-hd"><h3>选择联系人 / 群</h3><button class="x" onclick="closePicker()">×</button></div>
    <div class="modal-search"><input id="q" placeholder="搜索昵称 / 备注 / 群名..." oninput="onSearch()"></div>
    <div class="modal-role-row">
      <span>默认角色:</span>
      <select id="addRole"><option value="member">👤 member — 简单闲聊回复</option><option value="admin">⭐ admin — 完整工具/技能</option></select>
    </div>
    <div class="modal-body" id="picklist"><div class="loading">加载中...</div></div>
    <div class="modal-ft"><span class="cnt" id="cnt">已选 0</span>
      <button class="btn-confirm" id="btnConfirm" onclick="confirmAdd()" disabled>添加所选</button></div>
  </div>
</div>

<script>
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
var selected={};      // wxid -> {wxid,name,kind}
var searchTimer=null;

function load(){
  var el=document.getElementById('list');
  var sb = document.getElementById('sortBy').value;
  var q = (document.getElementById('searchInput').value || '').trim().toLowerCase();
  var rf = document.getElementById('roleFilter').value;
  fetch('/api/whitelist').then(function(r){return r.json()}).then(function(j){
    if(!j.ok){el.innerHTML='<div class="empty-state">加载失败</div>';return;}
    var items = (j.data || []).filter(function(t){
      if (q) {
        var hay = ((t.name || '') + ' ' + (t.wxid || '')).toLowerCase();
        if (hay.indexOf(q) === -1) return false;
      }
      if (rf && (t.role || 'member') !== rf) return false;
      return true;
    });
    if(!items.length){el.innerHTML='<div class="empty-state"><p>白名单为空 — 当前所有回复都会进人工审批</p></div>';return;}
    if (sb === 'name') items.sort(function(a,b){return (a.name||'').localeCompare(b.name||'')});
    else if (sb === 'role') items.sort(function(a,b){return (a.role||'member').localeCompare(b.role||'member')});
    else items.sort(function(a,b){return (b.added_at||'').localeCompare(a.added_at||'')});
    var h='';
    for(var i=0;i<items.length;i++){var t=items[i];
      var role=t.role||'member';
      var badgeCls='role-badge '+(role==='admin'?'admin':'member');
      var badgeTxt=role==='admin'?'⭐admin':'👤member';
      h+='<div class="wl-card"><span class="kb '+t.kind+'">'+(t.kind==='group'?'群':'联系人')+'</span>';
      h+='<div><div class="wl-name">'+esc(t.name||'(未命名)')+'</div><div class="wl-wxid">'+esc(t.wxid)+'</div></div>';
      h+='<div class="wl-right">';
      h+='<span class="'+badgeCls+'">'+badgeTxt+'</span>';
      h+='<select class="role-sel" onchange="setRole('+t.id+',this.value)"><option value="member"'+((role==='member')?' selected':'')+'>👤member</option><option value="admin"'+((role==='admin')?' selected':'')+'>⭐admin</option></select>';
      h+='<button class="btn-del" onclick="del('+t.id+')">删除</button></div></div>';
    }
    el.innerHTML=h;
  });
}
function setRole(id,role){
  fetch('/api/whitelist/'+id+'/role',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({role:role})})
    .then(function(r){return r.json()}).then(function(j){if(j.ok)load();else alert('失败: '+(j.error||''));});
}
function del(id){
  if(!confirm('确认删除?'))return;
  fetch('/api/whitelist/'+id+'/delete',{method:'POST'})
    .then(function(r){if(!r.ok)throw new Error(r.status);return r.json()})
    .then(function(j){if(j.ok){load();}else{alert('删除失败: '+(j.error||'未知错误'));}})
    .catch(function(e){alert('请求失败: '+e.message+'。请刷新后重试。');});
}

function renderAuto(on){
  var st=document.getElementById('asState'),tg=document.getElementById('asToggle');
  tg.checked=!!on;tg.disabled=false;
  st.textContent=on?'已开启 · 白名单自动发':'已关闭 · 全部人工审批';
  st.className='as-state '+(on?'on':'off');
}
function loadAuto(){
  fetch('/api/auto_send').then(function(r){return r.json()}).then(function(j){
    if(j.ok)renderAuto(j.enabled);
    else{document.getElementById('asState').textContent='读取失败';}
  }).catch(function(){document.getElementById('asState').textContent='读取失败';});
}
function toggleAuto(cb){
  var want=cb.checked;cb.disabled=true;
  if(want&&!confirm('确定开启自动发送?白名单里的消息将无需你确认直接发出(敏感词仍会拦截转人工)。')){
    cb.checked=false;cb.disabled=false;return;
  }
  fetch('/api/auto_send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:want})})
    .then(function(r){return r.json()}).then(function(j){
      if(j.ok)renderAuto(j.enabled);
      else{alert('失败: '+(j.error||''));cb.checked=!want;cb.disabled=false;}
    }).catch(function(){alert('网络失败');cb.checked=!want;cb.disabled=false;});
}

function openPicker(){
  selected={};updateCnt();
  document.getElementById('q').value='';
  document.getElementById('mask').classList.add('show');
  fetchList('/api/contacts');
}
function closePicker(){document.getElementById('mask').classList.remove('show');}
function onSearch(){
  clearTimeout(searchTimer);
  var q=document.getElementById('q').value.trim();
  searchTimer=setTimeout(function(){
    fetchList(q?('/api/contacts/search?q='+encodeURIComponent(q)):'/api/contacts');
  },250);
}
function fetchList(url){
  var el=document.getElementById('picklist');el.innerHTML='<div class="loading">加载中...</div>';
  fetch(url).then(function(r){return r.json()}).then(function(j){
    if(!j.ok){el.innerHTML='<div class="loading">加载失败: '+esc(j.error)+'</div>';return;}
    if(!j.data.length){el.innerHTML='<div class="loading">无结果</div>';return;}
    var h='';
    for(var i=0;i<j.data.length;i++){var t=j.data[i];
      var ck=selected[t.wxid]?'checked':'';
      h+='<label class="pick-row"><input type="checkbox" '+ck+
         ' onchange="toggle(this,'+esc(JSON.stringify(t)).replace(/"/g,'&quot;')+')">'+
         '<span class="nm">'+esc(t.name)+'</span>'+
         '<span class="kb2 '+t.kind+'">'+(t.kind==='group'?'群':'联系人')+'</span></label>';
    }
    el.innerHTML=h;
  });
}
function toggle(cb,t){if(cb.checked)selected[t.wxid]=t;else delete selected[t.wxid];updateCnt();}
function updateCnt(){
  var n=Object.keys(selected).length;
  document.getElementById('cnt').textContent='已选 '+n;
  document.getElementById('btnConfirm').disabled=(n===0);
}
function confirmAdd(){
  var items=Object.keys(selected).map(function(k){return selected[k];});
  if(!items.length)return;
  var role=document.getElementById('addRole').value;
  for(var i=0;i<items.length;i++)items[i].role=role;
  fetch('/api/whitelist',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({items:items})})
    .then(function(r){return r.json()}).then(function(j){
      if(j.ok){closePicker();load();}else{alert('失败: '+(j.error||''));}
    });
}
load();
loadAuto();
</script>
{% endblock %}