feat: item image now is required

This commit is contained in:
2025-09-21 13:47:19 +08:00
parent 8d12600df4
commit d2ab8d12f6
13 changed files with 1330 additions and 265 deletions
+40 -8
View File
@@ -1,20 +1,35 @@
from django.contrib import admin from django.contrib import admin
from .models import Item, ItemUsage, Category from .models import Item, ItemUsage, Category, ItemImage, UsageImage
class ItemImageInline(admin.TabularInline):
"""物品图片内联编辑"""
model = ItemImage
extra = 1
fields = ['image', 'description', 'is_primary']
class UsageImageInline(admin.TabularInline):
"""使用记录图片内联编辑"""
model = UsageImage
extra = 1
fields = ['image', 'image_type', 'description']
@admin.register(Item) @admin.register(Item)
class ItemAdmin(admin.ModelAdmin): class ItemAdmin(admin.ModelAdmin):
list_display = ['name', 'serial_number', 'category', 'status', 'location', 'created_at'] list_display = ['name', 'serial_number', 'category', 'status', 'location', 'owner', 'created_at']
list_filter = ['status', 'category', 'created_at'] list_filter = ['status', 'category', 'created_at']
search_fields = ['name', 'serial_number', 'description'] search_fields = ['name', 'serial_number', 'description', 'owner']
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']
inlines = [ItemImageInline]
fieldsets = ( fieldsets = (
('基本信息', { ('基本信息', {
'fields': ('name', 'description', 'serial_number', 'category') 'fields': ('name', 'description', 'serial_number', 'category')
}), }),
('状态和位置', { ('状态和位置', {
'fields': ('status', 'location') 'fields': ('status', 'location', 'owner')
}), }),
('购买信息', { ('购买信息', {
'fields': ('purchase_date', 'value') 'fields': ('purchase_date', 'value')
@@ -28,16 +43,17 @@ class ItemAdmin(admin.ModelAdmin):
@admin.register(ItemUsage) @admin.register(ItemUsage)
class ItemUsageAdmin(admin.ModelAdmin): class ItemUsageAdmin(admin.ModelAdmin):
list_display = ['item', 'user', 'start_time', 'end_time', 'is_returned', 'purpose'] list_display = ['item', 'user', 'borrower_contact', 'start_time', 'end_time', 'is_returned', 'purpose']
list_filter = ['is_returned', 'start_time', 'item__category'] list_filter = ['is_returned', 'start_time', 'item__category']
search_fields = ['item__name', 'user__username', 'purpose'] search_fields = ['item__name', 'user', 'purpose', 'borrower_contact']
readonly_fields = ['created_at'] readonly_fields = ['created_at']
inlines = [UsageImageInline]
fieldsets = ( fieldsets = (
('使用信息', { ('使用信息', {
'fields': ('item', 'user', 'purpose', 'notes') 'fields': ('item', 'user', 'borrower_contact', 'purpose', 'notes')
}), }),
('时间信息', { ('时间信息', {
'fields': ('start_time', 'end_time', 'is_returned') 'fields': ('start_time', 'end_time', 'expected_return_time', 'is_returned')
}), }),
('状况记录', { ('状况记录', {
'fields': ('condition_before', 'condition_after') 'fields': ('condition_before', 'condition_after')
@@ -49,6 +65,22 @@ class ItemUsageAdmin(admin.ModelAdmin):
) )
@admin.register(ItemImage)
class ItemImageAdmin(admin.ModelAdmin):
list_display = ['item', 'description', 'is_primary', 'created_at']
list_filter = ['is_primary', 'created_at']
search_fields = ['item__name', 'description']
readonly_fields = ['created_at']
@admin.register(UsageImage)
class UsageImageAdmin(admin.ModelAdmin):
list_display = ['usage', 'image_type', 'description', 'created_at']
list_filter = ['image_type', 'created_at']
search_fields = ['usage__item__name', 'usage__user', 'description']
readonly_fields = ['created_at']
@admin.register(Category) @admin.register(Category)
class CategoryAdmin(admin.ModelAdmin): class CategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'description', 'created_at'] list_display = ['name', 'description', 'created_at']
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,143 @@
from django.core.management.base import BaseCommand
from items.models import Category
# 添加物品类别:python manage.py create_default_categories
class Command(BaseCommand):
help = '创建默认的物品类别'
def handle(self, *args, **options):
# 默认物品类别列表
default_categories = [
{
'name': '电子设备',
'description': '包括电脑、手机、平板等电子产品'
},
{
'name': '办公用品',
'description': '办公桌椅、文具、打印机等办公设备'
},
{
'name': '实验器材',
'description': '实验室设备、仪器、工具等'
},
{
'name': '家具',
'description': '桌子、椅子、柜子等家具用品'
},
{
'name': '图书资料',
'description': '书籍、文档、资料等'
},
{
'name': '工具设备',
'description': '维修工具、测量仪器等'
},
{
'name': '音响设备',
'description': '音响、麦克风、投影仪等音视频设备'
},
{
'name': '运动器材',
'description': '体育用品、健身器材等'
},
{
'name': '清洁用品',
'description': '清洁工具、清洁剂等'
},
{
'name': '网络设备',
'description': '路由器、交换机、网线等网络相关设备'
},
{
'name': '安全设备',
'description': '监控摄像头、门禁系统、报警器等安全设备'
},
{
'name': '医疗用品',
'description': '急救包、体温计、血压计等医疗相关用品'
},
{
'name': '厨房用具',
'description': '微波炉、咖啡机、餐具等厨房设备'
},
{
'name': '照明设备',
'description': '台灯、吊灯、应急灯等照明用品'
},
{
'name': '存储设备',
'description': '硬盘、U盘、移动硬盘等存储设备'
},
{
'name': '车辆工具',
'description': '汽车配件、维修工具、车载设备等'
},
{
'name': '服装用品',
'description': '工作服、防护服、鞋帽等服装类物品'
},
{
'name': '教学用品',
'description': '黑板、白板、教学模型等教学设备'
},
{
'name': '通讯设备',
'description': '对讲机、电话、传真机等通讯设备'
},
{
'name': '空调制冷',
'description': '空调、风扇、加湿器等温度调节设备'
},
{
'name': '消防设备',
'description': '灭火器、烟雾报警器、消防栓等消防用品'
},
{
'name': '软件许可',
'description': '软件授权、许可证、数字资产等'
},
{
'name': '包装材料',
'description': '纸箱、胶带、包装袋等包装用品'
},
{
'name': '园艺用品',
'description': '花盆、园艺工具、肥料等园艺相关用品'
},
{
'name': '其他',
'description': '其他未分类的物品'
}
]
created_count = 0
updated_count = 0
for category_data in default_categories:
category, created = Category.objects.get_or_create(
name=category_data['name'],
defaults={'description': category_data['description']}
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f'创建类别: {category.name}')
)
else:
# 更新描述(如果不同)
if category.description != category_data['description']:
category.description = category_data['description']
category.save()
updated_count += 1
self.stdout.write(
self.style.WARNING(f'更新类别: {category.name}')
)
self.stdout.write(
self.style.SUCCESS(
f'\n任务完成!创建了 {created_count} 个新类别,更新了 {updated_count} 个类别。'
)
)
+87
View File
@@ -1,4 +1,27 @@
from django.db import models from django.db import models
import os
import uuid
def get_item_image_path(instance, filename):
"""生成物品图片的存储路径"""
# 如果物品还没有ID,先生成一个临时文件夹名
item_id = instance.item.id if instance.item.id else str(uuid.uuid4())
return f'items/{item_id}/initial/{filename}'
def get_usage_borrow_image_path(instance, filename):
"""生成借用时图片的存储路径"""
item_id = instance.usage.item.id if instance.usage.item.id else str(uuid.uuid4())
usage_id = instance.usage.id if instance.usage.id else str(uuid.uuid4())
return f'items/{item_id}/usage/{usage_id}/borrow/{filename}'
def get_usage_return_image_path(instance, filename):
"""生成归还时图片的存储路径"""
item_id = instance.usage.item.id if instance.usage.item.id else str(uuid.uuid4())
usage_id = instance.usage.id if instance.usage.id else str(uuid.uuid4())
return f'items/{item_id}/usage/{usage_id}/return/{filename}'
class Item(models.Model): class Item(models.Model):
@@ -34,6 +57,23 @@ class Item(models.Model):
return f"{self.name} ({self.serial_number})" return f"{self.name} ({self.serial_number})"
class ItemImage(models.Model):
"""物品图片模型"""
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='images', verbose_name='物品')
image = models.ImageField(upload_to=get_item_image_path, verbose_name='图片')
description = models.CharField(max_length=200, blank=True, verbose_name='图片描述')
is_primary = models.BooleanField(default=False, verbose_name='是否为主图')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='上传时间')
class Meta:
verbose_name = '物品图片'
verbose_name_plural = '物品图片'
ordering = ['-is_primary', '-created_at']
def __str__(self):
return f"{self.item.name} - 图片"
class ItemUsage(models.Model): class ItemUsage(models.Model):
"""物品使用记录模型""" """物品使用记录模型"""
item = models.ForeignKey(Item, on_delete=models.CASCADE, verbose_name='物品') item = models.ForeignKey(Item, on_delete=models.CASCADE, verbose_name='物品')
@@ -58,6 +98,53 @@ class ItemUsage(models.Model):
return f"{self.item.name} - {self.user} ({self.start_time.strftime('%Y-%m-%d %H:%M')})" return f"{self.item.name} - {self.user} ({self.start_time.strftime('%Y-%m-%d %H:%M')})"
class UsageImage(models.Model):
"""使用记录图片模型"""
IMAGE_TYPE_CHOICES = [
('borrow', '借用时'),
('return', '归还时'),
]
usage = models.ForeignKey(ItemUsage, on_delete=models.CASCADE, related_name='images', verbose_name='使用记录')
image = models.ImageField(upload_to='usage_images/', verbose_name='图片')
image_type = models.CharField(max_length=10, choices=IMAGE_TYPE_CHOICES, verbose_name='图片类型')
description = models.CharField(max_length=200, blank=True, verbose_name='图片描述')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='上传时间')
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# 保存后重新组织文件路径
if self.image and self.usage_id and self.usage.item_id:
import os
import shutil
from django.conf import settings
old_path = self.image.path
if self.image_type == 'borrow':
new_relative_path = f'items/{self.usage.item_id}/usage/{self.usage_id}/borrow/{os.path.basename(old_path)}'
else:
new_relative_path = f'items/{self.usage.item_id}/usage/{self.usage_id}/return/{os.path.basename(old_path)}'
new_path = os.path.join(settings.MEDIA_ROOT, new_relative_path)
# 创建目录
os.makedirs(os.path.dirname(new_path), exist_ok=True)
# 移动文件
if os.path.exists(old_path) and old_path != new_path:
shutil.move(old_path, new_path)
self.image.name = new_relative_path
super().save(update_fields=['image'])
class Meta:
verbose_name = '使用记录图片'
verbose_name_plural = '使用记录图片'
ordering = ['-created_at']
def __str__(self):
return f"{self.usage.item.name} - {self.get_image_type_display()}"
class Category(models.Model): class Category(models.Model):
"""物品类别模型""" """物品类别模型"""
name = models.CharField(max_length=50, unique=True, verbose_name='类别名称') name = models.CharField(max_length=50, unique=True, verbose_name='类别名称')
+59 -4
View File
@@ -1,7 +1,7 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import serializers from rest_framework import serializers
from .models import Item, ItemUsage, Category from .models import Item, ItemUsage, Category, ItemImage, UsageImage
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
@@ -16,15 +16,47 @@ class CategorySerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'description', 'created_at'] fields = ['id', 'name', 'description', 'created_at']
class ItemImageSerializer(serializers.ModelSerializer):
"""物品图片序列化器"""
image_url = serializers.SerializerMethodField()
class Meta:
model = ItemImage
fields = ['id', 'image', 'image_url', 'description', 'is_primary', 'created_at']
def get_image_url(self, obj):
request = self.context.get('request')
if obj.image and request:
return request.build_absolute_uri(obj.image.url)
return None
class UsageImageSerializer(serializers.ModelSerializer):
"""使用记录图片序列化器"""
image_url = serializers.SerializerMethodField()
class Meta:
model = UsageImage
fields = ['id', 'image', 'image_url', 'image_type', 'description', 'created_at']
def get_image_url(self, obj):
request = self.context.get('request')
if obj.image and request:
return request.build_absolute_uri(obj.image.url)
return None
class ItemSerializer(serializers.ModelSerializer): class ItemSerializer(serializers.ModelSerializer):
current_user = serializers.SerializerMethodField() current_user = serializers.SerializerMethodField()
images = ItemImageSerializer(many=True, read_only=True)
primary_image = serializers.SerializerMethodField()
class Meta: class Meta:
model = Item model = Item
fields = [ fields = [
'id', 'name', 'description', 'serial_number', 'category', 'status', 'id', 'name', 'description', 'serial_number', 'category', 'status',
'location', 'owner', 'purchase_date', 'value', 'created_at', 'updated_at', 'location', 'owner', 'purchase_date', 'value', 'created_at', 'updated_at',
'current_user' 'current_user', 'images', 'primary_image'
] ]
def get_current_user(self, obj): def get_current_user(self, obj):
@@ -39,20 +71,43 @@ class ItemSerializer(serializers.ModelSerializer):
} }
return None return None
def get_primary_image(self, obj):
"""获取主图片"""
primary_image = obj.images.filter(is_primary=True).first()
if primary_image:
request = self.context.get('request')
if request:
return request.build_absolute_uri(primary_image.image.url)
return None
class ItemUsageSerializer(serializers.ModelSerializer): class ItemUsageSerializer(serializers.ModelSerializer):
item_name = serializers.CharField(source='item.name', read_only=True) item_name = serializers.CharField(source='item.name', read_only=True)
item_serial = serializers.CharField(source='item.serial_number', read_only=True) item_serial = serializers.CharField(source='item.serial_number', read_only=True)
images = UsageImageSerializer(many=True, read_only=True)
borrow_images = serializers.SerializerMethodField()
return_images = serializers.SerializerMethodField()
class Meta: class Meta:
model = ItemUsage model = ItemUsage
fields = [ fields = [
'id', 'item', 'item_name', 'item_serial', 'user', 'borrower_contact', 'id', 'item', 'item_name', 'item_serial', 'user', 'borrower_contact',
'start_time', 'end_time', 'purpose', 'notes', 'is_returned', 'start_time', 'end_time', 'purpose', 'notes', 'is_returned',
'condition_before', 'condition_after', 'expected_return_time', 'created_at' 'condition_before', 'condition_after', 'expected_return_time', 'created_at',
'images', 'borrow_images', 'return_images'
] ]
read_only_fields = ['created_at'] read_only_fields = ['created_at']
def get_borrow_images(self, obj):
"""获取借用时图片"""
borrow_images = obj.images.filter(image_type='borrow')
return UsageImageSerializer(borrow_images, many=True, context=self.context).data
def get_return_images(self, obj):
"""获取归还时图片"""
return_images = obj.images.filter(image_type='return')
return UsageImageSerializer(return_images, many=True, context=self.context).data
class ItemDetailSerializer(ItemSerializer): class ItemDetailSerializer(ItemSerializer):
"""物品详情序列化器,包含使用历史""" """物品详情序列化器,包含使用历史"""
@@ -64,4 +119,4 @@ class ItemDetailSerializer(ItemSerializer):
def get_usage_history(self, obj): def get_usage_history(self, obj):
"""获取物品的使用历史""" """获取物品的使用历史"""
usages = ItemUsage.objects.filter(item=obj).order_by('-start_time')[:10] usages = ItemUsage.objects.filter(item=obj).order_by('-start_time')[:10]
return ItemUsageSerializer(usages, many=True).data return ItemUsageSerializer(usages, many=True, context=self.context).data
+2
View File
@@ -8,6 +8,8 @@ router.register(r'items', views.ItemViewSet)
router.register(r'usages', views.ItemUsageViewSet) router.register(r'usages', views.ItemUsageViewSet)
router.register(r'item_categories', views.CategoryViewSet) router.register(r'item_categories', views.CategoryViewSet)
router.register(r'users', views.UserViewSet) router.register(r'users', views.UserViewSet)
router.register(r'item-images', views.ItemImageViewSet)
router.register(r'usage-images', views.UsageImageViewSet)
urlpatterns = [ urlpatterns = [
path('api/', include(router.urls)), path('api/', include(router.urls)),
+145 -4
View File
@@ -4,17 +4,19 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework_simplejwt.authentication import JWTAuthentication from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework.parsers import MultiPartParser, FormParser
from .models import Item, ItemUsage, Category from .models import Item, ItemUsage, Category, ItemImage, UsageImage
from .serializers import ( from .serializers import (
ItemSerializer, ItemDetailSerializer, ItemUsageSerializer, ItemSerializer, ItemDetailSerializer, ItemUsageSerializer,
CategorySerializer, UserSerializer CategorySerializer, UserSerializer, ItemImageSerializer, UsageImageSerializer
) )
class ItemViewSet(viewsets.ModelViewSet): class ItemViewSet(viewsets.ModelViewSet):
"""物品管理API""" """物品管理API"""
authentication_classes = [JWTAuthentication] authentication_classes = [JWTAuthentication]
parser_classes = [MultiPartParser, FormParser]
queryset = Item.objects.all() queryset = Item.objects.all()
serializer_class = ItemSerializer serializer_class = ItemSerializer
@@ -23,6 +25,80 @@ class ItemViewSet(viewsets.ModelViewSet):
return ItemDetailSerializer return ItemDetailSerializer
return ItemSerializer return ItemSerializer
def create(self, request, *args, **kwargs):
"""创建物品,强制要求上传至少一张图片"""
# 检查是否上传了图片
images = request.FILES.getlist('images')
if not images:
return Response(
{'error': '创建物品时必须上传至少一张图片'},
status=status.HTTP_400_BAD_REQUEST
)
# 创建物品
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
item = serializer.save()
# 保存图片
for i, image in enumerate(images):
ItemImage.objects.create(
item=item,
image=image,
description=request.data.get(f'image_descriptions[{i}]', ''),
is_primary=(i == 0) # 第一张图片设为主图
)
# 返回包含图片的完整数据
response_serializer = ItemDetailSerializer(item, context={'request': request})
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'])
def upload_images(self, request, pk=None):
"""上传物品图片"""
item = self.get_object()
images = request.FILES.getlist('images')
if not images:
return Response(
{'error': '请选择要上传的图片'},
status=status.HTTP_400_BAD_REQUEST
)
uploaded_images = []
for i, image in enumerate(images):
item_image = ItemImage.objects.create(
item=item,
image=image,
description=request.data.get(f'image_descriptions[{i}]', ''),
is_primary=False
)
uploaded_images.append(item_image)
serializer = ItemImageSerializer(uploaded_images, many=True, context={'request': request})
return Response(serializer.data)
@action(detail=True, methods=['post'])
def set_primary_image(self, request, pk=None):
"""设置主图片"""
item = self.get_object()
image_id = request.data.get('image_id')
try:
# 取消当前主图
item.images.filter(is_primary=True).update(is_primary=False)
# 设置新主图
new_primary = item.images.get(id=image_id)
new_primary.is_primary = True
new_primary.save()
return Response({'message': '主图设置成功'})
except ItemImage.DoesNotExist:
return Response(
{'error': '图片不存在'},
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def borrow(self, request, pk=None): def borrow(self, request, pk=None):
"""借用物品""" """借用物品"""
@@ -57,11 +133,22 @@ class ItemViewSet(viewsets.ModelViewSet):
condition_before=condition_before condition_before=condition_before
) )
# 保存借用时的图片
borrow_images = request.FILES.getlist('borrow_images')
for i, image in enumerate(borrow_images):
UsageImage.objects.create(
usage=usage,
image=image,
image_type='borrow',
description=request.data.get(f'borrow_image_descriptions[{i}]', '')
)
# 更新物品状态 # 更新物品状态
item.status = 'in_use' item.status = 'in_use'
item.save() item.save()
return Response(ItemUsageSerializer(usage).data) response_serializer = ItemUsageSerializer(usage, context={'request': request})
return Response(response_serializer.data)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def return_item(self, request, pk=None): def return_item(self, request, pk=None):
@@ -86,11 +173,22 @@ class ItemViewSet(viewsets.ModelViewSet):
current_usage.notes = request.data.get('return_notes', current_usage.notes) current_usage.notes = request.data.get('return_notes', current_usage.notes)
current_usage.save() current_usage.save()
# 保存归还时的图片
return_images = request.FILES.getlist('return_images')
for i, image in enumerate(return_images):
UsageImage.objects.create(
usage=current_usage,
image=image,
image_type='return',
description=request.data.get(f'return_image_descriptions[{i}]', '')
)
# 更新物品状态 # 更新物品状态
item.status = 'available' item.status = 'available'
item.save() item.save()
return Response(ItemUsageSerializer(current_usage).data) response_serializer = ItemUsageSerializer(current_usage, context={'request': request})
return Response(response_serializer.data)
@action(detail=False) @action(detail=False)
def available(self, request): def available(self, request):
@@ -110,9 +208,36 @@ class ItemViewSet(viewsets.ModelViewSet):
class ItemUsageViewSet(viewsets.ModelViewSet): class ItemUsageViewSet(viewsets.ModelViewSet):
"""使用记录管理API""" """使用记录管理API"""
authentication_classes = [JWTAuthentication] authentication_classes = [JWTAuthentication]
parser_classes = [MultiPartParser, FormParser]
queryset = ItemUsage.objects.all() queryset = ItemUsage.objects.all()
serializer_class = ItemUsageSerializer serializer_class = ItemUsageSerializer
@action(detail=True, methods=['post'])
def upload_images(self, request, pk=None):
"""上传使用记录图片"""
usage = self.get_object()
images = request.FILES.getlist('images')
image_type = request.data.get('image_type', 'borrow')
if not images:
return Response(
{'error': '请选择要上传的图片'},
status=status.HTTP_400_BAD_REQUEST
)
uploaded_images = []
for i, image in enumerate(images):
usage_image = UsageImage.objects.create(
usage=usage,
image=image,
image_type=image_type,
description=request.data.get(f'image_descriptions[{i}]', '')
)
uploaded_images.append(usage_image)
serializer = UsageImageSerializer(uploaded_images, many=True, context={'request': request})
return Response(serializer.data)
@action(detail=False) @action(detail=False)
def current(self, request): def current(self, request):
"""获取当前使用中的记录""" """获取当前使用中的记录"""
@@ -143,3 +268,19 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet):
authentication_classes = [JWTAuthentication] authentication_classes = [JWTAuthentication]
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserSerializer serializer_class = UserSerializer
class ItemImageViewSet(viewsets.ModelViewSet):
"""物品图片管理API"""
authentication_classes = [JWTAuthentication]
parser_classes = [MultiPartParser, FormParser]
queryset = ItemImage.objects.all()
serializer_class = ItemImageSerializer
class UsageImageViewSet(viewsets.ModelViewSet):
"""使用记录图片管理API"""
authentication_classes = [JWTAuthentication]
parser_classes = [MultiPartParser, FormParser]
queryset = UsageImage.objects.all()
serializer_class = UsageImageSerializer
+6
View File
@@ -2,6 +2,7 @@ import {createRouter, createWebHistory} from 'vue-router'
import Login from '../views/Login.vue' import Login from '../views/Login.vue'
import ItemList from '../views/ItemList.vue' import ItemList from '../views/ItemList.vue'
import ItemDetail from '../views/ItemDetail.vue' import ItemDetail from '../views/ItemDetail.vue'
import ItemCreate from '../views/ItemCreate.vue'
import ItemUsage from '../views/ItemUsage.vue' import ItemUsage from '../views/ItemUsage.vue'
import Dashboard from '../views/Dashboard.vue' import Dashboard from '../views/Dashboard.vue'
import FinanceDashboard from '../views/FinanceDashboard.vue' import FinanceDashboard from '../views/FinanceDashboard.vue'
@@ -28,6 +29,11 @@ const routes = [
name: 'ItemList', name: 'ItemList',
component: ItemList component: ItemList
}, },
{
path: '/items/create',
name: 'ItemCreate',
component: ItemCreate
},
{ {
path: '/items/:id', path: '/items/:id',
name: 'ItemDetail', name: 'ItemDetail',
+55 -9
View File
@@ -113,9 +113,13 @@ export const itemService = {
return apiClient.get(`/items/${id}/`) return apiClient.get(`/items/${id}/`)
}, },
// 创建新物品 // 创建新物品(支持图片上传)
createItem(item) { createItem(formData) {
return apiClient.post('/items/', item) return apiClient.post('/items/', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}, },
// 更新物品 // 更新物品
@@ -128,6 +132,31 @@ export const itemService = {
return apiClient.delete(`/items/${id}/`) return apiClient.delete(`/items/${id}/`)
}, },
// 上传物品图片
uploadItemImages(itemId, formData) {
return apiClient.post(`/items/${itemId}/upload_images/`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 设置主图片
setPrimaryImage(itemId, imageId) {
const formData = new FormData()
formData.append('image_id', imageId)
return apiClient.post(`/items/${itemId}/set_primary_image/`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 删除物品图片
deleteItemImage(itemId, imageId) {
return apiClient.delete(`/item-images/${imageId}/`)
},
// 获取可用物品 // 获取可用物品
getAvailableItems() { getAvailableItems() {
return apiClient.get('/items/available/') return apiClient.get('/items/available/')
@@ -138,14 +167,22 @@ export const itemService = {
return apiClient.get('/items/in_use/') return apiClient.get('/items/in_use/')
}, },
// 借用物品 // 借用物品(支持图片上传)
borrowItem(itemId, borrowData) { borrowItem(itemId, formData) {
return apiClient.post(`/items/${itemId}/borrow/`, borrowData) return apiClient.post(`/items/${itemId}/borrow/`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}, },
// 归还物品 // 归还物品(支持图片上传)
returnItem(itemId, returnData) { returnItem(itemId, formData) {
return apiClient.post(`/items/${itemId}/return_item/`, returnData) return apiClient.post(`/items/${itemId}/return_item/`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
} }
} }
@@ -173,6 +210,15 @@ export const usageService = {
// 更新使用记录 // 更新使用记录
updateUsage(id, usage) { updateUsage(id, usage) {
return apiClient.put(`/usages/${id}/`, usage) return apiClient.put(`/usages/${id}/`, usage)
},
// 上传使用记录图片
uploadUsageImages(usageId, formData) {
return apiClient.post(`/usages/${usageId}/upload_images/`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
} }
} }
+290
View File
@@ -0,0 +1,290 @@
<template>
<AppHeader />
<div class="item-create">
<div class="create-header">
<el-button @click="$router.go(-1)" style="margin-bottom: 20px;">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<h2>创建新物品</h2>
</div>
<el-card>
<el-form :model="itemForm" :rules="rules" ref="itemFormRef" label-width="120px">
<!-- 物品图片上传 - 必填项 -->
<el-form-item label="物品图片" required>
<el-upload
ref="uploadRef"
:auto-upload="false"
:multiple="true"
:limit="10"
accept="image/*"
list-type="picture-card"
:on-change="handleImageChange"
:on-remove="handleImageRemove"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div style="margin-top: 10px;">
<el-text type="info" size="small">
* 必须上传至少一张图片支持 JPGPNG 格式最多上传 10 张图片
</el-text>
</div>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="物品名称" prop="name">
<el-input v-model="itemForm.name" placeholder="请输入物品名称" />
</el-form-item>
<el-form-item label="序列号" prop="serial_number">
<el-input v-model="itemForm.serial_number" placeholder="请输入序列号" />
</el-form-item>
<el-form-item label="类别" prop="category">
<el-select v-model="itemForm.category" placeholder="请选择类别" style="width: 100%">
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.name"
/>
</el-select>
</el-form-item>
<el-form-item label="位置" prop="location">
<el-input v-model="itemForm.location" placeholder="请输入存放位置" />
</el-form-item>
<el-form-item label="所有者" prop="owner">
<el-input v-model="itemForm.owner" placeholder="请输入所有者姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购买日期" prop="purchase_date">
<el-date-picker
v-model="itemForm.purchase_date"
type="date"
placeholder="请选择购买日期"
style="width: 100%"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="价值" prop="value">
<el-input-number
v-model="itemForm.value"
:precision="2"
:min="0"
placeholder="请输入物品价值"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="itemForm.status" placeholder="请选择状态" style="width: 100%">
<el-option label="可用" value="available" />
<el-option label="维护中" value="maintenance" />
<el-option label="损坏" value="damaged" />
<el-option label="已弃用" value="abandoned" />
<el-option label="禁止借用" value="prohibited" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述" prop="description">
<el-input
type="textarea"
v-model="itemForm.description"
:rows="4"
placeholder="请输入物品描述"
/>
</el-form-item>
<el-form-item>
<el-button @click="resetForm">重置</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">
创建物品
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script>
import { itemService, categoryService } from '@/services/api'
import { ElMessage } from 'element-plus'
import AppHeader from '../components/AppHeader.vue'
import { Plus, ArrowLeft } from '@element-plus/icons-vue'
export default {
name: 'ItemCreate',
components: {
AppHeader,
Plus,
ArrowLeft
},
data() {
return {
itemForm: {
name: '',
description: '',
serial_number: '',
category: '',
status: 'available',
location: '',
owner: '',
purchase_date: null,
value: null
},
uploadFiles: [],
categories: [],
submitting: false,
rules: {
name: [
{ required: true, message: '请输入物品名称', trigger: 'blur' }
],
serial_number: [
{ required: true, message: '请输入序列号', trigger: 'blur' }
],
category: [
{ required: true, message: '请选择类别', trigger: 'change' }
]
}
}
},
async mounted() {
await this.loadCategories()
},
methods: {
async loadCategories() {
try {
const response = await categoryService.getAllCategories()
this.categories = response.data
} catch (error) {
console.error('加载类别失败:', error)
ElMessage.error('加载类别失败')
}
},
handleImageChange(file, fileList) {
this.uploadFiles = fileList
},
handleImageRemove(file, fileList) {
this.uploadFiles = fileList
},
async submitForm() {
// 首先验证图片是否上传
if (this.uploadFiles.length === 0) {
ElMessage.error('请至少上传一张物品图片')
return
}
try {
// 验证表单
await this.$refs.itemFormRef.validate()
this.submitting = true
// 创建FormData对象
const formData = new FormData()
// 添加物品基本信息
Object.keys(this.itemForm).forEach(key => {
const value = this.itemForm[key]
if (value !== null && value !== '' && value !== undefined) {
formData.append(key, value)
}
})
// 添加图片文件
this.uploadFiles.forEach((fileItem, index) => {
// 确保获取正确的文件对象
const file = fileItem.raw || fileItem
if (file instanceof File) {
formData.append('images', file)
formData.append(`image_descriptions[${index}]`, '')
} else {
console.error('Invalid file object:', fileItem)
}
})
// 调试信息
console.log('上传文件数量:', this.uploadFiles.length)
for (let [key, value] of formData.entries()) {
console.log('FormData:', key, value)
}
const response = await itemService.createItem(formData)
ElMessage.success('物品创建成功')
// 跳转到物品详情页
this.$router.push(`/items/${response.data.id}`)
} catch (error) {
console.error('创建物品失败:', error)
// 更详细的错误处理
let errorMessage = '创建物品失败'
if (error.response && error.response.data) {
if (error.response.data.error) {
errorMessage = error.response.data.error
} else if (error.response.data.message) {
errorMessage = error.response.data.message
} else if (typeof error.response.data === 'string') {
errorMessage = error.response.data
}
} else if (error.message) {
errorMessage = error.message
}
ElMessage.error(errorMessage)
} finally {
this.submitting = false
}
},
resetForm() {
this.$refs.itemFormRef.resetFields()
this.uploadFiles = []
this.$refs.uploadRef.clearFiles()
}
}
}
</script>
<style scoped>
.item-create {
padding: 20px;
}
.create-header {
margin-bottom: 20px;
}
.create-header h2 {
margin: 10px 0;
color: #303133;
}
.el-form-item {
margin-bottom: 22px;
}
:deep(.el-upload--picture-card) {
width: 120px;
height: 120px;
}
:deep(.el-upload-list--picture-card .el-upload-list__item) {
width: 120px;
height: 120px;
}
</style>
+287 -4
View File
@@ -12,13 +12,63 @@
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>物品详情</span> <span>物品详情</span>
<div>
<el-button @click="showImageUpload = true" style="margin-right: 10px;">
<el-icon><Picture /></el-icon>
上传图片
</el-button>
<el-button type="primary" @click="editMode = !editMode"> <el-button type="primary" @click="editMode = !editMode">
{{ editMode ? '取消编辑' : '编辑' }} {{ editMode ? '取消编辑' : '编辑' }}
</el-button> </el-button>
</div> </div>
</div>
</template> </template>
<div v-if="item"> <div v-if="item">
<!-- 物品图片展示 -->
<el-card class="image-section" style="margin-bottom: 20px;">
<template #header>
<span>物品图片</span>
</template>
<div v-if="item.images && item.images.length > 0" class="image-gallery">
<div v-for="image in item.images" :key="image.id" class="image-item">
<div class="image-container">
<el-image
:src="image.image_url"
:preview-src-list="imagePreviewList"
fit="cover"
class="item-image"
/>
<!-- 图片操作按钮 -->
<div class="image-actions">
<el-button
v-if="!image.is_primary"
size="small"
type="primary"
@click="setPrimaryImage(image.id)"
class="action-btn"
>
设为主图
</el-button>
<el-button
size="small"
type="danger"
@click="deleteImage(image.id)"
class="action-btn"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<div class="image-info">
<el-tag v-if="image.is_primary" type="success" size="small">主图</el-tag>
<p v-if="image.description" class="image-desc">{{ image.description }}</p>
</div>
</div>
</div>
<el-empty v-else description="暂无图片" />
</el-card>
<el-form :model="editableItem" label-width="120px" :disabled="!editMode"> <el-form :model="editableItem" label-width="120px" :disabled="!editMode">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
@@ -103,6 +153,18 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="purpose" label="使用目的" /> <el-table-column prop="purpose" label="使用目的" />
<el-table-column label="图片" width="100">
<template #default="scope">
<el-button
v-if="scope.row.borrow_images?.length > 0 || scope.row.return_images?.length > 0"
size="small"
@click="showUsageImages(scope.row)"
>
<el-icon><Picture /></el-icon>
</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="is_returned" label="状态"> <el-table-column prop="is_returned" label="状态">
<template #default="scope"> <template #default="scope">
<el-tag :type="scope.row.is_returned ? 'success' : 'warning'"> <el-tag :type="scope.row.is_returned ? 'success' : 'warning'">
@@ -120,8 +182,37 @@
</div> </div>
</el-card> </el-card>
<!-- 物品图片上传对话框 -->
<el-dialog v-model="showImageUpload" title="上传物品图片" width="500px">
<el-upload
ref="uploadRef"
:auto-upload="false"
:multiple="true"
:limit="10"
accept="image/*"
list-type="picture-card"
:on-change="handleImageChange"
:on-remove="handleImageRemove"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div style="margin-top: 10px;">
<el-text type="info" size="small">
支持 JPGPNG 格式最多上传 10 张图片
</el-text>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="showImageUpload = false">取消</el-button>
<el-button type="primary" @click="uploadImages" :loading="uploading">
上传
</el-button>
</div>
</template>
</el-dialog>
<!-- 使用记录详情对话框 --> <!-- 使用记录详情对话框 -->
<el-dialog v-model="showUsageDialog" title="使用记录详情" width="600px"> <el-dialog v-model="showUsageDialog" title="使用记录详情" width="800px">
<div v-if="selectedUsage"> <div v-if="selectedUsage">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="使用者">{{ selectedUsage.user }}</el-descriptions-item> <el-descriptions-item label="使用者">{{ selectedUsage.user }}</el-descriptions-item>
@@ -132,11 +223,49 @@
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="使用前状况">{{ selectedUsage.condition_before || '无' }}</el-descriptions-item> <el-descriptions-item label="使用前状况">{{ selectedUsage.condition_before || '无' }}</el-descriptions-item>
<el-descriptions-item label="使用后状况">{{ selectedUsage.condition_after || '无' }}</el-descriptions-item> <el-descriptions-item label="使用后状况">{{ selectedUsage.condition_after || '无' }}</el-descriptions-item>
<el-descriptions-item label="使用目的">{{ selectedUsage.notes || '无' }}</el-descriptions-item> <el-descriptions-item label="使用目的">{{ selectedUsage.purpose || '无' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ selectedUsage.notes || '无' }}</el-descriptions-item> <el-descriptions-item label="备注" :span="2">{{ selectedUsage.notes || '无' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</div> </div>
</el-dialog> </el-dialog>
<!-- 使用记录图片对话框 -->
<el-dialog v-model="showUsageImagesDialog" title="使用记录图片" width="800px">
<div v-if="selectedUsageImages">
<div v-if="selectedUsageImages.borrow_images?.length > 0" class="usage-images-section">
<h4>借用时图片</h4>
<div class="image-gallery">
<el-image
v-for="image in selectedUsageImages.borrow_images"
:key="image.id"
:src="image.image_url"
:preview-src-list="borrowImagePreviewList"
fit="cover"
class="usage-image"
/>
</div>
</div>
<div v-if="selectedUsageImages.return_images?.length > 0" class="usage-images-section">
<h4>归还时图片</h4>
<div class="image-gallery">
<el-image
v-for="image in selectedUsageImages.return_images"
:key="image.id"
:src="image.image_url"
:preview-src-list="returnImagePreviewList"
fit="cover"
class="usage-image"
/>
</div>
</div>
<div v-if="(!selectedUsageImages.borrow_images || selectedUsageImages.borrow_images.length === 0) &&
(!selectedUsageImages.return_images || selectedUsageImages.return_images.length === 0)">
<el-empty description="暂无图片" />
</div>
</div>
</el-dialog>
</div> </div>
</template> </template>
@@ -144,12 +273,17 @@
import {itemService} from '@/services/api' import {itemService} from '@/services/api'
import {ElMessage} from 'element-plus' import {ElMessage} from 'element-plus'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
import { Picture, Plus, ArrowLeft, Delete } from '@element-plus/icons-vue'
import moment from 'moment' import moment from 'moment'
export default { export default {
name: 'ItemDetail', name: 'ItemDetail',
components: { components: {
AppHeader AppHeader,
Picture,
Plus,
ArrowLeft,
Delete
}, },
props: { props: {
id: { id: {
@@ -164,7 +298,23 @@ export default {
loading: false, loading: false,
editMode: false, editMode: false,
showUsageDialog: false, showUsageDialog: false,
selectedUsage: null selectedUsage: null,
showImageUpload: false,
uploading: false,
uploadFiles: [],
showUsageImagesDialog: false,
selectedUsageImages: null
}
},
computed: {
imagePreviewList() {
return this.item?.images?.map(img => img.image_url) || []
},
borrowImagePreviewList() {
return this.selectedUsageImages?.borrow_images?.map(img => img.image_url) || []
},
returnImagePreviewList() {
return this.selectedUsageImages?.return_images?.map(img => img.image_url) || []
} }
}, },
async mounted() { async mounted() {
@@ -199,6 +349,73 @@ export default {
this.selectedUsage = usage this.selectedUsage = usage
this.showUsageDialog = true this.showUsageDialog = true
}, },
showUsageImages(usage) {
this.selectedUsageImages = usage
this.showUsageImagesDialog = true
},
handleImageChange(file, fileList) {
this.uploadFiles = fileList
},
handleImageRemove(file, fileList) {
this.uploadFiles = fileList
},
async uploadImages() {
if (this.uploadFiles.length === 0) {
ElMessage.warning('请选择要上传的图片')
return
}
this.uploading = true
try {
const formData = new FormData()
this.uploadFiles.forEach((file, index) => {
formData.append('images', file.raw)
formData.append(`image_descriptions[${index}]`, '')
})
await itemService.uploadItemImages(this.id, formData)
ElMessage.success('图片上传成功')
this.showImageUpload = false
this.uploadFiles = []
this.$refs.uploadRef.clearFiles()
await this.loadItem()
} catch (error) {
console.error('图片上传失败:', error)
ElMessage.error('图片上传失败')
} finally {
this.uploading = false
}
},
async setPrimaryImage(imageId) {
try {
await itemService.setPrimaryImage(this.id, imageId)
ElMessage.success('主图设置成功')
await this.loadItem()
} catch (error) {
console.error('设置主图失败:', error)
ElMessage.error('设置主图失败')
}
},
async deleteImage(imageId) {
this.$confirm('确定删除这张图片吗?', '确认删除', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
await itemService.deleteItemImage(this.id, imageId)
ElMessage.success('图片删除成功')
await this.loadItem()
} catch (error) {
console.error('删除图片失败:', error)
ElMessage.error('删除图片失败')
}
})
.catch(() => {
// 取消删除
})
},
formatDate(dateString) { formatDate(dateString) {
return moment(dateString).format('YYYY-MM-DD HH:mm:ss') return moment(dateString).format('YYYY-MM-DD HH:mm:ss')
} }
@@ -216,4 +433,70 @@ export default {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.image-section {
margin-bottom: 20px;
}
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
padding: 10px 0;
}
.image-item {
text-align: center;
}
.item-image {
width: 200px;
height: 150px;
border-radius: 8px;
cursor: pointer;
}
.usage-image {
width: 150px;
height: 120px;
border-radius: 8px;
margin: 5px;
cursor: pointer;
}
.image-info {
margin-top: 8px;
}
.image-desc {
font-size: 12px;
color: #666;
margin: 4px 0 0 0;
}
.usage-images-section {
margin-bottom: 20px;
}
.usage-images-section h4 {
margin-bottom: 10px;
color: #409eff;
}
.image-container {
position: relative;
}
.image-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 5px;
}
.action-btn {
padding: 0 5px;
font-size: 12px;
}
</style> </style>
+197 -218
View File
@@ -2,7 +2,7 @@
<AppHeader /> <AppHeader />
<div class="item-list"> <div class="item-list">
<div class="toolbar"> <div class="toolbar">
<el-button type="primary" @click="showAddDialog = true"> <el-button type="primary" @click="$router.push('/items/create')">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
添加物品 添加物品
</el-button> </el-button>
@@ -31,6 +31,29 @@
</div> </div>
<el-table :data="filteredItems" style="width: 100%" v-loading="loading"> <el-table :data="filteredItems" style="width: 100%" v-loading="loading">
<el-table-column label="图片" width="80">
<template #default="scope">
<el-image
v-if="scope.row.primary_image"
:src="scope.row.primary_image"
fit="cover"
style="width: 50px; height: 50px; border-radius: 4px; cursor: pointer;"
:preview-src-list="[scope.row.primary_image]"
preview-teleported
/>
<el-image
v-else-if="scope.row.images && scope.row.images.length > 0"
:src="scope.row.images[0].image_url"
fit="cover"
style="width: 50px; height: 50px; border-radius: 4px; cursor: pointer;"
:preview-src-list="[scope.row.images[0].image_url]"
preview-teleported
/>
<div v-else class="no-image-placeholder">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-table-column>
<el-table-column prop="name" label="物品名称" /> <el-table-column prop="name" label="物品名称" />
<el-table-column prop="serial_number" label="序列号" /> <el-table-column prop="serial_number" label="序列号" />
<el-table-column prop="category" label="类别" /> <el-table-column prop="category" label="类别" />
@@ -79,171 +102,146 @@
> >
归还 归还
</el-button> </el-button>
<el-button size="small" @click="editItem(scope.row)">
编辑
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<!-- 添加物品对话框 -->
<el-dialog v-model="showAddDialog" title="添加物品" width="600px">
<el-form :model="newItem" label-width="100px" :rules="itemRules" ref="itemForm">
<el-form-item label="物品名称" prop="name">
<el-input v-model="newItem.name" />
</el-form-item>
<el-form-item label="序列号" prop="serial_number">
<el-input v-model="newItem.serial_number" />
</el-form-item>
<el-form-item label="类别" prop="category">
<el-input v-model="newItem.category" />
</el-form-item>
<el-form-item label="描述">
<el-input type="textarea" v-model="newItem.description" />
</el-form-item>
<el-form-item label="位置">
<el-input v-model="newItem.location" />
</el-form-item>
<el-form-item label="价值">
<el-input-number v-model="newItem.value" :precision="2" :min="0" />
</el-form-item>
<el-form-item label="购买日期">
<el-date-picker v-model="newItem.purchase_date" type="date" />
</el-form-item>
<el-form-item label="所有者">
<el-input v-model="newItem.owner" placeholder="请输入所有者姓名" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="saveItem">保存</el-button>
</template>
</el-dialog>
<!-- 借用物品对话框 --> <!-- 借用物品对话框 -->
<el-dialog v-model="showBorrowDialog" title="借用物品" width="500px"> <el-dialog v-model="showBorrowDialog" title="借用物品" width="600px">
<el-form :model="borrowForm" :rules="borrowRules" ref="borrowForm" label-width="100px"> <div v-if="selectedItem">
<el-form-item label="使用者" prop="user_name"> <h4>{{ selectedItem.name }} ({{ selectedItem.serial_number }})</h4>
<el-input v-model="borrowForm.user_name" placeholder="请输入使用者姓名" /> <el-form :model="borrowForm" :rules="borrowRules" ref="borrowFormRef" label-width="120px">
<el-form-item label="借用人姓名" prop="user_name">
<el-input v-model="borrowForm.user_name" placeholder="请输入借用人姓名" />
</el-form-item> </el-form-item>
<el-form-item label="联系方式" prop="user_contact"> <el-form-item label="联系方式" prop="user_contact">
<el-input v-model="borrowForm.user_contact" placeholder="请输入联系方式(手机号/QQ/微信/邮箱等)" /> <el-input v-model="borrowForm.user_contact" placeholder="请输入联系方式" />
</el-form-item> </el-form-item>
<el-form-item label="使用目的"> <el-form-item label="使用目的" prop="purpose">
<el-input v-model="borrowForm.purpose" /> <el-input v-model="borrowForm.purpose" placeholder="请输入使用目的" />
</el-form-item>
<el-form-item label="预计归还时间">
<el-date-picker
v-model="borrowForm.expected_return_time"
type="datetime"
placeholder="请选择预计归还时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item> </el-form-item>
<el-form-item label="使用前状况"> <el-form-item label="使用前状况">
<el-input v-model="borrowForm.condition_before" /> <el-input v-model="borrowForm.condition_before" type="textarea" :rows="2" placeholder="请描述物品当前状况" />
</el-form-item>
<el-form-item label="借用时图片">
<el-upload
ref="borrowUploadRef"
:auto-upload="false"
:multiple="true"
:limit="5"
accept="image/*"
list-type="picture-card"
:on-change="handleBorrowImageChange"
:on-remove="handleBorrowImageRemove"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div style="margin-top: 5px;">
<el-text type="info" size="small">
可上传借用时的物品状态图片可选最多5张
</el-text>
</div>
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input type="textarea" v-model="borrowForm.notes" /> <el-input v-model="borrowForm.notes" type="textarea" :rows="3" placeholder="其他备注信息" />
</el-form-item> </el-form-item>
</el-form> </el-form>
</div>
<template #footer> <template #footer>
<div class="dialog-footer">
<el-button @click="showBorrowDialog = false">取消</el-button> <el-button @click="showBorrowDialog = false">取消</el-button>
<el-button type="primary" @click="confirmBorrow">确认借用</el-button> <el-button type="primary" @click="confirmBorrow" :loading="borrowing">
确认借用
</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
<!-- 归还物品对话框 --> <!-- 归还物品对话框 -->
<el-dialog v-model="showReturnDialog" title="归还物品" width="500px"> <el-dialog v-model="showReturnDialog" title="归还物品" width="600px">
<el-form :model="returnForm" :rules="returnRules" ref="returnForm" label-width="100px"> <div v-if="selectedItem">
<el-form-item label="使用后状况" prop="condition_after"> <h4>{{ selectedItem.name }} ({{ selectedItem.serial_number }})</h4>
<el-input v-model="returnForm.condition_after" placeholder="请描述物品使用后的状况" /> <el-form :model="returnForm" ref="returnFormRef" label-width="120px">
<el-form-item label="使用后状况">
<el-input v-model="returnForm.condition_after" type="textarea" :rows="2" placeholder="请描述物品归还时的状况" />
</el-form-item>
<el-form-item label="归还时图片">
<el-upload
ref="returnUploadRef"
:auto-upload="false"
:multiple="true"
:limit="5"
accept="image/*"
list-type="picture-card"
:on-change="handleReturnImageChange"
:on-remove="handleReturnImageRemove"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div style="margin-top: 5px;">
<el-text type="info" size="small">
可上传归还时的物品状态图片可选最多5张
</el-text>
</div>
</el-form-item> </el-form-item>
<el-form-item label="归还备注"> <el-form-item label="归还备注">
<el-input type="textarea" v-model="returnForm.return_notes" placeholder="可填写其他归还说明(可选)" /> <el-input v-model="returnForm.return_notes" type="textarea" :rows="3" placeholder="归还时的备注信息" />
</el-form-item> </el-form-item>
</el-form> </el-form>
</div>
<template #footer> <template #footer>
<div class="dialog-footer">
<el-button @click="showReturnDialog = false">取消</el-button> <el-button @click="showReturnDialog = false">取消</el-button>
<el-button type="primary" @click="confirmReturn">确认归还</el-button> <el-button type="primary" @click="confirmReturn" :loading="returning">
</template> 确认归还
</el-dialog> </el-button>
</div>
<!-- 编辑物品对话框 -->
<el-dialog v-model="showEditDialog" title="编辑物品" width="600px">
<el-form :model="editForm" label-width="100px" :rules="itemRules" ref="editForm">
<el-form-item label="物品名称" prop="name">
<el-input v-model="editForm.name" />
</el-form-item>
<el-form-item label="序列号" prop="serial_number">
<el-input v-model="editForm.serial_number" />
</el-form-item>
<el-form-item label="类别" prop="category">
<el-input v-model="editForm.category" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="editForm.status">
<el-option label="可用" value="available" />
<el-option label="使用中" value="in_use" />
<el-option label="维护中" value="maintenance" />
<el-option label="损坏" value="damaged" />
<el-option label="丢失" value="lost" />
<el-option label="已弃用" value="abandoned" />
<el-option label="禁止借用" value="prohibited" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input type="textarea" v-model="editForm.description" />
</el-form-item>
<el-form-item label="位置">
<el-input v-model="editForm.location" />
</el-form-item>
<el-form-item label="价值">
<el-input-number v-model="editForm.value" :precision="2" :min="0" />
</el-form-item>
<el-form-item label="购买日期">
<el-date-picker v-model="editForm.purchase_date" type="date" />
</el-form-item>
<el-form-item label="所有者" prop="owner_id">
<el-input v-model="editForm.owner" placeholder="请输入所有者姓名" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">取消</el-button>
<el-button type="primary" @click="updateItem">保存修改</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script> <script>
import {itemService, userService} from '../services/api' import {itemService} from '@/services/api'
import {ElMessage} from 'element-plus' import {ElMessage} from 'element-plus'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
import { Plus, Search, Picture } from '@element-plus/icons-vue'
export default { export default {
name: 'ItemList', name: 'ItemList',
components: { components: {
AppHeader AppHeader,
Plus,
Search,
Picture
}, },
data() { data() {
return { return {
items: [], items: [],
users: [],
loading: false, loading: false,
searchKeyword: '', searchKeyword: '',
statusFilter: '', statusFilter: '',
showAddDialog: false,
showBorrowDialog: false, showBorrowDialog: false,
showReturnDialog: false, showReturnDialog: false,
showEditDialog: false, selectedItem: null,
currentItem: null, borrowing: false,
newItem: { returning: false,
name: '', borrowFiles: [],
serial_number: '', returnFiles: [],
category: '',
description: '',
location: '',
value: null,
purchase_date: null,
owner: ''
},
borrowForm: { borrowForm: {
user_name: '', user_name: '',
user_contact: '', user_contact: '',
purpose: '', purpose: '',
expected_return_time: null,
condition_before: '', condition_before: '',
notes: '' notes: ''
}, },
@@ -251,29 +249,16 @@ export default {
condition_after: '', condition_after: '',
return_notes: '' return_notes: ''
}, },
editForm: {
id: null,
name: '',
serial_number: '',
category: '',
status: '',
description: '',
location: '',
value: null,
purchase_date: null,
owner: ''
},
itemRules: {
name: [{ required: true, message: '请输入物品名称', trigger: 'blur' }],
serial_number: [{ required: true, message: '请输入序列号', trigger: 'blur' }],
category: [{ required: true, message: '请输入类别', trigger: 'blur' }]
},
returnRules: {
condition_after: [{ required: true, message: '请输入使用后状况', trigger: 'blur' }]
},
borrowRules: { borrowRules: {
user_name: [{ required: true, message: '请输入使用者姓名', trigger: 'blur' }], user_name: [
user_contact: [{ required: true, message: '请输入联系方式', trigger: 'blur' }] { required: true, message: '请输入借用人姓名', trigger: 'blur' }
],
user_contact: [
{ required: true, message: '请输入联系方式', trigger: 'blur' }
],
purpose: [
{ required: true, message: '请输入使用目的', trigger: 'blur' }
]
} }
} }
}, },
@@ -297,7 +282,6 @@ export default {
}, },
async mounted() { async mounted() {
await this.loadItems() await this.loadItems()
await this.loadUsers()
}, },
methods: { methods: {
async loadItems() { async loadItems() {
@@ -312,14 +296,6 @@ export default {
this.loading = false this.loading = false
} }
}, },
async loadUsers() {
try {
const response = await userService.getAllUsers()
this.users = response.data
} catch (error) {
console.error('加载用户列表失败:', error)
}
},
handleSearch() { handleSearch() {
// 搜索逻辑在computed中处理 // 搜索逻辑在computed中处理
}, },
@@ -329,116 +305,95 @@ export default {
viewItem(id) { viewItem(id) {
this.$router.push(`/items/${id}`) this.$router.push(`/items/${id}`)
}, },
editItem(item) {
this.currentItem = item
this.editForm = { ...item }
this.showEditDialog = true
},
borrowItem(item) { borrowItem(item) {
this.currentItem = item this.selectedItem = item
this.borrowForm = { this.borrowForm = {
user_name: '', user_name: '',
user_contact: '', user_contact: '',
purpose: '', purpose: '',
expected_return_time: null,
condition_before: '', condition_before: '',
notes: '' notes: ''
} }
this.showBorrowDialog = true this.showBorrowDialog = true
}, },
returnItem(item) { returnItem(item) {
this.currentItem = item this.selectedItem = item
this.returnForm = { this.returnForm = {
condition_after: '', condition_after: '',
return_notes: '' return_notes: ''
} }
this.showReturnDialog = true this.showReturnDialog = true
}, },
async saveItem() {
try {
await this.$refs.itemForm.validate()
const itemData = { ...this.newItem }
// 格式化购买日期
if (itemData.purchase_date) {
const date = new Date(itemData.purchase_date)
itemData.purchase_date = date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0')
}
await itemService.createItem(itemData)
ElMessage.success('物品添加成功')
this.showAddDialog = false
this.newItem = {
name: '',
serial_number: '',
category: '',
description: '',
location: '',
value: null,
purchase_date: null,
owner: ''
}
await this.loadItems()
} catch (error) {
console.error('添加物品失败:', error)
ElMessage.error('添加物品失败')
}
},
async confirmBorrow() { async confirmBorrow() {
try { try {
await this.$refs.borrowForm.validate() await this.$refs.borrowFormRef.validate()
await itemService.borrowItem(this.currentItem.id, this.borrowForm) this.borrowing = true
// 创建FormData对象
const formData = new FormData()
// 添加表单数据
Object.keys(this.borrowForm).forEach(key => {
if (this.borrowForm[key] !== null && this.borrowForm[key] !== '') {
formData.append(key, this.borrowForm[key])
}
})
// 添加借用时图片
this.borrowFiles.forEach((file, index) => {
formData.append('borrow_images', file)
formData.append(`borrow_image_descriptions[${index}]`, '')
})
await itemService.borrowItem(this.selectedItem.id, formData)
ElMessage.success('借用成功') ElMessage.success('借用成功')
this.showBorrowDialog = false this.showBorrowDialog = false
this.borrowFiles = []
this.$refs.borrowUploadRef?.clearFiles()
await this.loadItems() await this.loadItems()
} catch (error) { } catch (error) {
if (error.message && error.message.includes('validation')) { if (error.message && error.message.includes('validation')) {
return return
} }
console.error('借用失败:', error) console.error('借用失败:', error)
ElMessage.error('借用失败') ElMessage.error(error.response?.data?.error || '借用失败')
} finally {
this.borrowing = false
} }
}, },
async confirmReturn() { async confirmReturn() {
try { try {
await this.$refs.returnForm.validate() this.returning = true
await itemService.returnItem(this.currentItem.id, this.returnForm) // 创建FormData对象
const formData = new FormData()
// 添加表单数据
Object.keys(this.returnForm).forEach(key => {
if (this.returnForm[key] !== null && this.returnForm[key] !== '') {
formData.append(key, this.returnForm[key])
}
})
// 添加归还时图片
this.returnFiles.forEach((file, index) => {
formData.append('return_images', file)
formData.append(`return_image_descriptions[${index}]`, '')
})
await itemService.returnItem(this.selectedItem.id, formData)
ElMessage.success('归还成功') ElMessage.success('归还成功')
this.showReturnDialog = false this.showReturnDialog = false
this.returnFiles = []
this.$refs.returnUploadRef?.clearFiles()
await this.loadItems() await this.loadItems()
} catch (error) { } catch (error) {
if (error.message && error.message.includes('validation')) {
return
}
console.error('归还失败:', error) console.error('归还失败:', error)
ElMessage.error('归还失败') ElMessage.error(error.response?.data?.error || '归还失败')
} } finally {
}, this.returning = false
async updateItem() {
try {
await this.$refs.editForm.validate()
const itemData = { ...this.editForm }
// 格式化购买日期
if (itemData.purchase_date) {
const date = new Date(itemData.purchase_date)
itemData.purchase_date = date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0')
}
await itemService.updateItem(itemData.id, itemData)
ElMessage.success('物品信息更新成功')
this.showEditDialog = false
await this.loadItems()
} catch (error) {
console.error('更新物品失败:', error)
ElMessage.error('更新物品失败')
} }
}, },
getStatusType(status) { getStatusType(status) {
@@ -464,6 +419,18 @@ export default {
'prohibited': '禁止借用' 'prohibited': '禁止借用'
} }
return textMap[status] || '未知' return textMap[status] || '未知'
},
handleBorrowImageChange(file, fileList) {
this.borrowFiles = fileList.map(file => file.raw)
},
handleBorrowImageRemove(file, fileList) {
this.borrowFiles = fileList.map(file => file.raw)
},
handleReturnImageChange(file, fileList) {
this.returnFiles = fileList.map(file => file.raw)
},
handleReturnImageRemove(file, fileList) {
this.returnFiles = fileList.map(file => file.raw)
} }
} }
} }
@@ -485,4 +452,16 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.no-image-placeholder {
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #dcdfe6;
border-radius: 4px;
background-color: #f5f7fa;
color: #909399;
}
</style> </style>