Files
crm_project/frontend/src/views/customers/index.vue
T
hankin 423baff73b v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成
- Docker bridge 网络隔离(8000 端口封死)
- Gunicorn 4 Worker 多进程
- Alembic 数据库迁移基线
- 日志轮转 20m×3
- JWT 密钥 + DB 密码 + CORS 收紧
- 3-2-1 备份链路(NAS + R740-B 冷备)
- 连接池 pool_pre_ping + pool_recycle=3600
2026-03-16 07:31:37 +00:00

485 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* CRM 客户管理 —— 真实 API 驱动
* GET /api/customers (分页 + keyword 搜索)
* POST /api/customers (新增开户)
* PUT /api/customers/{id} (编辑)
* DELETE /api/customers/{id} (软删除/归档)
*/
import { ref, reactive, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { Search, Plus, View, Edit, Box, Upload, Download } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
const userStore = useUserStore()
const isAdmin = computed(() => userStore.userInfo?.data_scope === 'all' || (userStore.userInfo?.role_name || '').toLowerCase() === 'admin')
const router = useRouter()
const loading = ref(false)
// --- 搜索表单 ---
const searchForm = reactive({
keyword: '',
level: '',
industry: '',
showArchived: false,
})
// --- 数据 ---
const customerData = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
// --- 拉取客户列表 ---
const fetchCustomers = async () => {
loading.value = true
try {
const params: Record<string, any> = {
page: currentPage.value,
size: pageSize.value,
}
if (searchForm.keyword) params.keyword = searchForm.keyword
if (searchForm.level) params.level = searchForm.level
if (searchForm.showArchived) params.include_archived = true
const data: any = await request.get('/api/customers', { params })
customerData.value = data.items || []
total.value = data.total || 0
} catch {
// 已统一处理
} finally {
loading.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
fetchCustomers()
}
const handlePageChange = () => {
fetchCustomers()
}
// --- 新增客户弹窗 ---
const addDialogVisible = ref(false)
const addFormRef = ref<FormInstance>()
const addSubmitting = ref(false)
const addForm = reactive({
name: '',
level: 'B',
industry: '',
contact: '',
phone: '',
address: '',
remark: '',
})
const addFormRules = reactive<FormRules>({
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
})
const handleAddCustomer = () => {
Object.assign(addForm, { name: '', level: 'B', industry: '', contact: '', phone: '', address: '', remark: '' })
addDialogVisible.value = true
nextTick(() => addFormRef.value?.clearValidate())
}
const submitAddCustomer = async () => {
const valid = await addFormRef.value?.validate().catch(() => false)
if (!valid) return
addSubmitting.value = true
try {
await request.post('/api/customers', addForm)
ElMessage.success('客户开户成功')
addDialogVisible.value = false
fetchCustomers()
} catch {
// 已统一处理
} finally {
addSubmitting.value = false
}
}
// --- 编辑客户弹窗 ---
const editDialogVisible = ref(false)
const editFormRef = ref<FormInstance>()
const editSubmitting = ref(false)
const editForm = reactive({
id: '',
name: '',
level: '',
industry: '',
contact: '',
phone: '',
address: '',
remark: '',
})
const editFormRules = reactive<FormRules>({
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
})
const editCustomer = (row: any) => {
Object.assign(editForm, {
id: row.id,
name: row.name || '',
level: row.level || '',
industry: row.industry || '',
contact: row.contact || '',
phone: row.phone || '',
address: row.address || '',
remark: row.remark || '',
})
editDialogVisible.value = true
nextTick(() => editFormRef.value?.clearValidate())
}
const submitEditCustomer = async () => {
const valid = await editFormRef.value?.validate().catch(() => false)
if (!valid) return
editSubmitting.value = true
try {
const { id, ...payload } = editForm
await request.put(`/api/customers/${id}`, payload)
ElMessage.success('客户信息已更新')
editDialogVisible.value = false
fetchCustomers()
} catch {
// 已统一处理
} finally {
editSubmitting.value = false
}
}
// --- 查看档案 ---
const viewDetails = (row: any) => {
router.push({
path: '/customers/detail',
query: { id: row.id }
})
}
// --- 归档 (软删除) ---
const archiveCustomer = (row: any) => {
ElMessageBox.confirm(
`确定要将客户 "${row.name}" 设为归档状态吗?该操作不会彻底删除数据。`,
'归档确认',
{ confirmButtonText: '确定归档', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
await request.delete(`/api/customers/${row.id}`)
ElMessage.success('归档成功!')
fetchCustomers()
} catch {
// 已统一处理
}
}).catch(() => {})
}
const restoreCustomer = (row: any) => {
ElMessageBox.confirm(
`确定要恢复客户 "${row.name}" 吗?`,
'恢复确认',
{ confirmButtonText: '确定恢复', cancelButtonText: '取消', type: 'info' }
).then(async () => {
try {
await request.put(`/api/customers/${row.id}/restore`)
ElMessage.success('客户已恢复!')
fetchCustomers()
} catch {
// 已统一处理
}
}).catch(() => {})
}
// --- 辅助函数 ---
const getLevelType = (level: string) => {
if (!level) return ''
if (level.includes('A')) return 'danger'
if (level.includes('B')) return 'warning'
if (level.includes('C')) return 'info'
return ''
}
const getLevelLabel = (level: string) => {
if (level === 'A') return 'A级重点'
if (level === 'B') return 'B级普通'
if (level === 'C') return 'C级长尾'
return level || '-'
}
// --- 导入 / 导出 ---
const importDialogVisible = ref(false)
const getUploadHeaders = () => {
return { Authorization: `Bearer ${localStorage.getItem('crm_token') || ''}` }
}
const handleImportSuccess = (response: any) => {
if (response.code === 200) {
ElMessage.success(response.message || '导入成功')
importDialogVisible.value = false
fetchCustomers()
} else {
ElMessage.error(response.message || '导入失败')
}
}
const handleImportError = () => { ElMessage.error('上传失败,请检查网络或后端服务') }
const handleExport = async () => {
try {
const res = await request.get('/api/crm/export', {
responseType: 'blob'
})
const blob = new Blob([res as any], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `customers_export_${new Date().toISOString().split('T')[0]}.xlsx`
a.click()
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch {
ElMessage.error('导出失败或无权限')
}
}
// --- 初始化 ---
onMounted(() => {
fetchCustomers()
})
</script>
<template>
<div class="customer-list-container">
<!-- 1. 顶部高级检索区 -->
<el-card shadow="never" class="filter-section">
<div class="filter-wrapper">
<el-form :inline="true" :model="searchForm" class="filter-form">
<el-form-item label="客户名称">
<el-input v-model="searchForm.keyword" placeholder="请输入名称/拼音" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="客户级别">
<el-select v-model="searchForm.level" placeholder="全部分级" clearable style="width: 150px">
<el-option label="A级重点" value="A" />
<el-option label="B级普通" value="B" />
<el-option label="C级长尾" value="C" />
</el-select>
</el-form-item>
<el-form-item>
<el-switch v-model="searchForm.showArchived" active-text="显示已归档" @change="handleSearch" style="margin-right: 10px" />
</el-form-item>
</el-form>
<div class="action-buttons">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button type="warning" :icon="Upload" @click="importDialogVisible = true">导入客户</el-button>
<el-button v-if="isAdmin" type="info" :icon="Download" @click="handleExport">导出</el-button>
<el-button type="success" :icon="Plus" @click="handleAddCustomer">新增客户</el-button>
</div>
</div>
</el-card>
<!-- 2. 核心数据表格 -->
<el-card shadow="never" class="table-section">
<el-table :data="customerData" stripe border style="width: 100%" v-loading="loading">
<el-table-column prop="name" label="客户名称" min-width="220" show-overflow-tooltip>
<template #default="scope">
<span class="customer-name-bold">{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="客户级别" width="120" align="center">
<template #default="scope">
<el-tag :type="getLevelType(scope.row.level)" effect="light">
{{ getLevelLabel(scope.row.level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="industry" label="所属行业" min-width="160" show-overflow-tooltip />
<el-table-column prop="contact" label="联系人" width="120" />
<el-table-column prop="phone" label="联系电话" width="140" />
<el-table-column prop="address" label="地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="scope">
<el-button type="primary" link :icon="View" @click="viewDetails(scope.row)">查看档案</el-button>
<template v-if="!scope.row.is_deleted">
<el-button type="primary" link :icon="Edit" @click="editCustomer(scope.row)">编辑</el-button>
<el-button type="danger" link :icon="Box" @click="archiveCustomer(scope.row)">归档</el-button>
</template>
<el-button v-else type="success" link @click="restoreCustomer(scope.row)">恢复</el-button>
</template>
</el-table-column>
</el-table>
<!-- 3. 分页 -->
<div class="pagination-section">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
background
layout="total, prev, pager, next, jumper"
:total="total"
@current-change="handlePageChange"
@size-change="handlePageChange"
/>
</div>
</el-card>
<!-- 4. 新增客户弹窗 -->
<el-dialog v-model="addDialogVisible" title="新增客户开户" width="560px" destroy-on-close>
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="80px">
<el-form-item label="客户名称" prop="name">
<el-input v-model="addForm.name" placeholder="请输入客户公司名称" />
</el-form-item>
<el-form-item label="客户级别">
<el-select v-model="addForm.level" style="width: 100%">
<el-option label="A级重点" value="A" />
<el-option label="B级普通" value="B" />
<el-option label="C级长尾" value="C" />
</el-select>
</el-form-item>
<el-form-item label="行业">
<el-input v-model="addForm.industry" placeholder="所属行业" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="addForm.contact" placeholder="联系人姓名" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="addForm.phone" placeholder="联系电话" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="addForm.address" placeholder="地址" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="addForm.remark" type="textarea" :rows="2" placeholder="备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="addSubmitting" @click="submitAddCustomer">确定提交</el-button>
</template>
</el-dialog>
<!-- 5. 编辑客户弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑客户信息" width="560px" destroy-on-close>
<el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="80px">
<el-form-item label="客户名称" prop="name">
<el-input v-model="editForm.name" />
</el-form-item>
<el-form-item label="客户级别">
<el-select v-model="editForm.level" style="width: 100%">
<el-option label="A级重点" value="A" />
<el-option label="B级普通" value="B" />
<el-option label="C级长尾" value="C" />
</el-select>
</el-form-item>
<el-form-item label="行业">
<el-input v-model="editForm.industry" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="editForm.contact" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="editForm.phone" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="editForm.address" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="submitEditCustomer">保存修改</el-button>
</template>
</el-dialog>
<!-- 6. 批量导入弹窗 -->
<el-dialog v-model="importDialogVisible" title="批量导入客户" width="400px" destroy-on-close>
<div style="margin-bottom: 20px; line-height: 1.6;">
<p>1. 请先下载标准模板按要求填写数据</p>
<p>2. 仅支持 .xlsx 格式文件</p>
<p style="margin-top: 10px;">
<a href="/api/templates/customer_import_template.xlsx" target="_blank" style="color: #409eff; text-decoration: none;">
点击下载客户导入模板.xlsx
</a>
</p>
</div>
<el-upload
drag
action="/api/crm/import"
:headers="getUploadHeaders()"
accept=".xlsx,.xls"
:show-file-list="false"
:on-success="handleImportSuccess"
:on-error="handleImportError"
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<div class="el-upload__text">将文件拖到此处 <em>点击上传</em></div>
</el-upload>
</el-dialog>
</div>
</template>
<style scoped>
.customer-list-container {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 顶部检索区 */
.filter-section {
border-radius: 8px;
border: none;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.filter-wrapper {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: flex-start;
}
.filter-form .el-form-item {
margin-bottom: 0;
margin-right: 20px;
}
.action-buttons {
display: flex;
gap: 10px;
}
/* 数据表格区 */
.table-section {
border-radius: 8px;
border: none;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.customer-name-bold {
font-weight: bold;
color: #303133;
}
/* 分页 */
.pagination-section {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>