1600 lines
48 KiB
Vue
1600 lines
48 KiB
Vue
<!-- 商品分类管理 -->
|
||
<!-- 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>
|