核心功能: - ✅ Categories/Series双视图管理(category_view.rs + import_markdown.rs) - ✅ FUSE Multi-Volume支持(tree_type参数) - ✅ SSH/SFTP/SCP/rsync协议完整实现(4042行) - ✅ NFS/SMB Module Phase 1-3完成 - ✅ Archive Module Phase 1-4完成(2916行) - ✅ Download Center API完整实现 - ✅ S3兼容API实现(560行) Git配置修正: - ✅ 删除错误origin(gitea.momentry.ddns.net) - ✅ 删除m5max128(指向机器名) - ✅ 设置origin = m5max128gitea.momentry.ddns.net/admin/markbase - ✅ 设置m4minigitea = m4minigitea.momentry.ddns.net/warren/markbase 数据清理: - ✅ 删除38个临时SQLite(保留accusys.sqlite、demo.sqlite) - ✅ 删除.bak、test_*.bin、调试脚本等临时文件 - ✅ 删除临时目录(build/、download files/、raid_test/等) - ✅ 更新.gitignore排除临时文件 架构优化: - 52个文件修改,2434行新增,4739行删除 - Workspace成员整合(16个crate) - 数据库状态:accusys.sqlite保留(主demo测试) 远程同步: - ✅ 准备推送到m5max128gitea(远程Gitea) - ✅ 准备推送到m4minigitea(本地Gitea)
629 lines
22 KiB
HTML
629 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Product Manager - AccuSys Download Service</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
background-color: #f5f5f7;
|
|
}
|
|
|
|
h1 {
|
|
color: #1d1d1f;
|
|
font-size: 32px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
h2 {
|
|
color: #1d1d1f;
|
|
font-size: 24px;
|
|
margin-top: 30px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.stat-number {
|
|
font-size: 28px;
|
|
font-weight: bold;
|
|
color: #0071e3;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 14px;
|
|
color: #86868b;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.series-card {
|
|
background: white;
|
|
padding: 15px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.series-header {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #1d1d1f;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.series-stats {
|
|
display: flex;
|
|
gap: 15px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.series-stat {
|
|
font-size: 12px;
|
|
color: #86868b;
|
|
}
|
|
|
|
.product-table {
|
|
background: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
overflow: hidden;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
th {
|
|
background: #f5f5f7;
|
|
padding: 15px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
color: #1d1d1f;
|
|
}
|
|
|
|
td {
|
|
padding: 15px;
|
|
border-top: 1px solid #d2d2d7;
|
|
}
|
|
|
|
.product-name {
|
|
color: #0071e3;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.product-series {
|
|
color: #1d1d1f;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.product-desc {
|
|
color: #86868b;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.product-files {
|
|
color: #0071e3;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.btn {
|
|
background: #0071e3;
|
|
color: white;
|
|
border: none;
|
|
padding: 12px 24px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 16px;
|
|
margin-right: 10px;
|
|
}
|
|
|
|
.btn:hover {
|
|
background: #0077ed;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #f5f5f7;
|
|
color: #1d1d1f;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #e8e8ed;
|
|
}
|
|
|
|
.btn-small {
|
|
padding: 8px 16px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.form-container {
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-label {
|
|
font-weight: 600;
|
|
color: #1d1d1f;
|
|
margin-bottom: 8px;
|
|
display: block;
|
|
}
|
|
|
|
.form-input {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 1px solid #d2d2d7;
|
|
border-radius: 8px;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.form-input:focus {
|
|
outline: none;
|
|
border-color: #0071e3;
|
|
}
|
|
|
|
.form-select {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 1px solid #d2d2d7;
|
|
border-radius: 8px;
|
|
font-size: 16px;
|
|
background: white;
|
|
}
|
|
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.5);
|
|
z-index: 1000;
|
|
}
|
|
|
|
.modal-content {
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 12px;
|
|
max-width: 600px;
|
|
margin: 100px auto;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #86868b;
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
background: #1d1d1f;
|
|
color: white;
|
|
padding: 15px 30px;
|
|
border-radius: 8px;
|
|
display: none;
|
|
z-index: 1001;
|
|
}
|
|
|
|
.toast.success {
|
|
background: #34c724;
|
|
}
|
|
|
|
.toast.error {
|
|
background: #ff3b30;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Product Manager</h1>
|
|
<p style="color: #86868b; margin-bottom: 20px;">Manage product series and file mappings</p>
|
|
|
|
<div class="stats-grid" id="stats">
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="total-products">-</div>
|
|
<div class="stat-label">Total Products</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="total-series">-</div>
|
|
<div class="stat-label">Product Series</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="total-files">-</div>
|
|
<div class="stat-label">Mapped Files</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="total-size">-</div>
|
|
<div class="stat-label">Total Size</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 20px;">
|
|
<button class="btn" onclick="showAddProductForm()">+ Add Product</button>
|
|
<button class="btn btn-secondary" onclick="loadProducts()">Refresh</button>
|
|
</div>
|
|
|
|
<h2>Series Overview</h2>
|
|
<div id="series-overview">
|
|
<div class="loading">Loading series statistics...</div>
|
|
</div>
|
|
|
|
<h2>Products List</h2>
|
|
<div id="products-list">
|
|
<div class="loading">Loading products...</div>
|
|
</div>
|
|
|
|
<!-- Add Product Modal -->
|
|
<div id="add-product-modal" class="modal">
|
|
<div class="modal-content">
|
|
<h2>Add New Product</h2>
|
|
<div class="form-container">
|
|
<div class="form-group">
|
|
<label class="form-label">Product Name</label>
|
|
<input type="text" id="product-name" class="form-input" placeholder="e.g., ExaSAN-DAS-001">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Series</label>
|
|
<select id="product-series" class="form-select">
|
|
<option value="ExaSAN-DAS">ExaSAN-DAS</option>
|
|
<option value="ExaSAN-SAN">ExaSAN-SAN</option>
|
|
<option value="Gamma">Gamma</option>
|
|
<option value="T-Share">T-Share</option>
|
|
<option value="InneRAID">InneRAID</option>
|
|
<option value="Legacy">Legacy</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Description (Optional)</label>
|
|
<input type="text" id="product-desc" class="form-input" placeholder="e.g., ExaSAN Direct Attached Storage Model 001">
|
|
</div>
|
|
<div style="margin-top: 30px;">
|
|
<button class="btn" onclick="createProduct()">Create Product</button>
|
|
<button class="btn btn-secondary" onclick="hideAddProductForm()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Assign Files Modal -->
|
|
<div id="assign-files-modal" class="modal">
|
|
<div class="modal-content" style="max-width: 800px;">
|
|
<h2>Assign Files to Product</h2>
|
|
<p style="color: #86868b; margin-bottom: 20px;">Select files from uploaded files to assign to this product</p>
|
|
<div id="available-files-list" style="max-height: 400px; overflow-y: auto;">
|
|
<div class="loading">Loading available files...</div>
|
|
</div>
|
|
<div style="margin-top: 30px;">
|
|
<button class="btn" onclick="assignFiles()">Assign Selected Files</button>
|
|
<button class="btn btn-secondary" onclick="hideAssignFilesModal()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast Notification -->
|
|
<div id="toast" class="toast"></div>
|
|
|
|
<script>
|
|
const apiBase = window.location.protocol + '//' + window.location.host;
|
|
|
|
function formatSize(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
}
|
|
|
|
function showToast(message, type = 'success') {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.className = 'toast ' + type;
|
|
toast.style.display = 'block';
|
|
setTimeout(() => {
|
|
toast.style.display = 'none';
|
|
}, 3000);
|
|
}
|
|
|
|
function showAddProductForm() {
|
|
document.getElementById('add-product-modal').style.display = 'block';
|
|
}
|
|
|
|
function hideAddProductForm() {
|
|
document.getElementById('add-product-modal').style.display = 'none';
|
|
}
|
|
|
|
async function createProduct() {
|
|
const name = document.getElementById('product-name').value.trim();
|
|
const series = document.getElementById('product-series').value;
|
|
const desc = document.getElementById('product-desc').value.trim();
|
|
|
|
if (!name) {
|
|
showToast('Please enter product name', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${apiBase}/api/v2/products/create`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
product_name: name,
|
|
series: series,
|
|
description: desc || null
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.ok) {
|
|
showToast('✅ Product created successfully!', 'success');
|
|
hideAddProductForm();
|
|
loadProducts();
|
|
loadSeriesStats();
|
|
|
|
// Clear form
|
|
document.getElementById('product-name').value = '';
|
|
document.getElementById('product-desc').value = '';
|
|
} else {
|
|
showToast('❌ Error: ' + data.error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('❌ Network error: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadProducts() {
|
|
try {
|
|
const response = await fetch(`${apiBase}/api/v2/products`);
|
|
const data = await response.json();
|
|
|
|
document.getElementById('total-products').textContent = data.total || 0;
|
|
|
|
const productsListDiv = document.getElementById('products-list');
|
|
|
|
if (data.total === 0) {
|
|
productsListDiv.innerHTML = '<div class="loading">No products yet. Click "Add Product" to create one.</div>';
|
|
return;
|
|
}
|
|
|
|
let tableHtml = '<div class="product-table"><table>';
|
|
tableHtml += '<thead><tr>';
|
|
tableHtml += '<th>ID</th>';
|
|
tableHtml += '<th>Product Name</th>';
|
|
tableHtml += '<th>Series</th>';
|
|
tableHtml += '<th>Description</th>';
|
|
tableHtml += '<th>Files</th>';
|
|
tableHtml += '<th>Created</th>';
|
|
tableHtml += '<th>Actions</th>';
|
|
tableHtml += '</tr></thead><tbody>';
|
|
|
|
data.products.forEach(product => {
|
|
tableHtml += '<tr>';
|
|
tableHtml += `<td>${product.id}</td>`;
|
|
tableHtml += `<td class="product-name">${product.product_name}</td>`;
|
|
tableHtml += `<td class="product-series">${product.series}</td>`;
|
|
tableHtml += `<td class="product-desc">${product.description || '-'}</td>`;
|
|
tableHtml += `<td><button class="btn btn-small btn-secondary" onclick="viewProductFiles(${product.id})">View Files</button></td>`;
|
|
tableHtml += `<td><button class="btn btn-small" onclick="showAssignFilesModal(${product.id})">Assign Files</button></td>`;
|
|
tableHtml += `<td>${product.created_at}</td>`;
|
|
tableHtml += `<td><button class="btn btn-small btn-secondary" onclick="deleteProduct(${product.id}, '${product.product_name}')">Delete</button></td>`;
|
|
tableHtml += '</tr>';
|
|
});
|
|
|
|
tableHtml += '</tbody></table></div>';
|
|
productsListDiv.innerHTML = tableHtml;
|
|
|
|
} catch (error) {
|
|
console.error('Error loading products:', error);
|
|
document.getElementById('products-list').innerHTML =
|
|
'<div class="loading">Error loading products. Please refresh.</div>';
|
|
}
|
|
}
|
|
|
|
async function loadSeriesStats() {
|
|
try {
|
|
const response = await fetch(`${apiBase}/api/v2/products/stats`);
|
|
const data = await response.json();
|
|
|
|
document.getElementById('total-series').textContent = data.total_series || 0;
|
|
document.getElementById('total-files').textContent =
|
|
data.series_stats.reduce((sum, s) => sum + s.file_count, 0);
|
|
document.getElementById('total-size').textContent = formatSize(
|
|
data.series_stats.reduce((sum, s) => sum + s.total_size, 0)
|
|
);
|
|
|
|
const seriesOverviewDiv = document.getElementById('series-overview');
|
|
|
|
if (data.total_series === 0) {
|
|
seriesOverviewDiv.innerHTML = '<div class="loading">No series statistics available.</div>';
|
|
return;
|
|
}
|
|
|
|
let seriesHtml = '';
|
|
data.series_stats.forEach(stat => {
|
|
seriesHtml += '<div class="series-card">';
|
|
seriesHtml += `<div class="series-header">${stat.series}</div>`;
|
|
seriesHtml += '<div class="series-stats">';
|
|
seriesHtml += `<span class="series-stat">Products: ${stat.product_count}</span>`;
|
|
seriesHtml += `<span class="series-stat">Files: ${stat.file_count}</span>`;
|
|
seriesHtml += `<span class="series-stat">Size: ${formatSize(stat.total_size)}</span>`;
|
|
seriesHtml += '</div>';
|
|
seriesHtml += '</div>';
|
|
});
|
|
|
|
seriesOverviewDiv.innerHTML = seriesHtml;
|
|
|
|
} catch (error) {
|
|
console.error('Error loading series stats:', error);
|
|
document.getElementById('series-overview').innerHTML =
|
|
'<div class="loading">Error loading series statistics.</div>';
|
|
}
|
|
}
|
|
|
|
async function viewProductFiles(productId) {
|
|
try {
|
|
const response = await fetch(`${apiBase}/api/v2/products/${productId}/files`);
|
|
const data = await response.json();
|
|
|
|
showToast(`Product ${productId} has ${data.total_files} files (${formatSize(data.total_size)})`, 'success');
|
|
|
|
// TODO: Show files in modal
|
|
console.log('Product files:', data.files);
|
|
|
|
} catch (error) {
|
|
showToast('Error loading product files', 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteProduct(productId, productName) {
|
|
// 检查是否有文件映射
|
|
try {
|
|
const filesResponse = await fetch(`${apiBase}/api/v2/products/${productId}/files`);
|
|
const filesData = await filesResponse.json();
|
|
|
|
let confirmMessage = `Are you sure you want to delete product "${productName}"?`;
|
|
if (filesData.total_files > 0) {
|
|
confirmMessage += `\n\nThis product has ${filesData.total_files} file mappings that will also be deleted.`;
|
|
}
|
|
|
|
if (!confirm(confirmMessage)) {
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(`${apiBase}/api/v2/products/${productId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.ok) {
|
|
showToast('✅ Product deleted successfully!', 'success');
|
|
loadProducts();
|
|
loadSeriesStats();
|
|
} else {
|
|
showToast('❌ Error: ' + data.error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('❌ Network error: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Assign Files Functions
|
|
let currentProductId = null;
|
|
|
|
async function showAssignFilesModal(productId) {
|
|
currentProductId = productId;
|
|
|
|
try {
|
|
// 加载已上传文件列表
|
|
const response = await fetch(`${apiBase}/api/v2/files/accusys`);
|
|
const data = await response.json();
|
|
|
|
const filesListDiv = document.getElementById('available-files-list');
|
|
|
|
if (data.total_files === 0) {
|
|
filesListDiv.innerHTML = '<div class="loading">No uploaded files available. Please upload files first.</div>';
|
|
document.getElementById('assign-files-modal').style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
let html = '<table style="width: 100%"><thead><tr>';
|
|
html += '<th style="width: 50px;">Select</th>';
|
|
html += '<th>Filename</th>';
|
|
html += '<th>Size</th>';
|
|
html += '<th>Path</th>';
|
|
html += '</tr></thead><tbody>';
|
|
|
|
data.files.forEach(file => {
|
|
html += '<tr>';
|
|
html += `<td><input type="checkbox" class="file-checkbox" value="${file.relative_path}" data-size="${file.file_size}" data-hash="${file.file_hash || ''}" data-name="${file.filename}"></td>`;
|
|
html += `<td>${file.filename}</td>`;
|
|
html += `<td>${formatSize(file.file_size)}</td>`;
|
|
html += `<td style="font-size: 12px; color: #86868b;">${file.relative_path}</td>`;
|
|
html += '</tr>';
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
html += `<p style="margin-top: 10px; color: #86868b; font-size: 14px;">Total: ${data.total_files} files available (${formatSize(data.total_size)})</p>`;
|
|
|
|
filesListDiv.innerHTML = html;
|
|
document.getElementById('assign-files-modal').style.display = 'block';
|
|
|
|
} catch (error) {
|
|
showToast('❌ Error loading files: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
function hideAssignFilesModal() {
|
|
document.getElementById('assign-files-modal').style.display = 'none';
|
|
currentProductId = null;
|
|
}
|
|
|
|
async function assignFiles() {
|
|
const checkboxes = document.querySelectorAll('.file-checkbox:checked');
|
|
|
|
if (checkboxes.length === 0) {
|
|
showToast('Please select at least one file', 'error');
|
|
return;
|
|
}
|
|
|
|
const selectedFiles = Array.from(checkboxes).map(cb => ({
|
|
file_path: cb.value,
|
|
file_name: cb.dataset.name,
|
|
file_size: parseInt(cb.dataset.size),
|
|
file_hash: cb.dataset.hash || null
|
|
}));
|
|
|
|
try {
|
|
const response = await fetch(`${apiBase}/api/v2/products/${currentProductId}/assign-files`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ files: selectedFiles })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.ok) {
|
|
showToast(`✅ ${data.assigned_count} files assigned successfully!`, 'success');
|
|
hideAssignFilesModal();
|
|
loadProducts();
|
|
loadSeriesStats();
|
|
} else {
|
|
showToast('❌ Error: ' + data.error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('❌ Network error: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Auto-load on page load
|
|
loadProducts();
|
|
loadSeriesStats();
|
|
</script>
|
|
</body>
|
|
</html> |