mat/docs/superpowers/plans/2026-04-24-material-list-re...

32 KiB
Raw Permalink Blame History

材料列表重构 实施计划

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 devpnpm dev;查 package.json 确认)

Task 1后端 MaterialListSerializer 字段扩展

Files:

  • Modify: backend/apps/material/serializers.pyMaterialListSerializer 类)

  • 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 组供应商扩展字段

在类体添加:

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 5check
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

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_fieldsFilterSet 子类)。

  • Step 2扩展筛选

若使用 filterset_fields 简单 dict

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

git add backend/apps/material/
git commit -m "feat(material): 列表新增大类/阶段/供应商/成本区间等筛选"

Task 3前端列元数据常量

Files:

  • Create: frontend/src/views/material/materialColumns.js

  • Step 1新建文件,导出 COLUMN_GROUPSMATERIAL_COLUMNS

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

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

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 与计算
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> 内部为数据驱动分级表头
<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
<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> 样式:

.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 5npm run dev 启动前端,浏览器打开材料列表

验证:

  • 三组表头分层显示

  • 默认全部列可见

  • 列设置 popover 勾选后列立即隐藏,刷新页面后偏好保留

  • localStorage 键 mat3:material-list:columns:v1 写入正确

  • 整组取消勾选后该组表头消失

  • tag / star 渲染正确

  • Step 6Commit

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

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新增查询辅助函数
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 前补充:

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 为两行结构
<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调整样式
.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

git add frontend/src/views/MaterialManage.vue frontend/src/api/factory.js
git commit -m "feat(material): 工具栏紧凑化、筛选自动触发、增加高级筛选"

Task 7fetchMaterialChoices 后端补齐枚举(按需)

Files:

  • Modify: backend/apps/material/views.pychoices 接口)

若 Task 6 Step 3 发现后端 /materials/choices/ 未返回 major_category / stage / importance_level / cooperation_mode,在此 Task 补齐。cooperation_mode 来自 Factory 模型的 COOPERATION_MODE_CHOICES

  • Step 1读取 views.pychoices action

  • Step 2补齐返回

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

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 模式模板

结构:

<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>
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样式
.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

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 处理。