Compare commits

...

2 Commits

Author SHA1 Message Date
caoqianming 1440767c69 feat: 后端跨域 2026-03-10 16:43:57 +08:00
caoqianming 80a8f69edf feat: add material categories and polish UI 2026-03-10 16:40:21 +08:00
46 changed files with 4743 additions and 76 deletions

View File

@ -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'),
),
]

View File

@ -33,6 +33,12 @@ class UserCreateSerializer(serializers.ModelSerializer):
def validate(self, attrs): def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']: if attrs['password'] != attrs['password_confirm']:
raise serializers.ValidationError({"password": "密码字段不匹配。"}) 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 return attrs
def create(self, validated_data): def create(self, validated_data):

View File

@ -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.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework.exceptions import PermissionDenied
from .models import User from .models import User
from .serializers import UserSerializer, UserCreateSerializer, CustomTokenObtainPairSerializer from .serializers import UserSerializer, UserCreateSerializer, CustomTokenObtainPairSerializer
@ -12,16 +13,21 @@ class CustomTokenObtainPairView(TokenObtainPairView):
自定义JWT令牌获取视图 自定义JWT令牌获取视图
""" """
serializer_class = CustomTokenObtainPairSerializer serializer_class = CustomTokenObtainPairSerializer
permission_classes = [AllowAny]
class UserListView(generics.ListCreateAPIView): class UserListView(generics.ListCreateAPIView):
""" """
用户列表和创建视图 用户列表和创建视图
""" """
queryset = User.objects.all()
serializer_class = UserSerializer serializer_class = UserSerializer
permission_classes = [IsAuthenticated] 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): def get_serializer_class(self):
if self.request.method == 'POST': if self.request.method == 'POST':
return UserCreateSerializer return UserCreateSerializer
@ -30,7 +36,7 @@ class UserListView(generics.ListCreateAPIView):
def perform_create(self, serializer): def perform_create(self, serializer):
# 只有管理员可以创建用户 # 只有管理员可以创建用户
if self.request.user.role != 'admin': if self.request.user.role != 'admin':
raise PermissionError("只有管理员可以创建用户") raise PermissionDenied("只有管理员可以创建用户")
serializer.save() serializer.save()
@ -45,13 +51,20 @@ class UserDetailView(generics.RetrieveUpdateDestroyAPIView):
def perform_update(self, serializer): def perform_update(self, serializer):
# 普通用户只能修改自己的信息 # 普通用户只能修改自己的信息
if self.request.user.role != 'admin' and self.request.user.id != self.get_object().id: 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() serializer.save()
def perform_destroy(self, instance): def perform_destroy(self, instance):
# 只有管理员可以删除用户 # 只有管理员可以删除用户
if self.request.user.role != 'admin': if self.request.user.role != 'admin':
raise PermissionError("只有管理员可以删除用户") raise PermissionDenied("只有管理员可以删除用户")
instance.delete() instance.delete()

View File

@ -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.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from django.db.models import Q from rest_framework.exceptions import PermissionDenied
from .models import Dictionary from .models import Dictionary
from .serializers import DictionarySerializer, DictionaryGroupSerializer from .serializers import DictionarySerializer
class DictionaryListView(generics.ListCreateAPIView): class DictionaryListView(generics.ListCreateAPIView):
@ -35,7 +35,7 @@ class DictionaryListView(generics.ListCreateAPIView):
def perform_create(self, serializer): def perform_create(self, serializer):
# 只有管理员可以创建字典 # 只有管理员可以创建字典
if self.request.user.role != 'admin': if self.request.user.role != 'admin':
raise PermissionError("只有管理员可以创建字典") raise PermissionDenied("只有管理员可以创建字典")
serializer.save() serializer.save()
@ -50,13 +50,13 @@ class DictionaryDetailView(generics.RetrieveUpdateDestroyAPIView):
def perform_update(self, serializer): def perform_update(self, serializer):
# 只有管理员可以更新字典 # 只有管理员可以更新字典
if self.request.user.role != 'admin': if self.request.user.role != 'admin':
raise PermissionError("只有管理员可以更新字典") raise PermissionDenied("只有管理员可以更新字典")
serializer.save() serializer.save()
def perform_destroy(self, instance): def perform_destroy(self, instance):
# 只有管理员可以删除字典 # 只有管理员可以删除字典
if self.request.user.role != 'admin': if self.request.user.role != 'admin':
raise PermissionError("只有管理员可以删除字典") raise PermissionDenied("只有管理员可以删除字典")
instance.delete() instance.delete()
@ -66,7 +66,6 @@ def dictionary_grouped(request):
""" """
获取分组的数据字典 获取分组的数据字典
""" """
# 获取所有字典类型
dict_types = Dictionary.objects.values('type').distinct() dict_types = Dictionary.objects.values('type').distinct()
result = [] result = []

View File

@ -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.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.exceptions import PermissionDenied
from .models import Factory from .models import Factory
from .serializers import FactorySerializer, FactoryListSerializer from .serializers import FactorySerializer, FactoryListSerializer
@ -21,7 +22,7 @@ class FactoryListView(generics.ListCreateAPIView):
def perform_create(self, serializer): def perform_create(self, serializer):
# 只有管理员可以创建工厂 # 只有管理员可以创建工厂
if self.request.user.role != 'admin': if self.request.user.role != 'admin':
raise PermissionError("只有管理员可以创建工厂") raise PermissionDenied("只有管理员可以创建工厂")
serializer.save() serializer.save()
@ -37,13 +38,13 @@ class FactoryDetailView(generics.RetrieveUpdateDestroyAPIView):
# 普通用户只能修改自己所属工厂的信息 # 普通用户只能修改自己所属工厂的信息
if (self.request.user.role != 'admin' and if (self.request.user.role != 'admin' and
self.request.user.factory_id != self.get_object().id): self.request.user.factory_id != self.get_object().id):
raise PermissionError("无权修改其他工厂信息") raise PermissionDenied("无权修改其他工厂信息")
serializer.save() serializer.save()
def perform_destroy(self, instance): def perform_destroy(self, instance):
# 只有管理员可以删除工厂 # 只有管理员可以删除工厂
if self.request.user.role != 'admin': if self.request.user.role != 'admin':
raise PermissionError("只有管理员可以删除工厂") raise PermissionDenied("只有管理员可以删除工厂")
instance.delete() instance.delete()

View File

@ -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='应用场景'),
),
]

View File

@ -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',
},
),
]

View File

@ -47,10 +47,10 @@ class Material(models.Model):
material_subcategory = models.CharField(max_length=255, verbose_name='材料子分类') material_subcategory = models.CharField(max_length=255, verbose_name='材料子分类')
spec = models.CharField(max_length=255, blank=True, null=True, 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='符合标准') 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='应用场景说明') 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='替代材料类型') 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='优势说明') 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_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='成本说明') cost_desc = models.TextField(blank=True, null=True, verbose_name='成本说明')
@ -76,3 +76,40 @@ class Material(models.Model):
def __str__(self): def __str__(self):
return self.name 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

View File

@ -1,5 +1,16 @@
import json
from rest_framework import serializers 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): 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) 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) 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) 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 = JSONListField(
application_scene_display = serializers.CharField(source='get_application_scene_display', read_only=True) 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() brochure_url = serializers.SerializerMethodField()
class Meta: class Meta:
@ -37,6 +58,20 @@ class MaterialSerializer(serializers.ModelSerializer):
return request.build_absolute_uri(obj.brochure.url) return request.build_absolute_uri(obj.brochure.url)
return None 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): class MaterialListSerializer(serializers.ModelSerializer):
""" """
@ -52,3 +87,30 @@ class MaterialListSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'major_category', 'major_category_display', fields = ['id', 'name', 'major_category', 'major_category_display',
'material_category', 'material_subcategory', 'factory', 'material_category', 'material_subcategory', 'factory',
'factory_name', 'factory_short_name', 'status', 'status_display'] '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']

View File

@ -1,10 +1,14 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import MaterialViewSet from .views import MaterialViewSet, MaterialCategoryViewSet, MaterialSubcategoryViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r'', MaterialViewSet, basename='material') router.register(r'', MaterialViewSet, basename='material')
urlpatterns = [ 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)), path('', include(router.urls)),
] ]

View File

@ -1,10 +1,11 @@
from rest_framework import generics, status 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.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from .models import Material from rest_framework.exceptions import PermissionDenied
from .serializers import MaterialSerializer, MaterialListSerializer from .models import Material, MaterialCategory, MaterialSubcategory
from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer
class MaterialViewSet(ModelViewSet): class MaterialViewSet(ModelViewSet):
@ -28,6 +29,11 @@ class MaterialViewSet(ModelViewSet):
if status_filter: if status_filter:
queryset = queryset.filter(status=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') factory_id = self.request.query_params.get('factory_id')
if factory_id: if factory_id:
@ -38,11 +44,26 @@ class MaterialViewSet(ModelViewSet):
if major_category: if major_category:
queryset = queryset.filter(major_category=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') material_subcategory = self.request.query_params.get('material_subcategory')
if material_subcategory: if material_subcategory:
queryset = queryset.filter(material_subcategory=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 return queryset
def get_serializer_class(self): def get_serializer_class(self):
@ -70,7 +91,7 @@ class MaterialViewSet(ModelViewSet):
# 普通用户只能更新自己工厂的材料 # 普通用户只能更新自己工厂的材料
if (self.request.user.role != 'admin' and if (self.request.user.role != 'admin' and
self.request.user.factory_id != self.get_object().factory_id): self.request.user.factory_id != self.get_object().factory_id):
raise PermissionError("无权修改其他工厂的材料") raise PermissionDenied("无权修改其他工厂的材料")
serializer.save() serializer.save()
def perform_destroy(self, instance): def perform_destroy(self, instance):
@ -80,7 +101,7 @@ class MaterialViewSet(ModelViewSet):
# 普通用户只能删除自己工厂的材料 # 普通用户只能删除自己工厂的材料
if (self.request.user.role != 'admin' and if (self.request.user.role != 'admin' and
self.request.user.factory_id != instance.factory_id): self.request.user.factory_id != instance.factory_id):
raise PermissionError("无权删除其他工厂的材料") raise PermissionDenied("无权删除其他工厂的材料")
instance.delete() instance.delete()
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
@ -153,3 +174,72 @@ class MaterialViewSet(ModelViewSet):
material.status = 'draft' material.status = 'draft'
material.save() material.save()
return Response({"status": "审核拒绝"}) 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()

View File

@ -1,42 +1,50 @@
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response 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.material.models import Material
from apps.factory.models import Factory 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']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def overview_statistics(request): def overview_statistics(request):
""" """
数据总览统计 数据总览统计
""" """
# 只有管理员可访问 # 只有管理员可访问
if request.user.role != 'admin': if request.user.role != 'admin':
return Response({"detail": "无权访问"}, status=403) 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() 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') count=Count('id')
).order_by('-count') ).order_by('-count')
# 按材料子类的材料数量分布 # 按材料子类的材料数量分布
material_subcategory_stats = Material.objects.values('material_subcategory').annotate( material_subcategory_stats = approved_materials.values('material_subcategory').annotate(
count=Count('id') 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') count=Count('id')
).order_by('-count') ).order_by('-count')
@ -45,12 +53,16 @@ def overview_statistics(request):
count=Count('id') count=Count('id')
).order_by('-count') ).order_by('-count')
# 应用案例列表(有案例描述的材料) # 应用案例列表
cases_list = Material.objects.filter( cases_list = []
status='approved', for item in approved_materials.filter(cases__isnull=False).exclude(cases='')[:10]:
cases__isnull=False, cases_list.append({
cases__gt='' 'id': item.id,
).values('id', 'name', 'cases', 'factory__factory_name')[:10] '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({ return Response({
'total_materials': total_materials, 'total_materials': total_materials,
@ -60,7 +72,7 @@ def overview_statistics(request):
'material_subcategory_stats': list(material_subcategory_stats), 'material_subcategory_stats': list(material_subcategory_stats),
'brand_stats': list(brand_stats), 'brand_stats': list(brand_stats),
'region_stats': list(region_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': if request.user.role != 'admin':
return Response({"detail": "无权访问"}, status=403) return Response({"detail": "无权访问"}, status=403)
# 获取筛选条件
material_subcategory = request.query_params.get('material_subcategory') material_subcategory = request.query_params.get('material_subcategory')
# 基础查询
queryset = Material.objects.filter(status='approved') queryset = Material.objects.filter(status='approved')
# 按材料子类筛选
if material_subcategory: if material_subcategory:
queryset = queryset.filter(material_subcategory=material_subcategory) queryset = queryset.filter(material_subcategory=material_subcategory)
# 材料星级对比(按材料子类) subcategories = list(Material.objects.filter(status='approved').values_list(
star_stats = {} 'material_subcategory', flat=True
for subcategory in queryset.values_list('material_subcategory', flat=True).distinct(): ).distinct())
materials = queryset.filter(material_subcategory=subcategory)
star_stats[subcategory] = { def _level_counts(qs, field):
'quality_level': list(materials.values('quality_level').annotate(count=Count('id'))), raw = qs.values(field).annotate(count=Count('id'))
'durability_level': list(materials.values('durability_level').annotate(count=Count('id'))), mapping = {item[field]: item['count'] for item in raw if item[field] is not None}
'eco_level': list(materials.values('eco_level').annotate(count=Count('id'))), return [mapping.get(1, 0), mapping.get(2, 0), mapping.get(3, 0)]
'carbon_level': list(materials.values('carbon_level').annotate(count=Count('id'))),
'score_level': list(materials.values('score_level').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_stats = list(queryset.values('advantage', 'replace_type').annotate( advantage_replace_counter = {}
count=Count('id') 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 = [
application_scene_stats = list(queryset.values('application_scene').annotate( {'advantage': key[0], 'replace_type': key[1], 'count': count}
count=Count('id') 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( materials_list = []
'id', 'name', 'material_category', 'material_subcategory', for item in queryset[:20]:
'factory__factory_name', 'brochure' materials_list.append({
)[:20]) '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({ return Response({
'levels': [1, 2, 3],
'subcategories': subcategories,
'star_stats': star_stats, 'star_stats': star_stats,
'advantage_replace_stats': advantage_replace_stats, 'advantage_replace_stats': advantage_replace_stats,
'application_scene_stats': application_scene_stats, 'application_scene_stats': application_scene_stats,
'materials_list': materials_list, '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': if request.user.role != 'admin':
return Response({"detail": "无权访问"}, status=403) return Response({"detail": "无权访问"}, status=403)
# 工厂地区分布
region_stats = list(Factory.objects.values('province', 'city').annotate( region_stats = list(Factory.objects.values('province', 'city').annotate(
count=Count('id') count=Count('id')
).order_by('-count')) ).order_by('-count'))
# 工厂材料分类分布
factory_category_stats = [] factory_category_stats = []
approved_materials = Material.objects.filter(status='approved')
for factory in Factory.objects.all(): for factory in Factory.objects.all():
material_categories = Material.objects.filter(factory=factory).values( material_categories = approved_materials.filter(factory=factory).values(
'material_category' 'material_category'
).annotate( ).annotate(
count=Count('id') count=Count('id')
@ -148,10 +184,9 @@ def factory_statistics(request):
'factory_id': factory.id, 'factory_id': factory.id,
'factory_name': factory.factory_name, 'factory_name': factory.factory_name,
'categories': list(material_categories), 'categories': list(material_categories),
'total_materials': factory.materials.count() 'total_materials': approved_materials.filter(factory=factory).count()
}) })
# 工厂列表
factories_list = list(Factory.objects.values( factories_list = list(Factory.objects.values(
'id', 'factory_name', 'factory_short_name', 'province', 'city', 'website' 'id', 'factory_name', 'factory_short_name', 'province', 'city', 'website'
)) ))

View File

@ -163,4 +163,6 @@ CORS_ALLOWED_ORIGINS = [
"http://127.0.0.1:5173", "http://127.0.0.1:5173",
] ]
# 开发阶段可放开跨域,如需严格控制生产环境请关闭此项
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True

View File

@ -0,0 +1 @@
VITE_API_BASE_URL=http://127.0.0.1:8000/api

1
frontend/.env.production Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=https://api.example.com/api

12
frontend/index.html Normal file
View File

@ -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>

1736
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@ -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"
}
}

3
frontend/src/App.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

31
frontend/src/api/auth.js Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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>

View File

@ -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>

11
frontend/src/main.js Normal file
View File

@ -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')

View File

@ -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

View File

@ -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 }
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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(' ')
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

15
frontend/vite.config.js Normal file
View File

@ -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
}
})