Files
ns2.0/templates/admin_apps.html

966 lines
27 KiB
HTML
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card full-width">
<div class="card-header">
<div class="header-left">
<i class="fas fa-th-large"></i>
<h3>应用管理 {% if search %}(搜索结果){% else %}(共{{ total_count }}个){% endif %}</h3>
</div>
<div class="header-actions">
<select id="category-filter" onchange="filterByCategory(this.value)" class="category-select">
<option value="">全部分类</option>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
<form class="search-form header-search" method="GET" action="{{ url_for('admin_apps') }}">
<div class="search-wrapper">
<i class="fas fa-search"></i>
<input type="text" name="search" placeholder="搜索应用..." value="{{ search }}">
</div>
</form>
</div>
</div>
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>图标</th>
<th>名称</th>
<th>分类</th>
<th>添加时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="apps-tbody">
<!-- 应用列表将通过JavaScript动态加载 -->
</tbody>
</table>
<!-- 加载指示器 -->
<div id="loading-indicator" style="display: none; text-align: center; padding: 20px;">
<div class="spinner"></div>
<p>加载中...</p>
</div>
<!-- 加载更多按钮 -->
<div id="load-more" style="text-align: center; padding: 20px;">
<button onclick="loadMoreApps()" class="btn-primary">
<i class="fas fa-sync"></i> 加载更多
</button>
</div>
</div>
</div>
</div>
</div>
<style>
.app-icon-container {
display: flex;
align-items: center;
gap: 10px;
width: 300px;
}
.app-icon-small {
width: 40px;
height: 40px;
flex-shrink: 0;
position: relative;
border-radius: 8px;
overflow: hidden;
}
.app-icon-small img {
width: 100%;
height: 100%;
object-fit: cover;
transition: all 0.3s ease;
}
.icon-edit-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.3s ease;
}
.app-icon-small:hover .icon-edit-overlay {
opacity: 1;
}
.icon-edit-btn {
color: white;
cursor: pointer;
padding: 5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-edit-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.icon-url-input {
flex: 1;
min-width: 0;
padding: 4px 8px;
border: 1px solid #d2d2d7;
border-radius: 4px;
font-size: 12px;
color: #666;
transition: all 0.3s ease;
}
.icon-url-input:hover {
border-color: #b2b2b2;
}
.icon-url-input:focus {
border-color: #0066cc;
outline: none;
color: #333;
}
/* 调整表格第一列的宽度 */
.admin-table td:first-child {
width: 320px;
max-width: 320px;
}
.tablet-filter {
display: flex;
gap: 10px;
align-items: center;
}
.tablet-filter input {
padding: 8px 12px;
border: 1px solid #d2d2d7;
border-radius: 6px;
font-size: 14px;
min-width: 200px;
}
.tablet-filter input:focus {
border-color: #007AFF;
outline: none;
}
.tablet-filter button {
padding: 8px 16px;
background: #007AFF;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s ease;
}
.tablet-filter button:hover {
background: #0056b3;
}
/* 添加成功提示样式 */
.filter-success {
position: fixed;
top: 20px;
right: 20px;
padding: 10px 20px;
background: #4CAF50;
color: white;
border-radius: 4px;
z-index: 1000;
animation: fadeInOut 3s forwards;
}
@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); }
}
.filter-card {
margin-bottom: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.filter-content {
padding: 15px;
}
.filter-group {
display: flex;
gap: 10px;
align-items: center;
}
.filter-group input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d2d2d7;
border-radius: 6px;
font-size: 14px;
min-width: 300px;
}
.filter-group input:focus {
border-color: #007AFF;
outline: none;
}
.filter-group button {
padding: 8px 16px;
background: #007AFF;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s ease;
white-space: nowrap;
}
.filter-group button:hover {
background: #0056b3;
}
/* 批量筛选结果弹窗样式 */
.filter-result-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.filter-result-content {
position: relative;
background: white;
margin: 10% auto;
padding: 20px;
width: 90%;
max-width: 500px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.filter-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.filter-result-header h3 {
margin: 0;
font-size: 18px;
}
.filter-result-close {
font-size: 24px;
cursor: pointer;
color: #666;
}
.filter-result-list {
max-height: 300px;
overflow-y: auto;
}
.filter-result-item {
padding: 8px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-result-item.success {
color: #4CAF50;
}
.filter-result-item.error {
color: #f44336;
}
</style>
<div id="filterResultModal" class="filter-result-modal">
<div class="filter-result-content">
<div class="filter-result-header">
<h3>筛选结果</h3>
<span class="filter-result-close" onclick="closeFilterResultModal()">&times;</span>
</div>
<div id="filterResultList" class="filter-result-list">
<!-- 结果将在这里动态显示 -->
</div>
</div>
</div>
<script>
let currentPage = 1;
const pageSize = 50;
let loading = false;
let hasMore = true;
let lastScrollPosition = 0;
// 初始加载
document.addEventListener('DOMContentLoaded', () => {
loadApps();
});
// 滚动加载
document.addEventListener('scroll', () => {
// 获取当前滚动位置
const currentScroll = window.scrollY;
// 只在向下滚动时检查是否需要加载更多
if (currentScroll > lastScrollPosition) {
const scrollThreshold = document.documentElement.scrollHeight - window.innerHeight - 200;
if (currentScroll > scrollThreshold && !loading && hasMore) {
loadMoreApps();
}
}
// 更新上次滚动位置
lastScrollPosition = currentScroll;
});
// 添加分类筛选函数
function filterByCategory(categoryId) {
resetLoadState();
const tbody = document.getElementById('apps-tbody');
tbody.innerHTML = '';
loadApps(1, categoryId);
}
// 修改loadApps函数以支持分类筛选
function loadApps(page = 1, categoryId = '') {
if (loading || !hasMore) return;
loading = true;
const loadingIndicator = document.getElementById('loading-indicator');
loadingIndicator.style.display = 'block';
let url = "{{ url_for('admin_get_apps') }}?page=" + page + "&size=" + pageSize;
if (categoryId) {
url += "&category_id=" + categoryId;
}
{% if search %}
url += "&search={{ search }}";
{% endif %}
fetch(url)
.then(response => response.json())
.then(data => {
const tbody = document.getElementById('apps-tbody');
if (page === 1) {
tbody.innerHTML = '';
}
if (data.success) {
data.apps.forEach(app => {
const tr = createAppRow(app);
tbody.appendChild(tr);
});
hasMore = data.has_more;
document.getElementById('load-more').style.display = hasMore ? 'block' : 'none';
} else {
showNotification(data.error || '加载失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('加载失败,请重试', 'error');
})
.finally(() => {
loading = false;
loadingIndicator.style.display = 'none';
});
}
function loadMoreApps() {
if (loading || !hasMore) return;
loading = true;
const loadingIndicator = document.getElementById('loading-indicator');
loadingIndicator.style.display = 'block';
let url = "{{ url_for('admin_get_apps') }}?page=" + currentPage + "&size=" + pageSize;
const categoryId = document.getElementById('category-filter')?.value;
if (categoryId) {
url += "&category_id=" + categoryId;
}
{% if search %}
url += "&search={{ search }}";
{% endif %}
fetch(url)
.then(response => response.json())
.then(data => {
const tbody = document.getElementById('apps-tbody');
if (data.success) {
data.apps.forEach(app => {
const tr = createAppRow(app);
tbody.appendChild(tr);
});
hasMore = data.has_more;
if (hasMore) {
currentPage++;
}
document.getElementById('load-more').style.display = hasMore ? 'block' : 'none';
} else {
showNotification(data.error || '加载失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('加载失败,请重试', 'error');
})
.finally(() => {
loading = false;
loadingIndicator.style.display = 'none';
});
}
function createAppRow(app) {
const tr = document.createElement('tr');
tr.dataset.appId = app.id;
tr.innerHTML = `
<td>
<div class="app-icon-container">
<div class="app-icon-small">
<img src="${app.icon_path.startsWith('http') ? app.icon_path : '/static/uploads/' + app.icon_path}"
alt="${app.name}"
loading="lazy">
<div class="icon-edit-overlay">
<label class="icon-edit-btn" title="上传图标">
<i class="fas fa-camera"></i>
<input type="file"
class="icon-input"
accept="image/*"
onchange="updateIcon(${app.id}, this)"
style="display: none;">
</label>
</div>
</div>
<input type="url"
value="${app.icon_path}"
placeholder="图标URL"
onchange="updateIconUrl(${app.id}, this.value)"
class="icon-url-input">
</div>
</td>
<td>
<form onsubmit="submitEditForm(event, this)" class="edit-form">
<input type="hidden" name="app_id" value="${app.id}">
<div class="edit-group">
<input type="text" name="name" value="${app.name}" required placeholder="应用名称">
<select name="category_id" required>
{% for category in categories %}
<option value="{{ category.id }}"
${app.category_id === {{ category.id }} ? 'selected' : ''}>
{{ category.name }}
</option>
{% endfor %}
</select>
<button type="submit" class="btn-edit" title="保存">
<i class="fas fa-save"></i>
</button>
</div>
</form>
</td>
<td>${app.category_name}</td>
<td>${app.created_at}</td>
<td>
<div class="action-buttons">
<a href="#" class="btn-delete" onclick="deleteApp(${app.id}); return false;">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
`;
return tr;
}
function submitEditForm(event, form) {
event.preventDefault();
const formData = new FormData(form);
const appId = formData.get('app_id');
fetch('/edit_app/' + appId, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 显示成功消息
showNotification('修改成功', 'success');
// 更新分类名称显示
const row = form.closest('tr');
const categorySelect = form.querySelector('select[name="category_id"]');
const selectedOption = categorySelect.options[categorySelect.selectedIndex];
const categoryCell = row.querySelector('td:nth-child(3)'); // 第三列是分类显示列
categoryCell.textContent = selectedOption.textContent;
} else {
showNotification(data.error || '修改失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('保存失败,请重试', 'error');
});
}
function deleteApp(appId) {
if (confirm('确定要删除这个应用吗?')) {
fetch(`{{ url_for('delete_app', app_id=0) }}`.replace('0', appId), {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
// 找到并移除对应的表格行
const row = document.querySelector(`tr[data-app-id="${appId}"]`);
if (row) {
row.remove();
}
// 保持当前滚动位置
const currentScroll = window.scrollY;
window.scrollTo(0, currentScroll);
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('删除失败,请重试', 'error');
});
}
}
function updateIcon(appId, input) {
if (!input.files || !input.files[0]) {
showNotification('请选择图片文件', 'error');
return;
}
// 检查文件类型
const file = input.files[0];
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
showNotification('不支持的文件类型,请选择 jpg、png 或 gif 格式的图片', 'error');
return;
}
// 检查文件大小限制为5MB
if (file.size > 5 * 1024 * 1024) {
showNotification('文件太大请选择5MB以下的图片', 'error');
return;
}
const formData = new FormData();
formData.append('icon', file);
// 修改获取图片元素的方式
const parentTd = input.closest('td');
if (!parentTd) {
showNotification('找不到图片元素', 'error');
return;
}
const imgElement = parentTd.querySelector('.app-icon-small img');
if (!imgElement) {
showNotification('找不到图片元素', 'error');
return;
}
const originalSrc = imgElement.src;
imgElement.style.opacity = '0.5';
console.log('开始上传图标:', {
appId: appId,
fileName: file.name,
fileType: file.type,
fileSize: file.size
});
fetch(`/admin/update_app_icon/${appId}`, {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
.then(response => {
console.log('服务器响应:', response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log('服务器响应数据:', data);
if (data.success) {
showNotification(data.message || '图标更新成功', 'success');
const newSrc = '/static/uploads/' + data.icon_path;
console.log('更新图标路径:', newSrc);
imgElement.src = newSrc;
} else {
console.error('更新失败:', data.error);
showNotification(data.error || '更新失败', 'error');
imgElement.src = originalSrc;
}
})
.catch(error => {
console.error('请求错误:', error);
showNotification(`更新失败: ${error.message}`, 'error');
imgElement.src = originalSrc;
})
.finally(() => {
imgElement.style.opacity = '1';
input.value = '';
console.log('图标更新操作完成');
});
}
function showNotification(message, type = 'success') {
console.log(`显示通知: ${type} - ${message}`);
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
notification.style.position = 'fixed';
notification.style.bottom = '20px';
notification.style.right = '20px';
notification.style.padding = '10px 20px';
notification.style.borderRadius = '4px';
notification.style.backgroundColor = type === 'success' ? '#4CAF50' : '#f44336';
notification.style.color = 'white';
notification.style.zIndex = '1000';
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
notification.style.transition = 'all 0.3s ease';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
}, 10);
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
// 添加样式
const style = document.createElement('style');
style.textContent = `
.app-icon-small {
position: relative;
width: 40px;
height: 40px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
}
.app-icon-small img {
width: 100%;
height: 100%;
object-fit: cover;
transition: all 0.3s ease;
}
.icon-edit-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.3s ease;
}
.app-icon-small:hover .icon-edit-overlay {
opacity: 1;
}
.app-icon-small:hover img {
transform: scale(1.1);
}
.icon-edit-btn {
color: white;
cursor: pointer;
padding: 5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-edit-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.icon-edit-btn i {
font-size: 14px;
}
`;
document.head.appendChild(style);
// 添加样式
const filterStyle = document.createElement('style');
filterStyle.textContent = `
.header-actions {
display: flex;
gap: 15px;
align-items: center;
}
.category-select {
padding: 8px 12px;
border: 1px solid #d2d2d7;
border-radius: 6px;
font-size: 14px;
color: #1d1d1f;
background-color: white;
cursor: pointer;
transition: all 0.3s ease;
}
.category-select:hover {
border-color: #0066cc;
}
.category-select:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0,102,204,0.1);
}
`;
document.head.appendChild(filterStyle);
function updateIconUrl(appId, newUrl) {
if (!newUrl) return;
if (!newUrl.startsWith('http://') && !newUrl.startsWith('https://')) {
showNotification('请输入有效的URL地址', 'error');
return;
}
// 显示加载状态
const tr = document.querySelector(`tr[data-app-id="${appId}"]`);
const input = tr ? tr.querySelector('.icon-url-input') : null;
const img = tr ? tr.querySelector('.app-icon-small img') : null;
const originalUrl = img ? img.src : '';
if (input) input.disabled = true;
if (img) img.style.opacity = '0.5';
// 添加错误处理和重试逻辑
const updateRequest = async (retryCount = 0) => {
try {
// 修改为使用 url_for 生成的完整路径
const response = await fetch("{{ url_for('update_app_icon_url', app_id=0) }}".replace('0', appId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ icon_url: newUrl }),
credentials: 'same-origin'
});
// 检查响应状态
if (!response.ok) {
// 如果是 401 未授权,重定向到登录页面
if (response.status === 401) {
window.location.href = "{{ url_for('login') }}";
return;
}
// 如果是 429 请求过多,等待后重试
if (response.status === 429 && retryCount < 3) {
await new Promise(resolve => setTimeout(resolve, 1000));
return updateRequest(retryCount + 1);
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
showNotification('图标链接更新成功', 'success');
if (img) {
img.src = newUrl;
img.style.opacity = '1';
}
} else {
throw new Error(data.error || '更新失败');
}
} catch (error) {
console.error('Error:', error);
showNotification(error.message || '更新失败,请重试', 'error');
// 恢复原始状态
if (img) {
img.src = originalUrl;
img.style.opacity = '1';
}
if (input) {
input.value = originalUrl;
}
} finally {
if (input) input.disabled = false;
if (img) img.style.opacity = '1';
}
};
// 开始更新请求
updateRequest();
}
// 修改防抖函数
const debounce = (func, wait) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
return new Promise(resolve => {
timeout = setTimeout(() => {
resolve(func.apply(this, args));
}, wait);
});
};
};
// 使用防抖包装更新函数
const debouncedUpdateIconUrl = debounce(updateIconUrl, 500);
// 修改事件监听器
document.addEventListener('DOMContentLoaded', () => {
const tbody = document.getElementById('apps-tbody');
// 使事件委托处理图标URL输入
tbody.addEventListener('change', async (e) => {
if (e.target.classList.contains('icon-url-input')) {
const tr = e.target.closest('tr');
const appId = tr.dataset.appId;
if (appId) {
try {
await debouncedUpdateIconUrl(appId, e.target.value);
} catch (error) {
console.error('Error:', error);
showNotification('更新失败,请重试', 'error');
}
}
}
});
});
// 添加通知函数
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
Object.assign(notification.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
padding: '10px 20px',
borderRadius: '4px',
backgroundColor: type === 'success' ? '#4CAF50' : '#f44336',
color: 'white',
zIndex: '1000',
opacity: '0',
transform: 'translateY(20px)',
transition: 'all 0.3s ease'
});
document.body.appendChild(notification);
// 触发重排以应用过渡效果
notification.offsetHeight;
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
// 添加回车键触发筛选
document.getElementById('tabletAppFilter').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
filterTabletApps();
}
});
// 点击弹窗外部关闭
window.onclick = function(event) {
const modal = document.getElementById('filterResultModal');
if (event.target == modal) {
modal.style.display = 'none';
}
}
// 添加重置加载状态的函数
function resetLoadState() {
currentPage = 1;
hasMore = true;
loading = false;
lastScrollPosition = 0;
}
</script>
{% endblock %}