Compare commits
7 Commits
d06cf5c270
...
85ed7a20c1
| Author | SHA1 | Date |
|---|---|---|
|
|
85ed7a20c1 | |
|
|
75dde5243e | |
|
|
d96140795e | |
|
|
08b855794f | |
|
|
d3ceaded07 | |
|
|
e378bb9e1c | |
|
|
60157025f2 |
|
|
@ -0,0 +1,30 @@
|
|||
# Generated migration for brand app
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Brand',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='品牌名称')),
|
||||
('description', models.TextField(blank=True, null=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': 'brand',
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
from django.db import models
|
||||
|
||||
|
||||
class Brand(models.Model):
|
||||
"""
|
||||
品牌模型
|
||||
"""
|
||||
name = models.CharField(max_length=100, unique=True, verbose_name='品牌名称')
|
||||
description = models.TextField(blank=True, null=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 = 'brand'
|
||||
ordering = ['id']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Brand
|
||||
|
||||
|
||||
class BrandSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
品牌序列化器
|
||||
"""
|
||||
material_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Brand
|
||||
fields = ['id', 'name', 'description', 'created_at', 'updated_at', 'material_count']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'material_count']
|
||||
|
||||
def get_material_count(self, obj):
|
||||
return obj.materials.count()
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import BrandViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'', BrandViewSet, basename='brand')
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
from django.db.models import ProtectedError
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from .models import Brand
|
||||
from .serializers import BrandSerializer
|
||||
|
||||
|
||||
class BrandViewSet(ModelViewSet):
|
||||
"""
|
||||
品牌视图集:所有已认证用户可读,仅管理员可写
|
||||
"""
|
||||
serializer_class = BrandSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Brand.objects.all()
|
||||
search = self.request.query_params.get('search')
|
||||
if search:
|
||||
queryset = queryset.filter(name__icontains=search)
|
||||
return queryset
|
||||
|
||||
def _check_admin(self, action_verb):
|
||||
if self.request.user.role != 'admin':
|
||||
raise PermissionDenied(f"只有管理员可以{action_verb}品牌")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
self._check_admin("创建")
|
||||
serializer.save()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
self._check_admin("修改")
|
||||
serializer.save()
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
if request.user.role != 'admin':
|
||||
raise PermissionDenied("只有管理员可以删除品牌")
|
||||
instance = self.get_object()
|
||||
try:
|
||||
instance.delete()
|
||||
except ProtectedError:
|
||||
return Response(
|
||||
{"detail": "该品牌下存在材料,无法删除"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
|
@ -219,7 +219,7 @@ class Command(BaseCommand):
|
|||
website = _normalize_website(r.website_raw)
|
||||
|
||||
factory, f_created = Factory.objects.get_or_create(
|
||||
brand=r.brand,
|
||||
short_name=r.brand,
|
||||
defaults={
|
||||
"factory_name": r.factory_name,
|
||||
"province": province or "未知",
|
||||
|
|
@ -255,10 +255,10 @@ class Command(BaseCommand):
|
|||
)
|
||||
if existing_user:
|
||||
ensured_users += 1
|
||||
creds.append((factory.brand, existing_user.username, ""))
|
||||
creds.append((factory.short_name, existing_user.username, ""))
|
||||
continue
|
||||
|
||||
base = _make_username_base(factory.brand)
|
||||
base = _make_username_base(factory.short_name)
|
||||
username = _unique_username(UserModel, base)
|
||||
password = fixed_password or secrets.token_urlsafe(12)
|
||||
|
||||
|
|
@ -269,7 +269,7 @@ class Command(BaseCommand):
|
|||
factory=factory,
|
||||
)
|
||||
created_users += 1
|
||||
creds.append((factory.brand, user.username, password))
|
||||
creds.append((factory.short_name, user.username, password))
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated migration for factory app
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('factory', '0003_rename_factory_short_name_to_brand'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='factory',
|
||||
old_name='brand',
|
||||
new_name='short_name',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='factory',
|
||||
name='short_name',
|
||||
field=models.CharField(max_length=100, unique=True, verbose_name='供应商简称'),
|
||||
),
|
||||
]
|
||||
|
|
@ -7,7 +7,7 @@ class Factory(models.Model):
|
|||
dealer_name = models.CharField(max_length=255, blank=True, null=True, verbose_name='经销商名称')
|
||||
product_category = models.CharField(max_length=255, blank=True, null=True, verbose_name='产品分类')
|
||||
factory_name = models.CharField(max_length=255, verbose_name='生产工厂全称')
|
||||
brand = models.CharField(max_length=100, unique=True, verbose_name='品牌')
|
||||
short_name = models.CharField(max_length=100, unique=True, verbose_name='供应商简称')
|
||||
province = models.CharField(max_length=50, verbose_name='省')
|
||||
city = models.CharField(max_length=50, verbose_name='市')
|
||||
district = models.CharField(max_length=50, blank=True, null=True, verbose_name='区')
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ class FactorySerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = Factory
|
||||
fields = ['id', 'dealer_name', 'product_category', 'factory_name',
|
||||
'brand', 'province', 'city', 'district',
|
||||
fields = ['id', 'dealer_name', 'product_category', 'factory_name',
|
||||
'short_name', 'province', 'city', 'district',
|
||||
'address', 'website', 'created_at', 'updated_at',
|
||||
'material_count', 'usernames']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'material_count', 'usernames']
|
||||
|
|
@ -32,7 +32,7 @@ class FactoryListSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = Factory
|
||||
fields = ['id', 'factory_name', 'brand', 'province', 'city', 'dealer_name', 'usernames']
|
||||
fields = ['id', 'factory_name', 'short_name', 'province', 'city', 'dealer_name', 'usernames']
|
||||
|
||||
def get_usernames(self, obj):
|
||||
return list(obj.users.values_list('username', flat=True))
|
||||
|
|
|
|||
|
|
@ -229,9 +229,9 @@ def _resolve_factory(
|
|||
break
|
||||
term_lower = term.lower()
|
||||
for factory in all_factories:
|
||||
brand = (factory.brand or "").lower()
|
||||
short_name = (factory.short_name or "").lower()
|
||||
full_name = (factory.factory_name or "").lower()
|
||||
if (brand and brand in term_lower) or (full_name and full_name in term_lower):
|
||||
if (short_name and short_name in term_lower) or (full_name and full_name in term_lower):
|
||||
matched_factory = factory
|
||||
break
|
||||
if matched_factory:
|
||||
|
|
|
|||
|
|
@ -195,15 +195,15 @@ class Command(BaseCommand):
|
|||
if f:
|
||||
factory_cache[brand_name] = f
|
||||
return f
|
||||
# 2) 模糊:工厂品牌包含 Excel 值(如 Excel「立邦」匹配「立邦中国」)
|
||||
f = Factory.objects.filter(brand__icontains=brand_name).first()
|
||||
# 2) 模糊:供应商简称包含 Excel 值(如 Excel「立邦」匹配「立邦中国」)
|
||||
f = Factory.objects.filter(short_name__icontains=brand_name).first()
|
||||
if f:
|
||||
factory_cache[brand_name] = f
|
||||
return f
|
||||
# 3) 模糊:Excel 值包含某工厂品牌(如 Excel「立邦中国\n广州立邦」取首行后仍可匹配)
|
||||
# 3) 模糊:Excel 值包含某供应商简称(如 Excel「立邦中国\n广州立邦」取首行后仍可匹配)
|
||||
brand_lower = brand_name.lower()
|
||||
for factory in Factory.objects.all():
|
||||
if factory.brand and factory.brand.lower() in brand_lower:
|
||||
if factory.short_name and factory.short_name.lower() in brand_lower:
|
||||
factory_cache[brand_name] = factory
|
||||
return factory
|
||||
factory_cache[brand_name] = None
|
||||
|
|
@ -212,7 +212,7 @@ class Command(BaseCommand):
|
|||
# 未识别品牌工厂:品牌列无法匹配到任一工厂时,统一关联到此工厂(不存在则创建)
|
||||
UNRECOGNIZED_BRAND = "未识别的品牌"
|
||||
unrecognized_factory, _ = Factory.objects.get_or_create(
|
||||
brand=UNRECOGNIZED_BRAND,
|
||||
short_name=UNRECOGNIZED_BRAND,
|
||||
defaults={
|
||||
"factory_name": "未识别的品牌工厂",
|
||||
"province": "-",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
# Generated migration for material app
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('brand', '0001_initial'),
|
||||
('material', '0006_alter_material_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='material',
|
||||
name='brand',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.deletion.PROTECT,
|
||||
related_name='materials',
|
||||
to='brand.brand',
|
||||
verbose_name='品牌',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Data migration: 从 Factory.short_name 的唯一值创建 Brand,并回填 Material.brand
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Factory = apps.get_model('factory', 'Factory')
|
||||
Brand = apps.get_model('brand', 'Brand')
|
||||
Material = apps.get_model('material', 'Material')
|
||||
|
||||
short_names = (
|
||||
Factory.objects
|
||||
.exclude(short_name__isnull=True)
|
||||
.exclude(short_name='')
|
||||
.values_list('short_name', flat=True)
|
||||
.distinct()
|
||||
)
|
||||
for sn in short_names:
|
||||
Brand.objects.get_or_create(name=sn)
|
||||
|
||||
for brand in Brand.objects.all():
|
||||
Material.objects.filter(factory__short_name=brand.name).update(brand=brand)
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
# schema migration 回滚时会删掉 Material.brand 字段,这里无需操作
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('material', '0007_add_brand_fk'),
|
||||
('factory', '0004_rename_brand_to_short_name'),
|
||||
('brand', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
|
|
@ -87,6 +87,7 @@ class Material(models.Model):
|
|||
construction_method = models.CharField(max_length=255, blank=True, null=True, verbose_name='施工工艺')
|
||||
limit_condition = models.TextField(blank=True, null=True, verbose_name='限制条件')
|
||||
factory = models.ForeignKey('factory.Factory', on_delete=models.CASCADE, related_name='materials', verbose_name='材料单位名称')
|
||||
brand = models.ForeignKey('brand.Brand', on_delete=models.PROTECT, null=True, blank=True, related_name='materials', verbose_name='品牌')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name='状态')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
from rest_framework import serializers
|
||||
from apps.brand.models import Brand
|
||||
from .models import Material, MaterialCategory, MaterialSubcategory
|
||||
|
||||
|
||||
|
|
@ -18,7 +19,11 @@ class MaterialSerializer(serializers.ModelSerializer):
|
|||
材料序列化器
|
||||
"""
|
||||
factory_name = serializers.CharField(source='factory.factory_name', read_only=True)
|
||||
brand = serializers.CharField(source='factory.brand', read_only=True)
|
||||
factory_short_name = serializers.CharField(source='factory.short_name', read_only=True)
|
||||
brand = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Brand.objects.all(), allow_null=True, required=False
|
||||
)
|
||||
brand_name = serializers.CharField(source='brand.name', read_only=True)
|
||||
major_category_display = serializers.CharField(source='get_major_category_display', read_only=True)
|
||||
replace_type_display = serializers.CharField(source='get_replace_type_display', read_only=True)
|
||||
stage_display = serializers.CharField(source='get_stage_display', read_only=True)
|
||||
|
|
@ -51,7 +56,8 @@ class MaterialSerializer(serializers.ModelSerializer):
|
|||
'advantage_desc', 'cost_compare', 'cost_desc', 'cases', 'brochure',
|
||||
'brochure_url', 'quality_level', 'durability_level', 'eco_level',
|
||||
'carbon_level', 'score_level', 'connection_method', 'construction_method',
|
||||
'limit_condition', 'factory', 'factory_name', 'brand',
|
||||
'limit_condition', 'factory', 'factory_name', 'factory_short_name',
|
||||
'brand', 'brand_name',
|
||||
'status', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
|
@ -82,7 +88,8 @@ class MaterialListSerializer(serializers.ModelSerializer):
|
|||
材料列表序列化器(简化版)
|
||||
"""
|
||||
factory_name = serializers.CharField(source='factory.factory_name', read_only=True)
|
||||
brand = serializers.CharField(source='factory.brand', read_only=True)
|
||||
factory_short_name = serializers.CharField(source='factory.short_name', read_only=True)
|
||||
brand_name = serializers.CharField(source='brand.name', read_only=True)
|
||||
major_category_display = serializers.CharField(source='get_major_category_display', read_only=True)
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
stage_display = serializers.CharField(source='get_stage_display', read_only=True)
|
||||
|
|
@ -94,7 +101,8 @@ class MaterialListSerializer(serializers.ModelSerializer):
|
|||
'material_category', 'material_subcategory', 'stage', 'stage_display',
|
||||
'importance_level', 'importance_level_display', 'landing_project',
|
||||
'contact_person', 'contact_phone', 'handler', 'remark', 'factory',
|
||||
'factory_name', 'brand', 'status', 'status_display']
|
||||
'factory_name', 'factory_short_name', 'brand', 'brand_name',
|
||||
'status', 'status_display']
|
||||
|
||||
|
||||
class MaterialCategorySerializer(serializers.ModelSerializer):
|
||||
|
|
|
|||
|
|
@ -170,6 +170,11 @@ class MaterialViewSet(ModelViewSet):
|
|||
if factory_id:
|
||||
queryset = queryset.filter(factory_id=factory_id)
|
||||
|
||||
# 支持按品牌过滤
|
||||
brand_id = self.request.query_params.get('brand')
|
||||
if brand_id:
|
||||
queryset = queryset.filter(brand_id=brand_id)
|
||||
|
||||
# 支持按专业类别过滤
|
||||
major_category = self.request.query_params.get('major_category')
|
||||
if major_category:
|
||||
|
|
@ -373,7 +378,7 @@ class MaterialViewSet(ModelViewSet):
|
|||
material.contact_person or "",
|
||||
material.contact_phone or "",
|
||||
material.handler or "",
|
||||
material.factory.brand if material.factory else "",
|
||||
material.factory.short_name if material.factory else "",
|
||||
material.factory.factory_name if material.factory else "",
|
||||
material.spec or "",
|
||||
material.standard or "",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from rest_framework.response import Response
|
|||
from django.db.models import Count
|
||||
from apps.material.models import Material
|
||||
from apps.factory.models import Factory
|
||||
from apps.brand.models import Brand
|
||||
|
||||
|
||||
def _build_brochure_url(request, brochure_field):
|
||||
|
|
@ -27,26 +28,42 @@ def overview_statistics(request):
|
|||
# 材料总数
|
||||
total_materials = approved_materials.count()
|
||||
|
||||
# 材料种类(材料子类数量)
|
||||
total_material_categories = approved_materials.values('material_subcategory').distinct().count()
|
||||
# 材料种类(细分种类数量,排除空值)
|
||||
total_material_categories = (
|
||||
approved_materials
|
||||
.exclude(material_category__isnull=True)
|
||||
.exclude(material_category='')
|
||||
.values('material_category')
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
# 品牌数(工厂数)
|
||||
total_brands = Factory.objects.count()
|
||||
# 品牌数
|
||||
total_brands = Brand.objects.count()
|
||||
|
||||
# 按专业类别的材料数量分布
|
||||
major_category_stats = approved_materials.values('major_category').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')
|
||||
|
||||
# 按材料子类的材料数量分布
|
||||
material_subcategory_stats = approved_materials.values('material_subcategory').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')[:10]
|
||||
# 按材料子类的材料数量分布(排除空值)
|
||||
material_subcategory_stats = (
|
||||
approved_materials
|
||||
.exclude(material_subcategory__isnull=True)
|
||||
.exclude(material_subcategory='')
|
||||
.values('material_subcategory')
|
||||
.annotate(count=Count('id'))
|
||||
.order_by('-count')[:10]
|
||||
)
|
||||
|
||||
# 按所属品牌的材料数量分布
|
||||
brand_stats = approved_materials.values('factory__factory_name').annotate(
|
||||
count=Count('id')
|
||||
).order_by('-count')
|
||||
# 按所属品牌的材料数量分布(排除无品牌的材料)
|
||||
brand_stats = (
|
||||
approved_materials
|
||||
.exclude(brand__isnull=True)
|
||||
.values('brand__name')
|
||||
.annotate(count=Count('id'))
|
||||
.order_by('-count')
|
||||
)
|
||||
|
||||
# 按省份的工厂数量分布
|
||||
region_stats = Factory.objects.values('province').annotate(
|
||||
|
|
@ -188,7 +205,7 @@ def factory_statistics(request):
|
|||
})
|
||||
|
||||
factories_list = list(Factory.objects.values(
|
||||
'id', 'factory_name', 'brand', 'province', 'city', 'website'
|
||||
'id', 'factory_name', 'short_name', 'province', 'city', 'website'
|
||||
))
|
||||
|
||||
return Response({
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ INSTALLED_APPS = [
|
|||
'corsheaders',
|
||||
'apps.authentication',
|
||||
'apps.factory',
|
||||
'apps.brand',
|
||||
'apps.material',
|
||||
'apps.dictionary',
|
||||
'apps.statistics',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ urlpatterns = [
|
|||
path('admin/', admin.site.urls),
|
||||
path('api/auth/', include('apps.authentication.urls')),
|
||||
path('api/factory/', include('apps.factory.urls')),
|
||||
path('api/brand/', include('apps.brand.urls')),
|
||||
path('api/material/', include('apps.material.urls')),
|
||||
path('api/dictionary/', include('apps.dictionary.urls')),
|
||||
path('api/statistics/', include('apps.statistics.urls')),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,279 @@
|
|||
# 品牌(Brand)实体设计
|
||||
|
||||
- 日期:2026-04-23
|
||||
- 作者:brainstorm 协作产出
|
||||
- 状态:待实施
|
||||
|
||||
## 背景
|
||||
|
||||
当前系统的 `Factory`(供应商库)表包含一个 `brand` 字段(唯一文本),`Material` 通过外键关联 `Factory`,品牌信息依附在供应商上。业务现实是:
|
||||
|
||||
- 一个供应商(经销商/工厂)可能代理多个品牌
|
||||
- 一个品牌可能由多个供应商代理
|
||||
- 即 Brand ↔ Factory 为多对多关系
|
||||
|
||||
因此需要把"品牌"从 `Factory` 中独立出来,成为一级实体,并让 `Material` 同时关联"供应商"和"品牌"。原 `Factory.brand` 字段语义上更接近"供应商简称",需要重命名以消除歧义。
|
||||
|
||||
## 目标
|
||||
|
||||
- 新增独立的 Brand 实体(品牌库)
|
||||
- Material 同时关联 Factory(供应商)和 Brand(品牌)
|
||||
- 历史数据无缝迁移到新结构
|
||||
- 前端增加"品牌库"菜单项(admin)
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不建立独立的 Brand-Factory 多对多关联表(多对多关系通过 Material 间接体现,避免冗余)
|
||||
- 不为 Brand 设计除 name/description 之外的字段(logo、官网等后续按需再加)
|
||||
- 不引入软删除机制
|
||||
|
||||
## 设计决策总表
|
||||
|
||||
| 维度 | 决策 | 理由 |
|
||||
|---|---|---|
|
||||
| Brand 字段 | `name`(唯一必填)、`description`(可选) | 需求明确只要最简字段 |
|
||||
| Brand-Factory 关系 | 业务上多对多,但不建 M2M 表 | 通过 Material 间接体现,避免冗余 |
|
||||
| Material.brand | `ForeignKey(Brand, on_delete=PROTECT, null=True, blank=True)` | DB 层可空以容纳历史数据,前端必填;PROTECT 防误删 |
|
||||
| Material 与 brand 多选 | 单选(ForeignKey) | 绝大多数材料现实中只属于一个品牌 |
|
||||
| Factory.brand 处理 | 重命名为 `short_name`(保持 unique) | 语义更准,一次性改清楚 |
|
||||
| 数据迁移 | 自动迁移(从 Factory.short_name 唯一值自动建 Brand 并回填 Material.brand) | 历史数据一步到位 |
|
||||
| 迁移颗粒度 | 单次大迁移(schema + data 合并发布) | 数据量小,一次到位更省事 |
|
||||
| 菜单位置 | 顶层一级菜单"品牌库"(供应商库与材料管理之间) | 作为核心业务数据,位置显眼 |
|
||||
| 权限 | 管理界面(品牌库菜单+CRUD 页面)仅 admin;读接口对所有已认证用户开放(供材料表单下拉读取) | 品牌是管理员维护的主数据,但非 admin 需要在录入材料时选择品牌 |
|
||||
| 品牌删除行为 | PROTECT,前端 catch 错误并提示 | 防止误删丢关联 |
|
||||
| 品牌库搜索 | 仅按 name 模糊搜索 | description 语义不适合搜索 |
|
||||
| Material 列表 | 增加"品牌"列 + 品牌筛选器 | 与现有 factory 筛选保持一致 |
|
||||
|
||||
## 整体架构
|
||||
|
||||
新增独立 Django app:`brand`,与 `factory`、`material` 同级。
|
||||
|
||||
```
|
||||
backend/apps/
|
||||
brand/ # 新增
|
||||
__init__.py
|
||||
models.py # Brand 模型
|
||||
serializers.py
|
||||
views.py
|
||||
urls.py
|
||||
migrations/
|
||||
0001_initial.py
|
||||
```
|
||||
|
||||
变更涉及三个后端 app:
|
||||
|
||||
- `brand`(新建):定义 Brand 模型、CRUD API
|
||||
- `factory`:模型字段 `brand` → `short_name`(schema migration)
|
||||
- `material`:新增 `brand = ForeignKey(Brand, …)` 字段 + 跨 app 的数据迁移
|
||||
|
||||
前端新增:`/brands` 路由 + `BrandManage.vue` 视图 + `api/brand.js`,菜单加一项"品牌库"(admin 可见);修改 `MaterialManage.vue` 增加品牌列 / 筛选器 / 表单下拉;同步改 `FactoryManage.vue` 文案。
|
||||
|
||||
## 后端设计
|
||||
|
||||
### Brand 模型
|
||||
|
||||
```python
|
||||
# backend/apps/brand/models.py
|
||||
from django.db import models
|
||||
|
||||
class Brand(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True, verbose_name='品牌名称')
|
||||
description = models.TextField(blank=True, null=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 = 'brand'
|
||||
ordering = ['id']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
```
|
||||
|
||||
### Factory 字段重命名
|
||||
|
||||
```python
|
||||
# backend/apps/factory/models.py
|
||||
# 原:brand = models.CharField(max_length=100, unique=True, verbose_name='品牌')
|
||||
# 新:
|
||||
short_name = models.CharField(max_length=100, unique=True, verbose_name='供应商简称')
|
||||
```
|
||||
|
||||
### Material 新增外键
|
||||
|
||||
```python
|
||||
# backend/apps/material/models.py
|
||||
brand = models.ForeignKey(
|
||||
'brand.Brand',
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='materials',
|
||||
verbose_name='品牌',
|
||||
)
|
||||
```
|
||||
|
||||
### 迁移顺序
|
||||
|
||||
仓库现有 migration 最新状态:`factory/0003_rename_factory_short_name_to_brand.py`(此前字段原名 `factory_short_name`,在 0003 被 rename 为 `brand`;本次是反向 rename,恢复为 `short_name` 以消除歧义),`material/0006_alter_material_options_and_more.py`。因此本次新增 migration 编号如下:
|
||||
|
||||
1. `brand/migrations/0001_initial.py` — 建 Brand 表
|
||||
2. `factory/migrations/0004_rename_brand_to_short_name.py` — 重命名字段(手写 `migrations.RenameField`,避免依赖 makemigrations 的交互式识别;dependencies 指向 `('factory', '0003_rename_factory_short_name_to_brand')`)
|
||||
3. `material/migrations/0007_add_brand_fk.py` — 新增 Material.brand 外键(依赖 `brand.0001` 与 `material.0006_alter_material_options_and_more`)
|
||||
4. `material/migrations/0008_populate_brand.py` — 数据迁移(`RunPython`,依赖 `factory.0004`、`material.0007`、`brand.0001`)
|
||||
|
||||
### 数据迁移脚本
|
||||
|
||||
```python
|
||||
# material/migrations/0008_populate_brand.py
|
||||
from django.db import migrations
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Factory = apps.get_model('factory', 'Factory')
|
||||
Brand = apps.get_model('brand', 'Brand')
|
||||
Material = apps.get_model('material', 'Material')
|
||||
|
||||
# 1) 从 Factory.short_name 唯一值创建 Brand
|
||||
short_names = (
|
||||
Factory.objects
|
||||
.exclude(short_name__isnull=True)
|
||||
.exclude(short_name='')
|
||||
.values_list('short_name', flat=True)
|
||||
.distinct()
|
||||
)
|
||||
for sn in short_names:
|
||||
Brand.objects.get_or_create(name=sn)
|
||||
|
||||
# 2) 回填 Material.brand(按品牌批量 update,避免逐条 save)
|
||||
for brand in Brand.objects.all():
|
||||
Material.objects.filter(factory__short_name=brand.name).update(brand=brand)
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
# schema migration 回滚时会删掉 Material.brand 字段,这里无需操作
|
||||
pass
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('material', '0007_add_brand_fk'),
|
||||
('factory', '0004_rename_brand_to_short_name'),
|
||||
('brand', '0001_initial'),
|
||||
]
|
||||
operations = [migrations.RunPython(forwards, backwards)]
|
||||
```
|
||||
|
||||
### API 端点
|
||||
|
||||
`/api/brand/` 权限约定:**所有已认证用户可读,仅 admin 可写**(非 admin 用户在材料表单的品牌下拉需要读这个接口,所以不能整体 admin-only)。"决策总表"里的"仅 admin 可见可编辑"特指前端**菜单与管理界面**的可见/编辑权限,不影响读接口。
|
||||
|
||||
- `GET /api/brand/` — 列表,支持 `search=<name>` 模糊搜索与分页(已认证用户)
|
||||
- `POST /api/brand/` — 创建(admin)
|
||||
- `GET /api/brand/{id}/` — 详情(已认证用户)
|
||||
- `PUT /api/brand/{id}/` — 更新(admin)
|
||||
- `DELETE /api/brand/{id}/` — 删除(admin);若被 Material 引用则 PROTECT 报错,viewset 需捕获 `ProtectedError` 返回 400 + 中文提示
|
||||
|
||||
Material 现有接口调整:
|
||||
|
||||
- `GET /api/material/` 新增 `brand` query param(按品牌 id 过滤)
|
||||
- Material serializer 增加 `brand` 字段(读:嵌套 `{id, name}`;写:接受 id)
|
||||
|
||||
Factory 接口调整:
|
||||
|
||||
- serializer / filterset / ordering 中所有 `brand` 引用同步改为 `short_name`
|
||||
|
||||
## 前端设计
|
||||
|
||||
### 新增文件
|
||||
|
||||
- `frontend/src/api/brand.js` — 封装 `listBrands` / `createBrand` / `updateBrand` / `deleteBrand`
|
||||
- `frontend/src/views/BrandManage.vue` — 品牌库页面(参考 `DictionaryManage.vue` 风格)
|
||||
|
||||
### 路由与菜单
|
||||
|
||||
- `router/index.js`:MainLayout 的 children 中加 `{ path: 'brands', name: 'brands', component: () => import('@/views/BrandManage.vue'), meta: { admin: true } }`
|
||||
- `layouts/MainLayout.vue`:在"供应商库"与"材料管理"之间加 `<el-menu-item v-if="isAdmin" index="/brands">品牌库</el-menu-item>`
|
||||
|
||||
菜单顺序:用户管理 / 供应商库 / **品牌库** / 材料管理 / 数据大屏 / 配置项。
|
||||
|
||||
### BrandManage.vue 结构
|
||||
|
||||
- 顶部工具栏:按名称模糊搜索输入框 + "新增品牌"按钮
|
||||
- 表格列:ID / 品牌名称 / 品牌描述 / 创建时间 / 操作(编辑 / 删除)
|
||||
- 弹窗表单:
|
||||
- 名称(`required`,唯一冲突时后端返回错误,前端 catch 并提示)
|
||||
- 描述(textarea,可选)
|
||||
- 分页
|
||||
- 删除确认:点删除先 confirm,后端返回 400(PROTECT 触发)时 catch 并提示"该品牌下存在材料,无法删除"
|
||||
|
||||
### MaterialManage.vue 改动
|
||||
|
||||
- 列表页:
|
||||
- 新增"品牌"列,展示 `row.brand?.name`
|
||||
- 筛选区新增"品牌"下拉(数据从 `/api/brand/` 拉取,支持远程搜索)
|
||||
- 新增 / 编辑弹窗表单:
|
||||
- 增加"品牌"字段,`el-select` 远程搜索,前端标记 `required`
|
||||
- 位置紧挨"供应商"字段后
|
||||
- 提交时把 brand(id)纳入 payload
|
||||
|
||||
### MaterialDetail.vue 改动
|
||||
|
||||
- 详情页展示"品牌"字段,位置紧挨"供应商"
|
||||
|
||||
### FactoryManage.vue 改动
|
||||
|
||||
- 表格列 / 表单 label / 搜索筛选器中所有"品牌"文案改为"供应商简称"
|
||||
- 字段 key 从 `brand` 改为 `short_name`
|
||||
- 用 grep 扫一遍 `frontend/src` 确保无遗漏
|
||||
|
||||
## 实施步骤
|
||||
|
||||
严格按以下顺序执行,便于单步验证:
|
||||
|
||||
1. **后端模型与迁移**
|
||||
- 创建 `backend/apps/brand/` app 骨架
|
||||
- 在 `config/settings.py` 的 `INSTALLED_APPS` 注册 `apps.brand`
|
||||
- 在 `config/urls.py` 挂载 `path('api/brand/', include('apps.brand.urls'))`
|
||||
- 生成 Brand `0001_initial`
|
||||
- 修改 Factory 模型,手写 rename migration
|
||||
- 修改 Material 模型,生成 add FK migration
|
||||
- 写 `populate_brand` data migration
|
||||
- 本地 `python manage.py migrate` 验证
|
||||
|
||||
2. **后端 API & serializer**
|
||||
- Brand CRUD(admin 权限)
|
||||
- Material serializer 增加 brand 字段;filterset 增加 brand
|
||||
- Factory serializer / view 同步把 brand → short_name
|
||||
|
||||
3. **前端品牌库**
|
||||
- `api/brand.js` + `BrandManage.vue` + 路由 + 菜单
|
||||
|
||||
4. **前端材料页面改造**
|
||||
- MaterialManage 列表列 / 筛选器 / 表单下拉
|
||||
- MaterialDetail 显示品牌
|
||||
- FactoryManage 文案同步
|
||||
|
||||
5. **联调验证**
|
||||
- 品牌库看到从 Factory.short_name 迁移出的品牌
|
||||
- 材料列表品牌列已填
|
||||
- 新增材料时品牌下拉可用并必填
|
||||
- 删除被引用的品牌应失败并提示
|
||||
|
||||
## 风险与应对
|
||||
|
||||
- **Django rename migration 的交互坑**:`makemigrations` 会交互询问是否重命名,自动化环境不友好。**应对**:手写 `migrations.RenameField`。
|
||||
- **数据迁移边界**:`Factory.short_name` 为空 / None 时跳过;多个 Factory 相同 short_name 时 `get_or_create` 正确;Material 无 factory 时 brand 留空。
|
||||
- **前端 FactoryManage 对 brand 的遗留引用**:rename 时前端所有 `brand` 引用都要同步改,grep 检查避免漏网。注意 `frontend/src/styles/base.css` 和 `MainLayout.vue` 有 `--brand-900` / `--brand-950` CSS 变量,是设计 token 命名,与业务字段无关,grep 时需排除。
|
||||
- **品牌删除 PROTECT 错误**:DRF 默认 `ProtectedError` 抛 500。**应对**:viewset 的 `destroy` 捕获并返回 400 + 中文提示。
|
||||
- **回滚路径**:顺序反向 `migrate` 即可(material → factory → brand zero)。`backwards` 保持 no-op。
|
||||
|
||||
## 测试计划
|
||||
|
||||
- **迁移正确性**(在本地库上跑一次并验证):
|
||||
- Brand 记录数 = Factory.short_name 非空唯一值数
|
||||
- 所有 factory 有 short_name 的 Material,brand 非空
|
||||
- 同名 Factory.short_name 不会生成重复 Brand
|
||||
- **品牌库 CRUD 手工测试**:新增 / 编辑 / 搜索 / 分页 / 删除(有无引用两种情况)
|
||||
- **材料页**:品牌列显示 / 筛选生效 / 表单下拉可用 / 必填校验生效 / 提交成功
|
||||
- **Factory 重命名**:前端 FactoryManage 正常工作,无遗留 `brand` 引用
|
||||
- **权限**:非 admin 无法访问 `/brands` 路由,无法看到菜单项
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import api from './client'
|
||||
|
||||
export const fetchBrands = async (params) => {
|
||||
const { data } = await api.get('/brand/', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchBrandDetail = async (id) => {
|
||||
const { data } = await api.get(`/brand/${id}/`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const createBrand = async (payload) => {
|
||||
const { data } = await api.post('/brand/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const updateBrand = async (id, payload) => {
|
||||
const { data } = await api.put(`/brand/${id}/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteBrand = async (id) => {
|
||||
const { data } = await api.delete(`/brand/${id}/`)
|
||||
return data
|
||||
}
|
||||
|
|
@ -20,6 +20,9 @@ api.interceptors.response.use(
|
|||
if (error.response?.status === 401) {
|
||||
const { clearAuth } = useAuth()
|
||||
clearAuth()
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.replace('/login')
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,40 @@
|
|||
<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="openPassword">修改密码</el-button>
|
||||
<el-button size="small" @click="onLogout">退出</el-button>
|
||||
<header class="topbar">
|
||||
<div class="logo-title">房地产新材料选材管理数据系统</div>
|
||||
<div class="user">
|
||||
<div class="user-info">
|
||||
<div class="name">{{ user?.username || '用户' }}</div>
|
||||
<div class="role">{{ isAdmin ? '管理员' : '普通账号' }}</div>
|
||||
</div>
|
||||
</header>
|
||||
<section class="content">
|
||||
<router-view />
|
||||
</section>
|
||||
</main>
|
||||
<el-button size="small" @click="openPassword">修改密码</el-button>
|
||||
<el-button size="small" @click="onLogout">退出</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body">
|
||||
<aside class="sidebar">
|
||||
<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="/brands">品牌库</el-menu-item>
|
||||
<el-menu-item index="/materials">材料管理</el-menu-item>
|
||||
<el-menu-item v-if="isAdmin" index="/screen/overview">数据大屏</el-menu-item>
|
||||
<el-sub-menu v-if="isAdmin" index="config">
|
||||
<template #title>配置项</template>
|
||||
<el-menu-item index="/dictionary">材料分类</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<section class="content">
|
||||
<router-view />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="passwordVisible" title="修改密码" width="420px" class="dialog-scroll">
|
||||
|
|
@ -66,13 +68,6 @@ 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 passwordVisible = ref(false)
|
||||
|
|
@ -108,36 +103,69 @@ const onLogout = () => {
|
|||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.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);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--brand-900, #1b2a41);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
text-align: right;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-info .name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-info .role {
|
||||
font-size: 12px;
|
||||
color: #6b7785;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
|
|
@ -156,6 +184,24 @@ const onLogout = () => {
|
|||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu__title) {
|
||||
color: #fff;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu__title:hover) {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
||||
color: #7cb4e3;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu .el-menu) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
|
@ -163,34 +209,10 @@ const onLogout = () => {
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const routes = [
|
|||
{ 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: 'brands', name: 'brands', component: () => import('@/views/BrandManage.vue'), meta: { admin: true } },
|
||||
{ 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') }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">品牌库</div>
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="filters.search"
|
||||
placeholder="按品牌名称搜索"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
@keyup.enter="onSearch"
|
||||
@clear="onSearch"
|
||||
/>
|
||||
<el-button type="primary" @click="onSearch">搜索</el-button>
|
||||
<el-button v-if="isAdmin" type="primary" @click="openCreate">新增品牌</el-button>
|
||||
</div>
|
||||
<el-table v-loading="tableLoading" :data="brands" border :max-height="560">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="品牌名称" min-width="180" />
|
||||
<el-table-column prop="description" label="品牌描述" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="material_count" label="关联材料数" width="120" />
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" v-if="isAdmin">
|
||||
<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>
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
@current-change="onPageChange"
|
||||
@size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px" class="dialog-scroll">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="品牌名称" required>
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="品牌描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="4" />
|
||||
</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 { useAuth } from '@/store/auth'
|
||||
import { fetchBrands, createBrand, updateBrand, deleteBrand } from '@/api/brand'
|
||||
|
||||
const { isAdmin } = useAuth()
|
||||
const brands = ref([])
|
||||
const tableLoading = ref(false)
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
const filters = reactive({ search: '' })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref(null)
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const formatDate = (val) => {
|
||||
if (!val) return ''
|
||||
const d = new Date(val)
|
||||
if (Number.isNaN(d.getTime())) return val
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
const loadBrands = async () => {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize
|
||||
}
|
||||
if (filters.search) params.search = filters.search
|
||||
const data = await fetchBrands(params)
|
||||
brands.value = data.results || data
|
||||
pagination.total = data.count || brands.value.length
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm()
|
||||
isEdit.value = false
|
||||
currentId.value = null
|
||||
dialogTitle.value = '新增品牌'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEdit = (row) => {
|
||||
resetForm()
|
||||
isEdit.value = true
|
||||
currentId.value = row.id
|
||||
form.name = row.name || ''
|
||||
form.description = row.description || ''
|
||||
dialogTitle.value = '编辑品牌'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
ElMessage.warning('请填写品牌名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload = { name: form.name.trim(), description: form.description || '' }
|
||||
if (isEdit.value) {
|
||||
await updateBrand(currentId.value, payload)
|
||||
} else {
|
||||
await createBrand(payload)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadBrands()
|
||||
} catch (error) {
|
||||
const detail = error.response?.data?.detail
|
||||
const nameErr = error.response?.data?.name?.[0]
|
||||
ElMessage.error(detail || nameErr || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = (row) => {
|
||||
ElMessageBox.confirm(`确认删除品牌 ${row.name} 吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await deleteBrand(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadBrands()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '删除失败')
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const onSearch = () => {
|
||||
pagination.page = 1
|
||||
loadBrands()
|
||||
}
|
||||
|
||||
const onPageChange = (page) => {
|
||||
pagination.page = page
|
||||
loadBrands()
|
||||
}
|
||||
|
||||
const onPageSizeChange = (size) => {
|
||||
pagination.pageSize = size
|
||||
pagination.page = 1
|
||||
loadBrands()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBrands()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.dialog-scroll :deep(.el-dialog__body) {
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">
|
||||
工厂详情
|
||||
供应商详情
|
||||
<el-button class="back-btn" plain size="small" @click="goBack">返回</el-button>
|
||||
</div>
|
||||
<div class="card detail-card" v-if="factory">
|
||||
<el-descriptions :column="1" border class="detail-descriptions">
|
||||
<el-descriptions-item label="工厂全称">{{ displayText(factory.factory_name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="品牌">{{ displayText(factory.brand) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="供应商全称">{{ displayText(factory.factory_name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="供应商简称">{{ displayText(factory.short_name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="经销商">{{ displayText(factory.dealer_name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="产品分类">{{ displayText(factory.product_category) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地区">{{ displayRegion(factory) }}</el-descriptions-item>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">工厂管理</div>
|
||||
<div class="page-title">供应商库</div>
|
||||
<div class="toolbar">
|
||||
<el-button v-if="isAdmin" type="primary" @click="openCreate">新增工厂</el-button>
|
||||
<el-button v-if="isAdmin" type="primary" @click="openCreate">新增供应商</el-button>
|
||||
</div>
|
||||
<el-table v-loading="tableLoading" :data="factories" border :max-height="560">
|
||||
<el-table-column prop="factory_name" label="工厂全称" />
|
||||
<el-table-column prop="brand" label="品牌" />
|
||||
<el-table-column prop="factory_name" label="供应商全称" />
|
||||
<el-table-column prop="short_name" label="供应商简称" />
|
||||
<el-table-column prop="dealer_name" label="经销商" />
|
||||
<el-table-column label="用户账号">
|
||||
<template #default="scope">
|
||||
|
|
@ -49,11 +49,11 @@
|
|||
<el-form-item label="产品分类">
|
||||
<el-input v-model="form.product_category" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工厂全称" required>
|
||||
<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.brand" />
|
||||
<el-form-item label="供应商简称" required>
|
||||
<el-input v-model="form.short_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="省市区" required>
|
||||
<el-cascader
|
||||
|
|
@ -109,7 +109,7 @@ const form = reactive({
|
|||
dealer_name: '',
|
||||
product_category: '',
|
||||
factory_name: '',
|
||||
brand: '',
|
||||
short_name: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
|
|
@ -132,7 +132,7 @@ const resetForm = () => {
|
|||
form.dealer_name = ''
|
||||
form.product_category = ''
|
||||
form.factory_name = ''
|
||||
form.brand = ''
|
||||
form.short_name = ''
|
||||
form.province = ''
|
||||
form.city = ''
|
||||
form.district = ''
|
||||
|
|
@ -150,7 +150,7 @@ const onRegionChange = (val) => {
|
|||
const openCreate = () => {
|
||||
resetForm()
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增工厂'
|
||||
dialogTitle.value = '新增供应商'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ const openEdit = async (row) => {
|
|||
const detail = await fetchFactoryDetail(row.id)
|
||||
Object.assign(form, detail)
|
||||
regionValue.value = [detail.province, detail.city, detail.district].filter(Boolean).map(regionLabel)
|
||||
dialogTitle.value = '编辑工厂'
|
||||
dialogTitle.value = '编辑供应商'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +181,7 @@ const onSubmit = async () => {
|
|||
}
|
||||
|
||||
const onDelete = (row) => {
|
||||
ElMessageBox.confirm(`确认删除工厂 ${row.factory_name} 吗?`, '提示', { type: 'warning' })
|
||||
ElMessageBox.confirm(`确认删除供应商 ${row.factory_name} 吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
await deleteFactory(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@
|
|||
<el-descriptions-item label="成本对比">{{ formatPercent(material.cost_compare) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="成本说明">{{ displayText(material.cost_desc) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="案例">{{ displayText(material.cases) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属工厂">{{ displayText(material.factory_name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属供应商">{{ displayText(material.factory_name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="品牌">{{ displayText(material.brand_name) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="质量等级">{{ formatStarLevel(material.quality_level) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="耐久等级">{{ formatStarLevel(material.durability_level) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="环保等级">{{ formatStarLevel(material.eco_level) }}</el-descriptions-item>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,18 @@
|
|||
<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-select
|
||||
v-model="filters.brand"
|
||||
placeholder="品牌"
|
||||
clearable
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchBrands"
|
||||
:loading="brandSearchLoading"
|
||||
style="width: 180px"
|
||||
>
|
||||
<el-option v-for="item in brandFilterOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
<el-button @click="loadMaterials">查询</el-button>
|
||||
<el-button v-if="isAdmin" :loading="importing" @click="importDialogVisible = true">
|
||||
{{ importing ? '导入中...' : '导入数据' }}
|
||||
|
|
@ -32,7 +44,8 @@
|
|||
<el-table-column prop="contact_phone" label="对接人联系方式" width="130px"/>
|
||||
<el-table-column prop="handler" label="经办人" />
|
||||
<el-table-column prop="remark" label="备注" />
|
||||
<el-table-column prop="brand" label="材料单位名称" />
|
||||
<el-table-column prop="factory_short_name" label="材料单位名称" />
|
||||
<el-table-column prop="brand_name" label="品牌" />
|
||||
<el-table-column prop="status_display" label="状态" width="120" />
|
||||
<el-table-column label="操作" width="320">
|
||||
<template #default="scope">
|
||||
|
|
@ -191,7 +204,19 @@
|
|||
</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.brand" :value="item.id" />
|
||||
<el-option v-for="item in factories" :key="item.id" :label="item.short_name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="品牌" required>
|
||||
<el-select
|
||||
v-model="form.brand"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchBrandsForForm"
|
||||
:loading="brandFormSearchLoading"
|
||||
placeholder="请选择品牌"
|
||||
>
|
||||
<el-option v-for="item in brandFormOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
|
@ -230,6 +255,7 @@ import { useAuth } from '@/store/auth'
|
|||
import { fetchMaterials, fetchMaterialDetail, createMaterial, updateMaterial, deleteMaterial, submitMaterial, approveMaterial, rejectMaterial, fetchMaterialChoices, uploadImage, importMaterialsExcel, exportMaterialsExcel } from '@/api/material'
|
||||
import { fetchCategories, fetchSubcategories } from '@/api/category'
|
||||
import { fetchFactorySimple } from '@/api/factory'
|
||||
import { fetchBrands } from '@/api/brand'
|
||||
|
||||
const router = useRouter()
|
||||
const { isAdmin } = useAuth()
|
||||
|
|
@ -255,9 +281,15 @@ const templateDownloadUrl = `http://101.42.1.64:2260/media/material_import_templ
|
|||
const filters = reactive({
|
||||
name: '',
|
||||
status: '',
|
||||
material_subcategory: ''
|
||||
material_subcategory: '',
|
||||
brand: ''
|
||||
})
|
||||
|
||||
const brandFilterOptions = ref([])
|
||||
const brandFormOptions = ref([])
|
||||
const brandSearchLoading = ref(false)
|
||||
const brandFormSearchLoading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
major_category: '',
|
||||
|
|
@ -290,7 +322,8 @@ const form = reactive({
|
|||
connection_method: '',
|
||||
construction_method: '',
|
||||
limit_condition: '',
|
||||
factory: null
|
||||
factory: null,
|
||||
brand: null
|
||||
})
|
||||
|
||||
const majorOptions = ref([])
|
||||
|
|
@ -392,10 +425,43 @@ const resetForm = () => {
|
|||
connection_method: '',
|
||||
construction_method: '',
|
||||
limit_condition: '',
|
||||
factory: null
|
||||
factory: null,
|
||||
brand: null
|
||||
})
|
||||
}
|
||||
|
||||
const loadBrandFilterOptions = async () => {
|
||||
const data = await fetchBrands({ page_size: 100 })
|
||||
brandFilterOptions.value = data.results || data
|
||||
}
|
||||
|
||||
const searchBrands = async (query) => {
|
||||
brandSearchLoading.value = true
|
||||
try {
|
||||
const data = await fetchBrands({ page_size: 50, search: query || '' })
|
||||
brandFilterOptions.value = data.results || data
|
||||
} finally {
|
||||
brandSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchBrandsForForm = async (query) => {
|
||||
brandFormSearchLoading.value = true
|
||||
try {
|
||||
const data = await fetchBrands({ page_size: 50, search: query || '' })
|
||||
brandFormOptions.value = data.results || data
|
||||
} finally {
|
||||
brandFormSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ensureBrandInFormOptions = (brandId, brandName) => {
|
||||
if (!brandId) return
|
||||
if (!brandFormOptions.value.some((item) => item.id === brandId)) {
|
||||
brandFormOptions.value = [{ id: brandId, name: brandName || '' }, ...brandFormOptions.value]
|
||||
}
|
||||
}
|
||||
|
||||
const onCategoryChange = async (val, resetSub = true) => {
|
||||
if (!val) {
|
||||
await loadSubcategories()
|
||||
|
|
@ -410,10 +476,11 @@ const onCategoryChange = async (val, resetSub = true) => {
|
|||
}
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
const openCreate = async () => {
|
||||
resetForm()
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增材料'
|
||||
await searchBrandsForForm('')
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
|
|
@ -426,9 +493,12 @@ const openEdit = async (row) => {
|
|||
form.application_scene = item.application_scene || []
|
||||
form.advantage = item.advantage || []
|
||||
form.brochure_url = item.brochure_url || ''
|
||||
form.brand = item.brand || null
|
||||
if (form.material_category) {
|
||||
await onCategoryChange(form.material_category, false)
|
||||
}
|
||||
await searchBrandsForForm('')
|
||||
ensureBrandInFormOptions(item.brand, item.brand_name)
|
||||
dialogTitle.value = '编辑材料'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
|
@ -448,9 +518,14 @@ const handleUpload = async (options) => {
|
|||
}
|
||||
|
||||
const onSave = async () => {
|
||||
if (!form.brand) {
|
||||
ElMessage.warning('请选择品牌')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload = { ...form }
|
||||
delete payload.brochure_url
|
||||
delete payload.brand_name
|
||||
if (!isAdmin.value) {
|
||||
delete payload.factory
|
||||
}
|
||||
|
|
@ -475,7 +550,7 @@ const handleImportExcel = async (options) => {
|
|||
pagination.page = 1
|
||||
await loadMaterials()
|
||||
importDialogVisible.value = false
|
||||
ElMessage.success(`导入完成:新增 ${result.created} 条,更新 ${result.updated} 条,跳过 ${result.skipped} 条,新建工厂 ${result.created_factory || 0} 条`)
|
||||
ElMessage.success(`导入完成:新增 ${result.created} 条,更新 ${result.updated} 条,跳过 ${result.skipped} 条,新建供应商 ${result.created_factory || 0} 条`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '导入失败')
|
||||
} finally {
|
||||
|
|
@ -580,6 +655,7 @@ onMounted(() => {
|
|||
loadCategories()
|
||||
loadSubcategories()
|
||||
loadFactories()
|
||||
loadBrandFilterOptions()
|
||||
loadMaterials()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
{{ scope.row.role === 'admin' ? '管理员' : '普通账号' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="factory_name" label="所属工厂" />
|
||||
<el-table-column prop="factory_name" label="所属供应商" />
|
||||
<el-table-column prop="phone" label="手机" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column label="操作" width="240">
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
<el-option label="普通账号" value="user" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属工厂" v-if="form.role === 'user'" required>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ const updateCharts = () => {
|
|||
})
|
||||
|
||||
const brandData = (stats.value.brand_stats || []).map((item) => ({
|
||||
name: item.factory__factory_name,
|
||||
name: item.brand__name,
|
||||
value: item.count
|
||||
}))
|
||||
charts[2].setOption({
|
||||
|
|
|
|||
Loading…
Reference in New Issue