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

740 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 材料列表重构 实施计划
> **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 处理。