439 lines
16 KiB
JavaScript
439 lines
16 KiB
JavaScript
const $ = (sel, root = document) => root.querySelector(sel);
|
|
const root = $('#app');
|
|
|
|
const TOKEN_KEY = 'andishkade_panel_token';
|
|
const getToken = () => localStorage.getItem(TOKEN_KEY);
|
|
const setToken = (t) => localStorage.setItem(TOKEN_KEY, t);
|
|
const clearToken = () => localStorage.removeItem(TOKEN_KEY);
|
|
|
|
const CATEGORIES = ['بازار جهانی', 'سیاستگذاری', 'انرژی و ESG', 'صادرات', 'تولید داخلی', 'ریسک و بحران'];
|
|
const TYPES = [
|
|
{ value: 'quarterly', label: 'فصلی' },
|
|
{ value: 'flash', label: 'فلش' },
|
|
{ value: 'risk', label: 'هشدار ریسک' },
|
|
{ value: 'free', label: 'رایگان' },
|
|
{ value: 'special', label: 'ویژه' },
|
|
];
|
|
|
|
async function api(path, { method = 'GET', body, isForm = false } = {}) {
|
|
const headers = {};
|
|
const token = getToken();
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
if (body && !isForm) headers['Content-Type'] = 'application/json';
|
|
const res = await fetch(path, {
|
|
method,
|
|
headers,
|
|
body: isForm ? body : (body ? JSON.stringify(body) : undefined),
|
|
});
|
|
if (res.status === 401) {
|
|
clearToken();
|
|
renderLogin('نیاز به ورود مجدد است');
|
|
throw new Error('unauthorized');
|
|
}
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(text || res.statusText);
|
|
}
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
function renderLogin(errorMsg = '') {
|
|
root.innerHTML = `
|
|
<div class="auth-wrap">
|
|
<form class="card auth-card" id="loginForm">
|
|
<h1>ورود به پنل مدیریت</h1>
|
|
<p class="muted">اندیشکده فولاد</p>
|
|
<label>نام کاربری
|
|
<input name="username" autocomplete="username" required />
|
|
</label>
|
|
<label>رمز عبور
|
|
<input name="password" type="password" autocomplete="current-password" required />
|
|
</label>
|
|
${errorMsg ? `<div class="error">${errorMsg}</div>` : ''}
|
|
<button type="submit">ورود</button>
|
|
</form>
|
|
</div>
|
|
`;
|
|
$('#loginForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
try {
|
|
const data = await api('/api/auth/login', {
|
|
method: 'POST',
|
|
body: { username: fd.get('username'), password: fd.get('password') },
|
|
});
|
|
setToken(data.token);
|
|
renderList();
|
|
} catch {
|
|
renderLogin('نام کاربری یا رمز عبور اشتباه است');
|
|
}
|
|
});
|
|
}
|
|
|
|
function topbarHtml(active, primaryLabel) {
|
|
return `
|
|
<header class="topbar">
|
|
<div class="brand">پنل اندیشکده فولاد</div>
|
|
<nav class="tabs">
|
|
<button data-tab="articles" class="${active==='articles'?'tab-active':''}">مطالب</button>
|
|
<button data-tab="risks" class="${active==='risks'?'tab-active':''}">ریسکها</button>
|
|
</nav>
|
|
<div class="actions">
|
|
<button id="newBtn" class="primary">${primaryLabel}</button>
|
|
<button id="logoutBtn" class="ghost">خروج</button>
|
|
</div>
|
|
</header>
|
|
`;
|
|
}
|
|
function wireTabs() {
|
|
document.querySelectorAll('.tabs button').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
if (btn.dataset.tab === 'articles') renderList();
|
|
else if (btn.dataset.tab === 'risks') renderRisksList();
|
|
});
|
|
});
|
|
$('#logoutBtn').addEventListener('click', () => { clearToken(); renderLogin(); });
|
|
}
|
|
|
|
async function renderList() {
|
|
root.innerHTML = `
|
|
${topbarHtml('articles', '+ مطلب جدید')}
|
|
<main class="page">
|
|
<h2>مطالب</h2>
|
|
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
|
|
</main>
|
|
`;
|
|
wireTabs();
|
|
$('#newBtn').addEventListener('click', () => renderEditor(null));
|
|
|
|
try {
|
|
const items = await api('/api/articles?limit=200');
|
|
if (!items.length) {
|
|
$('#list').innerHTML = `<p class="muted">هنوز مطلبی ثبت نشده است.</p>`;
|
|
return;
|
|
}
|
|
$('#list').innerHTML = items.map((a) => `
|
|
<article class="row" data-id="${a.id}">
|
|
<div class="row-cover" style="background-image:url('${a.coverImage || ''}')"></div>
|
|
<div class="row-main">
|
|
<div class="row-meta">
|
|
${a.featured ? '<span class="pill red">ویژه</span>' : ''}
|
|
${a.category ? `<span class="pill">${a.category}</span>` : ''}
|
|
${a.publishDate ? `<span class="muted">${a.publishDate}</span>` : ''}
|
|
</div>
|
|
<h3>${a.title}</h3>
|
|
<p class="muted clamp-2">${a.summary || ''}</p>
|
|
</div>
|
|
<div class="row-actions">
|
|
<button data-action="edit">ویرایش</button>
|
|
<button data-action="delete" class="danger">حذف</button>
|
|
</div>
|
|
</article>
|
|
`).join('');
|
|
|
|
$('#list').addEventListener('click', async (e) => {
|
|
const btn = e.target.closest('button[data-action]');
|
|
if (!btn) return;
|
|
const row = btn.closest('.row');
|
|
const id = row.dataset.id;
|
|
if (btn.dataset.action === 'edit') {
|
|
const a = await api(`/api/articles/${id}`);
|
|
renderEditor(a);
|
|
} else if (btn.dataset.action === 'delete') {
|
|
if (!confirm('این مطلب حذف شود؟')) return;
|
|
await api(`/api/articles/${id}`, { method: 'DELETE' });
|
|
renderList();
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
$('#list').innerHTML = `<p class="error">خطا در بارگذاری مطالب.</p>`;
|
|
}
|
|
}
|
|
|
|
function renderEditor(article) {
|
|
const a = article || {
|
|
title: '', category: '', type: '', author: '', authorRole: '', authorInitial: '',
|
|
publishDate: '', pages: 0, price: 0, isFree: true, summary: '', body: '',
|
|
coverImage: '', tags: [], featured: false,
|
|
};
|
|
const isEdit = !!article;
|
|
|
|
root.innerHTML = `
|
|
<header class="topbar">
|
|
<div class="brand">${isEdit ? 'ویرایش مطلب' : 'مطلب جدید'}</div>
|
|
<div class="actions">
|
|
<button id="backBtn" class="ghost">← بازگشت</button>
|
|
</div>
|
|
</header>
|
|
<main class="page editor">
|
|
<form id="editorForm" class="grid">
|
|
<div class="col span-2">
|
|
<label>عنوان
|
|
<input name="title" value="${escapeAttr(a.title)}" required />
|
|
</label>
|
|
</div>
|
|
|
|
<label>دستهبندی
|
|
<select name="category">
|
|
<option value="">—</option>
|
|
${CATEGORIES.map(c => `<option value="${c}" ${c===a.category?'selected':''}>${c}</option>`).join('')}
|
|
</select>
|
|
</label>
|
|
|
|
<label>نوع
|
|
<select name="type">
|
|
<option value="">—</option>
|
|
${TYPES.map(t => `<option value="${t.value}" ${t.value===a.type?'selected':''}>${t.label}</option>`).join('')}
|
|
</select>
|
|
</label>
|
|
|
|
<label>نویسنده
|
|
<input name="author" value="${escapeAttr(a.author)}" />
|
|
</label>
|
|
<label>سمت نویسنده
|
|
<input name="authorRole" value="${escapeAttr(a.authorRole)}" />
|
|
</label>
|
|
<label>حرف ابتدای نام (برای آواتار)
|
|
<input name="authorInitial" maxlength="2" value="${escapeAttr(a.authorInitial)}" />
|
|
</label>
|
|
<label>تاریخ انتشار (شمسی، مثلاً «بهمن ۱۴۰۳»)
|
|
<input name="publishDate" value="${escapeAttr(a.publishDate)}" />
|
|
</label>
|
|
<label>تعداد صفحات
|
|
<input name="pages" type="number" min="0" value="${a.pages || 0}" />
|
|
</label>
|
|
<label>قیمت (ریال) — اگر رایگان است صفر
|
|
<input name="price" type="number" min="0" value="${a.price || 0}" />
|
|
</label>
|
|
|
|
<label class="checkbox">
|
|
<input type="checkbox" name="isFree" ${a.isFree?'checked':''} />
|
|
<span>رایگان است</span>
|
|
</label>
|
|
<label class="checkbox">
|
|
<input type="checkbox" name="featured" ${a.featured?'checked':''} />
|
|
<span>بهعنوان مطلب ویژه نمایش داده شود</span>
|
|
</label>
|
|
|
|
<div class="col span-2">
|
|
<label>برچسبها (با ویرگول فارسی یا انگلیسی جدا کنید)
|
|
<input name="tags" value="${escapeAttr((a.tags || []).join('، '))}" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="col span-2">
|
|
<label>عکس کاور
|
|
<div class="cover-row">
|
|
<input type="file" id="coverFile" accept="image/*" />
|
|
<input type="hidden" name="coverImage" value="${escapeAttr(a.coverImage)}" />
|
|
<img id="coverPreview" alt="" src="${escapeAttr(a.coverImage)}" />
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="col span-2">
|
|
<label>خلاصه
|
|
<textarea name="summary" rows="3">${escapeHtml(a.summary)}</textarea>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="col span-2">
|
|
<label>متن کامل مطلب
|
|
<textarea name="body" rows="14">${escapeHtml(a.body)}</textarea>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="col span-2 row-end">
|
|
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : 'ایجاد و انتشار'}</button>
|
|
</div>
|
|
</form>
|
|
</main>
|
|
`;
|
|
|
|
$('#backBtn').addEventListener('click', () => renderList());
|
|
|
|
$('#coverFile').addEventListener('change', async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
try {
|
|
const { url } = await api('/api/uploads', { method: 'POST', body: fd, isForm: true });
|
|
$('input[name="coverImage"]').value = url;
|
|
$('#coverPreview').src = url;
|
|
} catch (err) {
|
|
alert('آپلود ناموفق بود: ' + err.message);
|
|
}
|
|
});
|
|
|
|
$('#editorForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
const tagsRaw = String(fd.get('tags') || '').split(/[,،]/).map(s => s.trim()).filter(Boolean);
|
|
const payload = {
|
|
title: fd.get('title'),
|
|
category: fd.get('category') || null,
|
|
type: fd.get('type') || null,
|
|
author: fd.get('author') || null,
|
|
authorRole: fd.get('authorRole') || null,
|
|
authorInitial: fd.get('authorInitial') || null,
|
|
publishDate: fd.get('publishDate') || null,
|
|
pages: Number(fd.get('pages') || 0),
|
|
price: Number(fd.get('price') || 0),
|
|
isFree: fd.get('isFree') === 'on',
|
|
featured: fd.get('featured') === 'on',
|
|
summary: fd.get('summary') || null,
|
|
body: fd.get('body') || null,
|
|
coverImage: fd.get('coverImage') || null,
|
|
tags: tagsRaw,
|
|
};
|
|
try {
|
|
if (isEdit) {
|
|
await api(`/api/articles/${article.id}`, { method: 'PUT', body: payload });
|
|
} else {
|
|
await api('/api/articles', { method: 'POST', body: payload });
|
|
}
|
|
renderList();
|
|
} catch (err) {
|
|
alert('ذخیره ناموفق بود: ' + err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
return String(s ?? '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
}
|
|
function escapeHtml(s) {
|
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
const RISK_LEVELS = [
|
|
{ value: 'critical', label: 'بحرانی' },
|
|
{ value: 'high', label: 'بالا' },
|
|
{ value: 'medium', label: 'متوسط' },
|
|
{ value: 'low', label: 'پایین' },
|
|
{ value: 'opportunity', label: 'فرصت' },
|
|
];
|
|
|
|
async function renderRisksList() {
|
|
root.innerHTML = `
|
|
${topbarHtml('risks', '+ سیگنال جدید')}
|
|
<main class="page">
|
|
<h2>سیگنالهای ریسک</h2>
|
|
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
|
|
</main>
|
|
`;
|
|
wireTabs();
|
|
$('#newBtn').addEventListener('click', () => renderRiskEditor(null));
|
|
|
|
try {
|
|
const items = await api('/api/risks?limit=200');
|
|
if (!items.length) {
|
|
$('#list').innerHTML = `<p class="muted">هنوز سیگنالی ثبت نشده است.</p>`;
|
|
return;
|
|
}
|
|
const labelOf = (lvl) => (RISK_LEVELS.find(r => r.value === lvl)?.label) || lvl;
|
|
$('#list').innerHTML = items.map(r => `
|
|
<article class="row risk-row" data-id="${r.id}">
|
|
<div class="row-main">
|
|
<div class="row-meta">
|
|
<span class="pill level-${r.level}">${labelOf(r.level)}</span>
|
|
<strong>${r.name}</strong>
|
|
${r.date ? `<span class="muted">${r.date}</span>` : ''}
|
|
</div>
|
|
<p class="muted clamp-2">${r.quote}</p>
|
|
</div>
|
|
<div class="row-actions">
|
|
<button data-action="edit">ویرایش</button>
|
|
<button data-action="delete" class="danger">حذف</button>
|
|
</div>
|
|
</article>
|
|
`).join('');
|
|
|
|
$('#list').addEventListener('click', async (e) => {
|
|
const btn = e.target.closest('button[data-action]');
|
|
if (!btn) return;
|
|
const id = btn.closest('.row').dataset.id;
|
|
if (btn.dataset.action === 'edit') {
|
|
const r = await api(`/api/risks/${id}`);
|
|
renderRiskEditor(r);
|
|
} else if (btn.dataset.action === 'delete') {
|
|
if (!confirm('این سیگنال حذف شود؟')) return;
|
|
await api(`/api/risks/${id}`, { method: 'DELETE' });
|
|
renderRisksList();
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
$('#list').innerHTML = `<p class="error">خطا در بارگذاری سیگنالها.</p>`;
|
|
}
|
|
}
|
|
|
|
function renderRiskEditor(risk) {
|
|
const r = risk || { quote: '', name: '', date: '', level: 'high', sortOrder: 0 };
|
|
const isEdit = !!risk;
|
|
|
|
root.innerHTML = `
|
|
<header class="topbar">
|
|
<div class="brand">${isEdit ? 'ویرایش سیگنال ریسک' : 'سیگنال ریسک جدید'}</div>
|
|
<div class="actions">
|
|
<button id="backBtn" class="ghost">← بازگشت</button>
|
|
</div>
|
|
</header>
|
|
<main class="page editor">
|
|
<form id="editorForm" class="grid">
|
|
<div class="col span-2">
|
|
<label>عنوان / منبع
|
|
<input name="name" value="${escapeAttr(r.name)}" required />
|
|
</label>
|
|
</div>
|
|
<label>سطح
|
|
<select name="level">
|
|
${RISK_LEVELS.map(l => `<option value="${l.value}" ${l.value===r.level?'selected':''}>${l.label}</option>`).join('')}
|
|
</select>
|
|
</label>
|
|
<label>تاریخ / افق (شمسی)
|
|
<input name="date" value="${escapeAttr(r.date)}" />
|
|
</label>
|
|
<label>ترتیب نمایش (عدد کوچکتر اول)
|
|
<input name="sortOrder" type="number" value="${r.sortOrder || 0}" />
|
|
</label>
|
|
<div class="col span-2">
|
|
<label>متن سیگنال
|
|
<textarea name="quote" rows="6" required>${escapeHtml(r.quote)}</textarea>
|
|
</label>
|
|
</div>
|
|
<div class="col span-2 row-end">
|
|
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : 'ایجاد'}</button>
|
|
</div>
|
|
</form>
|
|
</main>
|
|
`;
|
|
|
|
$('#backBtn').addEventListener('click', () => renderRisksList());
|
|
|
|
$('#editorForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
const payload = {
|
|
name: fd.get('name'),
|
|
quote: fd.get('quote'),
|
|
date: fd.get('date') || null,
|
|
level: fd.get('level'),
|
|
sortOrder: Number(fd.get('sortOrder') || 0),
|
|
};
|
|
try {
|
|
if (isEdit) await api(`/api/risks/${risk.id}`, { method: 'PUT', body: payload });
|
|
else await api('/api/risks', { method: 'POST', body: payload });
|
|
renderRisksList();
|
|
} catch (err) {
|
|
alert('ذخیره ناموفق بود: ' + err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (getToken()) renderList();
|
|
else renderLogin();
|