feat: 添加修改密码功能

This commit is contained in:
yinzhou.ma
2026-07-16 22:58:46 +08:00
parent 815ae9e356
commit 42aeb84c64
3 changed files with 560 additions and 524 deletions
+7
View File
@@ -22,3 +22,10 @@ CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
+236 -280
View File
@@ -8,12 +8,17 @@
<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; }
body { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif; background: #f5f5f5; }
.header { background: #fff; padding: 15px 30px; border-bottom: 1px solid #e8e8e8; display: flex; justify-content: space-between; align-items: center; }
.header h1 { font-size: 20px; font-weight: 500; }
.header nav a { margin-left: 20px; text-decoration: none; color: #666; font-size: 14px; }
.header nav a:hover { color: #333; }
.container { max-width: 1200px; margin: 20px auto; padding: 0 20px; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.card { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.card h2 { font-size: 16px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #eee; }
.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; }
@@ -24,87 +29,45 @@
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 { margin-bottom: 30px; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px; background: #fff; }
.group.playing { border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24,144,255,0.1); }
.group-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.group-info { display: flex; align-items: center; gap: 15px; flex-wrap: wrap; }
.group-name { font-size: 16px; font-weight: 500; }
.group-time { font-size: 13px; color: #666; }
.group-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: #e6f7ff; color: #1890ff; }
.group-actions { display: flex; gap: 15px; }
.group-actions a { color: #1890ff; text-decoration: none; font-size: 13px; cursor: pointer; }
.group-actions a:hover { text-decoration: underline; }
.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 { position: relative; width: 140px; height: 140px; background: #e8e8e8; cursor: pointer; border-radius: 4px; overflow: hidden; }
.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;
}
.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: -6px; right: -6px; width: 22px; height: 22px; background: #ff4d4f; color: #fff; border: 2px solid #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; cursor: pointer; z-index: 10; }
.action-menu { display: none; position: absolute; top: -6px; right: 22px; background: #fff; border: 1px solid #e8e8e8; box-shadow: 0 2px 8px rgba(0,0,0,0.12); z-index: 100; min-width: 70px; }
.action-menu.show { display: block; }
.action-menu a { display: block; padding: 8px 16px; text-decoration: none; font-size: 13px; 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; }
.time-input input { width: 60px; height: 36px; border: 1px solid #d9d9d9; text-align: center; font-size: 14px; border-radius: 4px; }
</style>
</head>
<body>
<div class="header">
<h1>后台管理</h1>
<nav>
<a href="/">首页</a>
<a href="/admin">管理</a>
<a href="#" id="changePasswordBtn">修改密码</a>
<a href="#" id="logout">退出登录</a>
</nav>
</div>
<div class="container">
<div class="top-bar">
<div class="feature-status">
@@ -113,231 +76,238 @@
<input type="checkbox" id="featureToggle">
<span class="slider"></span>
</label>
<span id="featureStatus" style="color:#999;font-size:13px;">已关闭</span>
</div>
<div class="d-flex gap-2">
<a href="/" class="btn btn-outline-secondary">首页</a>
<button class="btn btn-dark" id="addGroupBtn">添加分组</button>
<button class="btn btn-outline-secondary btn-sm" id="resetBtn" style="display:none;">重置轮播</button>
<button class="btn btn-dark btn-sm" 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="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content"><div class="modal-body text-center py-4">
<h6 class="mb-4">确认删除该分组?</h6>
<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>
<button class="btn btn-outline-secondary btn-sm px-3" data-bs-dismiss="modal">取消</button>
<button class="btn btn-danger btn-sm px-3" 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">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content"><div class="modal-body text-center py-4">
<h6 class="mb-3">设置时间</h6>
<div class="time-input mb-3">
<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>
<button class="btn btn-outline-secondary btn-sm px-3" data-bs-dismiss="modal">取消</button>
<button class="btn btn-dark btn-sm px-3" 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="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content"><div class="modal-body text-center py-4">
<h6 class="mb-4">确认删除该图片?</h6>
<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>
<button class="btn btn-outline-secondary btn-sm px-3" data-bs-dismiss="modal">取消</button>
<button class="btn btn-danger btn-sm px-3" 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 class="modal-header py-2"><h6 class="modal-title">裁剪图片</h6><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 py-2"><button class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">取消</button><button class="btn btn-primary btn-sm" id="confirmCrop">确认上传</button></div>
</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 class="modal fade" id="changePasswordModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content"><div class="modal-body py-4">
<h6 class="mb-4 text-center">修改密码</h6>
<div class="mb-3">
<label class="form-label" style="font-size:13px;">旧密码</label>
<input type="password" class="form-control form-control-sm" id="oldPassword">
</div>
<div class="mb-3">
<label class="form-label" style="font-size:13px;">新密码</label>
<input type="password" class="form-control form-control-sm" id="newPassword">
</div>
<div class="mb-3">
<label class="form-label" style="font-size:13px;">确认新密码</label>
<input type="password" class="form-control form-control-sm" id="confirmPassword">
</div>
<div id="passwordError" class="text-danger mb-3" style="display:none;font-size:12px;"></div>
<div class="d-flex justify-content-center gap-3">
<button class="btn btn-outline-secondary btn-sm px-3" data-bs-dismiss="modal">取消</button>
<button class="btn btn-dark btn-sm px-3" id="confirmChangePassword">确认修改</button>
</div>
</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;
var cropper = null, selectedFile = null, currentGroupId = null;
var deleteGroupId = null, deleteImageId = null, setTimeGroupId = null;
var groupsData = [], playingGroupId = null;
var deleteGroupModal, setTimeModal, deleteImageModal, cropModal, changePasswordModal;
const token = localStorage.getItem('token');
var token = localStorage.getItem('token');
if (!token) window.location.href = '/login';
var headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
const headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
// WebSocket 连接到前台端口
var ws;
function connectWS() {
var protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
var host = location.hostname;
ws = new WebSocket(protocol + '//' + host + ':80');
ws.onmessage = function(e) {
try {
var data = JSON.parse(e.data);
if (data.type === 'state') {
playingGroupId = data.playing ? data.group_id : null;
highlightPlaying();
}
} catch(err) {}
};
ws.onclose = function() { setTimeout(connectWS, 3000); };
}
let deleteGroupModal, setTimeModal, deleteImageModal, cropModal;
function highlightPlaying() {
document.querySelectorAll('.group').forEach(function(el) {
var gid = parseInt(el.getAttribute('data-id'));
if (gid === playingGroupId) {
el.classList.add('playing');
var badge = el.querySelector('.group-badge');
if (badge) badge.style.display = '';
} else {
el.classList.remove('playing');
var badge = el.querySelector('.group-badge');
if (badge) badge.style.display = 'none';
}
});
}
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'));
changePasswordModal = new bootstrap.Modal(document.getElementById('changePasswordModal'));
loadConfig();
loadGroups();
connectWS();
document.getElementById('featureToggle').onchange = toggleFeature;
document.getElementById('addGroupBtn').onclick = addGroup;
document.getElementById('resetBtn').onclick = resetCarousel;
document.getElementById('confirmDeleteGroup').onclick = doDeleteGroup;
document.getElementById('confirmSetTime').onclick = doSetTime;
document.getElementById('confirmDeleteImage').onclick = doDeleteImage;
document.getElementById('confirmCrop').onclick = doCrop;
document.getElementById('changePasswordBtn').onclick = function(e) { e.preventDefault(); changePasswordModal.show(); };
document.getElementById('confirmChangePassword').onclick = doChangePassword;
document.getElementById('logout').onclick = function() { localStorage.removeItem('token'); window.location.href = '/login'; };
document.addEventListener('click', function(e) {
if (!e.target.closest('.image-item')) {
document.querySelectorAll('.action-menu').forEach(m => m.classList.remove('show'));
document.querySelectorAll('.action-menu').forEach(function(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';
var res = await fetch('/api/config');
var config = await res.json();
var enabled = config.feature_enabled === 'true';
document.getElementById('featureToggle').checked = enabled;
document.getElementById('featureStatus').textContent = enabled ? '已开启' : '已关闭';
document.getElementById('resetBtn').style.display = enabled ? '' : 'none';
}
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() })
});
var enabled = document.getElementById('featureToggle').checked;
await fetch('/api/config', { method: 'PUT', headers: headers, body: JSON.stringify({ key: 'feature_enabled', value: enabled.toString() }) });
document.getElementById('featureStatus').textContent = enabled ? '已开启' : '已关闭';
document.getElementById('resetBtn').style.display = enabled ? '' : 'none';
}
let countdownInterval = null;
let groupsData = [];
async function resetCarousel() {
await fetch('/api/carousel/reset', { method: 'POST', headers: headers });
}
async function loadGroups() {
const res = await fetch('/api/groups');
var 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 + '">' +
var container = document.getElementById('groupsContainer');
container.innerHTML = groupsData.map(function(g) {
var min = Math.floor(g.display_seconds / 60);
var sec = g.display_seconds % 60;
var timeStr = min + '分' + sec + '秒';
var isPlaying = g.id === playingGroupId;
return '<div class="group' + (isPlaying ? ' playing' : '') + '" 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>' +
'<span class="group-time">展示时间:' + timeStr + '</span>' +
'<span class="group-badge" style="display:' + (isPlaying ? '' : 'none') + ';">播放中</span>' +
'</div>' +
'<div class="group-actions">' +
'<a onclick="addImage(' + g.id + ')">添加图片</a>' +
'<a onclick="setTime(' + g.id + ', ' + g.display_seconds + ')">设置时间</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);
groupsData.forEach(function(g) { loadImages(g.id); });
}
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 + '">' +
async function loadImages(gid) {
var res = await fetch('/api/images?group_id=' + gid);
var images = await res.json();
var container = document.getElementById('images-' + gid);
container.innerHTML = images.map(function(img) {
var cls = img.enabled ? '' : ' disabled';
var toggleText = img.enabled ? '下架' : '上架';
var toggleClass = img.enabled ? 'disable' : 'enable';
return '<div class="image-item' + cls + '">' +
'<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="' + toggleClass + '" onclick="toggleImage(' + img.id + ',' + (img.enabled ? 0 : 1) + ')">' + toggleText + '</a>' +
'<a class="delete" onclick="deleteImage(' + img.id + ')">删除</a>' +
'</div>' +
'</div>';
@@ -345,77 +315,43 @@
}
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 })
});
var res = await fetch('/api/groups', { method: 'POST', headers: headers, body: JSON.stringify({ name: '分组' + (groupsData.length + 1), display_seconds: 30 }) });
if (res.ok) loadGroups();
}
function deleteGroup(id) {
deleteGroupId = id;
deleteGroupModal.show();
}
function deleteGroup(id) { deleteGroupId = id; deleteGroupModal.show(); }
async function doDeleteGroup() {
await fetch('/api/groups/' + deleteGroupId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
});
deleteGroupModal.hide();
loadGroups();
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;
function setTime(gid, sec) {
setTimeGroupId = gid;
document.getElementById('timeMinutes').value = Math.floor(sec / 60);
document.getElementById('timeSeconds').value = sec % 60;
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();
var total = (parseInt(document.getElementById('timeMinutes').value) || 0) * 60 + (parseInt(document.getElementById('timeSeconds').value) || 0);
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/*';
function addImage(gid) {
currentGroupId = gid;
var input = document.createElement('input');
input.type = 'file'; input.accept = 'image/*';
input.onchange = function(e) {
const file = e.target.files[0];
var file = e.target.files[0];
if (!file) return;
selectedFile = file;
const reader = new FileReader();
var reader = new FileReader();
reader.onload = function(e) {
document.getElementById('cropImage').src = e.target.result;
cropModal.show();
setTimeout(() => {
setTimeout(function() {
if (cropper) cropper.destroy();
cropper = new Cropper(document.getElementById('cropImage'), {
aspectRatio: 1,
viewMode: 1,
autoCropArea: 1
});
cropper = new Cropper(document.getElementById('cropImage'), { aspectRatio: 1, viewMode: 1, autoCropArea: 1 });
}, 300);
};
reader.readAsDataURL(file);
@@ -425,59 +361,79 @@
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();
var canvas = cropper.getCroppedCanvas({ width: 280, height: 280 });
var blob = await new Promise(function(r) { canvas.toBlob(r, 'image/jpeg', 0.7); });
var 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);
}
var 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'));
var menu = btn.nextElementSibling;
var wasShow = menu.classList.contains('show');
document.querySelectorAll('.action-menu').forEach(function(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'));
var endpoint = enable ? 'enable' : 'disable';
await fetch('/api/images/' + id + '/' + endpoint, { method: 'PUT', headers: { 'Authorization': 'Bearer ' + token } });
document.querySelectorAll('.action-menu').forEach(function(m) { m.classList.remove('show'); });
loadGroups();
}
function deleteImage(id) {
event.stopPropagation();
deleteImageId = id;
document.querySelectorAll('.action-menu').forEach(m => m.classList.remove('show'));
document.querySelectorAll('.action-menu').forEach(function(m) { m.classList.remove('show'); });
deleteImageModal.show();
}
async function doDeleteImage() {
await fetch('/api/images/' + deleteImageId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
await fetch('/api/images/' + deleteImageId, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } });
deleteImageModal.hide(); loadGroups();
}
async function doChangePassword() {
var oldPwd = document.getElementById('oldPassword').value;
var newPwd = document.getElementById('newPassword').value;
var confirmPwd = document.getElementById('confirmPassword').value;
var errorEl = document.getElementById('passwordError');
errorEl.style.display = 'none';
if (!oldPwd || !newPwd || !confirmPwd) {
errorEl.textContent = '请填写所有字段';
errorEl.style.display = 'block';
return;
}
if (newPwd !== confirmPwd) {
errorEl.textContent = '两次输入的新密码不一致';
errorEl.style.display = 'block';
return;
}
var res = await fetch('/api/change-password', {
method: 'PUT',
headers: headers,
body: JSON.stringify({ oldPassword: oldPwd, newPassword: newPwd })
});
deleteImageModal.hide();
loadGroups();
var data = await res.json();
if (res.ok) {
changePasswordModal.hide();
document.getElementById('oldPassword').value = '';
document.getElementById('newPassword').value = '';
document.getElementById('confirmPassword').value = '';
alert('密码修改成功');
} else {
errorEl.textContent = data.error || '修改失败';
errorEl.style.display = 'block';
}
}
</script>
</body>
+260 -187
View File
@@ -4,54 +4,37 @@ const multer = require('multer');
const initSqlJs = require('sql.js');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const WebSocket = require('ws');
const app = express();
const PORT = 3000;
const ADMIN_PORT = 16888;
const FRONT_PORT = 80;
let db;
async function initDB() {
const SQL = await initSqlJs();
const dbPath = path.join(__dirname, 'data', 'sqlite.db');
if (fs.existsSync(dbPath)) {
const fileBuffer = fs.readFileSync(dbPath);
db = new SQL.Database(fileBuffer);
db = new SQL.Database(fs.readFileSync(dbPath));
} else {
db = new SQL.Database();
}
db.run(fs.readFileSync(path.join(__dirname, 'database', 'schema.sql'), 'utf8'));
const schema = `
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
display_seconds INTEGER NOT NULL DEFAULT 30,
enabled INTEGER NOT NULL DEFAULT 1,
update_time INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
filename TEXT NOT NULL,
original_name TEXT,
size INTEGER DEFAULT 0,
width INTEGER DEFAULT 280,
height INTEGER DEFAULT 280,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT
);
`;
db.run(schema);
const defaults = {
'feature_enabled': 'false',
'current_group_index': '0',
'current_group_start_time': '0'
};
for (const [k, v] of Object.entries(defaults)) {
const r = runQuery("SELECT value FROM config WHERE key=?", [k]);
if (r.length === 0) db.run("INSERT INTO config (key,value) VALUES (?,?)", [k, v]);
}
// 确保有 config 行
const configCheck = db.exec("SELECT COUNT(*) as cnt FROM config WHERE key='feature_enabled'");
if (configCheck[0].values[0][0] === 0) {
db.run("INSERT INTO config (key, value) VALUES ('feature_enabled', 'false')");
// 确保有默认管理员账号
const userCheck = runQuery("SELECT COUNT(*) as cnt FROM users WHERE username=?", ['admin']);
if (userCheck.length === 0 || userCheck[0].cnt === 0) {
db.run("INSERT INTO users (username, password) VALUES (?,?)", ['admin', 'admin123']);
}
saveDB();
@@ -59,232 +42,322 @@ async function initDB() {
function saveDB() {
const data = db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(path.join(__dirname, 'data', 'sqlite.db'), buffer);
fs.writeFileSync(path.join(__dirname, 'data', 'sqlite.db'), Buffer.from(data));
}
function runQuery(sql, params) {
const stmt = db.prepare(sql);
if (params && params.length > 0) {
stmt.bind(params);
}
if (params && params.length > 0) stmt.bind(params);
const results = [];
while (stmt.step()) {
results.push(stmt.getAsObject());
}
while (stmt.step()) results.push(stmt.getAsObject());
stmt.free();
return results;
}
function runExec(sql, params) {
db.run(sql, params || []);
}
function getConfig(key) {
const r = runQuery("SELECT value FROM config WHERE key=?", [key]);
return r.length > 0 ? r[0].value : null;
}
function setConfig(key, value) {
runExec("INSERT OR REPLACE INTO config (key,value) VALUES (?,?)", [key, String(value)]);
saveDB();
}
// Multer配置
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, path.join(__dirname, 'uploads'));
},
filename: function (req, file, cb) {
const ext = path.extname(file.originalname);
cb(null, uuidv4() + ext);
function getEnabledGroups() {
return runQuery(`
SELECT g.*, (SELECT COUNT(*) FROM images WHERE group_id=g.id AND enabled=1) as img_count
FROM groups g WHERE g.enabled=1 ORDER BY g.id ASC
`).filter(g => g.img_count > 0);
}
// ========== 轮播逻辑 ==========
let wsClients = new Set();
function broadcast(data) {
const msg = JSON.stringify(data);
wsClients.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
});
}
function getCurrentState() {
const featureEnabled = getConfig('feature_enabled') === 'true';
if (!featureEnabled) return { type: 'state', playing: false, groups: [] };
const groups = getEnabledGroups();
if (groups.length === 0) return { type: 'state', playing: false, groups: [] };
const idx = parseInt(getConfig('current_group_index') || '0') % groups.length;
const startTime = parseInt(getConfig('current_group_start_time') || '0');
const now = Math.floor(Date.now() / 1000);
const currentGroup = groups[idx];
let remaining = 0;
if (currentGroup.display_seconds === 0) {
remaining = -1;
} else if (startTime > 0) {
remaining = Math.max(0, startTime + currentGroup.display_seconds - now);
}
const images = runQuery('SELECT * FROM images WHERE group_id=? AND enabled=1', [currentGroup.id]);
return {
type: 'state',
playing: true,
current_index: idx,
total: groups.length,
group_name: currentGroup.name,
group_id: currentGroup.id,
display_seconds: currentGroup.display_seconds,
remaining: remaining,
images: images
};
}
function broadcastState() {
broadcast(getCurrentState());
}
function startCarouselTimer() {
setInterval(() => {
if (getConfig('feature_enabled') !== 'true') return;
const groups = getEnabledGroups();
if (groups.length === 0) return;
const idx = parseInt(getConfig('current_group_index') || '0') % groups.length;
const startTime = parseInt(getConfig('current_group_start_time') || '0');
const currentGroup = groups[idx];
if (currentGroup.display_seconds === 0) {
broadcastState();
return;
}
if (startTime === 0) {
setConfig('current_group_start_time', Math.floor(Date.now() / 1000));
broadcastState();
return;
}
const now = Math.floor(Date.now() / 1000);
const elapsed = now - startTime;
if (elapsed >= currentGroup.display_seconds) {
const nextIdx = findNextGroupIndex(groups, idx);
setConfig('current_group_index', nextIdx);
setConfig('current_group_start_time', Math.floor(Date.now() / 1000));
}
broadcastState();
}, 1000);
}
function findNextGroupIndex(groups, currentIdx) {
for (let i = 1; i <= groups.length; i++) {
const nextIdx = (currentIdx + i) % groups.length;
if (groups[nextIdx].display_seconds > 0) return nextIdx;
}
return currentIdx;
}
function resetCarousel() {
setConfig('current_group_index', 0);
setConfig('current_group_start_time', Math.floor(Date.now() / 1000));
broadcastState();
}
// ========== Multer ==========
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, 'uploads')),
filename: (req, file, cb) => cb(null, uuidv4() + path.extname(file.originalname))
});
const upload = multer({ storage: storage });
const upload = multer({ storage });
// 中间件
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// ========== 前台服务 (端口 80) ==========
const frontApp = express();
frontApp.use(express.static(path.join(__dirname, 'public')));
frontApp.use('/uploads', express.static(path.join(__dirname, 'uploads')));
frontApp.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
const frontServer = frontApp.listen(FRONT_PORT, '0.0.0.0', () => {
console.log(`前台运行在 http://0.0.0.0:${FRONT_PORT}`);
});
const wss = new WebSocket.Server({ server: frontServer });
wss.on('connection', (ws) => {
wsClients.add(ws);
ws.send(JSON.stringify(getCurrentState()));
ws.on('close', () => wsClients.delete(ws));
});
// ========== 后台服务 (端口 18888) ==========
const adminApp = express();
adminApp.use(express.json());
adminApp.use(express.urlencoded({ extended: true }));
adminApp.use(express.static(path.join(__dirname, 'public')));
adminApp.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// 认证中间件
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token || token !== 'admin-token') {
return res.status(401).json({ error: '未授权' });
}
if (!token || token !== 'admin-token') return res.status(401).json({ error: '未授权' });
next();
};
// 认证API
app.post('/api/login', (req, res) => {
// 登录
adminApp.post('/api/login', (req, res) => {
const { username, password } = req.body;
if (username === 'admin' && password === 'admin123') {
const users = runQuery('SELECT * FROM users WHERE username = ? AND password = ?', [username, password]);
if (users.length > 0) {
res.json({ success: true, token: 'admin-token' });
} else {
res.status(401).json({ success: false, error: '用户名或密码错误' });
}
});
// 分组管理API
app.get('/api/groups', (req, res) => {
const groups = runQuery('SELECT * FROM groups ORDER BY id ASC');
res.json(groups);
// 修改密码
adminApp.put('/api/change-password', authMiddleware, (req, res) => {
const { oldPassword, newPassword } = req.body;
if (!oldPassword || !newPassword) {
return res.status(400).json({ error: '请输入旧密码和新密码' });
}
const users = runQuery('SELECT * FROM users WHERE username = ? AND password = ?', ['admin', oldPassword]);
if (users.length === 0) {
return res.status(400).json({ error: '旧密码错误' });
}
runExec('UPDATE users SET password = ? WHERE username = ?', [newPassword, 'admin']);
saveDB();
res.json({ success: true });
});
app.post('/api/groups', authMiddleware, (req, res) => {
// 分组
adminApp.get('/api/groups', (req, res) => {
res.json(runQuery('SELECT * FROM groups ORDER BY id ASC'));
});
adminApp.post('/api/groups', authMiddleware, (req, res) => {
const { name, display_seconds } = req.body;
runExec('INSERT INTO groups (name, display_seconds, update_time) VALUES (?, ?, 0)',
[name || '新分组', display_seconds || 30]);
const result = db.exec("SELECT last_insert_rowid() as id");
const id = result[0].values[0][0];
runExec('INSERT INTO groups (name, display_seconds) VALUES (?, ?)', [name || '新分组', display_seconds || 30]);
saveDB();
const id = db.exec("SELECT last_insert_rowid()")[0].values[0][0];
broadcastState();
res.json({ id });
});
app.put('/api/groups/:id', authMiddleware, (req, res) => {
adminApp.put('/api/groups/:id', authMiddleware, (req, res) => {
const { name, display_seconds, enabled } = req.body;
const id = parseInt(req.params.id);
runExec('UPDATE groups SET name = ?, display_seconds = ?, enabled = ? WHERE id = ?',
[name, display_seconds, enabled, id]);
runExec('UPDATE groups SET name=?, display_seconds=?, enabled=? WHERE id=?',
[name, display_seconds, enabled, parseInt(req.params.id)]);
saveDB();
broadcastState();
res.json({ success: true });
});
app.put('/api/groups/:id/settime', authMiddleware, (req, res) => {
adminApp.put('/api/groups/:id/settime', authMiddleware, (req, res) => {
const { display_seconds } = req.body;
const id = parseInt(req.params.id);
const now = Math.floor(Date.now() / 1000);
runExec('UPDATE groups SET display_seconds = ?, update_time = ? WHERE id = ?',
[display_seconds, now, id]);
runExec('UPDATE groups SET display_seconds=? WHERE id=?', [display_seconds, parseInt(req.params.id)]);
saveDB();
broadcastState();
res.json({ success: true });
});
app.delete('/api/groups/:id', authMiddleware, (req, res) => {
adminApp.delete('/api/groups/:id', authMiddleware, (req, res) => {
const id = parseInt(req.params.id);
// 先删除分组下的图片文件
const images = runQuery('SELECT filename FROM images WHERE group_id = ?', [id]);
images.forEach(img => {
const filePath = path.join(__dirname, 'uploads', img.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
const imgs = runQuery('SELECT filename FROM images WHERE group_id=?', [id]);
imgs.forEach(img => {
const fp = path.join(__dirname, 'uploads', img.filename);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
});
runExec('DELETE FROM images WHERE group_id = ?', [id]);
runExec('DELETE FROM groups WHERE id = ?', [id]);
runExec('DELETE FROM images WHERE group_id=?', [id]);
runExec('DELETE FROM groups WHERE id=?', [id]);
saveDB();
resetCarousel();
res.json({ success: true });
});
// 图片上传API
app.post('/api/upload', authMiddleware, upload.single('image'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: '没有上传文件' });
}
const group_id = parseInt(req.body.group_id);
if (!group_id) {
return res.status(400).json({ error: '缺少分组ID' });
}
const { filename, originalname, size } = req.file;
runExec('INSERT INTO images (group_id, filename, original_name, size) VALUES (?, ?, ?, ?)',
[group_id, filename, originalname, size || 0]);
const result = db.exec("SELECT last_insert_rowid() as id");
const id = result[0].values[0][0];
res.json({ id, filename });
});
// 图片列表API
app.get('/api/images', (req, res) => {
const group_id = parseInt(req.query.group_id);
let images;
if (group_id) {
images = runQuery('SELECT * FROM images WHERE group_id = ? ORDER BY id DESC', [group_id]);
// 图片
adminApp.get('/api/images', (req, res) => {
const gid = parseInt(req.query.group_id);
if (gid) {
res.json(runQuery('SELECT * FROM images WHERE group_id=? ORDER BY id DESC', [gid]));
} else {
images = runQuery('SELECT * FROM images ORDER BY id DESC');
res.json(runQuery('SELECT * FROM images ORDER BY id DESC'));
}
res.json(images);
});
// 图片启用/禁用API
app.put('/api/images/:id/enable', authMiddleware, (req, res) => {
const id = parseInt(req.params.id);
runExec('UPDATE images SET enabled = 1 WHERE id = ?', [id]);
res.json({ success: true });
adminApp.post('/api/upload', authMiddleware, upload.single('image'), (req, res) => {
if (!req.file) return res.status(400).json({ error: '没有上传文件' });
const group_id = parseInt(req.body.group_id);
if (!group_id) return res.status(400).json({ error: '缺少分组ID' });
runExec('INSERT INTO images (group_id, filename, original_name, size) VALUES (?,?,?,?)',
[group_id, req.file.filename, req.file.originalname, req.file.size || 0]);
saveDB();
const id = db.exec("SELECT last_insert_rowid()")[0].values[0][0];
broadcastState();
res.json({ id, filename: req.file.filename });
});
app.put('/api/images/:id/disable', authMiddleware, (req, res) => {
const id = parseInt(req.params.id);
runExec('UPDATE images SET enabled = 0 WHERE id = ?', [id]);
res.json({ success: true });
adminApp.put('/api/images/:id/enable', authMiddleware, (req, res) => {
runExec('UPDATE images SET enabled=1 WHERE id=?', [parseInt(req.params.id)]);
saveDB(); broadcastState(); res.json({ success: true });
});
// 图片删除API
app.delete('/api/images/:id', authMiddleware, (req, res) => {
adminApp.put('/api/images/:id/disable', authMiddleware, (req, res) => {
runExec('UPDATE images SET enabled=0 WHERE id=?', [parseInt(req.params.id)]);
saveDB(); broadcastState(); res.json({ success: true });
});
adminApp.delete('/api/images/:id', authMiddleware, (req, res) => {
const id = parseInt(req.params.id);
const images = runQuery('SELECT filename FROM images WHERE id = ?', [id]);
if (images.length > 0) {
const filePath = path.join(__dirname, 'uploads', images[0].filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
const imgs = runQuery('SELECT filename FROM images WHERE id=?', [id]);
if (imgs.length > 0) {
const fp = path.join(__dirname, 'uploads', imgs[0].filename);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
}
runExec('DELETE FROM images WHERE id = ?', [id]);
res.json({ success: true });
runExec('DELETE FROM images WHERE id=?', [id]);
saveDB(); broadcastState(); res.json({ success: true });
});
// 配置API
app.get('/api/config', (req, res) => {
// 配置
adminApp.get('/api/config', (req, res) => {
const rows = runQuery('SELECT * FROM config');
const config = {};
rows.forEach(row => { config[row.key] = row.value; });
rows.forEach(r => config[r.key] = r.value);
res.json(config);
});
app.put('/api/config', authMiddleware, (req, res) => {
const { key, value } = req.body;
runExec('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', [key, value]);
adminApp.put('/api/config', authMiddleware, (req, res) => {
setConfig(req.body.key, req.body.value);
broadcastState();
res.json({ success: true });
});
// 首页展示API
app.get('/api/display', (req, res) => {
const configRows = runQuery("SELECT value FROM config WHERE key = 'feature_enabled'");
const featureEnabled = configRows.length > 0 && configRows[0].value === 'true';
if (!featureEnabled) {
return res.json({ enabled: false, groups: [] });
}
const groups = runQuery('SELECT * FROM groups WHERE enabled = 1 ORDER BY id ASC');
const now = Math.floor(Date.now() / 1000);
const result = groups.filter(group => {
// 展示时间为0则一直展示
if (group.display_seconds === 0) return true;
// update_time为0则不展示(未设置过时间)
if (group.update_time === 0) return false;
// 计算剩余时间
const remaining = group.update_time + group.display_seconds - now;
return remaining > 0;
}).map(group => {
const images = runQuery('SELECT * FROM images WHERE group_id = ? AND enabled = 1', [group.id]);
const remaining = group.update_time === 0 ? 0 :
Math.max(0, group.update_time + group.display_seconds - now);
return {
...group,
remaining,
images
};
}).filter(group => group.images.length > 0);
res.json({ enabled: true, groups: result });
// 重置轮播
adminApp.post('/api/carousel/reset', authMiddleware, (req, res) => {
resetCarousel();
res.json({ success: true });
});
// 静态文件路由
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
// 路由 - 后台直接在根路径,未登录跳转登录页
adminApp.get('/login', (req, res) => res.sendFile(path.join(__dirname, 'public', 'login.html')));
adminApp.get('/', (req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
adminApp.listen(ADMIN_PORT, '0.0.0.0', () => {
console.log(`后台运行在 http://0.0.0.0:${ADMIN_PORT}`);
});
app.get('/login', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
});
// 启动服务器
// ========== 启动 ==========
initDB().then(() => {
app.listen(PORT, '0.0.0.0', () => {
console.log(`服务器运行在 http://localhost:${PORT}`);
});
startCarouselTimer();
console.log('数据库初始化完成');
}).catch(err => {
console.error('数据库初始化失败:', err);
console.error('启动失败:', err);
process.exit(1);
});