Files
showpic/public/admin.html
T
2026-07-16 22:51:02 +08:00

484 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>后台管理</title>
<link href="/vendor/bootstrap/bootstrap.min.css" rel="stylesheet">
<link href="/vendor/cropperjs/cropper.min.css" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif; background: #fff; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
.feature-status { display: flex; align-items: center; gap: 15px; }
.feature-status span { font-size: 16px; font-weight: 500; }
.toggle { position: relative; display: inline-block; width: 50px; height: 26px; }
.toggle input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background: #ccc; transition: .3s; border-radius: 26px; }
.slider:before { position: absolute; content: ""; height: 22px; width: 22px; left: 2px; bottom: 2px; background: #fff; transition: .3s; border-radius: 50%; }
input:checked + .slider { background: #1890ff; }
input:checked + .slider:before { transform: translateX(24px); }
.group { margin-bottom: 40px; border: 1px solid #e8e8e8; padding: 20px; }
.group-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.group-info { display: flex; align-items: center; gap: 20px; }
.group-name { font-size: 18px; font-weight: 500; }
.group-time { font-size: 14px; color: #666; }
.group-actions { display: flex; gap: 20px; }
.group-actions a { color: #1890ff; text-decoration: none; font-size: 14px; cursor: pointer; }
.group-actions a.delete { color: #ff4d4f; }
.image-list { display: flex; flex-wrap: wrap; gap: 15px; }
.image-item { position: relative; width: 140px; height: 140px; background: #e8e8e8; cursor: pointer; }
.image-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
.image-item.disabled::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
}
.delete-btn {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
background: #ff4d4f;
color: #fff;
border: 2px solid #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
cursor: pointer;
z-index: 10;
}
.action-menu {
display: none;
position: absolute;
top: -8px;
right: 24px;
background: #fff;
border: 1px solid #e8e8e8;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: 100;
min-width: 80px;
}
.action-menu.show {
display: block;
}
.action-menu a {
display: block;
padding: 10px 20px;
text-decoration: none;
font-size: 14px;
text-align: center;
border-bottom: 1px solid #e8e8e8;
cursor: pointer;
}
.action-menu a:last-child {
border-bottom: none;
}
.action-menu a.enable { color: #52c41a; }
.action-menu a.disable { color: #1890ff; }
.action-menu a.delete { color: #ff4d4f; }
.time-input { display: flex; align-items: center; gap: 10px; justify-content: center; }
.time-input input { width: 60px; height: 36px; border: 1px solid #d9d9d9; text-align: center; }
</style>
</head>
<body>
<div class="container">
<div class="top-bar">
<div class="feature-status">
<span>功能状态</span>
<label class="toggle">
<input type="checkbox" id="featureToggle">
<span class="slider"></span>
</label>
</div>
<div class="d-flex gap-2">
<a href="/" class="btn btn-outline-secondary">首页</a>
<button class="btn btn-dark" id="addGroupBtn">添加分组</button>
</div>
</div>
<div id="groupsContainer"></div>
</div>
<!-- 删除分组弹窗 -->
<div class="modal fade" id="deleteGroupModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-body text-center py-4">
<h5 class="mb-4">确认删除该分组?</h5>
<div class="d-flex justify-content-center gap-3">
<button type="button" class="btn btn-outline-secondary px-4" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger px-4" id="confirmDeleteGroup">确认删除</button>
</div>
</div>
</div>
</div>
</div>
<!-- 设置时间弹窗 -->
<div class="modal fade" id="setTimeModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-body text-center py-4">
<h5 class="mb-4">设置时间</h5>
<div class="time-input mb-4">
<input type="number" id="timeMinutes" min="0" value="0">
<span></span>
<input type="number" id="timeSeconds" min="0" value="30">
<span></span>
</div>
<div class="d-flex justify-content-center gap-3">
<button type="button" class="btn btn-outline-secondary px-4" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-dark px-4" id="confirmSetTime">保存</button>
</div>
</div>
</div>
</div>
</div>
<!-- 删除图片弹窗 -->
<div class="modal fade" id="deleteImageModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-body text-center py-4">
<h5 class="mb-4">确认删除该图片?</h5>
<div class="d-flex justify-content-center gap-3">
<button type="button" class="btn btn-outline-secondary px-4" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger px-4" id="confirmDeleteImage">确认删除</button>
</div>
</div>
</div>
</div>
</div>
<!-- 裁剪弹窗 -->
<div class="modal fade" id="cropModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">裁剪图片</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div style="max-height: 400px; overflow: hidden;">
<img id="cropImage" style="max-width: 100%;">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="confirmCrop">确认上传</button>
</div>
</div>
</div>
</div>
<script src="/vendor/bootstrap/bootstrap.bundle.min.js"></script>
<script src="/vendor/cropperjs/cropper.min.js"></script>
<script>
let cropper = null;
let selectedFile = null;
let currentGroupId = null;
let deleteGroupId = null;
let deleteImageId = null;
let setTimeGroupId = null;
let currentImageData = null;
const token = localStorage.getItem('token');
if (!token) window.location.href = '/login';
const headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
};
let deleteGroupModal, setTimeModal, deleteImageModal, cropModal;
document.addEventListener('DOMContentLoaded', function() {
deleteGroupModal = new bootstrap.Modal(document.getElementById('deleteGroupModal'));
setTimeModal = new bootstrap.Modal(document.getElementById('setTimeModal'));
deleteImageModal = new bootstrap.Modal(document.getElementById('deleteImageModal'));
cropModal = new bootstrap.Modal(document.getElementById('cropModal'));
loadConfig();
loadGroups();
document.getElementById('featureToggle').onchange = toggleFeature;
document.getElementById('addGroupBtn').onclick = addGroup;
document.getElementById('confirmDeleteGroup').onclick = doDeleteGroup;
document.getElementById('confirmSetTime').onclick = doSetTime;
document.getElementById('confirmDeleteImage').onclick = doDeleteImage;
document.getElementById('confirmCrop').onclick = doCrop;
document.addEventListener('click', function(e) {
if (!e.target.closest('.image-item')) {
document.querySelectorAll('.action-menu').forEach(m => m.classList.remove('show'));
}
});
});
async function loadConfig() {
const res = await fetch('/api/config');
const config = await res.json();
document.getElementById('featureToggle').checked = config.feature_enabled === 'true';
}
async function toggleFeature() {
const enabled = document.getElementById('featureToggle').checked;
await fetch('/api/config', {
method: 'PUT',
headers: headers,
body: JSON.stringify({ key: 'feature_enabled', value: enabled.toString() })
});
}
let countdownInterval = null;
let groupsData = [];
async function loadGroups() {
const res = await fetch('/api/groups');
groupsData = await res.json();
renderGroups();
}
function renderGroups() {
const container = document.getElementById('groupsContainer');
container.innerHTML = groupsData.map(g => {
const min = Math.floor(g.display_seconds / 60);
const sec = g.display_seconds % 60;
const timeStr = min + '分' + sec + '秒';
let countdownStr = '';
if (g.update_time > 0 && g.display_seconds > 0) {
const now = Math.floor(Date.now() / 1000);
const remaining = Math.max(0, g.update_time + g.display_seconds - now);
countdownStr = ' | 剩余:' + remaining + '秒';
} else if (g.display_seconds === 0) {
countdownStr = ' | 持续展示';
} else {
countdownStr = ' | 未开始';
}
return '<div class="group" data-id="' + g.id + '">' +
'<div class="group-header">' +
'<div class="group-info">' +
'<span class="group-name">' + g.name + '</span>' +
'<span class="group-time">展示时间:' + timeStr + '<span id="countdown-' + g.id + '" style="color:#1890ff;">' + countdownStr + '</span></span>' +
'</div>' +
'<div class="group-actions">' +
'<a onclick="addImage(' + g.id + ')">添加图片</a>' +
'<a onclick="setTime(' + g.id + ', ' + g.display_seconds + ')">设置时间</a>' +
'<a class="delete" onclick="deleteGroup(' + g.id + ')">删除</a>' +
'</div>' +
'</div>' +
'<div class="image-list" id="images-' + g.id + '"></div>' +
'</div>';
}).join('');
groupsData.forEach(g => loadImages(g.id));
// 启动倒计时
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(updateCountdowns, 1000);
}
function updateCountdowns() {
const now = Math.floor(Date.now() / 1000);
groupsData.forEach(g => {
const el = document.getElementById('countdown-' + g.id);
if (!el) return;
if (g.update_time > 0 && g.display_seconds > 0) {
const remaining = Math.max(0, g.update_time + g.display_seconds - now);
el.textContent = ' | 剩余:' + remaining + '秒';
el.style.color = remaining <= 10 ? '#ff4d4f' : '#1890ff';
} else if (g.display_seconds === 0) {
el.textContent = ' | 持续展示';
} else {
el.textContent = ' | 未开始';
}
});
}
async function loadImages(groupId) {
const res = await fetch('/api/images?group_id=' + groupId);
const images = await res.json();
const container = document.getElementById('images-' + groupId);
container.innerHTML = images.map(img => {
const disabledClass = img.enabled ? '' : ' disabled';
const toggleText = img.enabled ? '下架' : '上架';
const toggleClass = img.enabled ? 'disable' : 'enable';
return '<div class="image-item' + disabledClass + '">' +
'<img src="/uploads/' + img.filename + '">' +
'<div class="delete-btn" onclick="toggleActionMenu(this)">-</div>' +
'<div class="action-menu">' +
'<a class="' + toggleClass + '" onclick="toggleImage(' + img.id + ', ' + (img.enabled ? 0 : 1) + ')">' + toggleText + '</a>' +
'<a class="delete" onclick="deleteImage(' + img.id + ')">删除</a>' +
'</div>' +
'</div>';
}).join('');
}
async function addGroup() {
const groupsRes = await fetch('/api/groups');
const groups = await groupsRes.json();
const nextNum = groups.length + 1;
const res = await fetch('/api/groups', {
method: 'POST',
headers: headers,
body: JSON.stringify({ name: '分组' + nextNum, display_seconds: 30 })
});
if (res.ok) loadGroups();
}
function deleteGroup(id) {
deleteGroupId = id;
deleteGroupModal.show();
}
async function doDeleteGroup() {
await fetch('/api/groups/' + deleteGroupId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
});
deleteGroupModal.hide();
loadGroups();
}
function setTime(groupId, seconds) {
setTimeGroupId = groupId;
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
document.getElementById('timeMinutes').value = min;
document.getElementById('timeSeconds').value = sec;
setTimeModal.show();
}
async function doSetTime() {
const min = parseInt(document.getElementById('timeMinutes').value) || 0;
const sec = parseInt(document.getElementById('timeSeconds').value) || 0;
const total = min * 60 + sec;
await fetch('/api/groups/' + setTimeGroupId + '/settime', {
method: 'PUT',
headers: headers,
body: JSON.stringify({ display_seconds: total })
});
setTimeModal.hide();
loadGroups();
}
function addImage(groupId) {
currentGroupId = groupId;
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = function(e) {
const file = e.target.files[0];
if (!file) return;
selectedFile = file;
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('cropImage').src = e.target.result;
cropModal.show();
setTimeout(() => {
if (cropper) cropper.destroy();
cropper = new Cropper(document.getElementById('cropImage'), {
aspectRatio: 1,
viewMode: 1,
autoCropArea: 1
});
}, 300);
};
reader.readAsDataURL(file);
};
input.click();
}
async function doCrop() {
if (!cropper || !selectedFile) return;
const canvas = cropper.getCroppedCanvas({ width: 280, height: 280 });
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.7));
const formData = new FormData();
formData.append('image', blob, selectedFile.name);
formData.append('group_id', currentGroupId);
const res = await fetch('/api/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
if (res.ok) {
cropModal.hide();
loadImages(currentGroupId);
}
}
function toggleActionMenu(btn) {
event.stopPropagation();
const menu = btn.nextElementSibling;
const wasShow = menu.classList.contains('show');
document.querySelectorAll('.action-menu').forEach(m => m.classList.remove('show'));
if (!wasShow) menu.classList.add('show');
}
async function toggleImage(id, enable) {
event.stopPropagation();
const endpoint = enable ? 'enable' : 'disable';
await fetch('/api/images/' + id + '/' + endpoint, {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + token }
});
document.querySelectorAll('.action-menu').forEach(m => m.classList.remove('show'));
loadGroups();
}
function deleteImage(id) {
event.stopPropagation();
deleteImageId = id;
document.querySelectorAll('.action-menu').forEach(m => m.classList.remove('show'));
deleteImageModal.show();
}
async function doDeleteImage() {
await fetch('/api/images/' + deleteImageId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
});
deleteImageModal.hide();
loadGroups();
}
</script>
</body>
</html>