feat: 客户管理新增个人用户类型,双Tab分离管理
- customers 表新增 customer_type 列 (unit/individual) - 列表按个人优先排序,支持 customer_type 筛选 - PC端 el-radio-group 切换:单位客户 | 个人用户 - 个人用户表单: 仅姓名+备注,隐藏行业/地址/业务/月费/客户经理/联系人 - 所有业务模块客户搜索下拉中个人用户置顶 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,7 @@ async def list_customers(
|
|||||||
industry: Optional[str] = Query(None),
|
industry: Optional[str] = Query(None),
|
||||||
service: Optional[str] = Query(None),
|
service: Optional[str] = Query(None),
|
||||||
manager_id: Optional[str] = Query(None),
|
manager_id: Optional[str] = Query(None),
|
||||||
|
customer_type: Optional[str] = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(100, ge=1, le=1000),
|
page_size: int = Query(100, ge=1, le=1000),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
@@ -60,6 +61,8 @@ async def list_customers(
|
|||||||
|
|
||||||
base_query = select(Customer)
|
base_query = select(Customer)
|
||||||
|
|
||||||
|
if customer_type:
|
||||||
|
base_query = base_query.where(Customer.customer_type == customer_type)
|
||||||
if industry:
|
if industry:
|
||||||
base_query = base_query.where(Customer.industry.ilike(f"%{industry}%"))
|
base_query = base_query.where(Customer.industry.ilike(f"%{industry}%"))
|
||||||
if service:
|
if service:
|
||||||
@@ -89,7 +92,7 @@ async def list_customers(
|
|||||||
|
|
||||||
# Paginate
|
# Paginate
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
query = base_query.order_by(Customer.name).offset(offset).limit(page_size)
|
query = base_query.order_by(Customer.customer_type, Customer.name).offset(offset).limit(page_size)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
items = result.scalars().all()
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ async def lifespan(app: FastAPI):
|
|||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID"
|
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID"
|
||||||
))
|
))
|
||||||
|
# v0.9 — customer_type for unit vs individual
|
||||||
|
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||||
|
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS customer_type VARCHAR(20) DEFAULT 'unit'"
|
||||||
|
))
|
||||||
# New columns for v0.4
|
# New columns for v0.4
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'"
|
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class Customer(Base):
|
|||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
name: Mapped[str] = mapped_column(String(200), index=True)
|
name: Mapped[str] = mapped_column(String(200), index=True)
|
||||||
|
customer_type: Mapped[str] = mapped_column(String(20), default="unit") # unit / individual
|
||||||
industry: Mapped[str] = mapped_column(String(100), default="")
|
industry: Mapped[str] = mapped_column(String(100), default="")
|
||||||
address: Mapped[str] = mapped_column(String(500), default="")
|
address: Mapped[str] = mapped_column(String(500), default="")
|
||||||
in_use_services: Mapped[str] = mapped_column(Text, default="")
|
in_use_services: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class ContactOut(BaseModel):
|
|||||||
# ── Customer ──
|
# ── Customer ──
|
||||||
class CustomerCreate(BaseModel):
|
class CustomerCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
customer_type: str = "unit"
|
||||||
industry: str = ""
|
industry: str = ""
|
||||||
address: str = ""
|
address: str = ""
|
||||||
in_use_services: str = ""
|
in_use_services: str = ""
|
||||||
@@ -56,6 +57,7 @@ class CustomerCreate(BaseModel):
|
|||||||
|
|
||||||
class CustomerUpdate(BaseModel):
|
class CustomerUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
customer_type: Optional[str] = None
|
||||||
industry: Optional[str] = None
|
industry: Optional[str] = None
|
||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
in_use_services: Optional[str] = None
|
in_use_services: Optional[str] = None
|
||||||
@@ -67,6 +69,7 @@ class CustomerUpdate(BaseModel):
|
|||||||
class CustomerOut(BaseModel):
|
class CustomerOut(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
|
customer_type: str = "unit"
|
||||||
industry: str
|
industry: str
|
||||||
address: str
|
address: str
|
||||||
in_use_services: str
|
in_use_services: str
|
||||||
@@ -84,6 +87,7 @@ class CustomerOut(BaseModel):
|
|||||||
class CustomerListOut(BaseModel):
|
class CustomerListOut(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
|
customer_type: str = "unit"
|
||||||
industry: str
|
industry: str
|
||||||
in_use_services: str
|
in_use_services: str
|
||||||
primary_manager_name: Optional[str] = None
|
primary_manager_name: Optional[str] = None
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const search = ref('')
|
|||||||
const filterIndustry = ref('')
|
const filterIndustry = ref('')
|
||||||
const filterService = ref('')
|
const filterService = ref('')
|
||||||
const filterManagerId = ref('')
|
const filterManagerId = ref('')
|
||||||
|
const customerType = ref('unit') // 'unit' or 'individual'
|
||||||
const customers = ref<any[]>([])
|
const customers = ref<any[]>([])
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(25)
|
const pageSize = ref(25)
|
||||||
@@ -23,7 +24,7 @@ const dialogVisible = ref(false)
|
|||||||
const dialogTitle = ref('新建客户')
|
const dialogTitle = ref('新建客户')
|
||||||
const editId = ref('')
|
const editId = ref('')
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: '', industry: '', address: '', in_use_services: '',
|
name: '', customer_type: 'unit', industry: '', address: '', in_use_services: '',
|
||||||
fee_amount: '', fee_unit: '元/月',
|
fee_amount: '', fee_unit: '元/月',
|
||||||
remarks: '',
|
remarks: '',
|
||||||
contacts: [] as any[],
|
contacts: [] as any[],
|
||||||
@@ -68,6 +69,7 @@ async function loadCustomers() {
|
|||||||
if (filterIndustry.value) params.industry = filterIndustry.value
|
if (filterIndustry.value) params.industry = filterIndustry.value
|
||||||
if (filterService.value) params.service = filterService.value
|
if (filterService.value) params.service = filterService.value
|
||||||
if (filterManagerId.value) params.manager_id = filterManagerId.value
|
if (filterManagerId.value) params.manager_id = filterManagerId.value
|
||||||
|
if (customerType.value) params.customer_type = customerType.value
|
||||||
const res = await customersApi.list(params)
|
const res = await customersApi.list(params)
|
||||||
customers.value = res.data.items
|
customers.value = res.data.items
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
@@ -101,7 +103,7 @@ function parseMonthlyFee(fee: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.value = { name: '', industry: '', address: '', in_use_services: '', fee_amount: '', fee_unit: '元/月', remarks: '', contacts: [], assignee_id: '' }
|
form.value = { name: '', customer_type: customerType.value, industry: '', address: '', in_use_services: '', fee_amount: '', fee_unit: '元/月', remarks: '', contacts: [], assignee_id: '' }
|
||||||
feeCustomUnit.value = ''
|
feeCustomUnit.value = ''
|
||||||
existingContacts.value = []
|
existingContacts.value = []
|
||||||
}
|
}
|
||||||
@@ -128,7 +130,7 @@ async function openEdit(customer: any) {
|
|||||||
const res = await customersApi.get(customer.id)
|
const res = await customersApi.get(customer.id)
|
||||||
const c = res.data
|
const c = res.data
|
||||||
resetForm()
|
resetForm()
|
||||||
form.value.name = c.name; form.value.industry = c.industry; form.value.address = c.address
|
form.value.name = c.name; form.value.customer_type = c.customer_type || 'unit'; form.value.industry = c.industry; form.value.address = c.address
|
||||||
form.value.in_use_services = c.in_use_services; form.value.remarks = c.remarks || ''
|
form.value.in_use_services = c.in_use_services; form.value.remarks = c.remarks || ''
|
||||||
parseMonthlyFee(c.monthly_fee)
|
parseMonthlyFee(c.monthly_fee)
|
||||||
existingContacts.value = c.contacts || []
|
existingContacts.value = c.contacts || []
|
||||||
@@ -141,7 +143,7 @@ async function handleSubmit() {
|
|||||||
const monthly_fee = buildMonthlyFee()
|
const monthly_fee = buildMonthlyFee()
|
||||||
try {
|
try {
|
||||||
if (editId.value) {
|
if (editId.value) {
|
||||||
const body: any = { name: form.value.name, industry: form.value.industry, address: form.value.address, in_use_services: form.value.in_use_services, monthly_fee, remarks: form.value.remarks }
|
const body: any = { name: form.value.name, customer_type: form.value.customer_type, industry: form.value.industry, address: form.value.address, in_use_services: form.value.in_use_services, monthly_fee, remarks: form.value.remarks }
|
||||||
if (form.value.assignee_id) body.assignee_id = form.value.assignee_id
|
if (form.value.assignee_id) body.assignee_id = form.value.assignee_id
|
||||||
await customersApi.update(editId.value, body)
|
await customersApi.update(editId.value, body)
|
||||||
for (const c of form.value.contacts) {
|
for (const c of form.value.contacts) {
|
||||||
@@ -149,7 +151,7 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
ElMessage.success('已更新')
|
ElMessage.success('已更新')
|
||||||
} else {
|
} else {
|
||||||
await customersApi.create({ name: form.value.name, industry: form.value.industry, address: form.value.address, in_use_services: form.value.in_use_services, monthly_fee, remarks: form.value.remarks, contacts: form.value.contacts, assignee_id: form.value.assignee_id || undefined })
|
await customersApi.create({ name: form.value.name, customer_type: form.value.customer_type, industry: form.value.industry, address: form.value.address, in_use_services: form.value.in_use_services, monthly_fee, remarks: form.value.remarks, contacts: form.value.contacts, assignee_id: form.value.assignee_id || undefined })
|
||||||
ElMessage.success('已创建')
|
ElMessage.success('已创建')
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
@@ -329,6 +331,14 @@ async function handleImport() {
|
|||||||
<div class="page-rule"></div>
|
<div class="page-rule"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer Type Tabs -->
|
||||||
|
<div style="margin-bottom:14px">
|
||||||
|
<el-radio-group v-model="customerType" @change="(v: string) => { customerType = v; onFilterChange() }">
|
||||||
|
<el-radio-button value="unit">单位客户</el-radio-button>
|
||||||
|
<el-radio-button value="individual">个人用户</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Search & Filter -->
|
<!-- Search & Filter -->
|
||||||
<el-card style="margin-bottom: 16px;">
|
<el-card style="margin-bottom: 16px;">
|
||||||
<el-row :gutter="12" align="middle">
|
<el-row :gutter="12" align="middle">
|
||||||
@@ -363,7 +373,12 @@ async function handleImport() {
|
|||||||
<el-table :data="customers" stripe v-column-resize @selection-change="onSelectionChange">
|
<el-table :data="customers" stripe v-column-resize @selection-change="onSelectionChange">
|
||||||
<el-table-column v-if="auth.isDirector" type="selection" width="50" />
|
<el-table-column v-if="auth.isDirector" type="selection" width="50" />
|
||||||
<el-table-column type="index" label="序号" width="60" :index="(idx: number) => (currentPage - 1) * pageSize + idx + 1" />
|
<el-table-column type="index" label="序号" width="60" :index="(idx: number) => (currentPage - 1) * pageSize + idx + 1" />
|
||||||
<el-table-column prop="name" label="单位名称" min-width="180">
|
<el-table-column label="类型" width="70">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="row.customer_type === 'individual' ? 'info' : ''" effect="plain">{{ row.customer_type === 'individual' ? '个人' : '单位' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="name" :label="customerType === 'individual' ? '姓名' : '单位名称'" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-link type="primary" @click="openDetail(row)">{{ row.name }}</el-link>
|
<el-link type="primary" @click="openDetail(row)">{{ row.name }}</el-link>
|
||||||
</template>
|
</template>
|
||||||
@@ -389,7 +404,8 @@ async function handleImport() {
|
|||||||
<!-- Create/Edit Dialog -->
|
<!-- Create/Edit Dialog -->
|
||||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px">
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px">
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="单位名称" required><el-input v-model="form.name" placeholder="请输入单位名称" @blur="checkName" /><el-alert v-if="duplicateHint" :title="duplicateHint" type="warning" :closable="false" style="margin-top:6px" /></el-form-item>
|
<el-form-item :label="form.customer_type === 'individual' ? '姓名' : '单位名称'" required><el-input v-model="form.name" :placeholder="form.customer_type === 'individual' ? '请输入姓名' : '请输入单位名称'" @blur="checkName" /><el-alert v-if="duplicateHint" :title="duplicateHint" type="warning" :closable="false" style="margin-top:6px" /></el-form-item>
|
||||||
|
<template v-if="form.customer_type === 'unit'">
|
||||||
<el-form-item label="所属行业"><el-input v-model="form.industry" placeholder="如: 教育、医疗、政府..." /></el-form-item>
|
<el-form-item label="所属行业"><el-input v-model="form.industry" placeholder="如: 教育、医疗、政府..." /></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.address" /></el-form-item>
|
||||||
<el-form-item label="在用业务"><el-input v-model="form.in_use_services" placeholder="如: 云桌面、专线、视频会议" /></el-form-item>
|
<el-form-item label="在用业务"><el-input v-model="form.in_use_services" placeholder="如: 云桌面、专线、视频会议" /></el-form-item>
|
||||||
@@ -404,7 +420,9 @@ async function handleImport() {
|
|||||||
<el-col v-if="form.fee_unit === '自定义'" :span="8"><el-input v-model="feeCustomUnit" placeholder="单位" /></el-col>
|
<el-col v-if="form.fee_unit === '自定义'" :span="8"><el-input v-model="feeCustomUnit" placeholder="单位" /></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</template>
|
||||||
<el-form-item label="备注"><el-input v-model="form.remarks" type="textarea" :rows="2" placeholder="备注信息..." /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="form.remarks" type="textarea" :rows="2" placeholder="备注信息..." /></el-form-item>
|
||||||
|
<template v-if="form.customer_type === 'unit'">
|
||||||
<el-form-item label="归属客户经理" v-if="auth.isDirector">
|
<el-form-item label="归属客户经理" v-if="auth.isDirector">
|
||||||
<el-select v-model="form.assignee_id" :placeholder="editId ? '留空则不修改' : '不选则默认分配给自己'" clearable style="width:100%">
|
<el-select v-model="form.assignee_id" :placeholder="editId ? '留空则不修改' : '不选则默认分配给自己'" clearable style="width:100%">
|
||||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||||
@@ -424,6 +442,7 @@ async function handleImport() {
|
|||||||
</div>
|
</div>
|
||||||
<el-button size="small" @click="form.contacts.push({name:'',phone:'',role_desc:''})">+ 添加联系人</el-button>
|
<el-button size="small" @click="form.contacts.push({name:'',phone:'',role_desc:''})">+ 添加联系人</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</template>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
|||||||
Reference in New Issue
Block a user