Compare commits

...

29 Commits

Author SHA1 Message Date
caoqianming bf70b3f462 refactor(h5): H5 静态产物路径改为 backend/dist/h5/
与 PC 的 backend/dist/ 同目录复用,部署时手动把 frontend-h5/dist/* 拷进 backend/dist/h5/ 即可,
不需调整 settings.py 的现有 dist 路径。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 10:41:00 +08:00
caoqianming ff3c0d8fea feat(h5): runserver 同时托管 H5 SPA
- 新增 /m/ 路由:直出 frontend-h5/dist 下静态资源;不存在的路径回退到 dist/index.html
  以支持 vue-router history 模式刷新/直链
- PC 通配路由排除 m/,避免被 PC 的 index.html 拦截

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 10:19:51 +08:00
caoqianming 76b2c08801 style(h5): 大类卡片背景图改为本地资源
- 从 Pexels 下载建筑/景观/设备/装修四张主题图存到 public/img/majors/
- 不再依赖外部 CDN,离线/弱网也能正常显示

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 09:45:13 +08:00
caoqianming 61713bcb58 style(h5): 大类卡片改为 100px 高带背景图样式
- 使用 picsum.photos 按 seed 加载稳定的占位图
- 暗色渐变罩底保证文字可读,选中态切换为品牌绿渐变
- 后续可将 picsum URL 替换为 public/img/majors/ 下的本地图

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 09:41:34 +08:00
caoqianming d0af6b1596 style(h5): 首页大类卡片改为横向布局,高度收紧到 80px
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 09:38:37 +08:00
caoqianming d1201d6923 refactor(material): 改用显式失效缓存,移除 signals;TTL 30min → 5min
- 删除 signals.py,改在 approve / reject / perform_destroy / import_excel 四个会影响
  已审核材料集合的入口显式调用 invalidate_category_tree_cache(),调用栈可见、易追踪
- reject 与 perform_destroy 仅当原状态为 approved 时才失效,避免无效缓存抖动
- TTL 由 30 分钟降为 5 分钟,作为兜底防止遗漏路径

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 09:36:11 +08:00
caoqianming 25587dce21 perf(h5): 合并为单次 category-tree 接口 + 后端缓存
- 后端新增 /material/category-tree/ 一次性聚合 (大类→种类→子类),使用 Django cache 全局缓存(30 分钟)
- Material post_save / post_delete 信号自动失效缓存,保证一致性
- H5 首页从 4 次并行请求降为 1 次;命中缓存时不打 DB

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 09:34:29 +08:00
caoqianming 1a0af18457 feat(h5): 首页默认展示全部大类的材料种类,未选中时分组列出
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 09:22:54 +08:00
caoqianming a084f5c7e0 feat(h5): 统一品牌名称与 logo 与 PC 端一致(M³ + 房地产新材料选材管理数据系统)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 09:21:16 +08:00
caoqianming 7b5ff36b23 docs(h5): 添加 README 部署说明
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:57:12 +08:00
caoqianming 050a1840ec feat(h5): 材料详情页(三块展示)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:57:07 +08:00
caoqianming 5ff8874139 feat(h5): 种类详情页(子类 Tab + 材料列表)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:56:34 +08:00
caoqianming a136c3db85 feat(h5): 首页大类+种类浏览
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:56:07 +08:00
caoqianming eebd6825b9 feat(h5): 登录页
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 16:26:20 +08:00
caoqianming 82490bc786 feat(h5): 新增通用组件 NavBar/Toast/Chip/StarLevel/Skeleton
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 16:15:54 +08:00
caoqianming f410a95a3d feat(h5): 初始化 frontend-h5 脚手架(Vite+Vue3+Tailwind+Pinia+Router)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 16:05:36 +08:00
caoqianming 815e8df10b feat(material): 新增 H5 大类/种类聚合查询接口
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 15:51:17 +08:00
caoqianming 807e7de443 docs(h5): 新增 H5 材料浏览端实施计划
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 15:41:42 +08:00
caoqianming d550c73439 docs(h5): 新增 H5 材料浏览端设计文档
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 15:31:43 +08:00
caoqianming ddc26c414a feat(material): 新增/编辑表单按三块分区;供应商下拉显示全称
- MaterialForm edit 模式按 材料信息/品牌与供应商/案例信息 分段,字段顺序与列表/详情对齐
- 供应商下拉显示 factory_name(全称),fallback short_name;filterable 支持筛选
- 列表页高级筛选中的供应商下拉同步改用全称

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:40:44 +08:00
caoqianming 20268638f1 style(material): 分组表头左对齐
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:24:12 +08:00
caoqianming 4db17db5e4 feat(material): 调整工具栏第一行筛选项
- 材料大类、材料种类(原"细分种类",改为下拉)上移第一行
- 品牌下移到高级筛选
- 列标题"细分种类"改为"材料种类"
- 后端 material_category 改为 exact 匹配(配合下拉)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:22:28 +08:00
caoqianming 34fc101856 feat(material): 细分种类筛选上移至工具栏第一行
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:19:32 +08:00
caoqianming 95b04cd7ac feat(material): 详情视图分三块展示
view 模式拆为 材料信息 / 品牌与供应商 / 案例信息 三个 el-descriptions,
新增供应商合作模式、省-市展示。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:16:43 +08:00
caoqianming 9a42a2d4b8 feat(material): 列表页分级表头、列显隐配置、工具栏紧凑与高级筛选
- 表格改为数据驱动 + 三级分组表头 (材料信息/品牌与供应商/案例信息)
- tag / stars 特殊单元格渲染
- 列设置 popover:按分组全选/全不选/单选,localStorage 持久化
- 工具栏两行:常用筛选 + 主操作 / 高级筛选面板
- 下拉 change、输入框 Enter 自动触发查询
- 高级筛选有值时"高级筛选"按钮显示红点
- 删除"查询"按钮;新增"重置筛选"
- 新增供应商远程搜索下拉、合作模式/大类/阶段等筛选项

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:15:11 +08:00
caoqianming 4050186dc0 feat(material): 列元数据常量与列显隐偏好 composable
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:12:48 +08:00
caoqianming 99e6df0a57 feat(material): 列表序列化器与筛选扩展
- MaterialListSerializer 补全 A 组字段(规格/应用/优势/成本/星级/施工)
- 新增供应商扩展字段 factory_cooperation_mode/display/province/city
- MaterialSerializer 同步供应商扩展字段供详情使用
- 视图 queryset 增加 select_related('factory','brand')
- 新增筛选: stage / importance_level / factory / cooperation_mode /
  landing_project / cost_compare 区间 / score_level>=N / contact_person / handler
- material_category 改为 icontains 模糊匹配
- choices 接口追加 cooperation_mode

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:11:35 +08:00
caoqianming 2b76bbc62a docs: 材料列表重构实施计划
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:08:23 +08:00
caoqianming e927826ee0 docs: 材料列表重构设计稿
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:02:49 +08:00
48 changed files with 6826 additions and 132 deletions

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class MaterialConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.material'

View File

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

View File

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

View File

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

View File

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

View File

@ -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启动 runservercurl / 浏览器请求 `/api/materials/?page_size=1`**,确认 JSON 中含所有新字段。
- [ ] **Step 7Commit**
```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 4Commit**
```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 2Commit**
```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 2Commit**
```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 6Commit**
```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>
<!-- 列设置 popoverTask 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 8Commit**
```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 3curl 验证返回 JSON 含新 key**
- [ ] **Step 4Commit**
```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 7Commit**
```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 处理。

View File

@ -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 4history 模式
├── 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` 存 tokenkey 为 `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]` 标签值布局;竞争优势、应用场景、重要等级、替代类型用 chip4 项等级用星级可视化。长文本(优势说明、成本说明、应用说明)独立成段。
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` 技能产出

View File

@ -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`exactid
- `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`
- 材料名称 inputwidth 180`@keyup.enter="loadMaterials"`
- 状态 select140`@change="loadMaterials"`
- 材料子类 select160`@change`
- 品牌远程 select160`@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`
- 综合评分 ≥ Nselect 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 模式)

4
frontend-h5/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.local

27
frontend-h5/README.md Normal file
View File

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

12
frontend-h5/index.html Normal file
View File

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

2631
frontend-h5/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend-h5/package.json Normal file
View File

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

View File

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

20
frontend-h5/src/App.vue Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
<template>
<div class="animate-pulse bg-neutral-200/60 rounded" />
</template>

View File

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

View File

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

View File

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

View File

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

10
frontend-h5/src/main.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: [],
}

View File

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

View File

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

View File

@ -2,13 +2,71 @@
<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-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-option v-for="item in statusOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select> </el-select>
<el-select v-model="filters.material_subcategory" placeholder="材料子类" clearable style="width: 180px"> <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.material_category"
placeholder="材料种类"
clearable
filterable
style="width: 160px"
@change="triggerSearch"
>
<el-option v-for="item in filterCategoryOptions" :key="item.value" :label="item.name" :value="item.value" />
</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-option v-for="item in filterSubcategoryOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select> </el-select>
<el-badge :is-dot="hasAdvancedActive" class="advanced-badge">
<el-button text @click="advancedOpen = !advancedOpen">
高级筛选 {{ advancedOpen ? '▲' : '▼' }}
</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>
<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 <el-select
v-model="filters.brand" v-model="filters.brand"
placeholder="品牌" placeholder="品牌"
@ -17,37 +75,87 @@
remote remote
:remote-method="searchBrands" :remote-method="searchBrands"
:loading="brandSearchLoading" :loading="brandSearchLoading"
style="width: 180px" style="width: 160px"
@change="triggerSearch"
> >
<el-option v-for="item in brandFilterOptions" :key="item.id" :label="item.name" :value="item.id" /> <el-option v-for="item in brandFilterOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select> </el-select>
<el-button @click="loadMaterials">查询</el-button> <el-select v-model="filters.stage" placeholder="阶段" clearable style="width: 140px" @change="triggerSearch">
<el-button v-if="isAdmin" :loading="importing" @click="importDialogVisible = true"> <el-option v-for="item in stageOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
{{ importing ? '导入中...' : '导入数据' }} </el-select>
</el-button> <el-select v-model="filters.importance_level" placeholder="重要等级" clearable style="width: 140px" @change="triggerSearch">
<el-button :loading="exporting" @click="handleExportExcel"> <el-option v-for="item in importanceLevelOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
{{ exporting ? '导出中...' : '导出' }} </el-select>
</el-button> <el-select v-model="filters.factory__cooperation_mode" placeholder="合作模式" clearable style="width: 140px" @change="triggerSearch">
<el-button type="primary" @click="openCreate">新增材料</el-button> <el-option v-for="item in cooperationModeOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
<div class="toolbar-spacer" /> </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"
: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;"
>{{ 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>
<el-table-column label="操作" width="320" fixed="right"> <el-table-column label="操作" width="320" fixed="right">
<template #default="scope"> <template #default="scope">
<div class="table-actions"> <div class="table-actions">
@ -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;

View File

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

View File

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