740 lines
32 KiB
Markdown
740 lines
32 KiB
Markdown
# 材料列表重构 实施计划
|
||
|
||
> **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:启动 runserver,curl / 浏览器请求 `/api/materials/?page_size=1`**,确认 JSON 中含所有新字段。
|
||
|
||
- [ ] **Step 7:Commit**
|
||
|
||
```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 4:Commit**
|
||
|
||
```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 2:Commit**
|
||
|
||
```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 2:Commit**
|
||
|
||
```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 6:Commit**
|
||
|
||
```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>
|
||
|
||
<!-- 列设置 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:调整样式**
|
||
|
||
```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 8:Commit**
|
||
|
||
```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 3:curl 验证返回 JSON 含新 key**
|
||
|
||
- [ ] **Step 4:Commit**
|
||
|
||
```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 7:Commit**
|
||
|
||
```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 处理。
|