CareerBot/templates/admin/resume.html
ln0422 96997daed0 Initial commit: CareerBot full-stack career showcase with AI chatbot
- FastAPI backend with SQLAlchemy ORM and SQLite
- AI chatbot with OpenAI-compatible LLM integration (SSE streaming)
- Admin panel for content management, LLM config, token management
- Anonymous access with 3-question limit, token-based access control
- Recruiter intent detection with admin notification
- Resume generator (JD-based, Markdown to Word export)
- Chinese localized public interface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 20:36:38 +08:00

216 lines
8.4 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CareerBot Admin - Resume Generator</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.resume-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.resume-output {
background: white; border: 1px solid var(--border); border-radius: var(--radius);
padding: 28px; min-height: 400px; max-height: 70vh; overflow-y: auto;
font-size: 14px; line-height: 1.8; white-space: pre-wrap;
}
.resume-output h1 { font-size: 22px; text-align: center; margin: 0 0 12px; border-bottom: 2px solid var(--primary); padding-bottom: 8px; }
.resume-output h2 { font-size: 17px; color: var(--primary-dark); margin: 18px 0 8px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.resume-output h3 { font-size: 15px; margin: 12px 0 6px; }
.resume-output ul, .resume-output ol { padding-left: 20px; margin: 6px 0; }
.resume-output li { margin: 3px 0; }
.resume-output strong { color: var(--text); }
.resume-output p { margin: 6px 0; }
.generating { color: var(--primary); font-style: italic; }
@media (max-width: 1024px) { .resume-layout { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="logo">CareerBot Admin</div>
<nav>
<a href="/admin/dashboard"><span class="icon">&#9632;</span> Dashboard</a>
<a href="/admin/profile"><span class="icon">&#9786;</span> Profile</a>
<a href="/admin/education"><span class="icon">&#9734;</span> Education</a>
<a href="/admin/experience"><span class="icon">&#9998;</span> Experience</a>
<a href="/admin/skills"><span class="icon">&#9733;</span> Skills</a>
<a href="/admin/tokens"><span class="icon">&#9911;</span> Access Tokens</a>
<a href="/admin/messages"><span class="icon">&#9993;</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
<a href="/admin/llm-config"><span class="icon">&#9881;</span> LLM Config</a>
<a href="/admin/resume"><span class="icon">&#9997;</span> Resume Gen</a>
<a href="#" onclick="adminLogout()"><span class="icon">&#8594;</span> Logout</a>
</nav>
</aside>
<main class="admin-content">
<div class="admin-header">
<h2>Resume Generator</h2>
</div>
<div class="resume-layout">
<!-- Left: JD Input -->
<div>
<div class="admin-card">
<h3 style="margin-bottom:16px;">Upload or Paste JD</h3>
<div class="form-group">
<label>Upload JD File (.txt, .pdf, .docx)</label>
<input type="file" id="jd-file" accept=".txt,.pdf,.docx,.doc">
</div>
<div class="form-group">
<label>Or paste JD text below</label>
<textarea id="jd-text" rows="12" placeholder="Paste the job description here..."></textarea>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary" onclick="generateResume()" id="btn-generate">Generate Resume</button>
<button class="btn btn-success" onclick="downloadResume()" id="btn-download" disabled>Download .docx</button>
</div>
</div>
</div>
<!-- Right: Generated Resume -->
<div>
<div class="admin-card">
<h3 style="margin-bottom:16px;">Generated Resume</h3>
<div class="resume-output" id="resume-output">
<p style="color:var(--text-light);">Upload a JD and click "Generate Resume" to get started.</p>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/static/js/admin.js"></script>
<script>
let resumeMarkdown = '';
async function generateResume() {
const fileInput = document.getElementById('jd-file');
const jdText = document.getElementById('jd-text').value.trim();
const file = fileInput.files[0] || null;
if (!jdText && !file) {
showToast('Please upload a JD file or paste JD text', 'error');
return;
}
const btn = document.getElementById('btn-generate');
const output = document.getElementById('resume-output');
btn.disabled = true;
btn.textContent = 'Generating...';
document.getElementById('btn-download').disabled = true;
output.innerHTML = '<p class="generating">Generating tailored resume...</p>';
resumeMarkdown = '';
const formData = new FormData();
formData.append('jd_text', jdText);
if (file) formData.append('file', file);
try {
const resp = await fetch('/api/admin/resume/generate', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('admin_token') },
body: formData,
});
if (resp.status === 401) { window.location.href = '/admin/login'; return; }
output.innerHTML = '';
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const dataStr = trimmed.slice(5).trim();
if (!dataStr) continue;
try {
const data = JSON.parse(dataStr);
if (data.done) continue;
if (data.content) {
resumeMarkdown += data.content;
output.innerHTML = renderMarkdown(resumeMarkdown);
output.scrollTop = output.scrollHeight;
}
} catch (e) {}
}
}
if (resumeMarkdown) {
document.getElementById('btn-download').disabled = false;
}
} catch (e) {
output.innerHTML = '<p style="color:var(--danger)">Generation failed: ' + e.message + '</p>';
}
btn.disabled = false;
btn.textContent = 'Generate Resume';
}
async function downloadResume() {
if (!resumeMarkdown) return;
const formData = new FormData();
formData.append('content', resumeMarkdown);
try {
const resp = await fetch('/api/admin/resume/download', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('admin_token') },
body: formData,
});
if (!resp.ok) { showToast('Download failed', 'error'); return; }
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'resume.docx';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showToast('Resume downloaded!');
} catch (e) {
showToast('Download error: ' + e.message, 'error');
}
}
function renderMarkdown(md) {
// Simple markdown to HTML
let html = md
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// Headers
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Horizontal rule
.replace(/^---$/gm, '<hr>')
// Bullet list
.replace(/^[-*] (.+)$/gm, '<li>$1</li>')
// Numbered list
.replace(/^\d+\. (.+)$/gm, '<li>$1</li>')
// Paragraphs - convert double newlines
.replace(/\n\n/g, '</p><p>')
// Single newlines
.replace(/\n/g, '<br>');
// Wrap consecutive <li> in <ul>
html = html.replace(/(<li>.*?<\/li>(?:<br>)?)+/g, function(match) {
return '<ul>' + match.replace(/<br>/g, '') + '</ul>';
});
return '<p>' + html + '</p>';
}
</script>
</body>
</html>