Files
shanxia2.0/web-admin/src/views/system/category/index.vue
T
2026-05-25 17:43:49 +08:00

1600 lines
48 KiB
Vue
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.
<!-- 商品分类管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>分类管理</h1>
<p class="page-desc">维护商品分类树、参数关联与价格系数。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2>分类列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="分类名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.isActive" placeholder="请选择状态" clearable>
<el-option label="全部" value="" />
<el-option label="启用" :value="true" />
<el-option label="禁用" :value="false" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">支持按分类名称与状态筛选。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"><el-icon><RefreshLeft /></el-icon>重置</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch"><el-icon><Search /></el-icon>搜索</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增分类
</el-button>
<el-button class="btn-ghost" type="danger" :disabled="selectedRows.length === 0" @click="handleBatchDelete">
批量删除
</el-button>
<ArtExcelExport
:data="batchExportData"
:list-total="pagination.total"
:load-all-data="loadAllCategoriesForExport"
:filename="categoryExportFilename"
sheetName="分类数据"
:columns="categoryExportColumns"
>
批量导出
</ArtExcelExport>
<ArtExcelExport
:data="categoryImportTemplateData"
:filename="categoryImportTemplateFilename"
sheetName="分类导入模板"
:columns="categoryImportTemplateColumns"
:showSuccessMessage="false"
:showErrorMessage="false"
>
下载导入模板
</ArtExcelExport>
<ArtExcelImport
@import-success="handleCategoryImportSuccess"
@import-error="handleExcelImportError"
>
<el-button class="btn-ghost">批量导入</el-button>
</ArtExcelImport>
</div>
</div>
<div class="list-body list-wrap">
<el-table class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
@selection-change="handleCategoryTableSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="index => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="name"
label="分类名称"
min-width="150"
class-name="col-category-name"
align="center"
header-align="center"
>
<template #default="{ row }">
<div class="category-name-cell" :class="{ 'is-child': row.parentId }">
<span class="category-name-text">{{ row.name }}</span>
<el-tag v-if="!row.parentId && row.children?.length" type="info" size="small" class="ml-2">父级</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="图片" min-width="80" align="center" header-align="center">
<template #default="{ row }">
<el-image
v-if="row.imageUrl"
:src="row.imageUrl"
fit="cover"
style="width: 40px; height: 40px; border-radius: 6px"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="parameterCount" label="参数数量" min-width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.parameterCount > 0" type="primary" size="small">{{ row.parameterCount }}</el-tag>
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
<el-table-column label="价格系数" min-width="110" align="center">
<template #default="{ row }">
<span>{{ formatPriceCoefficient(row.priceCoefficient) }}</span>
</template>
</el-table-column>
<el-table-column prop="isActive" label="状态" min-width="80" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'info'" size="small">
{{ row.isActive ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createAt" label="创建时间" min-width="180" align="center" header-align="center" />
<el-table-column label="操作" min-width="400" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button class="btn-op-item primary" @click="handleAddChild(row)" v-if="!row.parentId">
新增子分类
</el-button>
<el-button class="btn-op-item" :class="{ primary: row.parentId }" @click="handleEdit(row)">编辑</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<span class="total-count">共 {{ pagination.total }} 条数据</span>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="900px"
:close-on-click-modal="false"
@close="handleDialogClose"
>
<div class="category-form-content">
<!-- 基本信息区域 -->
<div class="form-section">
<div class="section-title">基本信息</div>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="category-dialog-form"
>
<div class="form-row">
<el-form-item label="上级分类" class="flex-1">
<el-select v-model="formData.parentId" placeholder="请选择上级分类(空为顶级)" clearable>
<el-option v-for="cat in parentCategoryList" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</el-form-item>
<el-form-item label="分类名称" prop="name" class="flex-1">
<el-input v-model="formData.name" placeholder="请输入分类名称" />
</el-form-item>
</div>
<div class="form-row">
<el-form-item label="价格系数" prop="priceCoefficient" class="flex-1">
<div class="price-coefficient-field">
<el-input
type="number"
step="any"
v-model="formData.priceCoefficient"
:min="0"
:max="100"
controls-position="right"
class="price-coefficient-input"
/>
<span class="price-coefficient-hint">填写数字表示对应百分比,例如 5 表示 5%</span>
</div>
</el-form-item>
<el-form-item label="分类图片" class="flex-1">
<div class="category-image-row">
<el-image
v-if="formData.imageUrl"
:src="formData.imageUrl"
fit="cover"
style="width: 80px; height: 80px; border-radius: 8px"
/>
<div v-else class="category-image-placeholder">暂无</div>
<el-upload
ref="categoryImageUploadRef"
accept="image/*"
:show-file-list="false"
:auto-upload="false"
:limit="1"
:on-change="handleCategoryImageChange"
:on-exceed="handleCategoryImageExceed"
>
<el-button type="primary" plain>上传图片</el-button>
</el-upload>
</div>
</el-form-item>
</div>
<div class="form-row">
<el-form-item label="状态" v-if="isEdit" class="flex-1">
<el-switch v-model="formData.isActive" />
</el-form-item>
</div>
</el-form>
</div>
<!-- 参数配置区域 -->
<div class="form-section parameter-section">
<div class="section-header">
<div class="section-title">参数配置</div>
<div class="section-actions">
<el-button type="primary" plain @click="handleSelectParameters">
<el-icon><Plus /></el-icon>
添加参数
</el-button>
</div>
</div>
<div class="section-tips">两列展示,支持设置是否必填/默认值;拖拽参数项可调整排序</div>
<div v-if="associatedParameters.length > 0" class="parameter-list">
<el-form label-width="0px">
<draggable
v-model="associatedParameters"
class="parameter-grid"
ghost-class="parameter-ghost"
handle=".parameter-drag-handle"
@end="handleParameterDragEnd"
>
<div
v-for="(param, index) in associatedParameters"
:key="param.parameterId"
class="parameter-item"
>
<div class="parameter-item-header">
<div class="parameter-item-left">
<el-icon class="parameter-drag-handle"><Rank /></el-icon>
<span class="parameter-order">{{ index + 1 }}</span>
<span class="parameter-name">{{ param.parameterName }}</span>
<span class="parameter-code" v-if="param.parameterCode">({{ param.parameterCode }})</span>
</div>
<div class="parameter-item-right">
<el-button link type="danger" @click="handleRemoveParameter(index)">删除</el-button>
</div>
</div>
<div class="parameter-item-body">
<div class="parameter-type-row">
<div class="parameter-type-left">
<el-tag v-if="param.paramType === 'text'" type="info" size="small">文本</el-tag>
<el-tag v-else-if="param.paramType === 'select'" type="primary" size="small">下拉</el-tag>
<el-tag v-else-if="param.paramType === 'multi_select'" type="success" size="small">多选</el-tag>
</div>
<div class="parameter-type-right">
<el-checkbox v-model="param.isRequired" :true-value="1" :false-value="0">
是否必填
</el-checkbox>
</div>
</div>
<div class="parameter-default-row">
<div class="parameter-default-control">
<el-form-item :required="param.isRequired === 1" class="parameter-form-item">
<el-input
v-if="param.paramType === 'text'"
v-model="param.defaultValue"
placeholder="默认值"
/>
<el-select
v-else-if="param.paramType === 'select'"
v-model="param.defaultValue"
placeholder="请选择"
clearable
>
<el-option
v-for="opt in parseOptions(param.options)"
:key="opt"
:label="opt"
:value="opt"
/>
</el-select>
<el-checkbox-group
v-else-if="param.paramType === 'multi_select'"
v-model="param.selectedOptions"
@change="val => (param.defaultValue = Array.isArray(val) ? val.join(',') : '')"
>
<el-checkbox
v-for="opt in parseOptions(param.options)"
:key="opt"
:label="opt"
>
{{ opt }}
</el-checkbox>
</el-checkbox-group>
<span v-else class="text-gray">-</span>
</el-form-item>
</div>
</div>
</div>
</div>
</draggable>
</el-form>
</div>
<div v-else class="parameter-empty">
<el-empty description="暂无关联参数,点击上方按钮添加" :image-size="60" />
</div>
</div>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<!-- 选择参数弹窗 -->
<el-dialog v-model="selectParameterVisible" title="选择参数" width="900px">
<div class="parameter-select-wrapper">
<div class="parameter-select-filter">
<el-input
v-model="parameterFilter"
placeholder="搜索参数名称或编码"
clearable
@input="handleParameterFilter"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="parameter-select-list">
<el-table
ref="parameterTableRef"
:data="filteredAvailableParameters"
stripe
row-key="id"
height="400"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
>
<el-table-column type="selection" min-width="50" fixed />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
fixed
:index="index => index + 1"
/>
<el-table-column prop="name" label="参数名称" min-width="120" />
<el-table-column prop="code" label="参数编码" min-width="100" />
<el-table-column label="参数类型" min-width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.paramType === 'text'" type="info">文本</el-tag>
<el-tag v-else-if="row.paramType === 'select'" type="primary">下拉</el-tag>
<el-tag v-else-if="row.paramType === 'multi_select'" type="success">多选</el-tag>
</template>
</el-table-column>
<el-table-column
prop="options"
label="可选值"
min-width="300"
class-name="col-parameter-options"
>
<template #default="{ row }">
<div v-if="row.options" class="parameter-options-content">
{{ formatOptions(row.options) }}
</div>
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<template #footer>
<el-button class="btn-ghost" @click="selectParameterVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="confirmSelectParameters">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.name || '分类详情'"
:icon="Files"
:meta="`${viewRow?.isActive ? '启用' : '禁用'}${viewRow?.createAt ? ' · ' + viewRow.createAt : ''}`"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
/* eslint-disable vue/no-v-model-argument */
import { ref, reactive, onMounted, nextTick, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import { Plus, Search, Rank, Close, Check, View, Files, RefreshLeft } from '@element-plus/icons-vue'
import { VueDraggable as draggable } from 'vue-draggable-plus'
import type { UploadFile } from 'element-plus'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import {
fetchCategoryList,
fetchCreateCategory,
fetchUpdateCategory,
fetchDeleteCategory,
fetchUploadCategoryImage,
fetchProductParameterSimple,
fetchCategoryParameters,
fetchUpdateCategoryParameters
} from '../../../api/admin'
import { fetchAllPagedRecords } from '../../../utils/fetch-paged-list'
defineOptions({ name: 'ProductCategory' })
const searchForm = reactive<{ keyword: string; isActive: boolean | '' }>({ keyword: '', isActive: '' })
const tableData = ref<any[]>([])
const selectedRows = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 100, total: 0 })
const categoryList = ref<any[]>([])
const parentCategoryList = ref<any[]>([])
// ---- 查看弹窗 ----
const viewVisible = ref(false)
const viewRow = ref<any>(null)
const viewSections = computed(() => {
const row = viewRow.value
if (!row) return []
return [
{
title: '基本信息',
fields: [
{ label: '分类名称', value: row.name },
{ label: '上级分类', value: row.parentId ? parentCategoryList.value.find((c: any) => c.id === row.parentId)?.name || row.parentId : '无' },
{ label: '价格系数', value: formatPriceCoefficient(row.priceCoefficient) },
{ label: '状态', value: row.isActive ? '启用' : '禁用', tagType: row.isActive ? 'success' : 'info' },
{ label: '图片', value: row.imageUrl || '无' },
{ label: '创建时间', value: row.createAt }
]
}
]
})
function handleView(row: any) {
viewRow.value = row
viewVisible.value = true
}
const handleCategoryTableSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
const categoryExportFilename = 'base_category_batch_export'
const categoryImportTemplateFilename = 'base_category_import_template'
const categoryExportColumns = {
id: { title: 'id' },
name: { title: '分类名称' },
parentId: { title: '父分类ID' },
parentName: { title: '父分类名称' },
sortOrder: { title: '排序' },
priceCoefficient: { title: '价格系数(%)' },
isActive: { title: '状态' },
imageUrl: { title: '图片URL' }
} as const
const categoryImportTemplateColumns = {
id: { title: 'id' },
name: { title: '分类名称' },
parentId: { title: '父分类ID' },
sortOrder: { title: '排序' },
priceCoefficient: { title: '价格系数(%)' },
isActive: { title: '状态' },
imageUrl: { title: '图片URL' }
} as const
const categoryImportTemplateData = [
{
id: '',
name: '',
parentId: '',
sortOrder: 0,
priceCoefficient: 0,
isActive: true,
imageUrl: ''
}
]
const mapCategoryExportRow = (row: any, parentNameMap?: Map<string, string>) => {
const parentName =
row.parentName ??
(row.parentId && parentNameMap ? parentNameMap.get(row.parentId) : '') ??
''
return {
id: row.id,
name: row.name,
parentId: row.parentId,
parentName,
sortOrder: row.sortOrder,
priceCoefficient: row.priceCoefficient ?? 0,
isActive: row.isActive,
imageUrl: row.imageUrl
}
}
const formatPriceCoefficient = (v: unknown) => {
const n = Number(v)
if (!Number.isFinite(n)) return '0%'
return `${n}%`
}
const batchExportData = computed(() => {
const sel = selectedRows.value || []
if (sel.length === 0) return []
return sel.map(row => mapCategoryExportRow(row))
})
const loadAllCategoriesForExport = async () => {
const flat = await fetchAllPagedRecords((page, pageSize) =>
fetchCategoryList({
page,
pageSize,
keyword: searchForm.keyword || undefined,
isActive: searchForm.isActive === '' ? undefined : searchForm.isActive
})
)
const idToName = new Map(flat.map((c: any) => [c.id, c.name]))
return flat.map((row: any) => mapCategoryExportRow(row, idToName))
}
const handleBatchDelete = async () => {
if (selectedRows.value.length === 0) return
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedRows.value.length} 条分类吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const collectIds = (node: any, out: Set<string>) => {
const id = String(node?.id || '').trim()
if (id) out.add(id)
const children = Array.isArray(node?.children) ? node.children : []
for (const c of children) collectIds(c, out)
}
const idSet = new Set<string>()
for (const row of selectedRows.value) collectIds(row, idSet)
for (const id of idSet) {
await fetchDeleteCategory(id)
}
ElMessage.success(`批量删除成功,共删除 ${idSet.size} 条`)
selectedRows.value = []
loadData()
loadCategories()
} catch (error: any) {
if (error !== 'cancel') console.error('批量删除失败:', error)
}
}
// ======================
// Excel 导入
// ======================
const importLoading = ref(false)
const parseNumber = (v: any, defaultValue: number) => {
if (v === null || v === undefined || v === '') return defaultValue
const n = Number(v)
return Number.isFinite(n) ? n : defaultValue
}
const parseBool = (v: any, defaultValue: boolean) => {
if (v === null || v === undefined || v === '') return defaultValue
if (typeof v === 'boolean') return v
const s = String(v).trim().toLowerCase()
if (['1', 'true', 'yes', '启用', 'enabled'].includes(s)) return true
if (['0', 'false', 'no', '禁用', 'disabled'].includes(s)) return false
return defaultValue
}
const getExcelValue = (row: any, keys: string[]) => {
for (const k of keys) {
const v = row?.[k]
if (v !== undefined && v !== null && String(v).trim() !== '') return v
}
return undefined
}
const handleExcelImportError = (error: Error) => {
ElMessage.error(`导入失败:${error.message}`)
}
const handleCategoryImportSuccess = async (rows: Array<Record<string, unknown>>) => {
if (importLoading.value) return
importLoading.value = true
try {
if (!Array.isArray(rows) || rows.length === 0) {
ElMessage.warning('没有可导入的数据')
return
}
let successCount = 0
let failCount = 0
for (const raw of rows) {
const id = String(getExcelValue(raw, ['id', 'ID']) ?? '').trim()
const name = String(getExcelValue(raw, ['name', '分类名称']) ?? '').trim()
const parentId = String(getExcelValue(raw, ['parentId', '父分类ID']) ?? '').trim()
const sortOrder = parseNumber(getExcelValue(raw, ['sortOrder', '排序', 'sort_order']) as any, 0)
const priceCoefficient = parseNumber(
getExcelValue(raw, ['priceCoefficient', '价格系数(%)', '价格系数', 'price_coefficient']) as any,
0
)
const isActive = parseBool(getExcelValue(raw, ['isActive', '状态', 'is_active']) as any, true)
const imageUrl = String(getExcelValue(raw, ['imageUrl', '图片URL']) ?? '').trim()
if (!name) {
failCount++
continue
}
const payload: any = {
name,
parentId: parentId || undefined,
sortOrder,
priceCoefficient: Math.min(100, Math.max(0, priceCoefficient)),
isActive,
imageUrl: imageUrl || undefined
}
if (id) {
await fetchUpdateCategory(id, payload)
} else {
await fetchCreateCategory(payload)
}
successCount++
}
ElMessage.success(`导入完成:成功 ${successCount} 条,失败 ${failCount} 条`)
selectedRows.value = []
loadData()
loadCategories()
} catch (error: any) {
console.error('导入分类失败:', error)
ElMessage.error(`导入分类失败:${error?.message || error}`)
} finally {
importLoading.value = false
}
}
const dialogVisible = ref(false)
const dialogTitle = ref('新增分类')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const categoryImageUploadRef = ref<{ clearFiles: () => void } | null>(null)
const formData = reactive({
id: '',
name: '',
parentId: '',
sortOrder: 0,
priceCoefficient: 0,
isActive: true,
imageUrl: ''
})
const formRules = {
name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }],
priceCoefficient: [
{
validator: (_rule: unknown, value: unknown, callback: (e?: Error) => void) => {
const n = Number(value)
if (value === null || value === undefined || value === '' || Number.isNaN(n)) {
callback()
return
}
if (n < 0 || n > 100) {
callback(new Error('价格系数需在 0~100 之间(表示 0%~100%)'))
return
}
callback()
},
trigger: 'change'
}
]
}
// 分类参数相关
const associatedParameters = ref<any[]>([])
const selectParameterVisible = ref(false)
const availableParameters = ref<any[]>([])
const selectedParameterIds = ref<string[]>([])
const parameterTableRef = ref()
// 缓存参数列表,避免重复请求
let cachedParameters: any[] = []
let lastLoadTime = 0
const CACHE_DURATION = 30000 // 30秒缓存
// 参数过滤相关
const parameterFilter = ref('')
const filteredAvailableParameters = ref<any[]>([])
// 格式化可选值
const formatOptions = (options: string): string => {
if (!options) return '-'
try {
const arr = JSON.parse(options)
return Array.isArray(arr) ? arr.join(', ') : options
} catch {
return options
}
}
// 解析可选值
const parseOptions = (options: string): string[] => {
if (!options) return []
try {
const arr = JSON.parse(options)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
const parseMultiSelectDefaultValue = (value: any): string[] => {
if (value === undefined || value === null || value === '') return []
const str = String(value)
// 后端可能返回 "a,b,c" 或 '["a","b"]' 两种格式
if (str.trim().startsWith('[')) {
try {
const arr = JSON.parse(str)
return Array.isArray(arr) ? arr.map(String) : []
} catch {
// fallthrough
}
}
return str
.split(',')
.map(s => s.trim())
.filter(Boolean)
}
// 参数过滤搜索
const handleParameterFilter = () => {
const keyword = parameterFilter.value.trim().toLowerCase()
if (!keyword) {
filteredAvailableParameters.value = [...cachedParameters]
} else {
filteredAvailableParameters.value = cachedParameters.filter(p =>
p.name.toLowerCase().includes(keyword) || p.code.toLowerCase().includes(keyword)
)
}
}
const uploadCategoryImageFile = async (file: File) => {
try {
ElMessage.info('图片正在上传...')
const fd = new FormData()
fd.append('folder', 'categories')
fd.append('file', file)
const res: any = await fetchUploadCategoryImage(fd)
formData.imageUrl = res?.url || ''
ElMessage.success('图片上传成功')
} catch (e) {
console.error('分类图片上传失败:', e)
ElMessage.error('图片上传失败')
} finally {
categoryImageUploadRef.value?.clearFiles()
}
}
// 上传分类图片(limit=1 时第二次选文件会走 on-exceed,不会进 on-change)
const handleCategoryImageChange = async (uploadFile: UploadFile) => {
const file: any = (uploadFile as any).raw
if (!file) return
const status = (uploadFile as any).status
if (status && status !== 'ready') return
await uploadCategoryImageFile(file)
}
const handleCategoryImageExceed = (files: File[]) => {
const file = files[0]
if (file) uploadCategoryImageFile(file)
}
const loadData = async () => {
selectedRows.value = []
loading.value = true
try {
const res = await fetchCategoryList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
isActive: searchForm.isActive === '' ? undefined : searchForm.isActive
})
// 处理参数数量
const listWithParamCount = await Promise.all(
(res.list || []).map(async (item: any) => {
try {
const paramRes: any = await fetchCategoryParameters(item.id)
return { ...item, parameterCount: paramRes.total || 0 }
} catch {
return { ...item, parameterCount: 0 }
}
})
)
tableData.value = buildTree(listWithParamCount)
pagination.total = res.total
} catch (error) {
console.error('加载数据失败:', error)
} finally {
loading.value = false
}
}
// 将平铺数据转换为树形结构
const buildTree = (list: any[]) => {
const map = new Map<string, any>()
const roots: any[] = []
list.forEach(item => {
map.set(item.id, { ...item, children: [] })
})
list.forEach(item => {
const node = map.get(item.id)!
if (item.parentId && map.has(item.parentId)) {
map.get(item.parentId)!.children.push(node)
} else {
roots.push(node)
}
})
return roots
}
const loadCategories = async () => {
try {
const all = await fetchAllPagedRecords((page, pageSize) =>
fetchCategoryList({ page, pageSize })
)
categoryList.value = all
parentCategoryList.value = all.filter((cat: any) => !cat.parentId)
} catch (error) {
console.error('加载分类失败:', error)
}
}
// 打开分类弹窗时刷新上级分类选项(与其它菜单中新增分类后保持一致)
watch(dialogVisible, (visible) => {
if (visible) void loadCategories()
})
/** 与指定上级同级的分类中,最大 sortOrder + 1;该层级尚无数据时为 0 */
const getNextSortOrderForParent = (parentId: string | undefined | null) => {
const list = categoryList.value || []
const siblings = list.filter((cat: any) => {
if (!parentId) return !cat.parentId
return cat.parentId === parentId
})
if (siblings.length === 0) return 0
const max = Math.max(
...siblings.map((cat: any) => {
const n = Number(cat.sortOrder)
return Number.isFinite(n) ? n : 0
})
)
return max + 1
}
// 加载可用的参数模板列表(带缓存)
const loadAvailableParameters = async () => {
try {
// 如果缓存有效,直接使用缓存
if (cachedParameters.length > 0 && Date.now() - lastLoadTime < CACHE_DURATION) {
availableParameters.value = cachedParameters
filteredAvailableParameters.value = cachedParameters
return
}
const res: any = await fetchProductParameterSimple()
cachedParameters = res.list || []
lastLoadTime = Date.now()
availableParameters.value = cachedParameters
filteredAvailableParameters.value = cachedParameters
} catch (error) {
console.error('加载参数模板失败:', error)
}
}
// 加载分类关联的参数
const loadCategoryParameters = async (categoryId: string) => {
try {
const res: any = await fetchCategoryParameters(categoryId)
associatedParameters.value = (res.list || []).map((p: any) => {
const rawParamType = p.paramType ?? p.param_type
const paramType = rawParamType === 'multiSelect' ? 'multi_select' : rawParamType
const isMulti = paramType === 'multi_select'
const selectedOptions = isMulti ? parseMultiSelectDefaultValue(p.defaultValue ?? p.default_value) : []
return {
...p,
paramType,
// multi_select:defaultValue 以 "a,b,c" 或数组格式返回,这里统一转成数组供 checkbox-group 使用
selectedOptions,
// 保证提交给后端的是字符串(逗号分隔)
defaultValue: isMulti ? selectedOptions.join(',') : p.defaultValue ?? p.default_value ?? ''
}
})
} catch (error) {
console.error('加载分类参数失败:', error)
associatedParameters.value = []
}
}
// 打开选择参数弹窗
const handleSelectParameters = async () => {
parameterFilter.value = ''
await loadAvailableParameters()
// 从已关联的参数中提取ID (associatedParameters 使用 parameterId 字段)
selectedParameterIds.value = associatedParameters.value.map(p => p.parameterId)
console.log('[DEBUG] 已关联的参数ID列表:', selectedParameterIds.value)
selectParameterVisible.value = true
}
// 点击行选中/取消选中
const handleRowClick = (row: any) => {
console.log('[DEBUG] handleRowClick - 点击行:', row.id, row.name)
const table = parameterTableRef.value
if (!table) return
// 直接使用 toggleRowSelection 切换状态,handleSelectionChange 会自动同步 selectedParameterIds
const isSelected = selectedParameterIds.value.includes(row.id)
if (isSelected) {
table.toggleRowSelection(row, false)
} else {
table.toggleRowSelection(row, true)
}
}
// 更新表格选中状态
const updateTableSelection = () => {
const table = parameterTableRef.value
if (!table) {
console.log('[DEBUG] updateTableSelection - 表格未准备好,延迟重试')
setTimeout(() => updateTableSelection(), 100)
return
}
const idsToSelect = selectedParameterIds.value
console.log('[DEBUG] updateTableSelection - 需要选中的ID:', idsToSelect)
// 清空选中状态
table.clearSelection()
// 等待清空操作完成
setTimeout(() => {
// 使用过滤后的表格数据
const tableData = filteredAvailableParameters.value
console.log('[DEBUG] updateTableSelection - 表格数据条数:', tableData.length)
// 根据ID查找并选中对应行
idsToSelect.forEach(id => {
const row = tableData.find(p => p.id === id)
if (row) {
console.log('[DEBUG] updateTableSelection - 勾选:', id, row.name)
table.toggleRowSelection(row, true)
} else {
console.log('[DEBUG] updateTableSelection - 未在表格数据中找到:', id)
}
})
console.log('[DEBUG] updateTableSelection - 完成')
}, 100)
}
// 监听弹窗打开,数据加载完成后更新选中状态
watch(
() => selectParameterVisible.value,
(val) => {
if (val && filteredAvailableParameters.value.length > 0) {
// 弹窗打开后,等待 DOM 渲染完成
nextTick(() => {
setTimeout(() => updateTableSelection(), 100)
})
}
}
)
// 监听过滤后的参数列表变化,更新表格选中状态
watch(
() => filteredAvailableParameters.value.length,
() => {
if (selectParameterVisible.value && selectedParameterIds.value.length > 0) {
nextTick(() => updateTableSelection())
}
}
)
// 处理参数选择变化(由复选框触发)
const handleSelectionChange = (rows: any[]) => {
selectedParameterIds.value = rows.map(row => row?.id)
}
// 确认选择参数
const confirmSelectParameters = () => {
const selected = availableParameters.value.filter(p =>
selectedParameterIds.value.includes(p.id)
)
// 合并已有关联参数和新选择的参数
const existingIds = new Set(associatedParameters.value.map(p => p.parameterId))
const startOrder = associatedParameters.value.length + 1
let orderIndex = startOrder
const newParams = selected
.filter(p => !existingIds.has(p.id))
.map(p => ({
parameterId: p.id,
parameterName: p.name,
parameterCode: p.code,
paramType: p.paramType === 'multiSelect' ? 'multi_select' : p.paramType,
options: p.options,
placeholder: p.placeholder,
isRequired: 0,
defaultValue: '',
selectedOptions: p.paramType === 'multi_select' ? [] : [],
sortOrder: orderIndex++
}))
associatedParameters.value = [
...associatedParameters.value,
...newParams
]
selectParameterVisible.value = false
// 清空表格选中状态
parameterTableRef.value?.clearSelection()
ElMessage.success(`已添加 ${newParams.length} 个参数`)
}
const handleSearch = () => { pagination.page = 1; loadData() }
// 移除参数
const handleRemoveParameter = (index: number) => {
associatedParameters.value.splice(index, 1)
handleParameterDragEnd()
}
// 拖动结束后更新排序值
const handleParameterDragEnd = () => {
associatedParameters.value.forEach((item, index) => {
item.sortOrder = index + 1
})
}
const handleReset = () => { Object.assign(searchForm, { keyword: '', isActive: '' }); loadData() }
const handleAdd = async () => {
dialogTitle.value = '新增分类'
isEdit.value = false
await loadCategories()
const nextSort = getNextSortOrderForParent(undefined)
Object.assign(formData, {
id: '',
name: '',
parentId: '',
sortOrder: nextSort,
priceCoefficient: 0,
isActive: true,
imageUrl: ''
})
associatedParameters.value = []
dialogVisible.value = true
}
const handleAddChild = async (row: any) => {
dialogTitle.value = '新增子分类'
isEdit.value = false
await loadCategories()
const nextSort = getNextSortOrderForParent(row.id)
Object.assign(formData, {
id: '',
name: '',
parentId: row.id,
sortOrder: nextSort,
priceCoefficient: 0,
isActive: true,
imageUrl: ''
})
associatedParameters.value = []
dialogVisible.value = true
}
const handleEdit = async (row: any) => {
dialogTitle.value = '编辑分类'
isEdit.value = true
Object.assign(formData, {
id: row.id,
name: row.name,
parentId: row.parentId || '',
sortOrder: row.sortOrder || 0,
priceCoefficient: Number(row.priceCoefficient) || 0,
isActive: row.isActive,
imageUrl: row.imageUrl || ''
})
// 加载分类关联的参数
await loadCategoryParameters(row.id)
dialogVisible.value = true
}
const collectChildCategoryIds = (node: any): string[] => {
const children = Array.isArray(node?.children) ? node.children : []
const ids: string[] = []
children.forEach((child: any) => {
ids.push(...collectChildCategoryIds(child))
if (child?.id) ids.push(child.id)
})
return ids
}
const handleDelete = async (row: any) => {
try {
const childIds = collectChildCategoryIds(row)
const totalCount = childIds.length + 1
const confirmText = childIds.length
? `该父分类下有 ${childIds.length} 个子分类,删除父分类将一并删除全部子分类。确定继续吗?`
: '确定要删除该分类吗?'
await ElMessageBox.confirm(confirmText, '提示', { type: 'warning' })
await withFullscreenLoading(async () => {
for (const id of [...childIds, row.id]) {
await fetchDeleteCategory(id)
}
ElMessage.success(`删除成功,共删除 ${totalCount} 条分类`)
loadData()
}, fullscreenLoadingText.delete)
} catch (error: any) {
if (error !== 'cancel') console.error('删除失败:', error)
}
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
submitLoading.value = true
await withFullscreenLoading(async () => {
let categoryId = formData.id
if (isEdit.value) {
await fetchUpdateCategory(formData.id, {
name: formData.name,
parentId: formData.parentId || undefined,
sortOrder: formData.sortOrder,
priceCoefficient: formData.priceCoefficient,
isActive: formData.isActive,
imageUrl: formData.imageUrl || undefined,
})
} else {
const created: any = await fetchCreateCategory({
name: formData.name,
parentId: formData.parentId || undefined,
sortOrder: formData.sortOrder,
priceCoefficient: formData.priceCoefficient,
imageUrl: formData.imageUrl || undefined,
})
categoryId = created?.id || ''
}
// 保存分类参数关联(新增需在拿到分类 id 后调用,与编辑共用)
if (categoryId) {
const paramItems = associatedParameters.value.map(p => ({
categoryId,
parameterId: p.parameterId,
isRequired: p.isRequired,
defaultValue: p.defaultValue || '',
sortOrder: p.sortOrder
}))
await fetchUpdateCategoryParameters(categoryId, paramItems)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
loadData()
loadCategories()
}, fullscreenLoadingText.save)
} catch (error) {
console.error('提交失败:', error)
} finally {
submitLoading.value = false
}
}
/** 关闭时显式清空模型;勿依赖 resetFields()(会回到「首次挂载」快照,易导致先编辑再新增时回填旧数据) */
const handleDialogClose = () => {
associatedParameters.value = []
Object.assign(formData, {
id: '',
name: '',
parentId: '',
sortOrder: 0,
priceCoefficient: 0,
isActive: true,
imageUrl: ''
})
formRef.value?.clearValidate()
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => { loadData(); loadCategories() })
</script>
<style lang="scss" scoped>
.list-toolbar {
border-bottom: none !important;
}
.pagination.list-pager-bar {
border-top: none !important;
}
.category-image-row { display: flex; gap: 12px; align-items: center; }
.category-image-placeholder {
width: 80px; height: 80px; border-radius: 8px;
background: #f5f5f5; display: flex; align-items: center; justify-content: center;
color: #999;
}
.parameter-config-section {
padding: 10px 0;
}
.category-form-content {
display: flex;
flex-direction: column;
gap: 20px;
max-height: 65vh;
overflow-y: auto;
}
.form-section {
background: #fafafa;
border-radius: 8px;
padding: 20px;
}
.form-section .section-title {
font-size: 15px;
font-weight: 500;
color: #303133;
margin-bottom: 16px;
padding-bottom: 12px;
padding-left: 10px;
border-bottom: 1px solid #ebeef5;
border-left: 3px solid #10b981;
}
.form-row {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 18px;
.flex-1 {
flex: 1;
}
}
.form-row:last-child {
margin-bottom: 0;
}
.parameter-section {
background: #fff;
}
.parameter-section .section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.parameter-section .section-title {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.section-actions {
display: flex;
align-items: center;
gap: 8px;
}
.section-tips {
font-size: 12px;
color: #909399;
margin-bottom: 16px;
}
.parameter-table {
margin-top: 10px;
}
.parameter-list {
:deep(.el-form-item) {
margin-bottom: 12px;
}
}
.parameter-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.parameter-ghost {
opacity: 0.5;
background: #e6f7ff;
border: 1px dashed #10b981;
}
.parameter-item {
width: calc(50% - 6px);
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 12px;
}
.parameter-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.parameter-item-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.parameter-item-right {
display: flex;
align-items: center;
gap: 8px;
}
.parameter-drag-handle {
cursor: move;
color: #909399;
}
.parameter-drag-handle:hover {
color: #059669;
}
.parameter-order {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: #f2f3f5;
color: #606266;
font-size: 12px;
}
.parameter-name {
font-weight: 500;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
}
.parameter-code {
color: #909399;
font-size: 12px;
flex-shrink: 0;
}
.parameter-form-item {
margin-bottom: 0;
}
.parameter-default-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.parameter-default-control {
flex: 1;
min-width: 0;
:deep(.el-input__wrapper),
:deep(.el-select__wrapper) {
width: 100%;
}
}
.parameter-type-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.parameter-type-left {
display: flex;
align-items: center;
gap: 8px;
}
.parameter-type-right {
flex-shrink: 0;
white-space: nowrap;
padding-top: 2px;
:deep(.el-checkbox__label) {
white-space: nowrap;
}
}
.parameter-empty {
padding: 20px 0;
}
.parameter-select-list {
border: 1px solid var(--mint-border);
border-radius: var(--mint-radius);
overflow: hidden;
}
/* 选择参数弹窗:可选值列多行完整展示 */
.parameter-select-list :deep(.col-parameter-options .cell) {
white-space: normal;
word-break: break-word;
line-height: 1.55;
padding-top: 12px;
padding-bottom: 12px;
vertical-align: top;
}
.parameter-select-list .parameter-options-content {
word-break: break-word;
}
.parameter-select-wrapper {
display: flex;
flex-direction: column;
}
.parameter-select-filter {
padding: 12px;
background: transparent;
}
.text-gray {
color: #909399;
}
/* 树形列样式 */
:deep(.col-category-name .cell) {
display: flex;
flex-wrap: nowrap;
align-items: center;
line-height: 1.4;
}
:deep(.col-category-name .el-table__expand-icon) {
flex-shrink: 0;
}
:deep(.col-category-name .el-table__indent) {
flex-shrink: 0;
}
.category-name-cell {
display: inline-flex;
align-items: center;
flex: 1;
min-width: 0;
flex-wrap: nowrap;
&.is-child {
padding-left: 8px;
}
:deep(.el-tag) {
flex-shrink: 0;
}
}
.category-name-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.total-count {
color: #909399;
font-size: 14px;
}
.ml-2 {
margin-left: 8px;
}
.price-coefficient-field {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.price-coefficient-input {
width: 160px;
}
.price-coefficient-hint {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
</style>