docs: 材料列表重构实施计划

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-04-24 14:08:23 +08:00
parent e927826ee0
commit 2b76bbc62a
1 changed files with 739 additions and 0 deletions

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