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
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user