thay đổi quyền chia sẻ của user và quyền xem của các scene
@@ -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
|
// Nếu chuyển sang 'shared', đảm bảo scene con cũng có token riêng
|
||||||
if (privacy === 'shared') {
|
if (privacy === 'shared') {
|
||||||
const target = await Scene.findById(targetId);
|
const target = await Scene.findById(targetId);
|
||||||
if (target && !target.shareToken) {
|
if (target && !target.shareToken) {
|
||||||
updateData.shareToken = crypto.randomBytes(24).toString('hex');
|
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 });
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 5.7 MiB |
|
Before Width: | Height: | Size: 6.9 MiB |
|
Before Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 4.8 MiB |
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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').
|
||||||
|
|||||||