Reset data bị lỗi, thêm hotspot

This commit is contained in:
2026-06-08 11:50:47 +07:00
parent c495efad36
commit d9ed8032d3
10 changed files with 521 additions and 238 deletions
+33
View File
@@ -0,0 +1,33 @@
const mongoose = require('mongoose');
const hotspotSchema = new mongoose.Schema({
parent_scene_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Scene',
required: true
},
target_scene_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Scene'
},
title: {
type: String,
trim: true
},
description: {
type: String,
trim: true
},
coordinates: {
yaw: { type: Number, required: true },
pitch: { type: Number, required: true }
},
is_auto_return: {
type: Boolean,
default: false
}
}, {
timestamps: true
});
module.exports = mongoose.model('Hotspot', hotspotSchema);
+8 -31
View File
@@ -1,25 +1,24 @@
const mongoose = require('mongoose');
const sceneSchema = new mongoose.Schema({
title: {
name: {
type: String,
required: true,
trim: true
},
scene_url: {
type: String
},
assetId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Asset',
required: true
},
lat: {
type: Number,
required: true
gps: {
lat: { type: Number, required: true },
lng: { type: Number, required: true }
},
lng: {
type: Number,
required: true
},
owner: {
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
@@ -38,28 +37,6 @@ const sceneSchema = new mongoose.Schema({
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
hotspots: [{
pitch: {
type: Number,
required: true
},
yaw: {
type: Number,
required: true
},
text: {
type: String,
trim: true
},
description: {
type: String,
trim: true
},
targetSceneId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Scene'
}
}]
}, {
timestamps: true
});
+135 -80
View File
@@ -7,6 +7,7 @@ const crypto = require('crypto');
const User = require('../models/User');
const Asset = require('../models/Asset');
const Scene = require('../models/Scene');
const Hotspot = require('../models/Hotspot'); // Giả định bạn đã tạo model mới
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware');
@@ -77,8 +78,8 @@ router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => {
return res.status(400).json({ message: 'Please upload a panorama image' });
}
const latitude = parseFloat(lat);
const longitude = parseFloat(lng);
const latitude = Number(lat);
const longitude = Number(lng);
if (isNaN(latitude) || isNaN(longitude)) {
// Cleanup uploaded file on validation error
@@ -134,11 +135,14 @@ router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => {
// 7. Save Scene to DB
const scene = new Scene({
title,
name: title,
assetId: asset._id,
scene_url: processedFilePath, // Lưu đường dẫn ảnh trực tiếp
gps: {
lat: latitude,
lng: longitude,
owner: req.user._id,
lng: longitude
},
createdBy: req.user._id,
privacy: privacy || 'private',
shareToken,
sharedWith: parsedSharedWith
@@ -176,7 +180,7 @@ router.get('/scenes', optionalAuth, async (req, res) => {
{ privacy: 'public' },
{ privacy: 'member' },
{ privacy: 'shared' }, // shareToken will be required to fetch panorama, but coordinates show on map
{ owner: req.user._id },
{ createdBy: req.user._id },
{ sharedWith: req.user._id }
]
};
@@ -191,8 +195,8 @@ router.get('/scenes', optionalAuth, async (req, res) => {
}
const scenes = await Scene.find(query)
.populate('owner', 'username')
.populate('assetId', 'coordinates createdAt');
.populate('createdBy', 'username')
.lean();
console.log(`[Data Load] Đã tìm thấy ${scenes.length} scenes. Gửi phản hồi về Frontend...`);
res.json(scenes);
@@ -210,7 +214,7 @@ router.get('/scenes', optionalAuth, async (req, res) => {
router.get('/scenes/:id', optionalAuth, async (req, res) => {
try {
const scene = await Scene.findById(req.params.id)
.populate('owner', 'username')
.populate('createdBy', 'username')
.populate('assetId');
if (!scene) {
@@ -220,7 +224,7 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => {
const hasAccess =
scene.privacy === 'public' ||
(scene.privacy === 'member' && req.user) ||
(req.user && scene.owner._id.toString() === req.user._id.toString()) ||
(req.user && scene.createdBy._id.toString() === req.user._id.toString()) ||
(req.user && scene.sharedWith.includes(req.user._id)) ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken);
@@ -235,83 +239,130 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => {
});
/**
* @route POST /api/scenes/:id/hotspots
* @desc Add a new hotspot to a scene
* @access Private (Owner only)
* @route GET /api/hotspots/:scene_id
* @desc Lấy toàn bộ danh sách hotspot của scene hiện tại
*/
router.post('/scenes/:id/hotspots', protect, async (req, res) => {
router.get('/hotspots/:scene_id', async (req, res) => {
try {
const { hotspotId, pitch, yaw, text, description, targetSceneId } = req.body;
const scene = await Scene.findById(req.params.id);
if (!scene) {
return res.status(404).json({ message: 'Scene not found' });
const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_id });
res.json(hotspots);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
// Chỉ chủ sở hữu mới có quyền chỉnh sửa hotspots
if (scene.owner.toString() !== req.user._id.toString()) {
return res.status(403).json({ message: 'Access denied: Only the owner can add hotspots' });
}
if (hotspotId) {
// CẬP NHẬT HOTSPOT HIỆN CÓ
const hs = scene.hotspots.id(hotspotId);
if (!hs) return res.status(404).json({ message: 'Hotspot not found' });
hs.pitch = parseFloat(pitch) ?? hs.pitch;
hs.yaw = parseFloat(yaw) ?? hs.yaw;
hs.text = text ?? hs.text;
hs.description = description ?? hs.description;
hs.targetSceneId = targetSceneId ?? hs.targetSceneId;
} else {
// THÊM MỚI HOTSPOT
const newHotspot = {
pitch: parseFloat(pitch),
yaw: parseFloat(yaw),
text: text || '',
description: description || '',
targetSceneId: targetSceneId || undefined
};
if (isNaN(newHotspot.pitch) || isNaN(newHotspot.yaw)) {
return res.status(400).json({ message: 'Invalid coordinates' });
}
scene.hotspots.push(newHotspot);
}
await scene.save();
// LOGIC "MẸ - CON": TỰ ĐỘNG TẠO ĐIỂM QUAY LẠI
if (targetSceneId && targetSceneId !== 'null' && targetSceneId !== '' && typeof targetSceneId === 'string') {
/**
* @route POST /api/hotspots/create
* @desc Tạo mới Hotspot + Tự động tạo liên kết ngược
*/
router.post('/hotspots/create', protect, async (req, res) => {
try {
const targetScene = await Scene.findById(targetSceneId);
const { parent_scene_id, target_scene_id, title, description, coordinates } = req.body;
const parentScene = await Scene.findById(parent_scene_id);
// Phân quyền: Admin hoặc Người tạo Scene
const isAuthorized = req.user.role === 'Chủ sở hữu' || (parentScene && parentScene.createdBy.toString() === req.user._id.toString());
if (!parentScene || !isAuthorized) {
return res.status(403).json({ message: 'Không có quyền tạo hotspot cho scene này' });
}
const hotspot = new Hotspot({
parent_scene_id,
target_scene_id,
title,
description,
coordinates: {
yaw: Number(coordinates.yaw),
pitch: Number(coordinates.pitch)
},
is_auto_return: false
});
await hotspot.save();
if (target_scene_id) {
const targetScene = await Scene.findById(target_scene_id);
if (targetScene) {
const hasReverse = targetScene.hotspots.some(h =>
h.targetSceneId && h.targetSceneId.toString() === scene._id.toString()
);
const reverseYaw = coordinates.yaw > 0 ? coordinates.yaw - 180 : coordinates.yaw + 180;
if (!hasReverse) {
const originYaw = parseFloat(yaw) || 0;
const reverseYaw = originYaw > 0 ? originYaw - 180 : originYaw + 180;
// Fallback đa tầng cho tiêu đề quay lại
const backLabel = title || parentScene.name || parentScene.title || 'cảnh trước';
targetScene.hotspots.push({
pitch: 0,
yaw: reverseYaw,
text: `Quay lại: ${scene.title}`,
targetSceneId: scene._id
const reverseHotspot = new Hotspot({
parent_scene_id: target_scene_id,
target_scene_id: parent_scene_id,
title: `Quay lại ${backLabel}`,
coordinates: { yaw: reverseYaw, pitch: 0 },
is_auto_return: true
});
await targetScene.save();
}
}
} catch (err) {
console.error("Lỗi tạo hotspot ngược:", err.message);
await reverseHotspot.save();
}
}
res.status(201).json({
message: 'Hotspot added successfully',
hotspots: scene.hotspots
res.status(201).json(hotspot);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route PUT /api/hotspots/update/:id
* @desc Cập nhật hotspot
*/
router.put('/hotspots/update/:id', protect, async (req, res) => {
try {
const { title, description, coordinates } = req.body;
const hotspot = await Hotspot.findById(req.params.id);
if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' });
const parentScene = await Scene.findById(hotspot.parent_scene_id);
// Phân quyền Admin hoặc Owner
const isAuthorized = req.user.role === 'Chủ sở hữu' || (parentScene && parentScene.createdBy.toString() === req.user._id.toString());
if (!isAuthorized) {
return res.status(403).json({ message: 'Không có quyền cập nhật' });
}
if (title) hotspot.title = title;
if (description) hotspot.description = description;
if (coordinates) {
hotspot.coordinates = {
yaw: Number(coordinates.yaw),
pitch: Number(coordinates.pitch)
};
}
await hotspot.save();
res.json(hotspot);
} catch (error) {
res.status(500).json({ message: error.message });
}
});
/**
* @route DELETE /api/hotspots/delete/:id
* @desc Xóa hotspot + Xóa luôn hotspot ngược tương ứng
*/
router.delete('/hotspots/delete/:id', protect, async (req, res) => {
try {
const hotspot = await Hotspot.findById(req.params.id);
if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' });
const parentScene = await Scene.findById(hotspot.parent_scene_id);
// Phân quyền Admin hoặc Owner
const isAuthorized = req.user.role === 'Chủ sở hữu' || (parentScene && parentScene.createdBy.toString() === req.user._id.toString());
if (!isAuthorized) {
return res.status(403).json({ message: 'Không có quyền xóa' });
}
if (hotspot.target_scene_id) {
await Hotspot.deleteOne({
parent_scene_id: hotspot.target_scene_id,
target_scene_id: hotspot.parent_scene_id,
is_auto_return: true
});
}
await Hotspot.findByIdAndDelete(req.params.id);
res.json({ message: 'Hotspot deleted successfully' });
} catch (error) {
res.status(500).json({ message: error.message });
}
@@ -340,7 +391,7 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res
const hasAccess =
scene.privacy === 'public' ||
(scene.privacy === 'member' && req.user) ||
(req.user && scene.owner.toString() === req.user._id.toString()) ||
(req.user && scene.createdBy.toString() === req.user._id.toString()) ||
(req.user && scene.sharedWith.includes(req.user._id)) ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken);
@@ -381,15 +432,17 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => {
const { title, privacy, sharedWithUsers, lat, lng } = req.body;
const scene = await Scene.findById(req.params.id);
if (!scene || scene.owner.toString() !== req.user._id.toString()) {
// Phân quyền Admin hoặc Owner
const isAuthorized = req.user.role === 'Chủ sở hữu' || (scene && scene.createdBy.toString() === req.user._id.toString());
if (!scene || !isAuthorized) {
return res.status(403).json({ message: 'Not authorized' });
}
// Update basic info
scene.title = title || scene.title;
scene.name = title || scene.name;
scene.privacy = privacy || scene.privacy;
scene.lat = lat ? parseFloat(lat) : scene.lat;
scene.lng = lng ? parseFloat(lng) : scene.lng;
if (lat) scene.gps.lat = Number(lat);
if (lng) scene.gps.lng = Number(lng);
if (privacy === 'shared' && !scene.shareToken) {
scene.shareToken = crypto.randomBytes(24).toString('hex');
@@ -428,7 +481,9 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => {
router.delete('/scenes/:id', protect, async (req, res) => {
try {
const scene = await Scene.findById(req.params.id);
if (!scene || scene.owner.toString() !== req.user._id.toString()) {
// Phân quyền Admin hoặc Owner
const isAuthorized = req.user.role === 'Chủ sở hữu' || (scene && scene.createdBy.toString() === req.user._id.toString());
if (!scene || !isAuthorized) {
return res.status(403).json({ message: 'Not authorized' });
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 MiB

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

+11 -1
View File
@@ -74,7 +74,7 @@ html, body {
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 2000;
z-index: 4000;
justify-content: center;
align-items: center;
}
@@ -170,6 +170,16 @@ html, body {
#close-viewer-btn:hover {
background: white;
}
/* Modal Overlay */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
z-index: 4000;
}
.modal-content {
background: #fff;
padding: 20px;
+50 -15
View File
@@ -104,6 +104,22 @@
</div>
</div>
<!-- Hotspot Action Choice Modal -->
<div id="hotspot-action-modal" class="modal">
<div class="modal-content action-modal-content">
<span class="close-btn" onclick="closeHotspotActionModal()">&times;</span>
<h2 id="hs-action-title">Tùy chọn Hotspot</h2>
<div class="action-buttons">
<button id="btn-hs-edit" class="edit-btn-large">
<span class="icon">✏️</span> Chỉnh sửa Hotspot
</button>
<button id="btn-hs-delete" class="delete-btn-large">
<span class="icon">🗑️</span> Xóa Hotspot
</button>
</div>
</div>
</div>
<!-- 3D Panorama Viewer Container -->
<div id="viewer-container" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 2000; background: #000;">
<div id="panorama-viewer"></div>
@@ -134,15 +150,21 @@
</div>
<div class="form-group divider">
<label>Liên kết tới ảnh 3D khác:</label>
<div class="tab-container">
<button type="button" class="tab-btn active" onclick="switchHSTab('select')">Chọn ảnh có sẵn</button>
<button type="button" class="tab-btn" onclick="switchHSTab('upload')">Tải ảnh mới lên</button>
<label>Kiểu liên kết:</label>
<div class="radio-group">
<label class="radio-item">
<input type="radio" name="hsLinkType" value="existing" checked onclick="toggleHSLinkType('existing')">
Chọn từ ảnh có sẵn
</label>
<label class="radio-item">
<input type="radio" name="hsLinkType" value="upload" onclick="toggleHSLinkType('upload')">
Upload ảnh mới
</label>
</div>
</div>
<!-- Tab 1: Chọn ảnh có sẵn -->
<div id="hs-tab-select" class="tab-content">
<!-- Lựa chọn B: Chọn ảnh có sẵn -->
<div id="hs-section-existing" class="tab-content">
<label for="hs-target-id">Danh sách Scene của bạn:</label>
<select id="hs-target-id" name="targetSceneId">
<option value="">-- Chọn một cảnh để liên kết --</option>
@@ -150,22 +172,35 @@
</select>
</div>
<!-- Tab 2: Tải ảnh mới -->
<div id="hs-tab-upload" class="tab-content" style="display: none;">
<!-- Lựa chọn A: Tải ảnh mới -->
<div id="hs-section-upload" class="tab-content" style="display: none;">
<label for="hs-panorama-file">Chọn ảnh Panorama 360°:</label>
<input type="file" id="hs-panorama-file" name="panorama-file" accept="image/*">
<div class="gps-inheritance">
<label>Xử lý GPS cho ảnh mới:</label>
<select id="hs-gps-mode" onchange="toggleManualGPS()">
<option value="auto">Đọc từ EXIF ảnh (Mặc định)</option>
<option value="inherit">Lấy GPS của cảnh hiện tại</option>
<option value="manual">Nhập thủ công</option>
</select>
<div class="radio-group" style="flex-direction: column; gap: 5px;">
<label class="radio-item">
<input type="radio" name="hsGPSMode" value="map" checked onclick="toggleHSGPSMode('map')">
Map Selection (Chọn trên bản đồ con)
</label>
<label class="radio-item">
<input type="radio" name="hsGPSMode" value="manual" onclick="toggleHSGPSMode('manual')">
Manual Input (Nhập thủ công)
</label>
<label class="radio-item">
<input type="radio" name="hsGPSMode" value="inherit" onclick="toggleHSGPSMode('inherit')">
Inherit Parent (Kế thừa từ cảnh hiện tại)
</label>
</div>
<div id="hs-map-selector" style="margin-top: 10px;">
<div id="hs-mini-map"></div>
</div>
<div id="hs-manual-gps" style="display: none; margin-top: 10px;">
<input type="number" step="any" id="hs-lat" placeholder="Latitude">
<input type="number" step="any" id="hs-lng" placeholder="Longitude">
<input type="number" step="any" id="hs-lat" name="hs-lat" placeholder="Latitude">
<input type="number" step="any" id="hs-lng" name="hs-lng" placeholder="Longitude">
</div>
</div>
</div>
+238 -88
View File
@@ -5,6 +5,8 @@ let tempMarker = null;
let markerClusterGroup;
let currentSceneId = null;
let previousSceneId = null;
let miniMap = null;
let miniMapMarker = null;
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
@@ -66,11 +68,17 @@ function initMap() {
iconCreateFunction: function(cluster) {
const childMarkers = cluster.getAllChildMarkers();
try {
// Thay vì tạo object mới phức tạp, lấy HTML của marker đầu tiên
const firstIcon = childMarkers[0].options.icon;
if (firstIcon) return firstIcon;
if (childMarkers.length > 0 && childMarkers[0].options.icon) {
const childIcon = childMarkers[0].options.icon;
// Trả về một DivIcon MỚI dựa trên cấu hình của con, tránh dùng chung instance gây crash render
return L.divIcon({
html: childIcon.options.html,
className: childIcon.options.className,
iconSize: childIcon.options.iconSize,
iconAnchor: childIcon.options.iconAnchor
});
}
} catch (e) {}
// Fallback an toàn nếu có lỗi
return L.divIcon({ className: 'cluster-fallback', html: '<div style="background:#007bff;width:10px;height:10px;border-radius:50%;"></div>' });
}
});
@@ -99,6 +107,11 @@ function initMap() {
return;
}
// Chỉ Admin (Chủ sở hữu) mới có quyền tạo Scene mới trực tiếp trên map qua contextmenu
const userRole = localStorage.getItem('role');
const isAdmin = userRole === 'Chủ sở hữu' || userRole === 'admin';
if (!isAdmin) return;
const { lat, lng } = e.latlng;
openCreateSceneModal(lat, lng);
});
@@ -357,8 +370,9 @@ async function loadScenes() {
// Chỉ lặp qua danh sách Scene mẹ, lọc bỏ các hotspots trùng tọa độ
scenes.forEach((scene) => {
const latNum = parseFloat(scene.lat);
const lngNum = parseFloat(scene.lng);
// Ép kiểu tọa độ về Number để tránh lỗi render bản đồ
const latNum = Number(scene.gps?.lat || scene.lat);
const lngNum = Number(scene.gps?.lng || scene.lng);
if (isNaN(latNum) || isNaN(lngNum)) return;
@@ -368,12 +382,10 @@ async function loadScenes() {
seenCoordinates.add(coordKey);
// Kiểm tra an toàn dữ liệu từ MongoDB trước khi truy cập
if (!scene.assetId || !scene.assetId._id) {
console.warn(`Scene "${scene.title}" thiếu dữ liệu ảnh (AssetId), bỏ qua.`);
return;
}
const assetId = scene.assetId?._id || scene.assetId;
const sceneName = scene.name || scene.title;
let thumbUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`;
let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`;
if (token) thumbUrl += `?token=${token}`;
else if (scene.privacy === 'shared' && scene.shareToken) thumbUrl += `?token=${scene.shareToken}`;
@@ -382,7 +394,7 @@ async function loadScenes() {
html: `
<div class="scene-callout">
<div class="scene-img-wrapper">
<img src="${thumbUrl}" alt="${scene.title}">
<img src="${thumbUrl}" alt="${sceneName}">
</div>
</div>`,
iconSize: [64, 64],
@@ -391,16 +403,16 @@ async function loadScenes() {
const marker = L.marker([latNum, lngNum], {
icon: calloutIcon,
title: scene.title // Tooltip khi di chuột qua
title: sceneName
});
// Tạo nội dung thông tin khi Hover (Tooltip)
const createdDate = scene.assetId?.createdAt ? new Date(scene.assetId.createdAt).toLocaleDateString('vi-VN') : 'N/A';
const tooltipContent = `
<div class="scene-hover-info">
<strong>${scene.title}</strong><br>
<strong>${sceneName}</strong><br>
${scene.description ? `<small>${scene.description}</small><br>` : ''}
<span>Người tạo: ${scene.owner ? scene.owner.username : 'Ẩn danh'}</span><br>
<span>Người tạo: ${scene.createdBy ? scene.createdBy.username : 'Ẩn danh'}</span><br>
<span>Ngày tạo: ${createdDate}</span>
</div>
`;
@@ -423,9 +435,13 @@ async function loadScenes() {
}
const currentUserId = localStorage.getItem('userId');
const ownerId = scene.owner?._id || scene.owner;
const userRole = localStorage.getItem('role');
// Hỗ trợ cả schema cũ (owner) và mới (createdBy)
const ownerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner;
if (currentUserId && ownerId && ownerId.toString() === currentUserId.toString()) {
// Phân quyền: Admin hoặc Chủ sở hữu Scene
const isAdmin = userRole === 'Chủ sở hữu' || userRole === 'admin';
if (isAdmin || (currentUserId && ownerId && ownerId.toString() === currentUserId.toString())) {
handleEditDeleteScene(scene);
} else {
alert("Bạn không có quyền chỉnh sửa scene này.");
@@ -482,9 +498,11 @@ function closeActionModal() {
*/
function openEditSceneModal(scene) {
document.getElementById('modal-scene-id').value = scene._id;
document.getElementById('modal-lat').value = scene.lat;
document.getElementById('modal-lng').value = scene.lng;
document.getElementById('modal-title').value = scene.title;
// Cập nhật để hỗ trợ cả cấu trúc cũ và mới (gps.lat/name)
document.getElementById('modal-lat').value = scene.gps?.lat || scene.lat;
document.getElementById('modal-lng').value = scene.gps?.lng || scene.lng;
const sceneName = scene.name || scene.title;
document.getElementById('modal-title').value = sceneName;
document.getElementById('modal-privacy').value = scene.privacy;
document.getElementById('modal-panorama').required = false; // Photo update is optional
@@ -537,22 +555,28 @@ async function openScene(sceneId, privacy, shareToken, force = false) {
localStorage.setItem('activeScenePrivacy', privacy || '');
localStorage.setItem('activeSceneToken', shareToken || '');
const response = await fetch(url, {
method: 'GET',
headers
});
// Nạp đồng thời Scene và danh sách Hotspots từ Collection riêng
const [sceneRes, hotspotsRes] = await Promise.all([
fetch(url, { method: 'GET', headers }),
fetch(`${API_BASE_URL}/hotspots/${sceneId}`, { method: 'GET', headers })
]);
const scene = await response.json();
if (!response.ok) throw new Error(scene.message || 'Failed to fetch scene details');
const scene = await sceneRes.json();
const hotspots = await hotspotsRes.json();
if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details');
// Lấy ID người tạo (createdBy) để phân quyền chuột phải trong viewer
const sceneOwnerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner;
// Tự động focus bản đồ vào vị trí của Scene
if (map) {
map.flyTo([scene.lat, scene.lng], 16);
map.flyTo([scene.gps?.lat || scene.lat, scene.gps?.lng || scene.lng], 16);
}
// Cập nhật tọa độ vào các input ẩn để hỗ trợ GPS inheritance cho hotspot khi tải ảnh mới
document.getElementById('modal-lat').value = scene.lat;
document.getElementById('modal-lng').value = scene.lng;
document.getElementById('modal-lat').value = scene.gps?.lat || scene.lat;
document.getElementById('modal-lng').value = scene.gps?.lng || scene.lng;
// Cập nhật lịch sử di chuyển để hỗ trợ tạo hotspot ngược tự động
if (currentSceneId && currentSceneId !== sceneId) {
@@ -560,8 +584,11 @@ async function openScene(sceneId, privacy, shareToken, force = false) {
}
currentSceneId = sceneId;
// Construct secure image URL passing shareToken if applicable
let secureImageUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`;
// Kiểm tra an toàn assetId (hỗ trợ cả dạng Object và String ID)
const assetId = scene.assetId?._id || scene.assetId;
if (!assetId) throw new Error("Dữ liệu hình ảnh của cảnh này bị lỗi hoặc chưa xử lý xong.");
let secureImageUrl = `${API_BASE_URL}/assets/view/${assetId}`;
// Ưu tiên JWT token nếu đang đăng nhập, nếu không thì dùng shareToken
if (token) {
@@ -571,7 +598,7 @@ async function openScene(sceneId, privacy, shareToken, force = false) {
}
// Initialize 3D Viewer with secure, referer-protected image stream
initPanoramaViewer(secureImageUrl, scene.hotspots || []);
initPanoramaViewer(secureImageUrl, hotspots || [], sceneOwnerId);
} catch (error) {
localStorage.removeItem('activeSceneId');
@@ -609,14 +636,22 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
const modal = document.getElementById('hotspot-modal');
const form = document.getElementById('hotspot-form');
// Hiển thị Modal TRƯỚC để các logic UI (như Mini Map) tính toán được kích thước
modal.style.display = 'flex';
// Reset form và gán tọa độ
form.reset();
switchHSTab('select'); // Luôn mặc định về tab chọn ảnh có sẵn khi mở modal
document.getElementById('hs-pitch').value = pitch;
document.getElementById('hs-yaw').value = yaw;
document.getElementById('hs-id').value = existingHotspot ? existingHotspot._id : '';
document.getElementById('hotspot-modal-title').innerText = existingHotspot ? 'Cập nhật điểm điều hướng' : 'Thêm điểm điều hướng mới';
// Reset UI states
document.querySelector('input[name="hsLinkType"][value="existing"]').checked = true;
window.toggleHSLinkType('existing');
document.querySelector('input[name="hsGPSMode"][value="map"]').checked = true;
window.toggleHSGPSMode('map');
// Lấy danh sách Scene có sẵn để đổ vào dropdown
try {
const res = await fetch(`${API_BASE_URL}/scenes`, { headers: { 'Authorization': `Bearer ${token}` } });
@@ -625,52 +660,68 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
select.innerHTML = '<option value="">-- Chọn một cảnh để liên kết --</option>';
scenes.forEach(s => {
if (s._id !== currentSceneId) { // Không liên kết tới chính nó
select.innerHTML += `<option value="${s._id}">${s.title}</option>`;
select.innerHTML += `<option value="${s._id}">${s.name || s.title}</option>`;
}
});
// QUAN TRỌNG: Chỉ điền dữ liệu hotspot cũ SAU KHI dropdown đã được nạp đầy đủ options
if (existingHotspot) {
document.getElementById('hs-title').value = existingHotspot.text || '';
document.getElementById('hs-title').value = existingHotspot.title || '';
document.getElementById('hs-desc').value = existingHotspot.description || '';
if (existingHotspot.targetSceneId) {
select.value = existingHotspot.targetSceneId;
if (existingHotspot.target_scene_id) {
select.value = existingHotspot.target_scene_id;
}
}
} catch (e) { console.error("Lỗi nạp danh sách scene:", e); }
modal.style.display = 'flex';
// Xử lý sự kiện submit form
form.onsubmit = async (e) => {
e.preventDefault();
const formData = new FormData(form);
const linkType = formData.get('hsLinkType');
// Logic: Nếu chọn upload file mới, tạo Scene trước
let finalTargetId = formData.get('targetSceneId');
if (linkType === 'upload') {
const file = document.getElementById('hs-panorama-file').files[0];
if (!file) {
alert('Vui lòng chọn ảnh panorama.');
return;
}
const gpsMode = formData.get('hsGPSMode');
let lat = 0, lng = 0;
if (gpsMode === 'inherit') {
// Ép kiểu về Number khi lấy từ input
lat = Number(document.getElementById('modal-lat').value);
lng = Number(document.getElementById('modal-lng').value);
} else {
lat = Number(document.getElementById('hs-lat').value);
lng = Number(document.getElementById('hs-lng').value);
if (!lat || !lng) {
alert('Vui lòng chọn vị trí GPS.');
return;
}
}
if (file) {
const sceneData = new FormData();
sceneData.append('panorama', file);
sceneData.append('title', formData.get('title'));
const gpsMode = document.getElementById('hs-gps-mode').value;
if (gpsMode === 'manual') {
sceneData.append('lat', document.getElementById('hs-lat').value);
sceneData.append('lng', document.getElementById('hs-lng').value);
} else if (gpsMode === 'inherit') {
sceneData.append('lat', document.getElementById('modal-lat').value);
sceneData.append('lng', document.getElementById('modal-lng').value);
}
sceneData.append('lat', lat); // FormData sẽ convert sang string, Backend cần ép kiểu lại
sceneData.append('lng', lng);
sceneData.append('privacy', 'public');
uploadWithProgress(`${API_BASE_URL}/scenes`, 'POST', sceneData, token, 'hs', async (sceneRes) => {
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), sceneRes.scene._id, existingHotspot?._id);
closeHotspotModal();
});
return; // Dừng luồng cũ vì uploadWithProgress đã tiếp quản
return;
}
// Lưu Hotspot
const finalTargetId = formData.get('targetSceneId');
if (!finalTargetId) {
alert('Vui lòng chọn cảnh để liên kết.');
return;
}
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), finalTargetId, existingHotspot?._id);
modal.style.display = 'none';
};
@@ -684,25 +735,64 @@ function closeHotspotModal() {
}
/**
* Chuyển đổi giữa tab Chọn ảnh có sẵn và Tải ảnh mới
* Khởi tạo hoặc cập nhật Mini Map trong Hotspot Modal
*/
function initHSMiniMap() {
const pLat = parseFloat(document.getElementById('modal-lat').value) || 21.0285;
const pLng = parseFloat(document.getElementById('modal-lng').value) || 105.8542;
if (miniMap) {
miniMap.setView([pLat, pLng], 15);
updateHSMiniMapMarker(pLat, pLng);
// Fix lỗi vỡ tiles của Leaflet trong Modal
setTimeout(() => miniMap.invalidateSize(), 200);
return;
}
miniMap = L.map('hs-mini-map').setView([pLat, pLng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(miniMap);
miniMap.on('click', (e) => {
const { lat, lng } = e.latlng;
updateHSMiniMapMarker(lat, lng);
});
updateHSMiniMapMarker(pLat, pLng);
setTimeout(() => miniMap.invalidateSize(), 200);
}
function updateHSMiniMapMarker(lat, lng) {
if (miniMapMarker) miniMap.removeLayer(miniMapMarker);
miniMapMarker = L.marker([lat, lng]).addTo(miniMap);
document.getElementById('hs-lat').value = lat.toFixed(6);
document.getElementById('hs-lng').value = lng.toFixed(6);
}
window.toggleHSLinkType = function(type) {
document.getElementById('hs-section-existing').style.display = type === 'existing' ? 'block' : 'none';
document.getElementById('hs-section-upload').style.display = type === 'upload' ? 'block' : 'none';
if (type === 'upload') {
const gpsMode = document.querySelector('input[name="hsGPSMode"]:checked')?.value || 'map';
window.toggleHSGPSMode(gpsMode);
}
};
window.toggleHSGPSMode = function(mode) {
document.getElementById('hs-map-selector').style.display = mode === 'map' ? 'block' : 'none';
document.getElementById('hs-manual-gps').style.display = mode === 'manual' ? 'block' : 'none';
if (mode === 'map') initHSMiniMap();
};
/**
* Chuyển đổi tab cũ (giữ lại để tương thích nếu cần)
*/
function switchHSTab(tabName) {
const selectTab = document.getElementById('hs-tab-select');
const uploadTab = document.getElementById('hs-tab-upload');
const btns = document.querySelectorAll('.tab-btn');
btns.forEach(btn => btn.classList.remove('active'));
if (tabName === 'select') {
selectTab.style.display = 'block';
uploadTab.style.display = 'none';
btns[0].classList.add('active');
document.getElementById('hs-panorama-file').value = ''; // Reset file input
window.toggleHSLinkType('existing');
} else {
selectTab.style.display = 'none';
uploadTab.style.display = 'block';
btns[1].classList.add('active');
document.getElementById('hs-target-id').value = ''; // Reset select
window.toggleHSLinkType('upload');
}
}
@@ -718,40 +808,43 @@ function toggleManualGPS() {
/**
* Gửi dữ liệu lưu Hotspot lên Backend
*/
async function saveHotspotToDB(pitch, yaw, text, description, targetSceneId, hotspotId) {
async function saveHotspotToDB(pitch, yaw, title, description, targetSceneId, hotspotId) {
const token = localStorage.getItem('jwt');
// Gọi đúng API create hoặc update tùy vào trạng thái
const url = hotspotId ? `${API_BASE_URL}/hotspots/update/${hotspotId}` : `${API_BASE_URL}/hotspots/create`;
const method = hotspotId ? 'PUT' : 'POST';
try {
const response = await fetch(`${API_BASE_URL}/scenes/${currentSceneId}/hotspots`, {
method: 'POST',
const body = {
title,
description,
target_scene_id: targetSceneId,
coordinates: {
pitch: Number(pitch),
yaw: Number(yaw)
}
};
// Nếu tạo mới, cần gửi kèm ID của scene hiện tại làm parent
if (!hotspotId) {
body.parent_scene_id = currentSceneId;
}
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
hotspotId,
pitch,
yaw,
text,
description,
targetSceneId
})
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Lỗi khi lưu hotspot');
alert('Lưu điểm điều hướng thành công!');
// Refresh lại scene hiện tại để cập nhật viewer
// Chúng ta cần lấy lại thông tin scene để có assetId mới nếu có
const res = await fetch(`${API_BASE_URL}/scenes/${currentSceneId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const updatedScene = await res.json();
let secureImageUrl = `${API_BASE_URL}/assets/view/${updatedScene.assetId._id}?token=${token}`;
// Buộc nạp lại để cập nhật danh sách hotspot mới
openScene(currentSceneId, updatedScene.privacy, updatedScene.shareToken || '', true);
openScene(currentSceneId, localStorage.getItem('activeScenePrivacy'), localStorage.getItem('activeSceneToken'), true);
} catch (error) {
console.error(error);
@@ -785,3 +878,60 @@ window.systemReset = async function() {
alert("Lỗi reset: " + e.message);
}
};
/**
* Mở Menu tùy chọn cho Hotspot (Sửa/Xóa)
*/
window.openHotspotMenu = function(hotspot) {
const modal = document.getElementById('hotspot-action-modal');
const editBtn = document.getElementById('btn-hs-edit');
const deleteBtn = document.getElementById('btn-hs-delete');
modal.style.display = 'flex';
// Hành động Sửa: Mở form biên tập với dữ liệu cũ
editBtn.onclick = () => {
closeHotspotActionModal();
window.handleHotspotCreation(hotspot.pitch, hotspot.yaw, hotspot);
};
// Hành động Xóa: Xác nhận và gọi API xóa
deleteBtn.onclick = async () => {
if (confirm('Bạn có chắc chắn muốn xóa điểm điều hướng này?')) {
closeHotspotActionModal();
await deleteHotspot(hotspot._id);
}
};
};
/**
* Đóng Modal tùy chọn Hotspot
*/
function closeHotspotActionModal() {
document.getElementById('hotspot-action-modal').style.display = 'none';
}
/**
* Xóa Hotspot thông qua API
*/
async function deleteHotspot(hotspotId) {
const token = localStorage.getItem('jwt');
try {
// Gọi đúng API delete hotspot độc lập
const response = await fetch(`${API_BASE_URL}/hotspots/delete/${hotspotId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || 'Lỗi xóa hotspot');
}
alert('Đã xóa điểm điều hướng.');
// Refresh lại scene hiện tại để cập nhật viewer
openScene(currentSceneId, null, null, true);
} catch (e) {
alert(e.message);
}
}
+35 -12
View File
@@ -1,14 +1,17 @@
let activeViewer = null;
let currentHotspots = [];
let securityApplied = false;
let currentSceneOwnerId = null;
/**
* Initializes and shows the Pannellum 360° panorama viewer with security overlays.
* @param {string} imageUrl - Authorized URL to fetch the secure image stream
* @param {Array} hotspots - List of hotspots from the database
* @param {string} ownerId - ID of the scene owner
*/
function initPanoramaViewer(imageUrl, hotspots = []) {
function initPanoramaViewer(imageUrl, hotspots = [], ownerId = null) {
currentHotspots = hotspots;
currentSceneOwnerId = ownerId;
const container = document.getElementById('viewer-container');
container.style.display = 'block';
@@ -20,15 +23,15 @@ function initPanoramaViewer(imageUrl, hotspots = []) {
// Chuyển đổi dữ liệu hotspots từ DB sang định dạng Pannellum
const pannellumHotspots = hotspots.map(h => ({
pitch: h.pitch,
yaw: h.yaw,
pitch: h.coordinates?.pitch || h.pitch,
yaw: h.coordinates?.yaw || h.yaw,
type: "info",
text: h.text || "Điểm điều hướng",
text: h.title || "Điểm điều hướng",
id: h._id,
clickHandlerFunc: () => {
if (h.targetSceneId) {
if (h.target_scene_id || h.targetSceneId) {
// Gọi hàm openScene từ main_map.js
openScene(h.targetSceneId);
openScene(h.target_scene_id || h.targetSceneId);
}
}
}));
@@ -87,23 +90,43 @@ function applyViewerSecurity() {
// Nếu viewer đang hoạt động, lấy tọa độ Pitch/Yaw tại điểm click
if (activeViewer) {
// Kiểm tra phân quyền trước khi cho phép tương tác chuột phải
const userRole = localStorage.getItem('role');
const currentUserId = localStorage.getItem('userId');
// Phân quyền: Admin (Chủ sở hữu) hoặc Người tạo ra Scene này
const isAdmin = userRole === 'Chủ sở hữu' || userRole === 'admin';
const isAuthorized = isAdmin ||
(currentUserId && currentSceneOwnerId && currentUserId.toString() === currentSceneOwnerId.toString());
// Lấy tọa độ cầu (Pitch/Yaw) từ điểm click chuột
const coords = activeViewer.mouseEventToCoords(e);
if (!coords) return;
if (!coords) return false;
const pitch = coords[0];
const yaw = coords[1];
// Kiểm tra xem có hotspot nào gần điểm click không (ngưỡng 2 độ)
const existing = currentHotspots.find(h =>
Math.abs(h.pitch - pitch) < 2 && Math.abs(h.yaw - yaw) < 2
Math.abs((h.coordinates?.pitch || h.pitch) - pitch) < 2 &&
Math.abs((h.coordinates?.yaw || h.yaw) - yaw) < 2
);
if (typeof window.handleHotspotCreation === 'function') {
window.handleHotspotCreation(pitch, yaw, existing);
} else {
console.log(`Coordinates captured: Pitch ${pitch}, Yaw ${yaw}`);
// Nếu không được phép, dừng xử lý và chặn menu mặc định
if (!isAuthorized) return false;
if (existing) {
// ĐÃ CÓ Hotspot -> Hiện Menu: [Sửa Hotspot] / [Xóa Hotspot]
if (typeof window.openHotspotMenu === 'function') {
window.openHotspotMenu(existing);
}
} else {
// CHƯA CÓ Hotspot -> Hiện Form: [Tạo mới Hotspot]
if (typeof window.handleHotspotCreation === 'function') {
window.handleHotspotCreation(pitch, yaw, null);
}
}
console.log(`Coordinates captured: Pitch ${pitch}, Yaw ${yaw}`);
}
return false;
};