licensing-cotton/web/index.html
2025-11-01 15:19:24 +08:00

813 lines
28 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>License Management System</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
background: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.header h1 {
color: #333;
display: inline-block;
}
.user-info {
float: right;
line-height: 32px;
}
.btn-logout {
background: #e74c3c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
margin-left: 10px;
}
.btn-logout:hover {
background: #c0392b;
}
.login-container {
max-width: 400px;
margin: 100px auto;
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.login-container h2 {
text-align: center;
margin-bottom: 30px;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: 500;
}
input[type="text"],
input[type="password"],
input[type="datetime-local"],
select,
textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.btn {
width: 100%;
padding: 12px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
.btn:hover {
background: #5568d3;
}
.btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.dashboard {
display: none;
}
.dashboard.active {
display: block;
}
.tabs {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.tab-buttons {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #eee;
}
.tab-btn {
padding: 12px 24px;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #666;
border-bottom: 3px solid transparent;
transition: all 0.3s;
}
.tab-btn:hover {
color: #667eea;
}
.tab-btn.active {
color: #667eea;
border-bottom-color: #667eea;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.card {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.card h3 {
margin-bottom: 20px;
color: #333;
}
.device-item,
.request-item {
background: #f8f9fa;
padding: 15px;
margin-bottom: 10px;
border-radius: 5px;
border-left: 4px solid #667eea;
}
.device-item.expired {
border-left-color: #e74c3c;
}
.btn-small {
padding: 6px 12px;
font-size: 14px;
width: auto;
margin-right: 5px;
}
.btn-danger {
background: #e74c3c;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-success {
background: #27ae60;
}
.btn-success:hover {
background: #229954;
}
.alert {
padding: 15px;
margin-bottom: 20px;
border-radius: 5px;
display: none;
}
.alert.show {
display: block;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.form-row {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.form-row .form-group {
flex: 1;
}
code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.auto-renew-switch {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #667eea;
}
input:checked + .slider:before {
transform: translateX(26px);
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
}
.badge-success {
background: #d4edda;
color: #155724;
}
.badge-danger {
background: #f8d7da;
color: #721c24;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
</style>
</head>
<body>
<!-- Login Screen -->
<div id="loginScreen" class="login-container">
<h2>🔐 License Management</h2>
<div id="loginAlert" class="alert"></div>
<form id="loginForm">
<div class="form-group">
<label>用户名</label>
<input type="text" id="username" required placeholder="admin">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="password" required placeholder="••••••••">
</div>
<button type="submit" class="btn">登录</button>
</form>
</div>
<!-- Dashboard -->
<div id="dashboard" class="dashboard">
<div class="header">
<h1>📋 License Management System</h1>
<div class="user-info">
<span>欢迎, <strong id="currentUser"></strong></span>
<button class="btn-logout" onclick="logout()">退出</button>
</div>
</div>
<div class="container">
<div id="dashboardAlert" class="alert"></div>
<div class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" onclick="switchTab('devices')">设备管理</button>
<button class="tab-btn" onclick="switchTab('sign')">签发License</button>
<button class="tab-btn" onclick="switchTab('requests')">续期审批</button>
<button class="tab-btn" onclick="switchTab('settings')">系统设置</button>
</div>
<!-- Devices Tab -->
<div id="devices" class="tab-content active">
<div class="card">
<h3>设备列表</h3>
<button class="btn btn-small" onclick="loadDevices()">刷新列表</button>
<div id="deviceList"></div>
</div>
<div class="card">
<h3>创建/更新设备</h3>
<form id="deviceForm">
<div class="form-row">
<div class="form-group">
<label>设备ID</label>
<input type="text" id="deviceId" required placeholder="dev-001">
</div>
<div class="form-group">
<label>到期时间</label>
<input type="datetime-local" id="expiration" required>
</div>
</div>
<button type="submit" class="btn">保存设备</button>
</form>
</div>
</div>
<!-- Sign License Tab -->
<div id="sign" class="tab-content">
<div class="card">
<h3>签发License</h3>
<form id="signForm">
<div class="form-group">
<label>设备ID</label>
<input type="text" id="signDeviceId" required placeholder="dev-001">
</div>
<button type="submit" class="btn">签发License</button>
</form>
<div id="licenseResult" style="margin-top: 20px;"></div>
</div>
</div>
<!-- Requests Tab -->
<div id="requests" class="tab-content">
<div class="card">
<h3>待审批的续期请求</h3>
<button class="btn btn-small" onclick="loadPendingRequests()">刷新列表</button>
<div id="requestList"></div>
</div>
</div>
<!-- Settings Tab -->
<div id="settings" class="tab-content">
<div class="card">
<h3>系统设置</h3>
<div class="auto-renew-switch">
<span>自动续期所有设备:</span>
<label class="switch">
<input type="checkbox" id="autoRenewSwitch" onchange="toggleAutoRenew()">
<span class="slider"></span>
</label>
<span id="autoRenewStatus" class="badge badge-warning">关闭</span>
</div>
</div>
<div class="card">
<h3>🔑 Ed25519 公钥</h3>
<p style="margin-bottom: 15px; color: #666;">
此公钥用于验证 License 签名的有效性。您可以安全地将此公钥分发给需要验证 License 的设备。
</p>
<button class="btn btn-small" onclick="loadPublicKey()">刷新公钥</button>
<div id="publicKeyResult" style="margin-top: 20px;"></div>
</div>
</div>
</div>
</div>
</div>
<script>
const API_BASE = '/api';
let currentToken = '';
let currentUsername = '';
// Tab switching
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.getElementById(tabName).classList.add('active');
event.target.classList.add('active');
if (tabName === 'devices') {
loadDevices();
} else if (tabName === 'requests') {
loadPendingRequests();
}
}
// Show alert
function showAlert(elementId, message, type = 'success') {
const alert = document.getElementById(elementId);
alert.className = `alert alert-${type} show`;
alert.textContent = message;
setTimeout(() => alert.classList.remove('show'), 3000);
}
// Login
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
currentToken = data.token;
currentUsername = username;
document.getElementById('currentUser').textContent = username;
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('dashboard').classList.add('active');
loadDevices();
loadAutoRenewStatus();
} else {
showAlert('loginAlert', data.message || '登录失败', 'error');
}
} catch (error) {
showAlert('loginAlert', '网络错误: ' + error.message, 'error');
}
});
// Logout
function logout() {
currentToken = '';
currentUsername = '';
document.getElementById('loginScreen').style.display = 'block';
document.getElementById('dashboard').classList.remove('active');
}
// Load devices
async function loadDevices() {
try {
const response = await fetch(`${API_BASE}/device/list`, {
headers: { 'Authorization': `Bearer ${currentToken}` }
});
if (!response.ok) {
const errorText = await response.text();
console.error('设备列表加载失败:', response.status, errorText);
showAlert('dashboardAlert', `加载设备列表失败: ${errorText}`, 'error');
return;
}
const devices = await response.json();
const listDiv = document.getElementById('deviceList');
// 确保 devices 是数组
if (!Array.isArray(devices)) {
console.error('设备列表数据格式错误:', typeof devices, devices);
listDiv.innerHTML = `<p style="color: #e74c3c;">数据格式错误: ${typeof devices}</p>`;
return;
}
if (devices.length === 0) {
listDiv.innerHTML = '<p>暂无设备</p>';
return;
}
listDiv.innerHTML = devices.map(device => {
const expDate = new Date(device.expiration);
const isExpired = expDate < new Date();
return `
<div class="device-item ${isExpired ? 'expired' : ''}">
<strong>${device.device_id}</strong> -
到期: ${expDate.toLocaleString('zh-CN')}
${isExpired ? '<span class="badge badge-danger">已过期</span>' : '<span class="badge badge-success">有效</span>'}
</div>
`;
}).join('');
} catch (error) {
console.error('加载设备失败:', error);
showAlert('dashboardAlert', '加载设备失败: ' + error.message, 'error');
}
}
// Create/Update device
document.getElementById('deviceForm').addEventListener('submit', async (e) => {
e.preventDefault();
const deviceId = document.getElementById('deviceId').value;
const expiration = document.getElementById('expiration').value;
// Convert local datetime to RFC3339
const expirationISO = new Date(expiration).toISOString();
try {
const response = await fetch(`${API_BASE}/device/manage`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({ device_id: deviceId, expiration: expirationISO })
});
const text = await response.text();
if (response.ok) {
showAlert('dashboardAlert', text, 'success');
document.getElementById('deviceForm').reset();
loadDevices();
} else {
showAlert('dashboardAlert', text || '操作失败', 'error');
}
} catch (error) {
showAlert('dashboardAlert', '网络错误: ' + error.message, 'error');
}
});
// Sign license
document.getElementById('signForm').addEventListener('submit', async (e) => {
e.preventDefault();
const deviceId = document.getElementById('signDeviceId').value;
try {
const response = await fetch(`${API_BASE}/license/sign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({ device_id: deviceId })
});
const license = await response.json();
if (response.ok) {
const resultDiv = document.getElementById('licenseResult');
resultDiv.innerHTML = `
<h4>签发成功!</h4>
<pre style="background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto;">${JSON.stringify(license, null, 2)}</pre>
`;
showAlert('dashboardAlert', 'License签发成功', 'success');
} else {
showAlert('dashboardAlert', license.message || '签发失败', 'error');
}
} catch (error) {
showAlert('dashboardAlert', '网络错误: ' + error.message, 'error');
}
});
// Load pending requests
async function loadPendingRequests() {
try {
const response = await fetch(`${API_BASE}/admin/pending_requests`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${currentToken}` }
});
if (!response.ok) {
showAlert('dashboardAlert', '加载申请列表失败', 'error');
return;
}
const requests = await response.json();
const listDiv = document.getElementById('requestList');
// 确保 requests 是数组
if (!Array.isArray(requests)) {
listDiv.innerHTML = '<p style="color: #e74c3c;">数据格式错误</p>';
return;
}
if (requests.length === 0) {
listDiv.innerHTML = '<p>暂无待审批申请</p>';
return;
}
listDiv.innerHTML = requests.map(req => {
const reqDate = new Date(req.request_time);
return `
<div class="request-item">
<strong>设备ID: ${req.device_id}</strong><br>
申请时间: ${reqDate.toLocaleString('zh-CN')}<br>
<button class="btn btn-small btn-success" onclick="approveRequest('${req.device_id}')">批准</button>
<button class="btn btn-small btn-danger" onclick="rejectRequest('${req.device_id}')">拒绝</button>
</div>
`;
}).join('');
} catch (error) {
showAlert('dashboardAlert', '加载申请失败: ' + error.message, 'error');
}
}
// Approve request
async function approveRequest(deviceId) {
const expiration = new Date();
expiration.setFullYear(expiration.getFullYear() + 1);
try {
const response = await fetch(`${API_BASE}/admin/handle_request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({
device_id: deviceId,
expiration: expiration.toISOString(),
approved_by: currentUsername,
action: 'approve'
})
});
if (response.ok) {
showAlert('dashboardAlert', '申请已批准', 'success');
loadPendingRequests();
loadDevices();
} else {
showAlert('dashboardAlert', '批准失败', 'error');
}
} catch (error) {
showAlert('dashboardAlert', '网络错误: ' + error.message, 'error');
}
}
// Reject request
async function rejectRequest(deviceId) {
const expiration = new Date();
expiration.setFullYear(expiration.getFullYear() + 1);
try {
const response = await fetch(`${API_BASE}/admin/handle_request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({
device_id: deviceId,
expiration: expiration.toISOString(),
approved_by: currentUsername,
action: 'reject'
})
});
if (response.ok) {
showAlert('dashboardAlert', '申请已拒绝', 'success');
loadPendingRequests();
} else {
showAlert('dashboardAlert', '拒绝失败', 'error');
}
} catch (error) {
showAlert('dashboardAlert', '网络错误: ' + error.message, 'error');
}
}
// Load auto renew status
async function loadAutoRenewStatus() {
// This would require a GET endpoint, for now we'll just show the toggle
}
// Toggle auto renew
async function toggleAutoRenew() {
const enabled = document.getElementById('autoRenewSwitch').checked;
const status = document.getElementById('autoRenewStatus');
try {
const response = await fetch(`${API_BASE}/admin/allow_auto_renew`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({ enabled: enabled.toString() })
});
if (response.ok) {
status.textContent = enabled ? '开启' : '关闭';
status.className = enabled ? 'badge badge-success' : 'badge badge-warning';
showAlert('dashboardAlert', enabled ? '自动续期已开启' : '自动续期已关闭', 'success');
} else {
document.getElementById('autoRenewSwitch').checked = !enabled;
showAlert('dashboardAlert', '设置失败', 'error');
}
} catch (error) {
document.getElementById('autoRenewSwitch').checked = !enabled;
showAlert('dashboardAlert', '网络错误: ' + error.message, 'error');
}
}
// Load public key
async function loadPublicKey() {
const resultDiv = document.getElementById('publicKeyResult');
resultDiv.innerHTML = '<p>加载中...</p>';
try {
const response = await fetch(`${API_BASE}/license/public-key`);
if (response.ok) {
const data = await response.json();
resultDiv.innerHTML = `
<div style="margin-bottom: 20px;">
<h4 style="margin-bottom: 10px;">SSH 格式 (推荐)</h4>
<pre style="background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; word-break: break-all;">${data.ssh_format.trim()}</pre>
</div>
<div>
<h4 style="margin-bottom: 10px;">Base64 格式</h4>
<pre style="background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; word-break: break-all;">${data.base64}</pre>
</div>
`;
} else {
resultDiv.innerHTML = '<p style="color: #e74c3c;">加载公钥失败</p>';
}
} catch (error) {
resultDiv.innerHTML = `<p style="color: #e74c3c;">网络错误: ${error.message}</p>`;
}
}
// Set default expiration to 1 year from now
window.addEventListener('DOMContentLoaded', () => {
const expInput = document.getElementById('expiration');
const nextYear = new Date();
nextYear.setFullYear(nextYear.getFullYear() + 1);
const localDatetime = new Date(nextYear.getTime() - nextYear.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
expInput.value = localDatetime;
});
// Auto-load public key when switching to settings tab
document.addEventListener('DOMContentLoaded', function() {
const settingsBtn = document.querySelector('.tab-btn[onclick*="settings"]');
if (settingsBtn) {
settingsBtn.addEventListener('click', function() {
setTimeout(loadPublicKey, 100);
});
}
});
</script>
</body>
</html>