4 Commits

17 changed files with 533 additions and 82 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.git
.gitignore
uploads/*
!uploads/.gitkeep
.env
*.log
+16
View File
@@ -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
View File
@@ -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`: - `securityMiddleware.js`:
- `verifyReferer`: Chặn truy cập trực tiếp từ trình duyệt/site khác. - `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. - `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. - `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.
+22
View File
@@ -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"]
-3
View File
@@ -2,9 +2,6 @@ const mongoose = require('mongoose');
const path = require('path'); const path = require('path');
const dotenv = require('dotenv'); 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 () => { const connectDB = async () => {
try { try {
const dbURI = process.env.MONGODB_URI; const dbURI = process.env.MONGODB_URI;
+1 -1
View File
@@ -16,7 +16,7 @@
"bullmq": "^5.8.0", "bullmq": "^5.8.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"exiftool-vendored": "^26.4.0", "exiftool-vendored": "^29.2.0",
"express": "^5.2.1", "express": "^5.2.1",
"express-fileupload": "^1.5.2", "express-fileupload": "^1.5.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
+4 -1
View File
@@ -97,13 +97,16 @@ router.get('/users', protect, async (req, res) => {
router.put('/users/:id', 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' }); if (req.user.role !== 'admin' && req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' });
try { try {
const { fullName, email, role, password } = req.body; const { fullName, email, role, password, quota } = req.body;
const user = await User.findById(req.params.id); const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ message: 'User not found' }); if (!user) return res.status(404).json({ message: 'User not found' });
if (fullName) user.fullName = fullName; if (fullName) user.fullName = fullName;
if (email) user.email = email; if (email) user.email = email;
if (role && user.role !== 'admin') user.role = role; if (role && user.role !== 'admin') user.role = role;
if (password) user.password = password; if (password) user.password = password;
if (quota !== undefined) {
user.storage = { ...user.storage, quota: parseInt(quota) * 1024 * 1024 };
}
await user.save(); await user.save();
res.json({ message: 'User updated' }); res.json({ message: 'User updated' });
} catch (error) { res.status(500).json({ message: error.message }); } } catch (error) { res.status(500).json({ message: error.message }); }
+1
View File
@@ -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 // Ở đâ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('/admin', adminRoutes);
router.use('/auth', authRoutes); // Tích hợp API Đăng ký/Đăng nhập 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('/tours', tourRoutes); // Thêm các route cho Tour
router.use('/scenes', sceneRoutes); router.use('/scenes', sceneRoutes);
router.use('/users', userRoutes); router.use('/users', userRoutes);
+14
View File
@@ -4,6 +4,20 @@ const User = require('../models/User');
const router = express.Router(); 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 * @route POST /api/auth/register
* @desc Register a new user * @desc Register a new user
+3 -1
View File
@@ -1,8 +1,10 @@
const { Queue } = require('bullmq'); const { Queue } = require('bullmq');
const IORedis = require('ioredis'); 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({ const connection = new IORedis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: process.env.REDIS_PORT || 6379,
maxRetriesPerRequest: null maxRetriesPerRequest: null
}); });
+101 -5
View File
@@ -17,10 +17,17 @@ const { imageQueue } = require('./imageQueue');
const { deleteSceneCascade, propagateScenePrivacy } = require('../utils/sceneHelper'); const { deleteSceneCascade, propagateScenePrivacy } = require('../utils/sceneHelper');
const uploadDir = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(__dirname, '../uploads'); 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({ 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)}`) filename: (req, file, cb) => cb(null, `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${path.extname(file.originalname)}`)
}); });
const upload = multer({ storage }); const upload = multer({ storage });
@@ -54,8 +61,16 @@ router.post('/', protect, uploadSinglePanorama, checkQuota, async (req, res) =>
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;
// 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 processedFileName = `processed_${req.file.filename}.jpg`;
const processedFilePath = path.join(uploadDir, processedFileName); const processedFilePath = path.join(userUploadDir, processedFileName);
const asset = new Asset({ const asset = new Asset({
filePath: tempFilePath, 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) const scenes = await Scene.find(finalQuery)
.populate('createdBy', 'username') .populate('createdBy', 'username')
.populate('tourId') // Nạp thông tin Tour để Frontend nhận diện .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 // @route GET /api/scenes/:id
router.get('/:id', optionalAuth, async (req, res) => { router.get('/:id', optionalAuth, async (req, res) => {
try { try {
@@ -294,8 +383,14 @@ router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
} }
if (req.file) { 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 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 resizeTo8K(req.file.path, processedFilePath);
await injectGPSCoordinates(processedFilePath, scene.gps.lat, scene.gps.lng); 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; 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
View File
@@ -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 express = require('express');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const connectDB = require('./config/db'); const connectDB = require('./config/db');
// Cấu hình môi trường // Cấu hình môi trường
dotenv.config(); dotenv.config();
+53
View File
@@ -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:
+112 -12
View File
@@ -112,7 +112,7 @@ html, body {
#close-viewer-btn { #close-viewer-btn {
position: absolute; position: absolute;
top: 20px; top: 20px;
left: 20px; left: 60px;
padding: 12px 24px; padding: 12px 24px;
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
border: none; border: none;
@@ -1173,8 +1173,9 @@ html, body {
.admin-table th:nth-child(1) { min-width: 160px; } /* Họ tên */ .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(2) { min-width: 120px; } /* Username */
.admin-table th:nth-child(3) { min-width: 200px; } /* Email */ .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(4) { min-width: 120px; } /* Quyền hạn */
.admin-table th:nth-child(5) { min-width: 140px; } /* Reset Password */ .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 th:nth-child(6) { min-width: 140px; } /* Thao tác */
.admin-table td input, .admin-table td select { .admin-table td input, .admin-table td select {
@@ -1204,28 +1205,127 @@ html, body {
} }
/* Admin User Management Header */ /* Admin User Management Header */
.admin-management-header { .admin-management-controls {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: flex-end; gap: 15px;
margin-bottom: 20px; margin-bottom: 20px;
padding-bottom: 10px; padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.cleanup-row {
display: flex;
justify-content: flex-end;
}
.cleanup-btn { .cleanup-btn {
background: transparent; /* Nền trùng màu dashboard */ background: #444444 !important;
color: #fff; color: #222222 !important;
padding: 6px 12px; padding: 0 20px !important;
font-size: 13px; height: 36px;
border: 1px solid rgba(255, 255, 255, 0.1); border: none !important;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 13px;
transition: background 0.2s; transition: background 0.2s;
white-space: nowrap;
} }
.cleanup-btn:hover { .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 { .admin-search-container {
+3 -13
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Virtual 3D Tour Map</title> <title>Virtual Tour Map</title>
<!-- Leaflet CSS --> <!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<!-- Pannellum (3D Viewer) CSS --> <!-- Pannellum (3D Viewer) CSS -->
@@ -24,7 +24,7 @@
<!-- Top Bar --> <!-- Top Bar -->
<div id="top-bar"> <div id="top-bar">
<div class="app-brand"> <div class="app-brand">
<h1>Virtual 3D Tour Map</h1> <h1>Virtual Tour Map</h1>
</div> </div>
<div id="user-controls"> <div id="user-controls">
<div id="user-avatar" onclick="toggleDropdown()"> <div id="user-avatar" onclick="toggleDropdown()">
@@ -140,17 +140,7 @@
</div> </div>
</div> </div>
<div id="tab-user-management" class="dashboard-tab-pane admin-only"> <div id="tab-user-management" class="dashboard-tab-pane admin-only">
<div class="admin-management-header"> <div id="admin-users-list"></div>
<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-pagination" class="pagination-container"></div> <div id="admin-users-pagination" class="pagination-container"></div>
</div> </div>
<div id="tab-system-settings" class="dashboard-tab-pane admin-only"> <div id="tab-system-settings" class="dashboard-tab-pane admin-only">
+98 -36
View File
@@ -22,8 +22,6 @@ let sharedEmailsData = []; // [email]
// Initialize when DOM is ready // Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
try { 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 // 0. Kiểm tra tham số URL để truy cập trực tiếp
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
let urlSceneId = urlParams.get('sceneId'); let urlSceneId = urlParams.get('sceneId');
@@ -39,14 +37,15 @@ document.addEventListener('DOMContentLoaded', () => {
fetchSystemSettings(); fetchSystemSettings();
if (document.getElementById('map')) { if (document.getElementById('map')) {
console.log("1. Đang khởi tạo bản đồ Leaflet...");
initMap(); initMap();
} }
// Chạy tuần tự để tránh xung đột luồng xử lý // Chạy tuần tự để tránh xung đột luồng xử lý
checkAuthStatus(); // 2. Kiểm tra đăng nhập 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) { if (urlSceneId) {
console.log(`[Direct Access] Opening scene ${urlSceneId} from URL`); console.log(`[Direct Access] Opening scene ${urlSceneId} from URL`);
openScene(urlSceneId, urlToken ? 'shared' : null, urlToken); openScene(urlSceneId, urlToken ? 'shared' : null, urlToken);
@@ -489,6 +488,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 * Handles user login
*/ */
@@ -1008,8 +1043,6 @@ async function loadScenes(urlToken = null) {
const tourName = (scene.tourId && typeof scene.tourId === 'object') ? scene.tourId.name : sceneName; 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; 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'; const isProcessing = scene.status === 'processing';
if (isProcessing) foundProcessing++; if (isProcessing) foundProcessing++;
@@ -1953,49 +1986,78 @@ async function loadAdminUsers(page = 1) {
const { users, totalPages, currentPage, totalUsers } = data; const { users, totalPages, currentPage, totalUsers } = data;
let html = ` let html = `
<table class="admin-table"> <div class="admin-management-controls" style="display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; width: 100%;">
<thead> <div class="cleanup-row" style="display: flex; justify-content: flex-end; width: 100%;">
<tr> <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;">
<th>Họ tên</th> <i class="fas fa-broom"></i> Dọn dẹp dữ liệu
<th>Username</th> </button>
<th>Email</th> </div>
<th>Quyền hạn</th> <div class="admin-search-container" style="display: flex; width: 100%; gap: 8px;">
<th>Reset Password</th> <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;">
<th>Thao tác</th> <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>
</tr> </div>
</thead> </div>
<tbody>
<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 => { 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 += ` html += `
<tr> <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;">
<td><input type="text" id="adm-fn-${user._id}" value="${user.fullName || ''}"></td> <div class="card-field">
<td><strong>${user.username}</strong></td> <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;">
<td><input type="email" id="adm-em-${user._id}" value="${user.email || ''}"></td> </div>
<td>
<select id="adm-role-${user._id}" ${isRootAdmin ? 'disabled' : ''}> <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="user" ${user.role === 'user' ? 'selected' : ''}>User</option>
<option value="editor" ${user.role === 'editor' ? 'selected' : ''}>Editor</option> <option value="editor" ${user.role === 'editor' ? 'selected' : ''}>Editor</option>
<option value="moderator" ${user.role === 'moderator' ? 'selected' : ''}>Moderator</option> <option value="moderator" ${user.role === 'moderator' ? 'selected' : ''}>Moderator</option>
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin</option> <option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin</option>
</select> </select>
</td> </div>
<td><input type="password" id="adm-pw-${user._id}" placeholder="${isRootAdmin ? 'N/A' : 'Mật khẩu mới'}" ${isRootAdmin ? 'disabled' : ''}></td>
<td> <div class="card-field" style="display: flex; flex-direction: column; align-items: center; gap: 4px;">
<button class="edit-btn-small" onclick="updateUserByAdmin('${user._id}')">Lưu</button> <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;">
${isRootAdmin ? '' : `<button class="delete-btn-small" onclick="deleteUserByAdmin('${user._id}')">Xóa</button>`} <small style="color: #737373; font-size: 11px;">Đã dùng: ${usedMB} MB</small>
</td> </div>
</tr>
<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; container.innerHTML = html;
// Render Pagination UI
if (paginationContainer && totalPages > 1) { if (paginationContainer && totalPages > 1) {
paginationContainer.innerHTML = ` paginationContainer.innerHTML = `
<button class="pagination-btn" ${currentPage === 1 ? 'disabled' : ''} onclick="loadAdminUsers(${currentPage - 1})">Trang trước</button> <button class="pagination-btn" ${currentPage === 1 ? 'disabled' : ''} onclick="loadAdminUsers(${currentPage - 1})">Trang trước</button>
@@ -2007,14 +2069,14 @@ async function loadAdminUsers(page = 1) {
container.innerHTML = `<p style="color:red">Lỗi: ${e.message}</p>`; container.innerHTML = `<p style="color:red">Lỗi: ${e.message}</p>`;
} }
} }
window.updateUserByAdmin = async function(userId) { window.updateUserByAdmin = async function(userId) {
const token = localStorage.getItem('jwt'); const token = localStorage.getItem('jwt');
const payload = { const payload = {
fullName: document.getElementById(`adm-fn-${userId}`).value, fullName: document.getElementById(`adm-fn-${userId}`).value,
email: document.getElementById(`adm-em-${userId}`).value, email: document.getElementById(`adm-em-${userId}`).value,
role: document.getElementById(`adm-role-${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 { try {