Sửa lỗi quyền chia sẻ và hiển thị lên map ở các liên kết chéo
This commit is contained in:
@@ -27,6 +27,7 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu
|
|||||||
- `scene_url`: String
|
- `scene_url`: String
|
||||||
- `gps`: Object { `lat`: Number, `lng`: Number }
|
- `gps`: Object { `lat`: Number, `lng`: Number }
|
||||||
- `createdBy`: ObjectId (Ref: User)
|
- `createdBy`: ObjectId (Ref: User)
|
||||||
|
- `tourId`: ObjectId (Ref: Scene) - ID của cảnh gốc tạo nên tour
|
||||||
- `privacy`: String ['public', 'private', 'member', 'shared']
|
- `privacy`: String ['public', 'private', 'member', 'shared']
|
||||||
- `status`: String ['processing', 'completed', 'failed']
|
- `status`: String ['processing', 'completed', 'failed']
|
||||||
- `shareToken`: String (Dùng cho link truy cập nhanh)
|
- `shareToken`: String (Dùng cho link truy cập nhanh)
|
||||||
|
|||||||
@@ -36,11 +36,25 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res
|
|||||||
} else {
|
} else {
|
||||||
const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
||||||
const userEmail = req.user ? req.user.email : null;
|
const userEmail = req.user ? req.user.email : null;
|
||||||
const hasAccess = scene.privacy === 'public' ||
|
let hasAccess = scene.privacy === 'public' ||
|
||||||
(scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
|
(scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
|
||||||
(req.user && scene.createdBy.toString() === req.user._id.toString()) ||
|
(req.user && scene.createdBy.toString() === req.user._id.toString()) ||
|
||||||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
|
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
|
||||||
|
|
||||||
|
// [BRIDGE ACCESS LOGIC]
|
||||||
|
// Áp dụng tương tự cho Asset để đảm bảo hiển thị được ảnh khi di chuyển liên kết chéo
|
||||||
|
if (!hasAccess && req.query.token) {
|
||||||
|
const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id');
|
||||||
|
if (potentialParents.length > 0) {
|
||||||
|
const authorizedParentExists = await Scene.exists({
|
||||||
|
_id: { $in: potentialParents },
|
||||||
|
shareToken: req.query.token,
|
||||||
|
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
|
||||||
|
});
|
||||||
|
if (authorizedParentExists) hasAccess = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasAccess) return res.status(403).json({ message: 'Access denied' });
|
if (!hasAccess) return res.status(403).json({ message: 'Access denied' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,12 @@ const uploadSinglePanorama = (req, res, next) => {
|
|||||||
// @route POST /api/scenes
|
// @route POST /api/scenes
|
||||||
router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => {
|
router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, lat, lng, privacy, sharedWithUsers } = req.body;
|
const { title, lat, lng, privacy, sharedWithUsers, tourId } = req.body;
|
||||||
if (!req.file) return res.status(400).json({ message: 'Please upload a panorama image' });
|
if (!req.file) return res.status(400).json({ message: 'Please upload a panorama image' });
|
||||||
|
|
||||||
|
// Xử lý tourId từ FormData an toàn
|
||||||
|
const cleanedTourId = (tourId && tourId !== 'null' && tourId !== '') ? tourId : undefined;
|
||||||
|
|
||||||
const latitude = Number(lat) || 0;
|
const latitude = Number(lat) || 0;
|
||||||
const longitude = Number(lng) || 0;
|
const longitude = Number(lng) || 0;
|
||||||
const tempFilePath = req.file.path;
|
const tempFilePath = req.file.path;
|
||||||
@@ -64,8 +67,11 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
|
|||||||
privacy: privacy || 'private',
|
privacy: privacy || 'private',
|
||||||
shareToken,
|
shareToken,
|
||||||
sharedWith: parsedSharedWith,
|
sharedWith: parsedSharedWith,
|
||||||
status: 'processing'
|
status: 'processing',
|
||||||
|
tourId: cleanedTourId
|
||||||
});
|
});
|
||||||
|
// Mặc định mỗi cảnh mới khi tạo ra là cảnh gốc của chính nó
|
||||||
|
if (!scene.tourId) scene.tourId = scene._id;
|
||||||
await scene.save();
|
await scene.save();
|
||||||
|
|
||||||
await imageQueue.add('process-panorama', {
|
await imageQueue.add('process-panorama', {
|
||||||
@@ -101,19 +107,32 @@ router.get('/:id', optionalAuth, async (req, res) => {
|
|||||||
|
|
||||||
const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
const isTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
|
||||||
const userEmail = req.user ? req.user.email : null;
|
const userEmail = req.user ? req.user.email : null;
|
||||||
const hasAccess = scene.privacy === 'public' ||
|
let hasAccess = scene.privacy === 'public' ||
|
||||||
(scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
|
(scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) ||
|
||||||
(req.user && scene.createdBy._id.toString() === req.user._id.toString()) ||
|
(req.user && scene.createdBy._id.toString() === req.user._id.toString()) ||
|
||||||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
|
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid);
|
||||||
|
|
||||||
|
// [BRIDGE ACCESS LOGIC]
|
||||||
|
// Nếu chưa có quyền, kiểm tra xem người dùng có đến từ một cảnh hợp lệ thuộc Tour khác không
|
||||||
|
if (!hasAccess && req.query.token) {
|
||||||
|
// Tìm tất cả các cảnh (parent) có hotspot trỏ đến cảnh hiện tại
|
||||||
|
const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id');
|
||||||
|
if (potentialParents.length > 0) {
|
||||||
|
// Kiểm tra xem có cảnh cha nào sở hữu shareToken này và còn hạn không
|
||||||
|
const authorizedParentExists = await Scene.exists({
|
||||||
|
_id: { $in: potentialParents },
|
||||||
|
shareToken: req.query.token,
|
||||||
|
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
|
||||||
|
});
|
||||||
|
if (authorizedParentExists) hasAccess = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasAccess) return res.status(403).json({ message: 'Access denied' });
|
if (!hasAccess) return res.status(403).json({ message: 'Access denied' });
|
||||||
|
|
||||||
// Một cảnh chỉ được coi là cảnh con nếu có hotspot đi tới (không phải link quay lại) trỏ đến nó
|
// [BẢO MẬT] Một cảnh là cảnh con nếu nó thuộc về một tour và tourId khác với ID chính nó
|
||||||
const isChildScene = await Hotspot.exists({
|
const isChild = scene.tourId && scene.tourId.toString() !== scene._id.toString();
|
||||||
target_scene_id: scene._id,
|
res.json({ ...scene.toObject(), isChildScene: !!isChild });
|
||||||
is_auto_return: { $ne: true }
|
|
||||||
});
|
|
||||||
res.json({ ...scene.toObject(), isChildScene: !!isChildScene });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
}
|
}
|
||||||
@@ -130,11 +149,8 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// [BẢO MẬT] Kiểm tra nếu là cảnh con thì chặn thay đổi Privacy
|
// [BẢO MẬT] Kiểm tra nếu là cảnh con thì chặn thay đổi Privacy
|
||||||
// Chỉ chặn nếu cảnh này là đích đến của một luồng điều hướng đi xuôi
|
// Dựa vào tourId để xác định quan hệ cha-con chính xác, tránh bị nhầm bởi liên kết chéo (cross-link)
|
||||||
const isChild = await Hotspot.exists({
|
const isChild = scene.tourId && scene.tourId.toString() !== scene._id.toString();
|
||||||
target_scene_id: req.params.id,
|
|
||||||
is_auto_return: { $ne: true }
|
|
||||||
});
|
|
||||||
if (isChild && privacy && privacy !== scene.privacy) {
|
if (isChild && privacy && privacy !== scene.privacy) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
message: "Cảnh này thuộc một tour. Vui lòng thay đổi quyền riêng tư tại Cảnh gốc để đồng bộ."
|
message: "Cảnh này thuộc một tour. Vui lòng thay đổi quyền riêng tư tại Cảnh gốc để đồng bộ."
|
||||||
@@ -193,11 +209,13 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
|
|||||||
await fs.promises.unlink(req.file.path).catch(() => {});
|
await fs.promises.unlink(req.file.path).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [FIX] Đảm bảo root scene luôn có tourId để logic lan truyền hoạt động (cho cả dữ liệu cũ)
|
||||||
|
if (!scene.tourId) scene.tourId = scene._id;
|
||||||
await scene.save();
|
await scene.save();
|
||||||
|
|
||||||
// [CASCADING] Lan truyền Privacy xuống các cảnh con nếu đây là cảnh cha
|
// [CASCADING] Lan truyền Privacy xuống các cảnh con nếu đây là cảnh cha
|
||||||
if (!isChild) {
|
if (!isChild) {
|
||||||
await propagateScenePrivacy(scene._id, scene);
|
await propagateScenePrivacy(scene.tourId || scene._id, scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ message: 'Cập nhật thành công và đã đồng bộ quyền riêng tư cho các cảnh liên quan.', scene });
|
res.json({ message: 'Cập nhật thành công và đã đồng bộ quyền riêng tư cho các cảnh liên quan.', scene });
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ const { logActivity } = require('./logger');
|
|||||||
* @returns {Promise<{deletedCount: number}>} Số lượng scene đã xóa
|
* @returns {Promise<{deletedCount: number}>} Số lượng scene đã xóa
|
||||||
*/
|
*/
|
||||||
const deleteSceneCascade = async (rootSceneId, performer = 'System') => {
|
const deleteSceneCascade = async (rootSceneId, performer = 'System') => {
|
||||||
|
// 0. Xác định tourId của scene gốc để thiết lập biên giới xóa
|
||||||
|
const rootScene = await Scene.findById(rootSceneId);
|
||||||
|
if (!rootScene) return { deletedCount: 0 };
|
||||||
|
const tourId = rootScene.tourId ? rootScene.tourId.toString() : null;
|
||||||
|
|
||||||
// BƯỚC SỬA LỖI QUAN TRỌNG: Xóa toàn bộ "điều hướng" (Hotspots) trỏ ĐẾN scene này.
|
// BƯỚC SỬA LỖI QUAN TRỌNG: Xóa toàn bộ "điều hướng" (Hotspots) trỏ ĐẾN scene này.
|
||||||
// Đây chính là lệnh "xóa điều hướng" để cô lập scene con khỏi scene cha ngay lập tức.
|
// Đây chính là lệnh "xóa điều hướng" để cô lập scene con khỏi scene cha ngay lập tức.
|
||||||
// Nó đảm bảo các scene cha không còn bất kỳ liên kết nào dẫn đến luồng xóa này.
|
// Nó đảm bảo các scene cha không còn bất kỳ liên kết nào dẫn đến luồng xóa này.
|
||||||
@@ -27,14 +32,23 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => {
|
|||||||
// Chỉ tìm các hotspots xuất phát từ scene hiện tại trỏ đến các scene con.
|
// Chỉ tìm các hotspots xuất phát từ scene hiện tại trỏ đến các scene con.
|
||||||
// QUAN TRỌNG: Phải loại bỏ các liên kết "Quay lại" (is_auto_return: true)
|
// QUAN TRỌNG: Phải loại bỏ các liên kết "Quay lại" (is_auto_return: true)
|
||||||
// để tránh việc thuật toán đi ngược lên cảnh cha.
|
// để tránh việc thuật toán đi ngược lên cảnh cha.
|
||||||
|
// Populate target_scene_id để kiểm tra tourId
|
||||||
const childHotspots = await Hotspot.find({
|
const childHotspots = await Hotspot.find({
|
||||||
parent_scene_id: parentId,
|
parent_scene_id: parentId,
|
||||||
is_auto_return: { $ne: true }
|
is_auto_return: { $ne: true }
|
||||||
});
|
}).populate('target_scene_id', 'tourId');
|
||||||
|
|
||||||
for (const hs of childHotspots) {
|
for (const hs of childHotspots) {
|
||||||
if (hs.target_scene_id) {
|
if (hs.target_scene_id && typeof hs.target_scene_id === 'object') {
|
||||||
const targetIdStr = hs.target_scene_id.toString();
|
const targetScene = hs.target_scene_id;
|
||||||
if (!visited.has(targetIdStr)) {
|
const targetIdStr = targetScene._id.toString();
|
||||||
|
const targetTourId = targetScene.tourId ? targetScene.tourId.toString() : null;
|
||||||
|
|
||||||
|
// [BẢO MẬT] Ngăn chặn xóa dây chuyền sang Tour khác.
|
||||||
|
// Một cảnh chỉ được xóa nếu:
|
||||||
|
// 1. Nó có cùng tourId với cảnh gốc đang bị xóa.
|
||||||
|
// 2. tourId của cả hai phải tồn tại (không null) để tránh xóa nhầm dữ liệu cũ chưa migrate.
|
||||||
|
if (tourId && targetTourId && targetTourId === tourId && !visited.has(targetIdStr)) {
|
||||||
visited.add(targetIdStr);
|
visited.add(targetIdStr);
|
||||||
scenesToDelete.push(targetIdStr);
|
scenesToDelete.push(targetIdStr);
|
||||||
queue.push(targetIdStr);
|
queue.push(targetIdStr);
|
||||||
@@ -79,61 +93,35 @@ const deleteSceneCascade = async (rootSceneId, performer = 'System') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lan truyền thiết lập quyền riêng tư từ Scene cha xuống TOÀN BỘ các Scene con trong Tour (Đệ quy/BFS).
|
* Lan truyền thiết lập quyền riêng tư cho toàn bộ Tour dựa trên tourId.
|
||||||
* Đảm bảo tính nhất quán của toàn bộ Tour khi thay đổi quyền truy cập.
|
* Đảm bảo tính nhất quán của toàn bộ Tour khi thay đổi quyền truy cập.
|
||||||
* @param {string} parentSceneId - ID của Scene cha vừa được cập nhật
|
* @param {string} tourId - ID định danh của Tour (thường là ID của cảnh gốc)
|
||||||
* @param {Object} privacyData - Dữ liệu quyền riêng tư từ Scene cha (privacy, tokens, v.v.)
|
* @param {Object} privacyData - Dữ liệu quyền riêng tư mới
|
||||||
*/
|
*/
|
||||||
const propagateScenePrivacy = async (parentSceneId, privacyData) => {
|
const propagateScenePrivacy = async (tourId, privacyData) => {
|
||||||
|
if (!tourId) return;
|
||||||
|
|
||||||
const { privacy, shareToken, shareTokenExpires, sharedWith, sharedEmails } = privacyData;
|
const { privacy, shareToken, shareTokenExpires, sharedWith, sharedEmails } = privacyData;
|
||||||
|
|
||||||
// 1. Tìm tất cả các scene con ở mọi cấp độ bằng thuật toán BFS
|
|
||||||
let queue = [parentSceneId.toString()];
|
|
||||||
let allChildIds = [];
|
|
||||||
const visited = new Set(queue);
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const currentId = queue.shift();
|
|
||||||
// Tìm các hotspots xuất phát từ scene hiện tại (bỏ qua link quay lại để tránh vòng lặp)
|
|
||||||
const hotspots = await Hotspot.find({
|
|
||||||
parent_scene_id: currentId,
|
|
||||||
is_auto_return: { $ne: true }
|
|
||||||
}).select('target_scene_id');
|
|
||||||
|
|
||||||
for (const hs of hotspots) {
|
|
||||||
if (hs.target_scene_id) {
|
|
||||||
const targetIdStr = hs.target_scene_id.toString();
|
|
||||||
if (!visited.has(targetIdStr)) {
|
|
||||||
visited.add(targetIdStr);
|
|
||||||
allChildIds.push(targetIdStr);
|
|
||||||
queue.push(targetIdStr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allChildIds.length === 0) return;
|
|
||||||
|
|
||||||
// 2. Chuẩn bị dữ liệu cập nhật đồng bộ cho toàn bộ chuỗi
|
// 2. Chuẩn bị dữ liệu cập nhật đồng bộ cho toàn bộ chuỗi
|
||||||
const updateFields = { privacy };
|
const updateFields = { privacy, tourId }; // Luôn đồng bộ tourId
|
||||||
const updateQuery = { $set: updateFields };
|
const unsets = {};
|
||||||
|
|
||||||
if (privacy === 'shared') {
|
if (privacy === 'shared') {
|
||||||
// Chỉ gán nếu có giá trị, nếu không thì xóa hẳn để tránh lỗi Duplicate Key Null
|
// Chỉ gán nếu có giá trị, nếu không thì xóa hẳn để tránh lỗi Duplicate Key Null
|
||||||
if (shareToken) {
|
if (shareToken) {
|
||||||
updateFields.shareToken = shareToken;
|
updateFields.shareToken = shareToken;
|
||||||
} else {
|
} else {
|
||||||
if (!updateQuery.$unset) updateQuery.$unset = {};
|
unsets.shareToken = 1;
|
||||||
updateQuery.$unset.shareToken = 1;
|
|
||||||
}
|
}
|
||||||
updateFields.shareTokenExpires = shareTokenExpires || undefined;
|
updateFields.shareTokenExpires = shareTokenExpires || undefined;
|
||||||
updateFields.sharedWith = sharedWith || [];
|
updateFields.sharedWith = sharedWith || [];
|
||||||
updateFields.sharedEmails = sharedEmails || [];
|
updateFields.sharedEmails = sharedEmails || [];
|
||||||
} else {
|
} else {
|
||||||
// [BẢO MẬT] Xóa hoàn toàn token cho mọi chế độ không phải 'shared' để tránh lỗi Duplicate Key Null
|
// [BẢO MẬT] Xóa hoàn toàn token cho mọi chế độ không phải 'shared' để tránh lỗi Duplicate Key Null
|
||||||
if (!updateQuery.$unset) updateQuery.$unset = {};
|
unsets.shareToken = 1;
|
||||||
updateQuery.$unset.shareToken = 1;
|
unsets.shareTokenExpires = 1;
|
||||||
updateQuery.$unset.shareTokenExpires = 1;
|
|
||||||
// Nếu là private hoặc public, xóa luôn danh sách thành viên được chia sẻ
|
// Nếu là private hoặc public, xóa luôn danh sách thành viên được chia sẻ
|
||||||
if (privacy !== 'member') {
|
if (privacy !== 'member') {
|
||||||
updateFields.sharedWith = [];
|
updateFields.sharedWith = [];
|
||||||
@@ -141,13 +129,17 @@ const propagateScenePrivacy = async (parentSceneId, privacyData) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Cập nhật hàng loạt cho tất cả các scene con được tìm thấy
|
// Xây dựng Query cuối cùng
|
||||||
|
const updateQuery = { $set: updateFields };
|
||||||
|
if (Object.keys(unsets).length > 0) updateQuery.$unset = unsets;
|
||||||
|
|
||||||
|
// 3. Cập nhật hàng loạt dựa trên tourId (Nhanh và chính xác tuyệt đối)
|
||||||
await Scene.updateMany(
|
await Scene.updateMany(
|
||||||
{ _id: { $in: allChildIds } },
|
{ tourId: tourId },
|
||||||
updateQuery
|
updateQuery
|
||||||
);
|
);
|
||||||
|
|
||||||
await logActivity('PROPAGATE_PRIVACY_DEEP', { parentSceneId, childCount: allChildIds.length, privacy }, 'System');
|
await logActivity('PROPAGATE_PRIVACY_BY_TOUR', { tourId, privacy }, 'System');
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { deleteSceneCascade, propagateScenePrivacy };
|
module.exports = { deleteSceneCascade, propagateScenePrivacy };
|
||||||
+49
-2
@@ -1099,6 +1099,12 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
|
|||||||
const scene = await sceneRes.json();
|
const scene = await sceneRes.json();
|
||||||
const hotspots = await hotspotsRes.json();
|
const hotspots = await hotspotsRes.json();
|
||||||
|
|
||||||
|
// [FIX] Lưu tourId đang hoạt động sau khi đã nạp dữ liệu scene thành công
|
||||||
|
const currentTourId = scene.tourId?._id || scene.tourId || scene._id;
|
||||||
|
if (!localStorage.getItem('activeTourId') || force) {
|
||||||
|
localStorage.setItem('activeTourId', currentTourId);
|
||||||
|
}
|
||||||
|
|
||||||
if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details');
|
if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details');
|
||||||
|
|
||||||
// [FIX CRITICAL] Kiểm tra bảo mật Client-side:
|
// [FIX CRITICAL] Kiểm tra bảo mật Client-side:
|
||||||
@@ -1158,6 +1164,7 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
|
|||||||
localStorage.removeItem('activeSceneId');
|
localStorage.removeItem('activeSceneId');
|
||||||
localStorage.removeItem('activeScenePrivacy');
|
localStorage.removeItem('activeScenePrivacy');
|
||||||
localStorage.removeItem('activeSceneToken');
|
localStorage.removeItem('activeSceneToken');
|
||||||
|
localStorage.removeItem('activeTourId');
|
||||||
|
|
||||||
// Kiểm tra nếu đang truy cập qua link trực tiếp (URL có sceneId) mà gặp lỗi (do xóa token hoặc token không hợp lệ)
|
// Kiểm tra nếu đang truy cập qua link trực tiếp (URL có sceneId) mà gặp lỗi (do xóa token hoặc token không hợp lệ)
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -1231,6 +1238,29 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// [Task 3.1] Lắng nghe thay đổi để nhận diện liên kết chéo
|
||||||
|
select.onchange = () => {
|
||||||
|
const selectedId = select.value;
|
||||||
|
const targetScene = scenes.find(s => s._id === selectedId);
|
||||||
|
const activeTourId = localStorage.getItem('activeTourId');
|
||||||
|
const targetTourId = targetScene?.tourId?._id || targetScene?.tourId;
|
||||||
|
|
||||||
|
let crossLinkNotice = document.getElementById('hs-crosslink-notice');
|
||||||
|
if (!crossLinkNotice) {
|
||||||
|
crossLinkNotice = document.createElement('div');
|
||||||
|
crossLinkNotice.id = 'hs-crosslink-notice';
|
||||||
|
crossLinkNotice.style = 'font-size: 11px; margin-top: 5px; color: #ffc107; display: none;';
|
||||||
|
select.parentNode.appendChild(crossLinkNotice);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetTourId && activeTourId && targetTourId !== activeTourId) {
|
||||||
|
crossLinkNotice.innerText = "ℹ️ Cảnh này thuộc Tour khác. Liên kết sẽ được tạo dưới dạng liên kết chéo.";
|
||||||
|
crossLinkNotice.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
crossLinkNotice.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// QUAN TRỌNG: Chỉ điền dữ liệu hotspot cũ SAU KHI dropdown đã được nạp đầy đủ options
|
// QUAN TRỌNG: Chỉ điền dữ liệu hotspot cũ SAU KHI dropdown đã được nạp đầy đủ options
|
||||||
if (existingHotspot) {
|
if (existingHotspot) {
|
||||||
document.getElementById('hs-title').value = existingHotspot.title || '';
|
document.getElementById('hs-title').value = existingHotspot.title || '';
|
||||||
@@ -1277,6 +1307,10 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
|
|||||||
sceneData.append('lng', lng);
|
sceneData.append('lng', lng);
|
||||||
sceneData.append('privacy', 'public');
|
sceneData.append('privacy', 'public');
|
||||||
|
|
||||||
|
// [FIX] Kế thừa tourId từ cảnh cha khi tạo cảnh mới qua hotspot upload
|
||||||
|
const activeTourId = localStorage.getItem('activeTourId');
|
||||||
|
if (activeTourId) sceneData.append('tourId', activeTourId);
|
||||||
|
|
||||||
uploadWithProgress(`${API_BASE_URL}/scenes`, 'POST', sceneData, token, 'hs', async (sceneRes) => {
|
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);
|
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), sceneRes.scene._id, existingHotspot?._id);
|
||||||
closeHotspotModal();
|
closeHotspotModal();
|
||||||
@@ -2029,6 +2063,10 @@ window.openEditMetadataModal = function(scene, isChildArg = null) {
|
|||||||
sharedUsersData = scene.sharedWith || [];
|
sharedUsersData = scene.sharedWith || [];
|
||||||
sharedEmailsData = scene.sharedEmails || [];
|
sharedEmailsData = scene.sharedEmails || [];
|
||||||
|
|
||||||
|
// [FIX] Cập nhật activeTourId ngay khi chỉnh sửa để đồng bộ luồng tạo tour
|
||||||
|
const currentTourId = scene.tourId?._id || scene.tourId || scene._id;
|
||||||
|
localStorage.setItem('activeTourId', currentTourId);
|
||||||
|
|
||||||
document.getElementById('edit-modal-scene-id').value = scene._id;
|
document.getElementById('edit-modal-scene-id').value = scene._id;
|
||||||
document.getElementById('edit-modal-title').value = scene.name || scene.title || '';
|
document.getElementById('edit-modal-title').value = scene.name || scene.title || '';
|
||||||
document.getElementById('edit-modal-description').value = scene.description || '';
|
document.getElementById('edit-modal-description').value = scene.description || '';
|
||||||
@@ -2047,12 +2085,21 @@ window.openEditMetadataModal = function(scene, isChildArg = null) {
|
|||||||
privacySelect.disabled = true;
|
privacySelect.disabled = true;
|
||||||
childInfo.style.display = 'block';
|
childInfo.style.display = 'block';
|
||||||
if (modalTitle) modalTitle.innerText = "Chi tiết Cảnh con (Kế thừa)";
|
if (modalTitle) modalTitle.innerText = "Chi tiết Cảnh con (Kế thừa)";
|
||||||
childInfo.innerHTML = `ℹ️ Cảnh này thuộc một tour. Quyền riêng tư được quản lý bởi Cảnh gốc.`;
|
|
||||||
|
// [Task 3.2] Nhận diện và hiển thị nhãn liên kết chéo
|
||||||
|
const activeTourId = localStorage.getItem('activeTourId');
|
||||||
|
const sceneTourId = scene.tourId?._id || scene.tourId;
|
||||||
|
let crossLabel = activeTourId && sceneTourId && activeTourId !== sceneTourId.toString()
|
||||||
|
? `<br><span style="color: #ffc107; font-weight: bold;">⚠️ Liên kết Tour khác:</span> Quyền riêng tư được quản lý bởi Tour gốc của cảnh này.`
|
||||||
|
: `ℹ️ Cảnh này thuộc một tour. Quyền riêng tư được quản lý bởi Cảnh gốc.`;
|
||||||
|
childInfo.innerHTML = crossLabel;
|
||||||
} else {
|
} else {
|
||||||
privacySelect.value = scene.privacy;
|
privacySelect.value = scene.privacy;
|
||||||
privacySelect.disabled = false;
|
privacySelect.disabled = false;
|
||||||
childInfo.style.display = 'none';
|
childInfo.style.display = 'block';
|
||||||
if (modalTitle) modalTitle.innerText = "Sửa 3D Scene (Cảnh gốc)";
|
if (modalTitle) modalTitle.innerText = "Sửa 3D Scene (Cảnh gốc)";
|
||||||
|
// [Task 3.2] Cảnh báo Privacy cho Cảnh gốc
|
||||||
|
childInfo.innerHTML = `<i style="color: #888;">ℹ️ Thay đổi sẽ áp dụng cho toàn bộ tour này. Các cảnh liên kết chéo từ tour khác sẽ KHÔNG bị ảnh hưởng.</i>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEditPrivacyChange(); // Cập nhật hiển thị nút bánh răng
|
handleEditPrivacyChange(); // Cập nhật hiển thị nút bánh răng
|
||||||
|
|||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 6.9 MiB After Width: | Height: | Size: 6.9 MiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 MiB After Width: | Height: | Size: 5.3 MiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 5.7 MiB After Width: | Height: | Size: 5.7 MiB |
Reference in New Issue
Block a user