feat(crm): add product alias/brand manage dialog for inline master-data edits.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
4628738632
commit
0dbd2da703
1 file changed
+299
@@ -0,0 +1,299 @@
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
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"
|
||||
>
|
||||
<div v-loading="dialogLoading">
|
||||
<div v-if="props.mode === 'alias'" 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="aliases.length" class="sub-card-grid">
|
||||
<div
|
||||
v-for="(row, index) in 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>
|
||||
|
||||
<div v-else 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="brands.length" class="sub-card-grid">
|
||||
<div v-for="(row, index) in 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>
|
||||
<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 } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
fetchProductDetail,
|
||||
fetchUpdateProduct,
|
||||
type ProductAliasItem,
|
||||
type ProductBrandItem
|
||||
} from '@/api/crm/product'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
|
||||
defineOptions({ name: 'ProductAliasBrandDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
productId: string
|
||||
mode: 'alias' | 'brand'
|
||||
productName?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
saved: [product: Record<string, any>]
|
||||
}>()
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const dialogLoading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const loadedProductName = ref('')
|
||||
const aliases = ref<ProductAliasItem[]>([])
|
||||
const brands = ref<ProductBrandItem[]>([])
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
const name = props.productName || loadedProductName.value || ''
|
||||
const prefix = props.mode === 'alias' ? '维护别名' : '维护品牌'
|
||||
return name ? `${prefix} - ${name}` : prefix
|
||||
})
|
||||
|
||||
function resetLists() {
|
||||
aliases.value = []
|
||||
brands.value = []
|
||||
loadedProductName.value = ''
|
||||
}
|
||||
|
||||
async function handleOpen() {
|
||||
resetLists()
|
||||
if (!props.productId) {
|
||||
ElMessage.error('加载产品详情失败')
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const res = await fetchProductDetail({ id: String(props.productId) })
|
||||
const data = (res as any)?.data || res || {}
|
||||
loadedProductName.value = data.productName || ''
|
||||
aliases.value = (data.aliases || []).map((item: ProductAliasItem) => ({
|
||||
id: item.id,
|
||||
aliasName: item.aliasName || '',
|
||||
orderNum: item.orderNum
|
||||
}))
|
||||
brands.value = (data.brands || []).map((item: ProductBrandItem) => ({
|
||||
id: item.id,
|
||||
brandName: item.brandName || '',
|
||||
status: item.status || 'on',
|
||||
orderNum: item.orderNum
|
||||
}))
|
||||
} catch {
|
||||
ElMessage.error('加载产品详情失败')
|
||||
visible.value = false
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetLists()
|
||||
}
|
||||
|
||||
function handleAddAlias() {
|
||||
aliases.value.push({ aliasName: '' })
|
||||
}
|
||||
|
||||
function handleRemoveAlias(index: number) {
|
||||
aliases.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleAddBrand() {
|
||||
brands.value.push({ brandName: '', status: 'on' })
|
||||
}
|
||||
|
||||
function handleRemoveBrand(index: number) {
|
||||
brands.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function hasDuplicateNames(names: string[]) {
|
||||
const seen = new Set<string>()
|
||||
for (const name of names) {
|
||||
if (seen.has(name)) return true
|
||||
seen.add(name)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!props.productId) return
|
||||
|
||||
let payload: Record<string, any>
|
||||
if (props.mode === 'alias') {
|
||||
const nextAliases = aliases.value
|
||||
.filter((item) => item.aliasName?.trim())
|
||||
.map((item, index) => ({
|
||||
...(item.id ? { id: item.id } : {}),
|
||||
aliasName: item.aliasName.trim(),
|
||||
orderNum: item.orderNum ?? index + 1
|
||||
}))
|
||||
if (hasDuplicateNames(nextAliases.map((item) => item.aliasName))) {
|
||||
ElMessage.warning('别名不能重复')
|
||||
return
|
||||
}
|
||||
payload = { id: props.productId, aliases: nextAliases }
|
||||
} else {
|
||||
const nextBrands = brands.value
|
||||
.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
|
||||
}))
|
||||
if (hasDuplicateNames(nextBrands.map((item) => item.brandName))) {
|
||||
ElMessage.warning('品牌不能重复')
|
||||
return
|
||||
}
|
||||
payload = { id: props.productId, brands: nextBrands }
|
||||
}
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await fetchUpdateProduct(payload)
|
||||
const detailRes = await fetchProductDetail({ id: String(props.productId) })
|
||||
const product = (detailRes as any)?.data || detailRes || {}
|
||||
ElMessage.success('保存成功')
|
||||
visible.value = false
|
||||
emit('saved', product)
|
||||
} catch {
|
||||
// keep dialog open on API error
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sub-card-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in new issue
Block a user