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>
This commit is contained in:
caoqianming 2026-04-23 21:54:09 +08:00
parent d96140795e
commit 75dde5243e
29 changed files with 577 additions and 33 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

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

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

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

@ -7,7 +7,7 @@
<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.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

@ -6,7 +6,7 @@
</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="short_name" label="供应商简称" />
<el-table-column prop="dealer_name" label="经销商" />
<el-table-column label="用户账号">
<template #default="scope">
@ -52,8 +52,8 @@
<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 = ''

View File

@ -28,6 +28,7 @@
<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.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
}
@ -580,6 +655,7 @@ onMounted(() => {
loadCategories()
loadSubcategories()
loadFactories()
loadBrandFilterOptions()
loadMaterials()
})
</script>