thay đổi quyền chia sẻ của user và quyền xem của các scene

This commit is contained in:
2026-06-09 18:18:34 +07:00
parent 18e1c3d76d
commit d243c67718
11 changed files with 204 additions and 22 deletions
+100 -22
View File
@@ -532,7 +532,56 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => {
return res.status(403).json({ message: 'Access denied to this scene' }); return res.status(403).json({ message: 'Access denied to this scene' });
} }
res.json(scene); // Tăng số lượt xem nếu truy cập qua link chia sẻ
if (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid) {
// Tăng tổng lượt xem
scene.views = (scene.views || 0) + 1;
// Cập nhật lịch sử lượt xem theo ngày
const today = new Date();
today.setHours(0, 0, 0, 0); // Đặt về đầu ngày để nhóm theo ngày
const existingEntry = scene.viewHistory.find(entry =>
entry.date.getTime() === today.getTime()
);
if (existingEntry) {
existingEntry.count = (existingEntry.count || 0) + 1;
} else {
scene.viewHistory.push({ date: today, count: 1 });
}
await scene.save();
}
// Kiểm tra xem scene này có phải là scene con của một hotspot nào đó không
const isChildScene = await Hotspot.exists({ target_scene_id: scene._id });
// Trả về đối tượng scene đã được chuyển đổi sang plain object để thêm thuộc tính
res.json({ ...scene.toObject(), isChildScene: !!isChildScene });
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route GET /api/me/scenes/:id/view-stats
* @desc Lấy dữ liệu thống kê lượt xem theo thời gian của một scene
* @access Private (Owner only)
*/
router.get('/me/scenes/:id/view-stats', protect, async (req, res) => {
try {
const scene = await Scene.findById(req.params.id);
if (!scene) {
return res.status(404).json({ message: 'Scene not found' });
}
// Chỉ chủ sở hữu mới được xem thống kê chi tiết
if (scene.createdBy.toString() !== req.user._id.toString()) {
return res.status(403).json({ message: 'Bạn không có quyền xem thống kê này' });
}
res.json(scene.viewHistory.sort((a, b) => a.date - b.date)); // Sắp xếp theo ngày tăng dần
} catch (error) { } catch (error) {
res.status(500).json({ message: error.message }); res.status(500).json({ message: error.message });
} }
@@ -759,7 +808,7 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res
* @desc Update an existing scene * @desc Update an existing scene
* @access Private (Owner only) * @access Private (Owner only)
*/ */
router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => { router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res, next) => {
try { try {
const { title, description, privacy, sharedWithUsers, sharedEmails, shareExpireDays, lat, lng } = req.body; const { title, description, privacy, sharedWithUsers, sharedEmails, shareExpireDays, lat, lng } = req.body;
const scene = await Scene.findById(req.params.id); const scene = await Scene.findById(req.params.id);
@@ -768,6 +817,10 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => {
return res.status(403).json({ message: 'Not authorized' }); return res.status(403).json({ message: 'Not authorized' });
} }
// Đảm bảo req.user là một đối tượng thuần túy để ngăn chặn validation/save ngầm định của Mongoose
// Đây là một biện pháp phòng ngừa nếu req.user là một Mongoose document và có middleware khác cố gắng lưu nó.
if (req.user && typeof req.user.toObject === 'function') req.user = req.user.toObject();
const oldPrivacy = scene.privacy; const oldPrivacy = scene.privacy;
// Update basic info // Update basic info
@@ -796,29 +849,43 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => {
// LOGIC ĐỒNG BỘ QUYỀN RIÊNG TƯ (CASCADING PRIVACY) // LOGIC ĐỒNG BỘ QUYỀN RIÊNG TƯ (CASCADING PRIVACY)
// Nếu quyền chia sẻ thay đổi, cập nhật toàn bộ các Scene con liên kết trực tiếp // Nếu quyền chia sẻ thay đổi, cập nhật toàn bộ các Scene con liên kết trực tiếp
if (privacy && privacy !== oldPrivacy) { if (privacy && privacy !== oldPrivacy) {
try { const linkedHotspots = await Hotspot.find({ parent_scene_id: scene._id });
const linkedHotspots = await Hotspot.find({ parent_scene_id: scene._id }); const targetSceneIds = linkedHotspots
const targetSceneIds = linkedHotspots .map(h => h.target_scene_id)
.map(h => h.target_scene_id) .filter(id => id && id.toString() !== scene._id.toString());
.filter(id => id && id.toString() !== scene._id.toString());
if (targetSceneIds.length > 0) { if (targetSceneIds.length > 0) {
for (const targetId of targetSceneIds) { for (const targetId of targetSceneIds) {
const updateData = { privacy: privacy }; const updateData = { privacy: privacy };
let newShareToken = null;
// Nếu chuyển sang 'shared', đảm bảo scene con cũng có token riêng
if (privacy === 'shared') { // Nếu chuyển sang 'shared', đảm bảo scene con cũng có token riêng
const target = await Scene.findById(targetId); if (privacy === 'shared') {
if (target && !target.shareToken) { const target = await Scene.findById(targetId);
updateData.shareToken = crypto.randomBytes(24).toString('hex'); if (target && !target.shareToken) {
newShareToken = crypto.randomBytes(24).toString('hex');
updateData.shareToken = newShareToken;
// Đặt thời hạn token của scene con giống scene cha nếu có
if (scene.shareTokenExpires) {
updateData.shareTokenExpires = scene.shareTokenExpires;
}
} else if (target && target.shareToken) {
// Nếu scene con đã có token, giữ nguyên
updateData.shareToken = target.shareToken;
if (scene.shareTokenExpires) {
updateData.shareTokenExpires = scene.shareTokenExpires;
} else {
updateData.shareTokenExpires = null;
} }
} }
await Scene.updateOne({ _id: targetId }, { $set: updateData }); } else {
// Nếu không phải 'shared', xóa token và thời hạn của scene con
updateData.shareToken = null;
updateData.shareTokenExpires = null;
} }
console.log(`[Privacy Sync] Cascaded ${privacy} status to ${targetSceneIds.length} linked scenes.`); await Scene.updateOne({ _id: targetId }, { $set: updateData });
} }
} catch (err) { console.log(`[Privacy Sync] Cascaded privacy status to ${targetSceneIds.length} linked scenes.`);
console.error("Lỗi khi đồng bộ quyền riêng tư cho các scene con:", err.message);
} }
} }
@@ -857,7 +924,10 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => {
await scene.save(); await scene.save();
res.json({ message: 'Scene updated', scene }); res.json({ message: 'Scene updated', scene });
} catch (error) { } catch (error) {
res.status(500).json({ message: error.message }); if (error.name === 'ValidationError') {
return res.status(400).json({ message: error.message });
}
next(error); // Chuyển lỗi khác cho middleware xử lý lỗi chung
} }
}); });
@@ -1106,7 +1176,15 @@ router.get('/me/scenes', protect, async (req, res) => {
const scenes = await Scene.find({ createdBy: req.user._id }) const scenes = await Scene.find({ createdBy: req.user._id })
.populate('createdBy', 'username') .populate('createdBy', 'username')
.populate('assetId') .populate('assetId')
.sort({ createdAt: -1 }); .select('+views') // Đảm bảo trường 'views' được chọn nếu nó bị ẩn theo mặc định trong schema
.sort({ createdAt: -1 })
.lean(); // Sử dụng .lean() để tăng hiệu suất khi thêm thuộc tính tùy chỉnh
// Kiểm tra xem mỗi scene có phải là scene con hay không
for (let i = 0; i < scenes.length; i++) {
const isChild = await Hotspot.exists({ target_scene_id: scenes[i]._id });
scenes[i].isChildScene = !!isChild;
}
res.json(scenes); res.json(scenes);
} catch (error) { } catch (error) {
res.status(500).json({ message: error.message }); res.status(500).json({ message: error.message });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

+18
View File
@@ -8,6 +8,8 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<!-- Pannellum (3D Viewer) CSS --> <!-- Pannellum (3D Viewer) CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css"/> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css"/>
<!-- Chart.js CSS (tùy chọn, có thể không cần nếu chỉ dùng JS) -->
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/chart.js@3.7.0/dist/chart.min.css"> -->
<!-- Leaflet MarkerCluster CSS --> <!-- Leaflet MarkerCluster CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
@@ -192,6 +194,20 @@
</div> </div>
</div> </div>
<!-- View Stats Modal -->
<div id="view-stats-modal" class="modal-overlay">
<div class="modal-content logout-modal-dark" style="max-width: 700px;">
<h2 style="color: #fff; margin-bottom: 15px;" id="view-stats-modal-title">Thống kê lượt xem</h2>
<div style="width: 100%; height: 300px;">
<canvas id="view-stats-chart"></canvas>
</div>
<p style="color: #ccc; font-size: 12px; margin-top: 15px;">
Biểu đồ hiển thị số lượt xem theo ngày trong 30 ngày gần nhất.
</p>
<button onclick="closeViewStatsModal()" class="edit-btn-large" style="background: #444; width: 100%; margin-top: 20px; font-size: 14px;">Đóng</button>
</div>
</div>
<!-- Modal for Creating Scene --> <!-- Modal for Creating Scene -->
<div id="create-scene-modal" class="modal"> <div id="create-scene-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -571,6 +587,8 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- Pannellum JS --> <!-- Pannellum JS -->
<script src="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js"></script> <script src="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.0/dist/chart.min.js"></script>
<!-- Leaflet MarkerCluster JS --> <!-- Leaflet MarkerCluster JS -->
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script> <script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
+86
View File
@@ -1528,12 +1528,14 @@ async function loadMyScenes() {
<span>🔒 ${scene.privacy}</span> <span>🔒 ${scene.privacy}</span>
<span>👤 ${scene.createdBy?.username || 'Bạn'}</span> <span>👤 ${scene.createdBy?.username || 'Bạn'}</span>
<span>📅 ${formatSystemDate(scene.createdAt)}</span> <span>📅 ${formatSystemDate(scene.createdAt)}</span>
<span>👁️ ${scene.views || 0} lượt xem</span>
</div> </div>
${statusBadge} ${statusBadge}
</div> </div>
<div class="media-actions" style="border: none; padding: 0;"> <div class="media-actions" style="border: none; padding: 0;">
<button class="edit-btn-small" id="edit-scene-${scene._id}" ${scene.status === 'processing' ? 'disabled style="opacity:0.5; cursor:not-allowed;"' : ''}>Sửa</button> <button class="edit-btn-small" id="edit-scene-${scene._id}" ${scene.status === 'processing' ? 'disabled style="opacity:0.5; cursor:not-allowed;"' : ''}>Sửa</button>
<button class="delete-btn-small" id="delete-scene-${scene._id}">Xóa</button> <button class="delete-btn-small" id="delete-scene-${scene._id}">Xóa</button>
<button class="edit-btn-small" id="view-stats-${scene._id}" style="background: #6f42c1;">Thống kê</button>
</div> </div>
</div> </div>
`; `;
@@ -1555,6 +1557,11 @@ async function loadMyScenes() {
closeDashboard(); closeDashboard();
deleteScene(scene._id); deleteScene(scene._id);
}; };
// Xử lý nút Thống kê
document.getElementById(`view-stats-${scene._id}`).onclick = () => {
showViewStatsModal(scene._id, scene.name || scene.title);
};
}); });
} catch (e) { } catch (e) {
listContainer.innerHTML = `<p style="color:#ff4d4d">Lỗi: ${e.message}</p>`; listContainer.innerHTML = `<p style="color:#ff4d4d">Lỗi: ${e.message}</p>`;
@@ -2280,6 +2287,85 @@ async function updateSystemSettings(e) {
} }
} }
let viewStatsChartInstance = null; // Biến để lưu instance của Chart.js
/**
* Mở modal hiển thị biểu đồ thống kê lượt xem
*/
async function showViewStatsModal(sceneId, sceneTitle) {
const token = localStorage.getItem('jwt');
const modal = document.getElementById('view-stats-modal');
const titleElem = document.getElementById('view-stats-modal-title');
const chartCanvas = document.getElementById('view-stats-chart');
if (titleElem) titleElem.innerText = `Thống kê lượt xem: ${sceneTitle}`;
modal.style.display = 'flex';
try {
const res = await fetch(`${API_BASE_URL}/me/scenes/${sceneId}/view-stats`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const viewHistory = await res.json();
if (!res.ok) throw new Error(viewHistory.message);
// Chuẩn bị dữ liệu cho biểu đồ
const labels = [];
const data = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
// Lấy dữ liệu 30 ngày gần nhất
for (let i = 29; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
labels.push(d.toLocaleDateString(systemSettings.language === 'vi' ? 'vi-VN' : 'en-US', { day: '2-digit', month: '2-digit' }));
const entry = viewHistory.find(vh => new Date(vh.date).setHours(0,0,0,0) === d.getTime());
data.push(entry ? entry.count : 0);
}
// Nếu có instance cũ, hủy nó đi trước khi tạo mới
if (viewStatsChartInstance) {
viewStatsChartInstance.destroy();
}
viewStatsChartInstance = new Chart(chartCanvas, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Lượt xem',
data: data,
borderColor: '#007bff',
backgroundColor: 'rgba(0, 123, 255, 0.2)',
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
});
} catch (e) {
showNotification("Không thể tải thống kê lượt xem: " + e.message, 'error');
closeViewStatsModal();
}
}
/**
* Đóng modal thống kê lượt xem
*/
window.closeViewStatsModal = function() {
document.getElementById('view-stats-modal').style.display = 'none';
if (viewStatsChartInstance) {
viewStatsChartInstance.destroy(); // Hủy biểu đồ để giải phóng bộ nhớ
viewStatsChartInstance = null;
}
};
/** /**
* Opens a specific tab within the dashboard. * Opens a specific tab within the dashboard.
* @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes'). * @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes').