feat: 客户关联产品并支持按产品圈选通知群体

新增客户-产品多对多关联,列表可按产品筛选并复制邮箱;发证/下单自动回填历史关联。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 21:58:35 +08:00
1 parent d0e1ca3729
commit 4c46088f4f
10 files changed
+337 -89

No files matched your search

+132 -16
View File
@@ -2,14 +2,57 @@
<div>
<el-card shadow="never">
<div class="toolbar">
<el-input v-model="keyword" placeholder="搜索客户/邮箱/公司" clearable style="width: 260px" @keyup.enter="loadData" @clear="loadData" />
<el-button type="primary" @click="openCreate">新增客户</el-button>
<div class="filters">
<el-input
v-model="keyword"
placeholder="搜索客户/邮箱/公司"
clearable
style="width: 240px"
@keyup.enter="search"
@clear="search"
/>
<el-select
v-model="productFilter"
placeholder="按产品筛选"
clearable
filterable
style="width: 180px"
@change="search"
>
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</div>
<div class="actions">
<el-button :disabled="!productFilter || list.length === 0" @click="copyEmails">复制本页邮箱</el-button>
<el-button type="primary" @click="openCreate">新增客户</el-button>
</div>
</div>
<el-alert
type="info"
:closable="false"
show-icon
title="客户可关联多个产品。按产品筛选后即可圈选通知群体;发证/购买会自动写入产品关联。"
style="margin-bottom: 12px"
/>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="name" label="客户名称" min-width="120" />
<el-table-column prop="company" label="公司" min-width="140" />
<el-table-column prop="company" label="公司" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column label="关联产品" min-width="200">
<template #default="{ row }">
<template v-if="row.products?.length">
<el-tag
v-for="p in row.products"
:key="p.id"
size="small"
style="margin: 2px 4px 2px 0"
>{{ p.name }}</el-tag>
</template>
<span v-else class="muted">未关联</span>
</template>
</el-table-column>
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
@@ -32,12 +75,24 @@
/>
</el-card>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑客户' : '新增客户'" width="520px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑客户' : '新增客户'" width="560px" @closed="onDialogClosed">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="公司"><el-input v-model="form.company" /></el-form-item>
<el-form-item label="邮箱" prop="email"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="关联产品" prop="product_ids">
<el-select
v-model="form.product_ids"
multiple
filterable
clearable
placeholder="选择产品(可多选)"
style="width: 100%"
>
<el-option v-for="p in products" :key="p.id" :label="`${p.name} (${p.code})`" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
@@ -50,50 +105,91 @@
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessageBox } from 'element-plus'
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCustomers, createCustomer, updateCustomer, deleteCustomer, updateCustomerStatus } from '@/api/customer'
import { getAllProducts } from '@/api/product'
const list = ref([])
const products = ref([])
const loading = ref(false)
const keyword = ref('')
const productFilter = ref(null)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const dialogVisible = ref(false)
const formRef = ref(null)
const form = reactive({ id: null, name: '', company: '', email: '', phone: '', address: '', notes: '' })
const form = reactive({
id: null, name: '', company: '', email: '', phone: '', address: '', notes: '', product_ids: [],
})
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
}
function search() {
page.value = 1
loadData()
}
async function loadData() {
loading.value = true
try {
const res = await getCustomers({ page: page.value, page_size: pageSize.value, keyword: keyword.value })
const res = await getCustomers({
page: page.value,
page_size: pageSize.value,
keyword: keyword.value,
product_id: productFilter.value || undefined,
})
list.value = res.data.list || []
total.value = res.data.total
} finally { loading.value = false }
} finally {
loading.value = false
}
}
function openCreate() {
Object.assign(form, { id: null, name: '', company: '', email: '', phone: '', address: '', notes: '' })
Object.assign(form, {
id: null, name: '', company: '', email: '', phone: '', address: '', notes: '', product_ids: [],
})
dialogVisible.value = true
}
function edit(row) {
Object.assign(form, row)
Object.assign(form, {
id: row.id,
name: row.name,
company: row.company,
email: row.email,
phone: row.phone,
address: row.address,
notes: row.notes,
product_ids: [...(row.product_ids || [])],
})
dialogVisible.value = true
}
function onDialogClosed() {
formRef.value?.clearValidate?.()
}
async function submit() {
await formRef.value.validate(async (valid) => {
if (!valid) return
try {
if (form.id) await updateCustomer(form.id, form)
else await createCustomer(form)
const payload = {
name: form.name,
company: form.company,
email: form.email,
phone: form.phone,
address: form.address,
notes: form.notes,
product_ids: form.product_ids || [],
}
if (form.id) await updateCustomer(form.id, payload)
else await createCustomer(payload)
dialogVisible.value = false
loadData()
} catch { /* */ }
@@ -111,9 +207,29 @@ async function toggleStatus(row, val) {
loadData()
}
loadData()
async function copyEmails() {
const emails = list.value.map((r) => (r.email || '').trim()).filter(Boolean)
if (!emails.length) {
ElMessage.warning('本页没有可复制的邮箱')
return
}
try {
await navigator.clipboard.writeText(emails.join(', '))
ElMessage.success(`已复制 ${emails.length} 个邮箱`)
} catch {
ElMessage.error('复制失败,请手动选择')
}
}
onMounted(async () => {
const res = await getAllProducts()
products.value = res.data || []
loadData()
})
</script>
<style scoped>
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
.toolbar { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.filters, .actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.muted { color: #c0c4cc; font-size: 13px; }
</style>