CareerBot/app/static/js/admin.js
ln0422 501f8985ec Add /careerbot base path for www.ityb.me/careerbot deployment
- 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>
2026-04-07 22:07:34 +08:00

477 lines
19 KiB
JavaScript

// ── Admin Panel JavaScript ──
const BASE_PATH = window.BASE_PATH || '';
function authHeaders() {
return { 'Authorization': 'Bearer ' + localStorage.getItem('admin_token') };
}
async function apiGet(url) {
const resp = await fetch(BASE_PATH + url, { headers: authHeaders() });
if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return null; }
return resp.json();
}
async function apiPost(url, data) {
const resp = await fetch(BASE_PATH + url, {
method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return null; }
return resp.json();
}
async function apiPut(url, data) {
const resp = await fetch(BASE_PATH + url, {
method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return null; }
return resp.json();
}
async function apiDelete(url) {
const resp = await fetch(BASE_PATH + url, { method: 'DELETE', headers: authHeaders() });
if (resp.status === 401) { window.location.href = BASE_PATH + '/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 = BASE_PATH + '/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(BASE_PATH + '/api/admin/profile/photo', {
method: 'POST', headers: authHeaders(), body: formData,
});
if (resp.status === 401) { window.location.href = BASE_PATH + '/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();
}