chat.html 13.3 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="pageTitle">WeChat Chat</title>
<style>
* { margin:0;padding:0;box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:#f0f2f5; }

/* Header */
.chat-header { position:sticky;top:0;z-index:100;background:#2c3e50;color:white;padding:10px 16px;display:flex;align-items:center;gap:12px; }
.chat-header a { color:white;text-decoration:none;font-size:20px; }
.chat-header h2 { font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
.chat-header .sub { font-size:11px;color:#aaa; }

/* Messages — newest at top */
.messages { max-width:700px;margin:0 auto;padding:16px 12px;display:flex;flex-direction:column; }
.msg-bubble { margin-bottom:12px;display:flex; }
.msg-bubble.sent { justify-content:flex-end; }
.msg-bubble .bubble { max-width:72%;padding:10px 14px;border-radius:8px;font-size:14px;line-height:1.5;word-break:break-word;position:relative; }
.msg-bubble.received .bubble { background:white;color:#333; }
.msg-bubble.sent .bubble { background:#95EC69;color:#333; }
.msg-bubble .time-sub { font-size:11px;color:#999;margin-top:4px;text-align:right; }

/* Image */
.msg-image-wrap { position:relative;display:inline-block;max-width:280px; }
.msg-image-wrap img { width:100%;border-radius:6px;cursor:pointer; }

/* Article card */
.article-card { background:white;border-radius:8px;padding:12px;margin:4px 0;border:1px solid #e8e8e8;text-decoration:none;color:inherit;display:block; }
.article-card:hover { background:#f8f9fa; }
.article-card .card-thumb { width:60px;height:60px;border-radius:4px;object-fit:cover;flex-shrink:0;background:#f0f0f0; }
.article-card .card-title { font-size:13px;font-weight:600;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden; }
.article-card .card-desc { font-size:11px;color:#999;margin-top:4px;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden; }

/* Finder card */
.finder-card { background:white;border-radius:8px;overflow:hidden;margin:4px 0;border:1px solid #e8e8e8;cursor:pointer; }
.finder-card .finder-cover { position:relative;width:100%;aspect-ratio:9/16;max-height:260px;background:#000;overflow:hidden; }
.finder-card .finder-cover img { width:100%;height:100%;object-fit:cover; }
.finder-card .finder-play { position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:44px;height:44px;background:rgba(0,0,0,0.6);border-radius:50%;display:flex;align-items:center;justify-content:center; }
.finder-card .finder-play::after { content:'';border-left:12px solid white;border-top:7px solid transparent;border-bottom:7px solid transparent;margin-left:3px; }
.finder-card .finder-dur { position:absolute;bottom:6px;right:6px;background:rgba(0,0,0,0.7);color:white;font-size:10px;padding:2px 6px;border-radius:3px; }
.finder-card .finder-info { padding:8px 10px;display:flex;gap:8px;align-items:flex-start; }
.finder-card .finder-avatar { width:28px;height:28px;border-radius:50%;flex-shrink:0; }
.finder-card .finder-nick { font-size:12px;font-weight:600;color:#576b95; }
.finder-card .finder-desc { font-size:11px;color:#333;margin-top:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden; }

/* Load more */
.load-more { text-align:center;padding:16px;color:#999;font-size:13px;cursor:pointer; }
.load-more:hover { color:#3498db; }
.spinner { display:inline-block;width:20px;height:20px;border:3px solid #ddd;border-top-color:#3498db;border-radius:50%;animation:spin .6s linear infinite; }
@keyframes spin { to{transform:rotate(360deg); } }
.empty { text-align:center;padding:60px 20px;color:#999; }

/* Modal */
.img-modal { display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.9);z-index:999;align-items:center;justify-content:center; }
.img-modal img { max-width:95%;max-height:85%;border-radius:4px; }

audio { max-width:200px; }
</style>
</head>
<body>

<div class="chat-header">
  <a href="/">←</a>
  <div style="flex:1;min-width:0;">
    <h2 id="chatName">加载中...</h2>
    <div class="sub" id="chatSub"></div>
  </div>
  <button onclick="showDatePicker()" style="background:#3498db;color:white;border:none;padding:6px 12px;border-radius:6px;font-size:13px;cursor:pointer;white-space:nowrap;">📋 生成日报</button>
</div>

<div class="messages" id="messageList">
  <div class="empty"><div class="spinner"></div></div>
</div>

<div id="imgModal" class="img-modal" onclick="this.style.display='none'">
  <img id="imgModalSrc">
</div>

<script>
const talker = '{{ talker }}';
let offset = 0;
let loading = false;
let hasMore = true;

// ── Load messages (newest first, scroll down for older) ──
async function loadMessages() {
  if (loading || !hasMore) return;
  loading = true;

  // 第一次加载清掉初始 spinner
  if (offset === 0) {
    document.getElementById('messageList').innerHTML = '';
  }

  try {
    const r = await fetch(`/api/messages?talker=${encodeURIComponent(talker)}&limit=40&offset=${offset}`);
    const j = await r.json();
    if (!j.ok) return;

    const msgs = j.data;
    if (msgs.length < 40) hasMore = false;

    for (const m of msgs) {
      document.getElementById('messageList').insertAdjacentHTML('beforeend', renderMsg(m));
    }
    offset += msgs.length;

    // 没更多了,去掉转圈
    if (!hasMore && msgs.length === 0 && offset === 0) {
      document.getElementById('messageList').innerHTML = '<div class="empty">暂无消息</div>';
    }
  } catch(e) {}
  loading = false;
}

// ── Render single message ──
function renderMsg(m) {
  const cls = m.isSend ? 'sent' : 'received';
  const time = new Date((m.createTime||0)*1000);
  const timeStr = time.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
  const p = m.parsed || {};
  let inner = '';

  // Images
  if (p.images && p.images.length) {
    for (const img of p.images) {
      const imgUrl = '/media/image?path=' + encodeURIComponent(img.src||'');
      const fullUrl = '/media/image/full?path=' + encodeURIComponent(img.src||'');
      inner += `<div class="msg-image-wrap"><img src="${imgUrl}" onclick="showImg('${fullUrl}')" loading="lazy"></div>`;
    }
  }

  // Voice
  if (p.voice) {
    inner += `🎤 <audio controls src="/media/voice?path=${encodeURIComponent(p.voice.path||'')}"></audio>`;
  }

  // Article cards
  if (p.articles && p.articles.length) {
    for (const a of p.articles) {
      if (a.title || a.description) {
        const tt = escAttr(a.title||''), uu = escAttr(a.url||'');
        inner += '<div style="margin-bottom:4px;">';
        inner += '<a href="' + esc(a.url||'#') + '" target="_blank" class="article-card" style="display:block;">';
        inner += '<div style="display:flex;gap:10px;">';
        if (a.thumbnail) inner += '<img src="' + esc(a.thumbnail) + '" class="card-thumb" onerror="this.style.display=\'none\'">';
        else inner += '<div class="card-thumb" style="display:flex;align-items:center;justify-content:center;font-size:18px;">📄</div>';
        inner += '<div style="flex:1;min-width:0;">';
        inner += '<div class="card-title">' + esc(a.title||a.url||'') + '</div>';
        if (a.description) inner += '<div class="card-desc">' + esc(a.description) + '</div>';
        inner += '</div></div></a>';
        inner += '<div style="display:flex;gap:6px;margin-top:4px;">';
        inner += '<button onclick="event.preventDefault();doSummarize(\'' + tt + '\',\'' + uu + '\')" style="font-size:10px;padding:3px 8px;border:1px solid #3498db;border-radius:4px;background:white;color:#3498db;cursor:pointer;">📝 摘要</button>';
        inner += '<button onclick="event.preventDefault();doLearn(\'' + tt + '\',\'' + uu + '\')" style="font-size:10px;padding:3px 8px;border:1px solid #9b59b6;border-radius:4px;background:white;color:#9b59b6;cursor:pointer;">🔧 学习</button>';
        inner += '</div></div>';
      } else {
        inner += `<div>📄 <a href="${esc(a.url||'#')}" target="_blank">${esc(a.title||a.url)}</a></div>`;
      }
    }
  }

  // Finder
  if (p.finder && p.finder.nickname) {
    const f = p.finder;
    const dm = Math.floor(f.duration/60), ds = f.duration%60;
    inner += `<div class="finder-card" onclick="window.open('${esc(f.video_url||'#')}','_blank')">
      <div class="finder-cover">
        ${f.cover ? `<img src="${esc(f.cover)}" loading="lazy">` : ''}
        <div class="finder-play"></div>
        ${f.duration ? `<div class="finder-dur">${dm}:${String(ds).padStart(2,'0')}</div>` : ''}
      </div>
      <div class="finder-info">
        ${f.avatar ? `<img src="${esc(f.avatar)}" class="finder-avatar">` : ''}
        <div style="flex:1;">
          <div class="finder-nick">@${esc(f.nickname)}</div>
          ${f.description ? `<div class="finder-desc">${esc(f.description)}</div>` : ''}
        </div>
      </div>
    </div>`;
  }

  // Text
  if (p.text && p.text.trim()) {
    inner += `<div>${linkify(esc(p.text))}</div>`;
  }

  // Empty fallback
  if (!inner.trim()) {
    inner = '<span style="font-size:11px;color:#999;">[消息]</span>';
  }

  return `<div class="msg-bubble ${cls}"><div class="bubble">${inner}<div class="time-sub">${timeStr}</div></div></div>`;
}

// ── Utils ──
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function linkify(t) { return t.replace(/(https?:\/\/[^\s<]+)/g,'<a href="$1" target="_blank">$1</a>'); }
function showImg(url) { document.getElementById('imgModalSrc').src=url;document.getElementById('imgModal').style.display='flex'; }

// ── Infinite scroll ──
window.addEventListener('scroll', () => {
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 300) {
    loadMessages();
  }
});

// ── Load chat info ──
async function loadInfo() {
  try {
    const r = await fetch('/api/talker/' + encodeURIComponent(talker));
    const j = await r.json();
    if (j.ok && j.data) {
      document.getElementById('chatName').textContent = j.data.displayname || j.data.nickname || talker;
      const sub = j.data.memberCount ? j.data.memberCount+' 人' : '';
      document.getElementById('chatSub').textContent = sub;
      document.title = j.data.displayname || j.data.nickname || 'Chat';
    }
  } catch(e) {}
}

// ── 日报生成 ──
function showDatePicker() {
  const today = new Date().toISOString().slice(0,10);
  const yesterday = new Date(Date.now()-86400000).toISOString().slice(0,10);
  const date = prompt('选择日报日期 (YYYY-MM-DD):', yesterday);
  if (!date) return;
  generateReport(date);
}

async function generateReport(date) {
  const btn = document.querySelector('.chat-header button');
  btn.textContent = '⏳ 提交中...';
  btn.disabled = true;
  try {
    const r = await fetch('/api/reports/generate', {
      method: 'POST',
      headers: {'Content-Type':'application/json'},
      body: JSON.stringify({chatroom_id: talker, date: date})
    });
    const j = await r.json();
    if (!j.ok) { alert('失败: ' + (j.error || '')); btn.textContent = '📋 生成日报'; btn.disabled = false; return; }

    btn.textContent = '⏳ Hermes 生成中...';
    // 轮询等待
    for (let i = 0; i < 60; i++) {
      await new Promise(r => setTimeout(r, 3000));
      try {
        const sr = await fetch('/api/reports/generate/status');
        const sj = await sr.json();
        if (sj.ok && sj.data && sj.data.content) {
          if (confirm('日报生成完成!前往日报列表查看?')) {
            window.location.href = '/reports';
          }
          btn.textContent = '📋 生成日报';
          btn.disabled = false;
          return;
        }
      } catch(e) {}
    }
    alert('生成超时,请稍后刷新日报列表查看。');
  } catch(e) { alert('错误: ' + e.message); }
  btn.textContent = '📋 生成日报';
  btn.disabled = false;
}

// ── Init ──
function escAttr(s) { return String(s||'').replace(/'/g,"\\'").replace(/\\/g,'\\\\'); }
async function doSummarize(title, url) {
  if (!confirm('为这篇文章生成摘要?')) return;
  try {
    const r = await fetch('/api/articles/summarize', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,url})});
    const j = await r.json();
    if (j.ok) alert('摘要任务已提交!稍后查看日报列表。');
    else alert('失败: '+(j.error||''));
  } catch(e) { alert('错误: '+e.message); }
}

async function doGenImg(title, url) {
  if (!confirm('为这篇文章生成复古铜版画图片?')) return;
  try {
    const r = await fetch('/api/articles/generate-image', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,url})});
    const j = await r.json();
    if (j.ok) alert('图片生成任务已提交!稍后查看日报图片列表。');
    else alert('失败: '+(j.error||''));
  } catch(e) { alert('错误: '+e.message); }
}
async function doLearn(title, url) {
  if (!confirm('学习这篇文章并构建 skill?')) return;
  try {
    const r = await fetch('/api/articles/learn', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,url})});
    const j = await r.json();
    if (j.ok) alert('学习任务已提交!稍后查看日报列表。');
    else alert('失败: '+(j.error||''));
  } catch(e) { alert('错误: '+e.message); }
}
loadInfo();
loadMessages();
</script>
</body>
</html>