Compare commits

...

7 Commits

Author SHA1 Message Date
caoqianming 85ed7a20c1 fix: 大屏统计按新品牌实体,材料种类统计改为细分种类并排除空值
- 材料种类卡片:从 material_subcategory 改为 material_category 去重计数,排除 null/空
- 材料子类分布图:排除 material_subcategory 为空的材料,避免出现 null 类目
- 品牌数卡片:从 Factory.count 改为 Brand.count
- 品牌材料分布图:按 material.brand.name 分组,排除无品牌材料;前端字段同步

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 22:01:59 +08:00
caoqianming 75dde5243e feat: 新增品牌实体 + 材料关联品牌 + Factory.brand 改名 short_name
- 新增 brand app(Brand 模型/CRUD API,读认证用户、写管理员、PROTECT 删除)
- Material 新增 brand 外键(PROTECT,数据库可空,前端必填)
- Factory.brand 改名 short_name,并附带数据迁移从 factory.short_name
  回填 Material.brand 实现历史数据一步到位
- 前端新增品牌库菜单/页面/API,材料管理加品牌列/筛选/表单下拉,
  材料详情显示品牌,供应商页面文案同步改为"供应商简称"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:54:09 +08:00
caoqianming d96140795e docs: 按评审意见修正品牌设计文档
- 迁移编号按仓库现状调整为 factory/0004、material/0007、material/0008
- 说明本次是反向 rename(factory_short_name → brand → short_name)
- 统一 API 权限表述:读接口所有已认证用户,写接口 admin
- 数据迁移脚本改为按品牌批量 update
- 风险章节补充 CSS 变量 --brand-* 在 grep 时需排除

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:44:26 +08:00
caoqianming 08b855794f docs: 新增品牌实体设计文档
设计 Brand 实体、Factory.brand 重命名为 short_name、
Material 新增品牌外键的方案与迁移步骤。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:38:46 +08:00
caoqianming d3ceaded07 fix:顶栏改为全宽单行(系统名+用户信息),材料分类归入配置项子菜单
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 14:01:33 +08:00
caoqianming e378bb9e1c fix:token失效时接口返回401后强制跳转登录页,避免停留在空列表页
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 13:40:29 +08:00
caoqianming 60157025f2 fix:工厂管理前端文案改为供应商库,相关字段同步替换
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 08:52:57 +08:00
33 changed files with 1001 additions and 137 deletions

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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='供应商简称'),
),
]

View File

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

View File

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

View File

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

View File

@ -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": "-",

View File

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

View File

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

View File

@ -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='更新时间')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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后端返回 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` 路由,无法看到菜单项

26
frontend/src/api/brand.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('删除成功')

View File

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

View File

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

View File

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

View File

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