feat: product form and search UI for pinyin initials.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
5575b08437
commit
b151ac6499
5 files changed
+1457
-847
No files matched your search
@@ -16,6 +16,7 @@ export interface ProductAliasItem {
|
||||
export interface ProductFormData {
|
||||
id?: string
|
||||
productName: string
|
||||
namePinyinAbbr?: string
|
||||
productNo?: string
|
||||
categoryId?: string
|
||||
model?: string
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
placeholder?: string
|
||||
}>(),
|
||||
{
|
||||
placeholder: '请搜索并选择 CRM 产品'
|
||||
placeholder: '请搜索 CRM 产品(名称/拼音)'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑产品' : '新增产品'"
|
||||
width="960px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
modal-class="order-module-dialog-mask"
|
||||
style="backdrop-filter: none"
|
||||
:close-on-click-modal="false"
|
||||
@open="handleOpen"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
v-loading="dialogLoading"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="crm-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-3">
|
||||
<el-form-item label="产品编号">
|
||||
<el-input v-model="formData.productNo" placeholder="留空自动生成(如 SXWL000001)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品名称" prop="productName">
|
||||
<el-input v-model="formData.productName" placeholder="请输入产品名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="拼音首字母" class="form-col-span-2">
|
||||
<div class="pinyin-abbr-row">
|
||||
<el-input
|
||||
v-model="formData.namePinyinAbbr"
|
||||
placeholder="默认按名称自动生成,可手改"
|
||||
maxlength="100"
|
||||
clearable
|
||||
/>
|
||||
<el-button class="btn-ghost" @click="regeneratePinyinAbbr">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品分类">
|
||||
<el-tree-select
|
||||
v-model="formData.categoryId"
|
||||
:data="categoryOptions"
|
||||
:props="{ value: 'id', label: 'categoryName', children: 'children' }"
|
||||
placeholder="请选择产品分类"
|
||||
check-strictly
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="规格型号">
|
||||
<el-input v-model="formData.model" placeholder="请输入规格型号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="条码" prop="barcode">
|
||||
<el-input
|
||||
v-model="formData.barcode"
|
||||
:placeholder="isEdit ? '请输入条码' : '留空自动生成'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="税率(%)">
|
||||
<el-input-number
|
||||
v-model="formData.taxRate"
|
||||
:precision="0"
|
||||
:min="0"
|
||||
:max="100"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="初始库存">
|
||||
<el-input-number
|
||||
v-model="formData.stock"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="库存预警值">
|
||||
<el-input-number
|
||||
v-model="formData.lowStock"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in dictStore.getOptions('crm_product_status')"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否易制毒">
|
||||
<el-switch v-model="formData.isToxic" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否易制爆">
|
||||
<el-switch v-model="formData.isExplosive" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品图片" class="form-col-span-2">
|
||||
<AttachmentUploadField
|
||||
ref="productImagesUploadRef"
|
||||
v-model="formData.images"
|
||||
:limit="5"
|
||||
accept="image/*"
|
||||
folder="crm/product"
|
||||
hint="支持 JPG、PNG、WebP 等图片,最多 5 张"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品描述" style="grid-column: 1 / -1">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入产品描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" style="grid-column: 1 / -1">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="sub-card-section">
|
||||
<div class="sub-card-header">
|
||||
<span class="sub-card-title">品牌</span>
|
||||
<el-button type="primary" size="small" @click="handleAddBrand">
|
||||
<el-icon><Plus /></el-icon>新增品牌
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="formData.brands.length" class="sub-card-grid">
|
||||
<div v-for="(row, index) in formData.brands" :key="`brand-${index}`" class="sub-card">
|
||||
<el-input v-model="row.brandName" placeholder="品牌名称" class="sub-card-name" />
|
||||
<el-select v-model="row.status" placeholder="状态" class="sub-card-status">
|
||||
<el-option
|
||||
v-for="opt in dictStore.getOptions('crm_product_status')"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button link type="danger" class="sub-card-remove" @click="handleRemoveBrand(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="sub-card-empty">暂无品牌,点击上方新增</div>
|
||||
</div>
|
||||
|
||||
<div class="sub-card-section">
|
||||
<div class="sub-card-header">
|
||||
<span class="sub-card-title">别名</span>
|
||||
<el-button type="primary" size="small" @click="handleAddAlias">
|
||||
<el-icon><Plus /></el-icon>新增别名
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="formData.aliases.length" class="sub-card-grid">
|
||||
<div
|
||||
v-for="(row, index) in formData.aliases"
|
||||
:key="`alias-${index}`"
|
||||
class="sub-card sub-card--alias"
|
||||
>
|
||||
<el-input v-model="row.aliasName" placeholder="别名" class="sub-card-name" />
|
||||
<el-button link type="danger" class="sub-card-remove" @click="handleRemoveAlias(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="sub-card-empty">暂无别名,点击上方新增</div>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="visible = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitForm">
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close, Delete, Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
import {
|
||||
fetchCategoryTree,
|
||||
fetchCreateProduct,
|
||||
fetchProductDetail,
|
||||
fetchUpdateProduct,
|
||||
type ProductAliasItem,
|
||||
type ProductBrandItem
|
||||
} from '@/api/crm/product'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
|
||||
defineOptions({ name: 'ProductFormDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
/** 有值则为编辑,无值则为新增 */
|
||||
productId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
success: [product: Record<string, any>]
|
||||
}>()
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const isEdit = computed(() => !!props.productId)
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const productImagesUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
|
||||
const dialogLoading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const categoryOptions = ref<any[]>([])
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
productName: '',
|
||||
namePinyinAbbr: '',
|
||||
productNo: '',
|
||||
categoryId: '',
|
||||
model: '',
|
||||
barcode: '',
|
||||
stock: 0,
|
||||
lowStock: 10,
|
||||
taxRate: 13,
|
||||
status: 'on',
|
||||
isToxic: 0,
|
||||
isExplosive: 0,
|
||||
description: '',
|
||||
images: '',
|
||||
remark: '',
|
||||
brands: [] as ProductBrandItem[],
|
||||
aliases: [] as ProductAliasItem[]
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
productName: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
|
||||
barcode: [
|
||||
{
|
||||
validator: (_rule: any, value: string, callback: (err?: Error) => void) => {
|
||||
if (!value) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!/^\d+$/.test(value)) {
|
||||
callback(new Error('条码必须为数字'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resetFormData() {
|
||||
formData.id = ''
|
||||
formData.productName = ''
|
||||
formData.namePinyinAbbr = ''
|
||||
formData.productNo = ''
|
||||
formData.categoryId = ''
|
||||
formData.model = ''
|
||||
formData.barcode = ''
|
||||
formData.stock = 0
|
||||
formData.lowStock = 10
|
||||
formData.taxRate = 13
|
||||
formData.status = 'on'
|
||||
formData.isToxic = 0
|
||||
formData.isExplosive = 0
|
||||
formData.description = ''
|
||||
formData.images = ''
|
||||
formData.remark = ''
|
||||
formData.brands = []
|
||||
formData.aliases = []
|
||||
}
|
||||
|
||||
function regeneratePinyinAbbr() {
|
||||
formData.namePinyinAbbr = ''
|
||||
ElMessage.info('已清空,保存时将按产品名称重新生成拼音首字母')
|
||||
}
|
||||
|
||||
async function loadCategoryOptions() {
|
||||
try {
|
||||
const res = await fetchCategoryTree()
|
||||
categoryOptions.value = (res as any)?.data || res || []
|
||||
} catch {
|
||||
categoryOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpen() {
|
||||
resetFormData()
|
||||
await loadCategoryOptions()
|
||||
if (!props.productId) {
|
||||
return
|
||||
}
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const res = await fetchProductDetail({ id: String(props.productId) })
|
||||
const data = (res as any)?.data || res || {}
|
||||
Object.assign(formData, {
|
||||
id: data.id || '',
|
||||
productName: data.productName || '',
|
||||
namePinyinAbbr: data.namePinyinAbbr || '',
|
||||
productNo: data.productNo || '',
|
||||
categoryId: data.categoryId || '',
|
||||
model: data.model || '',
|
||||
barcode: data.barcode || '',
|
||||
stock: data.stock ?? 0,
|
||||
lowStock: data.lowStock ?? 10,
|
||||
taxRate: data.taxRate ?? 13,
|
||||
status: data.status || 'on',
|
||||
isToxic: data.isToxic ?? 0,
|
||||
isExplosive: data.isExplosive ?? 0,
|
||||
description: data.description || '',
|
||||
images: data.images || '',
|
||||
remark: data.remark || '',
|
||||
brands: (data.brands || []).map((item: ProductBrandItem) => ({
|
||||
id: item.id,
|
||||
brandName: item.brandName || '',
|
||||
status: item.status || 'on',
|
||||
orderNum: item.orderNum
|
||||
})),
|
||||
aliases: (data.aliases || []).map((item: ProductAliasItem) => ({
|
||||
id: item.id,
|
||||
aliasName: item.aliasName || '',
|
||||
orderNum: item.orderNum
|
||||
}))
|
||||
})
|
||||
} catch {
|
||||
ElMessage.error('加载产品详情失败')
|
||||
visible.value = false
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetFormData()
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
function handleAddBrand() {
|
||||
formData.brands.push({ brandName: '', status: 'on' })
|
||||
}
|
||||
|
||||
function handleRemoveBrand(index: number) {
|
||||
formData.brands.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleAddAlias() {
|
||||
formData.aliases.push({ aliasName: '' })
|
||||
}
|
||||
|
||||
function handleRemoveAlias(index: number) {
|
||||
formData.aliases.splice(index, 1)
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (productImagesUploadRef.value?.hasUploading?.()) {
|
||||
ElMessage.warning('附件上传中,请稍候')
|
||||
return
|
||||
}
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const data: Record<string, any> = {
|
||||
productName: formData.productName,
|
||||
// 显式传空串:后端按「重新生成」处理;有值则手改入库
|
||||
namePinyinAbbr: (formData.namePinyinAbbr || '').trim(),
|
||||
productNo: formData.productNo || undefined,
|
||||
categoryId: formData.categoryId || undefined,
|
||||
model: formData.model || undefined,
|
||||
barcode: formData.barcode || undefined,
|
||||
stock: formData.stock ?? 0,
|
||||
lowStock: formData.lowStock ?? 10,
|
||||
taxRate: formData.taxRate,
|
||||
status: formData.status,
|
||||
isToxic: formData.isToxic,
|
||||
isExplosive: formData.isExplosive,
|
||||
description: formData.description || undefined,
|
||||
images: formData.images || undefined,
|
||||
remark: formData.remark || undefined,
|
||||
brands: formData.brands
|
||||
.filter((item) => item.brandName?.trim())
|
||||
.map((item, index) => ({
|
||||
...(item.id ? { id: item.id } : {}),
|
||||
brandName: item.brandName.trim(),
|
||||
status: item.status || 'on',
|
||||
orderNum: item.orderNum ?? index + 1
|
||||
})),
|
||||
aliases: formData.aliases
|
||||
.filter((item) => item.aliasName?.trim())
|
||||
.map((item, index) => ({
|
||||
...(item.id ? { id: item.id } : {}),
|
||||
aliasName: item.aliasName.trim(),
|
||||
orderNum: item.orderNum ?? index + 1
|
||||
}))
|
||||
}
|
||||
let product: Record<string, any> = {}
|
||||
if (isEdit.value) {
|
||||
const res = await fetchUpdateProduct({ id: formData.id, ...data })
|
||||
product = (res as any)?.data || res || { id: formData.id, ...data }
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
const res = await fetchCreateProduct(data)
|
||||
product = (res as any)?.data || res || data
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
visible.value = false
|
||||
emit('success', product)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pinyin-abbr-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.el-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-card-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.sub-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sub-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text, #1e293b);
|
||||
}
|
||||
|
||||
.sub-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sub-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--mint-surface, #f8fafc);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sub-card-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sub-card-status {
|
||||
flex: 0 0 96px;
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.sub-card-remove {
|
||||
flex-shrink: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.sub-card-empty {
|
||||
padding: 16px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
text-align: center;
|
||||
background: var(--mint-surface, #f8fafc);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sub-card-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,508 @@
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="product-selector-root">
|
||||
<el-dialog
|
||||
title="选择产品"
|
||||
v-model="dialogVisible"
|
||||
width="80%"
|
||||
align-center
|
||||
append-to-body
|
||||
class="dialog-form-layout"
|
||||
modal-class="order-module-dialog-mask"
|
||||
style="backdrop-filter: none"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="panel dialog-list-panel">
|
||||
<div class="list-toolbar product-selector-toolbar">
|
||||
<span class="selected-hint">已选 {{ selectedProducts.length }} 项</span>
|
||||
<ListQuickSearchInput
|
||||
v-model="quickSearchKeyword"
|
||||
placeholder="产品名称/拼音首字母"
|
||||
@search="handleQuickSearch"
|
||||
@clear="handleQuickSearchClear"
|
||||
/>
|
||||
<el-button class="btn-main" type="primary" @click="handleAddProduct">
|
||||
<el-icon><Plus /></el-icon>新增
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
:disabled="selectedProducts.length !== 1"
|
||||
@click="handleToolbarEdit"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="list-body list-wrap dialog-list-body-only">
|
||||
<ListTable
|
||||
table-key="business-product-selector"
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="productList"
|
||||
class="list-table"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
:max-height="400"
|
||||
>
|
||||
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
min-width="60"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
label="产品编号"
|
||||
prop="productNo"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:show-overflow-tooltip="true"
|
||||
/>
|
||||
<el-table-column
|
||||
label="产品名称"
|
||||
prop="productName"
|
||||
min-width="200"
|
||||
align="left"
|
||||
header-align="left"
|
||||
class-name="col-name-wrap"
|
||||
label-class-name="col-name-wrap"
|
||||
/>
|
||||
<el-table-column
|
||||
label="品牌"
|
||||
min-width="140"
|
||||
align="left"
|
||||
header-align="left"
|
||||
class-name="col-name-wrap product-selector-wrap-col"
|
||||
label-class-name="col-name-wrap"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatProductBrands(row.brands) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="别名"
|
||||
min-width="140"
|
||||
align="left"
|
||||
header-align="left"
|
||||
class-name="col-name-wrap product-selector-wrap-col"
|
||||
label-class-name="col-name-wrap"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatProductAliases(row.aliases) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="产品分类"
|
||||
prop="categoryName"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
label="规格型号"
|
||||
prop="model"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:show-overflow-tooltip="true"
|
||||
/>
|
||||
<el-table-column
|
||||
label="计量单位"
|
||||
prop="unit"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<DictTag dict-type="crm_product_unit" :value="scope.row.unit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="税率(%)"
|
||||
prop="taxRate"
|
||||
min-width="90"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.taxRate || 0 }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="状态"
|
||||
prop="status"
|
||||
min-width="90"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="dictStore.getTagType('crm_product_status', scope.row.status)"
|
||||
size="small"
|
||||
>
|
||||
{{ dictStore.getLabel('crm_product_status', scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleRowEdit(row)">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
|
||||
<ListPagination
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNum"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
class="pagination list-pager-bar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left">已选产品 ({{ selectedProducts.length }})</el-divider>
|
||||
<div class="selected-products">
|
||||
<el-tag
|
||||
v-for="product in selectedProducts"
|
||||
:key="product.id"
|
||||
closable
|
||||
@close="handleRemoveSelection(product)"
|
||||
class="mr-2 mb-2"
|
||||
>
|
||||
{{ product.productName }}
|
||||
</el-tag>
|
||||
<span v-if="selectedProducts.length === 0" class="text-muted">暂无选择</span>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button class="btn-ghost" @click="handleClose">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
@click="handleConfirm"
|
||||
:disabled="selectedProducts.length === 0"
|
||||
>
|
||||
<el-icon><Check /></el-icon>确定 ({{ selectedProducts.length }})
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<ProductFormDialog
|
||||
v-model="formDialogVisible"
|
||||
:product-id="formProductId"
|
||||
@success="handleFormSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close, Edit, Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DictTag from '@/components/DictTag/index.vue'
|
||||
import ListPagination from '@/components/ListPagination/index.vue'
|
||||
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
|
||||
import ProductFormDialog from '@/components/business/product-form-dialog/index.vue'
|
||||
import {
|
||||
fetchCategoryTree,
|
||||
fetchProductList,
|
||||
type ProductAliasItem,
|
||||
type ProductBrandItem
|
||||
} from '@/api/crm/product'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
|
||||
defineOptions({ name: 'ProductSelector' })
|
||||
|
||||
interface ProductVO {
|
||||
id: string | number
|
||||
productNo?: string
|
||||
productName?: string
|
||||
categoryId?: string | number
|
||||
categoryName?: string
|
||||
model?: string
|
||||
unit?: string
|
||||
taxRate?: number
|
||||
status?: string
|
||||
brands?: ProductBrandItem[]
|
||||
aliases?: ProductAliasItem[]
|
||||
}
|
||||
|
||||
interface ProductCategoryVO {
|
||||
id: string | number
|
||||
categoryName: string
|
||||
children?: ProductCategoryVO[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
supplierId?: string | number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
confirm: [products: ProductVO[]]
|
||||
}>()
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const formDialogVisible = ref(false)
|
||||
const formProductId = ref<string | null>(null)
|
||||
const pendingSelectId = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
dialogVisible.value = val
|
||||
if (val) {
|
||||
quickSearchKeyword.value = ''
|
||||
queryParams.value.pageNum = 1
|
||||
getList()
|
||||
loadCategoryTree()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(dialogVisible, (val) => {
|
||||
emit('update:visible', val)
|
||||
})
|
||||
|
||||
const quickSearchKeyword = ref('')
|
||||
const queryParams = ref({
|
||||
pageNum: 1,
|
||||
pageSize: 20,
|
||||
status: 'on'
|
||||
})
|
||||
|
||||
const productList = ref<ProductVO[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const categoryTree = ref<any[]>([])
|
||||
const selectedProducts = ref<ProductVO[]>([])
|
||||
|
||||
const tableRef = ref<any>()
|
||||
|
||||
function formatProductBrands(brands?: ProductBrandItem[]) {
|
||||
if (!brands?.length) return '-'
|
||||
const names = brands.map((b) => b.brandName).filter(Boolean)
|
||||
return names.length ? names.join('、') : '-'
|
||||
}
|
||||
|
||||
function formatProductAliases(aliases?: ProductAliasItem[]) {
|
||||
if (!aliases?.length) return '-'
|
||||
const names = aliases.map((a) => a.aliasName).filter(Boolean)
|
||||
return names.length ? names.join('、') : '-'
|
||||
}
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const keyword = quickSearchKeyword.value?.trim()
|
||||
const res = await fetchProductList({
|
||||
page: queryParams.value.pageNum,
|
||||
pageSize: queryParams.value.pageSize,
|
||||
keyword: keyword || undefined,
|
||||
status: queryParams.value.status,
|
||||
supplierId:
|
||||
props.supplierId != null && props.supplierId !== '' ? String(props.supplierId) : undefined
|
||||
})
|
||||
const data = (res as any)?.data || res || {}
|
||||
productList.value = data.list || data.rows || []
|
||||
total.value = data.total ?? 0
|
||||
if (categoryTree.value.length > 0) {
|
||||
resolveCategoryNames()
|
||||
}
|
||||
await nextTick()
|
||||
await selectPendingRow()
|
||||
} catch (error) {
|
||||
console.error('获取产品列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadCategoryTree = async () => {
|
||||
try {
|
||||
const res = await fetchCategoryTree()
|
||||
const tree = (res as any)?.data || res || []
|
||||
categoryTree.value = convertTreeData(tree)
|
||||
resolveCategoryNames()
|
||||
} catch (error) {
|
||||
console.error('获取产品分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const convertTreeData = (data: ProductCategoryVO[]): any[] => {
|
||||
return data.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.categoryName,
|
||||
children: item.children ? convertTreeData(item.children) : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
const resolveCategoryName = (categoryId: any, tree: any[]): string => {
|
||||
for (const node of tree) {
|
||||
if (node.value === categoryId || node.id === categoryId) return node.label || ''
|
||||
if (node.children) {
|
||||
const found = resolveCategoryName(categoryId, node.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const resolveCategoryNames = () => {
|
||||
if (!categoryTree.value.length) return
|
||||
productList.value.forEach((product) => {
|
||||
if (!product.categoryName && product.categoryId != null) {
|
||||
product.categoryName = resolveCategoryName(product.categoryId, categoryTree.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function selectPendingRow() {
|
||||
const id = pendingSelectId.value
|
||||
if (!id) return
|
||||
pendingSelectId.value = null
|
||||
const row = productList.value.find((p) => String(p.id) === id)
|
||||
const table = tableRef.value
|
||||
if (!row || !table?.toggleRowSelection) return
|
||||
table.toggleRowSelection(row, true)
|
||||
}
|
||||
|
||||
function handleQuickSearch() {
|
||||
queryParams.value.pageNum = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
function handleQuickSearchClear() {
|
||||
quickSearchKeyword.value = ''
|
||||
queryParams.value.pageNum = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleSelectionChange = (selection: ProductVO[]) => {
|
||||
selectedProducts.value = selection
|
||||
}
|
||||
|
||||
const handleRemoveSelection = (product: ProductVO) => {
|
||||
const index = selectedProducts.value.findIndex((p) => p.id === product.id)
|
||||
if (index > -1) {
|
||||
selectedProducts.value.splice(index, 1)
|
||||
}
|
||||
const table = tableRef.value
|
||||
const row = productList.value.find((p) => String(p.id) === String(product.id))
|
||||
if (table?.toggleRowSelection && row) {
|
||||
table.toggleRowSelection(row, false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddProduct() {
|
||||
formProductId.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleToolbarEdit() {
|
||||
if (selectedProducts.value.length !== 1) {
|
||||
ElMessage.warning('请选择一条数据进行编辑')
|
||||
return
|
||||
}
|
||||
handleRowEdit(selectedProducts.value[0])
|
||||
}
|
||||
|
||||
function handleRowEdit(row: ProductVO) {
|
||||
formProductId.value = row?.id != null ? String(row.id) : null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleFormSuccess(product: Record<string, any>) {
|
||||
const wasCreate = !formProductId.value
|
||||
const id = product?.id != null ? String(product.id) : null
|
||||
if (wasCreate && id) {
|
||||
pendingSelectId.value = id
|
||||
} else if (id) {
|
||||
selectedProducts.value = selectedProducts.value.map((p) =>
|
||||
String(p.id) === id
|
||||
? { ...p, ...product, productName: product.productName || p.productName }
|
||||
: p
|
||||
)
|
||||
}
|
||||
await getList()
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm', selectedProducts.value)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
selectedProducts.value = []
|
||||
pendingSelectId.value = null
|
||||
quickSearchKeyword.value = ''
|
||||
formDialogVisible.value = false
|
||||
formProductId.value = null
|
||||
dialogVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.product-selector-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
:deep(.quick-search-input) {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-hint {
|
||||
margin-right: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.selected-products {
|
||||
min-height: 40px;
|
||||
padding: 10px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.product-selector-wrap-col .cell) {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user