694 lines
20 KiB
HTML
Executable File
694 lines
20 KiB
HTML
Executable File
{% extends "base.html" %}
|
|
|
|
{% block content %}
|
|
{% include 'admin_nav.html' %}
|
|
|
|
<div class="admin-container">
|
|
<div class="admin-content">
|
|
<!-- 标签页导航 -->
|
|
<div class="tab-nav">
|
|
<button class="tab-btn active" onclick="switchTab('unnotified')">
|
|
<i class="fas fa-bell-slash"></i>
|
|
未通知应用
|
|
</button>
|
|
<button class="tab-btn" onclick="switchTab('notified')">
|
|
<i class="fas fa-bell"></i>
|
|
已通知应用
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 未通知应用 -->
|
|
<div class="admin-card" id="unnotified-tab">
|
|
<div class="card-header">
|
|
<div class="header-left">
|
|
<i class="fas fa-bell-slash"></i>
|
|
<h3>未通知应用</h3>
|
|
</div>
|
|
<div class="filter-sort">
|
|
<select class="sort-select" onchange="sortTable(this, 'unnotified')">
|
|
<option value="last_added_desc">最近添加时间 ↓</option>
|
|
<option value="last_added_asc">最近添加时间 ↑</option>
|
|
<option value="count_desc">关注人数 ↓</option>
|
|
<option value="count_asc">关注人数 ↑</option>
|
|
</select>
|
|
<div class="search-box">
|
|
<input type="text" class="search-input"
|
|
placeholder="搜索未通知应用..."
|
|
onkeyup="filterTable(this, 'unnotified')">
|
|
<i class="fas fa-search"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="wishlist-stats">
|
|
<table class="wishlist-table">
|
|
<thead>
|
|
<tr>
|
|
<th>应用名称</th>
|
|
<th>关注人数</th>
|
|
<th>最早添加时间</th>
|
|
<th>最近添加时间</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for item in wishlist_items %}
|
|
{% if not item.notified %}
|
|
<tr class="wishlist-row">
|
|
<td class="app-name">{{ item.app_name }}</td>
|
|
<td>{{ item.count }}</td>
|
|
<td>{{ item.first_added }}</td>
|
|
<td>{{ item.last_added }}</td>
|
|
<td>
|
|
<div class="action-buttons">
|
|
<button class="btn-view" onclick="viewUsers('{{ item.app_name }}')" title="查看用户">
|
|
<i class="fas fa-users"></i>
|
|
</button>
|
|
<button class="btn-notify large" onclick="notifyUsers('{{ item.app_name }}')" title="发送通知">
|
|
<i class="fas fa-envelope"></i>
|
|
<span>发送通知</span>
|
|
</button>
|
|
<button class="btn-delete" onclick="deleteWishlistItem('{{ item.app_name }}')" title="删除">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 已通知应用 -->
|
|
<div class="admin-card" id="notified-tab" style="display: none;">
|
|
<div class="card-header">
|
|
<div class="header-left">
|
|
<i class="fas fa-bell"></i>
|
|
<h3>已通知应用</h3>
|
|
</div>
|
|
<div class="filter-sort">
|
|
<select class="sort-select" onchange="sortTable(this, 'notified')">
|
|
<option value="last_added_desc">最近添加时间 ↓</option>
|
|
<option value="last_added_asc">最近添加时间 ↑</option>
|
|
<option value="count_desc">关注人数 ↓</option>
|
|
<option value="count_asc">关注人数 ↑</option>
|
|
</select>
|
|
<div class="search-box">
|
|
<input type="text" class="search-input"
|
|
placeholder="搜索已通知应用..."
|
|
onkeyup="filterTable(this, 'notified')">
|
|
<i class="fas fa-search"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="wishlist-stats">
|
|
<table class="wishlist-table notified">
|
|
<thead>
|
|
<tr>
|
|
<th>应用名称</th>
|
|
<th>关注人数</th>
|
|
<th>最早添加时间</th>
|
|
<th>最近添加时间</th>
|
|
<th>通知时间</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for item in wishlist_items %}
|
|
{% if item.notified %}
|
|
<tr class="wishlist-row">
|
|
<td class="app-name">{{ item.app_name }}</td>
|
|
<td>{{ item.count }}</td>
|
|
<td>{{ item.first_added }}</td>
|
|
<td>{{ item.last_added }}</td>
|
|
<td>{{ item.notified_at }}</td>
|
|
<td>
|
|
<div class="action-buttons">
|
|
<button class="btn-view" onclick="viewUsers('{{ item.app_name }}')" title="查看用户">
|
|
<i class="fas fa-users"></i>
|
|
</button>
|
|
<button class="btn-notify" onclick="notifyUsers('{{ item.app_name }}')" title="重新发送通知">
|
|
<i class="fas fa-paper-plane"></i>
|
|
<span>重新发送</span>
|
|
</button>
|
|
<button class="btn-delete" onclick="deleteWishlistItem('{{ item.app_name }}')" title="删除">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 用户列表弹窗 -->
|
|
<div id="usersModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3>关注用户列表</h3>
|
|
<button class="close-btn" onclick="closeUsersModal()">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div id="usersList" class="users-list"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast 容器 -->
|
|
<div id="toast" class="toast"></div>
|
|
|
|
<style>
|
|
.wishlist-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.wishlist-table th,
|
|
.wishlist-table td {
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.wishlist-table th {
|
|
background: #f5f5f5;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.btn-view,
|
|
.btn-notify,
|
|
.btn-delete {
|
|
padding: 6px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.btn-view {
|
|
background: #1890ff;
|
|
color: white;
|
|
}
|
|
|
|
.btn-notify {
|
|
background: #52c41a;
|
|
color: white;
|
|
}
|
|
|
|
.btn-notify:hover {
|
|
background: #73d13d;
|
|
}
|
|
|
|
.btn-delete {
|
|
background: #ff4d4f;
|
|
color: white;
|
|
}
|
|
|
|
.btn-view:hover {
|
|
background: #40a9ff;
|
|
}
|
|
|
|
.btn-delete:hover {
|
|
background: #ff7875;
|
|
}
|
|
|
|
.users-list {
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
.user-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.user-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.user-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.user-name {
|
|
font-weight: 500;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.user-email {
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
|
|
/* 暗色模式适配 */
|
|
[data-theme="dark"] .wishlist-table th {
|
|
background: #1f1f1f;
|
|
}
|
|
|
|
[data-theme="dark"] .wishlist-table td {
|
|
border-color: #333;
|
|
}
|
|
|
|
[data-theme="dark"] .user-item {
|
|
border-color: #333;
|
|
}
|
|
|
|
[data-theme="dark"] .user-email {
|
|
color: #999;
|
|
}
|
|
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
z-index: 1000;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-content {
|
|
background: white;
|
|
border-radius: 8px;
|
|
width: 90%;
|
|
max-width: 600px;
|
|
max-height: 80vh;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
[data-theme="dark"] .modal-content {
|
|
background: #242424;
|
|
}
|
|
|
|
.admin-card {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.card-header {
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid #eee;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.search-box {
|
|
position: relative;
|
|
width: 300px;
|
|
}
|
|
|
|
.search-input {
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
padding-right: 35px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.search-input:focus {
|
|
border-color: #1890ff;
|
|
box-shadow: 0 0 0 2px rgba(24,144,255,0.2);
|
|
outline: none;
|
|
}
|
|
|
|
.search-box i {
|
|
position: absolute;
|
|
right: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: #999;
|
|
}
|
|
|
|
/* 暗色模式适配 */
|
|
[data-theme="dark"] .admin-card {
|
|
background: #242424;
|
|
}
|
|
|
|
[data-theme="dark"] .card-header {
|
|
border-color: #333;
|
|
}
|
|
|
|
[data-theme="dark"] .search-input {
|
|
background: #333;
|
|
border-color: #444;
|
|
color: #fff;
|
|
}
|
|
|
|
[data-theme="dark"] .search-input:focus {
|
|
border-color: #177ddc;
|
|
box-shadow: 0 0 0 2px rgba(23,125,220,0.2);
|
|
}
|
|
|
|
[data-theme="dark"] .search-box i {
|
|
color: #666;
|
|
}
|
|
|
|
/* 标签页样式 */
|
|
.tab-nav {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.tab-btn {
|
|
padding: 12px 24px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
background: white;
|
|
color: #666;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 15px;
|
|
transition: all 0.3s ease;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.tab-btn.active {
|
|
background: #1890ff !important;
|
|
color: white !important;
|
|
}
|
|
|
|
.tab-btn:hover:not(.active) {
|
|
background: #f5f5f5;
|
|
}
|
|
|
|
/* 暗色模式适配 */
|
|
[data-theme="dark"] .tab-btn {
|
|
background: #242424;
|
|
color: #999;
|
|
}
|
|
|
|
[data-theme="dark"] .tab-btn.active {
|
|
background: #177ddc !important;
|
|
color: white !important;
|
|
}
|
|
|
|
[data-theme="dark"] .tab-btn:hover:not(.active) {
|
|
background: #333;
|
|
}
|
|
|
|
/* Toast 样式 */
|
|
.toast {
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
padding: 12px 24px;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: white;
|
|
border-radius: 4px;
|
|
z-index: 1000;
|
|
display: none;
|
|
animation: fadeInOut 3s ease;
|
|
}
|
|
|
|
@keyframes fadeInOut {
|
|
0% { opacity: 0; transform: translateY(-20px); }
|
|
10% { opacity: 1; transform: translateY(0); }
|
|
90% { opacity: 1; transform: translateY(0); }
|
|
100% { opacity: 0; transform: translateY(-20px); }
|
|
}
|
|
|
|
/* 暗色模式适配 */
|
|
[data-theme="dark"] .toast {
|
|
background: rgba(255, 255, 255, 0.9);
|
|
color: #000;
|
|
}
|
|
|
|
/* Add styles for filter and sort */
|
|
.filter-sort {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
.filter-select,
|
|
.sort-select {
|
|
padding: 8px 12px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
background: white;
|
|
color: #666;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.filter-select:hover,
|
|
.sort-select:hover {
|
|
border-color: #40a9ff;
|
|
}
|
|
|
|
.filter-select:focus,
|
|
.sort-select:focus {
|
|
border-color: #1890ff;
|
|
box-shadow: 0 0 0 2px rgba(24,144,255,0.2);
|
|
outline: none;
|
|
}
|
|
|
|
/* Dark mode styles for filter and sort */
|
|
[data-theme="dark"] .filter-select,
|
|
[data-theme="dark"] .sort-select {
|
|
background: #333;
|
|
border-color: #444;
|
|
color: #fff;
|
|
}
|
|
|
|
[data-theme="dark"] .filter-select:hover,
|
|
[data-theme="dark"] .sort-select:hover {
|
|
border-color: #177ddc;
|
|
}
|
|
|
|
[data-theme="dark"] .filter-select:focus,
|
|
[data-theme="dark"] .sort-select:focus {
|
|
border-color: #177ddc;
|
|
box-shadow: 0 0 0 2px rgba(23,125,220,0.2);
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
function viewUsers(appName) {
|
|
fetch(`/admin/wishlist/users/${encodeURIComponent(appName)}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
const usersList = document.getElementById('usersList');
|
|
usersList.innerHTML = data.users.map(user => `
|
|
<div class="user-item">
|
|
<img src="${user.avatar || '/static/images/default-avatar.png'}"
|
|
alt="${user.name}"
|
|
class="user-avatar">
|
|
<div class="user-info">
|
|
<div class="user-name">${user.name}</div>
|
|
<div class="user-email">${user.email || '未设置邮箱'}</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
document.getElementById('usersModal').style.display = 'flex';
|
|
} else {
|
|
showToast(data.error || '获取用户列表失败');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Failed to get users:', error);
|
|
showToast('获取用户列表失败');
|
|
});
|
|
}
|
|
|
|
function closeUsersModal() {
|
|
document.getElementById('usersModal').style.display = 'none';
|
|
}
|
|
|
|
function deleteWishlistItem(appName) {
|
|
if (!confirm(`确定要删除应用"${appName}"的所有心愿记录吗?`)) return;
|
|
|
|
fetch(`/admin/wishlist/delete/${encodeURIComponent(appName)}`, {
|
|
method: 'POST'
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
location.reload();
|
|
} else {
|
|
showToast(data.error || '删除失败');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Delete failed:', error);
|
|
showToast('删除失败,请稍后重试');
|
|
});
|
|
}
|
|
|
|
function notifyUsers(appName) {
|
|
const row = event.target.closest('tr');
|
|
const isNotified = row.closest('table').classList.contains('notified');
|
|
const message = isNotified ?
|
|
`确定要重新发送通知给所有关注"${appName}"的用户吗?` :
|
|
`确定要通知所有关注"${appName}"的用户吗?`;
|
|
|
|
if (!confirm(message)) return;
|
|
|
|
fetch(`/admin/wishlist/notify/${encodeURIComponent(appName)}`, {
|
|
method: 'POST'
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showToast(data.message || '通知发送成功');
|
|
// 移动行到已通知表格
|
|
if (!isNotified) {
|
|
const notifiedTable = document.querySelector('.wishlist-table.notified tbody');
|
|
const newRow = row.cloneNode(true);
|
|
|
|
// 修改按钮样式和文本
|
|
const notifyBtn = newRow.querySelector('.btn-notify');
|
|
notifyBtn.classList.remove('large');
|
|
notifyBtn.innerHTML = '<i class="fas fa-paper-plane"></i><span>重新发送</span>';
|
|
|
|
// 添加通知时间列
|
|
const timeCell = document.createElement('td');
|
|
timeCell.textContent = new Date().toLocaleString();
|
|
newRow.insertBefore(timeCell, newRow.lastElementChild);
|
|
|
|
notifiedTable.insertBefore(newRow, notifiedTable.firstChild);
|
|
row.remove();
|
|
|
|
// 如果未通知列表为空,自动切换到已通知标签页
|
|
const unnotifiedRows = document.querySelector('.wishlist-table:not(.notified) tbody').children.length;
|
|
if (unnotifiedRows === 0) {
|
|
switchTab('notified');
|
|
}
|
|
}
|
|
} else {
|
|
showToast(data.error || '发送失败');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Notification failed:', error);
|
|
showToast('发送失败,请稍后重试');
|
|
});
|
|
}
|
|
|
|
// 点击遮罩层关闭弹窗
|
|
document.getElementById('usersModal').addEventListener('click', function(e) {
|
|
if (e.target === this) {
|
|
closeUsersModal();
|
|
}
|
|
});
|
|
|
|
function filterTable(input, type) {
|
|
const searchText = input.value.toLowerCase();
|
|
const table = type === 'notified' ?
|
|
document.querySelector('.wishlist-table.notified') :
|
|
document.querySelector('.wishlist-table:not(.notified)');
|
|
|
|
const rows = table.querySelectorAll('tbody tr');
|
|
|
|
rows.forEach(row => {
|
|
const appName = row.querySelector('.app-name').textContent.toLowerCase();
|
|
const matchesSearch = appName.includes(searchText);
|
|
row.style.display = matchesSearch ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
function switchTab(tabId) {
|
|
// 更新标签按钮状态
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
});
|
|
// 获取当前点击的按钮
|
|
const activeBtn = document.querySelector(`.tab-btn[onclick*="${tabId}"]`);
|
|
activeBtn.classList.add('active');
|
|
|
|
// 切换内容显示
|
|
document.getElementById('unnotified-tab').style.display = tabId === 'unnotified' ? 'block' : 'none';
|
|
document.getElementById('notified-tab').style.display = tabId === 'notified' ? 'block' : 'none';
|
|
}
|
|
|
|
// Toast 通知函数
|
|
function showToast(message, duration = 3000) {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.style.display = 'block';
|
|
|
|
// 重置动画
|
|
toast.style.animation = 'none';
|
|
toast.offsetHeight; // 触发重排
|
|
toast.style.animation = 'fadeInOut 3s ease';
|
|
|
|
setTimeout(() => {
|
|
toast.style.display = 'none';
|
|
}, duration);
|
|
}
|
|
|
|
function sortTable(select, type) {
|
|
const sortBy = select.value;
|
|
const table = type === 'notified' ?
|
|
document.querySelector('.wishlist-table.notified') :
|
|
document.querySelector('.wishlist-table:not(.notified)');
|
|
|
|
const tbody = table.querySelector('tbody');
|
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
|
|
rows.sort((a, b) => {
|
|
let aValue, bValue;
|
|
|
|
if (sortBy.startsWith('count')) {
|
|
aValue = parseInt(a.querySelectorAll('td')[1].textContent);
|
|
bValue = parseInt(b.querySelectorAll('td')[1].textContent);
|
|
} else if (sortBy.startsWith('last_added')) {
|
|
const aIndex = type === 'notified' ? 3 : 3;
|
|
const bIndex = type === 'notified' ? 3 : 3;
|
|
aValue = new Date(a.querySelectorAll('td')[aIndex].textContent);
|
|
bValue = new Date(b.querySelectorAll('td')[bIndex].textContent);
|
|
}
|
|
|
|
if (sortBy.endsWith('asc')) {
|
|
return aValue - bValue;
|
|
} else {
|
|
return bValue - aValue;
|
|
}
|
|
});
|
|
|
|
rows.forEach(row => tbody.appendChild(row));
|
|
}
|
|
</script>
|
|
{% endblock %} |