32 KiB
材料列表重构 实施计划
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 组供应商扩展字段
在类体添加:
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
D:/projects/mat3/backend/.venv/Scripts/python.exe D:/projects/mat3/backend/manage.py check
预期:System check identified no issues
-
Step 6:启动 runserver,curl / 浏览器请求
/api/materials/?page_size=1,确认 JSON 中含所有新字段。 -
Step 7:Commit
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:
filterset_fields = {
'name': ['icontains'],
'status': ['exact'],
'material_subcategory': ['exact'],
'brand': ['exact'],
'major_category': ['exact'],
'material_category': ['icontains'],
'stage': ['exact'],
'importance_level': ['exact'],
'factory': ['exact'],
'factory__cooperation_mode': ['exact'],
'landing_project': ['icontains'],
'cost_compare': ['gte', 'lte'],
'score_level': ['gte'],
'contact_person': ['icontains'],
'handler': ['icontains'],
}
若使用 FilterSet 子类,添加对应字段(参考现有写法)。
-
Step 3:逐个 curl 验证(至少抽查 3 个:
?major_category=xxx、?cost_compare__gte=10、?landing_project__icontains=abc) -
Step 4:Commit
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
export const COLUMN_GROUPS = [
{ key: 'material', label: '材料信息' },
{ key: 'supplier', label: '品牌与供应商' },
{ key: 'case', label: '案例信息' },
]
const fmt = (v) => (v === null || v === undefined || v === '' ? '-' : v)
export const MATERIAL_COLUMNS = [
// ==== A. 材料信息 ====
{ group: 'material', key: 'name', label: '材料名称', minWidth: 180, showOverflowTooltip: true },
{ group: 'material', key: 'major_category_display', label: '材料大类', width: 100 },
{ group: 'material', key: 'material_category', label: '细分种类', minWidth: 140, showOverflowTooltip: true },
{ group: 'material', key: 'material_subcategory', label: '材料子类', minWidth: 140, showOverflowTooltip: true },
{ group: 'material', key: 'stage_display', label: '阶段', width: 130, showOverflowTooltip: true },
{ group: 'material', key: 'importance_level_display', label: '重要等级', width: 110 },
{ group: 'material', key: 'status_display', label: '状态', width: 100 },
{ group: 'material', key: 'cost_compare', label: '成本比较(%)', width: 120, formatter: (r) => fmt(r.cost_compare) },
{ group: 'material', key: 'cost_desc', label: '成本说明', minWidth: 160, showOverflowTooltip: true },
{ group: 'material', key: 'advantage', label: '优势', minWidth: 200, slot: 'tags' },
{ group: 'material', key: 'advantage_desc', label: '优势说明', minWidth: 160, showOverflowTooltip: true },
{ group: 'material', key: 'application_scene', label: '应用场景', minWidth: 200, slot: 'tags' },
{ group: 'material', key: 'application_desc', label: '应用说明', minWidth: 160, showOverflowTooltip: true },
{ group: 'material', key: 'replace_type', label: '替代类型', width: 120, showOverflowTooltip: true },
{ group: 'material', key: 'connection_method', label: '连接方式', width: 120, showOverflowTooltip: true },
{ group: 'material', key: 'construction_method', label: '施工方式', width: 120, showOverflowTooltip: true },
{ group: 'material', key: 'limit_condition', label: '使用限制', minWidth: 140, showOverflowTooltip: true },
{ group: 'material', key: 'spec', label: '规格', width: 120, showOverflowTooltip: true },
{ group: 'material', key: 'standard', label: '执行标准', width: 120, showOverflowTooltip: true },
{ group: 'material', key: 'quality_level', label: '质量', width: 90, slot: 'stars' },
{ group: 'material', key: 'durability_level', label: '耐久', width: 90, slot: 'stars' },
{ group: 'material', key: 'eco_level', label: '环保', width: 90, slot: 'stars' },
{ group: 'material', key: 'carbon_level', label: '碳', width: 90, slot: 'stars' },
{ group: 'material', key: 'score_level', label: '综合评分', width: 110, slot: 'stars' },
// ==== B. 品牌与供应商 ====
{ group: 'supplier', key: 'brand_name', label: '品牌', minWidth: 140, showOverflowTooltip: true },
{ group: 'supplier', key: 'factory_short_name', label: '供应商简称', minWidth: 140, showOverflowTooltip: true },
{ group: 'supplier', key: 'factory_full_name', label: '供应商全称', minWidth: 180, showOverflowTooltip: true },
{ group: 'supplier', key: 'factory_cooperation_mode_display', label: '合作模式', width: 110 },
{ group: 'supplier', key: 'factory_location', label: '省-市', width: 140, formatter: (r) => [r.factory_province, r.factory_city].filter(Boolean).join('-') || '-' },
{ group: 'supplier', key: 'contact_person', label: '对接人', width: 100, showOverflowTooltip: true },
{ group: 'supplier', key: 'contact_phone', label: '对接电话', width: 150, showOverflowTooltip: true },
// ==== C. 案例信息 ====
{ group: 'case', key: 'landing_project', label: '落地项目', minWidth: 140, showOverflowTooltip: true },
{ group: 'case', key: 'cases', label: '案例', minWidth: 200, showOverflowTooltip: true },
{ group: 'case', key: 'handler', label: '经办人', width: 100, showOverflowTooltip: true },
{ group: 'case', key: 'remark', label: '备注', minWidth: 160, showOverflowTooltip: true },
]
export const ALL_COLUMN_KEYS = MATERIAL_COLUMNS.map((c) => c.key)
- Step 2:Commit
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 2:Commit
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 5:
npm run dev启动前端,浏览器打开材料列表
验证:
-
三组表头分层显示
-
默认全部列可见
-
列设置 popover 勾选后列立即隐藏,刷新页面后偏好保留
-
localStorage 键
mat3:material-list:columns:v1写入正确 -
整组取消勾选后该组表头消失
-
tag / star 渲染正确
-
Step 6:Commit
git add frontend/src/views/MaterialManage.vue
git commit -m "feat(material): 列表页分级表头 + 列显隐配置"
Task 6:工具栏紧凑化 + 自动触发 + 高级筛选
Files:
-
Modify:
frontend/src/views/MaterialManage.vue -
Step 1:扩展
filtersreactive
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>
<!-- 列设置 popover(Task 5 已加入,保留) -->
</div>
<div v-show="advancedOpen" class="toolbar-row toolbar-row--advanced">
<el-select v-model="filters.major_category" placeholder="材料大类" clearable style="width: 140px" @change="triggerSearch">
<el-option v-for="item in majorCategoryOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select>
<el-select v-model="filters.stage" placeholder="阶段" clearable style="width: 140px" @change="triggerSearch">
<el-option v-for="item in stageOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select>
<el-select v-model="filters.importance_level" placeholder="重要等级" clearable style="width: 140px" @change="triggerSearch">
<el-option v-for="item in importanceLevelOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select>
<el-select v-model="filters.factory__cooperation_mode" placeholder="合作模式" clearable style="width: 140px" @change="triggerSearch">
<el-option v-for="item in cooperationModeOptions" :key="item[0]" :label="item[1]" :value="item[0]" />
</el-select>
<el-select v-model="filters.factory" placeholder="供应商" clearable filterable remote
:remote-method="searchFactories" :loading="factorySearchLoading"
style="width: 180px" @change="triggerSearch">
<el-option v-for="item in factoryFilterOptions" :key="item.id" :label="item.short_name" :value="item.id" />
</el-select>
<el-input v-model="filters.material_category" placeholder="细分种类" clearable style="width: 160px" @keyup.enter="triggerSearch" />
<el-input v-model="filters.landing_project" placeholder="落地项目" clearable style="width: 160px" @keyup.enter="triggerSearch" />
<el-input v-model="filters.contact_person" placeholder="对接人" clearable style="width: 120px" @keyup.enter="triggerSearch" />
<el-input v-model="filters.handler" placeholder="经办人" clearable style="width: 120px" @keyup.enter="triggerSearch" />
<el-input-number v-model="filters.cost_compare__gte" :min="0" placeholder="成本≥" controls-position="right" style="width: 120px" @change="triggerSearch" />
<el-input-number v-model="filters.cost_compare__lte" :min="0" placeholder="成本≤" controls-position="right" style="width: 120px" @change="triggerSearch" />
<el-select v-model="filters.score_level__gte" placeholder="综合评分≥" clearable style="width: 140px" @change="triggerSearch">
<el-option v-for="n in [1,2,3]" :key="n" :label="`${n} 星及以上`" :value="n" />
</el-select>
<el-button @click="resetFilters">重置筛选</el-button>
</div>
</div>
- Step 5:调整样式
.toolbar { display: flex; flex-direction: column; gap: 8px; }
.toolbar-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.toolbar-row--advanced { padding: 8px; background: #f5f7fa; border-radius: 4px; }
.toolbar-spacer { flex: 1 1 auto; }
.advanced-badge :deep(.el-badge__content.is-dot) { top: 6px; right: 6px; }
原 .toolbar-spacer 若已有保留;原"查询"按钮删除。
-
Step 6:清理
loadMaterials—— 传参时展开整个filters;后端忽略空值(DRF FilterSet 默认行为)。验证空字符串传递不影响筛选(必要时加Object.fromEntries(Object.entries(filters).filter(([,v]) => v !== '' && v !== null)))。 -
Step 7:浏览器验证
-
第一行常用筛选 change/Enter 自动触发查询
-
点"高级筛选"展开第二行;设置任意高级值后收起,按钮出现红点
-
"重置筛选"清空所有筛选并刷新
-
新增/导入/导出/编辑流程正常
-
Step 8:Commit
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中choicesaction -
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 3:curl 验证返回 JSON 含新 key
-
Step 4:Commit
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 7:Commit
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 处理。