Compare commits

..

No commits in common. "85ed7a20c1000d161ab826cc166eac452ff8bb5b" and "d06cf5c270de0db98fb06b0185406349d6b8a470" have entirely different histories.

33 changed files with 137 additions and 1001 deletions

View File

@ -1,30 +0,0 @@
# 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'],
},
),
]

View File

@ -1,20 +0,0 @@
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

View File

@ -1,17 +0,0 @@
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()

View File

@ -1,8 +0,0 @@
from rest_framework.routers import DefaultRouter
from .views import BrandViewSet
router = DefaultRouter()
router.register(r'', BrandViewSet, basename='brand')
urlpatterns = router.urls

View File

@ -1,49 +0,0 @@
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)

View File

@ -219,7 +219,7 @@ class Command(BaseCommand):
website = _normalize_website(r.website_raw) website = _normalize_website(r.website_raw)
factory, f_created = Factory.objects.get_or_create( factory, f_created = Factory.objects.get_or_create(
short_name=r.brand, brand=r.brand,
defaults={ defaults={
"factory_name": r.factory_name, "factory_name": r.factory_name,
"province": province or "未知", "province": province or "未知",
@ -255,10 +255,10 @@ class Command(BaseCommand):
) )
if existing_user: if existing_user:
ensured_users += 1 ensured_users += 1
creds.append((factory.short_name, existing_user.username, "")) creds.append((factory.brand, existing_user.username, ""))
continue continue
base = _make_username_base(factory.short_name) base = _make_username_base(factory.brand)
username = _unique_username(UserModel, base) username = _unique_username(UserModel, base)
password = fixed_password or secrets.token_urlsafe(12) password = fixed_password or secrets.token_urlsafe(12)
@ -269,7 +269,7 @@ class Command(BaseCommand):
factory=factory, factory=factory,
) )
created_users += 1 created_users += 1
creds.append((factory.short_name, user.username, password)) creds.append((factory.brand, user.username, password))
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(

View File

@ -1,23 +0,0 @@
# 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='供应商简称'),
),
]

View File

@ -7,7 +7,7 @@ class Factory(models.Model):
dealer_name = models.CharField(max_length=255, blank=True, null=True, verbose_name='经销商名称') 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='产品分类') product_category = models.CharField(max_length=255, blank=True, null=True, verbose_name='产品分类')
factory_name = models.CharField(max_length=255, verbose_name='生产工厂全称') factory_name = models.CharField(max_length=255, verbose_name='生产工厂全称')
short_name = models.CharField(max_length=100, unique=True, verbose_name='供应商简称') brand = models.CharField(max_length=100, unique=True, verbose_name='品牌')
province = models.CharField(max_length=50, verbose_name='') province = models.CharField(max_length=50, verbose_name='')
city = 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='') district = models.CharField(max_length=50, blank=True, null=True, verbose_name='')

View File

@ -11,8 +11,8 @@ class FactorySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Factory model = Factory
fields = ['id', 'dealer_name', 'product_category', 'factory_name', fields = ['id', 'dealer_name', 'product_category', 'factory_name',
'short_name', 'province', 'city', 'district', 'brand', 'province', 'city', 'district',
'address', 'website', 'created_at', 'updated_at', 'address', 'website', 'created_at', 'updated_at',
'material_count', 'usernames'] 'material_count', 'usernames']
read_only_fields = ['id', '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: class Meta:
model = Factory model = Factory
fields = ['id', 'factory_name', 'short_name', 'province', 'city', 'dealer_name', 'usernames'] fields = ['id', 'factory_name', 'brand', 'province', 'city', 'dealer_name', 'usernames']
def get_usernames(self, obj): def get_usernames(self, obj):
return list(obj.users.values_list('username', flat=True)) return list(obj.users.values_list('username', flat=True))

View File

@ -229,9 +229,9 @@ def _resolve_factory(
break break
term_lower = term.lower() term_lower = term.lower()
for factory in all_factories: for factory in all_factories:
short_name = (factory.short_name or "").lower() brand = (factory.brand or "").lower()
full_name = (factory.factory_name or "").lower() full_name = (factory.factory_name or "").lower()
if (short_name and short_name in term_lower) or (full_name and full_name in term_lower): if (brand and brand in term_lower) or (full_name and full_name in term_lower):
matched_factory = factory matched_factory = factory
break break
if matched_factory: if matched_factory:

View File

@ -195,15 +195,15 @@ class Command(BaseCommand):
if f: if f:
factory_cache[brand_name] = f factory_cache[brand_name] = f
return f return f
# 2) 模糊:供应商简称包含 Excel 值(如 Excel「立邦」匹配「立邦中国」 # 2) 模糊:工厂品牌包含 Excel 值(如 Excel「立邦」匹配「立邦中国」
f = Factory.objects.filter(short_name__icontains=brand_name).first() f = Factory.objects.filter(brand__icontains=brand_name).first()
if f: if f:
factory_cache[brand_name] = f factory_cache[brand_name] = f
return f return f
# 3) 模糊Excel 值包含某供应商简称(如 Excel「立邦中国\n广州立邦」取首行后仍可匹配 # 3) 模糊Excel 值包含某工厂品牌(如 Excel「立邦中国\n广州立邦」取首行后仍可匹配
brand_lower = brand_name.lower() brand_lower = brand_name.lower()
for factory in Factory.objects.all(): for factory in Factory.objects.all():
if factory.short_name and factory.short_name.lower() in brand_lower: if factory.brand and factory.brand.lower() in brand_lower:
factory_cache[brand_name] = factory factory_cache[brand_name] = factory
return factory return factory
factory_cache[brand_name] = None factory_cache[brand_name] = None
@ -212,7 +212,7 @@ class Command(BaseCommand):
# 未识别品牌工厂:品牌列无法匹配到任一工厂时,统一关联到此工厂(不存在则创建) # 未识别品牌工厂:品牌列无法匹配到任一工厂时,统一关联到此工厂(不存在则创建)
UNRECOGNIZED_BRAND = "未识别的品牌" UNRECOGNIZED_BRAND = "未识别的品牌"
unrecognized_factory, _ = Factory.objects.get_or_create( unrecognized_factory, _ = Factory.objects.get_or_create(
short_name=UNRECOGNIZED_BRAND, brand=UNRECOGNIZED_BRAND,
defaults={ defaults={
"factory_name": "未识别的品牌工厂", "factory_name": "未识别的品牌工厂",
"province": "-", "province": "-",

View File

@ -1,26 +0,0 @@
# 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='品牌',
),
),
]

View File

@ -1,40 +0,0 @@
# 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),
]

View File

@ -87,7 +87,6 @@ class Material(models.Model):
construction_method = models.CharField(max_length=255, blank=True, null=True, verbose_name='施工工艺') construction_method = models.CharField(max_length=255, blank=True, null=True, verbose_name='施工工艺')
limit_condition = models.TextField(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='材料单位名称') 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='状态') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name='状态')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')

View File

@ -1,6 +1,5 @@
import json import json
from rest_framework import serializers from rest_framework import serializers
from apps.brand.models import Brand
from .models import Material, MaterialCategory, MaterialSubcategory from .models import Material, MaterialCategory, MaterialSubcategory
@ -19,11 +18,7 @@ class MaterialSerializer(serializers.ModelSerializer):
材料序列化器 材料序列化器
""" """
factory_name = serializers.CharField(source='factory.factory_name', read_only=True) factory_name = serializers.CharField(source='factory.factory_name', read_only=True)
factory_short_name = serializers.CharField(source='factory.short_name', read_only=True) brand = serializers.CharField(source='factory.brand', 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) 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)
stage_display = serializers.CharField(source='get_stage_display', read_only=True) stage_display = serializers.CharField(source='get_stage_display', read_only=True)
@ -56,8 +51,7 @@ class MaterialSerializer(serializers.ModelSerializer):
'advantage_desc', 'cost_compare', 'cost_desc', 'cases', 'brochure', 'advantage_desc', 'cost_compare', 'cost_desc', 'cases', 'brochure',
'brochure_url', 'quality_level', 'durability_level', 'eco_level', 'brochure_url', 'quality_level', 'durability_level', 'eco_level',
'carbon_level', 'score_level', 'connection_method', 'construction_method', 'carbon_level', 'score_level', 'connection_method', 'construction_method',
'limit_condition', 'factory', 'factory_name', 'factory_short_name', 'limit_condition', 'factory', 'factory_name', 'brand',
'brand', 'brand_name',
'status', 'created_at', 'updated_at'] 'status', 'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at']
@ -88,8 +82,7 @@ class MaterialListSerializer(serializers.ModelSerializer):
材料列表序列化器简化版 材料列表序列化器简化版
""" """
factory_name = serializers.CharField(source='factory.factory_name', read_only=True) factory_name = serializers.CharField(source='factory.factory_name', read_only=True)
factory_short_name = serializers.CharField(source='factory.short_name', read_only=True) brand = serializers.CharField(source='factory.brand', 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) major_category_display = serializers.CharField(source='get_major_category_display', read_only=True)
status_display = serializers.CharField(source='get_status_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) stage_display = serializers.CharField(source='get_stage_display', read_only=True)
@ -101,8 +94,7 @@ class MaterialListSerializer(serializers.ModelSerializer):
'material_category', 'material_subcategory', 'stage', 'stage_display', 'material_category', 'material_subcategory', 'stage', 'stage_display',
'importance_level', 'importance_level_display', 'landing_project', 'importance_level', 'importance_level_display', 'landing_project',
'contact_person', 'contact_phone', 'handler', 'remark', 'factory', 'contact_person', 'contact_phone', 'handler', 'remark', 'factory',
'factory_name', 'factory_short_name', 'brand', 'brand_name', 'factory_name', 'brand', 'status', 'status_display']
'status', 'status_display']
class MaterialCategorySerializer(serializers.ModelSerializer): class MaterialCategorySerializer(serializers.ModelSerializer):

View File

@ -170,11 +170,6 @@ class MaterialViewSet(ModelViewSet):
if factory_id: if factory_id:
queryset = queryset.filter(factory_id=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') major_category = self.request.query_params.get('major_category')
if major_category: if major_category:
@ -378,7 +373,7 @@ class MaterialViewSet(ModelViewSet):
material.contact_person or "", material.contact_person or "",
material.contact_phone or "", material.contact_phone or "",
material.handler or "", material.handler or "",
material.factory.short_name if material.factory else "", material.factory.brand if material.factory else "",
material.factory.factory_name if material.factory else "", material.factory.factory_name if material.factory else "",
material.spec or "", material.spec or "",
material.standard or "", material.standard or "",

View File

@ -4,7 +4,6 @@ from rest_framework.response import Response
from django.db.models import Count 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
from apps.brand.models import Brand
def _build_brochure_url(request, brochure_field): def _build_brochure_url(request, brochure_field):
@ -28,42 +27,26 @@ def overview_statistics(request):
# 材料总数 # 材料总数
total_materials = approved_materials.count() total_materials = approved_materials.count()
# 材料种类(细分种类数量,排除空值) # 材料种类(材料子类数量)
total_material_categories = ( total_material_categories = approved_materials.values('material_subcategory').distinct().count()
approved_materials
.exclude(material_category__isnull=True)
.exclude(material_category='')
.values('material_category')
.distinct()
.count()
)
# 品牌数 # 品牌数(工厂数)
total_brands = Brand.objects.count() total_brands = Factory.objects.count()
# 按专业类别的材料数量分布 # 按专业类别的材料数量分布
major_category_stats = approved_materials.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_subcategory_stats = approved_materials.values('material_subcategory').annotate(
approved_materials count=Count('id')
.exclude(material_subcategory__isnull=True) ).order_by('-count')[:10]
.exclude(material_subcategory='')
.values('material_subcategory')
.annotate(count=Count('id'))
.order_by('-count')[:10]
)
# 按所属品牌的材料数量分布(排除无品牌的材料) # 按所属品牌的材料数量分布
brand_stats = ( brand_stats = approved_materials.values('factory__factory_name').annotate(
approved_materials count=Count('id')
.exclude(brand__isnull=True) ).order_by('-count')
.values('brand__name')
.annotate(count=Count('id'))
.order_by('-count')
)
# 按省份的工厂数量分布 # 按省份的工厂数量分布
region_stats = Factory.objects.values('province').annotate( region_stats = Factory.objects.values('province').annotate(
@ -205,7 +188,7 @@ def factory_statistics(request):
}) })
factories_list = list(Factory.objects.values( factories_list = list(Factory.objects.values(
'id', 'factory_name', 'short_name', 'province', 'city', 'website' 'id', 'factory_name', 'brand', 'province', 'city', 'website'
)) ))
return Response({ return Response({

View File

@ -53,7 +53,6 @@ INSTALLED_APPS = [
'corsheaders', 'corsheaders',
'apps.authentication', 'apps.authentication',
'apps.factory', 'apps.factory',
'apps.brand',
'apps.material', 'apps.material',
'apps.dictionary', 'apps.dictionary',
'apps.statistics', 'apps.statistics',

View File

@ -13,7 +13,6 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/auth/', include('apps.authentication.urls')), path('api/auth/', include('apps.authentication.urls')),
path('api/factory/', include('apps.factory.urls')), path('api/factory/', include('apps.factory.urls')),
path('api/brand/', include('apps.brand.urls')),
path('api/material/', include('apps.material.urls')), path('api/material/', include('apps.material.urls')),
path('api/dictionary/', include('apps.dictionary.urls')), path('api/dictionary/', include('apps.dictionary.urls')),
path('api/statistics/', include('apps.statistics.urls')), path('api/statistics/', include('apps.statistics.urls')),

View File

@ -1,279 +0,0 @@
# 品牌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后端返回 400PROTECT 触发)时 catch 并提示"该品牌下存在材料,无法删除"
### MaterialManage.vue 改动
- 列表页:
- 新增"品牌"列,展示 `row.brand?.name`
- 筛选区新增"品牌"下拉(数据从 `/api/brand/` 拉取,支持远程搜索)
- 新增 / 编辑弹窗表单:
- 增加"品牌"字段,`el-select` 远程搜索,前端标记 `required`
- 位置紧挨"供应商"字段后
- 提交时把 brandid纳入 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 CRUDadmin 权限)
- 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 的 Materialbrand 非空
- 同名 Factory.short_name 不会生成重复 Brand
- **品牌库 CRUD 手工测试**:新增 / 编辑 / 搜索 / 分页 / 删除(有无引用两种情况)
- **材料页**:品牌列显示 / 筛选生效 / 表单下拉可用 / 必填校验生效 / 提交成功
- **Factory 重命名**:前端 FactoryManage 正常工作,无遗留 `brand` 引用
- **权限**:非 admin 无法访问 `/brands` 路由,无法看到菜单项

View File

@ -1,26 +0,0 @@
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
}

View File

@ -20,9 +20,6 @@ api.interceptors.response.use(
if (error.response?.status === 401) { if (error.response?.status === 401) {
const { clearAuth } = useAuth() const { clearAuth } = useAuth()
clearAuth() clearAuth()
if (window.location.pathname !== '/login') {
window.location.replace('/login')
}
} }
return Promise.reject(error) return Promise.reject(error)
} }

View File

@ -1,40 +1,38 @@
<template> <template>
<div class="layout"> <div class="layout">
<header class="topbar"> <aside class="sidebar">
<div class="logo-title">房地产新材料选材管理数据系统</div> <div class="logo">
<div class="user"> <div class="logo-title">房地产新材料选材管理数据系统</div>
<div class="user-info"> <div class="logo-sub">管理系统</div>
<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>
</div> </div>
</header> <el-menu
<div class="body"> :default-active="active"
<aside class="sidebar"> class="menu"
<el-menu router
:default-active="active" >
class="menu" <el-menu-item v-if="isAdmin" index="/users">用户管理</el-menu-item>
router <el-menu-item index="/factories">工厂管理</el-menu-item>
> <el-menu-item v-if="isAdmin" index="/dictionary">材料分类管理</el-menu-item>
<el-menu-item v-if="isAdmin" index="/users">用户管理</el-menu-item> <el-menu-item index="/materials">材料管理</el-menu-item>
<el-menu-item index="/factories">供应商库</el-menu-item> <el-menu-item v-if="isAdmin" index="/screen/overview">数据大屏</el-menu-item>
<el-menu-item v-if="isAdmin" index="/brands">品牌库</el-menu-item> </el-menu>
<el-menu-item index="/materials">材料管理</el-menu-item> </aside>
<el-menu-item v-if="isAdmin" index="/screen/overview">数据大屏</el-menu-item> <main class="main">
<el-sub-menu v-if="isAdmin" index="config"> <header class="topbar">
<template #title>配置项</template> <div class="breadcrumb">{{ title }}</div>
<el-menu-item index="/dictionary">材料分类</el-menu-item> <div class="user">
</el-sub-menu> <div class="user-info">
</el-menu> <div class="name">{{ user?.username || '用户' }}</div>
</aside> <div class="role">{{ isAdmin ? '管理员' : '普通账号' }}</div>
<main class="main"> </div>
<section class="content"> <el-button size="small" @click="openPassword">修改密码</el-button>
<router-view /> <el-button size="small" @click="onLogout">退出</el-button>
</section> </div>
</main> </header>
</div> <section class="content">
<router-view />
</section>
</main>
</div> </div>
<el-dialog v-model="passwordVisible" title="修改密码" width="420px" class="dialog-scroll"> <el-dialog v-model="passwordVisible" title="修改密码" width="420px" class="dialog-scroll">
@ -68,6 +66,13 @@ const router = useRouter()
const { state, isAdmin, clearAuth } = useAuth() const { state, isAdmin, clearAuth } = useAuth()
const active = computed(() => route.path) 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 user = computed(() => state.user)
const passwordVisible = ref(false) const passwordVisible = ref(false)
@ -103,69 +108,36 @@ const onLogout = () => {
<style scoped> <style scoped>
.layout { .layout {
display: flex; display: flex;
flex-direction: column;
height: 100vh; 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 { .sidebar {
width: 220px; width: 220px;
flex-shrink: 0;
background: linear-gradient(180deg, var(--brand-900), var(--brand-950)); background: linear-gradient(180deg, var(--brand-900), var(--brand-950));
color: #fff; color: #fff;
display: flex; display: flex;
flex-direction: column; 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 { .menu {
border-right: none; border-right: none;
background: transparent; background: transparent;
color: #fff; color: #fff;
padding-top: 12px;
} }
:deep(.el-menu-item) { :deep(.el-menu-item) {
@ -184,24 +156,6 @@ const onLogout = () => {
background-color: rgba(255, 255, 255, 0.08); 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 { .main {
flex: 1; flex: 1;
display: flex; display: flex;
@ -209,10 +163,34 @@ const onLogout = () => {
min-height: 0; 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 { .content {
flex: 1; flex: 1;
background: var(--bg); background: var(--bg);
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
} }
.breadcrumb {
font-weight: 600;
}
</style> </style>

View File

@ -18,7 +18,6 @@ const routes = [
{ path: 'users', name: 'users', component: () => import('@/views/UserManage.vue'), meta: { admin: true } }, { path: 'users', name: 'users', component: () => import('@/views/UserManage.vue'), meta: { admin: true } },
{ path: 'factories', name: 'factories', component: () => import('@/views/FactoryManage.vue') }, { path: 'factories', name: 'factories', component: () => import('@/views/FactoryManage.vue') },
{ path: 'factories/:id', name: 'factory-detail', component: () => import('@/views/FactoryDetail.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: 'dictionary', name: 'dictionary', component: () => import('@/views/DictionaryManage.vue'), meta: { admin: true } },
{ path: 'materials', name: 'materials', component: () => import('@/views/MaterialManage.vue') }, { path: 'materials', name: 'materials', component: () => import('@/views/MaterialManage.vue') },
{ path: 'materials/:id', name: 'material-detail', component: () => import('@/views/MaterialDetail.vue') } { path: 'materials/:id', name: 'material-detail', component: () => import('@/views/MaterialDetail.vue') }

View File

@ -1,210 +0,0 @@
<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>

View File

@ -1,13 +1,13 @@
<template> <template>
<div class="page"> <div class="page">
<div class="page-title"> <div class="page-title">
供应商详情 工厂详情
<el-button class="back-btn" plain size="small" @click="goBack">返回</el-button> <el-button class="back-btn" plain size="small" @click="goBack">返回</el-button>
</div> </div>
<div class="card detail-card" v-if="factory"> <div class="card detail-card" v-if="factory">
<el-descriptions :column="1" border class="detail-descriptions"> <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.factory_name) }}</el-descriptions-item>
<el-descriptions-item label="供应商简称">{{ displayText(factory.short_name) }}</el-descriptions-item> <el-descriptions-item label="品牌">{{ displayText(factory.brand) }}</el-descriptions-item>
<el-descriptions-item label="经销商">{{ displayText(factory.dealer_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="产品分类">{{ displayText(factory.product_category) }}</el-descriptions-item>
<el-descriptions-item label="地区">{{ displayRegion(factory) }}</el-descriptions-item> <el-descriptions-item label="地区">{{ displayRegion(factory) }}</el-descriptions-item>

View File

@ -1,12 +1,12 @@
<template> <template>
<div class="page"> <div class="page">
<div class="page-title">供应商库</div> <div class="page-title">工厂管理</div>
<div class="toolbar"> <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> </div>
<el-table v-loading="tableLoading" :data="factories" border :max-height="560"> <el-table v-loading="tableLoading" :data="factories" border :max-height="560">
<el-table-column prop="factory_name" label="供应商全称" /> <el-table-column prop="factory_name" label="工厂全称" />
<el-table-column prop="short_name" label="供应商简称" /> <el-table-column prop="brand" label="品牌" />
<el-table-column prop="dealer_name" label="经销商" /> <el-table-column prop="dealer_name" label="经销商" />
<el-table-column label="用户账号"> <el-table-column label="用户账号">
<template #default="scope"> <template #default="scope">
@ -49,11 +49,11 @@
<el-form-item label="产品分类"> <el-form-item label="产品分类">
<el-input v-model="form.product_category" /> <el-input v-model="form.product_category" />
</el-form-item> </el-form-item>
<el-form-item label="供应商全称" required> <el-form-item label="工厂全称" required>
<el-input v-model="form.factory_name" /> <el-input v-model="form.factory_name" />
</el-form-item> </el-form-item>
<el-form-item label="供应商简称" required> <el-form-item label="品牌" required>
<el-input v-model="form.short_name" /> <el-input v-model="form.brand" />
</el-form-item> </el-form-item>
<el-form-item label="省市区" required> <el-form-item label="省市区" required>
<el-cascader <el-cascader
@ -109,7 +109,7 @@ const form = reactive({
dealer_name: '', dealer_name: '',
product_category: '', product_category: '',
factory_name: '', factory_name: '',
short_name: '', brand: '',
province: '', province: '',
city: '', city: '',
district: '', district: '',
@ -132,7 +132,7 @@ const resetForm = () => {
form.dealer_name = '' form.dealer_name = ''
form.product_category = '' form.product_category = ''
form.factory_name = '' form.factory_name = ''
form.short_name = '' form.brand = ''
form.province = '' form.province = ''
form.city = '' form.city = ''
form.district = '' form.district = ''
@ -150,7 +150,7 @@ const onRegionChange = (val) => {
const openCreate = () => { const openCreate = () => {
resetForm() resetForm()
isEdit.value = false isEdit.value = false
dialogTitle.value = '新增供应商' dialogTitle.value = '新增工厂'
dialogVisible.value = true dialogVisible.value = true
} }
@ -161,7 +161,7 @@ const openEdit = async (row) => {
const detail = await fetchFactoryDetail(row.id) const detail = await fetchFactoryDetail(row.id)
Object.assign(form, detail) Object.assign(form, detail)
regionValue.value = [detail.province, detail.city, detail.district].filter(Boolean).map(regionLabel) regionValue.value = [detail.province, detail.city, detail.district].filter(Boolean).map(regionLabel)
dialogTitle.value = '编辑供应商' dialogTitle.value = '编辑工厂'
dialogVisible.value = true dialogVisible.value = true
} }
@ -181,7 +181,7 @@ const onSubmit = async () => {
} }
const onDelete = (row) => { const onDelete = (row) => {
ElMessageBox.confirm(`确认删除供应商 ${row.factory_name} 吗?`, '提示', { type: 'warning' }) ElMessageBox.confirm(`确认删除工厂 ${row.factory_name} 吗?`, '提示', { type: 'warning' })
.then(async () => { .then(async () => {
await deleteFactory(row.id) await deleteFactory(row.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')

View File

@ -27,8 +27,7 @@
<el-descriptions-item label="成本对比">{{ formatPercent(material.cost_compare) }}</el-descriptions-item> <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.cost_desc) }}</el-descriptions-item>
<el-descriptions-item label="案例">{{ displayText(material.cases) }}</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.quality_level) }}</el-descriptions-item>
<el-descriptions-item label="耐久等级">{{ formatStarLevel(material.durability_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> <el-descriptions-item label="环保等级">{{ formatStarLevel(material.eco_level) }}</el-descriptions-item>

View File

@ -9,18 +9,6 @@
<el-select v-model="filters.material_subcategory" placeholder="材料子类" clearable style="width: 180px"> <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-option v-for="item in filterSubcategoryOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select> </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 @click="loadMaterials">查询</el-button>
<el-button v-if="isAdmin" :loading="importing" @click="importDialogVisible = true"> <el-button v-if="isAdmin" :loading="importing" @click="importDialogVisible = true">
{{ importing ? '导入中...' : '导入数据' }} {{ importing ? '导入中...' : '导入数据' }}
@ -44,8 +32,7 @@
<el-table-column prop="contact_phone" label="对接人联系方式" width="130px"/> <el-table-column prop="contact_phone" label="对接人联系方式" width="130px"/>
<el-table-column prop="handler" label="经办人" /> <el-table-column prop="handler" label="经办人" />
<el-table-column prop="remark" label="备注" /> <el-table-column prop="remark" label="备注" />
<el-table-column prop="factory_short_name" label="材料单位名称" /> <el-table-column prop="brand" label="材料单位名称" />
<el-table-column prop="brand_name" label="品牌" />
<el-table-column prop="status_display" label="状态" width="120" /> <el-table-column prop="status_display" label="状态" width="120" />
<el-table-column label="操作" width="320"> <el-table-column label="操作" width="320">
<template #default="scope"> <template #default="scope">
@ -204,19 +191,7 @@
</el-form-item> </el-form-item>
<el-form-item label="材料单位名称" v-if="isAdmin"> <el-form-item label="材料单位名称" v-if="isAdmin">
<el-select v-model="form.factory"> <el-select v-model="form.factory">
<el-option v-for="item in factories" :key="item.id" :label="item.short_name" :value="item.id" /> <el-option v-for="item in factories" :key="item.id" :label="item.brand" :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-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -255,7 +230,6 @@ import { useAuth } from '@/store/auth'
import { fetchMaterials, fetchMaterialDetail, createMaterial, updateMaterial, deleteMaterial, submitMaterial, approveMaterial, rejectMaterial, fetchMaterialChoices, uploadImage, importMaterialsExcel, exportMaterialsExcel } from '@/api/material' import { fetchMaterials, fetchMaterialDetail, createMaterial, updateMaterial, deleteMaterial, submitMaterial, approveMaterial, rejectMaterial, fetchMaterialChoices, uploadImage, importMaterialsExcel, exportMaterialsExcel } from '@/api/material'
import { fetchCategories, fetchSubcategories } from '@/api/category' import { fetchCategories, fetchSubcategories } from '@/api/category'
import { fetchFactorySimple } from '@/api/factory' import { fetchFactorySimple } from '@/api/factory'
import { fetchBrands } from '@/api/brand'
const router = useRouter() const router = useRouter()
const { isAdmin } = useAuth() const { isAdmin } = useAuth()
@ -281,15 +255,9 @@ const templateDownloadUrl = `http://101.42.1.64:2260/media/material_import_templ
const filters = reactive({ const filters = reactive({
name: '', name: '',
status: '', status: '',
material_subcategory: '', material_subcategory: ''
brand: ''
}) })
const brandFilterOptions = ref([])
const brandFormOptions = ref([])
const brandSearchLoading = ref(false)
const brandFormSearchLoading = ref(false)
const form = reactive({ const form = reactive({
name: '', name: '',
major_category: '', major_category: '',
@ -322,8 +290,7 @@ const form = reactive({
connection_method: '', connection_method: '',
construction_method: '', construction_method: '',
limit_condition: '', limit_condition: '',
factory: null, factory: null
brand: null
}) })
const majorOptions = ref([]) const majorOptions = ref([])
@ -425,43 +392,10 @@ const resetForm = () => {
connection_method: '', connection_method: '',
construction_method: '', construction_method: '',
limit_condition: '', 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) => { const onCategoryChange = async (val, resetSub = true) => {
if (!val) { if (!val) {
await loadSubcategories() await loadSubcategories()
@ -476,11 +410,10 @@ const onCategoryChange = async (val, resetSub = true) => {
} }
} }
const openCreate = async () => { const openCreate = () => {
resetForm() resetForm()
isEdit.value = false isEdit.value = false
dialogTitle.value = '新增材料' dialogTitle.value = '新增材料'
await searchBrandsForForm('')
dialogVisible.value = true dialogVisible.value = true
} }
@ -493,12 +426,9 @@ const openEdit = async (row) => {
form.application_scene = item.application_scene || [] form.application_scene = item.application_scene || []
form.advantage = item.advantage || [] form.advantage = item.advantage || []
form.brochure_url = item.brochure_url || '' form.brochure_url = item.brochure_url || ''
form.brand = item.brand || null
if (form.material_category) { if (form.material_category) {
await onCategoryChange(form.material_category, false) await onCategoryChange(form.material_category, false)
} }
await searchBrandsForForm('')
ensureBrandInFormOptions(item.brand, item.brand_name)
dialogTitle.value = '编辑材料' dialogTitle.value = '编辑材料'
dialogVisible.value = true dialogVisible.value = true
} }
@ -518,14 +448,9 @@ const handleUpload = async (options) => {
} }
const onSave = async () => { const onSave = async () => {
if (!form.brand) {
ElMessage.warning('请选择品牌')
return
}
try { try {
const payload = { ...form } const payload = { ...form }
delete payload.brochure_url delete payload.brochure_url
delete payload.brand_name
if (!isAdmin.value) { if (!isAdmin.value) {
delete payload.factory delete payload.factory
} }
@ -550,7 +475,7 @@ const handleImportExcel = async (options) => {
pagination.page = 1 pagination.page = 1
await loadMaterials() await loadMaterials()
importDialogVisible.value = false 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) { } catch (error) {
ElMessage.error(error.response?.data?.detail || '导入失败') ElMessage.error(error.response?.data?.detail || '导入失败')
} finally { } finally {
@ -655,7 +580,6 @@ onMounted(() => {
loadCategories() loadCategories()
loadSubcategories() loadSubcategories()
loadFactories() loadFactories()
loadBrandFilterOptions()
loadMaterials() loadMaterials()
}) })
</script> </script>

View File

@ -11,7 +11,7 @@
{{ scope.row.role === 'admin' ? '管理员' : '普通账号' }} {{ scope.row.role === 'admin' ? '管理员' : '普通账号' }}
</template> </template>
</el-table-column> </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="phone" label="手机" />
<el-table-column prop="email" label="邮箱" /> <el-table-column prop="email" label="邮箱" />
<el-table-column label="操作" width="240"> <el-table-column label="操作" width="240">
@ -54,7 +54,7 @@
<el-option label="普通账号" value="user" /> <el-option label="普通账号" value="user" />
</el-select> </el-select>
</el-form-item> </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-select v-model="form.factory">
<el-option v-for="item in factories" :key="item.id" :label="item.factory_name" :value="item.id" /> <el-option v-for="item in factories" :key="item.id" :label="item.factory_name" :value="item.id" />
</el-select> </el-select>

View File

@ -113,7 +113,7 @@ const updateCharts = () => {
}) })
const brandData = (stats.value.brand_stats || []).map((item) => ({ const brandData = (stats.value.brand_stats || []).map((item) => ({
name: item.brand__name, name: item.factory__factory_name,
value: item.count value: item.count
})) }))
charts[2].setOption({ charts[2].setOption({