articles.html 15.2 KB
{% extends "base.html" %}
{% set request_path = "/articles" %}

{% block body %}
<style>
body{overflow-y:auto!important;height:auto!important;}
.fbar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;}
.fbar input{flex:1;min-width:120px;padding:10px;border:1px solid #ddd;border-radius:8px;font-size:14px;}
.fbar select,.fbar button{padding:10px;border:1px solid #ddd;border-radius:8px;font-size:13px;background:white;cursor:pointer;}
.fbar button.active{background:#3498db;color:white;border-color:#3498db;}
.btn-cached{background:#e8f5e9!important;color:#888!important;border-color:#ccc!important;cursor:default!important;}
.pagination{display:flex;gap:6px;justify-content:center;margin:16px 0;flex-wrap:wrap;align-items:center;}
.pagination button{padding:8px 12px;border:1px solid #ddd;border-radius:6px;background:white;font-size:13px;cursor:pointer;}
.pagination button.active{background:#3498db;color:white;border-color:#3498db;}
.pagination button:disabled{opacity:.4;cursor:default;}
.pagination span{font-size:13px;color:#666;}
.pagination select{padding:8px;border:1px solid #ddd;border-radius:6px;font-size:13px;}
.date-input{padding:10px;border:1px solid #ddd;border-radius:8px;font-size:13px;width:130px;background:white;}
</style>
<div style="max-width:700px;margin:0 auto;padding:20px;">
<h2 style="margin-bottom:12px;">📑 文章列表</h2>
<div class="fbar">
  <input type="text" id="searchInput" placeholder="搜索标题..." oninput="render()">
  <input type="date" id="dateFrom" class="date-input" onchange="loadPage(1)">
  <span style="color:#999;font-size:12px;">至</span>
  <input type="date" id="dateTo" class="date-input" onchange="loadPage(1)">
  <select id="groupFilter" onchange="render()"><option value="">全部群</option></select>
  <button id="btnTime" class="active" onclick="setSort('time')">时间↓</button>
  <button id="btnHot" onclick="setSort('hot')">热度↓</button>
  <button id="btnSummarized" onclick="setStatusFilter('summarized')" style="padding:8px 12px;border:1px solid #27ae60;border-radius:8px;font-size:12px;background:white;color:#27ae60;cursor:pointer;">已摘要</button>
  <button id="btnLearned" onclick="setStatusFilter('learned')" style="padding:8px 12px;border:1px solid #8e44ad;border-radius:8px;font-size:12px;background:white;color:#8e44ad;cursor:pointer;">已学习</button>
  <select id="perPageSel" onchange="changePerPage()">
    <option value="10">10条/页</option>
    <option value="20" selected>20条/页</option>
    <option value="50">50条/页</option>
  </select>
  <button id="btnRefresh" onclick="manualRefresh()" style="padding:10px 14px;border:1px solid #e67e22;border-radius:8px;font-size:13px;background:white;color:#e67e22;cursor:pointer;white-space:nowrap;margin-left:auto;">🔄 手动刷新</button>
</div>
<div style="font-size:12px;color:#999;margin-bottom:12px;" id="infoBar">加载中...</div>
<div id="pagerTop"></div>
<div id="articleList"><div class="loading"><div class="spinner"></div></div></div>
<div id="pagerBottom"></div>
</div>

<script>
var allArticles=[], sortBy='time', sortAsc=false;
var currentPage=1, totalPages=1, perPage=20, totalCount=0;
var cachedSummaries={}, cachedLearns={};
var statusFilter='';
var allGroups=[];

async function checkCache(){
  if(!allArticles.length)return;
  var urls=[];
  for(var i=0;i<allArticles.length;i++){
    urls.push({url:allArticles[i].url,title:allArticles[i].title});
  }
  try{
    var r1=await fetch('/api/articles/check-summaries',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({urls:urls,type:'summary'})});
    var j1=await r1.json();
    if(j1.ok&&j1.data.results){cachedSummaries=j1.data.results;}
    var r2=await fetch('/api/articles/check-summaries',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({urls:urls,type:'learn'})});
    var j2=await r2.json();
    if(j2.ok&&j2.data.results){cachedLearns=j2.data.results;}
  }catch(e){}
  render();
}

async function loadPage(page){
  currentPage=page;
  document.getElementById('articleList').innerHTML='<div class="loading"><div class="spinner"></div></div>';
  try{
    var df=document.getElementById('dateFrom').value;
    var dt=document.getElementById('dateTo').value;
    var apiUrl='/api/articles?page='+page+'&per_page='+perPage;
    if(df)apiUrl+='&date_from='+df;
    if(dt)apiUrl+='&date_to='+dt;
    var r=await fetch(apiUrl);
    var j=await r.json();
    if(!j.ok||!j.data.length){document.getElementById('articleList').innerHTML='<div class="empty-state">暂无文章</div>';return;}
    allArticles=j.data;
    totalCount=j.total;
    totalPages=Math.ceil(totalCount/perPage);
    if(!allGroups.length){
      try{
        var rAll=await fetch('/api/articles?page=1&per_page='+totalCount+(df?'&date_from='+df:'')+(dt?'&date_to='+dt:''));
        var jAll=await rAll.json();
        if(jAll.ok){
          var ag=new Set();
          for(var i=0;i<jAll.data.length;i++){var gs=jAll.data[i].groups||[];for(var jj=0;jj<gs.length;jj++)ag.add(gs[jj]);}
          allGroups=Array.from(ag).sort();
          var sel=document.getElementById('groupFilter');
          sel.innerHTML='<option value="">全部群</option>';
          for(var k=0;k<allGroups.length;k++)sel.innerHTML+='<option value="'+esc(allGroups[k])+'">'+esc(allGroups[k])+'</option>';
        }
      }catch(e){}
    }
    render();
    checkCache();
    renderPager();
  }catch(e){document.getElementById('articleList').innerHTML='<div class="empty-state">加载失败: '+e.message+'</div>';}
}

function renderPager(){
  var html='';
  html+='<span>共 '+totalCount+' 篇,'+totalPages+' 页</span>';
  html+='<button '+(currentPage<=1?'disabled':'')+' onclick="loadPage('+(currentPage-1)+')">上一页</button>';
  var start=Math.max(1,currentPage-2),end=Math.min(totalPages,currentPage+2);
  if(start>1){html+='<button onclick="loadPage(1)">1</button>';if(start>2)html+='<span>…</span>';}
  for(var i=start;i<=end;i++){
    html+='<button '+(i===currentPage?'class="active"':'')+' onclick="loadPage('+i+')">'+i+'</button>';
  }
  if(end<totalPages){if(end<totalPages-1)html+='<span>…</span>';html+='<button onclick="loadPage('+totalPages+')">'+totalPages+'</button>';}
  html+='<button '+(currentPage>=totalPages?'disabled':'')+' onclick="loadPage('+(currentPage+1)+')">下一页</button>';
  document.getElementById('pagerTop').innerHTML='<div class="pagination">'+html+'</div>';
  document.getElementById('pagerBottom').innerHTML='<div class="pagination">'+html+'</div>';
}

function changePerPage(){
  perPage=parseInt(document.getElementById('perPageSel').value);
  currentPage=1;
  loadPage(1);
}

function getDedupKey(url){
  if(!url||url.indexOf('mp.weixin.qq.com')<0)return url;
  var m=url.match(/__biz=([^&]+)/),mid=url.match(/mid=(\d+)/),idx=url.match(/idx=(\d+)/),sn=url.match(/sn=([a-f0-9]+)/);
  return m&&mid&&idx&&sn?m[1]+'|'+mid[1]+'|'+idx[1]+'|'+sn[1]:url;
}

function isCached(url,type){
  var dk=getDedupKey(url);
  return type==='summary'?!!cachedSummaries[dk]:!!cachedLearns[dk];
}
function getCachedInfo(url,type){
  var dk=getDedupKey(url);
  if(type==='summary')return cachedSummaries[dk]||null;
  return cachedLearns[dk]||null;
}

function setStatusFilter(f){statusFilter=statusFilter===f?'':f;document.getElementById('btnSummarized').style.background=statusFilter==='summarized'?'#27ae60':'white';document.getElementById('btnSummarized').style.color=statusFilter==='summarized'?'white':'#27ae60';document.getElementById('btnLearned').style.background=statusFilter==='learned'?'#8e44ad':'white';document.getElementById('btnLearned').style.color=statusFilter==='learned'?'white':'#8e44ad';render();}
function setSort(s){
  if(sortBy===s)sortAsc=!sortAsc;else{sortBy=s;sortAsc=false;}
  document.getElementById('btnTime').className=sortBy==='time'?'active':'';
  document.getElementById('btnHot').className=sortBy==='hot'?'active':'';
  document.getElementById('btnTime').textContent=sortBy==='time'?(sortAsc?'时间↑':'时间↓'):'时间↓';
  document.getElementById('btnHot').textContent=sortBy==='hot'?(sortAsc?'热度↑':'热度↓'):'热度↓';
  render();
}

function render(){
  var q=(document.getElementById('searchInput').value||'').trim().toLowerCase(),
      gf=document.getElementById('groupFilter').value,
      filtered=[];
  for(var i=0;i<allArticles.length;i++){
    var a=allArticles[i];
    if(q&&(a.title||'').toLowerCase().indexOf(q)<0)continue;
    if(gf&&(a.groups||[]).indexOf(gf)<0)continue;
    if(statusFilter==='summarized'&&!isCached(a.url,'summary'))continue;
    if(statusFilter==='learned'&&!isCached(a.url,'learn'))continue;
    filtered.push(a);
  }
  filtered.sort(function(a,b){
    if(sortBy==='time')return sortAsc?(a.createTime||0)-(b.createTime||0):(b.createTime||0)-(a.createTime||0);
    else return sortAsc?a.shareCount-b.shareCount:b.shareCount-a.shareCount;
  });
  var cs=0,cl=0;
  for(var i=0;i<filtered.length;i++){if(isCached(filtered[i].url,'summary'))cs++;if(isCached(filtered[i].url,'learn'))cl++;}
  var statusLabel=statusFilter==='summarized'?' · 已摘要':statusFilter==='learned'?' · 已学习':'';document.getElementById('infoBar').textContent='第 '+currentPage+' 页 · '+filtered.length+' 篇(已摘要 '+cs+' / 已学习 '+cl+')'+statusLabel+(q||gf?'(筛选自 '+totalCount+' 篇':'');
  var html='';
  for(var i=0;i<filtered.length;i++){
    var a=filtered[i],
        t=new Date(a.createTime||0),
        ts=t.toLocaleDateString()+' '+t.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
    html+='<div class="article-card" style="display:block;">';
    html+='<a href="'+esc(a.url)+'" target="_blank" style="text-decoration:none;color:inherit;display:flex;gap:10px;">';
    html+='<div class="card-thumb" style="display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;">📄</div>';
    html+='<div class="card-body" style="flex:1;min-width:0;">';
    html+='<div class="card-title">'+esc(a.title||'')+'</div>';
    if(a.description)html+='<div class="card-desc">'+esc(a.description)+'</div>';
    html+='<div class="card-source" style="display:flex;justify-content:space-between;"><span>📨 '+ts+'</span><span style="color:#e67e22;font-weight:600;">🔥 '+a.shareCount+'群</span></div>';
    if(a.groups&&a.groups.length)html+='<div style="font-size:10px;color:#bbb;margin-top:3px;">'+a.groups.join(' · ')+'</div>';
    html+='</div></a>';
    html+='<div style="display:flex;gap:8px;margin-top:10px;">';
    var ci=getCachedInfo(a.url,'summary');
    if(ci){
      html+='<button onclick="event.preventDefault();summarize(event,\''+escAttr(a.title)+'\',\''+escAttr(a.url)+'\')" style="flex:1;padding:10px;border:1px solid #27ae60;border-radius:8px;background:white;color:#27ae60;font-size:13px;cursor:pointer;">🔄 重新摘要</button>';
      html+='<a href="/report/'+ci.id+'" target="_blank" style="padding:10px 14px;border:1px solid #27ae60;border-radius:8px;background:#e8f5e9;color:#27ae60;font-size:13px;text-decoration:none;white-space:nowrap;">📄 查看 #'+ci.id+'</a>';
    }else{
      html+='<button onclick="event.preventDefault();summarize(event,\''+escAttr(a.title)+'\',\''+escAttr(a.url)+'\')" style="flex:1;padding:10px;border:1px solid #3498db;border-radius:8px;background:white;color:#3498db;font-size:13px;cursor:pointer;">📝 摘要</button>';
    }
    var li=getCachedInfo(a.url,'learn');
    if(li){
      html+='<button onclick="event.preventDefault();learnArticle(event,\''+escAttr(a.title)+'\',\''+escAttr(a.url)+'\')" style="flex:1;padding:10px;border:1px solid #8e44ad;border-radius:8px;background:white;color:#8e44ad;font-size:13px;cursor:pointer;">🔄 重新学习</button>';
      html+='<a href="/report/'+li.id+'" target="_blank" style="padding:10px 14px;border:1px solid #8e44ad;border-radius:8px;background:#f3e5f5;color:#8e44ad;font-size:13px;text-decoration:none;white-space:nowrap;">📄 查看 #'+li.id+'</a>';
    }else{
      html+='<button onclick="event.preventDefault();learnArticle(event,\''+escAttr(a.title)+'\',\''+escAttr(a.url)+'\')" style="flex:1;padding:10px;border:1px solid #9b59b6;border-radius:8px;background:white;color:#9b59b6;font-size:13px;cursor:pointer;">🔧 学习</button>';
    }
    html+='</div></div>';
  }
  document.getElementById('articleList').innerHTML=html||'<div class="empty-state">无匹配文章</div>';
}

function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function escAttr(s){return String(s||'').replace(/'/g,"\\'").replace(/\\/g,'\\\\');}

async function summarize(evt,title,url){evt.stopPropagation();evt.preventDefault();if(!confirm('为这篇文章生成摘要?'))return;
  try{var r=await fetch('/api/articles/summarize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:title,url:url})});var j=await r.json();
    if(!j.ok){alert('失败: '+(j.error||''));return;}
    if(j.data.status==='cached'){if(!confirm('已有摘要('+j.data.created_at+'),要重新生成吗?'))return;}
    alert('摘要任务已提交!刷新页面可查看。');
    var dk=getDedupKey(url);cachedSummaries[dk]=true;render();
  }catch(e){alert('错误: '+e.message);}}

async function learnArticle(evt,title,url){evt.stopPropagation();evt.preventDefault();if(!confirm('学习这篇文章并构建 skill?'))return;
  try{var r=await fetch('/api/articles/learn',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:title,url:url})});var j=await r.json();
    if(!j.ok){alert('失败: '+(j.error||''));return;}
    if(j.data.status==='cached'){if(!confirm('已有学习结果('+j.data.created_at+'),要重新学习吗?'))return;}
    alert('学习任务已提交!刷新页面可查看。');
    var dk=getDedupKey(url);cachedLearns[dk]=true;render();
  }catch(e){alert('错误: '+e.message);}}

async function manualRefresh(){
  var btn=document.getElementById('btnRefresh');
  if(btn.disabled)return;
  btn.disabled=true;
  btn.textContent='⏳ 提取中...';
  btn.style.opacity='0.6';
  try{
    var r=await fetch('/api/articles/refresh',{method:'POST'});
    var j=await r.json();
    if(!j.ok){alert('启动失败: '+(j.error||''));btn.disabled=false;btn.textContent='🔄 手动刷新';btn.style.opacity='1';return;}
    var tid=j.data.task_id;
    var start=Date.now(),done=false;
    while(Date.now()-start<120000){
      await new Promise(function(res){setTimeout(res,3000);});
      var pr=await fetch('/api/job/'+tid);
      var pj=await pr.json();
      if(!pj.ok)continue;
      var st=pj.data.status;
      if(st==='completed'){done=true;break;}
      if(st==='failed'){break;}
      btn.textContent='⏳ 提取中... ('+Math.round((Date.now()-start)/1000)+'s)';
    }
    if(done){
      try{
        var lr=await fetch('/api/job/'+tid+'/log');
        var lj=await lr.json();
        if(lj.ok&&lj.data.content){
          var c=lj.data.content;
          var m=c.match(/(\d+)\s+new articles/);
          if(m)alert('✅ 提取完成!新增 '+m[1]+' 篇文章。');
          else alert('✅ 提取完成!');
        }else alert('✅ 提取完成!');
      }catch(e){alert('✅ 提取完成!');}
    }else{
      alert('⚠️ 提取未完成或超时,请查看 /jobs 页面了解详情。');
    }
  }catch(e){alert('错误: '+e.message);}
  btn.disabled=false;
  btn.textContent='🔄 手动刷新';
  btn.style.opacity='1';
  loadPage(currentPage);
}

loadPage(1);
</script>
{% endblock %}