skills.html 10 KB
{% extends "base.html" %}
{% block title %}Skills - WeChat Viewer{% endblock %}
{% block page_title %}Skills{% endblock %}
{% block body %}
<style>
body{overflow-y:auto!important;height:auto!important;}
.skills-container{max-width:960px;margin:0 auto;padding:20px;}
.skills-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px;}
.skills-header h1{font-size:20px;color:#2c3e50;margin:0;}
.skills-header button{padding:6px 14px;background:#3498db;color:white;border:none;border-radius:6px;cursor:pointer;font-size:13px;}
.skills-controls{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;align-items:center;}
.skills-controls input{padding:7px 12px;border:1px solid #ddd;border-radius:6px;font-size:13px;flex:1;min-width:180px;max-width:280px;}
.skills-controls select{padding:7px 10px;border:1px solid #ddd;border-radius:6px;font-size:13px;background:white;}
.skills-table{width:100%;border-collapse:collapse;background:white;border-radius:8px;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,0.05);}
.skills-table th,.skills-table td{text-align:left;padding:10px 14px;border-bottom:1px solid #f0f0f0;font-size:13px;}
.skills-table th{background:#f8f9fa;color:#666;font-weight:600;font-size:12px;cursor:pointer;user-select:none;white-space:nowrap;}
.skills-table th:hover{background:#eef1f5;color:#333;}
.skills-table th .sort-arrow{font-size:10px;margin-left:3px;color:#bbb;}
.skills-table th.sorted .sort-arrow{color:#3498db;}
.skills-table tr:hover{background:#f8f9fa;}
.skills-table tr{cursor:pointer;}
.skill-name{font-weight:600;color:#2c3e50;}
.skill-meta{font-size:11px;color:#999;}
.pinned-badge{font-size:10px;background:#f39c12;color:white;padding:1px 6px;border-radius:8px;margin-left:6px;}
.empty-state{text-align:center;padding:60px 20px;color:#999;}
.empty-state .icon{font-size:48px;margin-bottom:12px;}
.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)}}
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;}
.status-dot.active{background:#27ae60;}
.status-dot.disabled{background:#95a5a6;}
.status-dot.archived{background:#e74c3c;}
.toggle-btn{border:none;background:none;cursor:pointer;font-size:16px;padding:2px 8px;border-radius:50%;transition:transform .15s;}
.toggle-btn:hover{transform:scale(1.3);}
.toggle-btn.active{opacity:1;}
.toggle-btn.disabled{opacity:.4;}
.skill-disabled{opacity:.5;}
.skill-disabled .skill-name{text-decoration:line-through;}
.skills-summary{font-size:11px;color:#999;margin-top:12px;text-align:right;}
@media(max-width:768px){
  .skills-controls{flex-direction:column;}
  .skills-controls input{max-width:100%;}
  .skills-table th,.skills-table td{padding:8px 10px;font-size:12px;}
}
</style>
<div class="skills-container">
<div class="skills-header">
<h1>🧩 Skills</h1>
<button onclick="refreshSkills()">🔄 刷新</button>
</div>
<div class="skills-controls">
<input type="text" id="skill-search" placeholder="🔍 搜索 skill 名称..." oninput="renderTable()">
<select id="skill-filter" onchange="renderTable()">
<option value="all">全部</option>
<option value="active">活跃</option>
<option value="disabled">已禁用</option>
<option value="archived">归档</option>
<option value="pinned">📌 已钉选</option>
<option value="global">🌐 全局可发现</option>
<option value="trigger">⚡ 触发级</option>
<option value="rare">🛡️ RARE_CRITICAL</option>
</select>
<select id="size-filter" onchange="renderTable()" title="SKILL.md 体积过滤(上下文污染风险)">
<option value="all">体积: 全部</option>
<option value="over60">> 60 行</option>
<option value="over150">> 150 行(护栏嫌疑)</option>
<option value="over300">> 300 行(硬上限)</option>
</select>
</div>
<div id="skills-list"></div>
<div class="skills-summary" id="skills-count"></div>
</div>
<script>
var allSkills = [];
var sortCol = 'use_count';
var sortAsc = false;

function formatDate(iso) {
  if (!iso) return '-';
  try {
    var d = new Date(iso);
    return d.toLocaleDateString('zh-CN', {month:'short',day:'numeric',year:'numeric'});
  } catch(e) { return iso.slice(0,10); }
}

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

function sortSkill(a, b) {
  var va, vb;
  if (sortCol === 'name' || sortCol === 'state' || sortCol === 'created_at') {
    va = (a[sortCol] || '').toLowerCase();
    vb = (b[sortCol] || '').toLowerCase();
    if (va < vb) return sortAsc ? -1 : 1;
    if (va > vb) return sortAsc ? 1 : -1;
    return 0;
  }
  // Numeric sort: primary key = sortCol, tiebreaker = job_count DESC
  va = Number(a[sortCol]) || 0;
  vb = Number(b[sortCol]) || 0;
  if (va < vb) return sortAsc ? -1 : 1;
  if (va > vb) return sortAsc ? 1 : -1;
  // Tie: job_count DESC
  var ja = Number(a.job_count) || 0;
  var jb = Number(b.job_count) || 0;
  return jb - ja;
}

function getFilteredSkills() {
  var query = (document.getElementById('skill-search').value || '').toLowerCase();
  var filter = document.getElementById('skill-filter').value;
  var sizeFilter = document.getElementById('size-filter').value;
  return allSkills.filter(function(s) {
    if (query && s.name.toLowerCase().indexOf(query) === -1) return false;
    if (filter === 'active' && s.state !== 'active') return false;
    if (filter === 'disabled' && s.state !== 'disabled') return false;
    if (filter === 'archived' && s.state === 'active') return false;
    if (filter === 'pinned' && !s.pinned) return false;
    if (filter === 'global' && s.trigger_level) return false;      // 触发级不全局暴露
    if (filter === 'trigger' && !s.trigger_level) return false;
    if (filter === 'rare' && !s.rare_critical) return false;
    var lines = Number(s.skill_lines) || 0;
    if (sizeFilter === 'over60' && lines <= 60) return false;
    if (sizeFilter === 'over150' && lines <= 150) return false;
    if (sizeFilter === 'over300' && lines <= 300) return false;
    return true;
  });
}

function sortBy(col) {
  if (sortCol === col) {
    sortAsc = !sortAsc;
  } else {
    sortCol = col;
    sortAsc = (col === 'name'); // name defaults to ASC, others to DESC
  }
  renderTable();
}

function renderArrow(col) {
  if (sortCol !== col) return '<span class="sort-arrow">⇅</span>';
  return '<span class="sort-arrow">' + (sortAsc ? '▲' : '▼') + '</span>';
}

function renderTable() {
  var el = document.getElementById('skills-list');
  var filtered = getFilteredSkills();
  filtered.sort(sortSkill);

  document.getElementById('skills-count').textContent = '共 ' + filtered.length + ' 个 Skills(总计 ' + allSkills.length + ')';

  if (!filtered.length) {
    el.innerHTML = '<div class="empty-state"><div class="icon">📦</div><p>没有匹配的 Skills</p></div>';
    return;
  }

  var html = '<table class="skills-table"><thead><tr>';
  html += '<th class="' + (sortCol==='name'?'sorted':'') + '" data-action="sort" onclick="sortBy(\'name\')">Skill 名称' + renderArrow('name') + '</th>';
  html += '<th class="' + (sortCol==='created_at'?'sorted':'') + '" data-action="sort" onclick="sortBy(\'created_at\')">创建时间' + renderArrow('created_at') + '</th>';
  html += '<th class="' + (sortCol==='use_count'?'sorted':'') + '" data-action="sort" onclick="sortBy(\'use_count\')">调用次数' + renderArrow('use_count') + '</th>';
  html += '<th class="' + (sortCol==='job_count'?'sorted':'') + '" data-action="sort" onclick="sortBy(\'job_count\')">任务数' + renderArrow('job_count') + '</th>';
  html += '<th class="' + (sortCol==='state'?'sorted':'') + '" data-action="sort" onclick="sortBy(\'state\')">状态' + renderArrow('state') + '</th>';
  html += '<th>开关</th>';
  html += '</tr></thead><tbody>';

  for (var i = 0; i < filtered.length; i++) {
    var s = filtered[i];
    var isDisabled = s.state === 'disabled';
    html += '<tr class="' + (isDisabled ? 'skill-disabled' : '') + '" onclick="location.href=\'/skill/' + s.name + '\'">';
    html += '<td><span class="skill-name">' + escHtml(s.name) + '</span>' + (s.pinned ? '<span class="pinned-badge">📌</span>' : '') + '</td>';
    html += '<td class="skill-meta">' + formatDate(s.created_at) + '</td>';
    html += '<td>' + s.use_count + '</td>';
    html += '<td>' + (s.job_count || 0) + '</td>';
    html += '<td><span class="status-dot ' + s.state + '"></span>' + (s.state === 'active' ? '活跃' : s.state === 'disabled' ? '已禁用' : s.state) + '</td>';
    html += '<td><button class="toggle-btn ' + s.state + '" onclick="event.stopPropagation();toggleSkill(\'' + s.name + '\',\'' + (s.state === 'active' ? 'disabled' : 'active') + '\')" title="' + (s.state === 'active' ? '点击禁用' : '点击启用') + '">' + (s.state === 'active' ? '🟢' : '🔴') + '</button></td>';
    html += '</tr>';
  }
  html += '</tbody></table>';
  el.innerHTML = html;
}

function toggleSkill(name, newState) {
  fetch('/api/skill/' + name + '/toggle', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({state: newState})
  }).then(function(r){return r.json()}).then(function(j){
    if (j.ok) {
      // Update local state
      for (var i = 0; i < allSkills.length; i++) {
        if (allSkills[i].name === name) {
          allSkills[i].state = newState;
          break;
        }
      }
      renderTable();
    }
  });
}

function refreshSkills() {
  var el = document.getElementById('skills-list');
  el.innerHTML = '<div class="loading"><div class="spinner"></div><p style="margin-top:12px;">加载中...</p></div>';
  fetch('/api/skills').then(function(r){return r.json()}).then(function(j){
    if (!j.ok && !j.data) {
      el.innerHTML = '<div class="empty-state"><div class="icon">📦</div><p>暂无 Skills</p></div>';
      return;
    }
    allSkills = j.data || [];
    renderTable();
  }).catch(function(e){
    el.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><p>加载失败: ' + e.message + '</p></div>';
  });
}

refreshSkills();
</script>
{% endblock %}