Files
markbase/markbase-core/src/product_manager.html
Warren 1300a4e223
Some checks failed
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled
MarkBase架构升级:Multi-Volume Virtual Tree + Dual-View Management + Git Remote修正
核心功能:
-  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)
2026-06-12 12:59:54 +08:00

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>