Просмотр исходного кода

feat(crm): add product alias/brand manage dialog for inline master-data edits.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 месяцев назад
Родитель
Сommit
0dbd2da703
1 измененных файлов с 299 добавлено и 0 удалено
  1. 299 0
      web-admin/src/components/business/product-alias-brand-dialog/index.vue

+ 299 - 0
web-admin/src/components/business/product-alias-brand-dialog/index.vue

@@ -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>