初始提交

This commit is contained in:
yinzhou.ma
2026-07-16 22:51:02 +08:00
commit 815ae9e356
939 changed files with 402135 additions and 0 deletions
+484
View File
@@ -0,0 +1,484 @@
<!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>
+260
View File
@@ -0,0 +1,260 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background-color: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 15px 0;
margin-bottom: 30px;
}
header .container {
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
font-size: 24px;
color: #333;
}
header nav a {
margin-left: 20px;
text-decoration: none;
color: #666;
}
header nav a:hover {
color: #333;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 30px;
padding: 20px 0;
}
.image-item {
position: relative;
width: 140px;
height: 140px;
overflow: hidden;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.image-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-item.disabled::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.btn {
display: inline-block;
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
}
.btn:hover {
background-color: #0056b3;
}
.btn-danger {
background-color: #dc3545;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-success {
background-color: #28a745;
}
.btn-success:hover {
background-color: #218838;
}
.card {
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 20px;
margin-bottom: 20px;
}
.group-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.group-item:last-child {
border-bottom: none;
}
.image-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 30px;
}
.image-card {
position: relative;
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.image-card img {
width: 140px;
height: 140px;
object-fit: cover;
}
.image-card.disabled::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.image-card .actions {
padding: 10px;
display: flex;
justify-content: space-between;
}
.login-form {
max-width: 400px;
margin: 50px auto;
background-color: white;
padding: 30px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.login-form h2 {
text-align: center;
margin-bottom: 20px;
}
.error {
color: #dc3545;
margin-bottom: 15px;
text-align: center;
}
.toggle {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #28a745;
}
input:checked + .slider:before {
transform: translateX(26px);
}
@media (max-width: 768px) {
.image-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 20px;
}
.image-item {
width: 120px;
height: 120px;
}
}
+161
View File
@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图片资源展示</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #fff; }
.header {
display: flex;
justify-content: center;
align-items: center;
padding: 16px 20px;
font-size: 18px;
font-weight: 500;
border-bottom: 1px solid #eee;
position: relative;
}
.user-icon {
position: absolute;
right: 20px;
width: 32px;
height: 32px;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
text-decoration: none;
color: #666;
}
.user-icon:hover {
background: #e0e0e0;
}
.content {
padding: 30px;
}
.image-grid {
display: flex;
flex-wrap: wrap;
gap: 30px;
}
.image-item {
width: 140px;
height: 140px;
background: #e8e8e8;
flex-shrink: 0;
}
.image-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.empty {
text-align: center;
color: #999;
padding: 80px 0;
}
</style>
</head>
<body>
<div class="header">
首页
</div>
<div class="content">
<div class="image-grid" id="imageGrid"></div>
<div class="empty" id="empty">暂无图片</div>
</div>
<script>
let currentGroupIndex = 0;
let groups = [];
let timer = null;
function checkLogin() {
if (!localStorage.getItem('token')) {
window.location.href = '/login';
return false;
}
return true;
}
document.addEventListener('DOMContentLoaded', loadDisplay);
async function loadDisplay() {
try {
const res = await fetch('/api/display');
const data = await res.json();
if (!data.enabled || data.groups.length === 0) {
document.getElementById('empty').style.display = 'block';
return;
}
document.getElementById('empty').style.display = 'none';
groups = data.groups;
showGroup(0);
} catch (e) {
console.error(e);
}
}
function showGroup(index) {
if (index >= groups.length) index = 0;
currentGroupIndex = index;
const group = groups[index];
const grid = document.getElementById('imageGrid');
grid.innerHTML = '';
group.images.forEach(img => {
const div = document.createElement('div');
div.className = 'image-item';
const imgEl = document.createElement('img');
imgEl.src = '/uploads/' + img.filename;
imgEl.loading = 'lazy';
let pressTimer;
imgEl.addEventListener('touchstart', function() {
pressTimer = setTimeout(function() {
saveImage('/uploads/' + img.filename, img.original_name);
}, 1000);
});
imgEl.addEventListener('touchend', function() { clearTimeout(pressTimer); });
imgEl.addEventListener('touchmove', function() { clearTimeout(pressTimer); });
div.appendChild(imgEl);
grid.appendChild(div);
});
startTimer(group.display_seconds);
}
function startTimer(seconds) {
if (timer) clearInterval(timer);
timer = setInterval(function() {
showGroup(currentGroupIndex + 1);
}, seconds * 1000);
}
function saveImage(url, name) {
var a = document.createElement('a');
a.href = url;
a.download = name || 'image.jpg';
a.click();
}
</script>
</body>
</html>
+321
View File
@@ -0,0 +1,321 @@
document.addEventListener('DOMContentLoaded', function() {
checkAuth();
loadConfig();
loadGroups();
document.getElementById('logout').addEventListener('click', logout);
document.getElementById('featureToggle').addEventListener('change', toggleFeature);
document.getElementById('addGroupBtn').addEventListener('click', addGroup);
document.getElementById('uploadBtn').addEventListener('click', () => document.getElementById('fileInput').click());
document.getElementById('fileInput').addEventListener('change', handleFileSelect);
document.getElementById('cropCancel').addEventListener('click', closeCropModal);
document.getElementById('cropConfirm').addEventListener('click', cropAndUpload);
});
let cropper = null;
let selectedFile = null;
function checkAuth() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
// 验证token有效性(可选)
}
async function logout() {
localStorage.removeItem('token');
window.location.href = '/login';
}
async function loadConfig() {
try {
const response = await fetch('/api/config');
const config = await response.json();
document.getElementById('featureToggle').checked = config.feature_enabled === 'true';
} catch (error) {
console.error('加载配置失败:', error);
}
}
async function toggleFeature() {
const enabled = document.getElementById('featureToggle').checked;
try {
await fetch('/api/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ key: 'feature_enabled', value: enabled.toString() })
});
} catch (error) {
console.error('更新配置失败:', error);
}
}
async function loadGroups() {
try {
const response = await fetch('/api/groups');
const groups = await response.json();
renderGroups(groups);
updateGroupSelect(groups);
} catch (error) {
console.error('加载分组失败:', error);
}
}
function renderGroups(groups) {
const container = document.getElementById('groupsList');
container.innerHTML = '';
groups.forEach(group => {
const groupItem = document.createElement('div');
groupItem.className = 'group-item';
groupItem.innerHTML = `
<div>
<strong>${group.name}</strong>
<span style="margin-left: 10px; color: #666;">展示时间: ${group.display_seconds}秒</span>
</div>
<div>
<button class="btn btn-danger" onclick="deleteGroup(${group.id})">删除</button>
</div>
`;
container.appendChild(groupItem);
});
}
function updateGroupSelect(groups) {
const select = document.getElementById('groupSelect');
select.innerHTML = '<option value="">请选择分组</option>';
groups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
select.appendChild(option);
});
}
async function addGroup() {
const name = prompt('请输入分组名称:');
if (!name) return;
const displaySeconds = prompt('请输入展示时间(秒):', '30');
if (!displaySeconds) return;
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name, display_seconds: parseInt(displaySeconds) })
});
if (response.ok) {
loadGroups();
}
} catch (error) {
console.error('添加分组失败:', error);
}
}
async function deleteGroup(id) {
if (!confirm('确定要删除这个分组吗?')) return;
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/groups/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
loadGroups();
}
} catch (error) {
console.error('删除分组失败:', error);
}
}
async function handleFileSelect(e) {
const file = e.target.files[0];
if (!file) return;
selectedFile = file;
const reader = new FileReader();
reader.onload = function(e) {
const cropImage = document.getElementById('cropImage');
cropImage.src = e.target.result;
document.getElementById('cropModal').style.display = 'block';
if (cropper) {
cropper.destroy();
}
cropper = new Cropper(cropImage, {
aspectRatio: 1,
viewMode: 1,
autoCropArea: 1,
ready: function() {
// 裁剪器就绪
}
});
};
reader.readAsDataURL(file);
e.target.value = '';
}
function closeCropModal() {
document.getElementById('cropModal').style.display = 'none';
if (cropper) {
cropper.destroy();
cropper = null;
}
selectedFile = null;
}
async function cropAndUpload() {
if (!cropper || !selectedFile) return;
const groupId = document.getElementById('groupSelect').value;
if (!groupId) {
alert('请先选择分组');
return;
}
try {
// 获取裁剪后的canvas
const canvas = cropper.getCroppedCanvas({
width: 280,
height: 280
});
// 压缩图片
const compressedBlob = await compressImage(canvas, 0.7);
// 创建FormData
const formData = new FormData();
formData.append('image', compressedBlob, selectedFile.name);
formData.append('group_id', groupId);
// 上传
const token = localStorage.getItem('token');
const response = await fetch('/api/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
if (response.ok) {
closeCropModal();
loadImages(groupId);
alert('上传成功');
} else {
alert('上传失败');
}
} catch (error) {
console.error('上传失败:', error);
alert('上传失败');
}
}
function compressImage(canvas, quality) {
return new Promise((resolve) => {
canvas.toBlob((blob) => {
resolve(blob);
}, 'image/jpeg', quality);
});
}
async function loadImages(groupId) {
if (!groupId) {
document.getElementById('imageList').innerHTML = '';
return;
}
try {
const response = await fetch(`/api/images?group_id=${groupId}`);
const images = await response.json();
renderImages(images);
} catch (error) {
console.error('加载图片失败:', error);
}
}
function renderImages(images) {
const container = document.getElementById('imageList');
container.innerHTML = '';
images.forEach(image => {
const imageCard = document.createElement('div');
imageCard.className = `image-card ${image.enabled ? '' : 'disabled'}`;
imageCard.innerHTML = `
<img src="/uploads/${image.filename}" alt="${image.original_name}">
<div class="actions">
<button class="btn btn-success" onclick="toggleImage(${image.id}, ${image.enabled ? 0 : 1})">
${image.enabled ? '下架' : '上架'}
</button>
<button class="btn btn-danger" onclick="deleteImage(${image.id})">删除</button>
</div>
`;
container.appendChild(imageCard);
});
}
async function toggleImage(id, enable) {
try {
const token = localStorage.getItem('token');
const endpoint = enable ? 'enable' : 'disable';
const response = await fetch(`/api/images/${id}/${endpoint}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const groupId = document.getElementById('groupSelect').value;
loadImages(groupId);
}
} catch (error) {
console.error('更新图片状态失败:', error);
}
}
async function deleteImage(id) {
if (!confirm('确定要删除这张图片吗?')) return;
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/images/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const groupId = document.getElementById('groupSelect').value;
loadImages(groupId);
}
} catch (error) {
console.error('删除图片失败:', error);
}
}
// 分组选择变化时加载图片
document.getElementById('groupSelect').addEventListener('change', function() {
loadImages(this.value);
});
+116
View File
@@ -0,0 +1,116 @@
document.addEventListener('DOMContentLoaded', function() {
loadDisplayData();
});
let currentGroupIndex = 0;
let groups = [];
let displayTimer = null;
async function loadDisplayData() {
try {
const response = await fetch('/api/display');
const data = await response.json();
if (!data.enabled) {
document.getElementById('feature-disabled').style.display = 'block';
document.getElementById('image-display').style.display = 'none';
return;
}
document.getElementById('feature-disabled').style.display = 'none';
document.getElementById('image-display').style.display = 'block';
groups = data.groups;
if (groups.length === 0) {
document.getElementById('group-name').textContent = '暂无分组';
return;
}
showGroup(0);
startRotation();
} catch (error) {
console.error('加载数据失败:', error);
}
}
function showGroup(index) {
if (index >= groups.length) {
index = 0;
}
currentGroupIndex = index;
const group = groups[index];
document.getElementById('group-name').textContent = group.name;
const grid = document.getElementById('image-grid');
grid.innerHTML = '';
group.images.forEach(image => {
const imageItem = document.createElement('div');
imageItem.className = 'image-item';
const img = document.createElement('img');
img.src = `/uploads/${image.filename}`;
img.alt = image.original_name;
img.loading = 'lazy';
// 长按保存功能
let pressTimer;
img.addEventListener('touchstart', function(e) {
pressTimer = setTimeout(function() {
saveImage(`/uploads/${image.filename}`, image.original_name);
}, 1000);
});
img.addEventListener('touchend', function() {
clearTimeout(pressTimer);
});
img.addEventListener('touchmove', function() {
clearTimeout(pressTimer);
});
imageItem.appendChild(img);
grid.appendChild(imageItem);
});
// 更新倒计时
updateTimer(group.display_seconds);
}
function updateTimer(seconds) {
const timerElement = document.getElementById('group-timer');
let remaining = seconds;
if (displayTimer) {
clearInterval(displayTimer);
}
timerElement.textContent = `剩余时间: ${remaining}`;
displayTimer = setInterval(function() {
remaining--;
if (remaining <= 0) {
clearInterval(displayTimer);
showGroup(currentGroupIndex + 1);
return;
}
timerElement.textContent = `剩余时间: ${remaining}`;
}, 1000);
}
function startRotation() {
if (groups.length <= 1) return;
// 自动轮播已在updateTimer中处理
}
function saveImage(url, filename) {
const link = document.createElement('a');
link.href = url;
link.download = filename || 'image.jpg';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
+34
View File
@@ -0,0 +1,34 @@
document.addEventListener('DOMContentLoaded', function() {
const loginForm = document.getElementById('loginForm');
const errorDiv = document.getElementById('error');
loginForm.addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (data.success) {
localStorage.setItem('token', data.token);
window.location.href = '/admin';
} else {
errorDiv.textContent = data.error;
errorDiv.style.display = 'block';
}
} catch (error) {
errorDiv.textContent = '登录失败,请重试';
errorDiv.style.display = 'block';
}
});
});
+41
View File
@@ -0,0 +1,41 @@
<!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 rel="stylesheet" href="css/style.css">
</head>
<body>
<header>
<div class="container">
<h1>图片资源管理</h1>
<nav>
<a href="/">首页</a>
<a href="/login">登录</a>
<a href="/admin">管理</a>
</nav>
</div>
</header>
<main class="container">
<div class="login-form">
<h2>管理员登录</h2>
<div id="error" class="error" style="display: none;"></div>
<form id="loginForm">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn" style="width: 100%;">登录</button>
</form>
</div>
</main>
<script src="js/login.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
/*!
* Cropper.js v1.5.13
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2022-11-20T05:30:43.444Z
*/.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{-webkit-backface-visibility:hidden;backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
File diff suppressed because one or more lines are too long