Compare commits
7 Commits
d7556aa087
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a0422aa00 | |||
| 00bde46c5e | |||
| 133a8721bb | |||
| 2e5fef229f | |||
| 1995b63474 | |||
| b3af752884 | |||
| 377c4d41d8 |
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,16 @@
|
||||
# Cấu hình Server
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
JWT_SECRET=generate_a_random_long_string_here
|
||||
SYSTEM_HOST=https://your-domain.com
|
||||
ADDITIONAL_ALLOWED_ORIGINS=http://localhost:5000
|
||||
|
||||
# Cấu hình MongoDB
|
||||
MONGO_USERNAME=admin
|
||||
MONGO_PASSWORD=secure_password_here
|
||||
MONGODB_URI=mongodb://admin:secure_password_here@mongo:27017/3dtours?authSource=admin
|
||||
|
||||
# Cấu hình Redis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
UPLOAD_DIR=/app/uploads
|
||||
+1
-9
@@ -145,12 +145,4 @@ Tài liệu này tổng hợp toàn bộ cấu trúc hệ thống phục vụ qu
|
||||
- `securityMiddleware.js`:
|
||||
- `verifyReferer`: Chặn truy cập trực tiếp từ trình duyệt/site khác.
|
||||
- `setNoCacheHeaders`: Chặn lưu cache các tài sản nhạy cảm.
|
||||
- `quotaMiddleware.js`: Kiểm tra dung lượng lưu trữ dựa trên Role người dùng.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ghi chú cho Refactor
|
||||
- Cần chuẩn hóa các đường dẫn tuyệt đối (hiện đang fix cứng `/home/locpham/...`).
|
||||
- Chuyển đổi các logic xử lý file đồng bộ (`fs.unlinkSync`) sang bất đồng bộ để tối ưu I/O.
|
||||
- Tách nhỏ `apiRoutes.js` thành các route con (admin, scenes, users, assets).
|
||||
- Bổ sung Unit Test cho logic tính toán tọa độ Hotspot ngược.
|
||||
- `quotaMiddleware.js`: Kiểm tra dung lượng lưu trữ dựa trên Role người dùng.
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM node:18-slim
|
||||
|
||||
# Cài đặt các công cụ biên dịch và thư viện cần thiết cho các module native (sharp, bcrypt)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
libvips-dev \
|
||||
perl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "backend/server.js"]
|
||||
@@ -2,9 +2,6 @@ const mongoose = require('mongoose');
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
// Tự động tìm và nạp file .env nằm cùng thư mục với folder config (tức là trong backend/.env)
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const connectDB = async () => {
|
||||
try {
|
||||
const dbURI = process.env.MONGODB_URI;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"bullmq": "^5.8.0",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"exiftool-vendored": "^26.4.0",
|
||||
"exiftool-vendored": "^29.2.0",
|
||||
"express": "^5.2.1",
|
||||
"express-fileupload": "^1.5.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
|
||||
@@ -97,13 +97,16 @@ router.get('/users', protect, async (req, res) => {
|
||||
router.put('/users/:id', protect, async (req, res) => {
|
||||
if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' });
|
||||
try {
|
||||
const { fullName, email, role, password } = req.body;
|
||||
const { fullName, email, role, password, quota } = req.body;
|
||||
const user = await User.findById(req.params.id);
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
if (fullName) user.fullName = fullName;
|
||||
if (email) user.email = email;
|
||||
if (role && user.role !== 'admin') user.role = role;
|
||||
if (password) user.password = password;
|
||||
if (quota !== undefined) {
|
||||
user.storage = { ...user.storage, quota: parseInt(quota) * 1024 * 1024 };
|
||||
}
|
||||
await user.save();
|
||||
res.json({ message: 'User updated' });
|
||||
} catch (error) { res.status(500).json({ message: error.message }); }
|
||||
|
||||
@@ -23,6 +23,7 @@ const assetRoutes = require('./assetRoutes');
|
||||
// Ở đây tôi gắn các route còn lại trực tiếp để không làm gián đoạn hệ thống
|
||||
router.use('/admin', adminRoutes);
|
||||
router.use('/auth', authRoutes); // Tích hợp API Đăng ký/Đăng nhập
|
||||
router.get('/share/:id', sceneRoutes.shareScene); // Route hỗ trợ Open Graph
|
||||
router.use('/tours', tourRoutes); // Thêm các route cho Tour
|
||||
router.use('/scenes', sceneRoutes);
|
||||
router.use('/users', userRoutes);
|
||||
|
||||
@@ -4,6 +4,20 @@ const User = require('../models/User');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @route GET /api/auth/init-status
|
||||
* @desc Check if the system has at least one admin
|
||||
* @access Public
|
||||
*/
|
||||
router.get('/init-status', async (req, res) => {
|
||||
try {
|
||||
const userCount = await User.countDocuments();
|
||||
res.json({ initialized: userCount > 0 });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route POST /api/auth/register
|
||||
* @desc Register a new user
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const { Queue } = require('bullmq');
|
||||
const IORedis = require('ioredis');
|
||||
|
||||
// Cấu hình kết nối Redis (Mặc định localhost:6379)
|
||||
// Cấu hình kết nối Redis sử dụng biến môi trường từ Docker
|
||||
const connection = new IORedis({
|
||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
maxRetriesPerRequest: null
|
||||
});
|
||||
|
||||
|
||||
@@ -17,10 +17,17 @@ const { imageQueue } = require('./imageQueue');
|
||||
const { deleteSceneCascade, propagateScenePrivacy } = require('../utils/sceneHelper');
|
||||
|
||||
const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads');
|
||||
const tempDir = path.join(uploadDir, 'temp');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, tempDir),
|
||||
destination: (req, file, cb) => {
|
||||
// req.user đã được populate bởi protect middleware
|
||||
const userId = req.user._id.toString();
|
||||
const userTempDir = path.join(uploadDir, userId, 'temp');
|
||||
if (!fs.existsSync(userTempDir)) {
|
||||
fs.mkdirSync(userTempDir, { recursive: true });
|
||||
}
|
||||
cb(null, userTempDir);
|
||||
},
|
||||
filename: (req, file, cb) => cb(null, `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${path.extname(file.originalname)}`)
|
||||
});
|
||||
const upload = multer({ storage });
|
||||
@@ -54,8 +61,16 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
|
||||
const latitude = Number(lat) || 0;
|
||||
const longitude = Number(lng) || 0;
|
||||
const tempFilePath = req.file.path;
|
||||
|
||||
// Tạo thư mục lưu trữ chính cho User nếu chưa có
|
||||
const userId = req.user._id.toString();
|
||||
const userUploadDir = path.join(uploadDir, userId);
|
||||
if (!fs.existsSync(userUploadDir)) {
|
||||
fs.mkdirSync(userUploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const processedFileName = `processed_${req.file.filename}.jpg`;
|
||||
const processedFilePath = path.join(uploadDir, processedFileName);
|
||||
const processedFilePath = path.join(userUploadDir, processedFileName);
|
||||
|
||||
const asset = new Asset({
|
||||
filePath: tempFilePath,
|
||||
@@ -143,7 +158,6 @@ router.get('/', optionalAuth, async (req, res) => {
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[SceneRoutes] GET /api/scenes - Final Query for user ${req.user?._id || 'Guest'}:`, JSON.stringify(finalQuery));
|
||||
const scenes = await Scene.find(finalQuery)
|
||||
.populate('createdBy', 'username')
|
||||
.populate('tourId') // Nạp thông tin Tour để Frontend nhận diện
|
||||
@@ -154,6 +168,81 @@ router.get('/', optionalAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route GET /api/share/:id
|
||||
* @desc Trang trung gian hỗ trợ Open Graph (Facebook, Zalo,...)
|
||||
*/
|
||||
const shareScene = async (req, res) => {
|
||||
try {
|
||||
const scene = await Scene.findById(req.params.id).populate('tourId');
|
||||
if (!scene) return res.status(404).send('Không tìm thấy cảnh 3D');
|
||||
|
||||
const tour = scene.tourId;
|
||||
const title = tour ? tour.name : (scene.name || 'Virtual Tour 3D');
|
||||
const description = tour ? tour.description : (scene.description || 'Khám phá không gian 360 độ chân thực');
|
||||
|
||||
// Lấy token chia sẻ (nếu có)
|
||||
const token = req.query.token || scene.shareToken || (tour && tour.shareToken) || '';
|
||||
|
||||
// Xác định host của hệ thống
|
||||
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
|
||||
const host = process.env.SYSTEM_HOST || `${protocol}://${req.get('host')}`;
|
||||
|
||||
// URL ảnh thumbnail gọi sang Asset API với cờ watermark (đã được xử lý trong assetRoutes.js)
|
||||
const imageUrl = `${host}/api/assets/view/${scene.assetId}?watermark=true${token ? '&token=' + token : ''}`;
|
||||
|
||||
// URL thực tế của ứng dụng để redirect người dùng
|
||||
const appUrl = `${host}/?sceneId=${scene._id}${token ? '&token=' + token : ''}`;
|
||||
|
||||
// URL Canonical của chính trang chia sẻ này (Dùng cho og:url)
|
||||
const shareUrl = `${host}/api/share/${scene._id}${token ? '?token=' + token : ''}`;
|
||||
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title}</title>
|
||||
<link rel="canonical" href="${appUrl}" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:site_name" content="3D Tours - Virtual Tour 360">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="${shareUrl}">
|
||||
<meta property="og:title" content="${title}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta property="og:image:secure_url" content="${imageUrl}">
|
||||
<meta property="og:image:type" content="image/jpeg">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:title" content="${title}">
|
||||
<meta property="twitter:description" content="${description}">
|
||||
<meta property="twitter:image" content="${imageUrl}">
|
||||
|
||||
<!-- Chuyển hướng người dùng về trang chủ để mở viewer -->
|
||||
<script type="text/javascript">
|
||||
window.location.href = "${appUrl}";
|
||||
</script>
|
||||
</head>
|
||||
<body style="font-family: sans-serif; text-align: center; padding-top: 50px; background: #1a1a1a; color: #fff;">
|
||||
<h2>${title}</h2>
|
||||
<p>Đang tải không gian 3D, vui lòng đợi...</p>
|
||||
</body>
|
||||
</html>`);
|
||||
} catch (error) {
|
||||
console.error("[Share Error]", error);
|
||||
res.status(500).send('Lỗi máy chủ');
|
||||
}
|
||||
};
|
||||
|
||||
// Đăng ký route phụ trợ trong router này
|
||||
router.get('/share/:id', shareScene);
|
||||
|
||||
// @route GET /api/scenes/:id
|
||||
router.get('/:id', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -294,8 +383,14 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
|
||||
}
|
||||
|
||||
if (req.file) {
|
||||
const userId = req.user._id.toString();
|
||||
const userUploadDir = path.join(uploadDir, userId);
|
||||
if (!fs.existsSync(userUploadDir)) {
|
||||
fs.mkdirSync(userUploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const processedFileName = `processed_${req.file.filename}.jpg`;
|
||||
const processedFilePath = path.join(uploadDir, processedFileName);
|
||||
const processedFilePath = path.join(userUploadDir, processedFileName);
|
||||
await resizeTo8K(req.file.path, processedFilePath);
|
||||
await injectGPSCoordinates(processedFilePath, scene.gps.lat, scene.gps.lng);
|
||||
|
||||
@@ -374,4 +469,5 @@ router.delete('/:id', protect, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.shareScene = shareScene; // Xuất hàm để apiRoutes sử dụng
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,93 @@
|
||||
const mongoose = require('mongoose');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') }); // Load .env for UPLOAD_DIR
|
||||
|
||||
const connectDB = require('../config/db');
|
||||
const Asset = require('../models/Asset');
|
||||
const User = require('../models/User'); // Required for Asset model's 'uploadedBy' reference
|
||||
|
||||
// Xác định thư mục uploads gốc
|
||||
const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../../uploads');
|
||||
|
||||
const migrateAssetsToUserFolders = async () => {
|
||||
try {
|
||||
console.log('--- Bắt đầu quy trình di chuyển Assets vào thư mục người dùng ---');
|
||||
await connectDB();
|
||||
|
||||
const allAssets = await Asset.find({});
|
||||
console.log(`Tìm thấy ${allAssets.length} Assets cần kiểm tra.`);
|
||||
|
||||
let movedCount = 0;
|
||||
let updatedDbCount = 0;
|
||||
let skippedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const asset of allAssets) {
|
||||
const currentFilePath = asset.filePath;
|
||||
const userId = asset.uploadedBy ? asset.uploadedBy.toString() : null;
|
||||
|
||||
if (!userId) {
|
||||
console.warn(`[WARN] Asset ${asset._id}: Không có thông tin người tải lên. Bỏ qua.`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kiểm tra xem đường dẫn hiện tại đã có userId trong cấu trúc chưa
|
||||
// Ví dụ: uploads/654321abcdef/processed_123.jpg
|
||||
const relativePathSegments = path.relative(uploadDir, currentFilePath).split(path.sep);
|
||||
if (relativePathSegments.length > 1 && relativePathSegments[0] === userId) {
|
||||
console.log(`[SKIP] Asset ${asset._id}: Đường dẫn đã ở đúng định dạng người dùng (${currentFilePath}).`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = path.basename(currentFilePath);
|
||||
const userUploadSubDir = path.join(uploadDir, userId); // e.g., /app/uploads/654321abcdef
|
||||
const newFilePath = path.join(userUploadSubDir, fileName); // e.g., /app/uploads/654321abcdef/processed_123.jpg
|
||||
|
||||
if (!fs.existsSync(userUploadSubDir)) {
|
||||
console.log(`[MKDIR] Tạo thư mục: ${userUploadSubDir}`);
|
||||
fs.mkdirSync(userUploadSubDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
// Kiểm tra sự tồn tại của tệp tin vật lý trước khi di chuyển
|
||||
if (fs.existsSync(currentFilePath)) {
|
||||
fs.renameSync(currentFilePath, newFilePath);
|
||||
movedCount++;
|
||||
console.log(`[MOVE] Asset ${asset._id}: Di chuyển từ ${currentFilePath} sang ${newFilePath}`);
|
||||
} else {
|
||||
console.warn(`[WARN] Tệp tin vật lý không tồn tại: ${currentFilePath} cho Asset ${asset._id}.`);
|
||||
}
|
||||
|
||||
// Cập nhật đường dẫn trong bản ghi Asset trong cơ sở dữ liệu
|
||||
asset.filePath = newFilePath;
|
||||
await asset.save();
|
||||
updatedDbCount++;
|
||||
|
||||
} catch (fileError) {
|
||||
console.error(`[ERROR] Lỗi khi xử lý file cho Asset ${asset._id} (${currentFilePath}): ${fileError.message}`);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('--- Hoàn tất quy trình di chuyển Assets ---');
|
||||
console.log(`Tổng Assets kiểm tra: ${allAssets.length}`);
|
||||
console.log(`Assets đã di chuyển file: ${movedCount}`);
|
||||
console.log(`Assets đã cập nhật DB: ${updatedDbCount}`);
|
||||
console.log(`Assets đã bỏ qua (đã đúng định dạng hoặc không có userId): ${skippedCount}`);
|
||||
console.log(`Assets gặp lỗi: ${errorCount}`);
|
||||
|
||||
} catch (dbError) {
|
||||
console.error('Lỗi kết nối hoặc truy vấn Database:', dbError.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (mongoose.connection) {
|
||||
await mongoose.connection.close();
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
migrateAssetsToUserFolders();
|
||||
+4
-1
@@ -1,9 +1,12 @@
|
||||
const crypto = require('crypto');
|
||||
// Đảm bảo crypto có sẵn toàn cục cho các thư viện cũ hoặc plugin mongoose
|
||||
global.crypto = crypto;
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const connectDB = require('./config/db');
|
||||
|
||||
// Cấu hình môi trường
|
||||
dotenv.config();
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:4.4 # Sử dụng phiên bản MongoDB cụ thể để đảm bảo tính ổn định
|
||||
container_name: 3dtours_mongo
|
||||
restart: always
|
||||
ports:
|
||||
- "27017:27017" # Mở cổng MongoDB ra ngoài (có thể bỏ nếu chỉ dùng nội bộ Docker)
|
||||
volumes:
|
||||
- mongo_data:/data/db # Lưu trữ dữ liệu MongoDB bền vững
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
|
||||
|
||||
redis:
|
||||
image: redis:6-alpine # Sử dụng phiên bản Redis nhẹ
|
||||
container_name: 3dtours_redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379" # Mở cổng Redis ra ngoài (có thể bỏ nếu chỉ dùng nội bộ Docker)
|
||||
volumes:
|
||||
- redis_data:/data # Lưu trữ dữ liệu Redis bền vững (tùy chọn)
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ./backend # Đường dẫn image trên Gitea Registry của bạn
|
||||
dockerfile: Dockerfile
|
||||
container_name: 3dtours_app
|
||||
restart: always
|
||||
ports:
|
||||
- "${PORT}:${PORT}" # Khớp cổng máy host với cổng bên trong container (ví dụ: 3000:3000)
|
||||
volumes:
|
||||
- uploads:/app/uploads # Lưu trữ các tệp ảnh panorama đã tải lên
|
||||
- ./frontend:/frontend # Gắn thư mục frontend vào container để Node.js truy cập được qua ../frontend
|
||||
environment:
|
||||
# Biến môi trường cho ứng dụng Node.js
|
||||
PORT: ${PORT}
|
||||
MONGODB_URI: ${MONGODB_URI}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR}
|
||||
NODE_ENV: ${NODE_ENV}
|
||||
SYSTEM_HOST: ${SYSTEM_HOST}
|
||||
ADDITIONAL_ALLOWED_ORIGINS: ${ADDITIONAL_ALLOWED_ORIGINS}
|
||||
depends_on:
|
||||
- mongo # Đảm bảo MongoDB khởi động trước
|
||||
- redis # Đảm bảo Redis khởi động trước
|
||||
command: node server.js
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
redis_data:
|
||||
uploads:
|
||||
+168
-15
@@ -111,11 +111,13 @@ html, body {
|
||||
|
||||
#close-viewer-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
top: 5px;
|
||||
left: 40px;
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: white solid 1px;
|
||||
border-radius: 10px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
@@ -125,6 +127,57 @@ html, body {
|
||||
|
||||
#close-viewer-btn:hover {
|
||||
background: white;
|
||||
color: rgba(44, 44, 44, 0.9);
|
||||
}
|
||||
|
||||
/* Viewer Info Overlay */
|
||||
#viewer-info-overlay {
|
||||
position: fixed;
|
||||
bottom: 15px;
|
||||
right: 15px;
|
||||
background: rgba(30, 30, 30, 0.5); /* Nền xám tối transparent 0.5 - Tăng độ rõ */
|
||||
border: 1px solid rgba(255, 255, 255, 0.5); /* Viền trắng mờ - Tăng độ rõ */
|
||||
border-radius: 10px;
|
||||
padding: 15px 20px;
|
||||
max-width: 300px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||
z-index: 3002; /* Trên viewer, dưới các modal */
|
||||
pointer-events: none; /* Không chặn tương tác chuột với viewer */
|
||||
opacity: 0; /* Khởi tạo ẩn */
|
||||
transform: translateY(20px); /* Hiệu ứng trượt lên */
|
||||
transition: opacity 0.3s ease-out, transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
#viewer-info-overlay.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
#viewer-info-overlay .info-content h4 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
color: #00d4ff; /* Màu xanh nổi bật cho tiêu đề */
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
#viewer-info-overlay .info-content p {
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#viewer-info-overlay .info-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
/* Modal Overlay */
|
||||
.modal-overlay {
|
||||
@@ -1173,8 +1226,9 @@ html, body {
|
||||
.admin-table th:nth-child(1) { min-width: 160px; } /* Họ tên */
|
||||
.admin-table th:nth-child(2) { min-width: 120px; } /* Username */
|
||||
.admin-table th:nth-child(3) { min-width: 200px; } /* Email */
|
||||
.admin-table th:nth-child(4) { min-width: 130px; } /* Quyền hạn */
|
||||
.admin-table th:nth-child(5) { min-width: 140px; } /* Reset Password */
|
||||
.admin-table th:nth-child(4) { min-width: 120px; } /* Quyền hạn */
|
||||
.admin-table th:nth-child(5) { min-width: 100px; } /* Dung lượng */
|
||||
.admin-table th:nth-child(6) { min-width: 140px; } /* Reset Password */
|
||||
.admin-table th:nth-child(6) { min-width: 140px; } /* Thao tác */
|
||||
|
||||
.admin-table td input, .admin-table td select {
|
||||
@@ -1204,28 +1258,127 @@ html, body {
|
||||
}
|
||||
|
||||
/* Admin User Management Header */
|
||||
.admin-management-header {
|
||||
.admin-management-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.cleanup-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cleanup-btn {
|
||||
background: transparent; /* Nền trùng màu dashboard */
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: #444444 !important;
|
||||
color: #222222 !important;
|
||||
padding: 0 20px !important;
|
||||
height: 36px;
|
||||
border: none !important;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cleanup-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1); /* Màu xám active của dashboard */
|
||||
background: #555555 !important;
|
||||
}
|
||||
|
||||
.admin-search-container {
|
||||
display: flex;
|
||||
gap: 0; /* Sát cạnh nhau */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-search-container input {
|
||||
flex: 1;
|
||||
background: #262626 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
color: #ffffff !important;
|
||||
padding: 8px 15px !important;
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.admin-search-btn {
|
||||
background: #444444 !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #000000 !important;
|
||||
padding: 0 25px !important;
|
||||
border-radius: 0 6px 6px 0 !important;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
/* Card layout styles */
|
||||
.admin-user-card {
|
||||
background: #262626 !important;
|
||||
border: 1px solid #404040 !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 2fr 1fr 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-users-header-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 2fr 1fr 1fr 1fr 1fr;
|
||||
gap: 15px;
|
||||
padding: 0 16px 12px 16px;
|
||||
color: #a3a3a3;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-field input:not(:disabled), .card-field select:not(:disabled) {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-field input:disabled, .card-field select:disabled {
|
||||
background: #404040 !important;
|
||||
color: #a3a3a3 !important;
|
||||
border: 1px solid #525252;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Màu sắc linh hoạt cho Input theo trạng thái */
|
||||
.card-field input[type="text"], .card-field input[type="email"], .card-field input[type="number"] {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
.card-field input:disabled, .card-field select:disabled {
|
||||
background: #404040 !important; /* Nền xám tối */
|
||||
color: #a3a3a3 !important; /* Chữ xám mờ */
|
||||
border-color: #525252 !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-field .edit-btn-small {
|
||||
background: #28a745 !important; /* Nền xanh lá */
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-search-container {
|
||||
|
||||
+30
-16
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Virtual 3D Tour Map</title>
|
||||
<title>Virtual Tour Map</title>
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
<!-- Pannellum (3D Viewer) CSS -->
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- Top Bar -->
|
||||
<div id="top-bar">
|
||||
<div class="app-brand">
|
||||
<h1>Virtual 3D Tour Map</h1>
|
||||
<h1>Virtual Tour Map</h1>
|
||||
</div>
|
||||
<div id="user-controls">
|
||||
<div id="user-avatar" onclick="toggleDropdown()">
|
||||
@@ -140,17 +140,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-user-management" class="dashboard-tab-pane admin-only">
|
||||
<div class="admin-management-header">
|
||||
|
||||
<button class="cleanup-btn" onclick="openManualCleanupConfirm()">
|
||||
🧹 Dọn dẹp dữ liệu
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-search-container">
|
||||
<input type="text" id="admin-user-search-input" placeholder="Tìm kiếm theo tên, email, username..." onkeydown="if(event.key === 'Enter') loadAdminUsers(1)">
|
||||
<button onclick="loadAdminUsers(1)" class="admin-search-btn">Tìm kiếm</button>
|
||||
</div>
|
||||
<div id="admin-users-list" class="dashboard-list"></div>
|
||||
<div id="admin-users-list"></div>
|
||||
<div id="admin-users-pagination" class="pagination-container"></div>
|
||||
</div>
|
||||
<div id="tab-system-settings" class="dashboard-tab-pane admin-only">
|
||||
@@ -528,16 +518,40 @@
|
||||
<div id="hotspot-modal" class="modal-overlay" style="display: none; z-index: 3000;">
|
||||
<div class="modal-content action-modal-content logout-modal-dark" style="max-width: 500px; border-top: 4px solid #ffc107;">
|
||||
<div class="modal-header">
|
||||
<h3 id="hotspot-modal-title">Biên tập điểm điều hướng</h3>
|
||||
<h3 id="hotspot-modal-title">Thêm/sửa điểm điều hướng</h3>
|
||||
<span class="close-btn" onclick="closeHotspotModal()">×</span>
|
||||
</div>
|
||||
|
||||
<form id="hotspot-form">
|
||||
<!-- Tọa độ ẩn để xử lý GPS và vị trí -->
|
||||
<input type="hidden" id="hs-pitch">
|
||||
<input type="hidden" id="hs-yaw">
|
||||
<div class="form-group" style="background: rgba(255,193,7,0.05); padding: 10px; border-radius: 6px; border: 1px dashed #ffc107; margin-bottom: 15px;">
|
||||
<label style="color: #ffc107; font-size: 12px; margin-bottom: 8px;">Vị trí hiển thị (Pitch/Yaw):</label>
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<input type="text" id="hs-pitch" readonly style="flex: 1; background: #222; border: 1px solid #444; color: #fff; text-align: center; font-family: monospace;">
|
||||
<input type="text" id="hs-yaw" readonly style="flex: 1; background: #222; border: 1px solid #444; color: #fff; text-align: center; font-family: monospace;">
|
||||
<button type="button" onclick="updateHotspotCoordsFromView()" class="edit-btn-small" style="background: #007bff; white-space: nowrap; height: 34px; padding: 0 10px;">
|
||||
<i class="fas fa-crosshairs"></i> Lấy tọa độ hiện tại
|
||||
</button>
|
||||
</div>
|
||||
<small style="display: block; color: #888; font-size: 10px; margin-top: 5px;">* Xoay ảnh đến vị trí mong muốn rồi nhấn nút để cập nhật điểm đặt bong bóng.</small>
|
||||
</div>
|
||||
<input type="hidden" id="hs-id">
|
||||
|
||||
<div class="form-group divider">
|
||||
<label>Hành động:</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-item"><input type="radio" name="hsActionMode" value="create" checked onclick="toggleHSActionMode('create')"> Thêm mới</label>
|
||||
<label class="radio-item"><input type="radio" name="hsActionMode" value="edit" onclick="toggleHSActionMode('edit')"> Sửa điểm có sẵn</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hs-edit-select-container" class="form-group" style="display: none; background: rgba(0, 123, 255, 0.1); padding: 10px; border-radius: 6px; border: 1px solid #007bff;">
|
||||
<label for="hs-to-edit-id" style="color: #00d4ff;">Chọn điểm để sửa:</label>
|
||||
<select id="hs-to-edit-id" onchange="onSelectHotspotToEdit(this.value)" style="background: #111; color: #fff; border: 1px solid #007bff;">
|
||||
<option value="">-- Chọn điểm trong Viewer --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hs-title">Tiêu đề (Label)</label>
|
||||
<input type="text" id="hs-title" name="title" placeholder="Ví dụ: Cổng vào, Phòng khách..." required>
|
||||
|
||||
+262
-54
@@ -22,8 +22,6 @@ let sharedEmailsData = []; // [email]
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
console.log("--- Bắt đầu khởi tạo Frontend ---");
|
||||
|
||||
// 0. Kiểm tra tham số URL để truy cập trực tiếp
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let urlSceneId = urlParams.get('sceneId');
|
||||
@@ -39,14 +37,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchSystemSettings();
|
||||
|
||||
if (document.getElementById('map')) {
|
||||
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
|
||||
|
||||
// 3. Xử lý logic vào thẳng Scene hoặc khôi phục trang
|
||||
// 2.1. Kiểm tra xem server đã có Admin chưa (dành cho cài đặt mới)
|
||||
checkSystemInitialization();
|
||||
|
||||
if (urlSceneId) {
|
||||
console.log(`[Direct Access] Opening scene ${urlSceneId} from URL`);
|
||||
openScene(urlSceneId, urlToken ? 'shared' : null, urlToken);
|
||||
@@ -370,6 +369,14 @@ function formatSystemDate(dateString) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiểm tra xem người dùng có đang dùng thiết bị di động hay không
|
||||
*/
|
||||
function isMobileDevice() {
|
||||
return (window.innerWidth <= 768) ||
|
||||
(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the full-screen Leaflet Map
|
||||
*/
|
||||
@@ -389,7 +396,11 @@ function initMap() {
|
||||
if (isNaN(startZoom)) startZoom = 13;
|
||||
|
||||
// Khởi tạo bản đồ với zoomControl và tắt attribution mặc định của Leaflet
|
||||
map = L.map('map', { zoomControl: true, attributionControl: false }).setView([startLat, startLng], startZoom);
|
||||
map = L.map('map', {
|
||||
zoomControl: !isMobileDevice(), // Ẩn nút +/- trên mobile để lấy thêm không gian hiển thị
|
||||
attributionControl: false,
|
||||
tap: true // Hỗ trợ click trên thiết bị cảm ứng tốt hơn
|
||||
}).setView([startLat, startLng], startZoom);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
@@ -489,6 +500,42 @@ function checkAuthStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiểm tra trạng thái khởi tạo của hệ thống
|
||||
*/
|
||||
async function checkSystemInitialization() {
|
||||
const token = localStorage.getItem('jwt');
|
||||
if (token) return; // Nếu đã đăng nhập thì bỏ qua
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/auth/init-status`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data && data.initialized === false) {
|
||||
showAdminSetupWizard();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Không thể kiểm tra trạng thái khởi tạo hệ thống:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hiển thị giao diện thiết lập Admin tối cao
|
||||
*/
|
||||
function showAdminSetupWizard() {
|
||||
// Mở dropdown và chuyển sang tab đăng ký
|
||||
const dropdown = document.getElementById('user-dropdown');
|
||||
if (dropdown) dropdown.classList.add('show');
|
||||
|
||||
switchAuthMode('register');
|
||||
|
||||
// Tùy chỉnh giao diện cho chế độ thiết lập
|
||||
const regBtn = document.querySelector('#register-section .auth-submit-btn');
|
||||
if (regBtn) regBtn.innerText = 'Thiết lập Admin tối cao';
|
||||
|
||||
showNotification("Hệ thống mới: Vui lòng đăng ký tài khoản Admin đầu tiên để quản trị server.", "warning");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user login
|
||||
*/
|
||||
@@ -1008,8 +1055,6 @@ async function loadScenes(urlToken = null) {
|
||||
const tourName = (scene.tourId && typeof scene.tourId === 'object') ? scene.tourId.name : sceneName;
|
||||
const tourDescription = (scene.tourId && typeof scene.tourId === 'object') ? scene.tourId.description : scene.description;
|
||||
|
||||
console.log(`[Frontend] Đang thêm marker cho Tour: ${tourName} (Scene đại diện: ${sceneName})`);
|
||||
|
||||
const isProcessing = scene.status === 'processing';
|
||||
if (isProcessing) foundProcessing++;
|
||||
|
||||
@@ -1023,7 +1068,7 @@ async function loadScenes(urlToken = null) {
|
||||
let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`;
|
||||
if (token) thumbUrl += `?token=${token}`;
|
||||
else if (scene.privacy === 'shared' && scene.shareToken) thumbUrl += `?token=${scene.shareToken}`;
|
||||
thumbHtml = `<img src="${thumbUrl}" alt="${sceneName}">`;
|
||||
thumbHtml = `<img src="${thumbUrl}" alt="${sceneName}" loading="lazy">`;
|
||||
}
|
||||
|
||||
const calloutIcon = L.divIcon({
|
||||
@@ -1390,6 +1435,9 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
|
||||
// Initialize 3D Viewer with secure, referer-protected image stream
|
||||
initPanoramaViewer(secureImageUrl, hotspots || [], sceneOwnerId, initialPitch, initialYaw);
|
||||
|
||||
// Hiển thị thông tin overlay góc màn hình
|
||||
updateViewerInfoOverlay(scene);
|
||||
|
||||
// Sau khi mở thành công từ URL trực tiếp, xóa tham số để làm sạch thanh địa chỉ (URL chuyên nghiệp)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('sceneId') || window.location.pathname.includes('/api/share/')) {
|
||||
@@ -1420,6 +1468,72 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cập nhật overlay thông tin cảnh đang xem (Góc dưới bên trái)
|
||||
*/
|
||||
async function updateViewerInfoOverlay(scene) {
|
||||
if (!scene) {
|
||||
console.warn("updateViewerInfoOverlay: Scene object is null or undefined. Cannot display info.");
|
||||
return;
|
||||
}
|
||||
|
||||
let overlay = document.getElementById('viewer-info-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'viewer-info-overlay';
|
||||
// Append to body to allow independent positioning and animation
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
const lang = systemSettings.language || 'vi';
|
||||
const name = scene.name || scene.title || (lang === 'vi' ? "Cảnh không tên" : "Untitled Scene");
|
||||
const desc = scene.description || "";
|
||||
const author = scene.createdBy?.username || (lang === 'vi' ? "Ẩn danh" : "Anonymous");
|
||||
const date = formatSystemDate(scene.createdAt);
|
||||
|
||||
// Lấy tọa độ để truy vấn địa chỉ
|
||||
const lat = scene.gps?.lat || scene.lat;
|
||||
const lng = scene.gps?.lng || scene.lng;
|
||||
let locationText = lang === 'vi' ? "Đang xác định vị trí..." : "Locating...";
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="info-content">
|
||||
<h4>${name}</h4>
|
||||
${desc ? `<p>${desc}</p>` : ''}
|
||||
<div class="info-meta">
|
||||
<span>👤 ${author}</span>
|
||||
<span>📸 ${lang === 'vi' ? 'Ngày chụp' : 'Date taken'}: ${date}</span>
|
||||
<span id="overlay-address">📍 ${locationText}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
overlay.classList.add('show'); // Make it visible with transition
|
||||
|
||||
// Thực hiện Reverse Geocoding để lấy địa chỉ từ tọa độ
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}`);
|
||||
const data = await res.json();
|
||||
const address = data.display_name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
||||
const addrElem = document.getElementById('overlay-address');
|
||||
if (addrElem) addrElem.innerText = `📍 ${address}`;
|
||||
} catch (e) {
|
||||
const addrElem = document.getElementById('overlay-address');
|
||||
if (addrElem) addrElem.innerText = `📍 ${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ẩn overlay thông tin cảnh đang xem
|
||||
*/
|
||||
window.hideViewerInfoOverlay = function() {
|
||||
const overlay = document.getElementById('viewer-info-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('show'); // Hide it with transition
|
||||
// Optionally remove from DOM after transition if not needed
|
||||
// setTimeout(() => overlay.remove(), 300); // Match CSS transition duration
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Khôi phục Scene đang xem từ localStorage sau khi reload trang
|
||||
*/
|
||||
@@ -1434,6 +1548,24 @@ function restoreActiveScene() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cập nhật tọa độ Pitch/Yaw cho hotspot từ góc nhìn trung tâm hiện tại của Viewer
|
||||
*/
|
||||
window.updateHotspotCoordsFromView = function() {
|
||||
// activeViewer được quản lý trong viewer360.js
|
||||
if (typeof activeViewer !== 'undefined' && activeViewer) {
|
||||
const pitch = activeViewer.getPitch();
|
||||
const yaw = activeViewer.getYaw();
|
||||
|
||||
document.getElementById('hs-pitch').value = pitch.toFixed(2);
|
||||
document.getElementById('hs-yaw').value = yaw.toFixed(2);
|
||||
|
||||
showNotification(`Đã ghi nhận vị trí mới: Pitch ${pitch.toFixed(2)}, Yaw ${yaw.toFixed(2)}`, 'success');
|
||||
} else {
|
||||
showNotification("Viewer không hoạt động, không thể lấy tọa độ.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Xử lý việc tạo hotspot sau khi click chuột phải trong trình xem 360
|
||||
* @param {number} pitch - Tọa độ dọc (-90 đến 90)
|
||||
@@ -1453,14 +1585,34 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
|
||||
// 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 độ
|
||||
// Reset form và gán tọa độ click ban đầu
|
||||
form.reset();
|
||||
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';
|
||||
document.getElementById('hotspot-modal-title').innerText = 'Thêm/sửa điểm điều hướng';
|
||||
|
||||
// Nạp danh sách hotspot hiện có trong Viewer vào dropdown chỉnh sửa
|
||||
const editSelect = document.getElementById('hs-to-edit-id');
|
||||
editSelect.innerHTML = '<option value="">-- Chọn điểm trong Viewer --</option>';
|
||||
if (typeof currentHotspots !== 'undefined' && currentHotspots.length > 0) {
|
||||
currentHotspots.forEach(h => {
|
||||
editSelect.innerHTML += `<option value="${h._id}">${h.title || 'Không tiêu đề'} (ID: ...${h._id.slice(-4)})</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
// Reset UI states
|
||||
if (existingHotspot) {
|
||||
document.querySelector('input[name="hsActionMode"][value="edit"]').checked = true;
|
||||
toggleHSActionMode('edit');
|
||||
editSelect.value = existingHotspot._id;
|
||||
// Điền dữ liệu của hotspot được click vào form
|
||||
onSelectHotspotToEdit(existingHotspot._id);
|
||||
} else {
|
||||
document.querySelector('input[name="hsActionMode"][value="create"]').checked = true;
|
||||
toggleHSActionMode('create');
|
||||
}
|
||||
|
||||
document.querySelector('input[name="hsLinkType"][value="existing"]').checked = true;
|
||||
window.toggleHSLinkType('existing');
|
||||
document.querySelector('input[name="hsGPSMode"][value="map"]').checked = true;
|
||||
@@ -1503,17 +1655,6 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
|
||||
if (existingNotice) existingNotice.style.opacity = '1';
|
||||
}
|
||||
};
|
||||
|
||||
// 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.title || '';
|
||||
document.getElementById('hs-desc').value = existingHotspot.description || '';
|
||||
if (existingHotspot.target_scene_id) {
|
||||
select.value = existingHotspot.target_scene_id;
|
||||
// Kích hoạt logic hiển thị thông báo ngay khi mở modal nếu đang sửa
|
||||
if (typeof select.onchange === 'function') select.onchange();
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error("Lỗi nạp danh sách scene:", e); }
|
||||
|
||||
// Xử lý sự kiện submit form
|
||||
@@ -1521,6 +1662,7 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
|
||||
e.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const linkType = formData.get('hsLinkType');
|
||||
const hotspotId = document.getElementById('hs-id').value;
|
||||
|
||||
if (linkType === 'upload') {
|
||||
const file = document.getElementById('hs-panorama-file').files[0];
|
||||
@@ -1559,7 +1701,7 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
|
||||
if (activeTourId) sceneData.append('tourId', activeTourId);
|
||||
|
||||
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, hotspotId);
|
||||
closeHotspotModal();
|
||||
});
|
||||
return;
|
||||
@@ -1570,11 +1712,48 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
|
||||
showNotification('Vui lòng chọn cảnh để liên kết.', 'warning');
|
||||
return;
|
||||
}
|
||||
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), finalTargetId, existingHotspot?._id);
|
||||
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), finalTargetId, hotspotId);
|
||||
closeHotspotModal();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Chuyển đổi giữa chế độ Thêm mới và Sửa điểm có sẵn
|
||||
*/
|
||||
window.toggleHSActionMode = function(mode) {
|
||||
const selectContainer = document.getElementById('hs-edit-select-container');
|
||||
selectContainer.style.display = mode === 'edit' ? 'block' : 'none';
|
||||
|
||||
if (mode === 'create') {
|
||||
document.getElementById('hs-id').value = '';
|
||||
// Giữ nguyên pitch/yaw vừa click, chỉ reset text
|
||||
document.getElementById('hs-title').value = '';
|
||||
document.getElementById('hs-desc').value = '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Điền thông tin khi người dùng chọn một hotspot từ danh sách để sửa
|
||||
*/
|
||||
window.onSelectHotspotToEdit = function(id) {
|
||||
if (!id) return;
|
||||
// currentHotspots được quản lý trong viewer360.js
|
||||
const hs = currentHotspots.find(h => h._id === id);
|
||||
if (hs) {
|
||||
document.getElementById('hs-id').value = hs._id;
|
||||
document.getElementById('hs-title').value = hs.title || '';
|
||||
document.getElementById('hs-desc').value = hs.description || '';
|
||||
|
||||
// Giữ nguyên tọa độ pitch/yaw từ điểm vừa click chuột phải
|
||||
// Không ghi đè bằng tọa độ cũ của hotspot để thực hiện việc di chuyển vị trí
|
||||
|
||||
if (hs.target_scene_id) {
|
||||
const targetId = hs.target_scene_id._id || hs.target_scene_id;
|
||||
document.getElementById('hs-target-id').value = targetId;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Đóng Modal biên tập Hotspot
|
||||
*/
|
||||
@@ -1853,7 +2032,7 @@ async function loadMyTours() {
|
||||
if (assetId) {
|
||||
let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`;
|
||||
if (token) thumbUrl += `?token=${token}`;
|
||||
card.style.backgroundImage = `url('${thumbUrl}')`;
|
||||
card.innerHTML = `<img class="tour-card-bg" src="${thumbUrl}" loading="lazy">`;
|
||||
} else {
|
||||
card.style.backgroundColor = '#1a1a1a';
|
||||
}
|
||||
@@ -1953,49 +2132,78 @@ async function loadAdminUsers(page = 1) {
|
||||
const { users, totalPages, currentPage, totalUsers } = data;
|
||||
|
||||
let html = `
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Họ tên</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Quyền hạn</th>
|
||||
<th>Reset Password</th>
|
||||
<th>Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="admin-management-controls" style="display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; width: 100%;">
|
||||
<div class="cleanup-row" style="display: flex; justify-content: flex-end; width: 100%;">
|
||||
<button class="cleanup-btn" onclick="openManualCleanupConfirm()" style="background: #1a1a1a; color: #fff; padding: 6px 14px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer;">
|
||||
<i class="fas fa-broom"></i> Dọn dẹp dữ liệu
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-search-container" style="display: flex; width: 100%; gap: 8px;">
|
||||
<input type="text" id="admin-user-search-input" placeholder="Tìm kiếm theo tên, email, username..." onkeydown="if(event.key === 'Enter') loadAdminUsers(1)" style="flex-grow: 1; background: #1a1a1a; color: #fff; padding: 8px 12px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; outline: none;">
|
||||
<button class="admin-search-btn" onclick="loadAdminUsers(1)" style="background: #1a1a1a; color: #fff; padding: 8px 20px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; white-space: nowrap;">Tìm kiếm</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-users-box-list" style="width: 100%; display: flex; flex-direction: column; gap: 12px;">
|
||||
|
||||
<div class="admin-table-header" style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 16px; align-items: center; text-align: center; padding: 0 16px; color: #a3a3a3; font-size: 14px; font-weight: 600; margin-bottom: 4px;">
|
||||
<div>Họ tên</div>
|
||||
<div>Username</div>
|
||||
<div>Email</div>
|
||||
<div>Quyền hạn</div>
|
||||
<div>Dung lượng</div>
|
||||
<div>Reset Password</div>
|
||||
<div>Thao tác</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
users.forEach(user => {
|
||||
const isRootAdmin = user.role === 'admin';
|
||||
const isRootAdmin = user.role === 'admin' || user.role === 'Chủ sở hữu';
|
||||
const quotaMB = user.storage?.quota ? Math.floor(user.storage.quota / (1024 * 1024)) : 0;
|
||||
const usedMB = user.storage?.used ? (user.storage.used / (1024 * 1024)).toFixed(1) : 0;
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td><input type="text" id="adm-fn-${user._id}" value="${user.fullName || ''}"></td>
|
||||
<td><strong>${user.username}</strong></td>
|
||||
<td><input type="email" id="adm-em-${user._id}" value="${user.email || ''}"></td>
|
||||
<td>
|
||||
<select id="adm-role-${user._id}" ${isRootAdmin ? 'disabled' : ''}>
|
||||
<div class="admin-user-box" style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 16px; align-items: center; text-align: center; background: #1a1a1a; border: 1px solid #262626; padding: 16px; border-radius: 12px;">
|
||||
<div class="card-field">
|
||||
<input type="text" id="adm-fn-${user._id}" value="${user.fullName || ''}" style="width: 100%; background: #111111; color: #fff; padding: 8px 10px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; outline: none;">
|
||||
</div>
|
||||
|
||||
<div class="card-field" style="font-weight: bold; font-size: 14px; color: #fff;">
|
||||
${user.username}
|
||||
</div>
|
||||
|
||||
<div class="card-field">
|
||||
<input type="email" id="adm-em-${user._id}" value="${user.email || ''}" style="width: 100%; background: #111111; color: #fff; padding: 8px 10px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; outline: none;">
|
||||
</div>
|
||||
|
||||
<div class="card-field">
|
||||
<select id="adm-role-${user._id}" ${isRootAdmin ? 'disabled' : ''} style="width: 100%; background: #111111; color: #fff; padding: 8px 10px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; cursor: ${isRootAdmin ? 'not-allowed' : 'pointer'}; outline: none;">
|
||||
<option value="user" ${user.role === 'user' ? 'selected' : ''}>User</option>
|
||||
<option value="editor" ${user.role === 'editor' ? 'selected' : ''}>Editor</option>
|
||||
<option value="moderator" ${user.role === 'moderator' ? 'selected' : ''}>Moderator</option>
|
||||
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="password" id="adm-pw-${user._id}" placeholder="${isRootAdmin ? 'N/A' : 'Mật khẩu mới'}" ${isRootAdmin ? 'disabled' : ''}></td>
|
||||
<td>
|
||||
<button class="edit-btn-small" onclick="updateUserByAdmin('${user._id}')">Lưu</button>
|
||||
${isRootAdmin ? '' : `<button class="delete-btn-small" onclick="deleteUserByAdmin('${user._id}')">Xóa</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
|
||||
<div class="card-field" style="display: flex; flex-direction: column; align-items: center; gap: 4px;">
|
||||
<input type="number" id="adm-quota-${user._id}" value="${quotaMB}" min="0" style="width: 100%; background: #111111; color: #fff; padding: 8px 10px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; text-align: center; outline: none;">
|
||||
<small style="color: #737373; font-size: 11px;">Đã dùng: ${usedMB} MB</small>
|
||||
</div>
|
||||
|
||||
<div class="card-field">
|
||||
<input type="text" id="adm-pw-${user._id}" placeholder="N/A" disabled style="width: 100%; background: #1a1a1a; color: #525252; padding: 8px 10px; border: 1px solid #262626; border-radius: 6px; font-size: 14px; text-align: center; cursor: not-allowed;">
|
||||
</div>
|
||||
|
||||
<div class="card-field">
|
||||
<button class="edit-btn-small" onclick="updateUserByAdmin('${user._id}')" style="width: 100%; background: #1a1a1a; color: #fff; padding: 8px 12px; border: 1px solid #333333; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s;">Lưu</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Render Pagination UI
|
||||
if (paginationContainer && totalPages > 1) {
|
||||
paginationContainer.innerHTML = `
|
||||
<button class="pagination-btn" ${currentPage === 1 ? 'disabled' : ''} onclick="loadAdminUsers(${currentPage - 1})">Trang trước</button>
|
||||
@@ -2007,14 +2215,14 @@ async function loadAdminUsers(page = 1) {
|
||||
container.innerHTML = `<p style="color:red">Lỗi: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.updateUserByAdmin = async function(userId) {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const payload = {
|
||||
fullName: document.getElementById(`adm-fn-${userId}`).value,
|
||||
email: document.getElementById(`adm-em-${userId}`).value,
|
||||
role: document.getElementById(`adm-role-${userId}`).value,
|
||||
password: document.getElementById(`adm-pw-${userId}`).value
|
||||
password: document.getElementById(`adm-pw-${userId}`).value,
|
||||
quota: document.getElementById(`adm-quota-${userId}`).value
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -119,9 +119,13 @@ function initPanoramaViewer(imageUrl, hotspots = [], ownerId = null, initialPitc
|
||||
activeViewer = pannellum.viewer('panorama-viewer', {
|
||||
"type": "equirectangular",
|
||||
"panorama": imageUrl,
|
||||
"autoRotate": 0,
|
||||
"autoRotateInactivityDelay": 5000,
|
||||
"autoLoad": true,
|
||||
"pitch": initialPitch,
|
||||
"yaw": initialYaw,
|
||||
"orientationOnByDefault": true,
|
||||
"draggable": true,
|
||||
"showControls": true,
|
||||
"compass": false,
|
||||
"mouseZoom": true,
|
||||
@@ -140,6 +144,11 @@ function initPanoramaViewer(imageUrl, hotspots = [], ownerId = null, initialPitc
|
||||
function closeViewer() {
|
||||
document.getElementById('viewer-container').style.display = 'none';
|
||||
|
||||
// Ẩn overlay thông tin cảnh nếu có
|
||||
if (typeof window.hideViewerInfoOverlay === 'function') {
|
||||
window.hideViewerInfoOverlay();
|
||||
}
|
||||
|
||||
// Xóa trạng thái Scene đang hoạt động khi đóng viewer
|
||||
localStorage.removeItem('activeSceneId');
|
||||
localStorage.removeItem('activeScenePrivacy');
|
||||
|
||||
Reference in New Issue
Block a user