fix: ảnh phải giữ nguyên hướng chụp

This commit is contained in:
2026-06-20 07:28:28 +07:00
parent def0e0d0f9
commit 4373ed684a
26 changed files with 665 additions and 18 deletions
+239 -1
View File
@@ -1052,6 +1052,8 @@ let TourController = class TourController {
fs.mkdirSync(memberOriginalDir, { recursive: true });
if (!fs.existsSync(tourDisplayPath))
fs.mkdirSync(tourDisplayPath, { recursive: true });
const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined;
const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined;
return Promise.all(files.map(async (file) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
@@ -1060,6 +1062,28 @@ let TourController = class TourController {
const originalFilePath = path.join(memberOriginalDir, originalFilename);
const displayFilePath = path.join(tourDisplayPath, displayFilename);
await fs.promises.writeFile(originalFilePath, file.buffer);
let lat;
let lng;
try {
const gps = await exifr_1.default.gps(file.buffer);
if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') {
lat = gps.latitude;
lng = gps.longitude;
}
}
catch (e) {
console.warn('[EXIF GPS] Không thể giải nén GPS từ EXIF ảnh:', e.message);
}
if (lat === undefined || lng === undefined) {
if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) {
lat = bodyLat;
lng = bodyLng;
}
}
if (lat === undefined || lng === undefined) {
lat = 10.7769;
lng = 106.7009;
}
let processBuffer = file.buffer;
const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif';
if (isHeic) {
@@ -1076,6 +1100,7 @@ let TourController = class TourController {
}
}
await (0, sharp_1.default)(processBuffer)
.rotate()
.resize(2560, 2560, {
fit: 'inside',
withoutEnlargement: true
@@ -1089,6 +1114,10 @@ let TourController = class TourController {
imageUrl: `/uploads/tours/${displayFilename}`,
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`,
privacy: 'TOUR_ONLY',
metadata: {
lat: lat,
lng: lng
}
}
});
}));
@@ -1624,6 +1653,7 @@ let PhotoController = class PhotoController {
}
}
await (0, sharp_1.default)(processBuffer)
.rotate()
.resize(2560, 2560, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toFile(displayFilePath);
@@ -2041,6 +2071,214 @@ AdminOtpController = __decorate([
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
EmailService, Object])
], AdminOtpController);
let AdminController = class AdminController {
constructor(prisma) {
this.prisma = prisma;
}
async getTrashPhotos() {
const dbPhotos = await this.prisma.photo.findMany({
select: {
id: true,
imageUrl: true,
originalUrl: true,
privacy: true,
tourId: true,
}
});
const dbImageUrls = new Set(dbPhotos.map(p => p.imageUrl).filter(Boolean));
const dbOriginalUrls = new Set(dbPhotos.map(p => p.originalUrl).filter(Boolean));
const allDiskFiles = [];
const getFilesRecursively = (dir) => {
if (!fs.existsSync(dir))
return;
const list = fs.readdirSync(dir);
for (const file of list) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
getFilesRecursively(fullPath);
}
else {
allDiskFiles.push(fullPath);
}
}
};
getFilesRecursively(UPLOAD_ROOT);
const trashPhotos = [];
for (const filePath of allDiskFiles) {
const relativePath = '/uploads' + filePath.substring(UPLOAD_ROOT.length).replace(/\\/g, '/');
if (path.basename(filePath).startsWith('.'))
continue;
const isUsed = dbImageUrls.has(relativePath) || dbOriginalUrls.has(relativePath);
if (!isUsed) {
let size = 0;
try {
size = fs.statSync(filePath).size;
}
catch (e) { }
trashPhotos.push({
filePath: filePath,
url: relativePath,
size: size,
reason: 'Tệp tin không tồn tại trong cơ sở dữ liệu (ảnh mồ côi)',
type: 'file_only'
});
}
}
const orphanedDbPhotos = dbPhotos.filter(p => !p.tourId && p.privacy !== 'PUBLIC');
for (const dbPhoto of orphanedDbPhotos) {
let size = 0;
if (dbPhoto.imageUrl) {
const fullPath = path.join(process.cwd(), dbPhoto.imageUrl.replace(/^\//, ''));
try {
if (fs.existsSync(fullPath)) {
size += fs.statSync(fullPath).size;
}
}
catch (e) { }
}
if (dbPhoto.originalUrl) {
const fullPath = path.join(process.cwd(), dbPhoto.originalUrl.replace(/^\//, ''));
try {
if (fs.existsSync(fullPath)) {
size += fs.statSync(fullPath).size;
}
}
catch (e) { }
}
trashPhotos.push({
id: dbPhoto.id,
url: dbPhoto.imageUrl,
size: size,
reason: 'Bản ghi ảnh riêng tư không gắn với chuyến đi nào',
type: 'db_orphaned'
});
}
return trashPhotos;
}
async cleanTrashPhotos() {
const trashList = await this.getTrashPhotos();
let deletedCount = 0;
let freedSpace = 0;
for (const item of trashList) {
if (item.type === 'file_only') {
if (item.filePath && fs.existsSync(item.filePath)) {
try {
fs.unlinkSync(item.filePath);
deletedCount++;
freedSpace += item.size;
}
catch (e) { }
}
}
else if (item.type === 'db_orphaned') {
const photo = await this.prisma.photo.findUnique({ where: { id: item.id } });
if (photo) {
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
try {
fs.unlinkSync(displayFilePath);
}
catch (e) { }
}
}
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
if (fs.existsSync(originalFilePath)) {
try {
fs.unlinkSync(originalFilePath);
}
catch (e) { }
}
}
try {
await this.prisma.photo.delete({ where: { id: item.id } });
deletedCount++;
freedSpace += item.size;
}
catch (e) { }
}
}
}
return {
success: true,
message: `Đã dọn dẹp thành công. Đã xóa ${deletedCount} mục, giải phóng ${(freedSpace / (1024 * 1024)).toFixed(2)} MB.`,
deletedCount,
freedSpace
};
}
async deleteSingleTrash(body) {
const { type, filePath, id } = body;
if (type === 'file_only') {
if (!filePath)
throw new common_1.BadRequestException('Đường dẫn tệp tin không hợp lệ');
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(UPLOAD_ROOT)) {
throw new common_1.ForbiddenException('Không được phép xóa tệp tin ngoài thư mục uploads');
}
if (fs.existsSync(resolvedPath)) {
fs.unlinkSync(resolvedPath);
return { success: true, message: 'Đã xóa tệp tin thành công' };
}
else {
throw new common_1.NotFoundException('Tệp tin không tồn tại');
}
}
else if (type === 'db_orphaned') {
if (!id)
throw new common_1.BadRequestException('ID bản ghi không hợp lệ');
const photo = await this.prisma.photo.findUnique({ where: { id } });
if (!photo)
throw new common_1.NotFoundException('Bản ghi không tồn tại trong cơ sở dữ liệu');
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
try {
fs.unlinkSync(displayFilePath);
}
catch (e) { }
}
}
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
if (fs.existsSync(originalFilePath)) {
try {
fs.unlinkSync(originalFilePath);
}
catch (e) { }
}
}
await this.prisma.photo.delete({ where: { id } });
return { success: true, message: 'Đã xóa bản ghi và các tệp liên quan thành công' };
}
throw new common_1.BadRequestException('Yêu cầu không hợp lệ');
}
};
__decorate([
(0, common_1.Get)(),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], AdminController.prototype, "getTrashPhotos", null);
__decorate([
(0, common_1.Delete)('clean'),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], AdminController.prototype, "cleanTrashPhotos", null);
__decorate([
(0, common_1.Delete)('delete-single'),
__param(0, (0, common_1.Body)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], AdminController.prototype, "deleteSingleTrash", null);
AdminController = __decorate([
(0, common_1.Controller)('admin/trash-photos'),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], AdminController);
let PublicPhotoController = class PublicPhotoController {
constructor(prisma, commentGateway) {
this.prisma = prisma;
@@ -2155,7 +2393,7 @@ AppModule = __decorate([
signOptions: { expiresIn: '1d' },
}),
],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController],
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector, EmailService, config_1.ConfigService],
exports: [prisma_service_1.PrismaService]
})
+1 -1
View File
File diff suppressed because one or more lines are too long
+231 -3
View File
@@ -1168,6 +1168,10 @@ class TourController {
if (!fs.existsSync(memberOriginalDir)) fs.mkdirSync(memberOriginalDir, { recursive: true });
if (!fs.existsSync(tourDisplayPath)) fs.mkdirSync(tourDisplayPath, { recursive: true });
// Trích xuất tọa độ GPS dự phòng từ request body gửi từ frontend
const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined;
const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined;
return Promise.all(files.map(async (file) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
@@ -1180,7 +1184,34 @@ class TourController {
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của thành viên
await fs.promises.writeFile(originalFilePath, file.buffer);
// 2. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px)
// 2. Trích xuất GPS từ EXIF
let lat: number | undefined;
let lng: number | undefined;
try {
const gps = await exifr.gps(file.buffer);
if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') {
lat = gps.latitude;
lng = gps.longitude;
}
} catch (e) {
console.warn('[EXIF GPS] Không thể giải nén GPS từ EXIF ảnh:', e.message);
}
// 3. Nếu EXIF không có GPS, dùng GPS dự phòng của thiết bị gửi từ Frontend
if (lat === undefined || lng === undefined) {
if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) {
lat = bodyLat;
lng = bodyLng;
}
}
// 4. Nếu vẫn không có vị trí nào, ghim tại vị trí mặc định [10.7769, 106.7009]
if (lat === undefined || lng === undefined) {
lat = 10.7769;
lng = 106.7009;
}
// 5. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px)
let processBuffer = file.buffer;
const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif';
if (isHeic) {
@@ -1198,6 +1229,7 @@ class TourController {
// Sử dụng Sharp để resize và tối ưu dung lượng ảnh
await sharp(processBuffer)
.rotate()
.resize(2560, 2560, {
fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K
withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh
@@ -1205,7 +1237,7 @@ class TourController {
.jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng
.toFile(displayFilePath);
// 3. Lưu thông tin vào Database (Lưu cả 2 đường dẫn)
// 6. Lưu thông tin vào Database (Lưu cả 2 đường dẫn và metadata GPS)
return this.prisma.photo.create({
data: {
tourId: tourId,
@@ -1213,6 +1245,10 @@ class TourController {
imageUrl: `/uploads/tours/${displayFilename}`, // URL ảnh 2K dùng để render (luôn là .jpg)
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`, // URL ảnh gốc để tải xuống
privacy: 'TOUR_ONLY',
metadata: {
lat: lat,
lng: lng
}
}
});
}));
@@ -1586,6 +1622,7 @@ class PhotoController {
// 6. Xử lý ảnh để hiển thị (kích thước tối đa 2K: 2560px)
await sharp(processBuffer)
.rotate()
.resize(2560, 2560, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toFile(displayFilePath);
@@ -1972,6 +2009,197 @@ class AdminOtpController {
}
}
@Controller('admin/trash-photos')
@UseGuards(JwtAuthGuard, AdminGuard)
class AdminController {
constructor(private prisma: PrismaService) {}
@Get()
async getTrashPhotos() {
// 1. Lấy tất cả ảnh trong DB
const dbPhotos = await this.prisma.photo.findMany({
select: {
id: true,
imageUrl: true,
originalUrl: true,
privacy: true,
tourId: true,
}
});
const dbImageUrls = new Set(
dbPhotos.map(p => p.imageUrl).filter(Boolean)
);
const dbOriginalUrls = new Set(
dbPhotos.map(p => p.originalUrl).filter(Boolean)
);
// 2. Tìm tất cả tệp tin vật lý trên đĩa
const allDiskFiles: string[] = [];
const getFilesRecursively = (dir: string) => {
if (!fs.existsSync(dir)) return;
const list = fs.readdirSync(dir);
for (const file of list) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
getFilesRecursively(fullPath);
} else {
allDiskFiles.push(fullPath);
}
}
};
getFilesRecursively(UPLOAD_ROOT);
const trashPhotos: any[] = [];
// 3. Kiểm tra các tệp tin trên đĩa xem có trong DB không
for (const filePath of allDiskFiles) {
const relativePath = '/uploads' + filePath.substring(UPLOAD_ROOT.length).replace(/\\/g, '/');
if (path.basename(filePath).startsWith('.')) continue;
const isUsed = dbImageUrls.has(relativePath) || dbOriginalUrls.has(relativePath);
if (!isUsed) {
let size = 0;
try {
size = fs.statSync(filePath).size;
} catch (e) {}
trashPhotos.push({
filePath: filePath,
url: relativePath,
size: size,
reason: 'Tệp tin không tồn tại trong cơ sở dữ liệu (ảnh mồ côi)',
type: 'file_only'
});
}
}
// 4. Kiểm tra các bản ghi DB mồ côi (không có tourId và privacy không phải PUBLIC)
const orphanedDbPhotos = dbPhotos.filter(p => !p.tourId && p.privacy !== 'PUBLIC');
for (const dbPhoto of orphanedDbPhotos) {
let size = 0;
if (dbPhoto.imageUrl) {
const fullPath = path.join(process.cwd(), dbPhoto.imageUrl.replace(/^\//, ''));
try {
if (fs.existsSync(fullPath)) {
size += fs.statSync(fullPath).size;
}
} catch (e) {}
}
if (dbPhoto.originalUrl) {
const fullPath = path.join(process.cwd(), dbPhoto.originalUrl.replace(/^\//, ''));
try {
if (fs.existsSync(fullPath)) {
size += fs.statSync(fullPath).size;
}
} catch (e) {}
}
trashPhotos.push({
id: dbPhoto.id,
url: dbPhoto.imageUrl,
size: size,
reason: 'Bản ghi ảnh riêng tư không gắn với chuyến đi nào',
type: 'db_orphaned'
});
}
return trashPhotos;
}
@Delete('clean')
async cleanTrashPhotos() {
const trashList = await this.getTrashPhotos();
let deletedCount = 0;
let freedSpace = 0;
for (const item of trashList) {
if (item.type === 'file_only') {
if (item.filePath && fs.existsSync(item.filePath)) {
try {
fs.unlinkSync(item.filePath);
deletedCount++;
freedSpace += item.size;
} catch (e) {}
}
} else if (item.type === 'db_orphaned') {
const photo = await this.prisma.photo.findUnique({ where: { id: item.id } });
if (photo) {
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
try { fs.unlinkSync(displayFilePath); } catch (e) {}
}
}
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
if (fs.existsSync(originalFilePath)) {
try { fs.unlinkSync(originalFilePath); } catch (e) {}
}
}
try {
await this.prisma.photo.delete({ where: { id: item.id } });
deletedCount++;
freedSpace += item.size;
} catch (e) {}
}
}
}
return {
success: true,
message: `Đã dọn dẹp thành công. Đã xóa ${deletedCount} mục, giải phóng ${(freedSpace / (1024 * 1024)).toFixed(2)} MB.`,
deletedCount,
freedSpace
};
}
@Delete('delete-single')
async deleteSingleTrash(@Body() body: { type: 'file_only' | 'db_orphaned'; filePath?: string; id?: string }) {
const { type, filePath, id } = body;
if (type === 'file_only') {
if (!filePath) throw new BadRequestException('Đường dẫn tệp tin không hợp lệ');
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(UPLOAD_ROOT)) {
throw new ForbiddenException('Không được phép xóa tệp tin ngoài thư mục uploads');
}
if (fs.existsSync(resolvedPath)) {
fs.unlinkSync(resolvedPath);
return { success: true, message: 'Đã xóa tệp tin thành công' };
} else {
throw new NotFoundException('Tệp tin không tồn tại');
}
} else if (type === 'db_orphaned') {
if (!id) throw new BadRequestException('ID bản ghi không hợp lệ');
const photo = await this.prisma.photo.findUnique({ where: { id } });
if (!photo) throw new NotFoundException('Bản ghi không tồn tại trong cơ sở dữ liệu');
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
try { fs.unlinkSync(displayFilePath); } catch (e) {}
}
}
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
if (fs.existsSync(originalFilePath)) {
try { fs.unlinkSync(originalFilePath); } catch (e) {}
}
}
await this.prisma.photo.delete({ where: { id } });
return { success: true, message: 'Đã xóa bản ghi và các tệp liên quan thành công' };
}
throw new BadRequestException('Yêu cầu không hợp lệ');
}
}
@Controller('public-photos')
class PublicPhotoController {
constructor(
@@ -2075,7 +2303,7 @@ class PublicPhotoController {
signOptions: { expiresIn: '1d' },
}) as any,
],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController],
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
exports: [PrismaService]
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-Dn1e9vte.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C4TR57aU.css">
<script type="module" crossorigin src="/assets/index-Kkg5SQS3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-YlatkTXO.css">
</head>
<body>
<div id="root"></div>
+21
View File
@@ -70,11 +70,32 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
setIsUploading(true);
try {
// Lấy tọa độ hiện tại của người dùng làm dự phòng nếu ảnh EXIF không có GPS
const location = await Promise.race([
new Promise<GeolocationPosition | null>((resolve) => {
if (!navigator.geolocation) {
resolve(null);
} else {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos),
() => resolve(null),
{ timeout: 4000, enableHighAccuracy: true }
);
}
}),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
]);
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('images', file);
});
if (location) {
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
}
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
method: 'POST',
headers: {
+163 -3
View File
@@ -7,11 +7,14 @@ interface UserManagementModalProps {
}
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState<'users' | 'photos'>('users');
const [activeTab, setActiveTab] = useState<'users' | 'photos' | 'trash'>('users');
const [users, setUsers] = useState<any[]>([]);
const [photos, setPhotos] = useState<any[]>([]);
const [trashPhotos, setTrashPhotos] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [photosLoading, setPhotosLoading] = useState(false);
const [trashLoading, setTrashLoading] = useState(false);
const [cleaning, setCleaning] = useState(false);
const [error, setError] = useState('');
const fetchUsers = async () => {
@@ -44,12 +47,72 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
}
};
const fetchTrashPhotos = async () => {
setTrashLoading(true);
try {
const response = await fetch(`/api/v1/admin/trash-photos`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) throw new Error('Không thể tải danh sách ảnh rác');
const data = await response.json();
setTrashPhotos(data);
} catch (err: any) {
setError(err.message);
} finally {
setTrashLoading(false);
}
};
const handleCleanTrash = async () => {
if (!confirm('Bạn có chắc muốn dọn sạch tất cả ảnh rác? Thao tác này sẽ xóa vĩnh viễn các tệp vật lý và dọn dẹp các bản ghi mồ côi.')) return;
setCleaning(true);
try {
const response = await fetch(`/api/v1/admin/trash-photos/clean`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Dọn dẹp thất bại');
alert(data.message);
fetchTrashPhotos();
} catch (err: any) {
alert(err.message);
} finally {
setCleaning(false);
}
};
const handleDeleteSingleTrash = async (item: any) => {
if (!confirm('Xóa mục này vĩnh viễn?')) return;
try {
const response = await fetch(`/api/v1/admin/trash-photos/delete-single`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: item.type,
filePath: item.filePath,
id: item.id
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Xóa mục thất bại');
fetchTrashPhotos();
} catch (err: any) {
alert(err.message);
}
};
useEffect(() => {
if (isOpen) {
if (activeTab === 'users') {
fetchUsers();
} else {
} else if (activeTab === 'photos') {
fetchPhotos();
} else if (activeTab === 'trash') {
fetchTrashPhotos();
}
}
}, [isOpen, activeTab]);
@@ -140,6 +203,15 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
<ImageIcon className="w-4 h-4" />
nh công cộng
</button>
<button
onClick={() => setActiveTab('trash')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'trash' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<Trash2 className="w-4 h-4" />
nh rác
</button>
</div>
{/* Content Body */}
@@ -212,7 +284,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
</tbody>
</table>
)
) : (
) : activeTab === 'photos' ? (
photosLoading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : photos.length === 0 ? (
@@ -248,6 +320,94 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
))}
</div>
)
) : (
trashLoading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : (
<div className="space-y-4">
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-2xl border border-gray-100">
<div>
<span className="text-sm font-bold text-gray-800">
Tổng số lượng: <span className="text-red-500 font-extrabold text-base">{trashPhotos.length}</span> mục nh rác
</span>
<span className="mx-2 text-gray-300">|</span>
<span className="text-sm font-bold text-gray-800">
Dung lượng ưc tính: <span className="text-blue-600 font-extrabold text-base">
{(trashPhotos.reduce((acc, curr) => acc + (curr.size || 0), 0) / (1024 * 1024)).toFixed(2)} MB
</span>
</span>
</div>
{trashPhotos.length > 0 && (
<button
onClick={handleCleanTrash}
disabled={cleaning}
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-2 shadow-md transition-all active:scale-95 disabled:opacity-50"
>
{cleaning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Dọn sạch nh rác
</button>
)}
</div>
{trashPhotos.length === 0 ? (
<div className="text-center py-20 text-gray-400 italic">Không nh rác nào. Hệ thống của bạn sạch sẽ!</div>
) : (
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
<table className="w-full text-left border-collapse bg-white">
<thead>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100 bg-gray-50/50">
<th className="py-3 px-4 font-bold">Hình nh</th>
<th className="py-3 px-4 font-bold">Thông tin</th>
<th className="py-3 px-4 font-bold"> do</th>
<th className="py-3 px-4 font-bold">Kích thước</th>
<th className="py-3 px-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{trashPhotos.map((item, index) => (
<tr key={index} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-3 px-4">
{item.url ? (
<div className="w-12 h-12 rounded-lg bg-gray-900 overflow-hidden shadow-sm border border-gray-100 flex items-center justify-center">
<img src={item.url} alt="Trash preview" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLElement).style.display = 'none'; }} />
</div>
) : (
<div className="w-12 h-12 rounded-lg bg-gray-100 border border-gray-100 flex items-center justify-center text-gray-400 text-[10px] font-bold">
N/A
</div>
)}
</td>
<td className="py-3 px-4 max-w-[200px] truncate">
<span className="text-xs font-mono text-gray-500 block truncate" title={item.url || item.filePath}>
{item.url || item.filePath}
</span>
<span className="text-[10px] font-black uppercase text-gray-400 tracking-wider">
{item.type === 'file_only' ? 'Tệp tin mồ côi' : 'Bản ghi mồ côi'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-xs text-red-500 font-semibold">{item.reason}</span>
</td>
<td className="py-3 px-4 text-xs font-semibold text-gray-700">
{((item.size || 0) / 1024).toFixed(1)} KB
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => handleDeleteSingleTrash(item)}
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-all shadow-sm active:scale-95"
title="Xóa vĩnh viễn"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
)}
</div>
</div>