534 lines
22 KiB
HTML
534 lines
22 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{% block title %}软件授权管理系统{% endblock %}</title>
|
||
|
||
<!-- Bootstrap CSS -->
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<!-- Font Awesome -->
|
||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||
<!-- Chart.js -->
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||
<!-- Custom CSS -->
|
||
<link href="/static/css/custom.css" rel="stylesheet">
|
||
|
||
<style>
|
||
.sidebar {
|
||
min-height: 100vh;
|
||
background-color: #343a40;
|
||
}
|
||
.sidebar .nav-link {
|
||
color: #fff;
|
||
padding: 1rem;
|
||
border-radius: 0;
|
||
}
|
||
.sidebar .nav-link:hover,
|
||
.sidebar .nav-link.active {
|
||
background-color: #495057;
|
||
color: #fff;
|
||
}
|
||
.main-content {
|
||
min-height: 100vh;
|
||
background-color: #f8f9fa;
|
||
}
|
||
.card-stats {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
}
|
||
.loading {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 9999;
|
||
}
|
||
.spinner {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
/* 消息通知样式 */
|
||
.notification-container {
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
z-index: 10000;
|
||
width: 300px;
|
||
}
|
||
/* 移动端导航按钮 */
|
||
.mobile-nav-btn {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
z-index: 1000;
|
||
border-radius: 50%;
|
||
width: 60px;
|
||
height: 60px;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||
}
|
||
.mobile-nav-btn i {
|
||
font-size: 24px;
|
||
}
|
||
/* 移动端导航菜单 */
|
||
.mobile-nav-menu {
|
||
position: fixed;
|
||
bottom: 90px;
|
||
right: 20px;
|
||
z-index: 1000;
|
||
display: none;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
background: white;
|
||
border-radius: 10px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||
padding: 15px;
|
||
min-width: 200px;
|
||
}
|
||
.mobile-nav-menu a {
|
||
padding: 10px 15px;
|
||
border-radius: 5px;
|
||
text-decoration: none;
|
||
color: #333;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.mobile-nav-menu a:hover {
|
||
background-color: #f0f0f0;
|
||
}
|
||
.mobile-nav-menu a.active {
|
||
background-color: #e9ecef;
|
||
font-weight: bold;
|
||
}
|
||
/* 在小屏幕上显示移动端导航按钮 */
|
||
@media (max-width: 767.98px) {
|
||
.mobile-nav-btn {
|
||
display: flex;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
{% block extra_css %}{% endblock %}
|
||
</head>
|
||
<body>
|
||
<!-- Loading Spinner -->
|
||
<div class="loading" id="loading">
|
||
<div class="spinner">
|
||
<div class="spinner-border text-light" role="status">
|
||
<span class="visually-hidden">加载中...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notification Container -->
|
||
<div class="notification-container" id="notification-container"></div>
|
||
|
||
{% if current_user.is_authenticated %}
|
||
<!-- Main Container -->
|
||
<div class="container-fluid">
|
||
<div class="row">
|
||
<!-- Sidebar -->
|
||
<nav class="col-md-3 col-lg-2 d-md-block sidebar collapse">
|
||
<div class="position-sticky pt-3">
|
||
<div class="text-center mb-4">
|
||
<h5 class="text-white">太一软件管理系统</h5>
|
||
<small class="text-muted">欢迎, {{ current_user.username }}</small>
|
||
</div>
|
||
|
||
<ul class="nav flex-column">
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}">
|
||
<i class="fas fa-tachometer-alt me-2"></i>
|
||
仪表板
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint and 'product' in request.endpoint }}" href="{{ url_for('web.products') }}">
|
||
<i class="fas fa-box me-2"></i>
|
||
产品管理
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint and 'license' in request.endpoint }}" href="{{ url_for('web.licenses') }}">
|
||
<i class="fas fa-key me-2"></i>
|
||
卡密管理
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint and 'version' in request.endpoint }}" href="{{ url_for('web.versions') }}">
|
||
<i class="fas fa-code-branch me-2"></i>
|
||
版本管理
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint and 'device' in request.endpoint }}" href="{{ url_for('web.devices') }}">
|
||
<i class="fas fa-desktop me-2"></i>
|
||
设备管理
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint and 'ticket' in request.endpoint }}" href="{{ url_for('web.tickets') }}">
|
||
<i class="fas fa-ticket-alt me-2"></i>
|
||
工单管理
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint and 'order' in request.endpoint }}" href="{{ url_for('web.orders') }}">
|
||
<i class="fas fa-file-invoice me-2"></i>
|
||
订单管理
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint and 'statistics' in request.endpoint }}" href="{{ url_for('web.statistics') }}">
|
||
<i class="fas fa-chart-bar me-2"></i>
|
||
统计分析
|
||
</a>
|
||
</li>
|
||
{% if current_user.is_super_admin() %}
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint and 'admin' in request.endpoint }}" href="{{ url_for('web.admins') }}">
|
||
<i class="fas fa-user-cog me-2"></i>
|
||
账号管理
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint and 'settings' in request.endpoint }}" href="{{ url_for('web.settings') }}">
|
||
<i class="fas fa-cog me-2"></i>
|
||
系统设置
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint and 'log' in request.endpoint }}" href="{{ url_for('web.logs') }}">
|
||
<i class="fas fa-file-alt me-2"></i>
|
||
日志管理
|
||
</a>
|
||
</li>
|
||
{% endif %}
|
||
</ul>
|
||
|
||
<hr class="text-white">
|
||
|
||
<div class="text-center">
|
||
<a href="{{ url_for('web.logout') }}" class="btn btn-outline-light btn-sm">
|
||
<i class="fas fa-sign-out-alt me-1"></i>
|
||
退出登录
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- Main Content -->
|
||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 main-content">
|
||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||
<h1 class="h2">{% block page_title %}仪表板{% endblock %}</h1>
|
||
<div class="btn-toolbar mb-2 mb-md-0">
|
||
{% block page_actions %}{% endblock %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Flash Messages -->
|
||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||
{% if messages %}
|
||
{% for category, message in messages %}
|
||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||
{{ message }}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
</div>
|
||
{% endfor %}
|
||
{% endif %}
|
||
{% endwith %}
|
||
|
||
<!-- Page Content -->
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 移动端导航按钮 -->
|
||
<button class="btn btn-primary mobile-nav-btn" id="mobileNavBtn" type="button">
|
||
<i class="fas fa-bars"></i>
|
||
</button>
|
||
|
||
<!-- 移动端导航菜单 -->
|
||
<div class="mobile-nav-menu" id="mobileNavMenu">
|
||
<a class="{{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}">
|
||
<i class="fas fa-tachometer-alt"></i>
|
||
仪表板
|
||
</a>
|
||
<a class="{{ 'active' if request.endpoint and 'product' in request.endpoint }}" href="{{ url_for('web.products') }}">
|
||
<i class="fas fa-box"></i>
|
||
产品管理
|
||
</a>
|
||
<a class="{{ 'active' if request.endpoint and 'license' in request.endpoint }}" href="{{ url_for('web.licenses') }}">
|
||
<i class="fas fa-key"></i>
|
||
卡密管理
|
||
</a>
|
||
<a class="{{ 'active' if request.endpoint and 'version' in request.endpoint }}" href="{{ url_for('web.versions') }}">
|
||
<i class="fas fa-code-branch"></i>
|
||
版本管理
|
||
</a>
|
||
<a class="{{ 'active' if request.endpoint and 'device' in request.endpoint }}" href="{{ url_for('web.devices') }}">
|
||
<i class="fas fa-desktop"></i>
|
||
设备管理
|
||
</a>
|
||
<a class="{{ 'active' if request.endpoint and 'ticket' in request.endpoint }}" href="{{ url_for('web.tickets') }}">
|
||
<i class="fas fa-ticket-alt"></i>
|
||
工单管理
|
||
</a>
|
||
<a class="{{ 'active' if request.endpoint and 'order' in request.endpoint }}" href="{{ url_for('web.orders') }}">
|
||
<i class="fas fa-file-invoice"></i>
|
||
订单管理
|
||
</a>
|
||
<a class="{{ 'active' if request.endpoint and 'statistics' in request.endpoint }}" href="{{ url_for('web.statistics') }}">
|
||
<i class="fas fa-chart-bar"></i>
|
||
统计分析
|
||
</a>
|
||
{% if current_user.is_super_admin() %}
|
||
<a class="{{ 'active' if request.endpoint and 'admin' in request.endpoint }}" href="{{ url_for('web.admins') }}">
|
||
<i class="fas fa-user-cog"></i>
|
||
账号管理
|
||
</a>
|
||
<a class="{{ 'active' if request.endpoint and 'settings' in request.endpoint }}" href="{{ url_for('web.settings') }}">
|
||
<i class="fas fa-cog"></i>
|
||
系统设置
|
||
</a>
|
||
<a class="{{ 'active' if request.endpoint and 'log' in request.endpoint }}" href="{{ url_for('web.logs') }}">
|
||
<i class="fas fa-file-alt"></i>
|
||
日志管理
|
||
</a>
|
||
{% endif %}
|
||
<hr>
|
||
<a href="{{ url_for('web.logout') }}">
|
||
<i class="fas fa-sign-out-alt"></i>
|
||
退出登录
|
||
</a>
|
||
</div>
|
||
{% else %}
|
||
<!-- Login Layout -->
|
||
<div class="container-fluid vh-100 d-flex align-items-center justify-content-center bg-light">
|
||
<div class="card shadow-lg" style="width: 100%; max-width: 400px;">
|
||
<div class="card-body">
|
||
{% block login_content %}{% endblock %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Bootstrap JS -->
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||
<!-- jQuery -->
|
||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||
<!-- Custom JS -->
|
||
<script src="/static/js/custom.js"></script>
|
||
|
||
<!-- Common Functions -->
|
||
<script>
|
||
// 设置前端域名全局变量
|
||
window.FRONTEND_DOMAIN = '{{ config.FRONTEND_DOMAIN or "" }}';
|
||
|
||
// 显示加载动画
|
||
function showLoading() {
|
||
document.getElementById('loading').style.display = 'block';
|
||
}
|
||
|
||
// 隐藏加载动画
|
||
function hideLoading() {
|
||
document.getElementById('loading').style.display = 'none';
|
||
}
|
||
|
||
// AJAX请求封装
|
||
function apiRequest(url, options = {}) {
|
||
// 自动构建完整的API URL
|
||
let fullUrl = url;
|
||
|
||
// 检查是否配置了前端域名
|
||
const frontendDomain = window.FRONTEND_DOMAIN || '';
|
||
|
||
// 修复URL构建逻辑,避免重复域名问题
|
||
if (url.startsWith('/')) {
|
||
// 如果是绝对路径,则使用配置的域名或当前主机
|
||
if (frontendDomain && !url.startsWith(frontendDomain)) {
|
||
// 确保frontendDomain不包含路径部分
|
||
let cleanDomain = frontendDomain;
|
||
try {
|
||
const urlObj = new URL(frontendDomain.startsWith('http') ? frontendDomain : 'http://' + frontendDomain);
|
||
cleanDomain = urlObj.origin;
|
||
} catch (e) {
|
||
// 如果解析失败,使用原始值
|
||
if (frontendDomain.includes('/')) {
|
||
cleanDomain = frontendDomain.split('/')[0];
|
||
}
|
||
}
|
||
fullUrl = cleanDomain + url;
|
||
} else if (!frontendDomain) {
|
||
fullUrl = window.location.origin + url;
|
||
}
|
||
} else if (!url.startsWith('http')) {
|
||
// 如果不是完整URL且不以http开头,则添加API前缀
|
||
if (frontendDomain) {
|
||
// 确保frontendDomain不包含路径部分
|
||
let cleanDomain = frontendDomain;
|
||
try {
|
||
const urlObj = new URL(frontendDomain.startsWith('http') ? frontendDomain : 'http://' + frontendDomain);
|
||
cleanDomain = urlObj.origin;
|
||
} catch (e) {
|
||
// 如果解析失败,使用原始值
|
||
if (frontendDomain.includes('/')) {
|
||
cleanDomain = frontendDomain.split('/')[0];
|
||
}
|
||
}
|
||
fullUrl = cleanDomain + '/api/v1/' + url;
|
||
} else {
|
||
fullUrl = window.location.origin + '/api/v1/' + url;
|
||
}
|
||
}
|
||
|
||
showLoading();
|
||
|
||
// 检查body是否是FormData,如果是则不设置Content-Type(让浏览器自动设置)
|
||
const isFormData = options.body instanceof FormData;
|
||
const defaultOptions = {
|
||
headers: isFormData ? {} : {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
credentials: 'same-origin' // 重要: 使用session cookies
|
||
};
|
||
|
||
return fetch(fullUrl, { ...defaultOptions, ...options })
|
||
.then(response => {
|
||
hideLoading();
|
||
if (!response.ok) {
|
||
// 对于401错误,表示需要重新登录
|
||
if (response.status === 401) {
|
||
return response.json().then(data => {
|
||
showNotification(data.message || '会话已过期,请重新登录', 'warning');
|
||
// 延迟重定向,给用户看到提示的时间
|
||
setTimeout(() => {
|
||
window.location.href = '/login';
|
||
}, 1500);
|
||
throw new Error('SESSION_EXPIRED');
|
||
});
|
||
}
|
||
// 对于403错误,显示权限不足的提示
|
||
else if (response.status === 403) {
|
||
// 先尝试解析错误信息
|
||
return response.json().then(errorData => {
|
||
showNotification(errorData.message || '权限不足,无法执行此操作', 'error');
|
||
throw new Error(`403: ${errorData.message || '权限不足'}`);
|
||
}).catch(() => {
|
||
// 如果无法解析JSON,使用默认消息
|
||
showNotification('权限不足,无法执行此操作', 'error');
|
||
throw new Error('403: 权限不足');
|
||
});
|
||
}
|
||
// 其他错误状态码
|
||
return response.json().then(errorData => {
|
||
throw new Error(`${response.status}: ${errorData.message || response.statusText}`);
|
||
}).catch(() => {
|
||
// 如果无法解析JSON错误信息,则使用默认消息
|
||
throw new Error(`${response.status}: ${response.statusText}`);
|
||
});
|
||
}
|
||
return response.json();
|
||
})
|
||
.catch(error => {
|
||
hideLoading();
|
||
// 只有非会话过期的错误才需要在控制台显示
|
||
if (error.message !== 'SESSION_EXPIRED') {
|
||
console.error('API request failed:', error);
|
||
}
|
||
throw error;
|
||
});
|
||
}
|
||
|
||
// 格式化日期
|
||
function formatDate(dateString) {
|
||
if (!dateString) return '-';
|
||
const date = new Date(dateString);
|
||
return date.toLocaleDateString('zh-CN') + ' ' + date.toLocaleTimeString('zh-CN');
|
||
}
|
||
|
||
// 格式化文件大小
|
||
function formatFileSize(bytes) {
|
||
if (bytes === 0) return '0 Bytes';
|
||
const k = 1024;
|
||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
}
|
||
|
||
// HTML转义函数 - 防止XSS攻击
|
||
function escapeHtml(text) {
|
||
if (text == null) return '';
|
||
const map = {
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
};
|
||
return String(text).replace(/[&<>"']/g, m => map[m]);
|
||
}
|
||
|
||
// 显示通知 - 统一使用静态JS中的函数
|
||
function showNotification(message, type = 'info') {
|
||
// 创建通知元素
|
||
const alertDiv = document.createElement('div');
|
||
const alertType = type === 'error' ? 'danger' : type;
|
||
alertDiv.className = `alert alert-${alertType} alert-dismissible fade show`;
|
||
alertDiv.innerHTML = `
|
||
${message}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
`;
|
||
alertDiv.style.marginBottom = '10px';
|
||
|
||
// 插入到通知容器中
|
||
const container = document.getElementById('notification-container') || document.querySelector('main');
|
||
if (container) {
|
||
container.insertBefore(alertDiv, container.firstChild);
|
||
|
||
// 自动隐藏
|
||
setTimeout(() => {
|
||
if (alertDiv.parentNode) {
|
||
alertDiv.remove();
|
||
}
|
||
}, 5000);
|
||
}
|
||
}
|
||
|
||
// 移动端导航菜单控制
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const mobileNavBtn = document.getElementById('mobileNavBtn');
|
||
const mobileNavMenu = document.getElementById('mobileNavMenu');
|
||
|
||
if (mobileNavBtn && mobileNavMenu) {
|
||
mobileNavBtn.addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
mobileNavMenu.style.display = mobileNavMenu.style.display === 'flex' ? 'none' : 'flex';
|
||
});
|
||
|
||
// 点击其他地方隐藏菜单
|
||
document.addEventListener('click', function(e) {
|
||
if (!mobileNavBtn.contains(e.target) && !mobileNavMenu.contains(e.target)) {
|
||
mobileNavMenu.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
// 页面加载完成后隐藏加载动画
|
||
hideLoading();
|
||
});
|
||
</script>
|
||
|
||
{% block extra_js %}{% endblock %}
|
||
</body>
|
||
</html> |