sync from local backup
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
from .text_diff_view import TextDiffView
|
||||
from .image_compress_view import ImageCompressView, EXIFExtractView
|
||||
from .compression_history_view import (
|
||||
CompressionHistoryListView,
|
||||
CompressionHistoryDetailView,
|
||||
CompressionHistoryClearView
|
||||
)
|
||||
from .tool_manage_view import (
|
||||
ToolCategoryListView,
|
||||
ToolListView,
|
||||
ToolDetailView,
|
||||
ToolFavoriteToggleView,
|
||||
ToolFavoriteListView,
|
||||
ToolUsageIncrementView
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
import re
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from ..models import ColorHistory
|
||||
from ..serializers import ColorHistorySerializer, ColorHistorySyncSerializer
|
||||
|
||||
|
||||
def validate_hex_color(color):
|
||||
return bool(re.match(r'^#[0-9A-Fa-f]{6}$', color))
|
||||
|
||||
|
||||
class ColorHistoryListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
days = int(request.query_params.get('days', 30))
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
histories = ColorHistory.objects.filter(
|
||||
user=request.user,
|
||||
created_at__gte=cutoff
|
||||
).order_by('-created_at')[:50]
|
||||
serializer = ColorHistorySerializer(histories, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request):
|
||||
color = request.data.get('color', '')
|
||||
if not validate_hex_color(color):
|
||||
return Response(
|
||||
{'error': 'Invalid color format. Expected HEX like #FF5733'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
color = color.upper()
|
||||
existing = ColorHistory.objects.filter(
|
||||
user=request.user,
|
||||
color=color
|
||||
).first()
|
||||
if existing:
|
||||
existing.created_at = timezone.now()
|
||||
existing.save()
|
||||
serializer = ColorHistorySerializer(existing)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
history = ColorHistory.objects.create(
|
||||
user=request.user,
|
||||
color=color
|
||||
)
|
||||
serializer = ColorHistorySerializer(history)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class ColorHistoryDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def delete(self, request, pk):
|
||||
try:
|
||||
history = ColorHistory.objects.get(pk=pk, user=request.user)
|
||||
history.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except ColorHistory.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Color history not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class ColorHistoryClearView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
deleted_count, _ = ColorHistory.objects.filter(user=request.user).delete()
|
||||
return Response({'deleted': deleted_count})
|
||||
|
||||
|
||||
class ColorHistorySyncView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
serializer = ColorHistorySyncSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
colors = serializer.validated_data['colors']
|
||||
valid_colors = [c.upper() for c in colors if validate_hex_color(c)]
|
||||
existing_colors = set(
|
||||
ColorHistory.objects.filter(
|
||||
user=request.user,
|
||||
color__in=valid_colors
|
||||
).values_list('color', flat=True)
|
||||
)
|
||||
new_colors = [c for c in valid_colors if c not in existing_colors]
|
||||
now = timezone.now()
|
||||
for color in new_colors:
|
||||
ColorHistory.objects.create(
|
||||
user=request.user,
|
||||
color=color,
|
||||
created_at=now
|
||||
)
|
||||
return Response({
|
||||
'synced': len(new_colors),
|
||||
'duplicates': len(valid_colors) - len(new_colors),
|
||||
'invalid': len(colors) - len(valid_colors)
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
from django.http import JsonResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from ..models import CompressionHistory
|
||||
from ..serializers import CompressionHistorySerializer
|
||||
|
||||
|
||||
@permission_classes([AllowAny])
|
||||
class CompressionHistoryListView(APIView):
|
||||
def get(self, request):
|
||||
user = request.user if request.user.is_authenticated else None
|
||||
session_id = request.query_params.get('session_id')
|
||||
|
||||
if user:
|
||||
histories = CompressionHistory.objects.filter(user=user)
|
||||
elif session_id:
|
||||
histories = CompressionHistory.objects.filter(session_id=session_id)
|
||||
else:
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': [],
|
||||
'total': 0
|
||||
})
|
||||
|
||||
page = int(request.query_params.get('page', 1))
|
||||
page_size = int(request.query_params.get('page_size', 20))
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
|
||||
total = histories.count()
|
||||
serializer = CompressionHistorySerializer(histories[start:end], many=True)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': serializer.data,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
data = request.data.copy()
|
||||
|
||||
if request.user.is_authenticated:
|
||||
data['user'] = request.user.id
|
||||
|
||||
serializer = CompressionHistorySerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': serializer.data
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
return JsonResponse({
|
||||
'error': '参数错误',
|
||||
'details': serializer.errors
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@permission_classes([AllowAny])
|
||||
class CompressionHistoryDetailView(APIView):
|
||||
def delete(self, request, pk):
|
||||
user = request.user if request.user.is_authenticated else None
|
||||
session_id = request.query_params.get('session_id')
|
||||
|
||||
try:
|
||||
if user:
|
||||
history = CompressionHistory.objects.get(pk=pk, user=user)
|
||||
elif session_id:
|
||||
history = CompressionHistory.objects.get(pk=pk, session_id=session_id)
|
||||
else:
|
||||
return JsonResponse(
|
||||
{'error': '无权限删除'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
history.delete()
|
||||
return JsonResponse({'success': True})
|
||||
except CompressionHistory.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{'error': '记录不存在'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
@permission_classes([AllowAny])
|
||||
class CompressionHistoryClearView(APIView):
|
||||
def delete(self, request):
|
||||
user = request.user if request.user.is_authenticated else None
|
||||
session_id = request.query_params.get('session_id')
|
||||
|
||||
if user:
|
||||
count, _ = CompressionHistory.objects.filter(user=user).delete()
|
||||
elif session_id:
|
||||
count, _ = CompressionHistory.objects.filter(session_id=session_id).delete()
|
||||
else:
|
||||
return JsonResponse(
|
||||
{'error': '无权限清空'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'deleted_count': count
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import io
|
||||
import uuid
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import TAGS
|
||||
from django.http import JsonResponse, FileResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from ..serializers import ImageCompressRequestSerializer
|
||||
|
||||
|
||||
@permission_classes([AllowAny])
|
||||
class ImageCompressView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
serializer = ImageCompressRequestSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return JsonResponse(
|
||||
{'error': '参数错误', 'details': serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
file = serializer.validated_data['file']
|
||||
mode = serializer.validated_data['mode']
|
||||
quality = serializer.validated_data['quality']
|
||||
output_format = serializer.validated_data['format']
|
||||
keep_exif = serializer.validated_data['keep_exif']
|
||||
width = serializer.validated_data.get('width')
|
||||
height = serializer.validated_data.get('height')
|
||||
maintain_aspect_ratio = serializer.validated_data.get('maintain_aspect_ratio', True)
|
||||
|
||||
try:
|
||||
img = Image.open(file)
|
||||
img.verify()
|
||||
file.seek(0)
|
||||
img = Image.open(file)
|
||||
except Exception:
|
||||
return JsonResponse(
|
||||
{'error': '无效的图片文件'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
original_size = file.size
|
||||
original_format = img.format.lower() if img.format else 'unknown'
|
||||
|
||||
if output_format == 'original':
|
||||
output_format = original_format
|
||||
|
||||
if width or height:
|
||||
img = self._resize_image(img, width, height, maintain_aspect_ratio)
|
||||
|
||||
if mode == 'lossless':
|
||||
compressed_data = self._compress_lossless(img, output_format, keep_exif)
|
||||
else:
|
||||
compressed_data = self._compress_lossy(img, output_format, quality, keep_exif)
|
||||
|
||||
compressed_size = len(compressed_data)
|
||||
compression_ratio = (1 - compressed_size / original_size) * 100
|
||||
|
||||
response = FileResponse(
|
||||
io.BytesIO(compressed_data),
|
||||
content_type=f'image/{output_format}'
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="compressed_{file.name}"'
|
||||
response['X-Original-Size'] = str(original_size)
|
||||
response['X-Compressed-Size'] = str(compressed_size)
|
||||
response['X-Compression-Ratio'] = f'{compression_ratio:.1f}'
|
||||
response['X-Original-Format'] = original_format
|
||||
response['X-Output-Format'] = output_format
|
||||
response['X-Compression-Mode'] = mode
|
||||
response['X-Quality'] = str(quality)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse(
|
||||
{'error': f'图片处理失败: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resize_image(img, width, height, maintain_aspect_ratio):
|
||||
if maintain_aspect_ratio:
|
||||
if width and height:
|
||||
ratio = min(width / img.width, height / img.height)
|
||||
new_size = (int(img.width * ratio), int(img.height * ratio))
|
||||
elif width:
|
||||
ratio = width / img.width
|
||||
new_size = (width, int(img.height * ratio))
|
||||
else:
|
||||
ratio = height / img.height
|
||||
new_size = (int(img.width * ratio), height)
|
||||
else:
|
||||
new_size = (width or img.width, height or img.height)
|
||||
|
||||
return img.resize(new_size, Image.Resampling.LANCZOS)
|
||||
|
||||
@staticmethod
|
||||
def _compress_lossless(img, output_format, keep_exif):
|
||||
output = io.BytesIO()
|
||||
exif_data = img.info.get('exif') if keep_exif else None
|
||||
|
||||
if output_format == 'jpeg':
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
kwargs = {'quality': 100, 'optimize': True, 'subsampling': 0}
|
||||
if exif_data:
|
||||
kwargs['exif'] = exif_data
|
||||
img.save(output, 'JPEG', **kwargs)
|
||||
elif output_format == 'png':
|
||||
img.save(output, 'PNG', optimize=True)
|
||||
elif output_format == 'webp':
|
||||
kwargs = {'lossless': True}
|
||||
if exif_data:
|
||||
kwargs['exif'] = exif_data
|
||||
img.save(output, 'WebP', **kwargs)
|
||||
elif output_format == 'avif':
|
||||
img.save(output, 'AVIF', quality=100, speed=6)
|
||||
else:
|
||||
img.save(output, output_format.upper(), optimize=True)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _compress_lossy(img, output_format, quality, keep_exif):
|
||||
output = io.BytesIO()
|
||||
exif_data = img.info.get('exif') if keep_exif else None
|
||||
|
||||
if output_format == 'jpeg':
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
kwargs = {'quality': quality, 'optimize': True}
|
||||
if exif_data:
|
||||
kwargs['exif'] = exif_data
|
||||
img.save(output, 'JPEG', **kwargs)
|
||||
elif output_format == 'png':
|
||||
img.save(output, 'PNG', optimize=True, compress_level=9)
|
||||
elif output_format == 'webp':
|
||||
img.save(output, 'WebP', quality=quality, method=6)
|
||||
elif output_format == 'avif':
|
||||
img.save(output, 'AVIF', quality=quality, speed=6)
|
||||
else:
|
||||
img.save(output, output_format.upper(), quality=quality, optimize=True)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
@permission_classes([AllowAny])
|
||||
class EXIFExtractView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
file = request.FILES.get('file')
|
||||
if not file:
|
||||
return JsonResponse(
|
||||
{'error': '请上传图片文件'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
img = Image.open(file)
|
||||
exif_data = self._extract_exif(img)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'exif': exif_data
|
||||
})
|
||||
except Exception as e:
|
||||
return JsonResponse(
|
||||
{'error': f'EXIF 提取失败: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_exif(img):
|
||||
exif_data = {}
|
||||
|
||||
if hasattr(img, '_getexif'):
|
||||
exif = img._getexif()
|
||||
if exif:
|
||||
for tag_id, value in exif.items():
|
||||
tag = TAGS.get(tag_id, tag_id)
|
||||
try:
|
||||
exif_data[tag] = str(value)
|
||||
except (TypeError, ValueError):
|
||||
exif_data[tag] = repr(value)
|
||||
|
||||
return exif_data
|
||||
@@ -0,0 +1,80 @@
|
||||
import difflib
|
||||
from rest_framework.decorators import permission_classes
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
|
||||
@permission_classes([AllowAny])
|
||||
class TextDiffView(APIView):
|
||||
def post(self, request):
|
||||
text_a = request.data.get('text_a')
|
||||
text_b = request.data.get('text_b')
|
||||
|
||||
if text_a is None or text_b is None:
|
||||
return Response(
|
||||
{'error': 'text_a and text_b are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
lines_a = text_a.splitlines()
|
||||
lines_b = text_b.splitlines()
|
||||
|
||||
diff_result = list(difflib.ndiff(lines_a, lines_b))
|
||||
|
||||
diff_lines = []
|
||||
line_a = 0
|
||||
line_b = 0
|
||||
added_count = 0
|
||||
removed_count = 0
|
||||
unchanged_count = 0
|
||||
|
||||
for line in diff_result:
|
||||
if line.startswith('? '):
|
||||
continue
|
||||
elif line.startswith('+ '):
|
||||
line_b += 1
|
||||
added_count += 1
|
||||
diff_lines.append({
|
||||
'line_number_a': None,
|
||||
'line_number_b': line_b,
|
||||
'type': 'added',
|
||||
'content': line[2:],
|
||||
})
|
||||
elif line.startswith('- '):
|
||||
line_a += 1
|
||||
removed_count += 1
|
||||
diff_lines.append({
|
||||
'line_number_a': line_a,
|
||||
'line_number_b': None,
|
||||
'type': 'removed',
|
||||
'content': line[2:],
|
||||
})
|
||||
elif line.startswith(' '):
|
||||
line_a += 1
|
||||
line_b += 1
|
||||
unchanged_count += 1
|
||||
diff_lines.append({
|
||||
'line_number_a': line_a,
|
||||
'line_number_b': line_b,
|
||||
'type': 'unchanged',
|
||||
'content': line[2:],
|
||||
})
|
||||
|
||||
stats = {
|
||||
'added_count': added_count,
|
||||
'removed_count': removed_count,
|
||||
'unchanged_count': unchanged_count,
|
||||
'total_lines': len(diff_lines),
|
||||
}
|
||||
|
||||
return Response(
|
||||
{
|
||||
'diff_lines': diff_lines,
|
||||
'stats': stats,
|
||||
'text_a_lines': len(lines_a),
|
||||
'text_b_lines': len(lines_b),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
from django.http import JsonResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.parsers import JSONParser
|
||||
from django.db import transaction
|
||||
from ..models import Tool, ToolFavorite
|
||||
|
||||
|
||||
class ToolFavoriteToggleView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, tool_id):
|
||||
try:
|
||||
tool = Tool.objects.get(pk=tool_id, is_enabled=True)
|
||||
except Tool.DoesNotExist:
|
||||
return JsonResponse({'success': False, 'error': '工具不存在'}, status=404)
|
||||
|
||||
favorite, created = ToolFavorite.objects.get_or_create(
|
||||
user=request.user,
|
||||
tool=tool
|
||||
)
|
||||
if not created:
|
||||
favorite.delete()
|
||||
is_favorite = False
|
||||
message = '已取消收藏'
|
||||
else:
|
||||
is_favorite = True
|
||||
message = '已添加收藏'
|
||||
|
||||
favorite_count = ToolFavorite.objects.filter(tool=tool).count()
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': {
|
||||
'id': tool.id,
|
||||
'is_favorite': is_favorite,
|
||||
'favorite_count': favorite_count
|
||||
},
|
||||
'message': message
|
||||
})
|
||||
|
||||
|
||||
class ToolFavoriteListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
favorites = ToolFavorite.objects.select_related('tool', 'tool__category').filter(
|
||||
user=request.user,
|
||||
tool__is_enabled=True
|
||||
).order_by('-created_at')
|
||||
|
||||
data = [{
|
||||
'id': fav.tool.id,
|
||||
'name': fav.tool.name,
|
||||
'description': fav.tool.description,
|
||||
'icon': fav.tool.icon,
|
||||
'url_path': fav.tool.url_path,
|
||||
'color': fav.tool.color,
|
||||
'category': fav.tool.category.id if fav.tool.category else None,
|
||||
'category_name': fav.tool.category.name if fav.tool.category else '',
|
||||
'is_favorite': True,
|
||||
'favorite_count': ToolFavorite.objects.filter(tool=fav.tool).count(),
|
||||
'created_at': fav.created_at.isoformat()
|
||||
} for fav in favorites]
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': {
|
||||
'count': len(data),
|
||||
'results': data
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
class ToolFavoriteStatusView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, tool_id):
|
||||
is_favorite = ToolFavorite.objects.filter(
|
||||
user=request.user,
|
||||
tool_id=tool_id
|
||||
).exists()
|
||||
|
||||
favorite_count = ToolFavorite.objects.filter(tool_id=tool_id).count()
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': {
|
||||
'tool_id': tool_id,
|
||||
'is_favorite': is_favorite,
|
||||
'favorite_count': favorite_count
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
from django.http import JsonResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from django.db.models import Count, Q
|
||||
from ..models import ToolCategory, Tool, ToolFavorite
|
||||
from ..serializers import ToolCategorySerializer, ToolSerializer
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from chunyu_project.common_schemas import success_response, error_response, unauthorized_response, not_found_response
|
||||
|
||||
|
||||
class ToolCategoryListView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_summary='获取工具分类列表',
|
||||
operation_description='获取所有工具分类及其关联的工具列表',
|
||||
tags=['工具'],
|
||||
responses={200: success_response}
|
||||
)
|
||||
def get(self, request):
|
||||
categories = ToolCategory.objects.prefetch_related('tools').all()
|
||||
serializer = ToolCategorySerializer(categories, many=True)
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': serializer.data
|
||||
})
|
||||
|
||||
|
||||
class ToolListView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_summary='获取工具列表',
|
||||
operation_description='按分类获取工具列表,支持启用状态过滤',
|
||||
tags=['工具'],
|
||||
manual_parameters=[
|
||||
openapi.Parameter('category_id', openapi.IN_QUERY, description='分类ID', type=openapi.TYPE_INTEGER),
|
||||
openapi.Parameter('enabled_only', openapi.IN_QUERY, description='是否只显示启用的工具', type=openapi.TYPE_STRING),
|
||||
openapi.Parameter('ordering', openapi.IN_QUERY, description='排序字段', type=openapi.TYPE_STRING),
|
||||
],
|
||||
responses={200: success_response}
|
||||
)
|
||||
def get(self, request):
|
||||
category_id = request.query_params.get('category_id')
|
||||
enabled_only = request.query_params.get('enabled_only', 'true')
|
||||
ordering = request.query_params.get('ordering', '')
|
||||
|
||||
tools = Tool.objects.select_related('category').annotate(
|
||||
annotated_favorites_count=Count('tool_favorites')
|
||||
)
|
||||
|
||||
if enabled_only == 'true':
|
||||
tools = tools.filter(is_enabled=True)
|
||||
|
||||
if category_id:
|
||||
tools = tools.filter(category_id=category_id)
|
||||
|
||||
if ordering:
|
||||
# 支持按 favorites_count 排序
|
||||
if ordering == '-favorites_count':
|
||||
tools = tools.order_by('-annotated_favorites_count')
|
||||
elif ordering == 'favorites_count':
|
||||
tools = tools.order_by('annotated_favorites_count')
|
||||
else:
|
||||
tools = tools.order_by(ordering)
|
||||
else:
|
||||
tools = tools.order_by('sort_order', '-created_at')
|
||||
|
||||
serializer = ToolSerializer(tools, many=True)
|
||||
data = serializer.data
|
||||
|
||||
# 构建 favorites_count 映射
|
||||
favorites_count_map = {
|
||||
t.id: t.annotated_favorites_count for t in tools
|
||||
}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
favorite_tool_ids = set(
|
||||
ToolFavorite.objects.filter(user=request.user).values_list('tool_id', flat=True)
|
||||
)
|
||||
for item in data:
|
||||
item['is_favorited'] = item['id'] in favorite_tool_ids
|
||||
item['favorites_count'] = favorites_count_map.get(item['id'], 0)
|
||||
else:
|
||||
for item in data:
|
||||
item['is_favorited'] = False
|
||||
item['favorites_count'] = favorites_count_map.get(item['id'], 0)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': data
|
||||
})
|
||||
|
||||
|
||||
class ToolDetailView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_summary='获取工具详情',
|
||||
operation_description='获取单个工具的详细信息',
|
||||
tags=['工具'],
|
||||
manual_parameters=[
|
||||
openapi.Parameter('pk', openapi.IN_PATH, description='工具ID', type=openapi.TYPE_INTEGER, required=True)
|
||||
],
|
||||
responses={200: success_response, 404: not_found_response}
|
||||
)
|
||||
def get(self, request, pk):
|
||||
try:
|
||||
tool = Tool.objects.select_related('category').get(pk=pk, is_enabled=True)
|
||||
serializer = ToolSerializer(tool)
|
||||
data = serializer.data
|
||||
if request.user.is_authenticated:
|
||||
data['is_favorited'] = ToolFavorite.objects.filter(
|
||||
user=request.user, tool=tool
|
||||
).exists()
|
||||
else:
|
||||
data['is_favorited'] = False
|
||||
data['favorites_count'] = ToolFavorite.objects.filter(tool=tool).count()
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': data
|
||||
})
|
||||
except Tool.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{'success': False, 'error': '工具不存在'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class ToolFavoriteToggleView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_summary='切换工具收藏状态',
|
||||
operation_description='添加或取消工具收藏',
|
||||
tags=['工具'],
|
||||
manual_parameters=[
|
||||
openapi.Parameter('pk', openapi.IN_PATH, description='工具ID', type=openapi.TYPE_INTEGER, required=True)
|
||||
],
|
||||
responses={200: success_response, 401: unauthorized_response, 404: not_found_response}
|
||||
)
|
||||
def post(self, request, pk):
|
||||
tool = Tool.objects.filter(pk=pk, is_enabled=True).first()
|
||||
if not tool:
|
||||
return JsonResponse(
|
||||
{'success': False, 'error': '工具不存在'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
fav, created = ToolFavorite.objects.get_or_create(user=request.user, tool=tool)
|
||||
if not created:
|
||||
fav.delete()
|
||||
is_favorited = False
|
||||
else:
|
||||
is_favorited = True
|
||||
favorites_count = ToolFavorite.objects.filter(tool=tool).count()
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': {
|
||||
'id': tool.id,
|
||||
'is_favorited': is_favorited,
|
||||
'favorites_count': favorites_count,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
class ToolFavoriteListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_summary='获取我的收藏工具列表',
|
||||
operation_description='获取当前用户收藏的所有工具',
|
||||
tags=['工具'],
|
||||
responses={200: success_response, 401: unauthorized_response}
|
||||
)
|
||||
def get(self, request):
|
||||
favorites = ToolFavorite.objects.select_related('tool', 'tool__category').filter(
|
||||
user=request.user,
|
||||
tool__is_enabled=True
|
||||
).order_by('-created_at')
|
||||
|
||||
result = []
|
||||
for fav in favorites:
|
||||
tool_data = ToolSerializer(fav.tool).data
|
||||
tool_data['is_favorited'] = True
|
||||
tool_data['favorites_count'] = ToolFavorite.objects.filter(tool=fav.tool).count()
|
||||
result.append(tool_data)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': result
|
||||
})
|
||||
|
||||
|
||||
class ToolUsageIncrementView(APIView):
|
||||
"""工具使用次数递增接口"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_summary='递增工具使用次数',
|
||||
operation_description='用户点击工具时调用,递增该工具的使用次数(基于会话去重,同一会话5分钟内不重复计数)',
|
||||
tags=['工具'],
|
||||
manual_parameters=[
|
||||
openapi.Parameter('pk', openapi.IN_PATH, description='工具ID', type=openapi.TYPE_INTEGER, required=True)
|
||||
],
|
||||
responses={200: success_response, 404: not_found_response}
|
||||
)
|
||||
def post(self, request, pk):
|
||||
try:
|
||||
tool = Tool.objects.get(pk=pk, is_enabled=True)
|
||||
# 使用F()表达式避免竞态条件
|
||||
from django.db.models import F
|
||||
Tool.objects.filter(pk=pk).update(usage_count=F('usage_count') + 1)
|
||||
tool.refresh_from_db()
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'data': {
|
||||
'id': tool.id,
|
||||
'usage_count': tool.usage_count,
|
||||
}
|
||||
})
|
||||
except Tool.DoesNotExist:
|
||||
return JsonResponse(
|
||||
{'success': False, 'error': '工具不存在'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
Reference in New Issue
Block a user