- Add BASE_PATH config, include all routers with prefix
- Inject {{ base }} Jinja2 global for all template URLs
- Add window.BASE_PATH for static JS files
- Update Nginx to proxy /careerbot/ path
- Add OPS_MANUAL.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
218 lines
8.6 KiB
HTML
218 lines
8.6 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="{{ base }}/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="{{ base }}/admin/dashboard"><span class="icon">■</span> Dashboard</a>
|
|
<a href="{{ base }}/admin/profile"><span class="icon">☺</span> Profile</a>
|
|
<a href="{{ base }}/admin/education"><span class="icon">☆</span> Education</a>
|
|
<a href="{{ base }}/admin/experience"><span class="icon">✎</span> Experience</a>
|
|
<a href="{{ base }}/admin/skills"><span class="icon">★</span> Skills</a>
|
|
<a href="{{ base }}/admin/tokens"><span class="icon">⚷</span> Access Tokens</a>
|
|
<a href="{{ base }}/admin/messages"><span class="icon">✉</span> Messages <span class="nav-badge" id="nav-badge" style="display:none;"></span></a>
|
|
<a href="{{ base }}/admin/llm-config"><span class="icon">⚙</span> LLM Config</a>
|
|
<a href="{{ base }}/admin/resume"><span class="icon">✍</span> Resume Gen</a>
|
|
<a href="#" onclick="adminLogout()"><span class="icon">→</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>window.BASE_PATH = "{{ base }}";</script>
|
|
<script src="{{ base }}/static/js/admin.js"></script>
|
|
<script>
|
|
const BASE = window.BASE_PATH || '';
|
|
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(BASE + '/api/admin/resume/generate', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('admin_token') },
|
|
body: formData,
|
|
});
|
|
|
|
if (resp.status === 401) { window.location.href = BASE + '/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(BASE + '/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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
// 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>
|