feat(material): 列表页分级表头、列显隐配置、工具栏紧凑与高级筛选
- 表格改为数据驱动 + 三级分组表头 (材料信息/品牌与供应商/案例信息) - tag / stars 特殊单元格渲染 - 列设置 popover:按分组全选/全不选/单选,localStorage 持久化 - 工具栏两行:常用筛选 + 主操作 / 高级筛选面板 - 下拉 change、输入框 Enter 自动触发查询 - 高级筛选有值时"高级筛选"按钮显示红点 - 删除"查询"按钮;新增"重置筛选" - 新增供应商远程搜索下拉、合作模式/大类/阶段等筛选项 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4050186dc0
commit
9a42a2d4b8
|
|
@ -2,65 +2,163 @@
|
|||
<div class="list-page">
|
||||
<div class="page-title">材料管理</div>
|
||||
<div class="toolbar">
|
||||
<el-input v-model="filters.name" placeholder="材料名称" style="width: 200px" />
|
||||
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 140px">
|
||||
<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: 180px">
|
||||
<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: 180px"
|
||||
>
|
||||
<el-option v-for="item in brandFilterOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
<el-button @click="loadMaterials">查询</el-button>
|
||||
<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>
|
||||
<div class="toolbar-spacer" />
|
||||
<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">
|
||||
高级筛选 {{ advancedOpen ? '▲' : '▼' }}
|
||||
</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>
|
||||
|
||||
<el-popover placement="bottom-end" :width="460" trigger="click">
|
||||
<template #reference>
|
||||
<el-button title="列设置">⚙ 列</el-button>
|
||||
</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 columnsOfGroup(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>
|
||||
</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 || item.factory_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: 130px" @change="triggerSearch" />
|
||||
<el-input-number v-model="filters.cost_compare__lte" :min="0" placeholder="成本≤" controls-position="right" style="width: 130px" @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>
|
||||
|
||||
<div class="table-wrap">
|
||||
<el-table v-loading="tableLoading" :data="materials" border height="100%">
|
||||
<el-table-column prop="name" label="材料名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="major_category_display" label="材料大类" width="100" />
|
||||
<el-table-column prop="material_category" label="细分种类" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="material_subcategory" label="材料子类" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="stage_display" label="阶段" width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="importance_level_display" label="重要等级" width="110" />
|
||||
<el-table-column prop="landing_project" label="落地项目" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="contact_person" label="对接人" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="contact_phone" label="对接人联系方式" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="handler" label="经办人" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="factory_short_name" label="供应商" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="brand_name" label="品牌" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="status_display" label="状态" width="100" />
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" @click="goDetail(scope.row)">详情</el-button>
|
||||
<el-button v-if="canEdit(scope.row)" size="small" @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button v-if="canSubmit(scope.row)" size="small" type="warning" @click="onSubmitAudit(scope.row)">提交审核</el-button>
|
||||
<el-button v-if="canApprove(scope.row)" size="small" type="success" @click="onApprove(scope.row)">审核通过</el-button>
|
||||
<el-button v-if="canApprove(scope.row)" size="small" type="danger" @click="onReject(scope.row)">审核拒绝</el-button>
|
||||
<el-button v-if="canDelete(scope.row)" size="small" type="danger" @click="onDelete(scope.row)">删除</el-button>
|
||||
</div>
|
||||
<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;"
|
||||
>{{ displayTag(col.key, 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]" class="stars">
|
||||
<span v-for="n in Number(scope.row[col.key]) || 0" :key="n">★</span>
|
||||
</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>
|
||||
</el-table>
|
||||
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" @click="goDetail(scope.row)">详情</el-button>
|
||||
<el-button v-if="canEdit(scope.row)" size="small" @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button v-if="canSubmit(scope.row)" size="small" type="warning" @click="onSubmitAudit(scope.row)">提交审核</el-button>
|
||||
<el-button v-if="canApprove(scope.row)" size="small" type="success" @click="onApprove(scope.row)">审核通过</el-button>
|
||||
<el-button v-if="canApprove(scope.row)" size="small" type="danger" @click="onReject(scope.row)">审核拒绝</el-button>
|
||||
<el-button v-if="canDelete(scope.row)" size="small" type="danger" @click="onDelete(scope.row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
|
|
@ -112,7 +210,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
|
@ -131,7 +229,10 @@ import {
|
|||
} from '@/api/material'
|
||||
import { fetchSubcategories } from '@/api/category'
|
||||
import { fetchBrands } from '@/api/brand'
|
||||
import { fetchFactories } from '@/api/factory'
|
||||
import MaterialForm from '@/views/material/MaterialForm.vue'
|
||||
import { MATERIAL_COLUMNS, COLUMN_GROUPS } from '@/views/material/materialColumns'
|
||||
import { useColumnPreferences } from '@/composables/useColumnPreferences'
|
||||
|
||||
const router = useRouter()
|
||||
const { isAdmin } = useAuth()
|
||||
|
|
@ -153,17 +254,69 @@ const materialFormRef = ref(null)
|
|||
const templateDownloadUrl = `http://101.42.1.64:2260/media/material_import_template.xlsx`
|
||||
|
||||
const filters = reactive({
|
||||
// 常用
|
||||
name: '',
|
||||
status: '',
|
||||
material_subcategory: '',
|
||||
brand: ''
|
||||
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 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 advancedOpen = ref(false)
|
||||
const hasAdvancedActive = computed(() =>
|
||||
advancedKeys.some((k) => {
|
||||
const v = filters[k]
|
||||
return v !== '' && v !== null && v !== undefined
|
||||
})
|
||||
)
|
||||
|
||||
const brandFilterOptions = ref([])
|
||||
const brandSearchLoading = ref(false)
|
||||
const factoryFilterOptions = ref([])
|
||||
const factorySearchLoading = ref(false)
|
||||
const statusOptions = ref([])
|
||||
const majorCategoryOptions = ref([])
|
||||
const stageOptions = ref([])
|
||||
const importanceLevelOptions = ref([])
|
||||
const cooperationModeOptions = ref([])
|
||||
const applicationSceneChoices = ref([])
|
||||
const advantageChoices = ref([])
|
||||
const filterSubcategoryOptions = ref([])
|
||||
|
||||
// 列显隐
|
||||
const { isVisible, toggle, setGroupVisible, reset: resetColumns } = useColumnPreferences('mat3:material-list:columns:v1')
|
||||
const columnsOfGroup = (groupKey) => MATERIAL_COLUMNS.filter((c) => c.group === groupKey)
|
||||
const visibleColumnsOfGroup = (groupKey) => MATERIAL_COLUMNS.filter((c) => c.group === groupKey && isVisible(c.key))
|
||||
const hasVisibleInGroup = (groupKey) => visibleColumnsOfGroup(groupKey).length > 0
|
||||
const groupColumnKeys = (groupKey) => columnsOfGroup(groupKey).map((c) => c.key)
|
||||
|
||||
// tag 中文化映射
|
||||
const displayTag = (colKey, code) => {
|
||||
const choices = colKey === 'application_scene' ? applicationSceneChoices.value
|
||||
: colKey === 'advantage' ? advantageChoices.value
|
||||
: []
|
||||
const hit = choices.find((c) => c[0] === code)
|
||||
return hit ? hit[1] : code
|
||||
}
|
||||
|
||||
const emptyForm = () => ({
|
||||
name: '',
|
||||
major_category: '',
|
||||
|
|
@ -202,14 +355,18 @@ const emptyForm = () => ({
|
|||
|
||||
const form = ref(emptyForm())
|
||||
|
||||
const buildQueryParams = () => {
|
||||
const params = { page: pagination.page, page_size: pagination.pageSize }
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v !== '' && v !== null && v !== undefined) params[k] = v
|
||||
})
|
||||
return params
|
||||
}
|
||||
|
||||
const loadMaterials = async () => {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
const data = await fetchMaterials({
|
||||
...filters,
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize
|
||||
})
|
||||
const data = await fetchMaterials(buildQueryParams())
|
||||
materials.value = data.results || data
|
||||
pagination.total = data.count || materials.value.length
|
||||
} finally {
|
||||
|
|
@ -217,9 +374,40 @@ const loadMaterials = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
const loadStatusOptions = async () => {
|
||||
const triggerSearch = () => {
|
||||
pagination.page = 1
|
||||
loadMaterials()
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.name = ''
|
||||
filters.status = ''
|
||||
filters.material_subcategory = ''
|
||||
filters.brand = ''
|
||||
filters.major_category = ''
|
||||
filters.material_category = ''
|
||||
filters.stage = ''
|
||||
filters.importance_level = ''
|
||||
filters.factory = ''
|
||||
filters.factory__cooperation_mode = ''
|
||||
filters.landing_project = ''
|
||||
filters.cost_compare__gte = null
|
||||
filters.cost_compare__lte = null
|
||||
filters.score_level__gte = null
|
||||
filters.contact_person = ''
|
||||
filters.handler = ''
|
||||
triggerSearch()
|
||||
}
|
||||
|
||||
const loadChoices = async () => {
|
||||
const data = await fetchMaterialChoices()
|
||||
statusOptions.value = data.status
|
||||
statusOptions.value = data.status || []
|
||||
majorCategoryOptions.value = data.major_category || []
|
||||
stageOptions.value = data.stage || []
|
||||
importanceLevelOptions.value = data.importance_level || []
|
||||
cooperationModeOptions.value = data.cooperation_mode || []
|
||||
applicationSceneChoices.value = data.application_scene || []
|
||||
advantageChoices.value = data.advantage || []
|
||||
}
|
||||
|
||||
const loadFilterSubcategories = async () => {
|
||||
|
|
@ -236,6 +424,11 @@ const loadBrandFilterOptions = async () => {
|
|||
brandFilterOptions.value = data.results || data
|
||||
}
|
||||
|
||||
const loadFactoryFilterOptions = async () => {
|
||||
const data = await fetchFactories({ page_size: 100 })
|
||||
factoryFilterOptions.value = data.results || data
|
||||
}
|
||||
|
||||
const searchBrands = async (query) => {
|
||||
brandSearchLoading.value = true
|
||||
try {
|
||||
|
|
@ -246,6 +439,16 @@ const searchBrands = async (query) => {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
form.value = emptyForm()
|
||||
isEdit.value = false
|
||||
|
|
@ -403,18 +606,76 @@ const onPageSizeChange = (size) => {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatusOptions()
|
||||
loadChoices()
|
||||
loadFilterSubcategories()
|
||||
loadBrandFilterOptions()
|
||||
loadFactoryFilterOptions()
|
||||
loadMaterials()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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 10px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.advanced-badge :deep(.el-badge__content.is-dot) {
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.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;
|
||||
border-top: 1px solid #ebeef5;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.stars {
|
||||
color: #f7ba2a;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.import-dialog__text {
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
|
|
|
|||
Loading…
Reference in New Issue