feat: add material categories and polish UI
This commit is contained in:
parent
4b001d23a4
commit
80a8f69edf
|
|
@ -0,0 +1,43 @@
|
|||
# Generated by Django 4.2.7 on 2026-03-10 07:49
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('authentication', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='user',
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='groups',
|
||||
field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='user_permissions',
|
||||
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='date_joined',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='username',
|
||||
field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username'),
|
||||
),
|
||||
]
|
||||
|
|
@ -33,6 +33,12 @@ class UserCreateSerializer(serializers.ModelSerializer):
|
|||
def validate(self, attrs):
|
||||
if attrs['password'] != attrs['password_confirm']:
|
||||
raise serializers.ValidationError({"password": "密码字段不匹配。"})
|
||||
role = attrs.get('role', 'user')
|
||||
factory = attrs.get('factory')
|
||||
if role == 'user' and not factory:
|
||||
raise serializers.ValidationError({"factory": "普通账号必须绑定所属工厂。"})
|
||||
if role == 'admin' and factory:
|
||||
raise serializers.ValidationError({"factory": "管理员账号不应绑定工厂。"})
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from rest_framework import generics, status
|
||||
from rest_framework import generics
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from .models import User
|
||||
from .serializers import UserSerializer, UserCreateSerializer, CustomTokenObtainPairSerializer
|
||||
|
||||
|
|
@ -12,16 +13,21 @@ class CustomTokenObtainPairView(TokenObtainPairView):
|
|||
自定义JWT令牌获取视图
|
||||
"""
|
||||
serializer_class = CustomTokenObtainPairSerializer
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
|
||||
class UserListView(generics.ListCreateAPIView):
|
||||
"""
|
||||
用户列表和创建视图
|
||||
"""
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.role == 'admin':
|
||||
return User.objects.all()
|
||||
return User.objects.filter(id=self.request.user.id)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.method == 'POST':
|
||||
return UserCreateSerializer
|
||||
|
|
@ -30,7 +36,7 @@ class UserListView(generics.ListCreateAPIView):
|
|||
def perform_create(self, serializer):
|
||||
# 只有管理员可以创建用户
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionError("只有管理员可以创建用户")
|
||||
raise PermissionDenied("只有管理员可以创建用户")
|
||||
serializer.save()
|
||||
|
||||
|
||||
|
|
@ -45,13 +51,20 @@ class UserDetailView(generics.RetrieveUpdateDestroyAPIView):
|
|||
def perform_update(self, serializer):
|
||||
# 普通用户只能修改自己的信息
|
||||
if self.request.user.role != 'admin' and self.request.user.id != self.get_object().id:
|
||||
raise PermissionError("无权修改其他用户信息")
|
||||
raise PermissionDenied("无权修改其他用户信息")
|
||||
|
||||
if self.request.user.role != 'admin':
|
||||
allowed_fields = {'first_name', 'last_name', 'email', 'phone'}
|
||||
for field in list(serializer.validated_data.keys()):
|
||||
if field not in allowed_fields:
|
||||
serializer.validated_data.pop(field)
|
||||
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
# 只有管理员可以删除用户
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionError("只有管理员可以删除用户")
|
||||
raise PermissionDenied("只有管理员可以删除用户")
|
||||
instance.delete()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from rest_framework import generics, status
|
||||
from rest_framework import generics
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from .models import Dictionary
|
||||
from .serializers import DictionarySerializer, DictionaryGroupSerializer
|
||||
from .serializers import DictionarySerializer
|
||||
|
||||
|
||||
class DictionaryListView(generics.ListCreateAPIView):
|
||||
|
|
@ -35,7 +35,7 @@ class DictionaryListView(generics.ListCreateAPIView):
|
|||
def perform_create(self, serializer):
|
||||
# 只有管理员可以创建字典
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionError("只有管理员可以创建字典")
|
||||
raise PermissionDenied("只有管理员可以创建字典")
|
||||
serializer.save()
|
||||
|
||||
|
||||
|
|
@ -50,13 +50,13 @@ class DictionaryDetailView(generics.RetrieveUpdateDestroyAPIView):
|
|||
def perform_update(self, serializer):
|
||||
# 只有管理员可以更新字典
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionError("只有管理员可以更新字典")
|
||||
raise PermissionDenied("只有管理员可以更新字典")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
# 只有管理员可以删除字典
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionError("只有管理员可以删除字典")
|
||||
raise PermissionDenied("只有管理员可以删除字典")
|
||||
instance.delete()
|
||||
|
||||
|
||||
|
|
@ -66,7 +66,6 @@ def dictionary_grouped(request):
|
|||
"""
|
||||
获取分组的数据字典
|
||||
"""
|
||||
# 获取所有字典类型
|
||||
dict_types = Dictionary.objects.values('type').distinct()
|
||||
|
||||
result = []
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from rest_framework import generics, status
|
||||
from rest_framework import generics
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from .models import Factory
|
||||
from .serializers import FactorySerializer, FactoryListSerializer
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ class FactoryListView(generics.ListCreateAPIView):
|
|||
def perform_create(self, serializer):
|
||||
# 只有管理员可以创建工厂
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionError("只有管理员可以创建工厂")
|
||||
raise PermissionDenied("只有管理员可以创建工厂")
|
||||
serializer.save()
|
||||
|
||||
|
||||
|
|
@ -37,13 +38,13 @@ class FactoryDetailView(generics.RetrieveUpdateDestroyAPIView):
|
|||
# 普通用户只能修改自己所属工厂的信息
|
||||
if (self.request.user.role != 'admin' and
|
||||
self.request.user.factory_id != self.get_object().id):
|
||||
raise PermissionError("无权修改其他工厂信息")
|
||||
raise PermissionDenied("无权修改其他工厂信息")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
# 只有管理员可以删除工厂
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionError("只有管理员可以删除工厂")
|
||||
raise PermissionDenied("只有管理员可以删除工厂")
|
||||
instance.delete()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 4.2.7 on 2026-03-10 07:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('material', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='material',
|
||||
name='advantage',
|
||||
field=models.JSONField(blank=True, default=list, null=True, verbose_name='竞争优势'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='material',
|
||||
name='application_scene',
|
||||
field=models.JSONField(blank=True, default=list, null=True, verbose_name='应用场景'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Generated by Django 4.2.7 on 2026-03-10 08:15
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('material', '0002_alter_material_advantage_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MaterialCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='分类名称')),
|
||||
('value', models.CharField(max_length=255, unique=True, verbose_name='分类值')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '材料分类',
|
||||
'verbose_name_plural': '材料分类',
|
||||
'db_table': 'material_category',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MaterialSubcategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='子分类名称')),
|
||||
('value', models.CharField(max_length=255, unique=True, verbose_name='子分类值')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='material.materialcategory', verbose_name='所属分类')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '材料子分类',
|
||||
'verbose_name_plural': '材料子分类',
|
||||
'db_table': 'material_subcategory',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -47,10 +47,10 @@ class Material(models.Model):
|
|||
material_subcategory = models.CharField(max_length=255, verbose_name='材料子分类')
|
||||
spec = models.CharField(max_length=255, blank=True, null=True, verbose_name='规格型号')
|
||||
standard = models.CharField(max_length=255, blank=True, null=True, verbose_name='符合标准')
|
||||
application_scene = models.CharField(max_length=20, choices=APPLICATION_SCENE_CHOICES, blank=True, null=True, verbose_name='应用场景')
|
||||
application_scene = models.JSONField(default=list, blank=True, null=True, verbose_name='应用场景')
|
||||
application_desc = models.TextField(blank=True, null=True, verbose_name='应用场景说明')
|
||||
replace_type = models.CharField(max_length=20, choices=REPLACE_TYPE_CHOICES, blank=True, null=True, verbose_name='替代材料类型')
|
||||
advantage = models.CharField(max_length=20, choices=ADVANTAGE_CHOICES, blank=True, null=True, verbose_name='竞争优势')
|
||||
advantage = models.JSONField(default=list, blank=True, null=True, verbose_name='竞争优势')
|
||||
advantage_desc = models.TextField(blank=True, null=True, verbose_name='优势说明')
|
||||
cost_compare = models.DecimalField(max_digits=5, decimal_places=2, blank=True, null=True, verbose_name='成本对比百分数')
|
||||
cost_desc = models.TextField(blank=True, null=True, verbose_name='成本说明')
|
||||
|
|
@ -76,3 +76,40 @@ class Material(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class MaterialCategory(models.Model):
|
||||
"""
|
||||
材料分类
|
||||
"""
|
||||
name = models.CharField(max_length=255, verbose_name='分类名称')
|
||||
value = models.CharField(max_length=255, unique=True, verbose_name='分类值')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '材料分类'
|
||||
verbose_name_plural = '材料分类'
|
||||
db_table = 'material_category'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class MaterialSubcategory(models.Model):
|
||||
"""
|
||||
材料子分类
|
||||
"""
|
||||
category = models.ForeignKey(MaterialCategory, on_delete=models.CASCADE, related_name='subcategories', verbose_name='所属分类')
|
||||
name = models.CharField(max_length=255, verbose_name='子分类名称')
|
||||
value = models.CharField(max_length=255, unique=True, verbose_name='子分类值')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '材料子分类'
|
||||
verbose_name_plural = '材料子分类'
|
||||
db_table = 'material_subcategory'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import json
|
||||
from rest_framework import serializers
|
||||
from .models import Material
|
||||
from .models import Material, MaterialCategory, MaterialSubcategory
|
||||
|
||||
|
||||
class JSONListField(serializers.ListField):
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
data = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
data = [data]
|
||||
return super().to_internal_value(data)
|
||||
|
||||
|
||||
class MaterialSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -10,8 +21,18 @@ class MaterialSerializer(serializers.ModelSerializer):
|
|||
factory_short_name = serializers.CharField(source='factory.factory_short_name', read_only=True)
|
||||
major_category_display = serializers.CharField(source='get_major_category_display', read_only=True)
|
||||
replace_type_display = serializers.CharField(source='get_replace_type_display', read_only=True)
|
||||
advantage_display = serializers.CharField(source='get_advantage_display', read_only=True)
|
||||
application_scene_display = serializers.CharField(source='get_application_scene_display', read_only=True)
|
||||
application_scene = JSONListField(
|
||||
child=serializers.ChoiceField(choices=Material.APPLICATION_SCENE_CHOICES),
|
||||
required=False,
|
||||
allow_empty=True
|
||||
)
|
||||
advantage = JSONListField(
|
||||
child=serializers.ChoiceField(choices=Material.ADVANTAGE_CHOICES),
|
||||
required=False,
|
||||
allow_empty=True
|
||||
)
|
||||
advantage_display = serializers.SerializerMethodField()
|
||||
application_scene_display = serializers.SerializerMethodField()
|
||||
brochure_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
|
|
@ -37,6 +58,20 @@ class MaterialSerializer(serializers.ModelSerializer):
|
|||
return request.build_absolute_uri(obj.brochure.url)
|
||||
return None
|
||||
|
||||
def _display_list(self, codes, choices):
|
||||
mapping = dict(choices)
|
||||
if not codes:
|
||||
return []
|
||||
if isinstance(codes, str):
|
||||
codes = [codes]
|
||||
return [mapping.get(code, code) for code in codes]
|
||||
|
||||
def get_application_scene_display(self, obj):
|
||||
return self._display_list(obj.application_scene, Material.APPLICATION_SCENE_CHOICES)
|
||||
|
||||
def get_advantage_display(self, obj):
|
||||
return self._display_list(obj.advantage, Material.ADVANTAGE_CHOICES)
|
||||
|
||||
|
||||
class MaterialListSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
|
@ -52,3 +87,30 @@ class MaterialListSerializer(serializers.ModelSerializer):
|
|||
fields = ['id', 'name', 'major_category', 'major_category_display',
|
||||
'material_category', 'material_subcategory', 'factory',
|
||||
'factory_name', 'factory_short_name', 'status', 'status_display']
|
||||
|
||||
|
||||
class MaterialCategorySerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
材料分类序列化器
|
||||
"""
|
||||
subcategory_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MaterialCategory
|
||||
fields = ['id', 'name', 'value', 'created_at', 'updated_at', 'subcategory_count']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'subcategory_count']
|
||||
|
||||
def get_subcategory_count(self, obj):
|
||||
return obj.subcategories.count()
|
||||
|
||||
|
||||
class MaterialSubcategorySerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
材料子分类序列化器
|
||||
"""
|
||||
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = MaterialSubcategory
|
||||
fields = ['id', 'category', 'category_name', 'name', 'value', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'category_name']
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import MaterialViewSet
|
||||
from .views import MaterialViewSet, MaterialCategoryViewSet, MaterialSubcategoryViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'', MaterialViewSet, basename='material')
|
||||
|
||||
urlpatterns = [
|
||||
path('categories/', MaterialCategoryViewSet.as_view({'get': 'list', 'post': 'create'}), name='material-category-list'),
|
||||
path('categories/<int:pk>/', MaterialCategoryViewSet.as_view({'get': 'retrieve', 'put': 'update', 'patch': 'partial_update', 'delete': 'destroy'}), name='material-category-detail'),
|
||||
path('subcategories/', MaterialSubcategoryViewSet.as_view({'get': 'list', 'post': 'create'}), name='material-subcategory-list'),
|
||||
path('subcategories/<int:pk>/', MaterialSubcategoryViewSet.as_view({'get': 'retrieve', 'put': 'update', 'patch': 'partial_update', 'delete': 'destroy'}), name='material-subcategory-detail'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from rest_framework import generics, status
|
||||
from rest_framework.decorators import api_view, permission_classes, action
|
||||
from rest_framework.decorators import api_view, action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from .models import Material
|
||||
from .serializers import MaterialSerializer, MaterialListSerializer
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from .models import Material, MaterialCategory, MaterialSubcategory
|
||||
from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer
|
||||
|
||||
|
||||
class MaterialViewSet(ModelViewSet):
|
||||
|
|
@ -28,6 +29,11 @@ class MaterialViewSet(ModelViewSet):
|
|||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
# 支持按名称搜索
|
||||
name = self.request.query_params.get('name')
|
||||
if name:
|
||||
queryset = queryset.filter(name__icontains=name)
|
||||
|
||||
# 支持按工厂过滤
|
||||
factory_id = self.request.query_params.get('factory_id')
|
||||
if factory_id:
|
||||
|
|
@ -38,11 +44,26 @@ class MaterialViewSet(ModelViewSet):
|
|||
if major_category:
|
||||
queryset = queryset.filter(major_category=major_category)
|
||||
|
||||
# 支持按材料分类过滤
|
||||
material_category = self.request.query_params.get('material_category')
|
||||
if material_category:
|
||||
queryset = queryset.filter(material_category=material_category)
|
||||
|
||||
# 支持按材料子类过滤
|
||||
material_subcategory = self.request.query_params.get('material_subcategory')
|
||||
if material_subcategory:
|
||||
queryset = queryset.filter(material_subcategory=material_subcategory)
|
||||
|
||||
# 支持按应用场景过滤 (JSONField contains)
|
||||
application_scene = self.request.query_params.get('application_scene')
|
||||
if application_scene:
|
||||
queryset = queryset.filter(application_scene__contains=[application_scene])
|
||||
|
||||
# 支持按竞争优势过滤 (JSONField contains)
|
||||
advantage = self.request.query_params.get('advantage')
|
||||
if advantage:
|
||||
queryset = queryset.filter(advantage__contains=[advantage])
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
|
|
@ -70,7 +91,7 @@ class MaterialViewSet(ModelViewSet):
|
|||
# 普通用户只能更新自己工厂的材料
|
||||
if (self.request.user.role != 'admin' and
|
||||
self.request.user.factory_id != self.get_object().factory_id):
|
||||
raise PermissionError("无权修改其他工厂的材料")
|
||||
raise PermissionDenied("无权修改其他工厂的材料")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
|
|
@ -80,7 +101,7 @@ class MaterialViewSet(ModelViewSet):
|
|||
# 普通用户只能删除自己工厂的材料
|
||||
if (self.request.user.role != 'admin' and
|
||||
self.request.user.factory_id != instance.factory_id):
|
||||
raise PermissionError("无权删除其他工厂的材料")
|
||||
raise PermissionDenied("无权删除其他工厂的材料")
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
|
|
@ -153,3 +174,72 @@ class MaterialViewSet(ModelViewSet):
|
|||
material.status = 'draft'
|
||||
material.save()
|
||||
return Response({"status": "审核拒绝"})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def choices(self, request):
|
||||
"""
|
||||
材料字段枚举
|
||||
"""
|
||||
return Response({
|
||||
'major_category': Material.MAJOR_CATEGORY_CHOICES,
|
||||
'replace_type': Material.REPLACE_TYPE_CHOICES,
|
||||
'advantage': Material.ADVANTAGE_CHOICES,
|
||||
'application_scene': Material.APPLICATION_SCENE_CHOICES,
|
||||
'star_level': Material.STAR_LEVEL_CHOICES,
|
||||
'status': Material.STATUS_CHOICES,
|
||||
})
|
||||
|
||||
|
||||
class MaterialCategoryViewSet(ModelViewSet):
|
||||
"""
|
||||
材料分类视图集
|
||||
"""
|
||||
queryset = MaterialCategory.objects.all().order_by('id')
|
||||
serializer_class = MaterialCategorySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionDenied("只有管理员可以创建材料分类")
|
||||
serializer.save()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionDenied("只有管理员可以更新材料分类")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionDenied("只有管理员可以删除材料分类")
|
||||
instance.delete()
|
||||
|
||||
|
||||
class MaterialSubcategoryViewSet(ModelViewSet):
|
||||
"""
|
||||
材料子分类视图集
|
||||
"""
|
||||
queryset = MaterialSubcategory.objects.select_related('category').all().order_by('id')
|
||||
serializer_class = MaterialSubcategorySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
category_id = self.request.query_params.get('category_id')
|
||||
if category_id:
|
||||
queryset = queryset.filter(category_id=category_id)
|
||||
return queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionDenied("只有管理员可以创建材料子分类")
|
||||
serializer.save()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionDenied("只有管理员可以更新材料子分类")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionDenied("只有管理员可以删除材料子分类")
|
||||
instance.delete()
|
||||
|
|
|
|||
|
|
@ -1,42 +1,50 @@
|
|||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count
|
||||
from apps.material.models import Material
|
||||
from apps.factory.models import Factory
|
||||
|
||||
|
||||
def _build_brochure_url(request, brochure_field):
|
||||
if brochure_field and request:
|
||||
return request.build_absolute_uri(brochure_field.url)
|
||||
return None
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def overview_statistics(request):
|
||||
"""
|
||||
数据总览统计
|
||||
"""
|
||||
# 只有管理员可以访问
|
||||
# 只有管理员可访问
|
||||
if request.user.role != 'admin':
|
||||
return Response({"detail": "无权访问"}, status=403)
|
||||
|
||||
approved_materials = Material.objects.filter(status='approved')
|
||||
|
||||
# 材料总数
|
||||
total_materials = Material.objects.count()
|
||||
total_materials = approved_materials.count()
|
||||
|
||||
# 材料种类(材料子类数量)
|
||||
total_material_categories = Material.objects.values('material_subcategory').distinct().count()
|
||||
total_material_categories = approved_materials.values('material_subcategory').distinct().count()
|
||||
|
||||
# 品牌数(工厂数)
|
||||
total_brands = Factory.objects.count()
|
||||
|
||||
# 按专业类别的材料数量分布
|
||||
major_category_stats = Material.objects.values('major_category').annotate(
|
||||
major_category_stats = approved_materials.values('major_category').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')
|
||||
|
||||
# 按材料子类的材料数量分布
|
||||
material_subcategory_stats = Material.objects.values('material_subcategory').annotate(
|
||||
material_subcategory_stats = approved_materials.values('material_subcategory').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')[:10] # 取前10个
|
||||
).order_by('-count')[:10]
|
||||
|
||||
# 按所属品牌的材料数量分布
|
||||
brand_stats = Material.objects.values('factory__factory_name').annotate(
|
||||
brand_stats = approved_materials.values('factory__factory_name').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')
|
||||
|
||||
|
|
@ -45,12 +53,16 @@ def overview_statistics(request):
|
|||
count=Count('id')
|
||||
).order_by('-count')
|
||||
|
||||
# 应用案例列表(有案例描述的材料)
|
||||
cases_list = Material.objects.filter(
|
||||
status='approved',
|
||||
cases__isnull=False,
|
||||
cases__gt=''
|
||||
).values('id', 'name', 'cases', 'factory__factory_name')[:10]
|
||||
# 应用案例列表
|
||||
cases_list = []
|
||||
for item in approved_materials.filter(cases__isnull=False).exclude(cases='')[:10]:
|
||||
cases_list.append({
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'cases': item.cases,
|
||||
'factory_name': item.factory.factory_name if item.factory else None,
|
||||
'brochure_url': _build_brochure_url(request, item.brochure),
|
||||
})
|
||||
|
||||
return Response({
|
||||
'total_materials': total_materials,
|
||||
|
|
@ -60,7 +72,7 @@ def overview_statistics(request):
|
|||
'material_subcategory_stats': list(material_subcategory_stats),
|
||||
'brand_stats': list(brand_stats),
|
||||
'region_stats': list(region_stats),
|
||||
'cases_list': list(cases_list),
|
||||
'cases_list': cases_list,
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -70,53 +82,79 @@ def material_statistics(request):
|
|||
"""
|
||||
材料库统计
|
||||
"""
|
||||
# 只有管理员可以访问
|
||||
if request.user.role != 'admin':
|
||||
return Response({"detail": "无权访问"}, status=403)
|
||||
|
||||
# 获取筛选条件
|
||||
material_subcategory = request.query_params.get('material_subcategory')
|
||||
|
||||
# 基础查询
|
||||
queryset = Material.objects.filter(status='approved')
|
||||
|
||||
# 按材料子类筛选
|
||||
if material_subcategory:
|
||||
queryset = queryset.filter(material_subcategory=material_subcategory)
|
||||
|
||||
# 材料星级对比(按材料子类)
|
||||
star_stats = {}
|
||||
for subcategory in queryset.values_list('material_subcategory', flat=True).distinct():
|
||||
materials = queryset.filter(material_subcategory=subcategory)
|
||||
star_stats[subcategory] = {
|
||||
'quality_level': list(materials.values('quality_level').annotate(count=Count('id'))),
|
||||
'durability_level': list(materials.values('durability_level').annotate(count=Count('id'))),
|
||||
'eco_level': list(materials.values('eco_level').annotate(count=Count('id'))),
|
||||
'carbon_level': list(materials.values('carbon_level').annotate(count=Count('id'))),
|
||||
'score_level': list(materials.values('score_level').annotate(count=Count('id'))),
|
||||
}
|
||||
subcategories = list(Material.objects.filter(status='approved').values_list(
|
||||
'material_subcategory', flat=True
|
||||
).distinct())
|
||||
|
||||
# 竞争优势与替代材料对比
|
||||
advantage_replace_stats = list(queryset.values('advantage', 'replace_type').annotate(
|
||||
count=Count('id')
|
||||
))
|
||||
def _level_counts(qs, field):
|
||||
raw = qs.values(field).annotate(count=Count('id'))
|
||||
mapping = {item[field]: item['count'] for item in raw if item[field] is not None}
|
||||
return [mapping.get(1, 0), mapping.get(2, 0), mapping.get(3, 0)]
|
||||
|
||||
# 应用场景对比
|
||||
application_scene_stats = list(queryset.values('application_scene').annotate(
|
||||
count=Count('id')
|
||||
))
|
||||
star_stats = {
|
||||
'quality_level': _level_counts(queryset, 'quality_level'),
|
||||
'durability_level': _level_counts(queryset, 'durability_level'),
|
||||
'eco_level': _level_counts(queryset, 'eco_level'),
|
||||
'carbon_level': _level_counts(queryset, 'carbon_level'),
|
||||
'score_level': _level_counts(queryset, 'score_level'),
|
||||
}
|
||||
|
||||
# 竞争优势与替代材料对比(优势为多选)
|
||||
advantage_replace_counter = {}
|
||||
for material in queryset:
|
||||
advantages = material.advantage or []
|
||||
for adv in advantages:
|
||||
key = (adv, material.replace_type)
|
||||
advantage_replace_counter[key] = advantage_replace_counter.get(key, 0) + 1
|
||||
|
||||
advantage_replace_stats = [
|
||||
{'advantage': key[0], 'replace_type': key[1], 'count': count}
|
||||
for key, count in advantage_replace_counter.items()
|
||||
]
|
||||
|
||||
# 应用场景对比(多选)
|
||||
application_scene_counter = {}
|
||||
for material in queryset:
|
||||
scenes = material.application_scene or []
|
||||
for scene in scenes:
|
||||
application_scene_counter[scene] = application_scene_counter.get(scene, 0) + 1
|
||||
|
||||
application_scene_stats = [
|
||||
{'application_scene': key, 'count': count}
|
||||
for key, count in application_scene_counter.items()
|
||||
]
|
||||
|
||||
# 材料列表
|
||||
materials_list = list(queryset.values(
|
||||
'id', 'name', 'material_category', 'material_subcategory',
|
||||
'factory__factory_name', 'brochure'
|
||||
)[:20])
|
||||
materials_list = []
|
||||
for item in queryset[:20]:
|
||||
materials_list.append({
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'material_category': item.material_category,
|
||||
'material_subcategory': item.material_subcategory,
|
||||
'factory_name': item.factory.factory_name if item.factory else None,
|
||||
'brochure_url': _build_brochure_url(request, item.brochure),
|
||||
})
|
||||
|
||||
return Response({
|
||||
'levels': [1, 2, 3],
|
||||
'subcategories': subcategories,
|
||||
'star_stats': star_stats,
|
||||
'advantage_replace_stats': advantage_replace_stats,
|
||||
'application_scene_stats': application_scene_stats,
|
||||
'materials_list': materials_list,
|
||||
'advantage_choices': Material.ADVANTAGE_CHOICES,
|
||||
'replace_type_choices': Material.REPLACE_TYPE_CHOICES,
|
||||
'application_scene_choices': Material.APPLICATION_SCENE_CHOICES,
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -126,19 +164,17 @@ def factory_statistics(request):
|
|||
"""
|
||||
工厂库统计
|
||||
"""
|
||||
# 只有管理员可以访问
|
||||
if request.user.role != 'admin':
|
||||
return Response({"detail": "无权访问"}, status=403)
|
||||
|
||||
# 工厂地区分布
|
||||
region_stats = list(Factory.objects.values('province', 'city').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count'))
|
||||
|
||||
# 工厂材料分类分布
|
||||
factory_category_stats = []
|
||||
approved_materials = Material.objects.filter(status='approved')
|
||||
for factory in Factory.objects.all():
|
||||
material_categories = Material.objects.filter(factory=factory).values(
|
||||
material_categories = approved_materials.filter(factory=factory).values(
|
||||
'material_category'
|
||||
).annotate(
|
||||
count=Count('id')
|
||||
|
|
@ -148,10 +184,9 @@ def factory_statistics(request):
|
|||
'factory_id': factory.id,
|
||||
'factory_name': factory.factory_name,
|
||||
'categories': list(material_categories),
|
||||
'total_materials': factory.materials.count()
|
||||
'total_materials': approved_materials.filter(factory=factory).count()
|
||||
})
|
||||
|
||||
# 工厂列表
|
||||
factories_list = list(Factory.objects.values(
|
||||
'id', 'factory_name', 'factory_short_name', 'province', 'city', 'website'
|
||||
))
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
VITE_API_BASE_URL=http://127.0.0.1:8000/api
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>新材料数据库</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "new-materials-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.8",
|
||||
"echarts": "^5.5.0",
|
||||
"element-china-area-data": "^5.0.0",
|
||||
"element-plus": "^2.7.3",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.2.10"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import api from './client'
|
||||
|
||||
export const login = async (payload) => {
|
||||
const { data } = await api.post('/auth/login/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchCurrentUser = async () => {
|
||||
const { data } = await api.get('/auth/user/')
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchUsers = async (params) => {
|
||||
const { data } = await api.get('/auth/users/', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export const createUser = async (payload) => {
|
||||
const { data } = await api.post('/auth/users/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const updateUser = async (id, payload) => {
|
||||
const { data } = await api.put(`/auth/users/${id}/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteUser = async (id) => {
|
||||
const { data } = await api.delete(`/auth/users/${id}/`)
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import api from './client'
|
||||
|
||||
export const fetchCategories = async () => {
|
||||
const { data } = await api.get('/material/categories/')
|
||||
return data
|
||||
}
|
||||
|
||||
export const createCategory = async (payload) => {
|
||||
const { data } = await api.post('/material/categories/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const updateCategory = async (id, payload) => {
|
||||
const { data } = await api.put(`/material/categories/${id}/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteCategory = async (id) => {
|
||||
const { data } = await api.delete(`/material/categories/${id}/`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchSubcategories = async (params) => {
|
||||
const { data } = await api.get('/material/subcategories/', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export const createSubcategory = async (payload) => {
|
||||
const { data } = await api.post('/material/subcategories/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const updateSubcategory = async (id, payload) => {
|
||||
const { data } = await api.put(`/material/subcategories/${id}/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteSubcategory = async (id) => {
|
||||
const { data } = await api.delete(`/material/subcategories/${id}/`)
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import axios from 'axios'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const { state } = useAuth()
|
||||
if (state.token) {
|
||||
config.headers.Authorization = `Bearer ${state.token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
const { clearAuth } = useAuth()
|
||||
clearAuth()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import api from './client'
|
||||
|
||||
export const fetchDictionary = async (params) => {
|
||||
const { data } = await api.get('/dictionary/', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchDictionaryGrouped = async () => {
|
||||
const { data } = await api.get('/dictionary/grouped/')
|
||||
return data
|
||||
}
|
||||
|
||||
export const createDictionary = async (payload) => {
|
||||
const { data } = await api.post('/dictionary/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const updateDictionary = async (id, payload) => {
|
||||
const { data } = await api.put(`/dictionary/${id}/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteDictionary = async (id) => {
|
||||
const { data } = await api.delete(`/dictionary/${id}/`)
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import api from './client'
|
||||
|
||||
export const fetchFactories = async (params) => {
|
||||
const { data } = await api.get('/factory/', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchFactorySimple = async () => {
|
||||
const { data } = await api.get('/factory/simple/')
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchFactoryDetail = async (id) => {
|
||||
const { data } = await api.get(`/factory/${id}/`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const createFactory = async (payload) => {
|
||||
const { data } = await api.post('/factory/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const updateFactory = async (id, payload) => {
|
||||
const { data } = await api.put(`/factory/${id}/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteFactory = async (id) => {
|
||||
const { data } = await api.delete(`/factory/${id}/`)
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import api from './client'
|
||||
|
||||
const toFormData = (payload) => {
|
||||
const form = new FormData()
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) return
|
||||
if (Array.isArray(value)) {
|
||||
form.append(key, JSON.stringify(value))
|
||||
return
|
||||
}
|
||||
form.append(key, value)
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
export const fetchMaterials = async (params) => {
|
||||
const { data } = await api.get('/material/', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchMaterialDetail = async (id) => {
|
||||
const { data } = await api.get(`/material/${id}/`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const createMaterial = async (payload, withFile = false) => {
|
||||
const dataPayload = withFile ? toFormData(payload) : payload
|
||||
const { data } = await api.post('/material/', dataPayload, {
|
||||
headers: withFile ? { 'Content-Type': 'multipart/form-data' } : undefined
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const updateMaterial = async (id, payload, withFile = false) => {
|
||||
const dataPayload = withFile ? toFormData(payload) : payload
|
||||
const { data } = await api.put(`/material/${id}/`, dataPayload, {
|
||||
headers: withFile ? { 'Content-Type': 'multipart/form-data' } : undefined
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteMaterial = async (id) => {
|
||||
const { data } = await api.delete(`/material/${id}/`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const submitMaterial = async (id) => {
|
||||
const { data } = await api.post(`/material/${id}/submit/`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const approveMaterial = async (id) => {
|
||||
const { data } = await api.post(`/material/${id}/approve/`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const rejectMaterial = async (id) => {
|
||||
const { data } = await api.post(`/material/${id}/reject/`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchMaterialChoices = async () => {
|
||||
const { data } = await api.get('/material/choices/')
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import api from './client'
|
||||
|
||||
export const fetchOverviewStats = async () => {
|
||||
const { data } = await api.get('/statistics/overview/')
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchMaterialStats = async (params) => {
|
||||
const { data } = await api.get('/statistics/materials/', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchFactoryStats = async () => {
|
||||
const { data } = await api.get('/statistics/factories/')
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<div class="logo-title">新材料数据库</div>
|
||||
<div class="logo-sub">管理系统</div>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="active"
|
||||
class="menu"
|
||||
router
|
||||
>
|
||||
<el-menu-item v-if="isAdmin" index="/users">用户管理</el-menu-item>
|
||||
<el-menu-item index="/factories">工厂管理</el-menu-item>
|
||||
<el-menu-item v-if="isAdmin" index="/dictionary">材料分类管理</el-menu-item>
|
||||
<el-menu-item index="/materials">材料管理</el-menu-item>
|
||||
<el-menu-item v-if="isAdmin" index="/screen/overview">数据大屏</el-menu-item>
|
||||
</el-menu>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div class="breadcrumb">{{ title }}</div>
|
||||
<div class="user">
|
||||
<div class="user-info">
|
||||
<div class="name">{{ user?.username || '用户' }}</div>
|
||||
<div class="role">{{ isAdmin ? '管理员' : '普通账号' }}</div>
|
||||
</div>
|
||||
<el-button size="small" @click="onLogout">退出</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<section class="content">
|
||||
<router-view />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { state, isAdmin, clearAuth } = useAuth()
|
||||
|
||||
const active = computed(() => route.path)
|
||||
const titleMap = {
|
||||
'/users': '用户管理',
|
||||
'/factories': '工厂管理',
|
||||
'/dictionary': '材料分类管理',
|
||||
'/materials': '材料管理'
|
||||
}
|
||||
const title = computed(() => titleMap[`/${route.path.split('/')[1]}`] || '系统首页')
|
||||
const user = computed(() => state.user)
|
||||
|
||||
const onLogout = () => {
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: linear-gradient(180deg, var(--brand-900), var(--brand-950));
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 20px 18px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logo-sub {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.menu {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
color: #fff;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
color: #7cb4e3;
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 3px 0 0 #7cb4e3;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item:hover) {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 24px rgba(15, 26, 42, 0.08);
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<div class="screen-layout">
|
||||
<header class="screen-header">
|
||||
<div class="screen-title">新材料管理数据中心</div>
|
||||
<nav class="screen-nav">
|
||||
<router-link to="/screen/overview" :class="{ active: isActive('/screen/overview') }">数据总览</router-link>
|
||||
<router-link to="/screen/materials" :class="{ active: isActive('/screen/materials') }">材料库</router-link>
|
||||
<router-link to="/screen/factories" :class="{ active: isActive('/screen/factories') }">工厂库</router-link>
|
||||
</nav>
|
||||
<button class="screen-back" @click="goBack">返回</button>
|
||||
</header>
|
||||
<section class="screen-content">
|
||||
<router-view />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isActive = (path) => route.path === path
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/materials')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.body.classList.add('screen-body')
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.classList.remove('screen-body')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import '@/styles/screen.css';
|
||||
</style>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './styles/base.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuth } from '@/store/auth'
|
||||
import MainLayout from '@/layouts/MainLayout.vue'
|
||||
import ScreenLayout from '@/layouts/ScreenLayout.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { public: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
redirect: '/materials',
|
||||
children: [
|
||||
{ path: 'users', name: 'users', component: () => import('@/views/UserManage.vue'), meta: { admin: true } },
|
||||
{ path: 'factories', name: 'factories', component: () => import('@/views/FactoryManage.vue') },
|
||||
{ path: 'factories/:id', name: 'factory-detail', component: () => import('@/views/FactoryDetail.vue') },
|
||||
{ path: 'dictionary', name: 'dictionary', component: () => import('@/views/DictionaryManage.vue'), meta: { admin: true } },
|
||||
{ path: 'materials', name: 'materials', component: () => import('@/views/MaterialManage.vue') },
|
||||
{ path: 'materials/:id', name: 'material-detail', component: () => import('@/views/MaterialDetail.vue') }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/screen',
|
||||
component: ScreenLayout,
|
||||
meta: { admin: true },
|
||||
redirect: '/screen/overview',
|
||||
children: [
|
||||
{ path: 'overview', name: 'screen-overview', component: () => import('@/views/screen/ScreenOverview.vue') },
|
||||
{ path: 'materials', name: 'screen-materials', component: () => import('@/views/screen/ScreenMaterials.vue') },
|
||||
{ path: 'factories', name: 'screen-factories', component: () => import('@/views/screen/ScreenFactories.vue') }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'notfound',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
meta: { public: true }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const { isAuthed, isAdmin } = useAuth()
|
||||
|
||||
if (to.meta.public) {
|
||||
return next()
|
||||
}
|
||||
|
||||
if (!isAuthed.value) {
|
||||
return next('/login')
|
||||
}
|
||||
|
||||
if (to.meta.admin && !isAdmin.value) {
|
||||
return next('/materials')
|
||||
}
|
||||
|
||||
return next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { reactive, computed } from 'vue'
|
||||
|
||||
const tokenKey = 'nm_token'
|
||||
const userKey = 'nm_user'
|
||||
|
||||
const state = reactive({
|
||||
token: localStorage.getItem(tokenKey) || '',
|
||||
user: JSON.parse(localStorage.getItem(userKey) || 'null')
|
||||
})
|
||||
|
||||
export const useAuth = () => {
|
||||
const isAuthed = computed(() => !!state.token)
|
||||
const isAdmin = computed(() => state.user?.role === 'admin')
|
||||
|
||||
const setAuth = (token, user) => {
|
||||
state.token = token
|
||||
state.user = user
|
||||
localStorage.setItem(tokenKey, token)
|
||||
localStorage.setItem(userKey, JSON.stringify(user))
|
||||
}
|
||||
|
||||
const clearAuth = () => {
|
||||
state.token = ''
|
||||
state.user = null
|
||||
localStorage.removeItem(tokenKey)
|
||||
localStorage.removeItem(userKey)
|
||||
}
|
||||
|
||||
return { state, isAuthed, isAdmin, setAuth, clearAuth }
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
:root {
|
||||
--brand-950: #0e1a2a;
|
||||
--brand-900: #16263c;
|
||||
--brand-800: #203754;
|
||||
--brand-700: #2f4b6b;
|
||||
--brand-500: #4e86b8;
|
||||
--accent-500: #f2b24c;
|
||||
--accent-400: #ffd18a;
|
||||
--bg: #f3f5fb;
|
||||
--bg-soft: #eef2f9;
|
||||
--text-900: #0f1a2a;
|
||||
--text-600: #5c6b7a;
|
||||
--card: #ffffff;
|
||||
--danger: #d64550;
|
||||
--success: #24a26a;
|
||||
--radius-lg: 14px;
|
||||
--radius-md: 10px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Alibaba PuHuiTi", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
background: radial-gradient(circle at top left, #ffffff 0%, #f5f7fb 35%, #e8eef8 100%);
|
||||
color: var(--text-900);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 20px 24px 28px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px;
|
||||
box-shadow: 0 18px 40px rgba(15, 26, 42, 0.08);
|
||||
border: 1px solid rgba(15, 26, 42, 0.05);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.el-table {
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
background: var(--bg-soft);
|
||||
color: var(--text-900);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-table td.el-table__cell {
|
||||
color: var(--text-600);
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-select__wrapper {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: linear-gradient(135deg, var(--brand-500), #6ea7d8);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
background: linear-gradient(135deg, #3f7bb1, #7db7e7);
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
.screen-body {
|
||||
margin: 0;
|
||||
background: radial-gradient(circle at top left, #203450 0%, #0c1422 45%, #070b12 100%);
|
||||
color: #e6edf5;
|
||||
font-family: "Rajdhani", "Alibaba PuHuiTi", "Noto Sans SC", sans-serif;
|
||||
}
|
||||
|
||||
.screen-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.screen-layout::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 20% 10%, rgba(159, 226, 255, 0.15), transparent 40%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255, 209, 138, 0.12), transparent 35%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.screen-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 18px 28px;
|
||||
background: rgba(6, 12, 20, 0.92);
|
||||
border-bottom: 1px solid rgba(159, 226, 255, 0.18);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.screen-title {
|
||||
font-size: 22px;
|
||||
letter-spacing: 6px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: #e3f3ff;
|
||||
}
|
||||
|
||||
.screen-nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.screen-nav a {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e6edf5;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid rgba(159, 226, 255, 0.15);
|
||||
box-shadow: inset 0 0 8px rgba(159, 226, 255, 0.2);
|
||||
}
|
||||
|
||||
.screen-nav a:hover {
|
||||
background: rgba(159, 226, 255, 0.2);
|
||||
color: #e3f3ff;
|
||||
}
|
||||
|
||||
.screen-nav a.active {
|
||||
background: linear-gradient(120deg, #5fb5ff, #9fe2ff);
|
||||
color: #051321;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 0 12px rgba(159, 226, 255, 0.6);
|
||||
}
|
||||
|
||||
.screen-back {
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 209, 138, 0.4);
|
||||
background: rgba(255, 209, 138, 0.1);
|
||||
color: #ffe2a6;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.screen-back:hover {
|
||||
background: rgba(255, 209, 138, 0.2);
|
||||
box-shadow: 0 0 12px rgba(255, 209, 138, 0.5);
|
||||
}
|
||||
|
||||
.screen-content {
|
||||
flex: 1;
|
||||
padding: 18px 24px 28px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.screen-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
}
|
||||
|
||||
.screen-card {
|
||||
background: rgba(7, 14, 24, 0.92);
|
||||
border: 1px solid rgba(159, 226, 255, 0.18);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.45);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screen-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -120px;
|
||||
right: -120px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: radial-gradient(circle, rgba(159, 226, 255, 0.22), transparent 70%);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.screen-card h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
color: #e6edf5;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.screen-card h3::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 46px;
|
||||
height: 2px;
|
||||
margin-top: 6px;
|
||||
background: linear-gradient(90deg, #9fe2ff, transparent);
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(140deg, rgba(78, 134, 184, 0.35), rgba(8, 18, 28, 0.9));
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: inset 0 0 20px rgba(159, 226, 255, 0.08);
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 12px;
|
||||
color: #c9d7e6;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.scroll-list {
|
||||
max-height: 220px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes scrollUp {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(-50%); }
|
||||
}
|
||||
|
||||
.scroll-inner {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
animation: scrollUp 18s linear infinite;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.screen-grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
.stat-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.screen-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { codeToText, regionData } from 'element-china-area-data'
|
||||
|
||||
const buildMap = (nodes, map) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node?.value && node?.label) {
|
||||
map[node.value] = node.label
|
||||
}
|
||||
if (node?.children?.length) {
|
||||
buildMap(node.children, map)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fallbackMap = {}
|
||||
if (!codeToText && Array.isArray(regionData)) {
|
||||
buildMap(regionData, fallbackMap)
|
||||
}
|
||||
|
||||
export const regionLabel = (code) => {
|
||||
if (!code) return ''
|
||||
const mapping = codeToText || fallbackMap
|
||||
return mapping?.[code] || code
|
||||
}
|
||||
|
||||
export const formatRegion = (province, city, district) => {
|
||||
const parts = [province, city, district].filter(Boolean).map(regionLabel)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">材料分类管理</div>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="材料分类" name="category">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCategoryCreate">新增分类</el-button>
|
||||
</div>
|
||||
<el-table :data="categories" border>
|
||||
<el-table-column prop="name" label="分类名称" />
|
||||
<el-table-column prop="value" label="分类值" />
|
||||
<el-table-column prop="subcategory_count" label="子类数量" width="120" />
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" @click="openCategoryEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="onCategoryDelete(scope.row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="材料子分类" name="subcategory">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="filters.category_id" placeholder="筛选分类" clearable @change="loadSubcategories" style="width: 200px">
|
||||
<el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="openSubcategoryCreate">新增子分类</el-button>
|
||||
</div>
|
||||
<el-table :data="subcategories" border>
|
||||
<el-table-column prop="category_name" label="所属分类" />
|
||||
<el-table-column prop="name" label="子分类名称" />
|
||||
<el-table-column prop="value" label="子分类值" />
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" @click="openSubcategoryEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="onSubcategoryDelete(scope.row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="480px">
|
||||
<el-form :model="categoryForm" label-width="90px">
|
||||
<el-form-item label="分类名称" required>
|
||||
<el-input v-model="categoryForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类值" required>
|
||||
<el-input v-model="categoryForm.value" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="categoryDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onCategorySubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="subcategoryDialogVisible" :title="subcategoryDialogTitle" width="520px">
|
||||
<el-form :model="subcategoryForm" label-width="100px">
|
||||
<el-form-item label="所属分类" required>
|
||||
<el-select v-model="subcategoryForm.category" filterable>
|
||||
<el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="子分类名称" required>
|
||||
<el-input v-model="subcategoryForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="子分类值" required>
|
||||
<el-input v-model="subcategoryForm.value" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="subcategoryDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubcategorySubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
fetchCategories,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
fetchSubcategories,
|
||||
createSubcategory,
|
||||
updateSubcategory,
|
||||
deleteSubcategory
|
||||
} from '@/api/category'
|
||||
|
||||
const activeTab = ref('category')
|
||||
const categories = ref([])
|
||||
const subcategories = ref([])
|
||||
|
||||
const filters = reactive({
|
||||
category_id: ''
|
||||
})
|
||||
|
||||
const categoryDialogVisible = ref(false)
|
||||
const categoryDialogTitle = ref('')
|
||||
const categoryIsEdit = ref(false)
|
||||
const categoryCurrentId = ref(null)
|
||||
const categoryForm = reactive({
|
||||
name: '',
|
||||
value: ''
|
||||
})
|
||||
|
||||
const subcategoryDialogVisible = ref(false)
|
||||
const subcategoryDialogTitle = ref('')
|
||||
const subcategoryIsEdit = ref(false)
|
||||
const subcategoryCurrentId = ref(null)
|
||||
const subcategoryForm = reactive({
|
||||
category: null,
|
||||
name: '',
|
||||
value: ''
|
||||
})
|
||||
|
||||
const loadCategories = async () => {
|
||||
const data = await fetchCategories()
|
||||
categories.value = data.results || data
|
||||
}
|
||||
|
||||
const loadSubcategories = async () => {
|
||||
const data = await fetchSubcategories(filters.category_id ? { category_id: filters.category_id } : {})
|
||||
subcategories.value = data.results || data
|
||||
}
|
||||
|
||||
const resetCategoryForm = () => {
|
||||
categoryForm.name = ''
|
||||
categoryForm.value = ''
|
||||
}
|
||||
|
||||
const resetSubcategoryForm = () => {
|
||||
subcategoryForm.category = null
|
||||
subcategoryForm.name = ''
|
||||
subcategoryForm.value = ''
|
||||
}
|
||||
|
||||
const openCategoryCreate = () => {
|
||||
resetCategoryForm()
|
||||
categoryIsEdit.value = false
|
||||
categoryDialogTitle.value = '新增分类'
|
||||
categoryDialogVisible.value = true
|
||||
}
|
||||
|
||||
const openCategoryEdit = (row) => {
|
||||
resetCategoryForm()
|
||||
categoryIsEdit.value = true
|
||||
categoryCurrentId.value = row.id
|
||||
categoryForm.name = row.name
|
||||
categoryForm.value = row.value
|
||||
categoryDialogTitle.value = '编辑分类'
|
||||
categoryDialogVisible.value = true
|
||||
}
|
||||
|
||||
const onCategorySubmit = async () => {
|
||||
try {
|
||||
if (categoryIsEdit.value) {
|
||||
await updateCategory(categoryCurrentId.value, { ...categoryForm })
|
||||
} else {
|
||||
await createCategory({ ...categoryForm })
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
categoryDialogVisible.value = false
|
||||
await loadCategories()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onCategoryDelete = (row) => {
|
||||
ElMessageBox.confirm(`确认删除分类 ${row.name} 吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
await deleteCategory(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadCategories()
|
||||
await loadSubcategories()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const openSubcategoryCreate = () => {
|
||||
resetSubcategoryForm()
|
||||
subcategoryIsEdit.value = false
|
||||
subcategoryDialogTitle.value = '新增子分类'
|
||||
subcategoryDialogVisible.value = true
|
||||
}
|
||||
|
||||
const openSubcategoryEdit = (row) => {
|
||||
resetSubcategoryForm()
|
||||
subcategoryIsEdit.value = true
|
||||
subcategoryCurrentId.value = row.id
|
||||
subcategoryForm.category = row.category
|
||||
subcategoryForm.name = row.name
|
||||
subcategoryForm.value = row.value
|
||||
subcategoryDialogTitle.value = '编辑子分类'
|
||||
subcategoryDialogVisible.value = true
|
||||
}
|
||||
|
||||
const onSubcategorySubmit = async () => {
|
||||
try {
|
||||
if (subcategoryIsEdit.value) {
|
||||
await updateSubcategory(subcategoryCurrentId.value, { ...subcategoryForm })
|
||||
} else {
|
||||
await createSubcategory({ ...subcategoryForm })
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
subcategoryDialogVisible.value = false
|
||||
await loadSubcategories()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onSubcategoryDelete = (row) => {
|
||||
ElMessageBox.confirm(`确认删除子分类 ${row.name} 吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
await deleteSubcategory(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadSubcategories()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories()
|
||||
await loadSubcategories()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">
|
||||
工厂详情
|
||||
<el-button class="back-btn" plain size="small" @click="goBack">返回</el-button>
|
||||
</div>
|
||||
<div class="card" v-if="factory">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="工厂全称">{{ factory.factory_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="工厂简称">{{ factory.factory_short_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="经销商">{{ factory.dealer_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="产品分类">{{ factory.product_category }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地区">{{ formatRegion(factory.province, factory.city, factory.district) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地址">{{ factory.address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="官网">{{ factory.website }}</el-descriptions-item>
|
||||
<el-descriptions-item label="材料数量">{{ factory.material_count }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { formatRegion } from '@/utils/region'
|
||||
import { fetchFactoryDetail } from '@/api/factory'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const factory = ref(null)
|
||||
|
||||
const loadDetail = async () => {
|
||||
factory.value = await fetchFactoryDetail(route.params.id)
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">工厂管理</div>
|
||||
<div class="toolbar">
|
||||
<el-button v-if="isAdmin" type="primary" @click="openCreate">新增工厂</el-button>
|
||||
</div>
|
||||
<el-table :data="factories" border>
|
||||
<el-table-column prop="factory_name" label="工厂全称" />
|
||||
<el-table-column prop="factory_short_name" label="工厂简称" />
|
||||
<el-table-column prop="dealer_name" label="经销商" />
|
||||
<el-table-column label="地区">
|
||||
<template #default="scope">
|
||||
{{ formatRegion(scope.row.province, scope.row.city, scope.row.district) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" @click="goDetail(scope.row)">详情</el-button>
|
||||
<el-button size="small" @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button v-if="isAdmin" size="small" type="danger" @click="onDelete(scope.row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="经销商" required>
|
||||
<el-input v-model="form.dealer_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品分类">
|
||||
<el-input v-model="form.product_category" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工厂全称" required>
|
||||
<el-input v-model="form.factory_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工厂简称" required>
|
||||
<el-input v-model="form.factory_short_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="省市区" required>
|
||||
<el-cascader
|
||||
v-model="regionValue"
|
||||
:options="regionOptions"
|
||||
clearable
|
||||
@change="onRegionChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="form.address" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="官网">
|
||||
<el-input v-model="form.website" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { regionData } from 'element-china-area-data'
|
||||
import { useAuth } from '@/store/auth'
|
||||
import { formatRegion } from '@/utils/region'
|
||||
import { fetchFactories, fetchFactoryDetail, createFactory, updateFactory, deleteFactory } from '@/api/factory'
|
||||
|
||||
const router = useRouter()
|
||||
const { isAdmin } = useAuth()
|
||||
const factories = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref(null)
|
||||
|
||||
const regionOptions = regionData
|
||||
const regionValue = ref([])
|
||||
|
||||
const form = reactive({
|
||||
dealer_name: '',
|
||||
product_category: '',
|
||||
factory_name: '',
|
||||
factory_short_name: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
address: '',
|
||||
website: ''
|
||||
})
|
||||
|
||||
const loadFactories = async () => {
|
||||
const data = await fetchFactories()
|
||||
factories.value = data.results || data
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.dealer_name = ''
|
||||
form.product_category = ''
|
||||
form.factory_name = ''
|
||||
form.factory_short_name = ''
|
||||
form.province = ''
|
||||
form.city = ''
|
||||
form.district = ''
|
||||
form.address = ''
|
||||
form.website = ''
|
||||
regionValue.value = []
|
||||
}
|
||||
|
||||
const onRegionChange = (val) => {
|
||||
form.province = val?.[0] || ''
|
||||
form.city = val?.[1] || ''
|
||||
form.district = val?.[2] || ''
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm()
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增工厂'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEdit = async (row) => {
|
||||
resetForm()
|
||||
isEdit.value = true
|
||||
currentId.value = row.id
|
||||
const detail = await fetchFactoryDetail(row.id)
|
||||
Object.assign(form, detail)
|
||||
regionValue.value = [detail.province, detail.city, detail.district].filter(Boolean)
|
||||
dialogTitle.value = '编辑工厂'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateFactory(currentId.value, { ...form })
|
||||
} else {
|
||||
await createFactory({ ...form })
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadFactories()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = (row) => {
|
||||
ElMessageBox.confirm(`确认删除工厂 ${row.factory_name} 吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
await deleteFactory(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadFactories()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const goDetail = (row) => {
|
||||
router.push(`/factories/${row.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFactories()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<div class="login">
|
||||
<div class="login-card">
|
||||
<div class="title">新材料数据库</div>
|
||||
<div class="subtitle">登录系统</div>
|
||||
<el-form :model="form" @submit.prevent>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
|
||||
</el-form-item>
|
||||
<el-button type="primary" class="btn" :loading="loading" @click="onLogin">登录</el-button>
|
||||
</el-form>
|
||||
<div class="hint">默认接口地址:/api</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { login } from '@/api/auth'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { setAuth } = useAuth()
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
const onLogin = async () => {
|
||||
if (!form.username || !form.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await login(form)
|
||||
setAuth(data.access, data.user)
|
||||
router.push('/materials')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1b2a41 0%, #3c6f8f 60%, #7cb4e3 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 380px;
|
||||
padding: 32px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 18px 40px rgba(15, 26, 42, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1b2a41;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #5c6b7a;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #9aa6b2;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">
|
||||
材料详情
|
||||
<el-button class="back-btn" plain size="small" @click="goBack">返回</el-button>
|
||||
</div>
|
||||
<div class="card" v-if="material">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="材料名称">{{ material.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="专业类别">{{ material.major_category_display }}</el-descriptions-item>
|
||||
<el-descriptions-item label="材料分类">{{ material.material_category }}</el-descriptions-item>
|
||||
<el-descriptions-item label="材料子类">{{ material.material_subcategory }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规格型号">{{ material.spec }}</el-descriptions-item>
|
||||
<el-descriptions-item label="符合标准">{{ material.standard }}</el-descriptions-item>
|
||||
<el-descriptions-item label="应用场景">{{ (material.application_scene_display || []).join('、') }}</el-descriptions-item>
|
||||
<el-descriptions-item label="应用说明">{{ material.application_desc }}</el-descriptions-item>
|
||||
<el-descriptions-item label="替代材料类型">{{ material.replace_type_display }}</el-descriptions-item>
|
||||
<el-descriptions-item label="竞争优势">{{ (material.advantage_display || []).join('、') }}</el-descriptions-item>
|
||||
<el-descriptions-item label="优势说明">{{ material.advantage_desc }}</el-descriptions-item>
|
||||
<el-descriptions-item label="成本对比">{{ material.cost_compare }}</el-descriptions-item>
|
||||
<el-descriptions-item label="成本说明">{{ material.cost_desc }}</el-descriptions-item>
|
||||
<el-descriptions-item label="案例">{{ material.cases }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属工厂">{{ material.factory_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="质量等级">{{ material.quality_level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="耐久等级">{{ material.durability_level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="环保等级">{{ material.eco_level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="低碳等级">{{ material.carbon_level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总评分">{{ material.score_level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="连接方式">{{ material.connection_method }}</el-descriptions-item>
|
||||
<el-descriptions-item label="施工工艺">{{ material.construction_method }}</el-descriptions-item>
|
||||
<el-descriptions-item label="限制条件">{{ material.limit_condition }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div v-if="material.brochure_url" class="brochure">
|
||||
<div class="brochure-title">宣传页</div>
|
||||
<img :src="material.brochure_url" alt="宣传页" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchMaterialDetail } from '@/api/material'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const material = ref(null)
|
||||
|
||||
const loadDetail = async () => {
|
||||
material.value = await fetchMaterialDetail(route.params.id)
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.brochure {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.brochure img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">材料管理</div>
|
||||
<div class="toolbar">
|
||||
<el-input v-model="filters.name" placeholder="材料名称" style="width: 200px" />
|
||||
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 140px">
|
||||
<el-option v-for="item in statusOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.material_subcategory" placeholder="材料子类" clearable style="width: 180px">
|
||||
<el-option v-for="item in filterSubcategoryOptions" :key="item.value" :label="item.name" :value="item.value" />
|
||||
</el-select>
|
||||
<el-button @click="loadMaterials">查询</el-button>
|
||||
<el-button type="primary" @click="openCreate">新增材料</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="materials" border>
|
||||
<el-table-column prop="name" label="材料名称" />
|
||||
<el-table-column prop="major_category_display" label="专业类别" />
|
||||
<el-table-column prop="material_category" label="材料分类" />
|
||||
<el-table-column prop="material_subcategory" label="材料子类" />
|
||||
<el-table-column prop="factory_short_name" label="所属工厂" />
|
||||
<el-table-column prop="status_display" label="状态" width="120" />
|
||||
<el-table-column label="操作" width="320">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" @click="goDetail(scope.row)">详情</el-button>
|
||||
<el-button size="small" @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button v-if="canSubmit(scope.row)" size="small" type="warning" @click="onSubmitAudit(scope.row)">提交审核</el-button>
|
||||
<el-button v-if="canApprove(scope.row)" size="small" type="success" @click="onApprove(scope.row)">审核通过</el-button>
|
||||
<el-button v-if="canApprove(scope.row)" size="small" type="danger" @click="onReject(scope.row)">审核拒绝</el-button>
|
||||
<el-button size="small" type="danger" @click="onDelete(scope.row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="820px">
|
||||
<el-form :model="form" label-width="110px">
|
||||
<el-form-item label="材料名称" required>
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="专业类别" required>
|
||||
<el-select v-model="form.major_category">
|
||||
<el-option v-for="item in majorOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="材料分类" required>
|
||||
<el-select v-model="form.material_category" filterable @change="onCategoryChange">
|
||||
<el-option v-for="item in categoryOptions" :key="item.value" :label="item.name" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="材料子类" required>
|
||||
<el-select v-model="form.material_subcategory" filterable>
|
||||
<el-option v-for="item in subcategoryOptions" :key="item.value" :label="item.name" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="规格型号">
|
||||
<el-input v-model="form.spec" />
|
||||
</el-form-item>
|
||||
<el-form-item label="符合标准">
|
||||
<el-input v-model="form.standard" />
|
||||
</el-form-item>
|
||||
<el-form-item label="应用场景">
|
||||
<el-select v-model="form.application_scene" multiple>
|
||||
<el-option v-for="item in sceneOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="应用说明">
|
||||
<el-input v-model="form.application_desc" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="替代材料类型">
|
||||
<el-select v-model="form.replace_type" clearable>
|
||||
<el-option v-for="item in replaceOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="竞争优势">
|
||||
<el-select v-model="form.advantage" multiple>
|
||||
<el-option v-for="item in advantageOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="优势说明">
|
||||
<el-input v-model="form.advantage_desc" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="成本对比(%)">
|
||||
<el-input-number v-model="form.cost_compare" :min="-100" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="成本说明">
|
||||
<el-input v-model="form.cost_desc" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="案例">
|
||||
<el-input v-model="form.cases" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="宣传页">
|
||||
<el-upload
|
||||
class="upload"
|
||||
:auto-upload="false"
|
||||
:show-file-list="true"
|
||||
:on-change="onFileChange"
|
||||
>
|
||||
<el-button>选择图片</el-button>
|
||||
</el-upload>
|
||||
<div v-if="form.brochure_url" class="preview">
|
||||
<img :src="form.brochure_url" alt="预览" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="质量等级">
|
||||
<el-select v-model="form.quality_level" clearable>
|
||||
<el-option v-for="item in starOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="耐久等级">
|
||||
<el-select v-model="form.durability_level" clearable>
|
||||
<el-option v-for="item in starOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="环保等级">
|
||||
<el-select v-model="form.eco_level" clearable>
|
||||
<el-option v-for="item in starOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="低碳等级">
|
||||
<el-select v-model="form.carbon_level" clearable>
|
||||
<el-option v-for="item in starOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="总评分">
|
||||
<el-select v-model="form.score_level" clearable>
|
||||
<el-option v-for="item in starOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="连接方式">
|
||||
<el-input v-model="form.connection_method" />
|
||||
</el-form-item>
|
||||
<el-form-item label="施工工艺">
|
||||
<el-input v-model="form.construction_method" />
|
||||
</el-form-item>
|
||||
<el-form-item label="限制条件">
|
||||
<el-input v-model="form.limit_condition" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属工厂" v-if="isAdmin">
|
||||
<el-select v-model="form.factory">
|
||||
<el-option v-for="item in factories" :key="item.id" :label="item.factory_name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuth } from '@/store/auth'
|
||||
import { fetchMaterials, fetchMaterialDetail, createMaterial, updateMaterial, deleteMaterial, submitMaterial, approveMaterial, rejectMaterial, fetchMaterialChoices } from '@/api/material'
|
||||
import { fetchCategories, fetchSubcategories } from '@/api/category'
|
||||
import { fetchFactorySimple } from '@/api/factory'
|
||||
|
||||
const router = useRouter()
|
||||
const { isAdmin } = useAuth()
|
||||
const materials = ref([])
|
||||
const factories = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref(null)
|
||||
const fileRef = ref(null)
|
||||
|
||||
const filters = reactive({
|
||||
name: '',
|
||||
status: '',
|
||||
material_subcategory: ''
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
major_category: '',
|
||||
material_category: '',
|
||||
material_subcategory: '',
|
||||
spec: '',
|
||||
standard: '',
|
||||
application_scene: [],
|
||||
application_desc: '',
|
||||
replace_type: '',
|
||||
advantage: [],
|
||||
advantage_desc: '',
|
||||
cost_compare: null,
|
||||
cost_desc: '',
|
||||
cases: '',
|
||||
brochure: null,
|
||||
brochure_url: '',
|
||||
quality_level: null,
|
||||
durability_level: null,
|
||||
eco_level: null,
|
||||
carbon_level: null,
|
||||
score_level: null,
|
||||
connection_method: '',
|
||||
construction_method: '',
|
||||
limit_condition: '',
|
||||
factory: null
|
||||
})
|
||||
|
||||
const majorOptions = ref([])
|
||||
const replaceOptions = ref([])
|
||||
const advantageOptions = ref([])
|
||||
const sceneOptions = ref([])
|
||||
const starOptions = ref([])
|
||||
const statusOptions = ref([])
|
||||
const categoryOptions = ref([])
|
||||
const subcategoryOptions = ref([])
|
||||
const filterSubcategoryOptions = ref([])
|
||||
const allSubcategories = ref([])
|
||||
|
||||
const loadMaterials = async () => {
|
||||
const data = await fetchMaterials({ ...filters })
|
||||
materials.value = data.results || data
|
||||
}
|
||||
|
||||
const loadChoices = async () => {
|
||||
const data = await fetchMaterialChoices()
|
||||
majorOptions.value = data.major_category
|
||||
replaceOptions.value = data.replace_type
|
||||
advantageOptions.value = data.advantage
|
||||
sceneOptions.value = data.application_scene
|
||||
starOptions.value = data.star_level
|
||||
statusOptions.value = data.status
|
||||
}
|
||||
|
||||
const loadCategories = async () => {
|
||||
const data = await fetchCategories()
|
||||
categoryOptions.value = (data.results || data).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
value: item.value
|
||||
}))
|
||||
}
|
||||
|
||||
const loadSubcategories = async (categoryId = '') => {
|
||||
const data = await fetchSubcategories(categoryId ? { category_id: categoryId } : {})
|
||||
allSubcategories.value = data.results || data
|
||||
const mapped = allSubcategories.value.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
category: item.category
|
||||
}))
|
||||
filterSubcategoryOptions.value = mapped
|
||||
subcategoryOptions.value = mapped
|
||||
}
|
||||
|
||||
const loadFactories = async () => {
|
||||
factories.value = await fetchFactorySimple()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
major_category: '',
|
||||
material_category: '',
|
||||
material_subcategory: '',
|
||||
spec: '',
|
||||
standard: '',
|
||||
application_scene: [],
|
||||
application_desc: '',
|
||||
replace_type: '',
|
||||
advantage: [],
|
||||
advantage_desc: '',
|
||||
cost_compare: null,
|
||||
cost_desc: '',
|
||||
cases: '',
|
||||
brochure: null,
|
||||
brochure_url: '',
|
||||
quality_level: null,
|
||||
durability_level: null,
|
||||
eco_level: null,
|
||||
carbon_level: null,
|
||||
score_level: null,
|
||||
connection_method: '',
|
||||
construction_method: '',
|
||||
limit_condition: '',
|
||||
factory: null
|
||||
})
|
||||
fileRef.value = null
|
||||
}
|
||||
|
||||
const onCategoryChange = async (val, resetSub = true) => {
|
||||
if (!val) {
|
||||
await loadSubcategories()
|
||||
return
|
||||
}
|
||||
const category = categoryOptions.value.find((item) => item.value === val)
|
||||
if (category) {
|
||||
await loadSubcategories(category.id)
|
||||
}
|
||||
if (resetSub) {
|
||||
form.material_subcategory = ''
|
||||
}
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm()
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增材料'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEdit = async (row) => {
|
||||
resetForm()
|
||||
isEdit.value = true
|
||||
currentId.value = row.id
|
||||
const item = await fetchMaterialDetail(row.id)
|
||||
Object.assign(form, item)
|
||||
form.application_scene = item.application_scene || []
|
||||
form.advantage = item.advantage || []
|
||||
form.brochure_url = item.brochure_url || ''
|
||||
if (form.material_category) {
|
||||
await onCategoryChange(form.material_category, false)
|
||||
}
|
||||
dialogTitle.value = '编辑材料'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const onFileChange = (file) => {
|
||||
fileRef.value = file.raw
|
||||
form.brochure = file.raw
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
const payload = { ...form }
|
||||
const withFile = !!fileRef.value
|
||||
if (!isAdmin.value) {
|
||||
delete payload.factory
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await updateMaterial(currentId.value, payload, withFile)
|
||||
} else {
|
||||
await createMaterial(payload, withFile)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadMaterials()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = (row) => {
|
||||
ElMessageBox.confirm(`确认删除材料 ${row.name} 吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
await deleteMaterial(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadMaterials()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const onSubmitAudit = async (row) => {
|
||||
await submitMaterial(row.id)
|
||||
ElMessage.success('已提交审核')
|
||||
loadMaterials()
|
||||
}
|
||||
|
||||
const onApprove = async (row) => {
|
||||
await approveMaterial(row.id)
|
||||
ElMessage.success('审核通过')
|
||||
loadMaterials()
|
||||
}
|
||||
|
||||
const onReject = async (row) => {
|
||||
await rejectMaterial(row.id)
|
||||
ElMessage.success('审核拒绝')
|
||||
loadMaterials()
|
||||
}
|
||||
|
||||
const canSubmit = (row) => !isAdmin.value && row.status === 'draft'
|
||||
const canApprove = (row) => isAdmin.value && row.status === 'pending'
|
||||
|
||||
const goDetail = (row) => {
|
||||
router.push(`/materials/${row.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadChoices()
|
||||
loadCategories()
|
||||
loadSubcategories()
|
||||
loadFactories()
|
||||
loadMaterials()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview img {
|
||||
width: 120px;
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">页面不存在</div>
|
||||
<el-button type="primary" @click="$router.push('/materials')">返回首页</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">用户管理</div>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增用户</el-button>
|
||||
</div>
|
||||
<el-table :data="users" border>
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column prop="role" label="角色">
|
||||
<template #default="scope">
|
||||
{{ scope.row.role === 'admin' ? '管理员' : '普通账号' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="factory_name" label="所属工厂" />
|
||||
<el-table-column prop="phone" label="手机" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="onDelete(scope.row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.username" :disabled="isEdit" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" v-if="!isEdit" required>
|
||||
<el-input v-model="form.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" v-if="!isEdit" required>
|
||||
<el-input v-model="form.password_confirm" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" required>
|
||||
<el-select v-model="form.role" @change="onRoleChange">
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="普通账号" value="user" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属工厂" v-if="form.role === 'user'" required>
|
||||
<el-select v-model="form.factory">
|
||||
<el-option v-for="item in factories" :key="item.id" :label="item.factory_name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机">
|
||||
<el-input v-model="form.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fetchUsers, createUser, updateUser, deleteUser } from '@/api/auth'
|
||||
import { fetchFactorySimple } from '@/api/factory'
|
||||
|
||||
const users = ref([])
|
||||
const factories = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
password_confirm: '',
|
||||
role: 'user',
|
||||
factory: null,
|
||||
phone: '',
|
||||
email: ''
|
||||
})
|
||||
|
||||
const loadUsers = async () => {
|
||||
const data = await fetchUsers()
|
||||
users.value = data.results || data
|
||||
}
|
||||
|
||||
const loadFactories = async () => {
|
||||
factories.value = await fetchFactorySimple()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.password_confirm = ''
|
||||
form.role = 'user'
|
||||
form.factory = null
|
||||
form.phone = ''
|
||||
form.email = ''
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm()
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增用户'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEdit = (row) => {
|
||||
resetForm()
|
||||
isEdit.value = true
|
||||
currentId.value = row.id
|
||||
form.username = row.username
|
||||
form.role = row.role
|
||||
form.factory = row.factory
|
||||
form.phone = row.phone
|
||||
form.email = row.email
|
||||
dialogTitle.value = '编辑用户'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const onRoleChange = () => {
|
||||
if (form.role === 'admin') {
|
||||
form.factory = null
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateUser(currentId.value, {
|
||||
role: form.role,
|
||||
factory: form.factory,
|
||||
phone: form.phone,
|
||||
email: form.email
|
||||
})
|
||||
} else {
|
||||
await createUser({ ...form })
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = (row) => {
|
||||
ElMessageBox.confirm(`确认删除用户 ${row.username} 吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadUsers()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
loadFactories()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="screen-grid">
|
||||
<div class="screen-card" style="grid-column: span 6;">
|
||||
<h3>工厂地区分布</h3>
|
||||
<div ref="regionChart" style="height: 260px;"></div>
|
||||
</div>
|
||||
<div class="screen-card" style="grid-column: span 6;">
|
||||
<h3>工厂材料分类分布</h3>
|
||||
<div ref="categoryChart" style="height: 260px;"></div>
|
||||
</div>
|
||||
<div class="screen-card" style="grid-column: span 12;">
|
||||
<h3>工厂列表</h3>
|
||||
<div class="scroll-list">
|
||||
<div class="scroll-inner">
|
||||
<div v-for="item in factories" :key="item.id" class="scroll-item" @click="goDetail(item)">
|
||||
<div>{{ item.factory_name }} · {{ formatRegion(item.province, item.city, '') }}</div>
|
||||
<div style="font-size: 12px; color: #9fb3c8;">{{ item.website }}</div>
|
||||
</div>
|
||||
<div v-for="item in factories" :key="`copy-${item.id}`" class="scroll-item" @click="goDetail(item)">
|
||||
<div>{{ item.factory_name }} · {{ formatRegion(item.province, item.city, '') }}</div>
|
||||
<div style="font-size: 12px; color: #9fb3c8;">{{ item.website }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as echarts from 'echarts'
|
||||
import { regionLabel, formatRegion } from '@/utils/region'
|
||||
import { fetchFactoryStats } from '@/api/statistics'
|
||||
|
||||
const router = useRouter()
|
||||
const factories = ref([])
|
||||
const regionChart = ref(null)
|
||||
const categoryChart = ref(null)
|
||||
let charts = []
|
||||
let timer = null
|
||||
const onResize = () => charts.forEach((chart) => chart.resize())
|
||||
|
||||
const initCharts = () => {
|
||||
charts = [
|
||||
echarts.init(regionChart.value),
|
||||
echarts.init(categoryChart.value)
|
||||
]
|
||||
}
|
||||
|
||||
const updateCharts = (data) => {
|
||||
const regionData = (data.region_stats || []).map((item) => ({
|
||||
name: `${regionLabel(item.province)}-${regionLabel(item.city)}`,
|
||||
value: item.count
|
||||
}))
|
||||
charts[0].setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{ type: 'pie', radius: ['30%', '70%'], data: regionData }]
|
||||
})
|
||||
|
||||
const factoryStats = data.factory_category_stats || []
|
||||
const factoriesAxis = factoryStats.map((item) => item.factory_name)
|
||||
const categories = [...new Set(factoryStats.flatMap((item) => item.categories.map((c) => c.material_category)))]
|
||||
|
||||
const series = categories.map((cat) => ({
|
||||
name: cat,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: factoryStats.map((item) => {
|
||||
const found = item.categories.find((c) => c.material_category === cat)
|
||||
return found ? found.count : 0
|
||||
})
|
||||
}))
|
||||
|
||||
charts[1].setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { textStyle: { color: '#c9d7e6' } },
|
||||
grid: { left: 40, right: 20, top: 30, bottom: 40 },
|
||||
xAxis: { type: 'category', data: factoriesAxis, axisLabel: { color: '#9fb3c8', rotate: 20 } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } },
|
||||
series
|
||||
})
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
const data = await fetchFactoryStats()
|
||||
factories.value = data.factories_list || []
|
||||
updateCharts(data)
|
||||
}
|
||||
|
||||
const goDetail = (item) => {
|
||||
router.push(`/factories/${item.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initCharts()
|
||||
await loadData()
|
||||
timer = setInterval(loadData, 10000)
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
charts.forEach((chart) => chart.dispose())
|
||||
clearInterval(timer)
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="toolbar" style="margin-bottom: 16px;">
|
||||
<el-select v-model="subcategory" placeholder="按材料子类筛选" clearable @change="loadData" style="width: 220px">
|
||||
<el-option v-for="item in subcategories" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="screen-grid">
|
||||
<div class="screen-card" style="grid-column: span 4;">
|
||||
<h3>材料星级对比</h3>
|
||||
<div ref="starChart" style="height: 260px;"></div>
|
||||
</div>
|
||||
<div class="screen-card" style="grid-column: span 4;">
|
||||
<h3>竞争优势与替代材料</h3>
|
||||
<div ref="advChart" style="height: 260px;"></div>
|
||||
</div>
|
||||
<div class="screen-card" style="grid-column: span 4;">
|
||||
<h3>应用场景对比</h3>
|
||||
<div ref="sceneChart" style="height: 260px;"></div>
|
||||
</div>
|
||||
<div class="screen-card" style="grid-column: span 12;">
|
||||
<h3>材料列表</h3>
|
||||
<div class="scroll-list">
|
||||
<div class="scroll-inner">
|
||||
<div v-for="item in materials" :key="item.id" class="scroll-item" @click="goDetail(item)">
|
||||
<div>{{ item.name }} · {{ item.material_subcategory }}</div>
|
||||
<div style="font-size: 12px; color: #9fb3c8;">{{ item.factory_name }}</div>
|
||||
</div>
|
||||
<div v-for="item in materials" :key="`copy-${item.id}`" class="scroll-item" @click="goDetail(item)">
|
||||
<div>{{ item.name }} · {{ item.material_subcategory }}</div>
|
||||
<div style="font-size: 12px; color: #9fb3c8;">{{ item.factory_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as echarts from 'echarts'
|
||||
import { fetchMaterialStats } from '@/api/statistics'
|
||||
|
||||
const router = useRouter()
|
||||
const subcategory = ref('')
|
||||
const subcategories = ref([])
|
||||
const materials = ref([])
|
||||
const starChart = ref(null)
|
||||
const advChart = ref(null)
|
||||
const sceneChart = ref(null)
|
||||
let timer = null
|
||||
let charts = []
|
||||
let choices = {
|
||||
advantage: [],
|
||||
replace_type: [],
|
||||
application_scene: []
|
||||
}
|
||||
const onResize = () => charts.forEach((chart) => chart.resize())
|
||||
|
||||
const initCharts = () => {
|
||||
charts = [
|
||||
echarts.init(starChart.value),
|
||||
echarts.init(advChart.value),
|
||||
echarts.init(sceneChart.value)
|
||||
]
|
||||
}
|
||||
|
||||
const mapLabel = (list, value) => {
|
||||
if (!value) return '未设置'
|
||||
const found = list.find((item) => item[0] === value)
|
||||
return found ? found[1] : value
|
||||
}
|
||||
|
||||
const updateCharts = (data) => {
|
||||
const levels = data.levels || [1, 2, 3]
|
||||
charts[0].setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { textStyle: { color: '#c9d7e6' } },
|
||||
xAxis: { type: 'category', data: levels.map((l) => `${l}星`), axisLabel: { color: '#9fb3c8' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } },
|
||||
series: [
|
||||
{ name: '质量', type: 'line', data: data.star_stats.quality_level, smooth: true },
|
||||
{ name: '耐久', type: 'line', data: data.star_stats.durability_level, smooth: true },
|
||||
{ name: '环保', type: 'line', data: data.star_stats.eco_level, smooth: true },
|
||||
{ name: '低碳', type: 'line', data: data.star_stats.carbon_level, smooth: true },
|
||||
{ name: '总评', type: 'line', data: data.star_stats.score_level, smooth: true }
|
||||
]
|
||||
})
|
||||
|
||||
const advStats = data.advantage_replace_stats || []
|
||||
const advTypes = [...new Set(advStats.map((item) => item.advantage))]
|
||||
const replaceTypes = [...new Set(advStats.map((item) => item.replace_type))]
|
||||
const series = replaceTypes.map((type) => ({
|
||||
name: mapLabel(choices.replace_type, type),
|
||||
type: 'bar',
|
||||
data: advTypes.map((adv) => {
|
||||
const found = advStats.find((item) => item.advantage === adv && item.replace_type === type)
|
||||
return found ? found.count : 0
|
||||
})
|
||||
}))
|
||||
charts[1].setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { textStyle: { color: '#c9d7e6' } },
|
||||
xAxis: { type: 'category', data: advTypes.map((adv) => mapLabel(choices.advantage, adv)), axisLabel: { color: '#9fb3c8' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } },
|
||||
series
|
||||
})
|
||||
|
||||
const sceneStats = data.application_scene_stats || []
|
||||
charts[2].setOption({
|
||||
xAxis: { type: 'category', data: sceneStats.map((item) => mapLabel(choices.application_scene, item.application_scene)), axisLabel: { color: '#9fb3c8' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } },
|
||||
series: [{ type: 'bar', data: sceneStats.map((item) => item.count), itemStyle: { color: '#f2b24c' } }]
|
||||
})
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
const data = await fetchMaterialStats(subcategory.value ? { material_subcategory: subcategory.value } : {})
|
||||
subcategories.value = data.subcategories || []
|
||||
materials.value = data.materials_list || []
|
||||
choices = {
|
||||
advantage: data.advantage_choices || [],
|
||||
replace_type: data.replace_type_choices || [],
|
||||
application_scene: data.application_scene_choices || []
|
||||
}
|
||||
updateCharts(data)
|
||||
}
|
||||
|
||||
const goDetail = (item) => {
|
||||
router.push(`/materials/${item.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initCharts()
|
||||
await loadData()
|
||||
timer = setInterval(loadData, 10000)
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
charts.forEach((chart) => chart.dispose())
|
||||
clearInterval(timer)
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="stat-cards">
|
||||
<div class="stat-card">
|
||||
<div class="label">材料总数</div>
|
||||
<div class="value">{{ stats.total_materials || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">材料种类</div>
|
||||
<div class="value">{{ stats.total_material_categories || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">品牌数</div>
|
||||
<div class="value">{{ stats.total_brands || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="screen-grid" style="margin-top: 18px;">
|
||||
<div class="screen-card" style="grid-column: span 4;">
|
||||
<h3>专业类别分布</h3>
|
||||
<div ref="majorChart" style="height: 260px;"></div>
|
||||
</div>
|
||||
<div class="screen-card" style="grid-column: span 4;">
|
||||
<h3>材料子类分布</h3>
|
||||
<div ref="subChart" style="height: 260px;"></div>
|
||||
</div>
|
||||
<div class="screen-card" style="grid-column: span 4;">
|
||||
<h3>品牌材料分布</h3>
|
||||
<div ref="brandChart" style="height: 260px;"></div>
|
||||
</div>
|
||||
<div class="screen-card" style="grid-column: span 6;">
|
||||
<h3>工厂地区分布</h3>
|
||||
<div ref="regionChart" style="height: 260px;"></div>
|
||||
</div>
|
||||
<div class="screen-card" style="grid-column: span 6;">
|
||||
<h3>应用案例</h3>
|
||||
<div class="scroll-list">
|
||||
<div class="scroll-inner">
|
||||
<div v-for="item in casesList" :key="item.id" class="scroll-item" @click="goMaterial(item)">
|
||||
<div>{{ item.name }}</div>
|
||||
<div style="font-size: 12px; color: #9fb3c8;">{{ item.factory_name }}</div>
|
||||
</div>
|
||||
<div v-for="item in casesList" :key="`copy-${item.id}`" class="scroll-item" @click="goMaterial(item)">
|
||||
<div>{{ item.name }}</div>
|
||||
<div style="font-size: 12px; color: #9fb3c8;">{{ item.factory_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as echarts from 'echarts'
|
||||
import { regionLabel } from '@/utils/region'
|
||||
import { fetchOverviewStats } from '@/api/statistics'
|
||||
|
||||
const router = useRouter()
|
||||
const stats = ref({})
|
||||
const casesList = ref([])
|
||||
const majorChart = ref(null)
|
||||
const subChart = ref(null)
|
||||
const brandChart = ref(null)
|
||||
const regionChart = ref(null)
|
||||
let timer = null
|
||||
let charts = []
|
||||
const onResize = () => charts.forEach((chart) => chart.resize())
|
||||
|
||||
const initCharts = () => {
|
||||
charts = [
|
||||
echarts.init(majorChart.value),
|
||||
echarts.init(subChart.value),
|
||||
echarts.init(brandChart.value),
|
||||
echarts.init(regionChart.value)
|
||||
]
|
||||
}
|
||||
|
||||
const updateCharts = () => {
|
||||
const majorLabel = {
|
||||
architecture: '建筑',
|
||||
landscape: '景观',
|
||||
equipment: '设备',
|
||||
decoration: '装修'
|
||||
}
|
||||
const majorData = (stats.value.major_category_stats || []).map((item) => ({
|
||||
name: majorLabel[item.major_category] || item.major_category,
|
||||
value: item.count
|
||||
}))
|
||||
charts[0].setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: majorData
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const subData = stats.value.material_subcategory_stats || []
|
||||
charts[1].setOption({
|
||||
grid: { left: 80, right: 20, top: 20, bottom: 20 },
|
||||
xAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: subData.map((item) => item.material_subcategory),
|
||||
axisLabel: { color: '#9fb3c8' }
|
||||
},
|
||||
series: [
|
||||
{ type: 'bar', data: subData.map((item) => item.count), itemStyle: { color: '#7cb4e3' } }
|
||||
]
|
||||
})
|
||||
|
||||
const brandData = (stats.value.brand_stats || []).map((item) => ({
|
||||
name: item.factory__factory_name,
|
||||
value: item.count
|
||||
}))
|
||||
charts[2].setOption({
|
||||
series: [
|
||||
{
|
||||
type: 'treemap',
|
||||
data: brandData,
|
||||
label: { show: true, color: '#0b121c' },
|
||||
itemStyle: {
|
||||
borderColor: '#0b121c'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const regionData = (stats.value.region_stats || []).map((item) => ({
|
||||
name: `${regionLabel(item.province)}-${regionLabel(item.city)}`,
|
||||
value: item.count
|
||||
}))
|
||||
charts[3].setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['30%', '70%'],
|
||||
data: regionData
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
const data = await fetchOverviewStats()
|
||||
stats.value = data
|
||||
casesList.value = data.cases_list || []
|
||||
updateCharts()
|
||||
}
|
||||
|
||||
const goMaterial = (item) => {
|
||||
router.push(`/materials/${item.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initCharts()
|
||||
await loadData()
|
||||
timer = setInterval(loadData, 10000)
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
charts.forEach((chart) => chart.dispose())
|
||||
clearInterval(timer)
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue