boss_job_detail.html 18.7 KB
{% extends "base.html" %}
{% block title %}BOSS 岗位详情 - WeChat Viewer{% endblock %}
{% block body %}
<style>
body{overflow-y:auto!important;height:auto!important;}
.jd-container{max-width:1100px;margin:0 auto;padding:20px;}
.jd-header{display:flex;align-items:center;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
.jd-header h1{font-size:20px;color:#2c3e50;margin:0;}
.jd-back{color:#3498db;text-decoration:none;font-size:13px;}
.jd-id{font-size:12px;color:#999;font-family:monospace;}
.jd-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:20px;}
.jd-card{background:white;border-radius:10px;padding:14px 18px;box-shadow:0 1px 3px rgba(0,0,0,0.06);}
.jd-card .num{font-size:22px;font-weight:700;color:#2c3e50;}
.jd-card .label{font-size:12px;color:#999;margin-top:4px;}
.badge{font-size:11px;padding:3px 10px;border-radius:12px;font-weight:500;white-space:nowrap;}
.badge.conf{background:#e8f8f0;color:#1a9e6c;}
.badge.unconf{background:#fdf3e7;color:#d97a1e;}
.rubric-box{background:white;border-radius:10px;padding:16px 18px;box-shadow:0 1px 3px rgba(0,0,0,0.06);margin-bottom:20px;}
.rubric-box h3{margin:0 0 10px;font-size:15px;color:#2c3e50;}
.rubric-meta{font-size:12px;color:#888;margin-bottom:10px;}
.rubric-dims{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;}
.dim-chip{background:#f0f4ff;color:#3b6fd4;font-size:11px;padding:4px 10px;border-radius:12px;}
.elim-chip{background:#fdf0f0;color:#d43b3b;font-size:11px;padding:4px 10px;border-radius:12px;}
.action-bar{display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap;}
.action-bar button{padding:8px 16px;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;}
.btn-import{background:#3498db;color:white;}
.btn-score{background:#e67e22;color:white;}
.btn-import:disabled,.btn-score:disabled{background:#ccc;cursor:not-allowed;}
.action-log{margin-top:10px;font-size:12px;color:#555;background:#f8f9fa;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:150px;overflow:auto;display:none;}
.rbar select,.rbar input{padding:8px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;background:white;cursor:pointer;}
.rbar input{width:200px;}
.rtable{width:100%;border-collapse:collapse;background:white;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);}
.rtable th{background:#f8f9fa;font-size:12px;color:#666;padding:10px 12px;text-align:left;border-bottom:1px solid #eee;}
.rtable td{padding:10px 12px;font-size:13px;border-bottom:1px solid #f5f5f5;vertical-align:middle;cursor:pointer;}
.rtable tr:last-child td{border-bottom:none;}
.rtable tr:hover td{background:#f8f9fa;}
.tier-badge{font-size:11px;padding:3px 10px;border-radius:12px;font-weight:500;}
.tier-recommend{background:#e8f8f0;color:#1a9e6c;}
.tier-backup{background:#fdf6e3;color:#b8860b;}
.tier-reject{background:#f5f5f5;color:#888;}
.tier-eliminated{background:#fdf0f0;color:#d43b3b;}
.tier-unscored{background:#eef2ff;color:#5b6bbf;}
.score-bar{display:inline-block;width:60px;height:6px;background:#eee;border-radius:3px;margin-left:6px;vertical-align:middle;overflow:hidden;}
.score-fill{display:block;height:100%;border-radius:3px;}
.score-high{background:#1a9e6c;}.score-mid{background:#f0a020;}.score-low{background:#d43b3b;}
.empty-state{text-align:center;padding:50px 20px;color:#999;}
.loading{text-align:center;padding:40px;color:#999;}
.spinner{display:inline-block;width:24px;height:24px;border:3px solid #ddd;border-top-color:#3498db;border-radius:50%;animation:spin .6s linear infinite;}
@keyframes spin{to{transform:rotate(360deg)}}
.pagination{display:flex;gap:6px;justify-content:center;margin:16px 0;flex-wrap:wrap;}
.pagination button{padding:6px 12px;border:1px solid #ddd;border-radius:6px;background:white;cursor:pointer;font-size:12px;}
.pagination button.active{background:#3498db;color:white;border-color:#3498db;}
.pagination button:disabled{opacity:.4;cursor:default;}
.modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:99;align-items:center;justify-content:center;}
.modal.show{display:flex;}
.modal-box{background:white;border-radius:12px;padding:24px 28px;max-width:420px;width:90%;text-align:center;}
.modal-box h3{margin:0 0 10px;font-size:16px;color:#2c3e50;}
.modal-box p{font-size:13px;color:#666;margin:0 0 12px;}
.status-badge{font-size:11px;padding:3px 10px;border-radius:10px;font-weight:500;cursor:pointer;display:inline-block;position:relative;user-select:none;border:1px solid transparent;}
.status-none{background:#eef2ff;color:#5b6bbf;}
.status-interview{background:#e8f8f0;color:#1a9e6c;}
.status-interviewed{background:#f0f4ff;color:#3b6fd4;}
.status-eliminated{background:#fdf0f0;color:#d43b3b;}
.status-hired{background:#fdf6e3;color:#b8860b;}
.status-menu{position:absolute;top:100%;left:0;background:white;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.12);z-index:50;min-width:90px;display:none;}
.status-menu div{padding:8px 14px;font-size:12px;cursor:pointer;}
.status-menu div:hover{background:#f5f7fa;}
</style>
<div class="jd-container">
<div class="jd-header">
  <a href="/boss-jobs" class="jd-back">← 岗位列表</a>
  <h1 id="jobTitle">岗位详情</h1>
  <span class="jd-id" id="jobIdTag"></span>
</div>
<div class="jd-cards" id="statCards"></div>

<div class="rubric-box" id="rubricBox">
  <h3>📋 筛选标准(第 2 层)</h3>
  <div id="rubricContent"><div class="loading"><div class="spinner"></div></div></div>
</div>

<div class="action-bar">
  <button id="btnImport" class="btn-import" onclick="importResumes()">📥 拉取简历入库</button>
  <button id="btnScore" class="btn-score" onclick="scoreResumes()" disabled>🎯 按本岗位标准打分</button>
  <div class="action-log" id="actionLog"></div>
</div>

<h3 style="margin:0 0 12px;color:#2c3e50;font-size:16px;">📄 本岗位简历(第 3 层)</h3>
<div class="rbar" style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;">
  <input type="text" id="searchInput" placeholder="搜索姓名/专业/学校..." oninput="render()">
  <select id="tierFilter" onchange="render()">
    <option value="">全部分档</option>
    <option value="recommend">推荐面试</option>
    <option value="backup">可备选</option>
    <option value="reject">暂不考虑</option>
    <option value="eliminated">已淘汰</option>
    <option value="unscored">未打分</option>
  </select>
  <select id="sortBy" onchange="render()">
    <option value="score">按分数↓</option>
    <option value="name">按姓名</option>
    <option value="edu">按学历</option>
  </select>
</div>
<div id="resumeList"><div class="loading"><div class="spinner"></div></div></div>
<div id="pager"></div>
</div>

<div class="modal" id="scoreModal"><div class="modal-box">
  <h3>🎯 打分中</h3><p id="scoreStatus">正在用本岗位 rubric 打分...</p><div class="spinner"></div>
</div></div>
<div class="modal" id="importModal"><div class="modal-box">
  <h3>📥 入库中</h3><p id="importStatus">正在提取并入库本岗位简历...</p><div class="spinner"></div>
</div></div>

<script>
var JOB_ID='{{ job_id }}';
var allResumes=[],currentPage=1,perPage=20,totalCount=0;

function esc(s){return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function fmtTime(ms){if(!ms)return '—';var d=new Date(ms);return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');}

function loadJob(){
  fetch('/api/boss-jobs').then(function(r){return r.json()}).then(function(j){
    if(!j.ok)return;
    var job=null;
    for(var i=0;i<j.data.length;i++){if(String(j.data[i].jobId)===JOB_ID){job=j.data[i];break;}}
    if(!job)return;
    document.getElementById('jobTitle').textContent=job.jobPosition;
    document.getElementById('jobIdTag').textContent='jobId: '+job.jobId;
    renderStats(job);
    renderRubric(job);
    document.getElementById('btnScore').disabled=!job.configured;
    if(!job.configured){
      document.getElementById('btnScore').title='该岗位未配置 rubric,无法打分';
    }
  });
}

function renderStats(job){
  document.getElementById('statCards').innerHTML=
    '<div class="jd-card"><div class="num">'+(job.candidates||0)+'</div><div class="label">候选人(BOSS)</div></div>'+
    '<div class="jd-card"><div class="num">'+(job.resumes_in_db||0)+'</div><div class="label">已入库简历</div></div>'+
    '<div class="jd-card"><div class="num">'+(job.scored||0)+'</div><div class="label">已打分</div></div>'+
    '<div class="jd-card"><div class="num">'+(job.resumes_in_db-(job.scored||0))+'</div><div class="label">待打分</div></div>'+
    '<div class="jd-card"><div class="num">'+fmtTime(job.latest_chat)+'</div><div class="label">最近沟通</div></div>';
}

function renderRubric(job){
  var box=document.getElementById('rubricContent');
  if(!job.configured){
    box.innerHTML='<div style="color:#d97a1e;font-size:13px;">⚠️ 该岗位尚未配置筛选标准(rubric)。</div>'+
      '<div style="font-size:12px;color:#888;margin-top:6px;">配置方式:在 <code>/sdcard/bosszhipin/data/rubrics/'+esc(job.jobId)+'.json</code> 创建 rubric 文件并更新 <code>jobs_registry.json</code>,然后刷新本页即可打分。</div>';
    return;
  }
  fetch('/api/boss-jobs').then(function(r){return r.json()}).then(function(j){
    // rubric 详情通过文件直接读(无需额外 API,前端展示版本+维度名)
    box.innerHTML='<div class="rubric-meta">文件:<code>'+esc(job.rubric_path)+'</code> · 版本 v'+job.rubric_version+'</div>'+
      '<div style="font-size:12px;color:#888;margin-bottom:8px;">打分维度与淘汰规则定义见 rubric 文件,打分按钮使用该文件作为唯一标准。</div>';
  });
}

function loadData(page){
  currentPage=page;
  document.getElementById('resumeList').innerHTML='<div class="loading"><div class="spinner"></div></div>';
  fetch('/api/boss-jobs/'+JOB_ID+'/resumes?page='+page+'&per_page='+perPage).then(function(r){return r.json()}).then(function(j){
    if(!j.ok){document.getElementById('resumeList').innerHTML='<div class="empty-state">加载失败</div>';return;}
    allResumes=j.data;totalCount=j.total;
    render();renderPager();
  }).catch(function(e){document.getElementById('resumeList').innerHTML='<div class="empty-state">加载失败: '+e.message+'</div>';});
}

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

function getTierBadge(r){
  if(!r.total_score&&r.total_score!==0)return '<span class="tier-badge tier-unscored">未打分</span>';
  if(r.eliminated==1||r.tier==='已淘汰')return '<span class="tier-badge tier-eliminated">已淘汰</span>';
  if(r.tier==='推荐面试')return '<span class="tier-badge tier-recommend">推荐面试</span>';
  if(r.tier==='可备选')return '<span class="tier-badge tier-backup">可备选</span>';
  return '<span class="tier-badge tier-reject">暂不考虑</span>';
}
function getScoreBar(score){
  if(score===null||score===undefined)return '';
  var cls=score>=75?'score-high':score>=55?'score-mid':'score-low';
  return '<span class="score-bar"><span class="score-fill '+cls+'" style="width:'+score+'%"></span></span>';
}
function getStatusBadge(r){
  var status=r.status||'';
  var labels={'待面试':'待面试','已面试':'已面试','已淘汰':'已淘汰','已入职':'已入职'};
  var cssMap={'待面试':'status-interview','已面试':'status-interviewed','已淘汰':'status-eliminated','已入职':'status-hired'};
  if(!status||!labels[status]){
    return '<span style="position:relative;display:inline-block">'+
      '<span class="status-badge status-none" onclick="event.stopPropagation();toggleStatusMenu(event,'+r.id+')">未设置 ▾</span>'+
      '<div class="status-menu" id="stmenu-'+r.id+'">'+
      '<div onclick="updateStatus('+r.id+',\'待面试\')">待面试</div>'+
      '<div onclick="updateStatus('+r.id+',\'已面试\')">已面试</div>'+
      '<div onclick="updateStatus('+r.id+',\'已淘汰\')">已淘汰</div>'+
      '<div onclick="updateStatus('+r.id+',\'已入职\')">已入职</div>'+
      '</div></span>';
  }
  return '<span style="position:relative;display:inline-block">'+
    '<span class="status-badge '+cssMap[status]+'" onclick="event.stopPropagation();toggleStatusMenu(event,'+r.id+')">'+labels[status]+' ▾</span>'+
    '<div class="status-menu" id="stmenu-'+r.id+'">'+
    '<div onclick="updateStatus('+r.id+',\'待面试\')">待面试</div>'+
    '<div onclick="updateStatus('+r.id+',\'已面试\')">已面试</div>'+
    '<div onclick="updateStatus('+r.id+',\'已淘汰\')">已淘汰</div>'+
    '<div onclick="updateStatus('+r.id+',\'已入职\')">已入职</div>'+
    '</div></span>';
}
function toggleStatusMenu(ev,id){
  ev.stopPropagation();
  var menus=document.querySelectorAll('.status-menu');
  for(var i=0;i<menus.length;i++)menus[i].style.display='none';
  var m=document.getElementById('stmenu-'+id);
  if(m)m.style.display=m.style.display==='block'?'none':'block';
}
function updateStatus(id,status){
  fetch('/api/resumes/'+id+'/status',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:status})})
    .then(function(r){return r.json()}).then(function(j){
      if(j.ok){
        for(var i=0;i<allResumes.length;i++)if(allResumes[i].id===id)allResumes[i].status=status;
        render();
      }
    });
  var menus=document.querySelectorAll('.status-menu');
  for(var i=0;i<menus.length;i++)menus[i].style.display='none';
}
document.addEventListener('click',function(){var ms=document.querySelectorAll('.status-menu');for(var i=0;i<ms.length;i++)ms[i].style.display='none';});

function render(){
  var q=(document.getElementById('searchInput').value||'').trim().toLowerCase();
  var tf=document.getElementById('tierFilter').value;
  var sb=document.getElementById('sortBy').value;
  var filtered=[];
  for(var i=0;i<allResumes.length;i++){
    var r=allResumes[i];
    if(q){
      var hay=(r.name+' '+(r.secondText||'')+' '+(r.education||'')+' '+(r.applyStatus||'')+' '+(r.city||'')).toLowerCase();
      if(hay.indexOf(q)<0)continue;
    }
    if(tf==='recommend'&&r.tier!=='推荐面试')continue;
    if(tf==='backup'&&r.tier!=='可备选')continue;
    if(tf==='reject'&&r.tier!=='暂不考虑')continue;
    if(tf==='eliminated'&&r.eliminated!=1&&r.tier!=='已淘汰')continue;
    if(tf==='unscored'&&r.total_score!==null&&r.total_score!==undefined)continue;
    filtered.push(r);
  }
  if(sb==='score')filtered.sort(function(a,b){return (b.total_score||-1)-(a.total_score||-1)});
  else if(sb==='name')filtered.sort(function(a,b){return (a.name||'').localeCompare(b.name||'')});
  else if(sb==='edu')filtered.sort(function(a,b){var m={'博士':4,'硕士':3,'本科':2,'大专':1};return (m[b.education]||0)-(m[a.education]||0)});
  var el=document.getElementById('resumeList');
  if(!filtered.length){el.innerHTML='<div class="empty-state">无匹配简历</div>';return;}
  var html='<table class="rtable"><thead><tr>';
  html+='<th style="width:30px;">#</th><th>姓名</th><th>性别</th><th>学历</th><th>学校/专业</th><th>到岗</th><th>分数</th><th>分档</th><th>状态</th>';
  html+='</tr></thead><tbody>';
  for(var i=0;i<filtered.length;i++){
    var r=filtered[i];
    var elimClass=(r.eliminated==1||r.tier==='已淘汰')?' style="color:#999;text-decoration:line-through;"':'';
    html+='<tr onclick="location.href=\'/resume/'+r.id+'\'">';
    html+='<td style="color:#999;">'+r.id+'</td>';
    html+='<td style="font-weight:600;"'+elimClass+'>'+esc(r.name||'')+'</td>';
    html+='<td'+elimClass+'>'+esc(r.gender||'—')+'</td>';
    html+='<td'+elimClass+'>'+esc(r.education||'')+'</td>';
    html+='<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"'+elimClass+'>'+esc((r.secondText||'').replace('毕业于 ',''))+'</td>';
    html+='<td style="font-size:11px;"'+elimClass+'>'+esc(r.applyStatus||'')+'</td>';
    if(r.eliminated==1||r.tier==='已淘汰'){
      html+='<td'+elimClass+'>0</td>';
      html+='<td><span class="tier-badge tier-eliminated" title="'+esc(r.eliminated_reason||'')+'">已淘汰</span></td>';
    }else{
      html+='<td>'+(r.total_score!==null&&r.total_score!==undefined?'<b>'+r.total_score+'</b>'+getScoreBar(r.total_score):'—')+'</td>';
      html+='<td>'+getTierBadge(r)+'</td>';
    }
    html+='<td onclick="event.stopPropagation()">'+getStatusBadge(r)+'</td>';
    html+='</tr>';
  }
  html+='</tbody></table>';
  el.innerHTML=html;
}

function importResumes(){
  var btn=document.getElementById('btnImport');
  btn.disabled=true;
  document.getElementById('importModal').classList.add('show');
  document.getElementById('importStatus').textContent='正在提取并入库本岗位简历...';
  fetch('/api/boss-jobs/'+JOB_ID+'/import',{method:'POST'}).then(function(r){return r.json()}).then(function(j){
    document.getElementById('importModal').classList.remove('show');
    btn.disabled=false;
    showLog(j.ok?'✅ 入库完成\n'+j.data.stdout:'❌ '+j.error);
    if(j.ok){loadJob();loadData(1);}
  }).catch(function(e){
    document.getElementById('importModal').classList.remove('show');
    btn.disabled=false;
    showLog('❌ '+e.message);
  });
}
function scoreResumes(){
  var btn=document.getElementById('btnScore');
  btn.disabled=true;
  document.getElementById('scoreModal').classList.add('show');
  document.getElementById('scoreStatus').textContent='正在用本岗位 rubric 打分...';
  fetch('/api/boss-jobs/'+JOB_ID+'/score',{method:'POST'}).then(function(r){return r.json()}).then(function(j){
    document.getElementById('scoreModal').classList.remove('show');
    btn.disabled=false;
    if(j.ok){
      showLog('✅ 打分完成 '+j.data.scored+' 份\n'+j.data.stdout);
      loadJob();loadData(1);
    }else{
      showLog('❌ '+j.error);
    }
  }).catch(function(e){
    document.getElementById('scoreModal').classList.remove('show');
    btn.disabled=false;
    showLog('❌ '+e.message);
  });
}
function showLog(text){
  var el=document.getElementById('actionLog');
  el.textContent=text;
  el.style.display='block';
}

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