Sửa lỗi đăng nhập vào admin mà không reload được page do lỗi tạo scene trước đó, sử dụng lệnh resetDB.js để khởi tạo lại, xóa các scene trước và ảnh đã upload
@@ -43,12 +43,33 @@ const upload = multer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for Multer middleware to catch "Request aborted" and other upload errors gracefully.
|
||||||
|
*/
|
||||||
|
const uploadSinglePanorama = (req, res, next) => {
|
||||||
|
const multerUpload = upload.single('panorama');
|
||||||
|
multerUpload(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
// Bắt lỗi khi client ngắt kết nối đột ngột
|
||||||
|
if (err.message === 'Request aborted') {
|
||||||
|
console.warn(`[Multer Warning]: Upload aborted by client at ${req.method} ${req.originalUrl}`);
|
||||||
|
return res.status(499).json({ message: 'Client aborted the request' });
|
||||||
|
}
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
return res.status(400).json({ message: `Multer error: ${err.message}` });
|
||||||
|
}
|
||||||
|
return res.status(400).json({ message: err.message });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/scenes
|
* @route POST /api/scenes
|
||||||
* @desc Create a new 3D scene (with 360 photo, 8K resize, EXIF injection)
|
* @desc Create a new 3D scene (with 360 photo, 8K resize, EXIF injection)
|
||||||
* @access Private (Registered Users)
|
* @access Private (Registered Users)
|
||||||
*/
|
*/
|
||||||
router.post('/scenes', protect, upload.single('panorama'), async (req, res) => {
|
router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, lat, lng, privacy, sharedWithUsers } = req.body;
|
const { title, lat, lng, privacy, sharedWithUsers } = req.body;
|
||||||
|
|
||||||
@@ -145,6 +166,7 @@ router.post('/scenes', protect, upload.single('panorama'), async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/scenes', optionalAuth, async (req, res) => {
|
router.get('/scenes', optionalAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
console.log(`[Data Load] Bắt đầu truy vấn scenes cho: ${req.user ? req.user._id : 'Khách'}`);
|
||||||
let query = {};
|
let query = {};
|
||||||
|
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
@@ -172,8 +194,10 @@ router.get('/scenes', optionalAuth, async (req, res) => {
|
|||||||
.populate('owner', 'username')
|
.populate('owner', 'username')
|
||||||
.populate('assetId', 'coordinates createdAt');
|
.populate('assetId', 'coordinates createdAt');
|
||||||
|
|
||||||
|
console.log(`[Data Load] Đã tìm thấy ${scenes.length} scenes. Gửi phản hồi về Frontend...`);
|
||||||
res.json(scenes);
|
res.json(scenes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`[Data Load Error]: ${error.stack}`);
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -257,6 +281,33 @@ router.post('/scenes/:id/hotspots', protect, async (req, res) => {
|
|||||||
|
|
||||||
await scene.save();
|
await scene.save();
|
||||||
|
|
||||||
|
// LOGIC "MẸ - CON": TỰ ĐỘNG TẠO ĐIỂM QUAY LẠI
|
||||||
|
if (targetSceneId && targetSceneId !== 'null' && targetSceneId !== '' && typeof targetSceneId === 'string') {
|
||||||
|
try {
|
||||||
|
const targetScene = await Scene.findById(targetSceneId);
|
||||||
|
if (targetScene) {
|
||||||
|
const hasReverse = targetScene.hotspots.some(h =>
|
||||||
|
h.targetSceneId && h.targetSceneId.toString() === scene._id.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasReverse) {
|
||||||
|
const originYaw = parseFloat(yaw) || 0;
|
||||||
|
const reverseYaw = originYaw > 0 ? originYaw - 180 : originYaw + 180;
|
||||||
|
|
||||||
|
targetScene.hotspots.push({
|
||||||
|
pitch: 0,
|
||||||
|
yaw: reverseYaw,
|
||||||
|
text: `Quay lại: ${scene.title}`,
|
||||||
|
targetSceneId: scene._id
|
||||||
|
});
|
||||||
|
await targetScene.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Lỗi tạo hotspot ngược:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: 'Hotspot added successfully',
|
message: 'Hotspot added successfully',
|
||||||
hotspots: scene.hotspots
|
hotspots: scene.hotspots
|
||||||
@@ -325,7 +376,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, upload.single('panorama'), async (req, res) => {
|
router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, privacy, sharedWithUsers, lat, lng } = req.body;
|
const { title, privacy, sharedWithUsers, lat, lng } = req.body;
|
||||||
const scene = await Scene.findById(req.params.id);
|
const scene = await Scene.findById(req.params.id);
|
||||||
@@ -396,4 +447,41 @@ router.delete('/scenes/:id', protect, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/maintenance/reset-all
|
||||||
|
* @desc Wipe all scenes, assets, and physical files (DANGEROUS: For dev reset only)
|
||||||
|
* @access Private (Owner only)
|
||||||
|
*/
|
||||||
|
router.post('/maintenance/reset-all', protect, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// 1. Xóa toàn bộ dữ liệu trong Database
|
||||||
|
await Scene.deleteMany({});
|
||||||
|
await Asset.deleteMany({});
|
||||||
|
// Lưu ý: Không xóa Users trừ khi bạn muốn reset cả tài khoản
|
||||||
|
|
||||||
|
// 2. Dọn dẹp thư mục uploads (trừ các file .gitkeep hoặc thư mục temp)
|
||||||
|
const files = fs.readdirSync(uploadDir);
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(uploadDir, file);
|
||||||
|
if (fs.lstatSync(fullPath).isFile()) {
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Dọn dẹp thư mục temp
|
||||||
|
const tempFiles = fs.readdirSync(tempDir);
|
||||||
|
for (const file of tempFiles) {
|
||||||
|
const fullPath = path.join(tempDir, file);
|
||||||
|
if (fs.lstatSync(fullPath).isFile()) {
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[Maintenance]: Toàn bộ dữ liệu tour đã bị xóa bởi ${req.user.username}`);
|
||||||
|
res.json({ message: 'Dữ liệu đã được xóa sạch. Hãy clear localStorage ở trình duyệt để bắt đầu lại.' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const connectDB = require('../config/db');
|
||||||
|
const Scene = require('../models/Scene');
|
||||||
|
const Asset = require('../models/Asset');
|
||||||
|
|
||||||
|
const reset = async () => {
|
||||||
|
try {
|
||||||
|
console.log('--- Bắt đầu quá trình Reset Dữ liệu ---');
|
||||||
|
|
||||||
|
// 1. Kết nối Database
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
// 2. Xóa bản ghi trong Database
|
||||||
|
console.log('1. Đang xóa dữ liệu trong MongoDB...');
|
||||||
|
const deletedScenes = await Scene.deleteMany({});
|
||||||
|
const deletedAssets = await Asset.deleteMany({});
|
||||||
|
console.log(`- Đã xóa ${deletedScenes.deletedCount} scenes.`);
|
||||||
|
console.log(`- Đã xóa ${deletedAssets.deletedCount} assets.`);
|
||||||
|
|
||||||
|
// 3. Dọn dẹp tệp tin vật lý
|
||||||
|
const uploadDir = path.join(__dirname, '../uploads');
|
||||||
|
const tempDir = path.join(uploadDir, 'temp');
|
||||||
|
|
||||||
|
console.log('2. Đang dọn dẹp thư mục uploads...');
|
||||||
|
const directories = [uploadDir, tempDir];
|
||||||
|
|
||||||
|
directories.forEach(dir => {
|
||||||
|
if (fs.existsSync(dir)) {
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(dir, file);
|
||||||
|
// Chỉ xóa file, không xóa thư mục (như temp) và tránh xóa file ẩn/cấu hình
|
||||||
|
if (fs.lstatSync(fullPath).isFile() && !file.startsWith('.')) {
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('--- Hoàn tất reset hệ thống! ---');
|
||||||
|
|
||||||
|
// Đóng kết nối
|
||||||
|
mongoose.connection.close();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Lỗi nghiêm trọng trong quá trình reset:', err);
|
||||||
|
mongoose.connection.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reset();
|
||||||
@@ -43,6 +43,16 @@ app.use(cors(corsOptions));
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Request Logger Middleware
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api', apiRoutes);
|
app.use('/api', apiRoutes);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.7 MiB |
|
Before Width: | Height: | Size: 7.1 MiB |
|
Before Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 6.2 MiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 14 MiB |
|
Before Width: | Height: | Size: 13 MiB |
|
Before Width: | Height: | Size: 13 MiB |
@@ -32,11 +32,11 @@ const degToDmsRational = (deg) => {
|
|||||||
const absolute = Math.abs(deg);
|
const absolute = Math.abs(deg);
|
||||||
const d = Math.floor(absolute);
|
const d = Math.floor(absolute);
|
||||||
const m = Math.floor((absolute - d) * 60);
|
const m = Math.floor((absolute - d) * 60);
|
||||||
const s = Math.round((absolute - d - m / 60) * 3600 * 100) / 100;
|
const s = Math.round((absolute - d - m / 60) * 3600 * 100);
|
||||||
return [
|
return [
|
||||||
[d, 1],
|
[d, 1],
|
||||||
[m, 1],
|
[m, 1],
|
||||||
[Math.round(s * 100), 100]
|
[s, 100]
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,7 +77,13 @@ const injectGPSCoordinates = async (filePath, lat, lng) => {
|
|||||||
exifObj["GPS"][piexif.GPSIFD.GPSLongitudeRef] = lngRef;
|
exifObj["GPS"][piexif.GPSIFD.GPSLongitudeRef] = lngRef;
|
||||||
exifObj["GPS"][piexif.GPSIFD.GPSLongitude] = degToDmsRational(lng);
|
exifObj["GPS"][piexif.GPSIFD.GPSLongitude] = degToDmsRational(lng);
|
||||||
|
|
||||||
const exifBytes = piexif.dump(exifObj);
|
// Chỉ đóng gói các IFD cần thiết để tránh lỗi 'pack' từ dữ liệu rác
|
||||||
|
const exifBytes = piexif.dump({
|
||||||
|
"0th": exifObj["0th"] || {},
|
||||||
|
"Exif": exifObj["Exif"] || {},
|
||||||
|
"GPS": exifObj["GPS"] || {}
|
||||||
|
});
|
||||||
|
|
||||||
const newJpegBinary = piexif.insert(exifBytes, jpegBinary);
|
const newJpegBinary = piexif.insert(exifBytes, jpegBinary);
|
||||||
|
|
||||||
fs.writeFileSync(filePath, Buffer.from(newJpegBinary, 'binary'));
|
fs.writeFileSync(filePath, Buffer.from(newJpegBinary, 'binary'));
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ html, body {
|
|||||||
|
|
||||||
/* 3D Viewer Overlays (Full Screen) */
|
/* 3D Viewer Overlays (Full Screen) */
|
||||||
#viewer-container {
|
#viewer-container {
|
||||||
|
display: none; /* Khởi tạo ẩn để không chặn chuột */
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -169,15 +170,6 @@ html, body {
|
|||||||
#close-viewer-btn:hover {
|
#close-viewer-btn:hover {
|
||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
/* Modal Overlay */
|
|
||||||
.modal-overlay {
|
|
||||||
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: 2000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
@@ -72,6 +72,16 @@
|
|||||||
<label for="modal-shared-users">Shared with User IDs (JSON Array):</label>
|
<label for="modal-shared-users">Shared with User IDs (JSON Array):</label>
|
||||||
<input type="text" id="modal-shared-users" name="sharedWithUsers" placeholder='["60c72b2f9b1d8a41c8888888"]'>
|
<input type="text" id="modal-shared-users" name="sharedWithUsers" placeholder='["60c72b2f9b1d8a41c8888888"]'>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Progress Bar for Scene Upload -->
|
||||||
|
<div id="create-progress-container" style="display: none; margin-bottom: 15px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 5px;">
|
||||||
|
<span id="create-progress-status">Uploading image...</span>
|
||||||
|
<span id="create-progress-percent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100%; height: 8px; background: #eee; border-radius: 4px; overflow: hidden;">
|
||||||
|
<div id="create-progress-bar" style="width: 0%; height: 100%; background: #28a745; transition: width 0.3s;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button type="submit" class="submit-btn">Save Scene</button>
|
<button type="submit" class="submit-btn">Save Scene</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE_URL = 'http://localhost:5000/api';
|
const API_BASE_URL = '/api'; // Sử dụng đường dẫn tương đối để tránh lỗi CORS/Hostname
|
||||||
|
|
||||||
let map;
|
let map;
|
||||||
let tempMarker = null;
|
let tempMarker = null;
|
||||||
@@ -8,10 +8,27 @@ let previousSceneId = null;
|
|||||||
|
|
||||||
// Initialize when DOM is ready
|
// Initialize when DOM is ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initMap();
|
try {
|
||||||
checkAuthStatus();
|
console.log("--- Bắt đầu khởi tạo Frontend ---");
|
||||||
loadScenes();
|
if (document.getElementById('map')) {
|
||||||
restoreActiveScene();
|
console.log("1. Đang khởi tạo bản đồ Leaflet...");
|
||||||
|
initMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chạy tuần tự để tránh xung đột luồng xử lý
|
||||||
|
checkAuthStatus(); // 2. Kiểm tra đăng nhập
|
||||||
|
|
||||||
|
// Đảm bảo map đã sẵn sàng trước khi nạp data
|
||||||
|
if (map) {
|
||||||
|
loadScenes().then(() => {
|
||||||
|
console.log("4. Đang chuẩn bị khôi phục Scene cũ (nếu có)...");
|
||||||
|
// Chỉ khôi phục khi bản đồ đã nạp xong các marker
|
||||||
|
setTimeout(restoreActiveScene, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ứng dụng không thể khởi tạo:", error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,12 +40,16 @@ function initMap() {
|
|||||||
const savedLng = localStorage.getItem('map-lng');
|
const savedLng = localStorage.getItem('map-lng');
|
||||||
const savedZoom = localStorage.getItem('map-zoom');
|
const savedZoom = localStorage.getItem('map-zoom');
|
||||||
|
|
||||||
// Nếu có dữ liệu cũ thì dùng, không thì mặc định là Hà Nội
|
// Đảm bảo tọa độ khởi tạo luôn hợp lệ
|
||||||
const startLat = savedLat ? parseFloat(savedLat) : 21.0285;
|
let startLat = parseFloat(savedLat);
|
||||||
const startLng = savedLng ? parseFloat(savedLng) : 105.8542;
|
let startLng = parseFloat(savedLng);
|
||||||
const startZoom = savedZoom ? parseInt(savedZoom) : 13;
|
let startZoom = parseInt(savedZoom);
|
||||||
|
|
||||||
map = L.map('map').setView([startLat, startLng], startZoom);
|
if (isNaN(startLat)) startLat = 21.0285;
|
||||||
|
if (isNaN(startLng)) startLng = 105.8542;
|
||||||
|
if (isNaN(startZoom)) startZoom = 13;
|
||||||
|
|
||||||
|
map = L.map('map', { zoomControl: true }).setView([startLat, startLng], startZoom);
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
@@ -44,8 +65,13 @@ function initMap() {
|
|||||||
showCoverageOnHover: false,
|
showCoverageOnHover: false,
|
||||||
iconCreateFunction: function(cluster) {
|
iconCreateFunction: function(cluster) {
|
||||||
const childMarkers = cluster.getAllChildMarkers();
|
const childMarkers = cluster.getAllChildMarkers();
|
||||||
// Lấy icon của Scene đầu tiên trong nhóm để làm đại diện cho cả cụm
|
try {
|
||||||
return childMarkers[0].options.icon;
|
// 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;
|
||||||
|
} 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>' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,6 +200,12 @@ function handleLogout() {
|
|||||||
localStorage.removeItem('activeScenePrivacy');
|
localStorage.removeItem('activeScenePrivacy');
|
||||||
localStorage.removeItem('activeSceneToken');
|
localStorage.removeItem('activeSceneToken');
|
||||||
localStorage.removeItem('userId');
|
localStorage.removeItem('userId');
|
||||||
|
|
||||||
|
// Đảm bảo đóng viewer nếu đang mở
|
||||||
|
if (typeof closeViewer === 'function') {
|
||||||
|
closeViewer();
|
||||||
|
}
|
||||||
|
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
loadScenes(); // Reload scenes to filter out private ones
|
loadScenes(); // Reload scenes to filter out private ones
|
||||||
alert('Logged out successfully');
|
alert('Logged out successfully');
|
||||||
@@ -193,10 +225,10 @@ function openCreateSceneModal(lat, lng) {
|
|||||||
if (tempMarker) map.removeLayer(tempMarker);
|
if (tempMarker) map.removeLayer(tempMarker);
|
||||||
tempMarker = L.marker([lat, lng]).addTo(map);
|
tempMarker = L.marker([lat, lng]).addTo(map);
|
||||||
|
|
||||||
|
document.getElementById('create-scene-modal').style.display = 'flex';
|
||||||
document.getElementById('modal-scene-id').value = '';
|
document.getElementById('modal-scene-id').value = '';
|
||||||
document.getElementById('modal-lat').value = lat.toFixed(6);
|
document.getElementById('modal-lat').value = lat.toFixed(6);
|
||||||
document.getElementById('modal-lng').value = lng.toFixed(6);
|
document.getElementById('modal-lng').value = lng.toFixed(6);
|
||||||
document.getElementById('create-scene-modal').style.display = 'flex';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -255,30 +287,34 @@ function uploadWithProgress(url, method, formData, token, prefix, callback) {
|
|||||||
const percentText = document.getElementById(`${prefix}-progress-percent`);
|
const percentText = document.getElementById(`${prefix}-progress-percent`);
|
||||||
const statusText = document.getElementById(`${prefix}-progress-status`);
|
const statusText = document.getElementById(`${prefix}-progress-status`);
|
||||||
|
|
||||||
container.style.display = 'block';
|
if (container) container.style.display = 'block';
|
||||||
statusText.innerText = "Đang tải ảnh lên...";
|
if (statusText) statusText.innerText = "Đang tải ảnh lên...";
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', (e) => {
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
const percent = Math.round((e.loaded / e.total) * 100);
|
const percent = Math.round((e.loaded / e.total) * 100);
|
||||||
bar.style.width = percent + '%';
|
if (bar) bar.style.width = percent + '%';
|
||||||
percentText.innerText = percent + '%';
|
if (percentText) percentText.innerText = percent + '%';
|
||||||
if (percent === 100) statusText.innerText = "Tải lên xong! Đang khởi tạo trên server...";
|
if (percent === 100 && statusText) statusText.innerText = "Tải lên xong! Đang khởi tạo trên server...";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('load', () => {
|
xhr.addEventListener('load', () => {
|
||||||
container.style.display = 'none';
|
if (container) container.style.display = 'none';
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
callback(JSON.parse(xhr.responseText));
|
callback(JSON.parse(xhr.responseText));
|
||||||
} else {
|
} else {
|
||||||
const err = JSON.parse(xhr.responseText);
|
let errorMsg = 'Không thể tải lên';
|
||||||
alert('Lỗi: ' + (err.message || 'Không thể tải lên'));
|
try {
|
||||||
|
const err = JSON.parse(xhr.responseText);
|
||||||
|
errorMsg = err.message || errorMsg;
|
||||||
|
} catch (e) {}
|
||||||
|
alert('Lỗi: ' + errorMsg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('error', () => {
|
xhr.addEventListener('error', () => {
|
||||||
container.style.display = 'none';
|
if (container) container.style.display = 'none';
|
||||||
alert('Lỗi kết nối mạng.');
|
alert('Lỗi kết nối mạng.');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -298,27 +334,45 @@ async function loadScenes() {
|
|||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/scenes`, {
|
// Thêm timestamp để tránh lỗi 304 hang do cache trình duyệt
|
||||||
|
const timestamp = new Date().getTime();
|
||||||
|
console.log(`3.1 Đang gửi yêu cầu lấy danh sách Scene (ts: ${timestamp})...`);
|
||||||
|
const response = await fetch(`${API_BASE_URL}/scenes?_=${timestamp}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers
|
headers
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`[API Response] /scenes status: ${response.status}`);
|
||||||
if (!response.ok) throw new Error('Failed to load scenes');
|
if (!response.ok) throw new Error('Failed to load scenes');
|
||||||
const scenes = await response.json();
|
const scenes = await response.json();
|
||||||
|
|
||||||
|
console.log(`[Data] Nhận được ${scenes.length} scenes từ server`);
|
||||||
|
if (!Array.isArray(scenes)) return;
|
||||||
|
|
||||||
// Xóa sạch các layers cũ và chuẩn bị lọc tọa độ
|
// Xóa sạch các layers cũ trước khi nạp mới
|
||||||
markerClusterGroup.clearLayers();
|
markerClusterGroup.clearLayers();
|
||||||
const markersToAdd = [];
|
const markersToAdd = [];
|
||||||
const activeSceneId = localStorage.getItem('activeSceneId');
|
const activeSceneId = localStorage.getItem('activeSceneId');
|
||||||
const seenCoordinates = new Set();
|
const seenCoordinates = new Set(); // Dùng để lọc "Ảnh mẹ" (1 marker per location)
|
||||||
|
|
||||||
// Chỉ lặp qua danh sách Scene mẹ, lọc bỏ các hotspots trùng tọa độ
|
// Chỉ lặp qua danh sách Scene mẹ, lọc bỏ các hotspots trùng tọa độ
|
||||||
scenes.forEach((scene) => {
|
scenes.forEach((scene) => {
|
||||||
// Tạo khóa tọa độ để đảm bảo mỗi vị trí địa lý chỉ có 1 Marker duy nhất
|
const latNum = parseFloat(scene.lat);
|
||||||
const coordKey = `${scene.lat.toFixed(6)},${scene.lng.toFixed(6)}`;
|
const lngNum = parseFloat(scene.lng);
|
||||||
|
|
||||||
|
if (isNaN(latNum) || isNaN(lngNum)) return;
|
||||||
|
|
||||||
|
// Logic lọc Ảnh mẹ: Mỗi tọa độ GPS chỉ tạo duy nhất 1 Marker đại diện
|
||||||
|
const coordKey = `${latNum.toFixed(6)},${lngNum.toFixed(6)}`;
|
||||||
if (seenCoordinates.has(coordKey)) return; // Bỏ qua nếu tọa độ này đã có Marker
|
if (seenCoordinates.has(coordKey)) return; // Bỏ qua nếu tọa độ này đã có Marker
|
||||||
seenCoordinates.add(coordKey);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
let thumbUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`;
|
let thumbUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`;
|
||||||
if (token) thumbUrl += `?token=${token}`;
|
if (token) thumbUrl += `?token=${token}`;
|
||||||
else if (scene.privacy === 'shared' && scene.shareToken) thumbUrl += `?token=${scene.shareToken}`;
|
else if (scene.privacy === 'shared' && scene.shareToken) thumbUrl += `?token=${scene.shareToken}`;
|
||||||
@@ -335,7 +389,7 @@ async function loadScenes() {
|
|||||||
iconAnchor: [32, 76] // Căn giữa ngang, đáy mũi tên tại tọa độ lat/lng
|
iconAnchor: [32, 76] // Căn giữa ngang, đáy mũi tên tại tọa độ lat/lng
|
||||||
});
|
});
|
||||||
|
|
||||||
const marker = L.marker([scene.lat, scene.lng], {
|
const marker = L.marker([latNum, lngNum], {
|
||||||
icon: calloutIcon,
|
icon: calloutIcon,
|
||||||
title: scene.title // Tooltip khi di chuột qua
|
title: scene.title // Tooltip khi di chuột qua
|
||||||
});
|
});
|
||||||
@@ -472,6 +526,7 @@ async function openScene(sceneId, privacy, shareToken, force = false) {
|
|||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[Viewer] Đang mở scene: ${sceneId}`);
|
||||||
let url = `${API_BASE_URL}/scenes/${sceneId}`;
|
let url = `${API_BASE_URL}/scenes/${sceneId}`;
|
||||||
if (privacy === 'shared' && shareToken) {
|
if (privacy === 'shared' && shareToken) {
|
||||||
url += `?token=${shareToken}`;
|
url += `?token=${shareToken}`;
|
||||||
@@ -703,3 +758,30 @@ async function saveHotspotToDB(pitch, yaw, text, description, targetSceneId, hot
|
|||||||
alert(error.message);
|
alert(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Công cụ dọn dẹp toàn bộ dữ liệu (Chỉ dùng cho nhà phát triển)
|
||||||
|
* Gọi lệnh: systemReset() từ trình duyệt
|
||||||
|
*/
|
||||||
|
window.systemReset = async function() {
|
||||||
|
if (!confirm("CẢNH BÁO: Thao tác này sẽ xóa sạch TOÀN BỘ scene và ảnh trên server. Bạn có chắc chắn?")) return;
|
||||||
|
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/maintenance/reset-all`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
localStorage.clear(); // Xóa sạch token, vị trí map, active scene
|
||||||
|
alert(data.message);
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert("Lỗi reset: " + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
let activeViewer = null;
|
let activeViewer = null;
|
||||||
let currentHotspots = [];
|
let currentHotspots = [];
|
||||||
|
let securityApplied = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes and shows the Pannellum 360° panorama viewer with security overlays.
|
* Initializes and shows the Pannellum 360° panorama viewer with security overlays.
|
||||||
@@ -72,6 +73,9 @@ function closeViewer() {
|
|||||||
* Appends event listeners to block right-clicks and common image saving shortcuts.
|
* Appends event listeners to block right-clicks and common image saving shortcuts.
|
||||||
*/
|
*/
|
||||||
function applyViewerSecurity() {
|
function applyViewerSecurity() {
|
||||||
|
if (securityApplied) return; // Chỉ gán sự kiện một lần duy nhất
|
||||||
|
securityApplied = true;
|
||||||
|
|
||||||
// Target the actual viewer element where Pannellum renders
|
// Target the actual viewer element where Pannellum renders
|
||||||
const container = document.getElementById('viewer-container');
|
const container = document.getElementById('viewer-container');
|
||||||
const panoramaViewer = document.getElementById('panorama-viewer');
|
const panoramaViewer = document.getElementById('panorama-viewer');
|
||||||
@@ -114,18 +118,16 @@ function applyViewerSecurity() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global safety shortcut listeners (F12, Ctrl+S, Ctrl+U, Ctrl+Shift+I)
|
// Global safety shortcut listeners (Ctrl+S, Ctrl+U) - Tạm thời cho phép F12 và Ctrl+Shift+I để debug
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
// Only enforce when viewer is active
|
// Only enforce when viewer is active
|
||||||
if (document.getElementById('viewer-container').style.display === 'block') {
|
if (document.getElementById('viewer-container').style.display === 'block') {
|
||||||
const isCtrlS = e.ctrlKey && (e.key === 's' || e.key === 'S');
|
const isCtrlS = e.ctrlKey && (e.key === 's' || e.key === 'S');
|
||||||
const isCtrlU = e.ctrlKey && (e.key === 'u' || e.key === 'U');
|
const isCtrlU = e.ctrlKey && (e.key === 'u' || e.key === 'U');
|
||||||
const isF12 = e.key === 'F12';
|
|
||||||
const isCtrlShiftI = e.ctrlKey && e.shiftKey && (e.key === 'i' || e.key === 'I');
|
|
||||||
|
|
||||||
if (isCtrlS || isCtrlU || isF12 || isCtrlShiftI) {
|
if (isCtrlS || isCtrlU) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
alert('Security Alert: Inspection and saving functions are restricted on this viewer.');
|
console.warn('Security Alert: Inspection and saving functions are restricted.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||