- 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>
476 lines
18 KiB
JavaScript
476 lines
18 KiB
JavaScript
// ── Admin Panel JavaScript ──
|
|
|
|
function authHeaders() {
|
|
return { 'Authorization': 'Bearer ' + localStorage.getItem('admin_token') };
|
|
}
|
|
|
|
async function apiGet(url) {
|
|
const resp = await fetch(url, { headers: authHeaders() });
|
|
if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
|
|
return resp.json();
|
|
}
|
|
|
|
async function apiPost(url, data) {
|
|
const resp = await fetch(url, {
|
|
method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
|
|
return resp.json();
|
|
}
|
|
|
|
async function apiPut(url, data) {
|
|
const resp = await fetch(url, {
|
|
method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
|
|
return resp.json();
|
|
}
|
|
|
|
async function apiDelete(url) {
|
|
const resp = await fetch(url, { method: 'DELETE', headers: authHeaders() });
|
|
if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
|
|
return resp.json();
|
|
}
|
|
|
|
function showToast(msg, type = 'success') {
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast toast-${type}`;
|
|
toast.textContent = msg;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 3000);
|
|
}
|
|
|
|
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
|
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
|
|
|
// ── Page Detection & Init ──
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const path = window.location.pathname;
|
|
if (path.includes('/admin/dashboard')) loadDashboard();
|
|
else if (path.includes('/admin/profile')) loadProfile();
|
|
else if (path.includes('/admin/education')) loadEducation();
|
|
else if (path.includes('/admin/experience')) loadExperience();
|
|
else if (path.includes('/admin/skills')) loadSkills();
|
|
else if (path.includes('/admin/tokens')) loadTokens();
|
|
else if (path.includes('/admin/messages')) loadMessages();
|
|
else if (path.includes('/admin/llm-config')) loadLLMConfig();
|
|
|
|
// Highlight active nav
|
|
document.querySelectorAll('.admin-sidebar nav a').forEach(a => {
|
|
if (a.getAttribute('href') === path) a.classList.add('active');
|
|
});
|
|
|
|
// Load unread badge on all admin pages
|
|
loadUnreadBadge();
|
|
});
|
|
|
|
function adminLogout() {
|
|
localStorage.removeItem('admin_token');
|
|
window.location.href = '/admin/login';
|
|
}
|
|
|
|
// ── Dashboard ──
|
|
async function loadDashboard() {
|
|
const [skills, edu, exp, tokens] = await Promise.all([
|
|
apiGet('/api/admin/skills'),
|
|
apiGet('/api/admin/education'),
|
|
apiGet('/api/admin/experience'),
|
|
apiGet('/api/admin/tokens'),
|
|
]);
|
|
document.getElementById('stat-skills').textContent = skills ? skills.length : 0;
|
|
document.getElementById('stat-education').textContent = edu ? edu.length : 0;
|
|
document.getElementById('stat-experience').textContent = exp ? exp.length : 0;
|
|
document.getElementById('stat-tokens').textContent = tokens ? tokens.filter(t => t.is_active).length : 0;
|
|
}
|
|
|
|
// ── Profile ──
|
|
async function loadProfile() {
|
|
const data = await apiGet('/api/admin/profile');
|
|
if (!data) return;
|
|
['name','phone','location','birthday','party','education_level','email','self_summary'].forEach(f => {
|
|
const el = document.getElementById('field-' + f);
|
|
if (el) el.value = data[f] || '';
|
|
});
|
|
if (data.photo_url) {
|
|
document.getElementById('photo-preview').innerHTML = `<img src="${data.photo_url}" style="max-width:120px;border-radius:8px;">`;
|
|
}
|
|
}
|
|
|
|
async function saveProfile() {
|
|
const fields = {};
|
|
['name','phone','location','birthday','party','education_level','email','self_summary'].forEach(f => {
|
|
const el = document.getElementById('field-' + f);
|
|
if (el) fields[f] = el.value;
|
|
});
|
|
const result = await apiPut('/api/admin/profile', fields);
|
|
if (result) showToast('Profile saved');
|
|
}
|
|
|
|
async function uploadPhoto() {
|
|
const input = document.getElementById('photo-input');
|
|
if (!input.files[0]) return;
|
|
const formData = new FormData();
|
|
formData.append('file', input.files[0]);
|
|
const resp = await fetch('/api/admin/profile/photo', {
|
|
method: 'POST', headers: authHeaders(), body: formData,
|
|
});
|
|
if (resp.status === 401) { window.location.href = '/admin/login'; return; }
|
|
const data = await resp.json();
|
|
if (data.photo_url) {
|
|
document.getElementById('photo-preview').innerHTML = `<img src="${data.photo_url}" style="max-width:120px;border-radius:8px;">`;
|
|
showToast('Photo uploaded');
|
|
}
|
|
}
|
|
|
|
// ── Education CRUD ──
|
|
let educationList = [];
|
|
async function loadEducation() {
|
|
educationList = await apiGet('/api/admin/education') || [];
|
|
renderEducation();
|
|
}
|
|
function renderEducation() {
|
|
const tbody = document.getElementById('education-tbody');
|
|
tbody.innerHTML = educationList.map(e => `
|
|
<tr>
|
|
<td>${e.start_date} - ${e.end_date}</td>
|
|
<td>${e.school}</td>
|
|
<td>${e.major}</td>
|
|
<td>${e.degree}</td>
|
|
<td class="actions">
|
|
<button class="btn btn-sm" onclick="editEducation(${e.id})">Edit</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteEducation(${e.id})">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
function showEducationModal(id = null) {
|
|
const item = id ? educationList.find(e => e.id === id) : {};
|
|
document.getElementById('edu-id').value = id || '';
|
|
['start_date','end_date','school','major','degree','details','sort_order'].forEach(f => {
|
|
document.getElementById('edu-' + f).value = (item && item[f]) || '';
|
|
});
|
|
openModal('education-modal');
|
|
}
|
|
function editEducation(id) { showEducationModal(id); }
|
|
async function saveEducation() {
|
|
const id = document.getElementById('edu-id').value;
|
|
const data = {};
|
|
['start_date','end_date','school','major','degree','details','sort_order'].forEach(f => {
|
|
const val = document.getElementById('edu-' + f).value;
|
|
data[f] = f === 'sort_order' ? parseInt(val) || 0 : val;
|
|
});
|
|
if (id) await apiPut(`/api/admin/education/${id}`, data);
|
|
else await apiPost('/api/admin/education', data);
|
|
closeModal('education-modal');
|
|
showToast('Saved');
|
|
loadEducation();
|
|
}
|
|
async function deleteEducation(id) {
|
|
if (!confirm('Delete this entry?')) return;
|
|
await apiDelete(`/api/admin/education/${id}`);
|
|
showToast('Deleted');
|
|
loadEducation();
|
|
}
|
|
|
|
// ── Experience CRUD ──
|
|
let experienceList = [];
|
|
async function loadExperience() {
|
|
experienceList = await apiGet('/api/admin/experience') || [];
|
|
renderExperience();
|
|
}
|
|
function renderExperience() {
|
|
const tbody = document.getElementById('experience-tbody');
|
|
tbody.innerHTML = experienceList.map(e => `
|
|
<tr>
|
|
<td>${e.start_date} - ${e.end_date}</td>
|
|
<td>${e.company}</td>
|
|
<td>${e.position}</td>
|
|
<td class="actions">
|
|
<button class="btn btn-sm" onclick="editExperience(${e.id})">Edit</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteExperience(${e.id})">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
function showExperienceModal(id = null) {
|
|
const item = id ? experienceList.find(e => e.id === id) : {};
|
|
document.getElementById('exp-id').value = id || '';
|
|
['start_date','end_date','company','position','company_intro','responsibilities','achievements','sort_order'].forEach(f => {
|
|
document.getElementById('exp-' + f).value = (item && item[f]) || '';
|
|
});
|
|
openModal('experience-modal');
|
|
}
|
|
function editExperience(id) { showExperienceModal(id); }
|
|
async function saveExperience() {
|
|
const id = document.getElementById('exp-id').value;
|
|
const data = {};
|
|
['start_date','end_date','company','position','company_intro','responsibilities','achievements','sort_order'].forEach(f => {
|
|
const val = document.getElementById('exp-' + f).value;
|
|
data[f] = f === 'sort_order' ? parseInt(val) || 0 : val;
|
|
});
|
|
if (id) await apiPut(`/api/admin/experience/${id}`, data);
|
|
else await apiPost('/api/admin/experience', data);
|
|
closeModal('experience-modal');
|
|
showToast('Saved');
|
|
loadExperience();
|
|
}
|
|
async function deleteExperience(id) {
|
|
if (!confirm('Delete this entry?')) return;
|
|
await apiDelete(`/api/admin/experience/${id}`);
|
|
showToast('Deleted');
|
|
loadExperience();
|
|
}
|
|
|
|
// ── Skills CRUD ──
|
|
let skillsList = [];
|
|
async function loadSkills() {
|
|
skillsList = await apiGet('/api/admin/skills') || [];
|
|
renderSkills();
|
|
}
|
|
function renderSkills() {
|
|
const tbody = document.getElementById('skills-tbody');
|
|
tbody.innerHTML = skillsList.map(s => `
|
|
<tr>
|
|
<td>${s.category}</td>
|
|
<td>${s.content.substring(0, 60)}${s.content.length > 60 ? '...' : ''}</td>
|
|
<td class="actions">
|
|
<button class="btn btn-sm" onclick="editSkill(${s.id})">Edit</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteSkill(${s.id})">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
function showSkillModal(id = null) {
|
|
const item = id ? skillsList.find(s => s.id === id) : {};
|
|
document.getElementById('skill-id').value = id || '';
|
|
['category','content','sort_order'].forEach(f => {
|
|
document.getElementById('skill-' + f).value = (item && item[f]) || '';
|
|
});
|
|
openModal('skill-modal');
|
|
}
|
|
function editSkill(id) { showSkillModal(id); }
|
|
async function saveSkill() {
|
|
const id = document.getElementById('skill-id').value;
|
|
const data = {};
|
|
['category','content','sort_order'].forEach(f => {
|
|
const val = document.getElementById('skill-' + f).value;
|
|
data[f] = f === 'sort_order' ? parseInt(val) || 0 : val;
|
|
});
|
|
if (id) await apiPut(`/api/admin/skills/${id}`, data);
|
|
else await apiPost('/api/admin/skills', data);
|
|
closeModal('skill-modal');
|
|
showToast('Saved');
|
|
loadSkills();
|
|
}
|
|
async function deleteSkill(id) {
|
|
if (!confirm('Delete this entry?')) return;
|
|
await apiDelete(`/api/admin/skills/${id}`);
|
|
showToast('Deleted');
|
|
loadSkills();
|
|
}
|
|
|
|
// ── Access Tokens ──
|
|
let tokensList = [];
|
|
async function loadTokens() {
|
|
tokensList = await apiGet('/api/admin/tokens') || [];
|
|
renderTokens();
|
|
}
|
|
function renderTokens() {
|
|
const tbody = document.getElementById('tokens-tbody');
|
|
tbody.innerHTML = tokensList.map(t => `
|
|
<tr>
|
|
<td><code>${t.token.substring(0, 12)}...</code></td>
|
|
<td>${t.note || '-'}</td>
|
|
<td>${t.used_questions || 0} / ${t.max_questions || 10}</td>
|
|
<td><span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">${t.is_active ? 'Active' : 'Inactive'}</span></td>
|
|
<td>${t.created_at ? new Date(t.created_at).toLocaleString() : '-'}</td>
|
|
<td class="actions">
|
|
<button class="btn btn-sm" onclick="copyToken('${t.token}')">Copy</button>
|
|
<button class="btn btn-sm" onclick="editToken(${t.id})">Edit</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteToken(${t.id})">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
async function generateToken() {
|
|
const note = prompt('Token note (optional):') || '';
|
|
const result = await apiPost('/api/admin/tokens', { note });
|
|
if (result && result.token) {
|
|
showToast('Token created: ' + result.token);
|
|
navigator.clipboard.writeText(result.token).catch(() => {});
|
|
loadTokens();
|
|
}
|
|
}
|
|
function copyToken(token) {
|
|
navigator.clipboard.writeText(token).then(() => showToast('Copied!')).catch(() => showToast('Copy failed', 'error'));
|
|
}
|
|
function editToken(id) {
|
|
const t = tokensList.find(x => x.id === id);
|
|
if (!t) return;
|
|
document.getElementById('token-edit-id').value = id;
|
|
document.getElementById('token-edit-note').value = t.note || '';
|
|
document.getElementById('token-edit-max').value = t.max_questions || 10;
|
|
document.getElementById('token-edit-used').value = t.used_questions || 0;
|
|
document.getElementById('token-edit-active').value = t.is_active ? 'true' : 'false';
|
|
openModal('token-modal');
|
|
}
|
|
async function saveToken() {
|
|
const id = document.getElementById('token-edit-id').value;
|
|
const data = {
|
|
note: document.getElementById('token-edit-note').value,
|
|
max_questions: parseInt(document.getElementById('token-edit-max').value) || 10,
|
|
used_questions: parseInt(document.getElementById('token-edit-used').value) || 0,
|
|
is_active: document.getElementById('token-edit-active').value === 'true',
|
|
};
|
|
await apiPut(`/api/admin/tokens/${id}`, data);
|
|
closeModal('token-modal');
|
|
showToast('Token updated');
|
|
loadTokens();
|
|
}
|
|
async function deleteToken(id) {
|
|
if (!confirm('Delete this token?')) return;
|
|
await apiDelete(`/api/admin/tokens/${id}`);
|
|
showToast('Deleted');
|
|
loadTokens();
|
|
}
|
|
|
|
// ── LLM Config ──
|
|
async function loadLLMConfig() {
|
|
const data = await apiGet('/api/admin/llm-config');
|
|
if (!data) return;
|
|
['api_url','api_key','model_name','system_prompt'].forEach(f => {
|
|
const el = document.getElementById('llm-' + f);
|
|
if (el) el.value = data[f] || '';
|
|
});
|
|
const mt = document.getElementById('llm-max_tokens');
|
|
if (mt) mt.value = data.max_tokens || 2048;
|
|
const mq = document.getElementById('llm-max_questions');
|
|
if (mq) mq.value = data.max_questions_per_session || 10;
|
|
const temp = document.getElementById('llm-temperature');
|
|
if (temp) {
|
|
temp.value = data.temperature || 0.7;
|
|
document.getElementById('temp-value').textContent = temp.value;
|
|
}
|
|
}
|
|
|
|
function setPreset(name) {
|
|
const presets = {
|
|
'qwen': { url: 'https://dashscope.aliyuncs.com/compatible-mode', model: 'qwen-plus' },
|
|
'deepseek': { url: 'https://api.deepseek.com', model: 'deepseek-chat' },
|
|
'openai': { url: 'https://api.openai.com', model: 'gpt-4o' },
|
|
'zhipu': { url: 'https://open.bigmodel.cn/api/paas', model: 'glm-4-flash' },
|
|
};
|
|
const p = presets[name];
|
|
if (p) {
|
|
document.getElementById('llm-api_url').value = p.url;
|
|
document.getElementById('llm-model_name').value = p.model;
|
|
}
|
|
}
|
|
|
|
async function saveLLMConfig() {
|
|
const data = {
|
|
api_url: document.getElementById('llm-api_url').value,
|
|
api_key: document.getElementById('llm-api_key').value,
|
|
model_name: document.getElementById('llm-model_name').value,
|
|
max_tokens: parseInt(document.getElementById('llm-max_tokens').value) || 2048,
|
|
temperature: parseFloat(document.getElementById('llm-temperature').value) || 0.7,
|
|
max_questions_per_session: parseInt(document.getElementById('llm-max_questions').value) || 10,
|
|
system_prompt: document.getElementById('llm-system_prompt').value,
|
|
};
|
|
const result = await apiPut('/api/admin/llm-config', data);
|
|
if (result) showToast('LLM config saved');
|
|
}
|
|
|
|
async function testLLMConnection() {
|
|
// Auto-save before testing
|
|
await saveLLMConfig();
|
|
showToast('Testing connection...');
|
|
try {
|
|
const result = await apiPost('/api/admin/llm-config/test', {});
|
|
if (result && result.result) {
|
|
showToast('Response: ' + result.result.substring(0, 100), 'success');
|
|
} else {
|
|
showToast('No response from LLM', 'error');
|
|
}
|
|
} catch (e) {
|
|
showToast('Test failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ── Recruiter Messages ──
|
|
|
|
async function loadUnreadBadge() {
|
|
try {
|
|
const data = await apiGet('/api/admin/messages/unread-count');
|
|
if (!data) return;
|
|
const badges = document.querySelectorAll('.nav-badge');
|
|
badges.forEach(badge => {
|
|
if (data.count > 0) {
|
|
badge.textContent = data.count;
|
|
badge.style.display = 'inline-block';
|
|
} else {
|
|
badge.style.display = 'none';
|
|
}
|
|
});
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
let messagesList = [];
|
|
async function loadMessages() {
|
|
messagesList = await apiGet('/api/admin/messages') || [];
|
|
renderMessages();
|
|
}
|
|
|
|
function renderMessages() {
|
|
const container = document.getElementById('messages-list');
|
|
const noMsg = document.getElementById('no-messages');
|
|
if (!messagesList.length) {
|
|
container.innerHTML = '';
|
|
noMsg.style.display = 'block';
|
|
return;
|
|
}
|
|
noMsg.style.display = 'none';
|
|
container.innerHTML = messagesList.map(m => `
|
|
<div class="message-card ${m.is_read ? '' : 'message-unread'}" style="padding:16px;margin-bottom:12px;border-radius:8px;border:1px solid ${m.is_read ? '#e2e8f0' : '#3b82f6'};background:${m.is_read ? '#fff' : '#eff6ff'};">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
|
<div>
|
|
<strong>${m.visitor_label || 'Unknown'}</strong>
|
|
${m.intent ? `<span class="badge badge-active" style="margin-left:8px;">${m.intent}</span>` : ''}
|
|
${!m.is_read ? '<span style="margin-left:8px;color:#3b82f6;font-size:12px;font-weight:600;">NEW</span>' : ''}
|
|
</div>
|
|
<span style="font-size:12px;color:#94a3b8;">${m.created_at ? new Date(m.created_at).toLocaleString() : ''}</span>
|
|
</div>
|
|
${m.company ? `<div style="font-size:13px;color:#475569;margin-bottom:4px;"><strong>Company:</strong> ${m.company}</div>` : ''}
|
|
${m.contact ? `<div style="font-size:13px;color:#475569;margin-bottom:4px;"><strong>Contact:</strong> ${m.contact}</div>` : ''}
|
|
${m.summary ? `<div style="font-size:13px;color:#334155;margin-bottom:8px;">${m.summary}</div>` : ''}
|
|
<div class="actions" style="display:flex;gap:8px;">
|
|
${!m.is_read ? `<button class="btn btn-sm" onclick="markRead(${m.id})">Mark Read</button>` : ''}
|
|
<button class="btn btn-sm btn-danger" onclick="deleteMessage(${m.id})">Delete</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function markRead(id) {
|
|
await apiPut(`/api/admin/messages/${id}/read`, {});
|
|
loadMessages();
|
|
loadUnreadBadge();
|
|
}
|
|
|
|
async function markAllRead() {
|
|
await apiPut('/api/admin/messages/read-all', {});
|
|
showToast('All marked as read');
|
|
loadMessages();
|
|
loadUnreadBadge();
|
|
}
|
|
|
|
async function deleteMessage(id) {
|
|
if (!confirm('Delete this message?')) return;
|
|
await apiDelete(`/api/admin/messages/${id}`);
|
|
showToast('Deleted');
|
|
loadMessages();
|
|
loadUnreadBadge();
|
|
}
|