001project_wildgrowth/backend/public/operational-banners.html

465 lines
20 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>运营位管理 - Wild Growth</title>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 24px 28px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 22px; font-weight: 600; }
.header a {
color: rgba(255,255,255,0.95);
text-decoration: none;
font-size: 14px;
}
.header a:hover { text-decoration: underline; }
.content { padding: 24px 28px; }
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
}
.toolbar input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
width: 200px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
background: #667eea;
color: white;
}
.btn:hover { background: #5568d3; }
.btn-secondary { background: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
.btn-danger { background: #dc3545; }
.btn-danger:hover { background: #c82333; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.banner-card {
border: 1px solid #e0e0e0;
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
.banner-card.deleted { opacity: 0.7; background: #f8f9fa; }
.banner-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: #f8f9fa;
border-bottom: 1px solid #eee;
}
.banner-head-left { display: flex; align-items: center; gap: 12px; }
.banner-title { font-weight: 600; font-size: 16px; }
.banner-meta { font-size: 12px; color: #666; }
.banner-actions { display: flex; gap: 8px; align-items: center; }
.banner-body { padding: 14px 18px; }
.banner-courses {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
}
.course-chip {
width: 80px;
text-align: center;
position: relative;
}
.course-chip img {
width: 80px;
height: 106px;
object-fit: cover;
border-radius: 8px;
display: block;
}
.course-chip .title {
font-size: 11px;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.course-chip .chip-actions {
position: absolute;
top: -6px;
right: -6px;
display: flex;
gap: 2px;
}
.course-chip .remove, .course-chip .order-btn {
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0;
color: white;
}
.course-chip .remove { background: #dc3545; }
.course-chip .remove:hover { background: #c82333; }
.course-chip .order-btn { background: #667eea; font-size: 10px; }
.course-chip .order-btn:hover { background: #5568d3; }
.course-chip .order-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.add-course-btn {
width: 80px;
height: 106px;
border: 2px dashed #ccc;
border-radius: 8px;
background: #fafafa;
color: #666;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.add-course-btn:hover { border-color: #667eea; color: #667eea; }
.empty-state { text-align: center; padding: 40px 20px; color: #666; }
.message { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; display: none; }
.message.success { background: #d4edda; color: #155724; }
.message.error { background: #f8d7da; color: #721c24; }
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-mask.show { display: flex; }
.modal {
background: white;
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal h3 { margin-bottom: 16px; font-size: 18px; }
.modal label { display: block; margin-bottom: 6px; font-size: 13px; color: #555; }
.modal input, .modal select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 8px;
margin-bottom: 14px;
font-size: 14px;
}
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.course-option {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.course-option input[type=checkbox] { flex-shrink: 0; }
.course-option img { width: 40px; height: 53px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
.course-option span { font-size: 14px; }
.loading { text-align: center; padding: 40px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>运营位管理</h1>
<a href="course-admin.html">← 返回课程管理</a>
</div>
<div class="content">
<div id="message" class="message"></div>
<div class="toolbar">
<input type="text" id="newTitle" placeholder="新运营位标题" />
<input type="number" id="newOrder" placeholder="排序" value="1" min="1" style="width:80px" />
<button class="btn" id="btnCreate">新建运营位</button>
</div>
<div id="list"></div>
</div>
</div>
<div id="modalEdit" class="modal-mask">
<div class="modal">
<h3>编辑运营位</h3>
<input type="hidden" id="editId" />
<label>标题</label>
<input type="text" id="editTitle" />
<label>排序</label>
<input type="number" id="editOrder" min="1" />
<label><input type="checkbox" id="editEnabled" checked /> 启用</label>
<div class="modal-actions">
<button class="btn btn-secondary" id="btnEditCancel">取消</button>
<button class="btn" id="btnEditSave">保存</button>
</div>
</div>
</div>
<div id="modalAddCourse" class="modal-mask">
<div class="modal">
<h3>添加课程到运营位(可多选)</h3>
<p id="addCourseBannerTitle" style="font-size:13px;color:#666;margin-bottom:12px;"></p>
<div id="courseList"></div>
<div class="modal-actions" style="margin-top:16px">
<button class="btn btn-secondary" id="btnAddCourseCancel">取消</button>
<button class="btn" id="btnAddCourseSubmit">添加选中</button>
</div>
</div>
</div>
<script>
const API_BASE = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
? 'http://localhost:3000' : window.location.origin;
const ADMIN = API_BASE + '/api/admin/operational-banners';
function showMsg(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = 'message ' + (type || 'success');
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 4000);
}
async function api(method, url, body) {
const opt = { method, headers: {} };
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
opt.headers['Content-Type'] = 'application/json';
opt.body = JSON.stringify(body);
}
const res = await fetch(url, opt);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error?.message || data.message || res.statusText);
return data;
}
function getImageUrl(url) {
if (!url) return '';
if (url.startsWith('http')) return url;
return url.startsWith('/') ? API_BASE + url : API_BASE + '/' + url;
}
let banners = [];
let publicCourses = [];
async function loadBanners() {
const listEl = document.getElementById('list');
listEl.innerHTML = '<div class="loading">加载中...</div>';
try {
const data = await api('GET', ADMIN);
banners = data.data.banners || [];
renderBanners();
} catch (e) {
listEl.innerHTML = '<div class="message error">加载失败:' + e.message + '</div>';
}
}
function renderBanners() {
const listEl = document.getElementById('list');
if (!banners.length) {
listEl.innerHTML = '<div class="empty-state">暂无运营位,请点击「新建运营位」添加。</div>';
return;
}
listEl.innerHTML = banners.map(b => {
const deleted = !!b.deletedAt;
const courses = b.courses || [];
const coursesHtml = courses.map((c, i) => `
<div class="course-chip" data-banner-id="${b.id}" data-course-id="${c.courseId}" data-index="${i}">
<div class="chip-actions">
<button type="button" class="order-btn" title="上移" onclick="moveCourse('${b.id}',${i},-1)" ${i === 0 ? 'disabled' : ''}>&uarr;</button>
<button type="button" class="order-btn" title="下移" onclick="moveCourse('${b.id}',${i},1)" ${i === courses.length - 1 ? 'disabled' : ''}>&darr;</button>
<button type="button" class="remove" title="移除" onclick="removeCourse('${b.id}','${c.courseId}')">&times;</button>
</div>
<img src="${getImageUrl(c.cover_image) || ''}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2280%22 height=%22106%22/>'" />
<div class="title">${escapeHtml(c.title || '')}</div>
</div>
`).join('');
const addBtn = !deleted && (b.courses || []).length < 10
? `<div class="add-course-btn" onclick="openAddCourse('${b.id}', this)">+ 添加课程</div>`
: '';
return `
<div class="banner-card ${deleted ? 'deleted' : ''}" data-id="${b.id}">
<div class="banner-head">
<div class="banner-head-left">
<span class="banner-title">${escapeHtml(b.title)}</span>
<span class="banner-meta">排序 ${b.orderIndex} · ${b.isEnabled ? '启用' : '禁用'}${deleted ? ' · 已删除' : ''} · ${(b.courses || []).length}/10 门课</span>
</div>
<div class="banner-actions">
${deleted ? '' : `<button class="btn btn-sm" onclick="openEdit('${b.id}')">编辑</button><button class="btn btn-sm btn-danger" onclick="deleteBanner('${b.id}')">删除</button>`}
</div>
</div>
<div class="banner-body">
<div class="banner-courses">${coursesHtml}${addBtn}</div>
</div>
</div>
`;
}).join('');
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s ?? '';
return div.innerHTML;
}
document.getElementById('btnCreate').onclick = async () => {
const title = document.getElementById('newTitle').value.trim();
if (!title) { showMsg('请输入标题', 'error'); return; }
const orderIndex = parseInt(document.getElementById('newOrder').value, 10) || 1;
try {
await api('POST', ADMIN, { title, orderIndex, isEnabled: true });
showMsg('已创建');
document.getElementById('newTitle').value = '';
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
};
function openEdit(id) {
const b = banners.find(x => x.id === id);
if (!b) return;
document.getElementById('editId').value = b.id;
document.getElementById('editTitle').value = b.title;
document.getElementById('editOrder').value = b.orderIndex;
document.getElementById('editEnabled').checked = b.isEnabled;
document.getElementById('modalEdit').classList.add('show');
}
document.getElementById('btnEditCancel').onclick = () => document.getElementById('modalEdit').classList.remove('show');
document.getElementById('btnEditSave').onclick = async () => {
const id = document.getElementById('editId').value;
const title = document.getElementById('editTitle').value.trim();
if (!title) { showMsg('标题不能为空', 'error'); return; }
const orderIndex = parseInt(document.getElementById('editOrder').value, 10) || 1;
const isEnabled = document.getElementById('editEnabled').checked;
try {
await api('PATCH', ADMIN + '/' + id, { title, orderIndex, isEnabled });
showMsg('已保存');
document.getElementById('modalEdit').classList.remove('show');
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
};
async function deleteBanner(id) {
if (!confirm('确定删除该运营位?删除后不会在发现页展示,可保留关联课程。')) return;
try {
await api('DELETE', ADMIN + '/' + id);
showMsg('已删除');
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
}
async function openAddCourse(bannerId, _btnEl) {
const banner = banners.find(b => b.id === bannerId);
document.getElementById('addCourseBannerTitle').textContent = '运营位:' + (banner ? banner.title : bannerId);
const listEl = document.getElementById('courseList');
listEl.innerHTML = '<div class="loading">加载课程列表...</div>';
document.getElementById('modalAddCourse').classList.add('show');
try {
const res = await fetch(API_BASE + '/api/courses');
const data = await res.json();
publicCourses = (data.data && data.data.courses) || [];
const banner = banners.find(x => x.id === bannerId);
const inBanner = new Set((banner.courses || []).map(c => c.courseId));
const available = publicCourses.filter(c => !inBanner.has(c.id));
if (!available.length) {
listEl.innerHTML = '<p style="color:#666">没有可添加的公开课程,或已全部添加。</p>';
document.getElementById('btnAddCourseSubmit').style.display = 'none';
return;
}
window._addCourseBannerId = bannerId;
listEl.innerHTML = available.map(c => `
<label class="course-option" style="cursor:pointer;display:flex;align-items:center;gap:10px;">
<input type="checkbox" class="add-course-cb" data-course-id="${c.id}" />
<img src="${getImageUrl(c.cover_image)}" alt="" onerror="this.style.display='none'" />
<span>${escapeHtml(c.title)}</span>
</label>
`).join('');
document.getElementById('btnAddCourseSubmit').style.display = 'inline-block';
} catch (e) {
listEl.innerHTML = '<p class="message error">加载失败:' + escapeHtml(e.message) + '</p>';
}
}
document.getElementById('btnAddCourseCancel').onclick = () => document.getElementById('modalAddCourse').classList.remove('show');
document.getElementById('btnAddCourseSubmit').onclick = async () => {
const bannerId = window._addCourseBannerId;
if (!bannerId) return;
const checked = document.querySelectorAll('.add-course-cb:checked');
if (!checked.length) { showMsg('请至少勾选一门课程', 'error'); return; }
const banner = banners.find(b => b.id === bannerId);
const startOrder = (banner && banner.courses && banner.courses.length) || 0;
const courses = Array.from(checked).map((el, i) => ({
courseId: el.getAttribute('data-course-id'),
orderIndex: startOrder + i + 1
}));
try {
await api('POST', ADMIN + '/' + bannerId + '/courses/batch', { courses });
showMsg('已添加 ' + courses.length + ' 门课程');
document.getElementById('modalAddCourse').classList.remove('show');
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
};
async function moveCourse(bannerId, index, delta) {
const banner = banners.find(b => b.id === bannerId);
if (!banner || !banner.courses || banner.courses.length < 2) return;
const courses = banner.courses.slice();
const newIndex = index + delta;
if (newIndex < 0 || newIndex >= courses.length) return;
[courses[index], courses[newIndex]] = [courses[newIndex], courses[index]];
const orderPayload = courses.map((c, i) => ({ courseId: c.courseId, orderIndex: i + 1 }));
try {
await api('PUT', ADMIN + '/' + bannerId + '/courses/order', orderPayload);
showMsg('已调整顺序');
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
}
async function removeCourse(bannerId, courseId) {
if (!confirm('确定从该运营位移除这门课程?')) return;
try {
await api('DELETE', ADMIN + '/' + bannerId + '/courses/' + courseId);
showMsg('已移除');
loadBanners();
} catch (e) { showMsg(e.message, 'error'); }
}
loadBanners();
</script>
</body>
</html>