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_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(
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',
'carbon_level', 'score_level', 'connection_method', 'construction_method',
'limit_condition', 'factory', 'factory_name', 'factory_short_name',
'factory_cooperation_mode', 'factory_cooperation_mode_display',
'factory_province', 'factory_city',
'brand', 'brand_name',
'status', '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):
if obj.brochure:
request = self.context.get('request')
@ -85,25 +94,44 @@ class MaterialSerializer(serializers.ModelSerializer):
class MaterialListSerializer(serializers.ModelSerializer):
"""
材料列表序列化器简化版
材料列表序列化器完整字段版供列表页按需展示列
"""
factory_name = serializers.CharField(source='factory.factory_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)
major_category_display = serializers.CharField(source='get_major_category_display', read_only=True)
status_display = serializers.CharField(source='get_status_display', read_only=True)
stage_display = serializers.CharField(source='get_stage_display', read_only=True)
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:
model = Material
fields = ['id', 'name', 'major_category', 'major_category_display',
'material_category', 'material_subcategory', 'stage', 'stage_display',
'importance_level', 'importance_level_display', 'landing_project',
'contact_person', 'contact_phone', 'handler', 'remark', 'factory',
'factory_name', 'factory_short_name', 'brand', 'brand_name',
'contact_person', 'contact_phone', 'handler', 'remark',
'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']
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):
"""

View File

@ -7,6 +7,7 @@ from openpyxl import Workbook
from openpyxl.worksheet.datavalidation import DataValidation
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from django.db.models import Count
from rest_framework import generics, status
from rest_framework.decorators import api_view, action
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.exceptions import PermissionDenied
from rest_framework.parsers import MultiPartParser
from django.core.cache import cache
from .models import Material, MaterialCategory, MaterialSubcategory
from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer
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):
@ -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':
@ -200,6 +210,52 @@ class MaterialViewSet(ModelViewSet):
if 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
def get_serializer_class(self):
@ -246,7 +302,10 @@ class MaterialViewSet(ModelViewSet):
# 普通用户只能删除创建中的材料
if self.request.user.role != 'admin' and instance.status != 'draft':
raise PermissionDenied("只有创建中的材料可以删除")
was_approved = instance.status == 'approved'
instance.delete()
if was_approved:
invalidate_category_tree_cache()
@action(detail=True, methods=['post'])
def submit(self, request, pk=None):
@ -294,6 +353,7 @@ class MaterialViewSet(ModelViewSet):
material.status = 'approved'
material.save()
invalidate_category_tree_cache()
return Response({"status": "审核通过"})
@action(detail=True, methods=['post'])
@ -315,8 +375,11 @@ class MaterialViewSet(ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
was_approved = material.status == 'approved'
material.status = 'draft'
material.save()
if was_approved:
invalidate_category_tree_cache()
return Response({"status": "审核拒绝"})
@action(detail=False, methods=['get'])
@ -333,6 +396,7 @@ class MaterialViewSet(ModelViewSet):
'application_scene': Material.APPLICATION_SCENE_CHOICES,
'star_level': Material.STAR_LEVEL_CHOICES,
'status': Material.STATUS_CHOICES,
'cooperation_mode': COOPERATION_MODE_CHOICES,
})
@action(detail=False, methods=['get'], url_path='template')
@ -433,8 +497,93 @@ class MaterialViewSet(ModelViewSet):
except Exception as exc:
return Response({"detail": f"导入失败: {exc}"}, status=status.HTTP_400_BAD_REQUEST)
invalidate_category_tree_cache()
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):
"""

View File

@ -7,7 +7,7 @@ from django.conf import settings
from django.conf.urls.static import static
from django.views.generic import TemplateView
from .views import upload_image
from .views import upload_image, serve_h5
urlpatterns = [
path('admin/', admin.site.urls),
@ -25,9 +25,15 @@ if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static('/assets/', document_root=settings.BASE_DIR / 'dist' / 'assets')
# 前端单页应用入口
# H5 子应用:必须放在 PC 通配路由之前
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'),
name='frontend-index'),
]

View File

@ -1,7 +1,9 @@
import os
import uuid
from pathlib import Path
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.permissions import IsAuthenticated
from rest_framework.parsers import MultiPartParser
@ -9,6 +11,22 @@ from rest_framework.response import Response
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 = {
'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,65 +2,173 @@
<div class="list-page">
<div class="page-title">材料管理</div>
<div class="toolbar">
<el-input v-model="filters.name" placeholder="材料名称" style="width: 200px" />
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 140px">
<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: 180px">
<el-option v-for="item in filterSubcategoryOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select>
<el-select
v-model="filters.brand"
placeholder="品牌"
clearable
filterable
remote
:remote-method="searchBrands"
:loading="brandSearchLoading"
style="width: 180px"
>
<el-option v-for="item in brandFilterOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-button @click="loadMaterials">查询</el-button>
<el-button v-if="isAdmin" :loading="importing" @click="importDialogVisible = true">
{{ importing ? '导入中...' : '导入数据' }}
</el-button>
<el-button :loading="exporting" @click="handleExportExcel">
{{ exporting ? '导出中...' : '导出' }}
</el-button>
<el-button type="primary" @click="openCreate">新增材料</el-button>
<div class="toolbar-spacer" />
<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.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-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
v-model="filters.brand"
placeholder="品牌"
clearable
filterable
remote
:remote-method="searchBrands"
:loading="brandSearchLoading"
style="width: 160px"
@change="triggerSearch"
>
<el-option v-for="item in brandFilterOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="filters.stage" placeholder="阶段" clearable style="width: 140px" @change="triggerSearch">
<el-option v-for="item in stageOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select>
<el-select v-model="filters.importance_level" placeholder="重要等级" clearable style="width: 140px" @change="triggerSearch">
<el-option v-for="item in importanceLevelOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select>
<el-select v-model="filters.factory__cooperation_mode" placeholder="合作模式" clearable style="width: 140px" @change="triggerSearch">
<el-option v-for="item in cooperationModeOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select>
<el-select
v-model="filters.factory"
placeholder="供应商"
clearable
filterable
remote
:remote-method="searchFactories"
:loading="factorySearchLoading"
style="width: 180px"
@change="triggerSearch"
>
<el-option v-for="item in factoryFilterOptions" :key="item.id" :label="item.factory_name || item.short_name" :value="item.id" />
</el-select>
<el-input v-model="filters.landing_project" placeholder="落地项目" clearable style="width: 160px" @keyup.enter="triggerSearch" />
<el-input v-model="filters.contact_person" placeholder="对接人" clearable style="width: 120px" @keyup.enter="triggerSearch" />
<el-input v-model="filters.handler" placeholder="经办人" clearable style="width: 120px" @keyup.enter="triggerSearch" />
<el-input-number v-model="filters.cost_compare__gte" :min="0" placeholder="成本≥" controls-position="right" style="width: 130px" @change="triggerSearch" />
<el-input-number v-model="filters.cost_compare__lte" :min="0" placeholder="成本≤" controls-position="right" style="width: 130px" @change="triggerSearch" />
<el-select v-model="filters.score_level__gte" placeholder="综合评分≥" clearable style="width: 140px" @change="triggerSearch">
<el-option v-for="n in [1, 2, 3]" :key="n" :label="`${n} 星及以上`" :value="n" />
</el-select>
<el-button @click="resetFilters">重置筛选</el-button>
</div>
</div>
<div class="table-wrap">
<el-table v-loading="tableLoading" :data="materials" border height="100%">
<el-table-column prop="name" label="材料名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="major_category_display" label="材料大类" width="100" />
<el-table-column prop="material_category" label="细分种类" min-width="140" show-overflow-tooltip />
<el-table-column prop="material_subcategory" label="材料子类" min-width="140" show-overflow-tooltip />
<el-table-column prop="stage_display" label="阶段" width="130" show-overflow-tooltip />
<el-table-column prop="importance_level_display" label="重要等级" width="110" />
<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 prop="contact_phone" label="对接人联系方式" width="150" show-overflow-tooltip />
<el-table-column prop="handler" label="经办人" width="100" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="factory_short_name" label="供应商" min-width="140" show-overflow-tooltip />
<el-table-column prop="brand_name" label="品牌" min-width="140" show-overflow-tooltip />
<el-table-column prop="status_display" label="状态" width="100" />
<el-table-column label="操作" width="320" fixed="right">
<template #default="scope">
<div class="table-actions">
<el-button size="small" @click="goDetail(scope.row)">详情</el-button>
<el-button v-if="canEdit(scope.row)" size="small" @click="openEdit(scope.row)">编辑</el-button>
<el-button v-if="canSubmit(scope.row)" size="small" type="warning" @click="onSubmitAudit(scope.row)">提交审核</el-button>
<el-button v-if="canApprove(scope.row)" size="small" type="success" @click="onApprove(scope.row)">审核通过</el-button>
<el-button v-if="canApprove(scope.row)" size="small" type="danger" @click="onReject(scope.row)">审核拒绝</el-button>
<el-button v-if="canDelete(scope.row)" size="small" type="danger" @click="onDelete(scope.row)">删除</el-button>
</div>
<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="left"
header-align="left"
>
<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;"
>{{ 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>
</el-table>
<el-table-column label="操作" width="320" fixed="right">
<template #default="scope">
<div class="table-actions">
<el-button size="small" @click="goDetail(scope.row)">详情</el-button>
<el-button v-if="canEdit(scope.row)" size="small" @click="openEdit(scope.row)">编辑</el-button>
<el-button v-if="canSubmit(scope.row)" size="small" type="warning" @click="onSubmitAudit(scope.row)">提交审核</el-button>
<el-button v-if="canApprove(scope.row)" size="small" type="success" @click="onApprove(scope.row)">审核通过</el-button>
<el-button v-if="canApprove(scope.row)" size="small" type="danger" @click="onReject(scope.row)">审核拒绝</el-button>
<el-button v-if="canDelete(scope.row)" size="small" type="danger" @click="onDelete(scope.row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination
@ -112,7 +220,7 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuth } from '@/store/auth'
@ -129,9 +237,12 @@ import {
importMaterialsExcel,
exportMaterialsExcel
} from '@/api/material'
import { fetchSubcategories } from '@/api/category'
import { fetchCategories, fetchSubcategories } from '@/api/category'
import { fetchBrands } from '@/api/brand'
import { fetchFactories } from '@/api/factory'
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 { isAdmin } = useAuth()
@ -153,17 +264,70 @@ const materialFormRef = ref(null)
const templateDownloadUrl = `http://101.42.1.64:2260/media/material_import_template.xlsx`
const filters = reactive({
//
name: '',
status: '',
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 brandSearchLoading = ref(false)
const factoryFilterOptions = ref([])
const factorySearchLoading = ref(false)
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 { 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 = () => ({
name: '',
major_category: '',
@ -202,14 +366,18 @@ const 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 () => {
tableLoading.value = true
try {
const data = await fetchMaterials({
...filters,
page: pagination.page,
page_size: pagination.pageSize
})
const data = await fetchMaterials(buildQueryParams())
materials.value = data.results || data
pagination.total = data.count || materials.value.length
} 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()
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 () => {
@ -236,6 +444,11 @@ const loadBrandFilterOptions = async () => {
brandFilterOptions.value = data.results || data
}
const loadFactoryFilterOptions = async () => {
const data = await fetchFactories({ page_size: 100 })
factoryFilterOptions.value = data.results || data
}
const searchBrands = async (query) => {
brandSearchLoading.value = true
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 = () => {
form.value = emptyForm()
isEdit.value = false
@ -403,18 +626,77 @@ const onPageSizeChange = (size) => {
}
onMounted(() => {
loadStatusOptions()
loadChoices()
loadFilterCategories()
loadFilterSubcategories()
loadBrandFilterOptions()
loadFactoryFilterOptions()
loadMaterials()
})
</script>
<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 {
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 {
color: #606266;
line-height: 1.6;

View File

@ -5,6 +5,7 @@
:model="modelValue"
label-width="120px"
>
<div class="form-section-title">材料信息</div>
<el-form-item label="材料名称" required>
<el-input :model-value="modelValue.name" @update:model-value="set('name', $event)" />
</el-form-item>
@ -13,7 +14,7 @@
<el-option v-for="item in majorOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select>
</el-form-item>
<el-form-item label="细分种类" required>
<el-form-item label="材料种类" required>
<el-select
:model-value="modelValue.material_category"
filterable
@ -42,21 +43,6 @@
<el-option v-for="item in importanceLevelOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select>
</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-input :model-value="modelValue.spec" @update:model-value="set('spec', $event)" />
</el-form-item>
@ -76,6 +62,15 @@
<el-option v-for="item in replaceOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select>
</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-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]" />
@ -95,23 +90,6 @@
<el-form-item label="成本说明">
<el-input :model-value="modelValue.cost_desc" type="textarea" @update:model-value="set('cost_desc', $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-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-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]" />
@ -137,20 +115,22 @@
<el-option v-for="item in starOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select>
</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 v-if="isAdmin" label="供应商">
<el-select :model-value="modelValue.factory" @update:model-value="set('factory', $event)">
<el-option v-for="item in factories" :key="item.id" :label="item.short_name" :value="item.id" />
</el-select>
<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>
<div class="form-section-title">品牌与供应商</div>
<el-form-item label="品牌" required>
<el-select
: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-select>
</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>
<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.major_category_display) }}</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.stage_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.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.status_display) }}</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="应用场景">{{ 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.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="优势说明">{{ displayText(modelValue.advantage_desc) }}</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.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.durability_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.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 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 class="brochure-title">宣传页</div>
<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 formatPercent = (value) => (value === null || value === undefined || 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 () => {
if (props.mode !== 'edit') return
@ -368,6 +390,24 @@ defineExpose({ validate, clearValidate })
</script>
<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 {
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)