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