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:
zlwuandCursor committed 2026-08-07 12:46:28 +08:00
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>