diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index d609d3b..e4b1a17 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -532,7 +532,56 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => { 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) { 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 * @access Private (Owner only) */ -router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => { +router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res, next) => { try { const { title, description, privacy, sharedWithUsers, sharedEmails, shareExpireDays, lat, lng } = req.body; 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' }); } + // Đả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; // 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) // 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) { - try { - const linkedHotspots = await Hotspot.find({ parent_scene_id: scene._id }); - const targetSceneIds = linkedHotspots - .map(h => h.target_scene_id) - .filter(id => id && id.toString() !== scene._id.toString()); + const linkedHotspots = await Hotspot.find({ parent_scene_id: scene._id }); + const targetSceneIds = linkedHotspots + .map(h => h.target_scene_id) + .filter(id => id && id.toString() !== scene._id.toString()); - if (targetSceneIds.length > 0) { - for (const targetId of targetSceneIds) { - const updateData = { privacy: privacy }; - - // Nếu chuyển sang 'shared', đảm bảo scene con cũng có token riêng - if (privacy === 'shared') { - const target = await Scene.findById(targetId); - if (target && !target.shareToken) { - updateData.shareToken = crypto.randomBytes(24).toString('hex'); + if (targetSceneIds.length > 0) { + for (const targetId of targetSceneIds) { + 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') { + const target = await Scene.findById(targetId); + 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.error("Lỗi khi đồng bộ quyền riêng tư cho các scene con:", err.message); + console.log(`[Privacy Sync] Cascaded privacy status to ${targetSceneIds.length} linked scenes.`); } } @@ -857,7 +924,10 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => { await scene.save(); res.json({ message: 'Scene updated', scene }); } 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 }) .populate('createdBy', 'username') .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); } catch (error) { res.status(500).json({ message: error.message }); diff --git a/backend/uploads/processed_1780998741676_81695da9.jpg.jpg b/backend/uploads/processed_1780998741676_81695da9.jpg.jpg deleted file mode 100644 index c697bcf..0000000 Binary files a/backend/uploads/processed_1780998741676_81695da9.jpg.jpg and /dev/null differ diff --git a/backend/uploads/processed_1780998925919_fd52e93a.jpg.jpg b/backend/uploads/processed_1780998925919_fd52e93a.jpg.jpg deleted file mode 100644 index 9288525..0000000 Binary files a/backend/uploads/processed_1780998925919_fd52e93a.jpg.jpg and /dev/null differ diff --git a/backend/uploads/processed_1780998987016_edc44f4a.jpg.jpg b/backend/uploads/processed_1780998987016_edc44f4a.jpg.jpg deleted file mode 100644 index 7f95014..0000000 Binary files a/backend/uploads/processed_1780998987016_edc44f4a.jpg.jpg and /dev/null differ diff --git a/backend/uploads/processed_1780999028354_a04713cb.jpg.jpg b/backend/uploads/processed_1780999028354_a04713cb.jpg.jpg deleted file mode 100644 index 9634f46..0000000 Binary files a/backend/uploads/processed_1780999028354_a04713cb.jpg.jpg and /dev/null differ diff --git a/backend/uploads/processed_1780999075558_29c91830.jpg.jpg b/backend/uploads/processed_1780999075558_29c91830.jpg.jpg deleted file mode 100644 index 7c5095c..0000000 Binary files a/backend/uploads/processed_1780999075558_29c91830.jpg.jpg and /dev/null differ diff --git a/backend/uploads/processed_1780999146678_dc25cc80.jpg.jpg b/backend/uploads/processed_1780999146678_dc25cc80.jpg.jpg deleted file mode 100644 index 16b5fe4..0000000 Binary files a/backend/uploads/processed_1780999146678_dc25cc80.jpg.jpg and /dev/null differ diff --git a/backend/uploads/processed_1780999167827_db42488d.jpg.jpg b/backend/uploads/processed_1780999167827_db42488d.jpg.jpg deleted file mode 100644 index 7aacebb..0000000 Binary files a/backend/uploads/processed_1780999167827_db42488d.jpg.jpg and /dev/null differ diff --git a/backend/uploads/processed_1780999587246_c0f27535.jpg.jpg b/backend/uploads/processed_1780999587246_c0f27535.jpg.jpg new file mode 100644 index 0000000..fa71660 Binary files /dev/null and b/backend/uploads/processed_1780999587246_c0f27535.jpg.jpg differ diff --git a/frontend/index.html b/frontend/index.html index 7bcdbc8..0091bf8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,8 @@ + + @@ -192,6 +194,20 @@ + + +
+
`; @@ -1555,6 +1557,11 @@ async function loadMyScenes() { closeDashboard(); 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) { listContainer.innerHTML = `

Lỗi: ${e.message}

`; @@ -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. * @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes').