Compare commits
29 Commits
b2d518e736
...
bf70b3f462
| Author | SHA1 | Date |
|---|---|---|
|
|
bf70b3f462 | |
|
|
ff3c0d8fea | |
|
|
76b2c08801 | |
|
|
61713bcb58 | |
|
|
d0af6b1596 | |
|
|
d1201d6923 | |
|
|
25587dce21 | |
|
|
1a0af18457 | |
|
|
a084f5c7e0 | |
|
|
7b5ff36b23 | |
|
|
050a1840ec | |
|
|
5ff8874139 | |
|
|
a136c3db85 | |
|
|
eebd6825b9 | |
|
|
82490bc786 | |
|
|
f410a95a3d | |
|
|
815e8df10b | |
|
|
807e7de443 | |
|
|
d550c73439 | |
|
|
ddc26c414a | |
|
|
20268638f1 | |
|
|
4db17db5e4 | |
|
|
34fc101856 | |
|
|
95b04cd7ac | |
|
|
9a42a2d4b8 | |
|
|
4050186dc0 | |
|
|
99e6df0a57 | |
|
|
2b76bbc62a | |
|
|
e927826ee0 |
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.material'
|
||||||
|
|
@ -20,6 +20,10 @@ 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)
|
factory_short_name = serializers.CharField(source='factory.short_name', read_only=True)
|
||||||
|
factory_cooperation_mode = serializers.CharField(source='factory.cooperation_mode', read_only=True, default=None)
|
||||||
|
factory_cooperation_mode_display = serializers.SerializerMethodField()
|
||||||
|
factory_province = serializers.CharField(source='factory.province', read_only=True, default=None)
|
||||||
|
factory_city = serializers.CharField(source='factory.city', read_only=True, default=None)
|
||||||
brand = serializers.PrimaryKeyRelatedField(
|
brand = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=Brand.objects.all(), allow_null=True, required=False
|
queryset=Brand.objects.all(), allow_null=True, required=False
|
||||||
)
|
)
|
||||||
|
|
@ -57,10 +61,15 @@ class MaterialSerializer(serializers.ModelSerializer):
|
||||||
'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', 'factory_short_name',
|
||||||
|
'factory_cooperation_mode', 'factory_cooperation_mode_display',
|
||||||
|
'factory_province', 'factory_city',
|
||||||
'brand', 'brand_name',
|
'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']
|
||||||
|
|
||||||
|
def get_factory_cooperation_mode_display(self, obj):
|
||||||
|
return obj.factory.get_cooperation_mode_display() if obj.factory and obj.factory.cooperation_mode else None
|
||||||
|
|
||||||
def get_brochure_url(self, obj):
|
def get_brochure_url(self, obj):
|
||||||
if obj.brochure:
|
if obj.brochure:
|
||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
|
|
@ -85,25 +94,44 @@ class MaterialSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class MaterialListSerializer(serializers.ModelSerializer):
|
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)
|
factory_short_name = serializers.CharField(source='factory.short_name', read_only=True)
|
||||||
|
factory_cooperation_mode = serializers.CharField(source='factory.cooperation_mode', read_only=True, default=None)
|
||||||
|
factory_cooperation_mode_display = serializers.SerializerMethodField()
|
||||||
|
factory_province = serializers.CharField(source='factory.province', read_only=True, default=None)
|
||||||
|
factory_city = serializers.CharField(source='factory.city', read_only=True, default=None)
|
||||||
brand_name = serializers.CharField(source='brand.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)
|
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)
|
||||||
importance_level_display = serializers.CharField(source='get_importance_level_display', read_only=True)
|
importance_level_display = serializers.CharField(source='get_importance_level_display', read_only=True)
|
||||||
|
replace_type_display = serializers.CharField(source='get_replace_type_display', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Material
|
model = Material
|
||||||
fields = ['id', 'name', 'major_category', 'major_category_display',
|
fields = ['id', 'name', 'major_category', 'major_category_display',
|
||||||
'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_name', 'factory_short_name', 'brand', 'brand_name',
|
'spec', 'standard',
|
||||||
|
'application_scene', 'application_desc',
|
||||||
|
'replace_type', 'replace_type_display',
|
||||||
|
'advantage', 'advantage_desc',
|
||||||
|
'connection_method', 'construction_method', 'limit_condition',
|
||||||
|
'cost_compare', 'cost_desc', 'cases',
|
||||||
|
'quality_level', 'durability_level', 'eco_level',
|
||||||
|
'carbon_level', 'score_level',
|
||||||
|
'factory', 'factory_name', 'factory_short_name',
|
||||||
|
'factory_cooperation_mode', 'factory_cooperation_mode_display',
|
||||||
|
'factory_province', 'factory_city',
|
||||||
|
'brand', 'brand_name',
|
||||||
'status', 'status_display']
|
'status', 'status_display']
|
||||||
|
|
||||||
|
def get_factory_cooperation_mode_display(self, obj):
|
||||||
|
return obj.factory.get_cooperation_mode_display() if obj.factory and obj.factory.cooperation_mode else None
|
||||||
|
|
||||||
|
|
||||||
class MaterialCategorySerializer(serializers.ModelSerializer):
|
class MaterialCategorySerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from openpyxl import Workbook
|
||||||
from openpyxl.worksheet.datavalidation import DataValidation
|
from openpyxl.worksheet.datavalidation import DataValidation
|
||||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
|
from django.db.models import Count
|
||||||
from rest_framework import generics, status
|
from rest_framework import generics, status
|
||||||
from rest_framework.decorators import api_view, action
|
from rest_framework.decorators import api_view, action
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
@ -14,9 +15,18 @@ from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.parsers import MultiPartParser
|
from rest_framework.parsers import MultiPartParser
|
||||||
|
from django.core.cache import cache
|
||||||
from .models import Material, MaterialCategory, MaterialSubcategory
|
from .models import Material, MaterialCategory, MaterialSubcategory
|
||||||
from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer
|
from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer
|
||||||
from .importers import import_materials_plan_excel
|
from .importers import import_materials_plan_excel
|
||||||
|
from apps.factory.models import COOPERATION_MODE_CHOICES
|
||||||
|
|
||||||
|
|
||||||
|
CATEGORY_TREE_CACHE_KEY = 'material:category_tree:approved'
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_category_tree_cache():
|
||||||
|
cache.delete(CATEGORY_TREE_CACHE_KEY)
|
||||||
|
|
||||||
|
|
||||||
def _join_choice_values(values, choices):
|
def _join_choice_values(values, choices):
|
||||||
|
|
@ -149,7 +159,7 @@ class MaterialViewSet(ModelViewSet):
|
||||||
"""
|
"""
|
||||||
根据用户角色过滤材料
|
根据用户角色过滤材料
|
||||||
"""
|
"""
|
||||||
queryset = Material.objects.all().order_by('-created_at', '-id')
|
queryset = Material.objects.select_related('factory', 'brand').order_by('-created_at', '-id')
|
||||||
|
|
||||||
# 普通用户只能看到自己工厂的材料
|
# 普通用户只能看到自己工厂的材料
|
||||||
if self.request.user.role != 'admin':
|
if self.request.user.role != 'admin':
|
||||||
|
|
@ -200,6 +210,52 @@ class MaterialViewSet(ModelViewSet):
|
||||||
if advantage:
|
if advantage:
|
||||||
queryset = queryset.filter(advantage__contains=[advantage])
|
queryset = queryset.filter(advantage__contains=[advantage])
|
||||||
|
|
||||||
|
# 阶段
|
||||||
|
stage = self.request.query_params.get('stage')
|
||||||
|
if stage:
|
||||||
|
queryset = queryset.filter(stage=stage)
|
||||||
|
|
||||||
|
# 重要等级
|
||||||
|
importance_level = self.request.query_params.get('importance_level')
|
||||||
|
if importance_level:
|
||||||
|
queryset = queryset.filter(importance_level=importance_level)
|
||||||
|
|
||||||
|
# 供应商(显式 id 过滤)
|
||||||
|
factory = self.request.query_params.get('factory')
|
||||||
|
if factory:
|
||||||
|
queryset = queryset.filter(factory_id=factory)
|
||||||
|
|
||||||
|
# 合作模式
|
||||||
|
cooperation_mode = self.request.query_params.get('factory__cooperation_mode')
|
||||||
|
if cooperation_mode:
|
||||||
|
queryset = queryset.filter(factory__cooperation_mode=cooperation_mode)
|
||||||
|
|
||||||
|
# 落地项目(模糊)
|
||||||
|
landing_project = self.request.query_params.get('landing_project')
|
||||||
|
if landing_project:
|
||||||
|
queryset = queryset.filter(landing_project__icontains=landing_project)
|
||||||
|
|
||||||
|
# 成本比较区间
|
||||||
|
cost_gte = self.request.query_params.get('cost_compare__gte')
|
||||||
|
if cost_gte not in (None, ''):
|
||||||
|
queryset = queryset.filter(cost_compare__gte=cost_gte)
|
||||||
|
cost_lte = self.request.query_params.get('cost_compare__lte')
|
||||||
|
if cost_lte not in (None, ''):
|
||||||
|
queryset = queryset.filter(cost_compare__lte=cost_lte)
|
||||||
|
|
||||||
|
# 综合评分下限
|
||||||
|
score_gte = self.request.query_params.get('score_level__gte')
|
||||||
|
if score_gte not in (None, ''):
|
||||||
|
queryset = queryset.filter(score_level__gte=score_gte)
|
||||||
|
|
||||||
|
# 对接人 / 经办人
|
||||||
|
contact_person = self.request.query_params.get('contact_person')
|
||||||
|
if contact_person:
|
||||||
|
queryset = queryset.filter(contact_person__icontains=contact_person)
|
||||||
|
handler = self.request.query_params.get('handler')
|
||||||
|
if handler:
|
||||||
|
queryset = queryset.filter(handler__icontains=handler)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
|
|
@ -246,7 +302,10 @@ class MaterialViewSet(ModelViewSet):
|
||||||
# 普通用户只能删除创建中的材料
|
# 普通用户只能删除创建中的材料
|
||||||
if self.request.user.role != 'admin' and instance.status != 'draft':
|
if self.request.user.role != 'admin' and instance.status != 'draft':
|
||||||
raise PermissionDenied("只有创建中的材料可以删除")
|
raise PermissionDenied("只有创建中的材料可以删除")
|
||||||
|
was_approved = instance.status == 'approved'
|
||||||
instance.delete()
|
instance.delete()
|
||||||
|
if was_approved:
|
||||||
|
invalidate_category_tree_cache()
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def submit(self, request, pk=None):
|
def submit(self, request, pk=None):
|
||||||
|
|
@ -294,6 +353,7 @@ class MaterialViewSet(ModelViewSet):
|
||||||
|
|
||||||
material.status = 'approved'
|
material.status = 'approved'
|
||||||
material.save()
|
material.save()
|
||||||
|
invalidate_category_tree_cache()
|
||||||
return Response({"status": "审核通过"})
|
return Response({"status": "审核通过"})
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
|
|
@ -315,8 +375,11 @@ class MaterialViewSet(ModelViewSet):
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
|
was_approved = material.status == 'approved'
|
||||||
material.status = 'draft'
|
material.status = 'draft'
|
||||||
material.save()
|
material.save()
|
||||||
|
if was_approved:
|
||||||
|
invalidate_category_tree_cache()
|
||||||
return Response({"status": "审核拒绝"})
|
return Response({"status": "审核拒绝"})
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=['get'])
|
||||||
|
|
@ -333,6 +396,7 @@ class MaterialViewSet(ModelViewSet):
|
||||||
'application_scene': Material.APPLICATION_SCENE_CHOICES,
|
'application_scene': Material.APPLICATION_SCENE_CHOICES,
|
||||||
'star_level': Material.STAR_LEVEL_CHOICES,
|
'star_level': Material.STAR_LEVEL_CHOICES,
|
||||||
'status': Material.STATUS_CHOICES,
|
'status': Material.STATUS_CHOICES,
|
||||||
|
'cooperation_mode': COOPERATION_MODE_CHOICES,
|
||||||
})
|
})
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], url_path='template')
|
@action(detail=False, methods=['get'], url_path='template')
|
||||||
|
|
@ -433,8 +497,93 @@ class MaterialViewSet(ModelViewSet):
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return Response({"detail": f"导入失败: {exc}"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": f"导入失败: {exc}"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
invalidate_category_tree_cache()
|
||||||
return Response(result)
|
return Response(result)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='category-tree', permission_classes=[IsAuthenticated])
|
||||||
|
def category_tree(self, request):
|
||||||
|
"""
|
||||||
|
H5 浏览端的全量种类树:一次返回所有大类 → 材料种类 → 子类的聚合结果。
|
||||||
|
与用户无关(仅依赖 status='approved'),故走全局缓存;Material 变更时由 signals 失效。
|
||||||
|
"""
|
||||||
|
cached = cache.get(CATEGORY_TREE_CACHE_KEY)
|
||||||
|
if cached is not None:
|
||||||
|
return Response(cached)
|
||||||
|
|
||||||
|
rows = (Material.objects
|
||||||
|
.filter(status='approved')
|
||||||
|
.exclude(major_category__isnull=True).exclude(major_category__exact='')
|
||||||
|
.exclude(material_category__isnull=True).exclude(material_category__exact='')
|
||||||
|
.values('major_category', 'material_category', 'material_subcategory')
|
||||||
|
.annotate(count=Count('id'))
|
||||||
|
.order_by('major_category', 'material_category', 'material_subcategory'))
|
||||||
|
|
||||||
|
majors = {}
|
||||||
|
for row in rows:
|
||||||
|
mj = row['major_category']
|
||||||
|
cat = row['material_category']
|
||||||
|
sub = row['material_subcategory'] or ''
|
||||||
|
cnt = row['count']
|
||||||
|
|
||||||
|
major_node = majors.setdefault(mj, {})
|
||||||
|
cat_node = major_node.setdefault(cat, {'count': 0, 'subs': {}})
|
||||||
|
cat_node['count'] += cnt
|
||||||
|
if sub:
|
||||||
|
cat_node['subs'][sub] = cat_node['subs'].get(sub, 0) + cnt
|
||||||
|
|
||||||
|
major_display = dict(Material.MAJOR_CATEGORY_CHOICES)
|
||||||
|
data = []
|
||||||
|
for mj_value, cats in majors.items():
|
||||||
|
data.append({
|
||||||
|
'value': mj_value,
|
||||||
|
'label': major_display.get(mj_value, mj_value),
|
||||||
|
'categories': [
|
||||||
|
{
|
||||||
|
'value': cat,
|
||||||
|
'count': node['count'],
|
||||||
|
'subcategories': [
|
||||||
|
{'value': s, 'count': c}
|
||||||
|
for s, c in sorted(node['subs'].items())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for cat, node in sorted(cats.items())
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
cache.set(CATEGORY_TREE_CACHE_KEY, data, timeout=60 * 5)
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='categories-by-major')
|
||||||
|
def categories_by_major(self, request):
|
||||||
|
major = request.query_params.get('major_category')
|
||||||
|
if not major:
|
||||||
|
return Response({"detail": "major_category 参数必填"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
qs = (Material.objects
|
||||||
|
.filter(major_category=major, status='approved')
|
||||||
|
.exclude(material_category__isnull=True)
|
||||||
|
.exclude(material_category__exact='')
|
||||||
|
.values('material_category')
|
||||||
|
.annotate(count=Count('id'))
|
||||||
|
.order_by('material_category'))
|
||||||
|
data = [{"value": row['material_category'], "count": row['count']} for row in qs]
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='subcategories-by-category')
|
||||||
|
def subcategories_by_category(self, request):
|
||||||
|
major = request.query_params.get('major_category')
|
||||||
|
category = request.query_params.get('material_category')
|
||||||
|
if not major or not category:
|
||||||
|
return Response({"detail": "major_category 和 material_category 均必填"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
qs = (Material.objects
|
||||||
|
.filter(major_category=major, material_category=category, status='approved')
|
||||||
|
.exclude(material_subcategory__isnull=True)
|
||||||
|
.exclude(material_subcategory__exact='')
|
||||||
|
.values('material_subcategory')
|
||||||
|
.annotate(count=Count('id'))
|
||||||
|
.order_by('material_subcategory'))
|
||||||
|
data = [{"value": row['material_subcategory'], "count": row['count']} for row in qs]
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
class MaterialCategoryViewSet(ModelViewSet):
|
class MaterialCategoryViewSet(ModelViewSet):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from .views import upload_image
|
from .views import upload_image, serve_h5
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
|
@ -25,9 +25,15 @@ if settings.DEBUG:
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
urlpatterns += static('/assets/', document_root=settings.BASE_DIR / 'dist' / 'assets')
|
urlpatterns += static('/assets/', document_root=settings.BASE_DIR / 'dist' / 'assets')
|
||||||
|
|
||||||
# 前端单页应用入口
|
# H5 子应用:必须放在 PC 通配路由之前
|
||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
re_path(r'^(?!api/|admin/|media/).*$',
|
re_path(r'^m/?$', serve_h5, name='h5-index'),
|
||||||
|
re_path(r'^m/(?P<path>.*)$', serve_h5, name='h5-assets'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 前端单页应用入口(PC 端)
|
||||||
|
urlpatterns += [
|
||||||
|
re_path(r'^(?!api/|admin/|media/|m/).*$',
|
||||||
TemplateView.as_view(template_name='index.html'),
|
TemplateView.as_view(template_name='index.html'),
|
||||||
name='frontend-index'),
|
name='frontend-index'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.views.static import serve as static_serve
|
||||||
from rest_framework.decorators import api_view, permission_classes, parser_classes
|
from rest_framework.decorators import api_view, permission_classes, parser_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.parsers import MultiPartParser
|
from rest_framework.parsers import MultiPartParser
|
||||||
|
|
@ -9,6 +11,22 @@ from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
|
|
||||||
|
H5_DIST_ROOT = Path(settings.BASE_DIR) / 'dist' / 'h5'
|
||||||
|
|
||||||
|
|
||||||
|
def serve_h5(request, path=''):
|
||||||
|
"""
|
||||||
|
H5 SPA 静态资源 + history 路由回退。
|
||||||
|
- /m/ 或 /m/<route>(无扩展名/不存在的路径)→ 返回 dist/index.html
|
||||||
|
- /m/assets/xxx.js, /m/img/xxx.jpg 等真实存在的文件 → 直出
|
||||||
|
"""
|
||||||
|
if path:
|
||||||
|
candidate = H5_DIST_ROOT / path
|
||||||
|
if candidate.is_file():
|
||||||
|
return static_serve(request, path, document_root=str(H5_DIST_ROOT))
|
||||||
|
return static_serve(request, 'index.html', document_root=str(H5_DIST_ROOT))
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_IMAGE_TYPES = {
|
ALLOWED_IMAGE_TYPES = {
|
||||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp',
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,739 @@
|
||||||
|
# 材料列表重构 实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 重构材料管理列表页,实现分级表头(材料信息/品牌与供应商/案例信息)、列显隐配置(localStorage 持久化)、筛选扩展、工具栏紧凑化、详情页分块。
|
||||||
|
|
||||||
|
**Architecture:** 后端 `MaterialListSerializer` 扩展字段 + 筛选集追加;前端列表页改为数据驱动渲染,新增列偏好 composable + 分级表头;详情页(`MaterialForm` view 模式)拆为三个 `el-descriptions`。
|
||||||
|
|
||||||
|
**Tech Stack:** Django + DRF(后端)、Vue 3 + Element Plus(前端)、localStorage(前端持久化)。
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-24-material-list-redesign-design.md`
|
||||||
|
|
||||||
|
**Testing approach:** 项目当前无自动化测试。采用手动验证:后端用 `manage.py check` + curl/浏览器调用接口,前端用 `pnpm dev` / `npm run dev` + 浏览器核对。每个任务结束后在浏览器完成对应验证清单。
|
||||||
|
|
||||||
|
**Dev commands:**
|
||||||
|
- 后端:`D:/projects/mat3/backend/.venv/Scripts/python.exe D:/projects/mat3/backend/manage.py runserver`
|
||||||
|
- 前端:进入 `D:/projects/mat3/frontend` 后按项目约定启动(`npm run dev` 或 `pnpm dev`;查 package.json 确认)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1:后端 `MaterialListSerializer` 字段扩展
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/apps/material/serializers.py`(`MaterialListSerializer` 类)
|
||||||
|
|
||||||
|
- [ ] **Step 1:阅读当前 `MaterialListSerializer`**,记录已有字段集合,明确哪些需要新增。
|
||||||
|
|
||||||
|
- [ ] **Step 2:新增 A 组直通字段**
|
||||||
|
|
||||||
|
在 `MaterialListSerializer.Meta.fields` 中追加:
|
||||||
|
```
|
||||||
|
'spec', 'standard', 'application_scene', 'application_desc',
|
||||||
|
'replace_type', 'advantage', 'advantage_desc',
|
||||||
|
'connection_method', 'construction_method', 'limit_condition',
|
||||||
|
'cost_compare', 'cost_desc',
|
||||||
|
'quality_level', 'durability_level', 'eco_level', 'carbon_level', 'score_level',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:新增 B 组供应商扩展字段**
|
||||||
|
|
||||||
|
在类体添加:
|
||||||
|
```python
|
||||||
|
factory_full_name = serializers.CharField(source='factory.factory_name', read_only=True, default=None)
|
||||||
|
factory_cooperation_mode = serializers.CharField(source='factory.cooperation_mode', read_only=True, default=None)
|
||||||
|
factory_cooperation_mode_display = serializers.SerializerMethodField()
|
||||||
|
factory_province = serializers.CharField(source='factory.province', read_only=True, default=None)
|
||||||
|
factory_city = serializers.CharField(source='factory.city', read_only=True, default=None)
|
||||||
|
|
||||||
|
def get_factory_cooperation_mode_display(self, obj):
|
||||||
|
return obj.factory.get_cooperation_mode_display() if obj.factory else None
|
||||||
|
```
|
||||||
|
把新字段加入 `Meta.fields`。
|
||||||
|
|
||||||
|
- [ ] **Step 4:确认视图 queryset 有 `select_related('factory', 'brand')`**
|
||||||
|
|
||||||
|
检查 `backend/apps/material/views.py` 列表视图的 `get_queryset()` 或 `queryset`;如无则加上,避免 N+1。
|
||||||
|
|
||||||
|
- [ ] **Step 5:跑 `check`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
D:/projects/mat3/backend/.venv/Scripts/python.exe D:/projects/mat3/backend/manage.py check
|
||||||
|
```
|
||||||
|
预期:`System check identified no issues`
|
||||||
|
|
||||||
|
- [ ] **Step 6:启动 runserver,curl / 浏览器请求 `/api/materials/?page_size=1`**,确认 JSON 中含所有新字段。
|
||||||
|
|
||||||
|
- [ ] **Step 7:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/apps/material/serializers.py backend/apps/material/views.py
|
||||||
|
git commit -m "feat(material): 列表序列化器扩展成本/优势/主要参数/供应商字段"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2:后端列表筛选字段追加
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/apps/material/views.py`(或独立的 `filters.py`)
|
||||||
|
|
||||||
|
- [ ] **Step 1:定位当前 filterset 配置**(`filterset_fields` 或 `FilterSet` 子类)。
|
||||||
|
|
||||||
|
- [ ] **Step 2:扩展筛选**
|
||||||
|
|
||||||
|
若使用 `filterset_fields` 简单 dict:
|
||||||
|
```python
|
||||||
|
filterset_fields = {
|
||||||
|
'name': ['icontains'],
|
||||||
|
'status': ['exact'],
|
||||||
|
'material_subcategory': ['exact'],
|
||||||
|
'brand': ['exact'],
|
||||||
|
'major_category': ['exact'],
|
||||||
|
'material_category': ['icontains'],
|
||||||
|
'stage': ['exact'],
|
||||||
|
'importance_level': ['exact'],
|
||||||
|
'factory': ['exact'],
|
||||||
|
'factory__cooperation_mode': ['exact'],
|
||||||
|
'landing_project': ['icontains'],
|
||||||
|
'cost_compare': ['gte', 'lte'],
|
||||||
|
'score_level': ['gte'],
|
||||||
|
'contact_person': ['icontains'],
|
||||||
|
'handler': ['icontains'],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
若使用 `FilterSet` 子类,添加对应字段(参考现有写法)。
|
||||||
|
|
||||||
|
- [ ] **Step 3:逐个 curl 验证**(至少抽查 3 个:`?major_category=xxx`、`?cost_compare__gte=10`、`?landing_project__icontains=abc`)
|
||||||
|
|
||||||
|
- [ ] **Step 4:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/apps/material/
|
||||||
|
git commit -m "feat(material): 列表新增大类/阶段/供应商/成本区间等筛选"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3:前端列元数据常量
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/views/material/materialColumns.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1:新建文件**,导出 `COLUMN_GROUPS` 与 `MATERIAL_COLUMNS`
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const COLUMN_GROUPS = [
|
||||||
|
{ key: 'material', label: '材料信息' },
|
||||||
|
{ key: 'supplier', label: '品牌与供应商' },
|
||||||
|
{ key: 'case', label: '案例信息' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const fmt = (v) => (v === null || v === undefined || v === '' ? '-' : v)
|
||||||
|
|
||||||
|
export const MATERIAL_COLUMNS = [
|
||||||
|
// ==== A. 材料信息 ====
|
||||||
|
{ group: 'material', key: 'name', label: '材料名称', minWidth: 180, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'major_category_display', label: '材料大类', width: 100 },
|
||||||
|
{ group: 'material', key: 'material_category', label: '细分种类', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'material_subcategory', label: '材料子类', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'stage_display', label: '阶段', width: 130, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'importance_level_display', label: '重要等级', width: 110 },
|
||||||
|
{ group: 'material', key: 'status_display', label: '状态', width: 100 },
|
||||||
|
{ group: 'material', key: 'cost_compare', label: '成本比较(%)', width: 120, formatter: (r) => fmt(r.cost_compare) },
|
||||||
|
{ group: 'material', key: 'cost_desc', label: '成本说明', minWidth: 160, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'advantage', label: '优势', minWidth: 200, slot: 'tags' },
|
||||||
|
{ group: 'material', key: 'advantage_desc', label: '优势说明', minWidth: 160, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'application_scene', label: '应用场景', minWidth: 200, slot: 'tags' },
|
||||||
|
{ group: 'material', key: 'application_desc', label: '应用说明', minWidth: 160, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'replace_type', label: '替代类型', width: 120, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'connection_method', label: '连接方式', width: 120, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'construction_method', label: '施工方式', width: 120, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'limit_condition', label: '使用限制', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'spec', label: '规格', width: 120, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'standard', label: '执行标准', width: 120, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'quality_level', label: '质量', width: 90, slot: 'stars' },
|
||||||
|
{ group: 'material', key: 'durability_level', label: '耐久', width: 90, slot: 'stars' },
|
||||||
|
{ group: 'material', key: 'eco_level', label: '环保', width: 90, slot: 'stars' },
|
||||||
|
{ group: 'material', key: 'carbon_level', label: '碳', width: 90, slot: 'stars' },
|
||||||
|
{ group: 'material', key: 'score_level', label: '综合评分', width: 110, slot: 'stars' },
|
||||||
|
|
||||||
|
// ==== B. 品牌与供应商 ====
|
||||||
|
{ group: 'supplier', key: 'brand_name', label: '品牌', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'supplier', key: 'factory_short_name', label: '供应商简称', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'supplier', key: 'factory_full_name', label: '供应商全称', minWidth: 180, showOverflowTooltip: true },
|
||||||
|
{ group: 'supplier', key: 'factory_cooperation_mode_display', label: '合作模式', width: 110 },
|
||||||
|
{ group: 'supplier', key: 'factory_location', label: '省-市', width: 140, formatter: (r) => [r.factory_province, r.factory_city].filter(Boolean).join('-') || '-' },
|
||||||
|
{ group: 'supplier', key: 'contact_person', label: '对接人', width: 100, showOverflowTooltip: true },
|
||||||
|
{ group: 'supplier', key: 'contact_phone', label: '对接电话', width: 150, showOverflowTooltip: true },
|
||||||
|
|
||||||
|
// ==== C. 案例信息 ====
|
||||||
|
{ group: 'case', key: 'landing_project', label: '落地项目', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'case', key: 'cases', label: '案例', minWidth: 200, showOverflowTooltip: true },
|
||||||
|
{ group: 'case', key: 'handler', label: '经办人', width: 100, showOverflowTooltip: true },
|
||||||
|
{ group: 'case', key: 'remark', label: '备注', minWidth: 160, showOverflowTooltip: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ALL_COLUMN_KEYS = MATERIAL_COLUMNS.map((c) => c.key)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/views/material/materialColumns.js
|
||||||
|
git commit -m "feat(material): 添加列表列元数据常量"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4:列偏好 composable
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/composables/useColumnPreferences.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1:新建 composable**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export function useColumnPreferences(storageKey) {
|
||||||
|
const hidden = ref([])
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey)
|
||||||
|
if (!raw) return
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (Array.isArray(parsed)) hidden.value = parsed.filter((x) => typeof x === 'string')
|
||||||
|
} catch {
|
||||||
|
hidden.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(hidden.value))
|
||||||
|
} catch { /* quota / private mode — ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVisible = (key) => !hidden.value.includes(key)
|
||||||
|
|
||||||
|
const toggle = (key) => {
|
||||||
|
const i = hidden.value.indexOf(key)
|
||||||
|
if (i >= 0) hidden.value.splice(i, 1)
|
||||||
|
else hidden.value.push(key)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setGroupVisible = (groupKeys, visible) => {
|
||||||
|
if (visible) {
|
||||||
|
hidden.value = hidden.value.filter((k) => !groupKeys.includes(k))
|
||||||
|
} else {
|
||||||
|
const set = new Set(hidden.value)
|
||||||
|
groupKeys.forEach((k) => set.add(k))
|
||||||
|
hidden.value = [...set]
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
hidden.value = []
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return { hidden, isVisible, toggle, setGroupVisible, reset }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/composables/useColumnPreferences.js
|
||||||
|
git commit -m "feat: 添加列显隐偏好 composable"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5:列表页改造(分级表头 + 特殊 slot + 列设置)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/views/MaterialManage.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1:在 `<script setup>` 顶部 import**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { MATERIAL_COLUMNS, COLUMN_GROUPS, ALL_COLUMN_KEYS } from '@/views/material/materialColumns'
|
||||||
|
import { useColumnPreferences } from '@/composables/useColumnPreferences'
|
||||||
|
import { Setting, Star, StarFilled, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
|
||||||
|
```
|
||||||
|
|
||||||
|
(若项目未引入 `@element-plus/icons-vue`,用现有图标约定;如 `el-icon-setting` 字体图标则调整。)
|
||||||
|
|
||||||
|
- [ ] **Step 2:声明 composable 与计算**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { isVisible, toggle, setGroupVisible, reset: resetColumns, hidden } = useColumnPreferences('mat3:material-list:columns:v1')
|
||||||
|
|
||||||
|
const visibleColumnsOfGroup = (groupKey) =>
|
||||||
|
MATERIAL_COLUMNS.filter((c) => c.group === groupKey && isVisible(c.key))
|
||||||
|
|
||||||
|
const hasVisibleInGroup = (groupKey) => visibleColumnsOfGroup(groupKey).length > 0
|
||||||
|
|
||||||
|
const groupColumnKeys = (groupKey) =>
|
||||||
|
MATERIAL_COLUMNS.filter((c) => c.group === groupKey).map((c) => c.key)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:替换表格 `<el-table>` 内部为数据驱动分级表头**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<el-table v-loading="tableLoading" :data="materials" border height="100%">
|
||||||
|
<template v-for="group in COLUMN_GROUPS" :key="group.key">
|
||||||
|
<el-table-column
|
||||||
|
v-if="hasVisibleInGroup(group.key)"
|
||||||
|
:label="group.label"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
v-for="col in visibleColumnsOfGroup(group.key)"
|
||||||
|
:key="col.key"
|
||||||
|
:prop="col.slot || col.formatter ? undefined : col.key"
|
||||||
|
:label="col.label"
|
||||||
|
:min-width="col.minWidth"
|
||||||
|
:width="col.width"
|
||||||
|
:show-overflow-tooltip="col.showOverflowTooltip"
|
||||||
|
>
|
||||||
|
<template v-if="col.slot === 'tags'" #default="scope">
|
||||||
|
<template v-if="Array.isArray(scope.row[col.key]) && scope.row[col.key].length">
|
||||||
|
<el-tag
|
||||||
|
v-for="(t, idx) in scope.row[col.key]"
|
||||||
|
:key="idx"
|
||||||
|
size="small"
|
||||||
|
style="margin-right: 4px; margin-bottom: 2px;"
|
||||||
|
>{{ t }}</el-tag>
|
||||||
|
</template>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="col.slot === 'stars'" #default="scope">
|
||||||
|
<span v-if="scope.row[col.key]">
|
||||||
|
<el-icon v-for="n in scope.row[col.key]" :key="n" color="#f7ba2a"><StarFilled /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="col.formatter" #default="scope">
|
||||||
|
{{ col.formatter(scope.row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="320" fixed="right">
|
||||||
|
<!-- 保留原有操作 slot 内容 -->
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4:在工具栏右侧加"列设置"按钮 + popover**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<el-popover placement="bottom-end" :width="440" trigger="click">
|
||||||
|
<template #reference>
|
||||||
|
<el-button :icon="Setting" circle title="列设置" />
|
||||||
|
</template>
|
||||||
|
<div class="column-setting">
|
||||||
|
<div v-for="group in COLUMN_GROUPS" :key="group.key" class="column-setting__group">
|
||||||
|
<div class="column-setting__header">
|
||||||
|
<span class="column-setting__title">{{ group.label }}</span>
|
||||||
|
<el-button size="small" text @click="setGroupVisible(groupColumnKeys(group.key), true)">全选</el-button>
|
||||||
|
<el-button size="small" text @click="setGroupVisible(groupColumnKeys(group.key), false)">全不选</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="column-setting__cols">
|
||||||
|
<el-checkbox
|
||||||
|
v-for="col in MATERIAL_COLUMNS.filter((c) => c.group === group.key)"
|
||||||
|
:key="col.key"
|
||||||
|
:model-value="isVisible(col.key)"
|
||||||
|
@change="toggle(col.key)"
|
||||||
|
>{{ col.label }}</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column-setting__footer">
|
||||||
|
<el-button size="small" @click="resetColumns">恢复默认</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
```
|
||||||
|
|
||||||
|
添加 `<style scoped>` 样式:
|
||||||
|
```css
|
||||||
|
.column-setting__group { margin-bottom: 12px; }
|
||||||
|
.column-setting__header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.column-setting__title { font-weight: 600; }
|
||||||
|
.column-setting__cols { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px 12px; }
|
||||||
|
.column-setting__footer { display: flex; justify-content: flex-end; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5:`npm run dev` 启动前端,浏览器打开材料列表**
|
||||||
|
|
||||||
|
验证:
|
||||||
|
- 三组表头分层显示
|
||||||
|
- 默认全部列可见
|
||||||
|
- 列设置 popover 勾选后列立即隐藏,刷新页面后偏好保留
|
||||||
|
- localStorage 键 `mat3:material-list:columns:v1` 写入正确
|
||||||
|
- 整组取消勾选后该组表头消失
|
||||||
|
- tag / star 渲染正确
|
||||||
|
|
||||||
|
- [ ] **Step 6:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/views/MaterialManage.vue
|
||||||
|
git commit -m "feat(material): 列表页分级表头 + 列显隐配置"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6:工具栏紧凑化 + 自动触发 + 高级筛选
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/views/MaterialManage.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1:扩展 `filters` reactive**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const filters = reactive({
|
||||||
|
// 常用
|
||||||
|
name: '', status: '', material_subcategory: '', brand: '',
|
||||||
|
// 高级
|
||||||
|
major_category: '', material_category: '', stage: '', importance_level: '',
|
||||||
|
factory: '', factory__cooperation_mode: '',
|
||||||
|
landing_project: '', cost_compare__gte: null, cost_compare__lte: null,
|
||||||
|
score_level__gte: null, contact_person: '', handler: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const advancedOpen = ref(false)
|
||||||
|
const advancedKeys = [
|
||||||
|
'major_category','material_category','stage','importance_level',
|
||||||
|
'factory','factory__cooperation_mode','landing_project',
|
||||||
|
'cost_compare__gte','cost_compare__lte','score_level__gte',
|
||||||
|
'contact_person','handler',
|
||||||
|
]
|
||||||
|
const hasAdvancedActive = computed(() =>
|
||||||
|
advancedKeys.some((k) => filters[k] !== '' && filters[k] !== null && filters[k] !== undefined)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
(记得 `import { computed } from 'vue'`。)
|
||||||
|
|
||||||
|
- [ ] **Step 2:新增查询辅助函数**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const triggerSearch = () => {
|
||||||
|
pagination.page = 1
|
||||||
|
loadMaterials()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
Object.keys(filters).forEach((k) => {
|
||||||
|
filters[k] = (typeof filters[k] === 'number') ? null : (filters[k] === null ? null : '')
|
||||||
|
})
|
||||||
|
triggerSearch()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:加载枚举选项**
|
||||||
|
|
||||||
|
在 `onMounted` 前补充:
|
||||||
|
```js
|
||||||
|
const majorCategoryOptions = ref([])
|
||||||
|
const stageOptions = ref([])
|
||||||
|
const importanceLevelOptions = ref([])
|
||||||
|
const cooperationModeOptions = ref([])
|
||||||
|
const factoryFilterOptions = ref([])
|
||||||
|
const factorySearchLoading = ref(false)
|
||||||
|
|
||||||
|
const loadEnumOptions = async () => {
|
||||||
|
const data = await fetchMaterialChoices() // 已有接口
|
||||||
|
statusOptions.value = data.status
|
||||||
|
majorCategoryOptions.value = data.major_category || []
|
||||||
|
stageOptions.value = data.stage || []
|
||||||
|
importanceLevelOptions.value = data.importance_level || []
|
||||||
|
cooperationModeOptions.value = data.cooperation_mode || [] // 若后端未返回,需 Task 7 补
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFactories = async (query) => {
|
||||||
|
factorySearchLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchFactories({ page_size: 50, search: query || '' })
|
||||||
|
factoryFilterOptions.value = data.results || data
|
||||||
|
} finally { factorySearchLoading.value = false }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
确认 `loadStatusOptions` 调用替换为 `loadEnumOptions`;确认 `fetchFactories` 已在 `@/api/factory` 导出,否则补。
|
||||||
|
|
||||||
|
⚠️ **若 `fetchMaterialChoices` 当前只返回 status**,需要扩后端 `/api/materials/choices/` 同时返回 `major_category / stage / importance_level / cooperation_mode` 的 `[[value, label]]`。把此扩展纳入本 step。
|
||||||
|
|
||||||
|
- [ ] **Step 4:替换工具栏 HTML 为两行结构**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<el-input v-model="filters.name" placeholder="材料名称" clearable style="width: 180px" @keyup.enter="triggerSearch" />
|
||||||
|
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 140px" @change="triggerSearch">
|
||||||
|
<el-option v-for="item in statusOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.material_subcategory" placeholder="材料子类" clearable style="width: 160px" @change="triggerSearch">
|
||||||
|
<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: 160px" @change="triggerSearch">
|
||||||
|
<el-option v-for="item in brandFilterOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-badge :is-dot="hasAdvancedActive" class="advanced-badge">
|
||||||
|
<el-button text @click="advancedOpen = !advancedOpen">
|
||||||
|
高级筛选
|
||||||
|
<el-icon style="margin-left: 4px;"><component :is="advancedOpen ? ArrowUp : ArrowDown" /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-badge>
|
||||||
|
|
||||||
|
<div class="toolbar-spacer" />
|
||||||
|
|
||||||
|
<el-button v-if="isAdmin" :loading="importing" @click="importDialogVisible = true">
|
||||||
|
{{ importing ? '导入中...' : '导入数据' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button :loading="exporting" @click="handleExportExcel">
|
||||||
|
{{ exporting ? '导出中...' : '导出' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="openCreate">新增材料</el-button>
|
||||||
|
|
||||||
|
<!-- 列设置 popover(Task 5 已加入,保留) -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="advancedOpen" class="toolbar-row toolbar-row--advanced">
|
||||||
|
<el-select v-model="filters.major_category" placeholder="材料大类" clearable style="width: 140px" @change="triggerSearch">
|
||||||
|
<el-option v-for="item in majorCategoryOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.stage" placeholder="阶段" clearable style="width: 140px" @change="triggerSearch">
|
||||||
|
<el-option v-for="item in stageOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.importance_level" placeholder="重要等级" clearable style="width: 140px" @change="triggerSearch">
|
||||||
|
<el-option v-for="item in importanceLevelOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.factory__cooperation_mode" placeholder="合作模式" clearable style="width: 140px" @change="triggerSearch">
|
||||||
|
<el-option v-for="item in cooperationModeOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.factory" placeholder="供应商" clearable filterable remote
|
||||||
|
:remote-method="searchFactories" :loading="factorySearchLoading"
|
||||||
|
style="width: 180px" @change="triggerSearch">
|
||||||
|
<el-option v-for="item in factoryFilterOptions" :key="item.id" :label="item.short_name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="filters.material_category" placeholder="细分种类" clearable style="width: 160px" @keyup.enter="triggerSearch" />
|
||||||
|
<el-input v-model="filters.landing_project" placeholder="落地项目" clearable style="width: 160px" @keyup.enter="triggerSearch" />
|
||||||
|
<el-input v-model="filters.contact_person" placeholder="对接人" clearable style="width: 120px" @keyup.enter="triggerSearch" />
|
||||||
|
<el-input v-model="filters.handler" placeholder="经办人" clearable style="width: 120px" @keyup.enter="triggerSearch" />
|
||||||
|
<el-input-number v-model="filters.cost_compare__gte" :min="0" placeholder="成本≥" controls-position="right" style="width: 120px" @change="triggerSearch" />
|
||||||
|
<el-input-number v-model="filters.cost_compare__lte" :min="0" placeholder="成本≤" controls-position="right" style="width: 120px" @change="triggerSearch" />
|
||||||
|
<el-select v-model="filters.score_level__gte" placeholder="综合评分≥" clearable style="width: 140px" @change="triggerSearch">
|
||||||
|
<el-option v-for="n in [1,2,3]" :key="n" :label="`${n} 星及以上`" :value="n" />
|
||||||
|
</el-select>
|
||||||
|
<el-button @click="resetFilters">重置筛选</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5:调整样式**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.toolbar { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.toolbar-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.toolbar-row--advanced { padding: 8px; background: #f5f7fa; border-radius: 4px; }
|
||||||
|
.toolbar-spacer { flex: 1 1 auto; }
|
||||||
|
.advanced-badge :deep(.el-badge__content.is-dot) { top: 6px; right: 6px; }
|
||||||
|
```
|
||||||
|
|
||||||
|
原 `.toolbar-spacer` 若已有保留;原"查询"按钮删除。
|
||||||
|
|
||||||
|
- [ ] **Step 6:清理 `loadMaterials`** —— 传参时展开整个 `filters`;后端忽略空值(DRF FilterSet 默认行为)。验证空字符串传递不影响筛选(必要时加 `Object.fromEntries(Object.entries(filters).filter(([,v]) => v !== '' && v !== null))`)。
|
||||||
|
|
||||||
|
- [ ] **Step 7:浏览器验证**
|
||||||
|
|
||||||
|
- 第一行常用筛选 change/Enter 自动触发查询
|
||||||
|
- 点"高级筛选"展开第二行;设置任意高级值后收起,按钮出现红点
|
||||||
|
- "重置筛选"清空所有筛选并刷新
|
||||||
|
- 新增/导入/导出/编辑流程正常
|
||||||
|
|
||||||
|
- [ ] **Step 8:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/views/MaterialManage.vue frontend/src/api/factory.js
|
||||||
|
git commit -m "feat(material): 工具栏紧凑化、筛选自动触发、增加高级筛选"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7:`fetchMaterialChoices` 后端补齐枚举(按需)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/apps/material/views.py`(choices 接口)
|
||||||
|
|
||||||
|
若 Task 6 Step 3 发现后端 `/materials/choices/` 未返回 `major_category / stage / importance_level / cooperation_mode`,在此 Task 补齐。`cooperation_mode` 来自 `Factory` 模型的 `COOPERATION_MODE_CHOICES`。
|
||||||
|
|
||||||
|
- [ ] **Step 1:读取 `views.py` 中 `choices` action**
|
||||||
|
|
||||||
|
- [ ] **Step 2:补齐返回**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.factory.models import Factory
|
||||||
|
return Response({
|
||||||
|
'status': Material.STATUS_CHOICES,
|
||||||
|
'major_category': Material.MAJOR_CATEGORY_CHOICES,
|
||||||
|
'stage': Material.STAGE_CHOICES,
|
||||||
|
'importance_level': Material.IMPORTANCE_LEVEL_CHOICES,
|
||||||
|
'cooperation_mode': Factory.COOPERATION_MODE_CHOICES,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
(确认实际类属性名。)
|
||||||
|
|
||||||
|
- [ ] **Step 3:curl 验证返回 JSON 含新 key**
|
||||||
|
|
||||||
|
- [ ] **Step 4:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/apps/material/views.py
|
||||||
|
git commit -m "feat(material): choices 接口返回大类/阶段/重要等级/合作模式"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8:详情页(`MaterialForm` view 模式)分块
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/views/material/MaterialForm.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1:定位 view 模式分支**
|
||||||
|
|
||||||
|
当前 view 模式是一个 `el-descriptions` 平铺所有字段。拆分思路:把 A/B/C 三组各用一个 `el-descriptions` 包住。
|
||||||
|
|
||||||
|
- [ ] **Step 2:重写 view 模式模板**
|
||||||
|
|
||||||
|
结构:
|
||||||
|
```html
|
||||||
|
<template v-if="mode === 'view'">
|
||||||
|
<el-descriptions title="材料信息" :column="2" border class="detail-section">
|
||||||
|
<el-descriptions-item label="材料名称">{{ form.name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="材料大类">{{ form.major_category_display || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="细分种类">{{ form.material_category || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="材料子类">{{ form.material_subcategory || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="阶段">{{ form.stage_display || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="重要等级">{{ form.importance_level_display || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">{{ form.status_display || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="规格">{{ form.spec || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="执行标准">{{ form.standard || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="应用场景">
|
||||||
|
<el-tag v-for="t in (form.application_scene || [])" :key="t" size="small" style="margin-right: 4px;">{{ t }}</el-tag>
|
||||||
|
<span v-if="!form.application_scene?.length">-</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="应用说明">{{ form.application_desc || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="替代类型">{{ form.replace_type || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="连接方式">{{ form.connection_method || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="施工方式">{{ form.construction_method || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="使用限制">{{ form.limit_condition || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="优势">
|
||||||
|
<el-tag v-for="t in (form.advantage || [])" :key="t" size="small" style="margin-right: 4px;">{{ t }}</el-tag>
|
||||||
|
<span v-if="!form.advantage?.length">-</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="优势说明">{{ form.advantage_desc || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="成本比较(%)">{{ form.cost_compare ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="成本说明">{{ form.cost_desc || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="质量"><Stars :value="form.quality_level" /></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="耐久"><Stars :value="form.durability_level" /></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="环保"><Stars :value="form.eco_level" /></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="碳"><Stars :value="form.carbon_level" /></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="综合评分"><Stars :value="form.score_level" /></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="宣传册" :span="2">
|
||||||
|
<img v-if="form.brochure_url" :src="form.brochure_url" style="max-width: 260px;" />
|
||||||
|
<span v-else>-</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-descriptions title="品牌与供应商" :column="2" border class="detail-section">
|
||||||
|
<el-descriptions-item label="品牌">{{ form.brand_name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="供应商简称">{{ form.factory_short_name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="供应商全称">{{ form.factory_full_name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="合作模式">{{ form.factory_cooperation_mode_display || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="省-市">{{ [form.factory_province, form.factory_city].filter(Boolean).join('-') || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接人">{{ form.contact_person || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接电话">{{ form.contact_phone || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-descriptions title="案例信息" :column="1" border class="detail-section">
|
||||||
|
<el-descriptions-item label="落地项目">{{ form.landing_project || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="案例">
|
||||||
|
<div style="white-space: pre-wrap;">{{ form.cases || '-' }}</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="经办人">{{ form.handler || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注">{{ form.remark || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:新增本地 `Stars` 小组件**(或内嵌 `<template>`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const Stars = {
|
||||||
|
props: ['value'],
|
||||||
|
template: `<span v-if="value"><el-icon v-for="n in value" :key="n" color="#f7ba2a"><StarFilled /></el-icon></span><span v-else>-</span>`,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(或直接在模板里 inline,避免引入组件。)
|
||||||
|
|
||||||
|
- [ ] **Step 4:样式**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.detail-section { margin-bottom: 16px; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5:确认详情页取数**
|
||||||
|
|
||||||
|
`MaterialDetail.vue` 调用 `fetchMaterialDetail`(详情接口,字段已全);但 `factory_full_name / factory_cooperation_mode_display / factory_province / factory_city` 是我们在 List 序列化器加的字段——需要确认 Detail 序列化器也返回这些,或者在详情页的 view 模式下通过嵌套的 `factory` 对象取。
|
||||||
|
|
||||||
|
→ 若详情接口返回嵌套 `factory`(对象)而非扁平字段,模板改为 `form.factory?.factory_name` / `form.factory?.get_cooperation_mode_display`(后端需返回 display)。本 step 先读 serializers,按现状调整。
|
||||||
|
|
||||||
|
- [ ] **Step 6:浏览器验证详情页三块分节**
|
||||||
|
|
||||||
|
- [ ] **Step 7:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/views/material/MaterialForm.vue
|
||||||
|
git commit -m "feat(material): 详情视图分三块展示"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9:回归与收尾
|
||||||
|
|
||||||
|
- [ ] **Step 1:端到端手动回归**
|
||||||
|
|
||||||
|
- 列表筛选(常用 + 高级)全部生效
|
||||||
|
- 列设置:勾选/全选/全不选/恢复默认、localStorage 持久化
|
||||||
|
- 整组隐藏时表头消失
|
||||||
|
- 新增 / 编辑 / 提交审核 / 审核通过 / 审核拒绝 / 删除 / 导入 / 导出均正常
|
||||||
|
- 详情页三块展示正确
|
||||||
|
- 分页 / 分页大小切换正常
|
||||||
|
|
||||||
|
- [ ] **Step 2:检查控制台无报错**
|
||||||
|
|
||||||
|
- [ ] **Step 3:若发现 issue,修复并 commit**
|
||||||
|
|
||||||
|
- [ ] **Step 4:(可选)创建合并 PR**(由用户决定)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 备注 / 风险
|
||||||
|
|
||||||
|
- **后端字段命名**:B 组使用 `factory_full_name` 避免与 Factory 序列化器里可能已有的 `factory_name` 冲突。确认 `Factory.factory_name` 字段存在;若实际字段名是 `name`(非 `factory_name`),全局调整。
|
||||||
|
- **`fetchMaterialChoices` 扩展**:若前端已有其他页面依赖其返回形状(如只有 `status`),加字段是向后兼容的。
|
||||||
|
- **`application_scene / advantage` 均为 JSONField**:确认数据库中存的是数组而非逗号字符串;若为字符串,渲染 fallback 显示原文。
|
||||||
|
- **详情接口字段对齐**:`MaterialDetailSerializer` 若返回嵌套 `factory` 对象,详情页模板用 `form.factory?.xxx`;否则用扁平字段。Task 8 Step 5 处理。
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
# H5 材料浏览端 设计文档
|
||||||
|
|
||||||
|
- 日期:2026-04-24
|
||||||
|
- 作者:caoqianming
|
||||||
|
- 状态:草案
|
||||||
|
|
||||||
|
## 1 · 背景与目标
|
||||||
|
|
||||||
|
在现有 mat3 项目(PC 端材料管理 + Django 后端)基础上,新增面向手机浏览器的 **H5 材料查看端**。
|
||||||
|
核心诉求:**只读浏览**——登录后按 "大类 → 种类 → 子类 → 材料 → 详情" 的层级快速找到材料并查看详细信息。
|
||||||
|
不包含新增 / 编辑 / 导入 / 审批等写操作。
|
||||||
|
|
||||||
|
## 2 · 项目结构与技术栈
|
||||||
|
|
||||||
|
### 2.1 组织方式
|
||||||
|
新建独立项目 `frontend-h5/`,与现有 `frontend/` 平级;独立 Vite、独立依赖、独立打包。
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend-h5/
|
||||||
|
├── index.html
|
||||||
|
├── vite.config.js # base: '/m/',dev proxy → 后端
|
||||||
|
├── package.json
|
||||||
|
├── tailwind.config.js
|
||||||
|
├── postcss.config.js
|
||||||
|
└── src/
|
||||||
|
├── main.js
|
||||||
|
├── App.vue
|
||||||
|
├── router/ # vue-router 4,history 模式
|
||||||
|
├── store/ # pinia
|
||||||
|
├── api/ # 参照 frontend/src/api 复制同名文件,保持一致
|
||||||
|
├── composables/ # useAuth / useInfiniteScroll / useToast ...
|
||||||
|
├── styles/ # tailwind.css、全局变量
|
||||||
|
├── components/ # MaterialCard / CategoryCard / StarLevel / Chip / NavBar / Toast ...
|
||||||
|
└── views/
|
||||||
|
├── Login.vue
|
||||||
|
├── Home.vue
|
||||||
|
├── CategoryDetail.vue
|
||||||
|
└── MaterialDetail.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 技术栈
|
||||||
|
- Vue 3 + vue-router 4 + Pinia
|
||||||
|
- Axios(同 PC 端一致的拦截器:附 token、统一错误处理、401 跳登录)
|
||||||
|
- **Tailwind CSS**(自定义设计令牌),不使用 Vant/NutUI
|
||||||
|
- 少量自研组件(Toast、NavBar、Tab、InfiniteList、StarLevel、Chip、Skeleton)
|
||||||
|
|
||||||
|
### 2.3 部署
|
||||||
|
- 构建产物部署到 Nginx 子路径 `/m/`(与 PC 端同域名)
|
||||||
|
- `vite.config.js` 设 `base: '/m/'`
|
||||||
|
- 开发期 `vite` devServer 走本地端口(如 5174),通过 `server.proxy` 把 `/api` 转发到后端
|
||||||
|
|
||||||
|
### 2.4 登录态
|
||||||
|
- `localStorage` 存 token,key 为 `h5_token`(与 PC 端 `token` 隔离,避免互相污染)
|
||||||
|
- Pinia `authStore` 暴露 `token` / `user` / `login()` / `logout()`
|
||||||
|
- Axios request 拦截器:存在 `h5_token` 则附 `Authorization`
|
||||||
|
- Axios response 拦截器:401 清 token 并跳 `/login?redirect=<current>`
|
||||||
|
|
||||||
|
## 3 · 路由与页面流
|
||||||
|
|
||||||
|
| 路径 | 页面 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/login` | Login | 账号密码登录 |
|
||||||
|
| `/` | Home | 大类卡片 + 选中后展开种类区 |
|
||||||
|
| `/category/:major/:category` | CategoryDetail | 子类 Tab + 材料列表 |
|
||||||
|
| `/material/:id` | MaterialDetail | 材料详情(三块) |
|
||||||
|
|
||||||
|
**路由守卫**:非 `/login` 页进入前校验 token;无 token → 跳 `/login?redirect=<原路径>`;登录成功后回跳。
|
||||||
|
|
||||||
|
**过渡**:页面切换用右推左 slide + fade CSS 过渡。
|
||||||
|
|
||||||
|
**状态保留**:
|
||||||
|
- Pinia `uiStore` 记录 Home 选中的大类、CategoryDetail 选中的子类 Tab、列表滚动位置
|
||||||
|
- 从详情返回时恢复选中态和滚动位置
|
||||||
|
- 使用 `<keep-alive>` 缓存 Home 与 CategoryDetail
|
||||||
|
|
||||||
|
## 4 · 数据接口
|
||||||
|
|
||||||
|
### 4.1 复用现有接口
|
||||||
|
|
||||||
|
| 用途 | 接口 |
|
||||||
|
| --- | --- |
|
||||||
|
| 登录 | `POST /auth/login/` |
|
||||||
|
| 当前用户 | `GET /auth/user/` |
|
||||||
|
| 材料列表 | `GET /material/?major_category=&material_category=&material_subcategory=&search=&page=&page_size=` |
|
||||||
|
| 材料详情 | `GET /material/{id}/` |
|
||||||
|
| 选项字典 | `GET /material/choices/` |
|
||||||
|
|
||||||
|
### 4.2 新增接口
|
||||||
|
|
||||||
|
由于 `MaterialCategory` 与 `Material.major_category` 没有 FK 关系,"大类下的种类""种类下的子类" 需要从 `Material` 表按大类 distinct 计算。两个新接口都作为 `MaterialViewSet` 的 `@action` 提供。
|
||||||
|
|
||||||
|
**接口 1**:`GET /material/categories-by-major/?major_category=architecture`
|
||||||
|
|
||||||
|
- 从 `Material` 表取 `major_category=X` 的记录,按 `material_category` 分组 count
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "value": "地砖", "count": 12 },
|
||||||
|
{ "value": "涂料", "count": 8 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**接口 2**:`GET /material/subcategories-by-category/?major_category=&material_category=`
|
||||||
|
|
||||||
|
- 同上,按 `material_subcategory` 分组 count,过滤空值
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "value": "釉面砖", "count": 5 },
|
||||||
|
{ "value": "通体砖", "count": 3 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
"全部"分类不从后端返回,由前端在 Tab 第一项注入,点击时不传 `material_subcategory` 参数。
|
||||||
|
|
||||||
|
### 4.3 列表卡片使用字段
|
||||||
|
|
||||||
|
- `name`(材料名称)
|
||||||
|
- `cost_compare`(成本对比,百分数,负数表示更便宜)
|
||||||
|
- `advantage_display`(竞争优势,取前 2 个 chip 显示)
|
||||||
|
- `importance_level`(重要等级 chip)
|
||||||
|
- `score_level`(综合评分,1-3 星)
|
||||||
|
- `factory_short_name`(供应商简称)
|
||||||
|
|
||||||
|
### 4.4 分页与加载
|
||||||
|
|
||||||
|
- `page_size = 20`
|
||||||
|
- 列表支持下拉刷新 + 上拉无限加载(基于 IntersectionObserver 的 `useInfiniteScroll`)
|
||||||
|
|
||||||
|
## 5 · 视觉与交互设计
|
||||||
|
|
||||||
|
### 5.1 设计语言
|
||||||
|
**克制的卡片式 · 暖中性色** + **墨绿品牌色**
|
||||||
|
|
||||||
|
- 背景:白 `#FFFFFF` + 极浅灰分层 `#FAFAFA` / `#F5F4F2`
|
||||||
|
- 主色:`#2F4F3F`(墨绿),用于按钮、重点数值、选中态
|
||||||
|
- 辅助色:红 `#D2584A`(核心)、蓝 `#5A7FB8`(优先)、灰 `#8A8A8A`(一般)
|
||||||
|
- 卡片圆角 `16-20px`;阴影 `0 1px 2px rgba(0,0,0,.04)`;不用重描边
|
||||||
|
- 字体:系统栈 (`-apple-system, "PingFang SC", ...`);数值字段启用 `font-feature-settings: "tnum"` 等宽
|
||||||
|
- 点按反馈:`active:scale-[0.98]` + 背景变深
|
||||||
|
|
||||||
|
以上颜色 / 圆角 / 间距在 `tailwind.config.js` 定义为 theme tokens,后续组件直接用工具类。
|
||||||
|
|
||||||
|
### 5.2 关键页面
|
||||||
|
|
||||||
|
**Login**
|
||||||
|
- 上 60% 留白 + 品牌标识
|
||||||
|
- 下 40% 表单:用户名、密码、提交按钮
|
||||||
|
- 失败信息由 Toast 从顶部下滑显示,2s 自动消失
|
||||||
|
|
||||||
|
**Home**
|
||||||
|
- 顶部栏:欢迎语 + 用户名 + 退出图标
|
||||||
|
- 主体:2×2 大类卡片网格(建筑/景观/设备/装修),每卡显示大类名 + 材料总数
|
||||||
|
- 选中大类后在下方 slide-down + fade 出现种类卡片区(2 列网格,卡带材料数角标)
|
||||||
|
- 未选中时下方显示引导文案
|
||||||
|
- 选中的大类 ID 写入 `uiStore`,从详情返回恢复展开状态
|
||||||
|
|
||||||
|
**CategoryDetail**
|
||||||
|
- 顶部 NavBar:返回按钮 + 种类名标题
|
||||||
|
- 吸顶的子类 Tab 栏(横向滚动,"全部" 为第一项,选中下划线 + 文字加重)
|
||||||
|
- 材料卡列表:通栏,左信息区(名称 / 供应商 / 优势 chip)+ 右数值区(成本 / 评分 / 重要等级)
|
||||||
|
- 下拉刷新、上拉无限加载
|
||||||
|
- 空态:图标 + "暂无材料"
|
||||||
|
|
||||||
|
**MaterialDetail**
|
||||||
|
- 顶部 NavBar
|
||||||
|
- 可选顶部 banner:`brochure` 宣传图(16:9,懒加载,点击可全屏预览)
|
||||||
|
- 三个 section,每个 section 是一张圆角卡:
|
||||||
|
|
||||||
|
1. **材料信息**:基础 / 应用 / 优势 / 成本 / 评分,`grid-cols-[auto_1fr]` 标签值布局;竞争优势、应用场景、重要等级、替代类型用 chip;4 项等级用星级可视化。长文本(优势说明、成本说明、应用说明)独立成段。
|
||||||
|
2. **品牌与供应商**:品牌、供应商简称/全称、合作模式、省-市、对接人、对接人联系方式。电话字段带 `tel:` 链接可一键拨打。
|
||||||
|
3. **案例信息**:落地项目、案例(保留换行)、经办人、备注。
|
||||||
|
|
||||||
|
### 5.3 通用交互
|
||||||
|
- Loading:顶部 NProgress 细线进度条;列表用骨架屏
|
||||||
|
- 错误:非 401 错误 Toast 提示;页面级错误态带"重试"按钮
|
||||||
|
- 401:清 token + 跳登录
|
||||||
|
|
||||||
|
## 6 · 后端改动清单
|
||||||
|
|
||||||
|
`apps/material/views.py`
|
||||||
|
- `MaterialViewSet` 新增两个 `@action(detail=False, methods=['get'])`:
|
||||||
|
- `categories_by_major`
|
||||||
|
- `subcategories_by_category`
|
||||||
|
- 两者权限与现有列表一致(登录用户可访问)
|
||||||
|
- 考虑给 `material_category` / `material_subcategory` 字段的"查询计数"加缓存(低优,后续再说)
|
||||||
|
|
||||||
|
无 model migration。
|
||||||
|
|
||||||
|
## 7 · 验收标准
|
||||||
|
|
||||||
|
- [ ] 手机浏览器(iOS Safari / 微信内置 / Android Chrome)访问 `/m/`,可完成 登录 → 大类 → 种类 → 子类 → 材料详情 的完整浏览
|
||||||
|
- [ ] 详情页三块布局、字段映射与本文档 §5.2 一致
|
||||||
|
- [ ] 从详情返回上级页面,滚动位置与选中 Tab 保持
|
||||||
|
- [ ] 401 时自动跳登录且登录后回跳原路径
|
||||||
|
- [ ] 包体积:首屏 gzip 后 < 150 KB(不含首图)
|
||||||
|
- [ ] 单手可达性:关键按钮在屏幕下半区
|
||||||
|
- [ ] PC 端 `/` 功能不受影响
|
||||||
|
|
||||||
|
## 8 · 非目标
|
||||||
|
|
||||||
|
- 新增、编辑、导入、导出、审批
|
||||||
|
- 离线缓存 / PWA
|
||||||
|
- 多语言
|
||||||
|
- 手机号验证码登录(后续视需要再开 Spec)
|
||||||
|
|
||||||
|
## 9 · 风险与开放项
|
||||||
|
|
||||||
|
- **微信内置浏览器差异**:目前未要求适配微信分享/JSSDK,仅确保页面能正常打开
|
||||||
|
- **H5 与 PC 登录态隔离**:使用不同 localStorage key,后端 token 为同一份 JWT,无服务端改动成本
|
||||||
|
- **种类 distinct 性能**:当前数据量小可直接 SQL `GROUP BY`;后续若过万条可再加索引或 Redis 缓存
|
||||||
|
- **视觉最终稿**:本文档只定方向,具体 mockup 由后续 `frontend-design` 技能产出
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
# 材料管理列表页重构设计
|
||||||
|
|
||||||
|
**日期**:2026-04-24
|
||||||
|
**范围**:`frontend/src/views/MaterialManage.vue` 列表页与 `frontend/src/views/material/MaterialForm.vue` 详情视图;`backend/apps/material/` 列表序列化器与筛选集
|
||||||
|
**目标**:列表展示完整信息、分级分组呈现、可配置列显隐(持久化)、更多筛选、操作体验更紧凑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与动机
|
||||||
|
|
||||||
|
当前 `MaterialManage.vue` 列表页存在以下问题:
|
||||||
|
|
||||||
|
- 14 列平铺展示,缺少语义分组,用户难以快速定位字段
|
||||||
|
- 重要字段(成本、优势、主要参数、星级)未在列表展示,必须点入详情
|
||||||
|
- 供应商/品牌仅展示 `factory_short_name` 与 `brand_name`,其余属性(合作模式、省市)不可见
|
||||||
|
- 筛选条件仅 4 项,无法按大类、阶段、供应商、成本区间等过滤
|
||||||
|
- 工具栏按钮松散,需手动点"查询"按钮,效率低
|
||||||
|
- 详情页为 `MaterialForm` 的 view 模式复用,字段扁平铺陈无分块
|
||||||
|
|
||||||
|
## 2. 设计原则
|
||||||
|
|
||||||
|
- **信息分组**:列表与详情使用同一套三分组语义——"材料信息 / 品牌与供应商 / 案例信息"
|
||||||
|
- **列表完整、可配置**:一次性加载完整字段,由前端决定显示哪几列;用户偏好持久化到 `localStorage`
|
||||||
|
- **即时响应**:下拉筛选 `@change` 立即触发查询、输入框 `@keyup.enter` 触发查询,删除显式"查询"按钮
|
||||||
|
- **YAGNI**:仅做列显隐(不做列拖拽排序);仅持久化列偏好(不持久化筛选条件)
|
||||||
|
|
||||||
|
## 3. 分组与列归属
|
||||||
|
|
||||||
|
### A. 材料信息
|
||||||
|
|
||||||
|
- 材料名称、材料大类、细分种类、材料子类
|
||||||
|
- 阶段、重要等级、状态
|
||||||
|
- 规格、执行标准
|
||||||
|
- 应用场景(JSON 数组 → tag)、应用说明、替代类型
|
||||||
|
- 连接方式、施工方式、使用限制
|
||||||
|
- 优势(JSON 数组 → tag)、优势说明
|
||||||
|
- 成本比较(%)、成本说明
|
||||||
|
- 质量/耐久/环保/碳/综合评分(星级 1-3)
|
||||||
|
|
||||||
|
### B. 品牌与供应商
|
||||||
|
|
||||||
|
- 品牌名称
|
||||||
|
- 供应商简称、供应商全称、合作模式、省-市、对接人、对接电话
|
||||||
|
|
||||||
|
### C. 案例信息
|
||||||
|
|
||||||
|
- 落地项目、案例(cases)、经办人、备注
|
||||||
|
|
||||||
|
操作列独立固定右侧,不属于三组。
|
||||||
|
|
||||||
|
## 4. 后端改动
|
||||||
|
|
||||||
|
### 4.1 `MaterialListSerializer`(`backend/apps/material/serializers.py`)
|
||||||
|
|
||||||
|
新增字段(所有字段均为只读,源自 `Material` 实例或其关系):
|
||||||
|
|
||||||
|
**A 组补充**(直接暴露 model 字段):
|
||||||
|
`spec`, `standard`, `application_scene`, `application_desc`, `replace_type`, `advantage`, `advantage_desc`, `connection_method`, `construction_method`, `limit_condition`, `cost_compare`, `cost_desc`, `quality_level`, `durability_level`, `eco_level`, `carbon_level`, `score_level`
|
||||||
|
|
||||||
|
**B 组补充**:
|
||||||
|
- `factory_full_name` — `source='factory.factory_name'`
|
||||||
|
- `factory_cooperation_mode` — `source='factory.cooperation_mode'`
|
||||||
|
- `factory_cooperation_mode_display` — `SerializerMethodField`,返回 `get_cooperation_mode_display()`
|
||||||
|
- `factory_province` — `source='factory.province'`
|
||||||
|
- `factory_city` — `source='factory.city'`
|
||||||
|
|
||||||
|
**C 组**:`cases` 已在,无需改动。
|
||||||
|
|
||||||
|
**性能**:视图层确认对列表查询加上 `select_related('factory', 'brand')`(若已有则保留)。
|
||||||
|
|
||||||
|
### 4.2 筛选集(ViewSet `filterset_fields` 或 FilterSet 类)
|
||||||
|
|
||||||
|
追加:
|
||||||
|
- `major_category`(exact)
|
||||||
|
- `material_category`(icontains)
|
||||||
|
- `stage`(exact)
|
||||||
|
- `importance_level`(exact)
|
||||||
|
- `factory`(exact,id)
|
||||||
|
- `factory__cooperation_mode`(exact)
|
||||||
|
- `landing_project`(icontains)
|
||||||
|
- `cost_compare__gte`, `cost_compare__lte`
|
||||||
|
- `score_level__gte`
|
||||||
|
- `contact_person`(icontains)
|
||||||
|
- `handler`(icontains)
|
||||||
|
|
||||||
|
保留原有:`name`(icontains)、`status`、`material_subcategory`、`brand`。
|
||||||
|
|
||||||
|
### 4.3 不改动
|
||||||
|
|
||||||
|
- `MaterialDetailSerializer`:字段已全
|
||||||
|
- `Brand` / `Factory` 模型
|
||||||
|
- 导入/导出/审批相关接口
|
||||||
|
|
||||||
|
## 5. 前端改动
|
||||||
|
|
||||||
|
### 5.1 新增文件
|
||||||
|
|
||||||
|
#### `frontend/src/views/material/materialColumns.js`
|
||||||
|
|
||||||
|
导出两个常量:
|
||||||
|
|
||||||
|
- `COLUMN_GROUPS`:`[{ key: 'material', label: '材料信息' }, { key: 'supplier', label: '品牌与供应商' }, { key: 'case', label: '案例信息' }]`
|
||||||
|
- `MATERIAL_COLUMNS`:有序数组,每项形如
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
group: 'material',
|
||||||
|
key: 'name',
|
||||||
|
label: '材料名称',
|
||||||
|
minWidth: 180, // 或 width
|
||||||
|
showOverflowTooltip: true,
|
||||||
|
slot: null, // 特殊渲染时填 slot 名:'advantage' | 'appScene' | 'stars'
|
||||||
|
formatter: (row) => ..., // 普通文本格式化
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
列顺序(按用户"靠前展示"要求):
|
||||||
|
1. A 组:材料名称、材料大类、细分种类、材料子类、阶段、重要等级、状态、成本比较、成本说明、优势、优势说明、应用场景、应用说明、替代类型、连接方式、施工方式、使用限制、规格、执行标准、质量/耐久/环保/碳/综合评分
|
||||||
|
2. B 组:品牌、供应商简称、供应商全称、合作模式、省-市、对接人、对接电话
|
||||||
|
3. C 组:落地项目、案例、经办人、备注
|
||||||
|
|
||||||
|
#### `frontend/src/composables/useColumnPreferences.js`
|
||||||
|
|
||||||
|
通用 composable:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function useColumnPreferences(storageKey, allColumnKeys) {
|
||||||
|
const hidden = ref([]) // 从 localStorage 初始化
|
||||||
|
|
||||||
|
const load = () => { /* try parse, fallback [] */ }
|
||||||
|
const save = () => localStorage.setItem(storageKey, JSON.stringify(hidden.value))
|
||||||
|
|
||||||
|
const isVisible = (key) => !hidden.value.includes(key)
|
||||||
|
const toggle = (key) => { /* add or remove, then save */ }
|
||||||
|
const setGroupVisible = (groupKeys, visible) => { /* batch, then save */ }
|
||||||
|
const reset = () => { hidden.value = []; save() }
|
||||||
|
|
||||||
|
load()
|
||||||
|
return { hidden, isVisible, toggle, setGroupVisible, reset }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**存储约定**:
|
||||||
|
- 键:`mat3:material-list:columns:v1`
|
||||||
|
- 值:仅存"隐藏的列 key 数组"(新增列默认显示,不受旧偏好影响)
|
||||||
|
- 操作列 key `__actions__`(或类似)不纳入可切换列表
|
||||||
|
|
||||||
|
### 5.2 `MaterialManage.vue` 改造
|
||||||
|
|
||||||
|
#### 工具栏(两行,紧凑)
|
||||||
|
|
||||||
|
**第一行**(常用筛选 + 主操作,`gap: 8px`):
|
||||||
|
- 材料名称 input(width 180)`@keyup.enter="loadMaterials"`
|
||||||
|
- 状态 select(140)`@change="loadMaterials"`
|
||||||
|
- 材料子类 select(160)`@change`
|
||||||
|
- 品牌远程 select(160)`@change`
|
||||||
|
- "高级筛选"切换按钮(text 按钮 + 箭头图标;若存在任意高级筛选值则右上角红点徽标)
|
||||||
|
- `.toolbar-spacer`(flex: 1)
|
||||||
|
- 导入 / 导出 / 新增材料 / **列设置**(齿轮图标按钮)
|
||||||
|
|
||||||
|
删除原"查询"按钮(自动触发)。
|
||||||
|
|
||||||
|
**第二行**(`v-show="advancedOpen"`,`gap: 8px`,`flex-wrap: wrap`):
|
||||||
|
- 材料大类 select、阶段 select、重要等级 select、合作模式 select(`@change`)
|
||||||
|
- 细分种类 input、落地项目 input、对接人 input、经办人 input(`@keyup.enter`)
|
||||||
|
- 供应商 select(远程搜索,`@change`)
|
||||||
|
- 成本比较 min / max 双 InputNumber(`@change`)
|
||||||
|
- 综合评分 ≥ N(select 1-3 或 slider,`@change`)
|
||||||
|
- "重置筛选"按钮(清空全部筛选并查询)
|
||||||
|
|
||||||
|
#### 触发逻辑
|
||||||
|
|
||||||
|
所有筛选值变更:`pagination.page = 1` → `loadMaterials()`。
|
||||||
|
折叠"高级筛选"面板**不**清空字段值;仅控制显隐。"重置筛选"清空全部(含常用与高级)。
|
||||||
|
|
||||||
|
#### 表格(数据驱动 + 分级表头)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<el-table :data="materials" border height="100%">
|
||||||
|
<template v-for="group in COLUMN_GROUPS" :key="group.key">
|
||||||
|
<el-table-column
|
||||||
|
v-if="hasVisibleInGroup(group.key)"
|
||||||
|
:label="group.label"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
v-for="col in visibleColumnsOfGroup(group.key)"
|
||||||
|
:key="col.key"
|
||||||
|
:prop="col.key"
|
||||||
|
:label="col.label"
|
||||||
|
:min-width="col.minWidth"
|
||||||
|
:width="col.width"
|
||||||
|
:show-overflow-tooltip="col.showOverflowTooltip"
|
||||||
|
>
|
||||||
|
<template v-if="col.slot" #default="scope">
|
||||||
|
<AdvantageCell v-if="col.slot === 'advantage'" :value="scope.row[col.key]" />
|
||||||
|
<AppSceneCell v-else-if="col.slot === 'appScene'" :value="scope.row[col.key]" />
|
||||||
|
<StarsCell v-else-if="col.slot === 'stars'" :value="scope.row[col.key]" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="col.formatter" #default="scope">
|
||||||
|
{{ col.formatter(scope.row) ?? '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="320" fixed="right">
|
||||||
|
<!-- 原有操作按钮,不动 -->
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
```
|
||||||
|
|
||||||
|
特殊单元格组件(内嵌或单独 SFC 均可):
|
||||||
|
- **AdvantageCell / AppSceneCell**:接收 JSON 数组,渲染 `el-tag` 列表;空则 "-"
|
||||||
|
- **StarsCell**:接收 1-3 数字,渲染对应数量的黄色 ⭐,空显示 "-"
|
||||||
|
|
||||||
|
整组隐藏(`hasVisibleInGroup = false`)时整个分组表头消失。
|
||||||
|
|
||||||
|
#### 列设置 popover
|
||||||
|
|
||||||
|
齿轮图标按钮触发 `el-popover`(宽 ~420px,最大高度带滚动):
|
||||||
|
|
||||||
|
- 按 `COLUMN_GROUPS` 分节,每节标题 + "全选 / 全不选" 小按钮
|
||||||
|
- 每列渲染 `el-checkbox`,绑定到 `isVisible(col.key)`,`@change` 调用 `toggle(col.key)`
|
||||||
|
- 底部一个"恢复默认"按钮调用 `reset()`
|
||||||
|
|
||||||
|
### 5.3 详情页分块(`MaterialForm.vue` view 模式)
|
||||||
|
|
||||||
|
将当前单个 `el-descriptions` 拆为三个,顺序与列表分组一致:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<template v-if="mode === 'view'">
|
||||||
|
<el-descriptions title="材料信息" :column="2" border>
|
||||||
|
<!-- A 组字段 -->
|
||||||
|
</el-descriptions>
|
||||||
|
<el-descriptions title="品牌与供应商" :column="2" border class="mt-4">
|
||||||
|
<!-- B 组字段 -->
|
||||||
|
</el-descriptions>
|
||||||
|
<el-descriptions title="案例信息" :column="1" border class="mt-4">
|
||||||
|
<!-- C 组字段,cases 单独一行 white-space: pre-wrap -->
|
||||||
|
</el-descriptions>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
星级字段使用 ⭐ 渲染;JSON 数组用 `el-tag` 列表;宣传册图片独占一行。
|
||||||
|
|
||||||
|
edit 模式不变。
|
||||||
|
|
||||||
|
## 6. 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
用户动作(change / enter / checkbox)
|
||||||
|
└─→ filters reactive 更新 / hidden 数组更新
|
||||||
|
├─→ loadMaterials() → GET /api/materials/?filters → 更新 materials
|
||||||
|
└─→ localStorage.setItem('mat3:material-list:columns:v1', JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 错误与边界
|
||||||
|
|
||||||
|
- **localStorage 损坏**(JSON.parse 失败):捕获异常,回退到 `hidden = []`,覆盖写回合法值
|
||||||
|
- **列结构演进**:后续新增列时,因只存"隐藏列",新列对老用户默认可见;如需强制重置偏好,bump 存储键版本号为 `v2`
|
||||||
|
- **空值**:所有 formatter 与 slot 遇 `null/undefined/[]` 显示 `-`
|
||||||
|
- **整组隐藏**:通过 `hasVisibleInGroup()` 计算属性剔除整个分组表头
|
||||||
|
|
||||||
|
## 8. 测试 / 验证清单
|
||||||
|
|
||||||
|
- [ ] 后端 `/api/materials/` 返回新增字段;新增筛选参数工作
|
||||||
|
- [ ] 前端列表三组表头正确渲染,列顺序符合设计
|
||||||
|
- [ ] `advantage / application_scene / stars` 特殊渲染正确
|
||||||
|
- [ ] 列设置 popover 勾选立即生效,刷新后偏好恢复
|
||||||
|
- [ ] 全部隐藏某组时整组表头消失;"恢复默认"还原全显
|
||||||
|
- [ ] 所有下拉 `@change` 自动触发查询;输入框 Enter 触发查询
|
||||||
|
- [ ] 高级筛选收起后值保留;有值时红点标记显示;"重置筛选"清全部
|
||||||
|
- [ ] 详情页三块分节展示
|
||||||
|
- [ ] 现有导入/导出/新增/编辑/审批/分页流程不受影响
|
||||||
|
- [ ] 工具栏按钮紧凑、无换行
|
||||||
|
|
||||||
|
## 9. 非目标(YAGNI)
|
||||||
|
|
||||||
|
- 列拖拽排序
|
||||||
|
- 筛选条件持久化
|
||||||
|
- 分页默认值调整
|
||||||
|
- 品牌字段扩展
|
||||||
|
- 列宽持久化
|
||||||
|
- 表格导出仅可见列(导出使用现有后端接口,所有字段)
|
||||||
|
|
||||||
|
## 10. 文件清单
|
||||||
|
|
||||||
|
**后端**
|
||||||
|
- `backend/apps/material/serializers.py`(改 `MaterialListSerializer`)
|
||||||
|
- `backend/apps/material/views.py` 或 `filters.py`(追加筛选字段)
|
||||||
|
|
||||||
|
**前端新增**
|
||||||
|
- `frontend/src/views/material/materialColumns.js`
|
||||||
|
- `frontend/src/composables/useColumnPreferences.js`
|
||||||
|
- (可选)`frontend/src/views/material/cells/AdvantageCell.vue` 等,亦可内嵌
|
||||||
|
|
||||||
|
**前端改动**
|
||||||
|
- `frontend/src/views/MaterialManage.vue`
|
||||||
|
- `frontend/src/views/material/MaterialForm.vue`(view 模式)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
# frontend-h5
|
||||||
|
|
||||||
|
H5 材料浏览端。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5174/m/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 生产
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build # 输出到 dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx 挂载示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
location /m/ {
|
||||||
|
alias /path/to/frontend-h5/dist/;
|
||||||
|
try_files $uri $uri/ /m/index.html;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
API 仍走主站 `/api/`。
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<title>房地产新材料选材管理数据系统</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "mat3-h5",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
|
"vite": "^5.2.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script setup>
|
||||||
|
import Toast from '@/components/Toast.vue'
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="page" mode="out-in">
|
||||||
|
<keep-alive :include="['Home', 'CategoryDetail']">
|
||||||
|
<component :is="Component" />
|
||||||
|
</keep-alive>
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
<Toast />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-enter-active,
|
||||||
|
.page-leave-active { transition: all 0.2s ease; }
|
||||||
|
.page-enter-from { transform: translateX(20px); opacity: 0; }
|
||||||
|
.page-leave-to { transform: translateX(-20px); opacity: 0; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import api from './client'
|
||||||
|
|
||||||
|
export const login = async (payload) => (await api.post('/auth/login/', payload)).data
|
||||||
|
export const fetchCurrentUser = async () => (await api.get('/auth/user/')).data
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({ baseURL: '/api', timeout: 15000 })
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('h5_token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('h5_token')
|
||||||
|
const current = window.location.pathname + window.location.search
|
||||||
|
if (!current.startsWith('/m/login')) {
|
||||||
|
window.location.href = `/m/login?redirect=${encodeURIComponent(current)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import api from './client'
|
||||||
|
|
||||||
|
export const fetchMaterials = async (params) => (await api.get('/material/', { params })).data
|
||||||
|
export const fetchMaterialDetail = async (id) => (await api.get(`/material/${id}/`)).data
|
||||||
|
export const fetchCategoryTree = async () => (await api.get('/material/category-tree/')).data
|
||||||
|
export const fetchCategoriesByMajor = async (major_category) =>
|
||||||
|
(await api.get('/material/categories-by-major/', { params: { major_category } })).data
|
||||||
|
export const fetchSubcategoriesByCategory = async (major_category, material_category) =>
|
||||||
|
(await api.get('/material/subcategories-by-category/', { params: { major_category, material_category } })).data
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<script setup>
|
||||||
|
defineProps({ value: String, count: Number })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="relative px-4 py-3 rounded-card bg-white shadow-card active:bg-surface-warm cursor-pointer">
|
||||||
|
<div class="font-medium truncate">{{ value }}</div>
|
||||||
|
<div class="absolute top-2 right-3 text-xs text-muted tnum">{{ count }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script setup>
|
||||||
|
defineProps({ tone: { type: String, default: 'neutral' } })
|
||||||
|
const map = {
|
||||||
|
brand: 'bg-brand/10 text-brand',
|
||||||
|
danger: 'bg-danger/10 text-danger',
|
||||||
|
info: 'bg-info/10 text-info',
|
||||||
|
neutral: 'bg-neutral-100 text-neutral-600',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 text-xs rounded-full whitespace-nowrap" :class="map[tone]">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script setup>
|
||||||
|
defineProps({ label: String, value: String, active: Boolean, bg: String })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="relative h-[100px] rounded-card shadow-card overflow-hidden cursor-pointer transition active:scale-[0.98]">
|
||||||
|
<img :src="bg" alt="" class="absolute inset-0 w-full h-full object-cover" loading="lazy" />
|
||||||
|
<div class="absolute inset-0"
|
||||||
|
:class="active
|
||||||
|
? 'bg-gradient-to-tr from-brand/85 via-brand/70 to-brand/40'
|
||||||
|
: 'bg-gradient-to-tr from-black/55 via-black/35 to-black/10'"></div>
|
||||||
|
<div class="relative h-full px-4 flex items-end pb-3 text-white">
|
||||||
|
<div class="text-lg font-semibold tracking-wide drop-shadow-sm">{{ label }}</div>
|
||||||
|
<div v-if="active" class="ml-auto self-start mt-3 w-2 h-2 rounded-full bg-white"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup>
|
||||||
|
import Chip from './Chip.vue'
|
||||||
|
import StarLevel from './StarLevel.vue'
|
||||||
|
defineProps({ item: Object })
|
||||||
|
|
||||||
|
const importanceTone = (lv) => {
|
||||||
|
if (lv === '核心') return 'danger'
|
||||||
|
if (lv === '优先') return 'info'
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
const costLabel = (v) => (v == null ? '—' : `${v > 0 ? '+' : ''}${Number(v)}%`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-card shadow-card p-4 active:bg-surface-warm cursor-pointer">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-base font-semibold truncate">{{ item.name }}</div>
|
||||||
|
<div class="mt-1 text-xs text-muted truncate">{{ item.factory_short_name || '—' }}</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
|
<Chip v-if="item.importance_level" :tone="importanceTone(item.importance_level)">{{ item.importance_level }}</Chip>
|
||||||
|
<Chip v-for="a in (item.advantage_display || []).slice(0,2)" :key="a" tone="brand">{{ a }}</Chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right shrink-0">
|
||||||
|
<div class="tnum text-lg font-semibold" :class="item.cost_compare < 0 ? 'text-brand' : 'text-neutral-800'">
|
||||||
|
{{ costLabel(item.cost_compare) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-muted mt-0.5">成本对比</div>
|
||||||
|
<div class="mt-2"><StarLevel :value="item.score_level || 0" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
defineProps({ title: String, showBack: { type: Boolean, default: true } })
|
||||||
|
const router = useRouter()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<header class="sticky top-0 z-20 h-12 flex items-center bg-white/90 backdrop-blur border-b border-line">
|
||||||
|
<button v-if="showBack" class="w-12 h-12 flex items-center justify-center active:bg-line" @click="router.back()">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M13 4l-6 6 6 6" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<div v-else class="w-12 h-12"></div>
|
||||||
|
<h1 class="flex-1 text-center text-base font-semibold truncate px-4">{{ title }}</h1>
|
||||||
|
<div class="w-12 h-12"><slot name="right" /></div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<template>
|
||||||
|
<div class="animate-pulse bg-neutral-200/60 rounded" />
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<script setup>
|
||||||
|
defineProps({ value: { type: Number, default: 0 }, max: { type: Number, default: 3 } })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<span class="inline-flex items-center gap-0.5">
|
||||||
|
<svg v-for="i in max" :key="i" width="14" height="14" viewBox="0 0 20 20"
|
||||||
|
:fill="i <= value ? '#2F4F3F' : '#E5E5E5'">
|
||||||
|
<path d="M10 1.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L10 14.9 4.8 17.6l1-5.8L1.5 7.7l5.9-.9z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<script setup>
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
const { state } = useToast()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="fixed top-2 left-0 right-0 z-50 flex flex-col items-center gap-2 pointer-events-none">
|
||||||
|
<transition-group name="toast">
|
||||||
|
<div v-for="t in state.list" :key="t.id"
|
||||||
|
class="px-4 py-2 rounded-full text-sm shadow-card"
|
||||||
|
:class="t.type==='error' ? 'bg-danger text-white' : 'bg-neutral-800/90 text-white'">
|
||||||
|
{{ t.msg }}
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
<style>
|
||||||
|
.toast-enter-active,.toast-leave-active{transition:all .25s ease}
|
||||||
|
.toast-enter-from,.toast-leave-to{opacity:0;transform:translateY(-8px)}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { onMounted, onBeforeUnmount, ref } from 'vue'
|
||||||
|
|
||||||
|
export function useInfiniteScroll(target, onLoad) {
|
||||||
|
const observer = ref(null)
|
||||||
|
onMounted(() => {
|
||||||
|
observer.value = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting) onLoad()
|
||||||
|
})
|
||||||
|
if (target.value) observer.value.observe(target.value)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => observer.value?.disconnect())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
const state = reactive({ list: [] })
|
||||||
|
let seq = 0
|
||||||
|
export function useToast() {
|
||||||
|
const show = (msg, type = 'info', duration = 2000) => {
|
||||||
|
const id = ++seq
|
||||||
|
state.list.push({ id, msg, type })
|
||||||
|
setTimeout(() => { state.list = state.list.filter(x => x.id !== id) }, duration)
|
||||||
|
}
|
||||||
|
return { state, show }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './styles/tailwind.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue'), meta: { public: true } },
|
||||||
|
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
|
||||||
|
{ path: '/category/:major/:category', name: 'CategoryDetail', component: () => import('@/views/CategoryDetail.vue'), props: true },
|
||||||
|
{ path: '/material/:id', name: 'MaterialDetail', component: () => import('@/views/MaterialDetail.vue'), props: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory('/m/'),
|
||||||
|
routes,
|
||||||
|
scrollBehavior(to, from, saved) { return saved || { top: 0 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.meta.public) return true
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (!auth.isAuthed) {
|
||||||
|
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import * as authApi from '@/api/auth'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', {
|
||||||
|
state: () => ({
|
||||||
|
token: localStorage.getItem('h5_token') || '',
|
||||||
|
user: JSON.parse(localStorage.getItem('h5_user') || 'null'),
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
isAuthed: (s) => !!s.token,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async login(payload) {
|
||||||
|
const data = await authApi.login(payload)
|
||||||
|
this.token = data.access
|
||||||
|
this.user = data.user || null
|
||||||
|
localStorage.setItem('h5_token', this.token)
|
||||||
|
if (this.user) localStorage.setItem('h5_user', JSON.stringify(this.user))
|
||||||
|
},
|
||||||
|
async loadUser() {
|
||||||
|
this.user = await authApi.fetchCurrentUser()
|
||||||
|
localStorage.setItem('h5_user', JSON.stringify(this.user))
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
this.token = ''
|
||||||
|
this.user = null
|
||||||
|
localStorage.removeItem('h5_token')
|
||||||
|
localStorage.removeItem('h5_user')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useUiStore = defineStore('ui', {
|
||||||
|
state: () => ({
|
||||||
|
selectedMajor: '',
|
||||||
|
categorySubTab: {},
|
||||||
|
scrollCache: {},
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
setMajor(v) { this.selectedMajor = v },
|
||||||
|
setSubTab(key, v) { this.categorySubTab[key] = v },
|
||||||
|
saveScroll(key, top) { this.scrollCache[key] = top },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html, body, #app {
|
||||||
|
@apply h-full bg-surface-alt text-neutral-900 font-sans antialiased;
|
||||||
|
}
|
||||||
|
body { -webkit-tap-highlight-color: transparent; }
|
||||||
|
.tnum { font-feature-settings: 'tnum'; font-variant-numeric: tabular-nums; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import NavBar from '@/components/NavBar.vue'
|
||||||
|
import MaterialCard from '@/components/MaterialCard.vue'
|
||||||
|
import Skeleton from '@/components/Skeleton.vue'
|
||||||
|
import { useUiStore } from '@/store/ui'
|
||||||
|
import { fetchSubcategoriesByCategory, fetchMaterials } from '@/api/material'
|
||||||
|
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
|
||||||
|
|
||||||
|
defineOptions({ name: 'CategoryDetail' })
|
||||||
|
const props = defineProps({ major: String, category: String })
|
||||||
|
const router = useRouter()
|
||||||
|
const ui = useUiStore()
|
||||||
|
|
||||||
|
const stateKey = computed(() => `${props.major}::${props.category}`)
|
||||||
|
const subs = ref([])
|
||||||
|
const activeSub = ref(ui.categorySubTab[stateKey.value] || '')
|
||||||
|
const items = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
const loading = ref(false)
|
||||||
|
const initialLoading = ref(true)
|
||||||
|
const sentinel = ref(null)
|
||||||
|
|
||||||
|
const loadSubs = async () => {
|
||||||
|
subs.value = await fetchSubcategoriesByCategory(props.major, props.category)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAndLoad = async () => {
|
||||||
|
page.value = 1; items.value = []; hasMore.value = true
|
||||||
|
await loadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (loading.value || !hasMore.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
major_category: props.major,
|
||||||
|
material_category: props.category,
|
||||||
|
status: 'approved',
|
||||||
|
page: page.value,
|
||||||
|
page_size: 20,
|
||||||
|
}
|
||||||
|
if (activeSub.value) params.material_subcategory = activeSub.value
|
||||||
|
const res = await fetchMaterials(params)
|
||||||
|
const list = res.results || res
|
||||||
|
items.value.push(...list)
|
||||||
|
hasMore.value = !!res.next
|
||||||
|
page.value += 1
|
||||||
|
} finally { loading.value = false; initialLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickSub = (v) => {
|
||||||
|
activeSub.value = v
|
||||||
|
ui.setSubTab(stateKey.value, v)
|
||||||
|
initialLoading.value = true
|
||||||
|
resetAndLoad()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadSubs()
|
||||||
|
await resetAndLoad()
|
||||||
|
})
|
||||||
|
|
||||||
|
useInfiniteScroll(sentinel, loadMore)
|
||||||
|
|
||||||
|
const goDetail = (id) => router.push({ name: 'MaterialDetail', params: { id } })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen pb-6">
|
||||||
|
<NavBar :title="category" />
|
||||||
|
<div class="sticky top-12 z-10 bg-white border-b border-line">
|
||||||
|
<div class="flex gap-1 overflow-x-auto px-2 py-2 no-scrollbar">
|
||||||
|
<button class="px-3 py-1 rounded-full text-sm whitespace-nowrap"
|
||||||
|
:class="activeSub === '' ? 'bg-brand text-white' : 'bg-surface-warm text-neutral-700'"
|
||||||
|
@click="pickSub('')">全部</button>
|
||||||
|
<button v-for="s in subs" :key="s.value"
|
||||||
|
class="px-3 py-1 rounded-full text-sm whitespace-nowrap"
|
||||||
|
:class="activeSub === s.value ? 'bg-brand text-white' : 'bg-surface-warm text-neutral-700'"
|
||||||
|
@click="pickSub(s.value)">{{ s.value }} ({{ s.count }})</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="p-4 space-y-3">
|
||||||
|
<template v-if="initialLoading">
|
||||||
|
<Skeleton v-for="n in 4" :key="n" class="h-24" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="items.length">
|
||||||
|
<MaterialCard v-for="it in items" :key="it.id" :item="it" @click="goDetail(it.id)" />
|
||||||
|
<div ref="sentinel" class="py-4 text-center text-xs text-muted">
|
||||||
|
{{ hasMore ? (loading ? '加载中…' : '下拉加载') : '没有更多了' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="py-16 text-center text-sm text-muted">暂无材料</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import MajorCategoryCard from '@/components/MajorCategoryCard.vue'
|
||||||
|
import CategoryCard from '@/components/CategoryCard.vue'
|
||||||
|
import Skeleton from '@/components/Skeleton.vue'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useUiStore } from '@/store/ui'
|
||||||
|
import { fetchCategoryTree } from '@/api/material'
|
||||||
|
|
||||||
|
defineOptions({ name: 'Home' })
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const ui = useUiStore()
|
||||||
|
|
||||||
|
const fallbackMajors = [
|
||||||
|
{ value: 'architecture', label: '建筑' },
|
||||||
|
{ value: 'landscape', label: '景观' },
|
||||||
|
{ value: 'equipment', label: '设备' },
|
||||||
|
{ value: 'decoration', label: '装修' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BASE = import.meta.env.BASE_URL
|
||||||
|
const majorBg = {
|
||||||
|
architecture: `${BASE}img/majors/architecture.jpg`,
|
||||||
|
landscape: `${BASE}img/majors/landscape.jpg`,
|
||||||
|
equipment: `${BASE}img/majors/equipment.jpg`,
|
||||||
|
decoration: `${BASE}img/majors/decoration.jpg`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const selected = ref(ui.selectedMajor)
|
||||||
|
|
||||||
|
const majors = computed(() => {
|
||||||
|
if (!tree.value.length) return fallbackMajors
|
||||||
|
const order = ['architecture', 'landscape', 'equipment', 'decoration']
|
||||||
|
const map = Object.fromEntries(tree.value.map((n) => [n.value, n]))
|
||||||
|
return order.filter((v) => map[v]).map((v) => ({ value: v, label: map[v].label }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleGroups = computed(() => {
|
||||||
|
const list = tree.value.length ? tree.value : []
|
||||||
|
if (selected.value) return list.filter((g) => g.value === selected.value)
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSelect = (v) => {
|
||||||
|
selected.value = selected.value === v ? '' : v
|
||||||
|
ui.setMajor(selected.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!auth.user) { try { await auth.loadUser() } catch {} }
|
||||||
|
try { tree.value = await fetchCategoryTree() }
|
||||||
|
finally { loading.value = false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const goCategory = (major, c) => router.push({ name: 'CategoryDetail', params: { major, category: c.value } })
|
||||||
|
const onLogout = () => { auth.logout(); router.replace('/login') }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<header class="h-12 px-4 flex items-center justify-between bg-white border-b border-line">
|
||||||
|
<div class="text-sm text-muted">你好,<span class="text-neutral-800 font-medium">{{ auth.user?.username || '' }}</span></div>
|
||||||
|
<button class="text-sm text-muted active:text-danger" @click="onLogout">退出</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="p-4 grid grid-cols-2 gap-3">
|
||||||
|
<MajorCategoryCard v-for="m in majors" :key="m.value"
|
||||||
|
:label="m.label" :value="m.value" :bg="majorBg[m.value]" :active="selected === m.value"
|
||||||
|
@click="onSelect(m.value)" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="px-4 pb-6 space-y-5">
|
||||||
|
<div v-if="loading" class="grid grid-cols-2 gap-3">
|
||||||
|
<Skeleton v-for="n in 6" :key="n" class="h-14" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="!visibleGroups.length" class="py-10 text-center text-sm text-muted">暂无已审核材料</div>
|
||||||
|
<div v-for="g in visibleGroups" :key="g.value">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="text-xs text-muted">{{ g.label }} · 材料种类</div>
|
||||||
|
<span class="text-[11px] text-muted tnum">{{ g.categories.length }} 类</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<CategoryCard v-for="c in g.categories" :key="c.value" :value="c.value" :count="c.count"
|
||||||
|
@click="goCategory(g.value, c)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
|
defineOptions({ name: 'Login' })
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { show } = useToast()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!username.value || !password.value) {
|
||||||
|
show('请输入账号和密码', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.login({ username: username.value, password: password.value })
|
||||||
|
const redirect = route.query.redirect || '/'
|
||||||
|
router.replace(redirect)
|
||||||
|
} catch (e) {
|
||||||
|
show(e?.response?.data?.detail || '登录失败', 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col bg-surface">
|
||||||
|
<div class="flex-[0.55] flex items-end justify-center pb-10">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-16 h-16 mx-auto rounded-2xl bg-brand text-white text-2xl font-bold flex items-center justify-center">M³</div>
|
||||||
|
<h1 class="mt-4 text-lg font-semibold leading-snug">房地产新材料<br/>选材管理数据系统</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted">登录后查看材料库</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-[0.45] px-6 space-y-3">
|
||||||
|
<input v-model="username" placeholder="账号"
|
||||||
|
class="w-full h-12 px-4 rounded-card bg-surface-alt border border-line focus:border-brand outline-none" />
|
||||||
|
<input v-model="password" type="password" placeholder="密码"
|
||||||
|
class="w-full h-12 px-4 rounded-card bg-surface-alt border border-line focus:border-brand outline-none" />
|
||||||
|
<button :disabled="loading" @click="submit"
|
||||||
|
class="w-full h-12 rounded-card bg-brand text-white text-base font-medium active:bg-brand-dark disabled:opacity-60">
|
||||||
|
{{ loading ? '登录中…' : '登录' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import NavBar from '@/components/NavBar.vue'
|
||||||
|
import Chip from '@/components/Chip.vue'
|
||||||
|
import StarLevel from '@/components/StarLevel.vue'
|
||||||
|
import Skeleton from '@/components/Skeleton.vue'
|
||||||
|
import { fetchMaterialDetail } from '@/api/material'
|
||||||
|
|
||||||
|
defineOptions({ name: 'MaterialDetail' })
|
||||||
|
const props = defineProps({ id: [String, Number] })
|
||||||
|
|
||||||
|
const data = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try { data.value = await fetchMaterialDetail(props.id) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const importanceTone = (lv) => lv === '核心' ? 'danger' : lv === '优先' ? 'info' : 'neutral'
|
||||||
|
const d = (v) => (v == null || v === '') ? '—' : v
|
||||||
|
const costLabel = (v) => v == null ? '—' : `${v > 0 ? '+' : ''}${Number(v)}%`
|
||||||
|
const location = computed(() => {
|
||||||
|
const x = data.value
|
||||||
|
if (!x) return ''
|
||||||
|
return [x.factory_province_name, x.factory_city_name].filter(Boolean).join(' · ')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen pb-8">
|
||||||
|
<NavBar :title="data?.name || '材料详情'" />
|
||||||
|
|
||||||
|
<template v-if="loading">
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<Skeleton class="h-40" />
|
||||||
|
<Skeleton class="h-60" />
|
||||||
|
<Skeleton class="h-40" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="data">
|
||||||
|
<img v-if="data.brochure" :src="data.brochure" class="w-full aspect-video object-cover bg-surface-warm" />
|
||||||
|
|
||||||
|
<section class="mx-4 mt-4 p-4 bg-white rounded-card shadow-card">
|
||||||
|
<h2 class="text-sm font-semibold text-neutral-500 mb-3">材料信息</h2>
|
||||||
|
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||||
|
<span class="text-muted">材料名称</span><span>{{ d(data.name) }}</span>
|
||||||
|
<span class="text-muted">材料大类</span><span>{{ d(data.major_category_display) }}</span>
|
||||||
|
<span class="text-muted">细分种类</span><span>{{ d(data.material_category) }}</span>
|
||||||
|
<span class="text-muted">材料子类</span><span>{{ d(data.material_subcategory) }}</span>
|
||||||
|
<span class="text-muted">阶段</span><span>{{ d(data.stage_display) }}</span>
|
||||||
|
<span class="text-muted">重要等级</span>
|
||||||
|
<span>
|
||||||
|
<Chip v-if="data.importance_level" :tone="importanceTone(data.importance_level)">{{ data.importance_level }}</Chip>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-muted">规格型号</span><span>{{ d(data.spec) }}</span>
|
||||||
|
<span class="text-muted">符合标准</span><span>{{ d(data.standard) }}</span>
|
||||||
|
<span class="text-muted">应用场景</span>
|
||||||
|
<span class="flex flex-wrap gap-1">
|
||||||
|
<Chip v-for="a in (data.application_scene_display || [])" :key="a" tone="brand">{{ a }}</Chip>
|
||||||
|
<span v-if="!(data.application_scene_display||[]).length">—</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-muted">替代类型</span><span>{{ d(data.replace_type_display) }}</span>
|
||||||
|
<span class="text-muted">连接方式</span><span>{{ d(data.connection_method) }}</span>
|
||||||
|
<span class="text-muted">施工工艺</span><span>{{ d(data.construction_method) }}</span>
|
||||||
|
<span class="text-muted">竞争优势</span>
|
||||||
|
<span class="flex flex-wrap gap-1">
|
||||||
|
<Chip v-for="a in (data.advantage_display || [])" :key="a" tone="brand">{{ a }}</Chip>
|
||||||
|
<span v-if="!(data.advantage_display||[]).length">—</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-muted">成本对比</span><span class="tnum" :class="data.cost_compare < 0 ? 'text-brand font-medium' : ''">{{ costLabel(data.cost_compare) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="data.application_desc" class="mt-4 pt-3 border-t border-line">
|
||||||
|
<div class="text-muted text-xs mb-1">应用说明</div>
|
||||||
|
<div class="text-sm whitespace-pre-wrap">{{ data.application_desc }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="data.advantage_desc" class="mt-3">
|
||||||
|
<div class="text-muted text-xs mb-1">优势说明</div>
|
||||||
|
<div class="text-sm whitespace-pre-wrap">{{ data.advantage_desc }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="data.cost_desc" class="mt-3">
|
||||||
|
<div class="text-muted text-xs mb-1">成本说明</div>
|
||||||
|
<div class="text-sm whitespace-pre-wrap">{{ data.cost_desc }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="data.limit_condition" class="mt-3">
|
||||||
|
<div class="text-muted text-xs mb-1">限制条件</div>
|
||||||
|
<div class="text-sm whitespace-pre-wrap">{{ data.limit_condition }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 pt-3 border-t border-line grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm items-center">
|
||||||
|
<span class="text-muted">质量等级</span><StarLevel :value="data.quality_level || 0" />
|
||||||
|
<span class="text-muted">耐久等级</span><StarLevel :value="data.durability_level || 0" />
|
||||||
|
<span class="text-muted">环保等级</span><StarLevel :value="data.eco_level || 0" />
|
||||||
|
<span class="text-muted">低碳等级</span><StarLevel :value="data.carbon_level || 0" />
|
||||||
|
<span class="text-muted font-medium">综合评分</span><StarLevel :value="data.score_level || 0" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-4 mt-4 p-4 bg-white rounded-card shadow-card">
|
||||||
|
<h2 class="text-sm font-semibold text-neutral-500 mb-3">品牌与供应商</h2>
|
||||||
|
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||||
|
<span class="text-muted">品牌</span><span>{{ d(data.brand_name) }}</span>
|
||||||
|
<span class="text-muted">供应商简称</span><span>{{ d(data.factory_short_name) }}</span>
|
||||||
|
<span class="text-muted">供应商全称</span><span>{{ d(data.factory_name) }}</span>
|
||||||
|
<span class="text-muted">合作模式</span><span>{{ d(data.factory_cooperation_mode_display) }}</span>
|
||||||
|
<span class="text-muted">省-市</span><span>{{ d(location) }}</span>
|
||||||
|
<span class="text-muted">对接人</span><span>{{ d(data.contact_person) }}</span>
|
||||||
|
<span class="text-muted">联系方式</span>
|
||||||
|
<span>
|
||||||
|
<a v-if="data.contact_phone" :href="`tel:${data.contact_phone}`" class="text-brand">{{ data.contact_phone }}</a>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-4 mt-4 p-4 bg-white rounded-card shadow-card">
|
||||||
|
<h2 class="text-sm font-semibold text-neutral-500 mb-3">案例信息</h2>
|
||||||
|
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||||
|
<span class="text-muted">落地项目</span><span>{{ d(data.landing_project) }}</span>
|
||||||
|
<span class="text-muted">经办人</span><span>{{ d(data.handler) }}</span>
|
||||||
|
<span class="text-muted">备注</span><span>{{ d(data.remark) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="data.cases" class="mt-3">
|
||||||
|
<div class="text-muted text-xs mb-1">案例</div>
|
||||||
|
<div class="text-sm whitespace-pre-wrap">{{ data.cases }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{vue,js}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: { DEFAULT: '#2F4F3F', dark: '#233C2F', light: '#6B8878' },
|
||||||
|
surface: { DEFAULT: '#FFFFFF', alt: '#FAFAFA', warm: '#F5F4F2' },
|
||||||
|
danger: '#D2584A',
|
||||||
|
info: '#5A7FB8',
|
||||||
|
muted: '#8A8A8A',
|
||||||
|
line: '#EEEEEE',
|
||||||
|
},
|
||||||
|
borderRadius: { card: '18px' },
|
||||||
|
boxShadow: { card: '0 1px 2px rgba(0,0,0,0.04)' },
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['-apple-system', 'BlinkMacSystemFont', '"PingFang SC"', '"Microsoft YaHei"', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/m/',
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export function useColumnPreferences(storageKey) {
|
||||||
|
const hidden = ref([])
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey)
|
||||||
|
if (!raw) return
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
hidden.value = parsed.filter((x) => typeof x === 'string')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
hidden.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(hidden.value))
|
||||||
|
} catch {
|
||||||
|
/* quota / private mode — ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVisible = (key) => !hidden.value.includes(key)
|
||||||
|
|
||||||
|
const toggle = (key) => {
|
||||||
|
const i = hidden.value.indexOf(key)
|
||||||
|
if (i >= 0) hidden.value.splice(i, 1)
|
||||||
|
else hidden.value.push(key)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setGroupVisible = (groupKeys, visible) => {
|
||||||
|
if (visible) {
|
||||||
|
hidden.value = hidden.value.filter((k) => !groupKeys.includes(k))
|
||||||
|
} else {
|
||||||
|
const set = new Set(hidden.value)
|
||||||
|
groupKeys.forEach((k) => set.add(k))
|
||||||
|
hidden.value = [...set]
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
hidden.value = []
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return { hidden, isVisible, toggle, setGroupVisible, reset }
|
||||||
|
}
|
||||||
|
|
@ -2,65 +2,173 @@
|
||||||
<div class="list-page">
|
<div class="list-page">
|
||||||
<div class="page-title">材料管理</div>
|
<div class="page-title">材料管理</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-input v-model="filters.name" placeholder="材料名称" style="width: 200px" />
|
<div class="toolbar-row">
|
||||||
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 140px">
|
<el-input v-model="filters.name" placeholder="材料名称" clearable style="width: 180px" @keyup.enter="triggerSearch" />
|
||||||
<el-option v-for="item in statusOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 140px" @change="triggerSearch">
|
||||||
</el-select>
|
<el-option v-for="item in statusOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
<el-select v-model="filters.material_subcategory" placeholder="材料子类" clearable style="width: 180px">
|
</el-select>
|
||||||
<el-option v-for="item in filterSubcategoryOptions" :key="item.value" :label="item.name" :value="item.value" />
|
<el-select v-model="filters.major_category" placeholder="材料大类" clearable style="width: 140px" @change="triggerSearch">
|
||||||
</el-select>
|
<el-option v-for="item in majorCategoryOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
<el-select
|
</el-select>
|
||||||
v-model="filters.brand"
|
<el-select
|
||||||
placeholder="品牌"
|
v-model="filters.material_category"
|
||||||
clearable
|
placeholder="材料种类"
|
||||||
filterable
|
clearable
|
||||||
remote
|
filterable
|
||||||
:remote-method="searchBrands"
|
style="width: 160px"
|
||||||
:loading="brandSearchLoading"
|
@change="triggerSearch"
|
||||||
style="width: 180px"
|
>
|
||||||
>
|
<el-option v-for="item in filterCategoryOptions" :key="item.value" :label="item.name" :value="item.value" />
|
||||||
<el-option v-for="item in brandFilterOptions" :key="item.id" :label="item.name" :value="item.id" />
|
</el-select>
|
||||||
</el-select>
|
<el-select v-model="filters.material_subcategory" placeholder="材料子类" clearable style="width: 160px" @change="triggerSearch">
|
||||||
<el-button @click="loadMaterials">查询</el-button>
|
<el-option v-for="item in filterSubcategoryOptions" :key="item.value" :label="item.name" :value="item.value" />
|
||||||
<el-button v-if="isAdmin" :loading="importing" @click="importDialogVisible = true">
|
</el-select>
|
||||||
{{ importing ? '导入中...' : '导入数据' }}
|
<el-badge :is-dot="hasAdvancedActive" class="advanced-badge">
|
||||||
</el-button>
|
<el-button text @click="advancedOpen = !advancedOpen">
|
||||||
<el-button :loading="exporting" @click="handleExportExcel">
|
高级筛选 {{ advancedOpen ? '▲' : '▼' }}
|
||||||
{{ exporting ? '导出中...' : '导出' }}
|
</el-button>
|
||||||
</el-button>
|
</el-badge>
|
||||||
<el-button type="primary" @click="openCreate">新增材料</el-button>
|
|
||||||
<div class="toolbar-spacer" />
|
<div class="toolbar-spacer" />
|
||||||
|
|
||||||
|
<el-button v-if="isAdmin" :loading="importing" @click="importDialogVisible = true">
|
||||||
|
{{ importing ? '导入中...' : '导入数据' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button :loading="exporting" @click="handleExportExcel">
|
||||||
|
{{ exporting ? '导出中...' : '导出' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="openCreate">新增材料</el-button>
|
||||||
|
|
||||||
|
<el-popover placement="bottom-end" :width="460" trigger="click">
|
||||||
|
<template #reference>
|
||||||
|
<el-button title="列设置">⚙ 列</el-button>
|
||||||
|
</template>
|
||||||
|
<div class="column-setting">
|
||||||
|
<div v-for="group in COLUMN_GROUPS" :key="group.key" class="column-setting__group">
|
||||||
|
<div class="column-setting__header">
|
||||||
|
<span class="column-setting__title">{{ group.label }}</span>
|
||||||
|
<el-button size="small" text @click="setGroupVisible(groupColumnKeys(group.key), true)">全选</el-button>
|
||||||
|
<el-button size="small" text @click="setGroupVisible(groupColumnKeys(group.key), false)">全不选</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="column-setting__cols">
|
||||||
|
<el-checkbox
|
||||||
|
v-for="col in columnsOfGroup(group.key)"
|
||||||
|
:key="col.key"
|
||||||
|
:model-value="isVisible(col.key)"
|
||||||
|
@change="toggle(col.key)"
|
||||||
|
>{{ col.label }}</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column-setting__footer">
|
||||||
|
<el-button size="small" @click="resetColumns">恢复默认</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="advancedOpen" class="toolbar-row toolbar-row--advanced">
|
||||||
|
<el-select
|
||||||
|
v-model="filters.brand"
|
||||||
|
placeholder="品牌"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
:remote-method="searchBrands"
|
||||||
|
:loading="brandSearchLoading"
|
||||||
|
style="width: 160px"
|
||||||
|
@change="triggerSearch"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in brandFilterOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.stage" placeholder="阶段" clearable style="width: 140px" @change="triggerSearch">
|
||||||
|
<el-option v-for="item in stageOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.importance_level" placeholder="重要等级" clearable style="width: 140px" @change="triggerSearch">
|
||||||
|
<el-option v-for="item in importanceLevelOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.factory__cooperation_mode" placeholder="合作模式" clearable style="width: 140px" @change="triggerSearch">
|
||||||
|
<el-option v-for="item in cooperationModeOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="filters.factory"
|
||||||
|
placeholder="供应商"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
:remote-method="searchFactories"
|
||||||
|
:loading="factorySearchLoading"
|
||||||
|
style="width: 180px"
|
||||||
|
@change="triggerSearch"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in factoryFilterOptions" :key="item.id" :label="item.factory_name || item.short_name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="filters.landing_project" placeholder="落地项目" clearable style="width: 160px" @keyup.enter="triggerSearch" />
|
||||||
|
<el-input v-model="filters.contact_person" placeholder="对接人" clearable style="width: 120px" @keyup.enter="triggerSearch" />
|
||||||
|
<el-input v-model="filters.handler" placeholder="经办人" clearable style="width: 120px" @keyup.enter="triggerSearch" />
|
||||||
|
<el-input-number v-model="filters.cost_compare__gte" :min="0" placeholder="成本≥" controls-position="right" style="width: 130px" @change="triggerSearch" />
|
||||||
|
<el-input-number v-model="filters.cost_compare__lte" :min="0" placeholder="成本≤" controls-position="right" style="width: 130px" @change="triggerSearch" />
|
||||||
|
<el-select v-model="filters.score_level__gte" placeholder="综合评分≥" clearable style="width: 140px" @change="triggerSearch">
|
||||||
|
<el-option v-for="n in [1, 2, 3]" :key="n" :label="`${n} 星及以上`" :value="n" />
|
||||||
|
</el-select>
|
||||||
|
<el-button @click="resetFilters">重置筛选</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<el-table v-loading="tableLoading" :data="materials" border height="100%">
|
<el-table v-loading="tableLoading" :data="materials" border height="100%">
|
||||||
<el-table-column prop="name" label="材料名称" min-width="180" show-overflow-tooltip />
|
<template v-for="group in COLUMN_GROUPS" :key="group.key">
|
||||||
<el-table-column prop="major_category_display" label="材料大类" width="100" />
|
<el-table-column
|
||||||
<el-table-column prop="material_category" label="细分种类" min-width="140" show-overflow-tooltip />
|
v-if="hasVisibleInGroup(group.key)"
|
||||||
<el-table-column prop="material_subcategory" label="材料子类" min-width="140" show-overflow-tooltip />
|
:label="group.label"
|
||||||
<el-table-column prop="stage_display" label="阶段" width="130" show-overflow-tooltip />
|
align="left"
|
||||||
<el-table-column prop="importance_level_display" label="重要等级" width="110" />
|
header-align="left"
|
||||||
<el-table-column prop="landing_project" label="落地项目" min-width="140" show-overflow-tooltip />
|
>
|
||||||
<el-table-column prop="contact_person" label="对接人" width="100" show-overflow-tooltip />
|
<el-table-column
|
||||||
<el-table-column prop="contact_phone" label="对接人联系方式" width="150" show-overflow-tooltip />
|
v-for="col in visibleColumnsOfGroup(group.key)"
|
||||||
<el-table-column prop="handler" label="经办人" width="100" show-overflow-tooltip />
|
:key="col.key"
|
||||||
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
|
:prop="col.slot || col.formatter ? undefined : col.key"
|
||||||
<el-table-column prop="factory_short_name" label="供应商" min-width="140" show-overflow-tooltip />
|
:label="col.label"
|
||||||
<el-table-column prop="brand_name" label="品牌" min-width="140" show-overflow-tooltip />
|
:min-width="col.minWidth"
|
||||||
<el-table-column prop="status_display" label="状态" width="100" />
|
:width="col.width"
|
||||||
<el-table-column label="操作" width="320" fixed="right">
|
:show-overflow-tooltip="col.showOverflowTooltip"
|
||||||
<template #default="scope">
|
>
|
||||||
<div class="table-actions">
|
<template v-if="col.slot === 'tags'" #default="scope">
|
||||||
<el-button size="small" @click="goDetail(scope.row)">详情</el-button>
|
<template v-if="Array.isArray(scope.row[col.key]) && scope.row[col.key].length">
|
||||||
<el-button v-if="canEdit(scope.row)" size="small" @click="openEdit(scope.row)">编辑</el-button>
|
<el-tag
|
||||||
<el-button v-if="canSubmit(scope.row)" size="small" type="warning" @click="onSubmitAudit(scope.row)">提交审核</el-button>
|
v-for="(t, idx) in scope.row[col.key]"
|
||||||
<el-button v-if="canApprove(scope.row)" size="small" type="success" @click="onApprove(scope.row)">审核通过</el-button>
|
:key="idx"
|
||||||
<el-button v-if="canApprove(scope.row)" size="small" type="danger" @click="onReject(scope.row)">审核拒绝</el-button>
|
size="small"
|
||||||
<el-button v-if="canDelete(scope.row)" size="small" type="danger" @click="onDelete(scope.row)">删除</el-button>
|
style="margin-right: 4px; margin-bottom: 2px;"
|
||||||
</div>
|
>{{ displayTag(col.key, t) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="col.slot === 'stars'" #default="scope">
|
||||||
|
<span v-if="scope.row[col.key]" class="stars">
|
||||||
|
<span v-for="n in Number(scope.row[col.key]) || 0" :key="n">★</span>
|
||||||
|
</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="col.formatter" #default="scope">
|
||||||
|
{{ col.formatter(scope.row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table-column>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
<el-table-column label="操作" width="320" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button size="small" @click="goDetail(scope.row)">详情</el-button>
|
||||||
|
<el-button v-if="canEdit(scope.row)" size="small" @click="openEdit(scope.row)">编辑</el-button>
|
||||||
|
<el-button v-if="canSubmit(scope.row)" size="small" type="warning" @click="onSubmitAudit(scope.row)">提交审核</el-button>
|
||||||
|
<el-button v-if="canApprove(scope.row)" size="small" type="success" @click="onApprove(scope.row)">审核通过</el-button>
|
||||||
|
<el-button v-if="canApprove(scope.row)" size="small" type="danger" @click="onReject(scope.row)">审核拒绝</el-button>
|
||||||
|
<el-button v-if="canDelete(scope.row)" size="small" type="danger" @click="onDelete(scope.row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
|
|
@ -112,7 +220,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useAuth } from '@/store/auth'
|
import { useAuth } from '@/store/auth'
|
||||||
|
|
@ -129,9 +237,12 @@ import {
|
||||||
importMaterialsExcel,
|
importMaterialsExcel,
|
||||||
exportMaterialsExcel
|
exportMaterialsExcel
|
||||||
} from '@/api/material'
|
} from '@/api/material'
|
||||||
import { fetchSubcategories } from '@/api/category'
|
import { fetchCategories, fetchSubcategories } from '@/api/category'
|
||||||
import { fetchBrands } from '@/api/brand'
|
import { fetchBrands } from '@/api/brand'
|
||||||
|
import { fetchFactories } from '@/api/factory'
|
||||||
import MaterialForm from '@/views/material/MaterialForm.vue'
|
import MaterialForm from '@/views/material/MaterialForm.vue'
|
||||||
|
import { MATERIAL_COLUMNS, COLUMN_GROUPS } from '@/views/material/materialColumns'
|
||||||
|
import { useColumnPreferences } from '@/composables/useColumnPreferences'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isAdmin } = useAuth()
|
const { isAdmin } = useAuth()
|
||||||
|
|
@ -153,17 +264,70 @@ const materialFormRef = ref(null)
|
||||||
const templateDownloadUrl = `http://101.42.1.64:2260/media/material_import_template.xlsx`
|
const templateDownloadUrl = `http://101.42.1.64:2260/media/material_import_template.xlsx`
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
|
// 常用
|
||||||
name: '',
|
name: '',
|
||||||
status: '',
|
status: '',
|
||||||
material_subcategory: '',
|
material_subcategory: '',
|
||||||
brand: ''
|
brand: '',
|
||||||
|
// 高级
|
||||||
|
major_category: '',
|
||||||
|
material_category: '',
|
||||||
|
stage: '',
|
||||||
|
importance_level: '',
|
||||||
|
factory: '',
|
||||||
|
factory__cooperation_mode: '',
|
||||||
|
landing_project: '',
|
||||||
|
cost_compare__gte: null,
|
||||||
|
cost_compare__lte: null,
|
||||||
|
score_level__gte: null,
|
||||||
|
contact_person: '',
|
||||||
|
handler: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const advancedKeys = [
|
||||||
|
'brand', 'stage', 'importance_level',
|
||||||
|
'factory', 'factory__cooperation_mode', 'landing_project',
|
||||||
|
'cost_compare__gte', 'cost_compare__lte', 'score_level__gte',
|
||||||
|
'contact_person', 'handler',
|
||||||
|
]
|
||||||
|
const advancedOpen = ref(false)
|
||||||
|
const hasAdvancedActive = computed(() =>
|
||||||
|
advancedKeys.some((k) => {
|
||||||
|
const v = filters[k]
|
||||||
|
return v !== '' && v !== null && v !== undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const brandFilterOptions = ref([])
|
const brandFilterOptions = ref([])
|
||||||
const brandSearchLoading = ref(false)
|
const brandSearchLoading = ref(false)
|
||||||
|
const factoryFilterOptions = ref([])
|
||||||
|
const factorySearchLoading = ref(false)
|
||||||
const statusOptions = ref([])
|
const statusOptions = ref([])
|
||||||
|
const majorCategoryOptions = ref([])
|
||||||
|
const stageOptions = ref([])
|
||||||
|
const importanceLevelOptions = ref([])
|
||||||
|
const cooperationModeOptions = ref([])
|
||||||
|
const applicationSceneChoices = ref([])
|
||||||
|
const advantageChoices = ref([])
|
||||||
|
const filterCategoryOptions = ref([])
|
||||||
const filterSubcategoryOptions = ref([])
|
const filterSubcategoryOptions = ref([])
|
||||||
|
|
||||||
|
// 列显隐
|
||||||
|
const { isVisible, toggle, setGroupVisible, reset: resetColumns } = useColumnPreferences('mat3:material-list:columns:v1')
|
||||||
|
const columnsOfGroup = (groupKey) => MATERIAL_COLUMNS.filter((c) => c.group === groupKey)
|
||||||
|
const visibleColumnsOfGroup = (groupKey) => MATERIAL_COLUMNS.filter((c) => c.group === groupKey && isVisible(c.key))
|
||||||
|
const hasVisibleInGroup = (groupKey) => visibleColumnsOfGroup(groupKey).length > 0
|
||||||
|
const groupColumnKeys = (groupKey) => columnsOfGroup(groupKey).map((c) => c.key)
|
||||||
|
|
||||||
|
// tag 中文化映射
|
||||||
|
const displayTag = (colKey, code) => {
|
||||||
|
const choices = colKey === 'application_scene' ? applicationSceneChoices.value
|
||||||
|
: colKey === 'advantage' ? advantageChoices.value
|
||||||
|
: []
|
||||||
|
const hit = choices.find((c) => c[0] === code)
|
||||||
|
return hit ? hit[1] : code
|
||||||
|
}
|
||||||
|
|
||||||
const emptyForm = () => ({
|
const emptyForm = () => ({
|
||||||
name: '',
|
name: '',
|
||||||
major_category: '',
|
major_category: '',
|
||||||
|
|
@ -202,14 +366,18 @@ const emptyForm = () => ({
|
||||||
|
|
||||||
const form = ref(emptyForm())
|
const form = ref(emptyForm())
|
||||||
|
|
||||||
|
const buildQueryParams = () => {
|
||||||
|
const params = { page: pagination.page, page_size: pagination.pageSize }
|
||||||
|
Object.entries(filters).forEach(([k, v]) => {
|
||||||
|
if (v !== '' && v !== null && v !== undefined) params[k] = v
|
||||||
|
})
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
const loadMaterials = async () => {
|
const loadMaterials = async () => {
|
||||||
tableLoading.value = true
|
tableLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await fetchMaterials({
|
const data = await fetchMaterials(buildQueryParams())
|
||||||
...filters,
|
|
||||||
page: pagination.page,
|
|
||||||
page_size: pagination.pageSize
|
|
||||||
})
|
|
||||||
materials.value = data.results || data
|
materials.value = data.results || data
|
||||||
pagination.total = data.count || materials.value.length
|
pagination.total = data.count || materials.value.length
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -217,9 +385,49 @@ const loadMaterials = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadStatusOptions = async () => {
|
const triggerSearch = () => {
|
||||||
|
pagination.page = 1
|
||||||
|
loadMaterials()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filters.name = ''
|
||||||
|
filters.status = ''
|
||||||
|
filters.material_subcategory = ''
|
||||||
|
filters.brand = ''
|
||||||
|
filters.major_category = ''
|
||||||
|
filters.material_category = ''
|
||||||
|
filters.stage = ''
|
||||||
|
filters.importance_level = ''
|
||||||
|
filters.factory = ''
|
||||||
|
filters.factory__cooperation_mode = ''
|
||||||
|
filters.landing_project = ''
|
||||||
|
filters.cost_compare__gte = null
|
||||||
|
filters.cost_compare__lte = null
|
||||||
|
filters.score_level__gte = null
|
||||||
|
filters.contact_person = ''
|
||||||
|
filters.handler = ''
|
||||||
|
triggerSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadChoices = async () => {
|
||||||
const data = await fetchMaterialChoices()
|
const data = await fetchMaterialChoices()
|
||||||
statusOptions.value = data.status
|
statusOptions.value = data.status || []
|
||||||
|
majorCategoryOptions.value = data.major_category || []
|
||||||
|
stageOptions.value = data.stage || []
|
||||||
|
importanceLevelOptions.value = data.importance_level || []
|
||||||
|
cooperationModeOptions.value = data.cooperation_mode || []
|
||||||
|
applicationSceneChoices.value = data.application_scene || []
|
||||||
|
advantageChoices.value = data.advantage || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFilterCategories = async () => {
|
||||||
|
const data = await fetchCategories()
|
||||||
|
filterCategoryOptions.value = (data.results || data).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
value: item.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadFilterSubcategories = async () => {
|
const loadFilterSubcategories = async () => {
|
||||||
|
|
@ -236,6 +444,11 @@ const loadBrandFilterOptions = async () => {
|
||||||
brandFilterOptions.value = data.results || data
|
brandFilterOptions.value = data.results || data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadFactoryFilterOptions = async () => {
|
||||||
|
const data = await fetchFactories({ page_size: 100 })
|
||||||
|
factoryFilterOptions.value = data.results || data
|
||||||
|
}
|
||||||
|
|
||||||
const searchBrands = async (query) => {
|
const searchBrands = async (query) => {
|
||||||
brandSearchLoading.value = true
|
brandSearchLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -246,6 +459,16 @@ const searchBrands = async (query) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchFactories = async (query) => {
|
||||||
|
factorySearchLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchFactories({ page_size: 50, search: query || '' })
|
||||||
|
factoryFilterOptions.value = data.results || data
|
||||||
|
} finally {
|
||||||
|
factorySearchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
form.value = emptyForm()
|
form.value = emptyForm()
|
||||||
isEdit.value = false
|
isEdit.value = false
|
||||||
|
|
@ -403,18 +626,77 @@ const onPageSizeChange = (size) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadStatusOptions()
|
loadChoices()
|
||||||
|
loadFilterCategories()
|
||||||
loadFilterSubcategories()
|
loadFilterSubcategories()
|
||||||
loadBrandFilterOptions()
|
loadBrandFilterOptions()
|
||||||
|
loadFactoryFilterOptions()
|
||||||
loadMaterials()
|
loadMaterials()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-row--advanced {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-spacer {
|
.toolbar-spacer {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.advanced-badge :deep(.el-badge__content.is-dot) {
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-setting__group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-setting__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-setting__title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-setting__cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-setting__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars {
|
||||||
|
color: #f7ba2a;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.import-dialog__text {
|
.import-dialog__text {
|
||||||
color: #606266;
|
color: #606266;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
:model="modelValue"
|
:model="modelValue"
|
||||||
label-width="120px"
|
label-width="120px"
|
||||||
>
|
>
|
||||||
|
<div class="form-section-title">材料信息</div>
|
||||||
<el-form-item label="材料名称" required>
|
<el-form-item label="材料名称" required>
|
||||||
<el-input :model-value="modelValue.name" @update:model-value="set('name', $event)" />
|
<el-input :model-value="modelValue.name" @update:model-value="set('name', $event)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
@ -13,7 +14,7 @@
|
||||||
<el-option v-for="item in majorOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
<el-option v-for="item in majorOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="细分种类" required>
|
<el-form-item label="材料种类" required>
|
||||||
<el-select
|
<el-select
|
||||||
:model-value="modelValue.material_category"
|
:model-value="modelValue.material_category"
|
||||||
filterable
|
filterable
|
||||||
|
|
@ -42,21 +43,6 @@
|
||||||
<el-option v-for="item in importanceLevelOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
<el-option v-for="item in importanceLevelOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="落地项目">
|
|
||||||
<el-input :model-value="modelValue.landing_project" @update:model-value="set('landing_project', $event)" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="对接人">
|
|
||||||
<el-input :model-value="modelValue.contact_person" @update:model-value="set('contact_person', $event)" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="对接人联系方式">
|
|
||||||
<el-input :model-value="modelValue.contact_phone" @update:model-value="set('contact_phone', $event)" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="经办人">
|
|
||||||
<el-input :model-value="modelValue.handler" @update:model-value="set('handler', $event)" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="备注">
|
|
||||||
<el-input :model-value="modelValue.remark" @update:model-value="set('remark', $event)" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="规格型号">
|
<el-form-item label="规格型号">
|
||||||
<el-input :model-value="modelValue.spec" @update:model-value="set('spec', $event)" />
|
<el-input :model-value="modelValue.spec" @update:model-value="set('spec', $event)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
@ -76,6 +62,15 @@
|
||||||
<el-option v-for="item in replaceOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
<el-option v-for="item in replaceOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="连接方式">
|
||||||
|
<el-input :model-value="modelValue.connection_method" @update:model-value="set('connection_method', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="施工工艺">
|
||||||
|
<el-input :model-value="modelValue.construction_method" @update:model-value="set('construction_method', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="限制条件">
|
||||||
|
<el-input :model-value="modelValue.limit_condition" type="textarea" @update:model-value="set('limit_condition', $event)" />
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="竞争优势">
|
<el-form-item label="竞争优势">
|
||||||
<el-select :model-value="modelValue.advantage" multiple @update:model-value="set('advantage', $event)">
|
<el-select :model-value="modelValue.advantage" multiple @update:model-value="set('advantage', $event)">
|
||||||
<el-option v-for="item in advantageOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
<el-option v-for="item in advantageOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
|
|
@ -95,23 +90,6 @@
|
||||||
<el-form-item label="成本说明">
|
<el-form-item label="成本说明">
|
||||||
<el-input :model-value="modelValue.cost_desc" type="textarea" @update:model-value="set('cost_desc', $event)" />
|
<el-input :model-value="modelValue.cost_desc" type="textarea" @update:model-value="set('cost_desc', $event)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="案例">
|
|
||||||
<el-input :model-value="modelValue.cases" type="textarea" @update:model-value="set('cases', $event)" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="宣传页">
|
|
||||||
<el-upload
|
|
||||||
class="upload"
|
|
||||||
:auto-upload="true"
|
|
||||||
:show-file-list="false"
|
|
||||||
:http-request="handleUpload"
|
|
||||||
accept="image/*"
|
|
||||||
>
|
|
||||||
<el-button :loading="uploading">{{ uploading ? '上传中...' : '选择图片' }}</el-button>
|
|
||||||
</el-upload>
|
|
||||||
<div v-if="modelValue.brochure_url" class="preview">
|
|
||||||
<img :src="modelValue.brochure_url" alt="预览" />
|
|
||||||
</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="质量等级">
|
<el-form-item label="质量等级">
|
||||||
<el-select :model-value="modelValue.quality_level" clearable @update:model-value="set('quality_level', $event)">
|
<el-select :model-value="modelValue.quality_level" clearable @update:model-value="set('quality_level', $event)">
|
||||||
<el-option v-for="item in starOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
<el-option v-for="item in starOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
|
|
@ -137,20 +115,22 @@
|
||||||
<el-option v-for="item in starOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
<el-option v-for="item in starOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="连接方式">
|
<el-form-item label="宣传页">
|
||||||
<el-input :model-value="modelValue.connection_method" @update:model-value="set('connection_method', $event)" />
|
<el-upload
|
||||||
</el-form-item>
|
class="upload"
|
||||||
<el-form-item label="施工工艺">
|
:auto-upload="true"
|
||||||
<el-input :model-value="modelValue.construction_method" @update:model-value="set('construction_method', $event)" />
|
:show-file-list="false"
|
||||||
</el-form-item>
|
:http-request="handleUpload"
|
||||||
<el-form-item label="限制条件">
|
accept="image/*"
|
||||||
<el-input :model-value="modelValue.limit_condition" type="textarea" @update:model-value="set('limit_condition', $event)" />
|
>
|
||||||
</el-form-item>
|
<el-button :loading="uploading">{{ uploading ? '上传中...' : '选择图片' }}</el-button>
|
||||||
<el-form-item v-if="isAdmin" label="供应商">
|
</el-upload>
|
||||||
<el-select :model-value="modelValue.factory" @update:model-value="set('factory', $event)">
|
<div v-if="modelValue.brochure_url" class="preview">
|
||||||
<el-option v-for="item in factories" :key="item.id" :label="item.short_name" :value="item.id" />
|
<img :src="modelValue.brochure_url" alt="预览" />
|
||||||
</el-select>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="form-section-title">品牌与供应商</div>
|
||||||
<el-form-item label="品牌" required>
|
<el-form-item label="品牌" required>
|
||||||
<el-select
|
<el-select
|
||||||
:model-value="modelValue.brand"
|
:model-value="modelValue.brand"
|
||||||
|
|
@ -164,42 +144,80 @@
|
||||||
<el-option v-for="item in brandFormOptions" :key="item.id" :label="item.name" :value="item.id" />
|
<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-item v-if="isAdmin" label="供应商">
|
||||||
|
<el-select :model-value="modelValue.factory" filterable @update:model-value="set('factory', $event)">
|
||||||
|
<el-option v-for="item in factories" :key="item.id" :label="item.factory_name || item.short_name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="对接人">
|
||||||
|
<el-input :model-value="modelValue.contact_person" @update:model-value="set('contact_person', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="对接人联系方式">
|
||||||
|
<el-input :model-value="modelValue.contact_phone" @update:model-value="set('contact_phone', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="form-section-title">案例信息</div>
|
||||||
|
<el-form-item label="落地项目">
|
||||||
|
<el-input :model-value="modelValue.landing_project" @update:model-value="set('landing_project', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="案例">
|
||||||
|
<el-input :model-value="modelValue.cases" type="textarea" @update:model-value="set('cases', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="经办人">
|
||||||
|
<el-input :model-value="modelValue.handler" @update:model-value="set('handler', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input :model-value="modelValue.remark" @update:model-value="set('remark', $event)" />
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-descriptions :column="1" border>
|
<el-descriptions title="材料信息" :column="2" border class="detail-section">
|
||||||
<el-descriptions-item label="材料名称">{{ displayText(modelValue.name) }}</el-descriptions-item>
|
<el-descriptions-item label="材料名称">{{ displayText(modelValue.name) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="材料大类">{{ displayText(modelValue.major_category_display) }}</el-descriptions-item>
|
<el-descriptions-item label="材料大类">{{ displayText(modelValue.major_category_display) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="细分种类">{{ displayText(modelValue.material_category) }}</el-descriptions-item>
|
<el-descriptions-item label="细分种类">{{ displayText(modelValue.material_category) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="材料子类">{{ displayText(modelValue.material_subcategory) }}</el-descriptions-item>
|
<el-descriptions-item label="材料子类">{{ displayText(modelValue.material_subcategory) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="阶段">{{ displayText(modelValue.stage_display) }}</el-descriptions-item>
|
<el-descriptions-item label="阶段">{{ displayText(modelValue.stage_display) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="重要等级">{{ displayText(modelValue.importance_level_display) }}</el-descriptions-item>
|
<el-descriptions-item label="重要等级">{{ displayText(modelValue.importance_level_display) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="落地项目">{{ displayText(modelValue.landing_project) }}</el-descriptions-item>
|
<el-descriptions-item label="状态">{{ displayText(modelValue.status_display) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="对接人">{{ displayText(modelValue.contact_person) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="对接人联系方式">{{ displayText(modelValue.contact_phone) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="经办人">{{ displayText(modelValue.handler) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="备注">{{ displayText(modelValue.remark) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="规格型号">{{ displayText(modelValue.spec) }}</el-descriptions-item>
|
<el-descriptions-item label="规格型号">{{ displayText(modelValue.spec) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="符合标准">{{ displayText(modelValue.standard) }}</el-descriptions-item>
|
<el-descriptions-item label="符合标准">{{ displayText(modelValue.standard) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="应用场景">{{ displayList(modelValue.application_scene_display) }}</el-descriptions-item>
|
<el-descriptions-item label="应用场景">{{ displayList(modelValue.application_scene_display) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="应用说明">{{ displayText(modelValue.application_desc) }}</el-descriptions-item>
|
<el-descriptions-item label="应用说明">{{ displayText(modelValue.application_desc) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="替代材料类型">{{ displayText(modelValue.replace_type_display) }}</el-descriptions-item>
|
<el-descriptions-item label="替代材料类型">{{ displayText(modelValue.replace_type_display) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="连接方式">{{ displayText(modelValue.connection_method) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="施工工艺">{{ displayText(modelValue.construction_method) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="限制条件">{{ displayText(modelValue.limit_condition) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="竞争优势">{{ displayList(modelValue.advantage_display) }}</el-descriptions-item>
|
<el-descriptions-item label="竞争优势">{{ displayList(modelValue.advantage_display) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="优势说明">{{ displayText(modelValue.advantage_desc) }}</el-descriptions-item>
|
<el-descriptions-item label="优势说明">{{ displayText(modelValue.advantage_desc) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="成本对比">{{ formatPercent(modelValue.cost_compare) }}</el-descriptions-item>
|
<el-descriptions-item label="成本对比">{{ formatPercent(modelValue.cost_compare) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="成本说明">{{ displayText(modelValue.cost_desc) }}</el-descriptions-item>
|
<el-descriptions-item label="成本说明">{{ displayText(modelValue.cost_desc) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="案例">{{ displayText(modelValue.cases) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="供应商">{{ displayText(modelValue.factory_name) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="品牌">{{ displayText(modelValue.brand_name) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="质量等级">{{ formatStarLevel(modelValue.quality_level) }}</el-descriptions-item>
|
<el-descriptions-item label="质量等级">{{ formatStarLevel(modelValue.quality_level) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="耐久等级">{{ formatStarLevel(modelValue.durability_level) }}</el-descriptions-item>
|
<el-descriptions-item label="耐久等级">{{ formatStarLevel(modelValue.durability_level) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="环保等级">{{ formatStarLevel(modelValue.eco_level) }}</el-descriptions-item>
|
<el-descriptions-item label="环保等级">{{ formatStarLevel(modelValue.eco_level) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="低碳等级">{{ formatStarLevel(modelValue.carbon_level) }}</el-descriptions-item>
|
<el-descriptions-item label="低碳等级">{{ formatStarLevel(modelValue.carbon_level) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="总评分">{{ formatStarLevel(modelValue.score_level) }}</el-descriptions-item>
|
<el-descriptions-item label="总评分">{{ formatStarLevel(modelValue.score_level) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="连接方式">{{ displayText(modelValue.connection_method) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="施工工艺">{{ displayText(modelValue.construction_method) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="限制条件">{{ displayText(modelValue.limit_condition) }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-descriptions title="品牌与供应商" :column="2" border class="detail-section">
|
||||||
|
<el-descriptions-item label="品牌">{{ displayText(modelValue.brand_name) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="供应商简称">{{ displayText(modelValue.factory_short_name) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="供应商全称">{{ displayText(modelValue.factory_name) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="合作模式">{{ displayText(modelValue.factory_cooperation_mode_display) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="省-市">{{ displayLocation(modelValue) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接人">{{ displayText(modelValue.contact_person) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接人联系方式">{{ displayText(modelValue.contact_phone) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-descriptions title="案例信息" :column="1" border class="detail-section">
|
||||||
|
<el-descriptions-item label="落地项目">{{ displayText(modelValue.landing_project) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="案例">
|
||||||
|
<div style="white-space: pre-wrap;">{{ displayText(modelValue.cases) }}</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="经办人">{{ displayText(modelValue.handler) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注">{{ displayText(modelValue.remark) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
<div v-if="modelValue.brochure_url" class="brochure">
|
<div v-if="modelValue.brochure_url" class="brochure">
|
||||||
<div class="brochure-title">宣传页</div>
|
<div class="brochure-title">宣传页</div>
|
||||||
<img :src="modelValue.brochure_url" alt="宣传页" />
|
<img :src="modelValue.brochure_url" alt="宣传页" />
|
||||||
|
|
@ -309,6 +327,10 @@ const displayText = (value) => (value === null || value === undefined || value =
|
||||||
const displayList = (value) => (value?.length ? value.join('、') : '-')
|
const displayList = (value) => (value?.length ? value.join('、') : '-')
|
||||||
const formatPercent = (value) => (value === null || value === undefined || value === '' ? '-' : `${value}%`)
|
const formatPercent = (value) => (value === null || value === undefined || value === '' ? '-' : `${value}%`)
|
||||||
const formatStarLevel = (value) => (value ? `${value}星` : '-')
|
const formatStarLevel = (value) => (value ? `${value}星` : '-')
|
||||||
|
const displayLocation = (m) => {
|
||||||
|
const parts = [m?.factory_province, m?.factory_city].filter(Boolean)
|
||||||
|
return parts.length ? parts.join('-') : '-'
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (props.mode !== 'edit') return
|
if (props.mode !== 'edit') return
|
||||||
|
|
@ -368,6 +390,24 @@ defineExpose({ validate, clearValidate })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-title:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.upload {
|
.upload {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
export const COLUMN_GROUPS = [
|
||||||
|
{ key: 'material', label: '材料信息' },
|
||||||
|
{ key: 'supplier', label: '品牌与供应商' },
|
||||||
|
{ key: 'case', label: '案例信息' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const fmt = (v) => (v === null || v === undefined || v === '' ? '-' : v)
|
||||||
|
|
||||||
|
export const MATERIAL_COLUMNS = [
|
||||||
|
// ==== A. 材料信息 ====
|
||||||
|
{ group: 'material', key: 'name', label: '材料名称', minWidth: 180, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'major_category_display', label: '材料大类', width: 100 },
|
||||||
|
{ group: 'material', key: 'material_category', label: '材料种类', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'material_subcategory', label: '材料子类', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'stage_display', label: '阶段', width: 130, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'importance_level_display', label: '重要等级', width: 110 },
|
||||||
|
{ group: 'material', key: 'status_display', label: '状态', width: 100 },
|
||||||
|
{ group: 'material', key: 'cost_compare', label: '成本比较(%)', width: 120, formatter: (r) => fmt(r.cost_compare) },
|
||||||
|
{ group: 'material', key: 'cost_desc', label: '成本说明', minWidth: 160, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'advantage', label: '优势', minWidth: 200, slot: 'tags' },
|
||||||
|
{ group: 'material', key: 'advantage_desc', label: '优势说明', minWidth: 160, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'application_scene', label: '应用场景', minWidth: 200, slot: 'tags' },
|
||||||
|
{ group: 'material', key: 'application_desc', label: '应用说明', minWidth: 160, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'replace_type_display', label: '替代类型', width: 120, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'connection_method', label: '连接方式', width: 120, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'construction_method', label: '施工方式', width: 120, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'limit_condition', label: '使用限制', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'spec', label: '规格', width: 120, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'standard', label: '执行标准', width: 120, showOverflowTooltip: true },
|
||||||
|
{ group: 'material', key: 'quality_level', label: '质量', width: 90, slot: 'stars' },
|
||||||
|
{ group: 'material', key: 'durability_level', label: '耐久', width: 90, slot: 'stars' },
|
||||||
|
{ group: 'material', key: 'eco_level', label: '环保', width: 90, slot: 'stars' },
|
||||||
|
{ group: 'material', key: 'carbon_level', label: '碳', width: 90, slot: 'stars' },
|
||||||
|
{ group: 'material', key: 'score_level', label: '综合评分', width: 110, slot: 'stars' },
|
||||||
|
|
||||||
|
// ==== B. 品牌与供应商 ====
|
||||||
|
{ group: 'supplier', key: 'brand_name', label: '品牌', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'supplier', key: 'factory_short_name', label: '供应商简称', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'supplier', key: 'factory_name', label: '供应商全称', minWidth: 180, showOverflowTooltip: true },
|
||||||
|
{ group: 'supplier', key: 'factory_cooperation_mode_display', label: '合作模式', width: 110 },
|
||||||
|
{ group: 'supplier', key: 'factory_location', label: '省-市', width: 140, formatter: (r) => [r.factory_province, r.factory_city].filter(Boolean).join('-') || '-' },
|
||||||
|
{ group: 'supplier', key: 'contact_person', label: '对接人', width: 100, showOverflowTooltip: true },
|
||||||
|
{ group: 'supplier', key: 'contact_phone', label: '对接电话', width: 150, showOverflowTooltip: true },
|
||||||
|
|
||||||
|
// ==== C. 案例信息 ====
|
||||||
|
{ group: 'case', key: 'landing_project', label: '落地项目', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ group: 'case', key: 'cases', label: '案例', minWidth: 200, showOverflowTooltip: true },
|
||||||
|
{ group: 'case', key: 'handler', label: '经办人', width: 100, showOverflowTooltip: true },
|
||||||
|
{ group: 'case', key: 'remark', label: '备注', minWidth: 160, showOverflowTooltip: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ALL_COLUMN_KEYS = MATERIAL_COLUMNS.map((c) => c.key)
|
||||||
Loading…
Reference in New Issue