增加CRM模块

This commit is contained in:
2025-11-13 17:24:59 +08:00
parent 117e2b3440
commit 695bf2194f
32 changed files with 5340 additions and 44 deletions
+33
View File
@@ -0,0 +1,33 @@
import request from '@/utils/request'
export function listContacts(params) {
return request({
url: '/api/crm/contact/list',
method: 'get',
params,
})
}
export function createContact(data) {
return request({
url: '/api/crm/contact/add',
method: 'post',
data,
})
}
export function updateContact(data) {
return request({
url: '/api/crm/contact/edit',
method: 'post',
data,
})
}
export function deleteContact(data) {
return request({
url: '/api/crm/contact/delete',
method: 'post',
data,
})
}
+72
View File
@@ -0,0 +1,72 @@
import request from '@/utils/request'
/**
* 获取客户列表
* @param {Object} params 查询参数
* @param {number} params.tenantId 租户ID
* @returns {Promise}
*/
export function listCustomers(params) {
return request({
url: '/api/crm/customer/list',
method: 'get',
params
})
}
/**
* 获取客户详情
* @param {number|string} id 客户ID
* @param {number} params.tenantId 租户ID
* @returns {Promise}
*/
export function getCustomer(id) {
return request({
url: '/api/crm/customer/detail',
method: 'get',
params: { id }
})
}
/**
* 创建客户
* @param {Object} data 客户数据
* @param {number} data.tenantId 租户ID
* @returns {Promise}
*/
export function createCustomer(data) {
return request({
url: '/api/crm/customer/add',
method: 'post',
data
})
}
/**
* 更新客户
* @param {number|string} id 客户ID
* @param {Object} data 更新的数据
* @param {number} data.tenantId 租户ID
* @returns {Promise}
*/
export function updateCustomer(id, data) {
return request({
url: '/api/crm/customer/edit',
method: 'post',
data: { id, ...data }
})
}
/**
* 删除客户
* @param {number|string} id 客户ID
* @param {number} tenantId 租户ID
* @returns {Promise}
*/
export function deleteCustomer(id, tenantId) {
return request({
url: '/api/crm/customer/delete',
method: 'post',
data: { id, tenantId }
})
}
+72
View File
@@ -0,0 +1,72 @@
import request from '@/utils/request'
/**
* 获取供应商列表
* @param {Object} params 查询参数
* @param {number} params.tenantId 租户ID
* @returns {Promise}
*/
export function listSuppliers(params) {
return request({
url: '/api/crm/supplier/list',
method: 'get',
params
})
}
/**
* 获取供应商详情
* @param {number|string} id 供应商ID
* @param {number} params.tenantId 租户ID
* @returns {Promise}
*/
export function getSupplier(id) {
return request({
url: '/api/crm/supplier/detail',
method: 'get',
params: { id }
})
}
/**
* 创建供应商
* @param {Object} data 供应商数据
* @param {number} data.tenantId 租户ID
* @returns {Promise}
*/
export function createSupplier(data) {
return request({
url: '/api/crm/supplier/add',
method: 'post',
data
})
}
/**
* 更新供应商
* @param {number|string} id 供应商ID
* @param {Object} data 更新的数据
* @param {number} data.tenantId 租户ID
* @returns {Promise}
*/
export function updateSupplier(id, data) {
return request({
url: '/api/crm/supplier/edit',
method: 'post',
data: { id, ...data }
})
}
/**
* 删除供应商
* @param {number|string} id 供应商ID
* @param {number} tenantId 租户ID
* @returns {Promise}
*/
export function deleteSupplier(id, tenantId) {
return request({
url: '/api/apps/crm/supplier/delete',
method: 'post',
data: { id, tenantId }
})
}
+8 -6
View File
@@ -139,10 +139,11 @@ export const useMenuStore = defineStore('menu', () => {
if (res.data !== undefined && res.data !== null) {
// 确保 data 是数组
const menuData = Array.isArray(res.data) ? res.data : [];
menus.value = menuData;
const filtered = menuData.filter(m => (m.isShow ?? 1) !== 0);
menus.value = filtered;
// 保存到缓存
saveToCache(menuData);
return menuData;
saveToCache(filtered);
return filtered;
} else {
// data 为 null 或 undefined,使用空数组
console.warn('菜单数据为空,使用空数组');
@@ -155,9 +156,10 @@ export const useMenuStore = defineStore('menu', () => {
// 如果响应格式不符合预期,尝试直接使用 res.data
if (res.data !== undefined) {
const menuData = Array.isArray(res.data) ? res.data : [];
menus.value = menuData;
saveToCache(menuData);
return menuData;
const filtered = menuData.filter(m => (m.isShow ?? 1) !== 0);
menus.value = filtered;
saveToCache(filtered);
return filtered;
}
// 如果都不符合,抛出错误
@@ -0,0 +1,201 @@
<template>
<el-drawer
v-model="visible"
title="联系人管理"
size="600px"
:close-on-click-modal="false"
>
<div class="contact-container">
<el-button type="primary" @click="handleAdd" style="margin-bottom: 16px">
新增联系人
</el-button>
<el-table :data="contactList" border>
<el-table-column prop="name" label="姓名" />
<el-table-column prop="phone" label="电话" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="position" label="职位" />
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑联系人' : '新增联系人'"
width="500px"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="电话" prop="phone">
<el-input v-model="form.phone" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" />
</el-form-item>
<el-form-item label="职位" prop="position">
<el-input v-model="form.position" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-select v-model="form.gender" placeholder="请选择性别" clearable style="width: 100%">
<el-option label="未知" :value="0" />
<el-option label="男" :value="1" />
<el-option label="女" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="部门" prop="department">
<el-input v-model="form.department" />
</el-form-item>
<el-form-item label="主联系人" prop="is_primary">
<el-switch v-model="form.is_primary" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmit">确定</el-button>
</template>
</el-dialog>
</el-drawer>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listContacts, createContact, updateContact, deleteContact } from '@/api/contact.js'
const props = defineProps({
modelValue: Boolean,
model: Object
})
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const contactList = ref([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const form = ref({
id: null,
name: '',
phone: '',
email: '',
position: '',
gender: 0,
department: '',
is_primary: 0,
})
const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
}
function handleAdd() {
isEdit.value = false
form.value = { id: null, name: '', phone: '', email: '', position: '' }
dialogVisible.value = true
}
function handleEdit(row) {
isEdit.value = true
form.value = { ...row }
dialogVisible.value = true
}
function getTenantId() {
try {
const ui = localStorage.getItem('userInfo')
if (ui) {
const u = JSON.parse(ui)
const tid = u.tenant_id || u.tenantId || ''
return tid != null && tid !== undefined ? String(tid) : ''
}
} catch (e) {}
return ''
}
async function fetchList() {
const tenant_id = getTenantId()
if (!props.model?.id || !tenant_id) return
const params = { tenant_id, related_type: 1, related_id: props.model.id }
const res = await listContacts(params)
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res)
if (resp && resp.code === 0) {
contactList.value = (resp.data || []).map(it => ({
id: it.id,
name: it.contact_name,
phone: it.mobile || it.phone || '',
email: it.email || '',
position: it.position || '',
gender: Number(it.gender || 0),
department: it.department || '',
is_primary: Number(it.is_primary || 0),
_raw: it,
}))
}
}
function handleDelete(row) {
ElMessageBox.confirm('确认删除该联系人?', '提示', {
type: 'warning'
}).then(async () => {
const tenant_id = getTenantId()
await deleteContact({ id: row.id, tenant_id })
ElMessage.success('删除成功')
fetchList()
emit('saved')
})
}
async function onSubmit() {
if (!formRef.value) return
await formRef.value.validate()
const tenant_id = getTenantId()
const base = {
tenant_id,
related_type: 1,
related_id: props.model?.id,
contact_name: form.value.name,
mobile: form.value.phone,
email: form.value.email,
position: form.value.position,
gender: form.value.gender,
department: form.value.department,
is_primary: form.value.is_primary,
}
if (isEdit.value) {
await updateContact({ id: form.value.id, ...base })
} else {
await createContact(base)
}
ElMessage.success(isEdit.value ? '修改成功' : '新增成功')
dialogVisible.value = false
fetchList()
emit('saved')
}
watch(() => props.model, (m) => {
if (m && m.id) {
fetchList()
} else {
contactList.value = []
}
}, { immediate: true, deep: true })
</script>
<style scoped>
.contact-container {
padding: 0 20px;
}
</style>
@@ -0,0 +1,72 @@
<template>
<el-drawer v-model="visible" :title="title" size="40%" destroy-on-close>
<el-descriptions :column="2" border v-if="model">
<el-descriptions-item label="客户名称">{{ model.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ model.contact || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ model.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ model.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户类型">{{ getLabel(customerTypeOptions, model.customer_type) }}</el-descriptions-item>
<el-descriptions-item label="客户等级">{{ getLabel(customerLevelOptions, model.customer_level) }}</el-descriptions-item>
<el-descriptions-item label="所属行业">{{ getLabel(industryOptions, model.industry) }}</el-descriptions-item>
<el-descriptions-item label="客户状态">
<el-tag :type="(model.status===1||model.status==='1') ? 'success' : 'info'">
{{ getLabel(customerStatusOptions, String(model.status ?? '')) || ((model.status===1||model.status==='1') ? '正常' : '停用') }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ model.address || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="visible=false">关闭</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import request from '@/utils/request'
const props = defineProps<{ modelValue: boolean, model?: any }>()
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v)
})
const title = computed(() => `客户详情${props.model?.name ? ' - ' + props.model.name : ''}`)
const customerTypeOptions = ref<{label:string,value:string}[]>([])
const customerLevelOptions = ref<{label:string,value:string}[]>([])
const industryOptions = ref<{label:string,value:string}[]>([])
const customerStatusOptions = ref<{label:string,value:string}[]>([])
function mapDictItems(items: any[]) {
return (items || []).map(it => ({ label: it.dict_label, value: String(it.dict_value) }))
}
function getLabel(options: {label:string,value:string}[], value: any) {
const val = value != null && value !== undefined ? String(value) : ''
const found = options.find(o => o.value === val)
return found ? found.label : '-'
}
async function loadDictOptions() {
const [typeRes, levelRes, industryRes, statusRes] = await Promise.all([
request({ url: '/api/dict/items/code/customer_type', method: 'get' }),
request({ url: '/api/dict/items/code/customer_level', method: 'get' }),
request({ url: '/api/dict/items/code/industry', method: 'get' }),
request({ url: '/api/dict/items/code/customer_status', method: 'get' }),
])
const getData = (r:any) => (r && r.data && (r.data.data || r.data)) || []
customerTypeOptions.value = mapDictItems(getData(typeRes))
customerLevelOptions.value = mapDictItems(getData(levelRes))
industryOptions.value = mapDictItems(getData(industryRes))
customerStatusOptions.value = mapDictItems(getData(statusRes))
}
watch(() => visible.value, (v) => {
if (v) loadDictOptions()
})
</script>
<style scoped>
</style>
@@ -0,0 +1,225 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑客户' : '新增客户'"
width="600px"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="客户名称" prop="name">
<el-input v-model="form.name" placeholder="请输入客户名称" />
</el-form-item>
<el-form-item label="客户类型" prop="customer_type">
<el-select v-model="form.customer_type" placeholder="请选择客户类型" filterable clearable>
<el-option
v-for="item in customerTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="客户等级" prop="customer_level">
<el-select v-model="form.customer_level" placeholder="请选择客户等级" filterable clearable>
<el-option
v-for="item in customerLevelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="所属行业" prop="industry">
<el-select v-model="form.industry" placeholder="请选择所属行业" filterable clearable>
<el-option
v-for="item in industryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="联系人" prop="contact">
<el-input v-model="form.contact" placeholder="请输入联系人" />
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入联系电话" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="地址" prop="address">
<el-input v-model="form.address" type="textarea" placeholder="请输入地址" />
</el-form-item>
<el-form-item label="客户状态" prop="status">
<el-select v-model="form.status" placeholder="请选择客户状态" clearable>
<el-option
v-for="item in customerStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onCancel">取消</el-button>
<el-button type="primary" @click="onSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed, onMounted } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { createCustomer, updateCustomer } from '../../../../../api/customer.js'
import request from '@/utils/request'
const props = defineProps<{ modelValue: boolean, isEdit: boolean, model?: any }>()
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v)
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
id: null as string | null,
name: '',
customer_type: '',
customer_level: '',
industry: '',
contact: '',
phone: '',
email: '',
address: '',
status: '' as any
})
const rules: FormRules = {
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
customer_type: [{ required: true, message: '请选择客户类型', trigger: 'change' }],
customer_level: [{ required: true, message: '请选择客户等级', trigger: 'change' }],
industry: [{ required: true, message: '请选择所属行业', trigger: 'change' }],
contact: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
phone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
}
const customerTypeOptions = ref<{label:string,value:string}[]>([])
const customerLevelOptions = ref<{label:string,value:string}[]>([])
const industryOptions = ref<{label:string,value:string}[]>([])
const customerStatusOptions = ref<{label:string,value:string}[]>([])
function mapDictItems(items: any[]) {
return (items || []).map(it => ({ label: it.dict_label, value: it.dict_value }))
}
async function loadDictOptions() {
const [typeRes, levelRes, industryRes, statusRes] = await Promise.all([
request({ url: '/api/dict/items/code/customer_type', method: 'get' }),
request({ url: '/api/dict/items/code/customer_level', method: 'get' }),
request({ url: '/api/dict/items/code/industry', method: 'get' }),
request({ url: '/api/dict/items/code/customer_status', method: 'get' }),
])
const getData = (r:any) => (r && r.data && (r.data.data || r.data)) || []
customerTypeOptions.value = mapDictItems(getData(typeRes))
customerLevelOptions.value = mapDictItems(getData(levelRes))
industryOptions.value = mapDictItems(getData(industryRes))
customerStatusOptions.value = mapDictItems(getData(statusRes))
}
watch(() => props.model, (m) => {
if (m) {
form.id = m.id ?? null
form.name = (m.customer_name ?? m.name) ?? ''
form.customer_type = m.customer_type != null && m.customer_type !== undefined ? String(m.customer_type) : ''
form.customer_level = m.customer_level != null && m.customer_level !== undefined ? String(m.customer_level) : ''
form.industry = m.industry != null && m.industry !== undefined ? String(m.industry) : ''
form.contact = (m.contact_person ?? m.contact) ?? ''
form.phone = (m.contact_phone ?? m.phone) ?? ''
form.email = (m.contact_email ?? m.email) ?? ''
form.address = m.address ?? ''
form.status = m.status != null && m.status !== undefined ? String(m.status) : ''
} else {
resetForm()
}
}, { immediate: true, deep: true })
function resetForm() {
form.id = null
form.name = ''
form.customer_type = ''
form.customer_level = ''
form.industry = ''
form.contact = ''
form.phone = ''
form.email = ''
form.address = ''
form.status = ''
formRef.value?.clearValidate()
}
async function onSubmit() {
if (!formRef.value) return
await formRef.value.validate()
try {
submitting.value = true
const tenantId = (() => {
try {
const ui = localStorage.getItem('userInfo')
if (ui) {
const u = JSON.parse(ui)
const tid = u.tenant_id || u.tenantId || ''
return tid != null && tid !== undefined ? String(tid) : ''
}
} catch (e) {}
return ''
})()
const payload: any = {
customer_name: form.name,
customer_type: form.customer_type,
customer_level: form.customer_level,
industry: form.industry,
contact_person: form.contact,
contact_phone: form.phone,
contact_email: form.email,
address: form.address,
status: String(form.status),
tenant_id: tenantId,
}
if (props.isEdit && form.id) {
await updateCustomer(form.id, payload)
} else {
await createCustomer(payload)
}
ElMessage.success(props.isEdit ? '修改成功' : '新增成功')
emit('saved')
visible.value = false
} finally {
submitting.value = false
}
}
function onCancel() {
visible.value = false
}
watch(() => visible.value, (v) => {
if (v) {
loadDictOptions()
}
})
onMounted(() => {
if (visible.value) {
loadDictOptions()
}
})
</script>
<style scoped>
</style>
@@ -0,0 +1,107 @@
<template>
<el-drawer
v-model="visible"
title="开票信息"
size="600px"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="发票抬头" prop="invoice_title">
<el-input v-model="form.invoice_title" placeholder="请输入发票抬头" />
</el-form-item>
<el-form-item label="纳税人识别号" prop="tax_number">
<el-input v-model="form.tax_number" placeholder="请输入纳税人识别号" />
</el-form-item>
<el-form-item label="开户银行" prop="bank_name">
<el-input v-model="form.bank_name" placeholder="请输入开户银行" />
</el-form-item>
<el-form-item label="银行账号" prop="bank_account">
<el-input v-model="form.bank_account" placeholder="请输入银行账号" />
</el-form-item>
<el-form-item label="注册地址" prop="registered_address">
<el-input v-model="form.registered_address" type="textarea" placeholder="请输入注册地址" />
</el-form-item>
<el-form-item label="注册电话" prop="registered_phone">
<el-input v-model="form.registered_phone" placeholder="请输入注册电话" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onCancel">取消</el-button>
<el-button type="primary" @click="onSubmit" :loading="submitting">保存</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
const props = defineProps<{ modelValue: boolean, model?: any }>()
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v)
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
invoice_title: '',
tax_number: '',
bank_name: '',
bank_account: '',
registered_address: '',
registered_phone: ''
})
const rules: FormRules = {
invoice_title: [{ required: true, message: '请输入发票抬头', trigger: 'blur' }],
tax_number: [{ required: true, message: '请输入纳税人识别号', trigger: 'blur' }]
}
function resetForm() {
form.invoice_title = ''
form.tax_number = ''
form.bank_name = ''
form.bank_account = ''
form.registered_address = ''
form.registered_phone = ''
formRef.value?.clearValidate()
}
function onCancel() {
visible.value = false
}
async function onSubmit() {
if (!formRef.value) return
await formRef.value.validate()
try {
submitting.value = true
// TODO: 调用保存开票信息的API
ElMessage.success('保存成功')
emit('saved')
visible.value = false
} finally {
submitting.value = false
}
}
watch(() => props.model, (m) => {
if (m) {
form.invoice_title = m.invoice_title ?? ''
form.tax_number = m.tax_number ?? ''
form.bank_name = m.bank_name ?? ''
form.bank_account = m.bank_account ?? ''
form.registered_address = m.registered_address ?? ''
form.registered_phone = m.registered_phone ?? ''
} else {
resetForm()
}
}, { immediate: true, deep: true })
</script>
<style scoped>
</style>
+318
View File
@@ -0,0 +1,318 @@
<template>
<div class="crm-customer">
<div class="customer-container">
<!-- 顶部操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">新增客户</el-button>
<el-button @click="handleRefresh">刷新</el-button>
<div class="search-bar">
<el-input v-model="searchQuery" placeholder="搜索客户名称/联系人/电话" clearable @clear="handleSearch">
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
</div>
</div>
<!-- 客户列表 -->
<el-table :data="customerList" v-loading="loading" stripe border style="width: 100%">
<el-table-column prop="name" label="客户名称" width="220" />
<el-table-column prop="contact" label="联系人" width="100" />
<el-table-column prop="phone" label="联系电话" min-width="80" />
<el-table-column prop="email" label="邮箱" min-width="100" />
<el-table-column prop="address" label="地址" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="info" @click="handleContactView(row)">联系人</el-button>
<el-button link type="info" @click="handleView(row)">详情</el-button>
<el-button link type="info" @click="handleInvoiceView(row)">开票信息</el-button>
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :total="total"
:page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</div>
<!-- 编辑弹窗 -->
<Edit v-model="dialogVisible" :is-edit="isEdit" :model="currentRow" @saved="onSaved" />
<!-- 详情抽屉 -->
<Detail v-model="detailVisible" :model="currentRow" />
<!-- 开票信息抽屉 -->
<Invoice v-model="invoiceVisible" :model="currentRow" />
<!-- 联系人抽屉 -->
<Contact v-model="contactVisible" :model="currentRow" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Search } from '@element-plus/icons-vue';
import Edit from './components/edit.vue'
import Detail from './components/detail.vue'
import Invoice from './components/invoice.vue'
import Contact from './components/contact.vue'
import { listCustomers, getCustomer, deleteCustomer } from '@/api/customer.js'
const loading = ref(false);
const customerList = ref([]);
const searchQuery = ref('');
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
const dialogVisible = ref(false);
const detailVisible = ref(false);
const contactVisible = ref(false);
const invoiceVisible = ref(false);
const isEdit = ref(false);
const currentRow = ref(null);
function fetchCustomerList() {
loading.value = true;
const params = {
keyword: searchQuery.value,
page: currentPage.value,
pageSize: pageSize.value,
}
listCustomers(params)
.then((res) => {
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res)
if (resp && resp.code === 0 && resp.data) {
const rows = resp.data.list || []
total.value = resp.data.total || 0
customerList.value = rows.map((m) => ({
id: m.id,
name: m.customer_name || m.name || '',
contact: m.contact_person || m.contact || '',
phone: m.contact_phone || m.phone || '',
email: m.contact_email || m.email || '',
address: m.address || '',
status: typeof m.status === 'number' ? m.status : (m.status === '0' ? 0 : 1),
_raw: m,
}))
} else {
customerList.value = []
total.value = 0
if (resp && resp.message) ElMessage.error(resp.message)
}
})
.finally(() => {
loading.value = false;
})
}
function handleAdd() {
isEdit.value = false;
currentRow.value = null;
dialogVisible.value = true;
}
function handleEdit(row) {
isEdit.value = true;
// 获取详情,保证带上字典字段值
getCustomer(row.id).then((res) => {
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res)
if (resp && resp.code === 0 && resp.data) {
const m = resp.data
currentRow.value = {
id: m.id,
name: m.customer_name || m.name || '',
contact: m.contact_person || m.contact || '',
phone: m.contact_phone || m.phone || '',
email: m.contact_email || m.email || '',
address: m.address || '',
status: typeof m.status === 'number' ? m.status : (m.status === '0' ? 0 : 1),
// 传递字典字段原始值
customer_type: m.customer_type,
customer_level: m.customer_level,
industry: m.industry,
_raw: m,
}
} else {
// 回退使用列表行数据
currentRow.value = { ...row }
}
dialogVisible.value = true;
})
}
function handleView(row) {
// 拉取最新详情再展示
getCustomer(row.id).then((res) => {
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res)
if (resp && resp.code === 0 && resp.data) {
const m = resp.data
currentRow.value = {
id: m.id,
name: m.customer_name || m.name || '',
contact: m.contact_person || m.contact || '',
phone: m.contact_phone || m.phone || '',
email: m.contact_email || m.email || '',
address: m.address || '',
status: typeof m.status === 'number' ? m.status : (m.status === '0' ? 0 : 1),
// 传递字典字段原始值供详情组件显示
customer_type: m.customer_type,
customer_level: m.customer_level,
industry: m.industry,
_raw: m,
}
detailVisible.value = true
}
})
}
function handleContactView(row) {
// 先打开联系人抽屉,再异步拉详情以便得到 _raw.contacts
currentRow.value = { ...row }
contactVisible.value = true
getCustomer(row.id).then((res) => {
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res)
if (resp && resp.code === 0 && resp.data) {
const m = resp.data
currentRow.value = {
id: m.id,
name: m.customer_name || m.name || '',
contact: m.contact_person || m.contact || '',
phone: m.contact_phone || m.phone || '',
email: m.contact_email || m.email || '',
address: m.address || '',
status: typeof m.status === 'number' ? m.status : (m.status === '0' ? 0 : 1),
customer_type: m.customer_type,
customer_level: m.customer_level,
industry: m.industry,
_raw: m,
}
}
})
}
function handleInvoiceView(row) {
// 先打开抽屉,后异步加载详情
currentRow.value = { ...row }
invoiceVisible.value = true
getCustomer(row.id)
.then((res) => {
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res)
if (resp && resp.code === 0 && resp.data) {
const m = resp.data
currentRow.value = {
id: m.id,
name: m.customer_name || m.name || '',
contact: m.contact_person || m.contact || '',
phone: m.contact_phone || m.phone || '',
email: m.contact_email || m.email || '',
address: m.address || '',
status: typeof m.status === 'number' ? m.status : (m.status === '0' ? 0 : 1),
customer_type: m.customer_type,
customer_level: m.customer_level,
industry: m.industry,
invoice_title: m.invoice_title,
tax_number: m.tax_number,
bank_name: m.bank_name,
bank_account: m.bank_account,
registered_address: m.registered_address,
registered_phone: m.registered_phone,
_raw: m,
}
}
})
.catch(() => {})
}
function handleDelete(row) {
ElMessageBox.confirm('确定要删除该客户吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteCustomer(row.id)
.then((res) => {
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res)
if (resp && resp.code === 0) {
ElMessage.success('删除成功');
fetchCustomerList();
} else {
ElMessage.error((resp && resp.message) || '删除失败')
}
})
});
}
function onSaved() {
fetchCustomerList();
}
function handleSearch() {
currentPage.value = 1;
fetchCustomerList();
}
function handleRefresh() {
searchQuery.value = '';
currentPage.value = 1;
fetchCustomerList();
}
function handleSizeChange() {
fetchCustomerList();
}
function handleCurrentChange() {
fetchCustomerList();
}
onMounted(() => {
fetchCustomerList();
});
</script>
<style lang="scss" scoped>
.crm-customer {
padding: 20px;
height: 100%;
background: #f5f7fa;
.customer-container {
background: #fff;
border-radius: 8px;
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 20px;
.search-bar {
margin-left: auto;
width: 300px;
}
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
}
</style>
+7
View File
@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup></script>
<style lang="scss" scoped></style>
@@ -0,0 +1,201 @@
<template>
<el-drawer
v-model="visible"
title="联系人管理"
size="600px"
:close-on-click-modal="false"
>
<div class="contact-container">
<el-button type="primary" @click="handleAdd" style="margin-bottom: 16px">
新增联系人
</el-button>
<el-table :data="contactList" border>
<el-table-column prop="name" label="姓名" />
<el-table-column prop="phone" label="电话" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="position" label="职位" />
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑联系人' : '新增联系人'"
width="500px"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="电话" prop="phone">
<el-input v-model="form.phone" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" />
</el-form-item>
<el-form-item label="职位" prop="position">
<el-input v-model="form.position" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-select v-model="form.gender" placeholder="请选择性别" clearable style="width: 100%">
<el-option label="未知" :value="0" />
<el-option label="男" :value="1" />
<el-option label="女" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="部门" prop="department">
<el-input v-model="form.department" />
</el-form-item>
<el-form-item label="主联系人" prop="is_primary">
<el-switch v-model="form.is_primary" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmit">确定</el-button>
</template>
</el-dialog>
</el-drawer>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listContacts, createContact, updateContact, deleteContact } from '@/api/contact.js'
const props = defineProps({
modelValue: Boolean,
model: Object
})
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const contactList = ref([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const form = ref({
id: null,
name: '',
phone: '',
email: '',
position: '',
gender: 0,
department: '',
is_primary: 0,
})
const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
}
function handleAdd() {
isEdit.value = false
form.value = { id: null, name: '', phone: '', email: '', position: '' }
dialogVisible.value = true
}
function handleEdit(row) {
isEdit.value = true
form.value = { ...row }
dialogVisible.value = true
}
function getTenantId() {
try {
const ui = localStorage.getItem('userInfo')
if (ui) {
const u = JSON.parse(ui)
const tid = u.tenant_id || u.tenantId || ''
return tid != null && tid !== undefined ? String(tid) : ''
}
} catch (e) {}
return ''
}
async function fetchList() {
const tenant_id = getTenantId()
if (!props.model?.id || !tenant_id) return
const params = { tenant_id, related_type: 2, related_id: props.model.id }
const res = await listContacts(params)
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res)
if (resp && resp.code === 0) {
contactList.value = (resp.data || []).map(it => ({
id: it.id,
name: it.contact_name,
phone: it.mobile || it.phone || '',
email: it.email || '',
position: it.position || '',
gender: Number(it.gender || 0),
department: it.department || '',
is_primary: Number(it.is_primary || 0),
_raw: it,
}))
}
}
function handleDelete(row) {
ElMessageBox.confirm('确认删除该联系人?', '提示', {
type: 'warning'
}).then(async () => {
const tenant_id = getTenantId()
await deleteContact({ id: row.id, tenant_id })
ElMessage.success('删除成功')
fetchList()
emit('saved')
})
}
async function onSubmit() {
if (!formRef.value) return
await formRef.value.validate()
const tenant_id = getTenantId()
const base = {
tenant_id,
related_type: 2,
related_id: props.model?.id,
contact_name: form.value.name,
mobile: form.value.phone,
email: form.value.email,
position: form.value.position,
gender: form.value.gender,
department: form.value.department,
is_primary: form.value.is_primary,
}
if (isEdit.value) {
await updateContact({ id: form.value.id, ...base })
} else {
await createContact(base)
}
ElMessage.success(isEdit.value ? '修改成功' : '新增成功')
dialogVisible.value = false
fetchList()
emit('saved')
}
watch(() => props.model, (m) => {
if (m && m.id) {
fetchList()
} else {
contactList.value = []
}
}, { immediate: true, deep: true })
</script>
<style scoped>
.contact-container {
padding: 0 20px;
}
</style>
@@ -0,0 +1,72 @@
<template>
<el-drawer v-model="visible" :title="title" size="40%" destroy-on-close>
<el-descriptions :column="2" border v-if="model">
<el-descriptions-item label="供应商名称">{{ model.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ model.contact || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ model.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ model.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="供应商类型">{{ getLabel(supplierTypeOptions, model.supplier_type) }}</el-descriptions-item>
<el-descriptions-item label="供应商等级">{{ getLabel(supplierLevelOptions, model.supplier_level) }}</el-descriptions-item>
<el-descriptions-item label="所属行业">{{ getLabel(industryOptions, model.industry) }}</el-descriptions-item>
<el-descriptions-item label="供应商状态">
<el-tag :type="(model.status===1||model.status==='1') ? 'success' : 'info'">
{{ getLabel(supplierStatusOptions, String(model.status ?? '')) || ((model.status===1||model.status==='1') ? '正常' : '停用') }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ model.address || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="visible=false">关闭</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import request from '@/utils/request'
const props = defineProps<{ modelValue: boolean, model?: any }>()
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v)
})
const title = computed(() => `供应商详情${props.model?.name ? ' - ' + props.model.name : ''}`)
const supplierTypeOptions = ref<{label:string,value:string}[]>([])
const supplierLevelOptions = ref<{label:string,value:string}[]>([])
const industryOptions = ref<{label:string,value:string}[]>([])
const supplierStatusOptions = ref<{label:string,value:string}[]>([])
function mapDictItems(items: any[]) {
return (items || []).map(it => ({ label: it.dict_label, value: String(it.dict_value) }))
}
function getLabel(options: {label:string,value:string}[], value: any) {
const val = value != null && value !== undefined ? String(value) : ''
const found = options.find(o => o.value === val)
return found ? found.label : '-'
}
async function loadDictOptions() {
const [typeRes, levelRes, industryRes, statusRes] = await Promise.all([
request({ url: '/api/dict/items/code/supplier_type', method: 'get' }),
request({ url: '/api/dict/items/code/supplier_level', method: 'get' }),
request({ url: '/api/dict/items/code/industry', method: 'get' }),
request({ url: '/api/dict/items/code/supplier_status', method: 'get' }),
])
const getData = (r:any) => (r && r.data && (r.data.data || r.data)) || []
supplierTypeOptions.value = mapDictItems(getData(typeRes))
supplierLevelOptions.value = mapDictItems(getData(levelRes))
industryOptions.value = mapDictItems(getData(industryRes))
supplierStatusOptions.value = mapDictItems(getData(statusRes))
}
watch(() => visible.value, (v) => {
if (v) loadDictOptions()
})
</script>
<style scoped>
</style>
@@ -0,0 +1,204 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑供应商' : '新增供应商'"
width="600px"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="供应商名称" prop="name">
<el-input v-model="form.name" placeholder="请输入供应商名称" />
</el-form-item>
<el-form-item label="供应商类型" prop="supplier_type">
<el-select v-model="form.supplier_type" placeholder="请选择供应商类型" filterable clearable>
<el-option v-for="item in supplierTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="供应商等级" prop="supplier_level">
<el-select v-model="form.supplier_level" placeholder="请选择供应商等级" filterable clearable>
<el-option v-for="item in supplierLevelOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="所属行业" prop="industry">
<el-select v-model="form.industry" placeholder="请选择所属行业" filterable clearable>
<el-option v-for="item in industryOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="联系人" prop="contact">
<el-input v-model="form.contact" placeholder="请输入联系人" />
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入联系电话" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="地址" prop="address">
<el-input v-model="form.address" type="textarea" placeholder="请输入地址" />
</el-form-item>
<el-form-item label="供应商状态" prop="status">
<el-select v-model="form.status" placeholder="请选择供应商状态" clearable>
<el-option v-for="item in supplierStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onCancel">取消</el-button>
<el-button type="primary" @click="onSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed, onMounted } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { createSupplier, updateSupplier } from '../../../../../api/supplier.js'
import request from '@/utils/request'
const props = defineProps<{ modelValue: boolean, isEdit: boolean, model?: any }>()
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v)
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
id: null as string | null,
name: '',
supplier_type: '',
supplier_level: '',
industry: '',
contact: '',
phone: '',
email: '',
address: '',
status: '' as any
})
const rules: FormRules = {
name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }],
supplier_type: [{ required: true, message: '请选择供应商类型', trigger: 'change' }],
supplier_level: [{ required: true, message: '请选择供应商等级', trigger: 'change' }],
industry: [{ required: true, message: '请选择所属行业', trigger: 'change' }],
contact: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
phone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
}
const supplierTypeOptions = ref<{label:string,value:string}[]>([])
const supplierLevelOptions = ref<{label:string,value:string}[]>([])
const industryOptions = ref<{label:string,value:string}[]>([])
const supplierStatusOptions = ref<{label:string,value:string}[]>([])
function mapDictItems(items: any[]) {
return (items || []).map(it => ({ label: it.dict_label, value: String(it.dict_value) }))
}
async function loadDictOptions() {
const [typeRes, levelRes, industryRes, statusRes] = await Promise.all([
request({ url: '/api/dict/items/code/supplier_type', method: 'get' }),
request({ url: '/api/dict/items/code/supplier_level', method: 'get' }),
request({ url: '/api/dict/items/code/industry', method: 'get' }),
request({ url: '/api/dict/items/code/supplier_status', method: 'get' }),
])
const getData = (r:any) => (r && r.data && (r.data.data || r.data)) || []
supplierTypeOptions.value = mapDictItems(getData(typeRes))
supplierLevelOptions.value = mapDictItems(getData(levelRes))
industryOptions.value = mapDictItems(getData(industryRes))
supplierStatusOptions.value = mapDictItems(getData(statusRes))
}
watch(() => props.model, (m) => {
if (m) {
form.id = m.id ?? null
form.name = (m.supplier_name ?? m.name) ?? ''
form.supplier_type = m.supplier_type != null && m.supplier_type !== undefined ? String(m.supplier_type) : ''
form.supplier_level = m.supplier_level != null && m.supplier_level !== undefined ? String(m.supplier_level) : ''
form.industry = m.industry != null && m.industry !== undefined ? String(m.industry) : ''
form.contact = (m.contact_person ?? m.contact) ?? ''
form.phone = (m.contact_phone ?? m.phone) ?? ''
form.email = (m.contact_email ?? m.email) ?? ''
form.address = m.address ?? ''
form.status = m.status != null && m.status !== undefined ? String(m.status) : ''
} else {
resetForm()
}
}, { immediate: true, deep: true })
function resetForm() {
form.id = null
form.name = ''
form.supplier_type = ''
form.supplier_level = ''
form.industry = ''
form.contact = ''
form.phone = ''
form.email = ''
form.address = ''
form.status = ''
formRef.value?.clearValidate()
}
async function onSubmit() {
if (!formRef.value) return
await formRef.value.validate()
try {
submitting.value = true
const tenantId = (() => {
try {
const ui = localStorage.getItem('userInfo')
if (ui) {
const u = JSON.parse(ui)
const tid = u.tenant_id || u.tenantId || ''
return tid != null && tid !== undefined ? String(tid) : ''
}
} catch (e) {}
return ''
})()
const payload: any = {
supplier_name: form.name,
supplier_type: form.supplier_type,
supplier_level: form.supplier_level,
industry: form.industry,
contact_person: form.contact,
contact_phone: form.phone,
contact_email: form.email,
address: form.address,
status: String(form.status),
tenant_id: tenantId,
}
if (props.isEdit && form.id) {
await updateSupplier(form.id, payload)
} else {
await createSupplier(payload)
}
ElMessage.success(props.isEdit ? '修改成功' : '新增成功')
emit('saved')
visible.value = false
} finally {
submitting.value = false
}
}
function onCancel() {
visible.value = false
}
watch(() => visible.value, (v) => {
if (v) {
loadDictOptions()
}
})
onMounted(() => {
if (visible.value) {
loadDictOptions()
}
})
</script>
<style scoped>
</style>
@@ -0,0 +1,107 @@
<template>
<el-drawer
v-model="visible"
title="开票信息"
size="600px"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="发票抬头" prop="invoice_title">
<el-input v-model="form.invoice_title" placeholder="请输入发票抬头" />
</el-form-item>
<el-form-item label="纳税人识别号" prop="tax_number">
<el-input v-model="form.tax_number" placeholder="请输入纳税人识别号" />
</el-form-item>
<el-form-item label="开户银行" prop="bank_name">
<el-input v-model="form.bank_name" placeholder="请输入开户银行" />
</el-form-item>
<el-form-item label="银行账号" prop="bank_account">
<el-input v-model="form.bank_account" placeholder="请输入银行账号" />
</el-form-item>
<el-form-item label="注册地址" prop="registered_address">
<el-input v-model="form.registered_address" type="textarea" placeholder="请输入注册地址" />
</el-form-item>
<el-form-item label="注册电话" prop="registered_phone">
<el-input v-model="form.registered_phone" placeholder="请输入注册电话" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onCancel">取消</el-button>
<el-button type="primary" @click="onSubmit" :loading="submitting">保存</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
const props = defineProps<{ modelValue: boolean, model?: any }>()
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v)
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
invoice_title: '',
tax_number: '',
bank_name: '',
bank_account: '',
registered_address: '',
registered_phone: ''
})
const rules: FormRules = {
invoice_title: [{ required: true, message: '请输入发票抬头', trigger: 'blur' }],
tax_number: [{ required: true, message: '请输入纳税人识别号', trigger: 'blur' }]
}
function resetForm() {
form.invoice_title = ''
form.tax_number = ''
form.bank_name = ''
form.bank_account = ''
form.registered_address = ''
form.registered_phone = ''
formRef.value?.clearValidate()
}
function onCancel() {
visible.value = false
}
async function onSubmit() {
if (!formRef.value) return
await formRef.value.validate()
try {
submitting.value = true
// TODO: 调用保存开票信息的API
ElMessage.success('保存成功')
emit('saved')
visible.value = false
} finally {
submitting.value = false
}
}
watch(() => props.model, (m) => {
if (m) {
form.invoice_title = m.invoice_title ?? ''
form.tax_number = m.tax_number ?? ''
form.bank_name = m.bank_name ?? ''
form.bank_account = m.bank_account ?? ''
form.registered_address = m.registered_address ?? ''
form.registered_phone = m.registered_phone ?? ''
} else {
resetForm()
}
}, { immediate: true, deep: true })
</script>
<style scoped>
</style>
+310
View File
@@ -0,0 +1,310 @@
<template>
<div class="crm-customer">
<div class="customer-container">
<!-- 顶部操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">新增供应商</el-button>
<el-button @click="handleRefresh">刷新</el-button>
<div class="search-bar">
<el-input
v-model="searchQuery"
placeholder="搜索供应商名称/联系人/电话"
clearable
@clear="handleSearch"
>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
</div>
</div>
<!-- 供应商列表 -->
<el-table
:data="customerList"
v-loading="loading"
stripe
border
style="width: 100%"
>
<el-table-column prop="name" label="供应商名称" width="180" />
<el-table-column prop="contact" label="联系人" width="120" />
<el-table-column prop="phone" label="联系电话" width="150" />
<el-table-column prop="email" label="邮箱" width="200" />
<el-table-column prop="address" label="地址" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '正常' : '停用' }}
function handleContactView(row) {
currentRow.value = { ...row }
contactVisible.value = true
getSupplier(row.id).then((res) => {
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res)
if (resp && resp.code === 0 && resp.data) {
const m = resp.data
currentRow.value = {
id: m.id,
name: m.supplier_name || m.name || '',
contact: m.contact_person || m.contact || '',
phone: m.contact_phone || m.phone || '',
email: m.contact_email || m.email || '',
address: m.address || '',
status: typeof m.status === 'number' ? m.status : (m.status === '0' ? 0 : 1),
supplier_type: m.supplier_type,
supplier_level: m.supplier_level,
industry: m.industry,
_raw: m,
}
}
})
}
function handleInvoiceView(row) {
currentRow.value = { ...row }
invoiceVisible.value = true
getSupplier(row.id).then((res) => {
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res)
if (resp && resp.code === 0 && resp.data) {
const m = resp.data
currentRow.value = {
id: m.id,
name: m.supplier_name || m.name || '',
contact: m.contact_person || m.contact || '',
phone: m.contact_phone || m.phone || '',
email: m.contact_email || m.email || '',
address: m.address || '',
status: typeof m.status === 'number' ? m.status : (m.status === '0' ? 0 : 1),
supplier_type: m.supplier_type,
supplier_level: m.supplier_level,
industry: m.industry,
// 开票相关(如果后端返回则透传给抽屉)
invoice_title: m.invoice_title,
tax_number: m.tax_number,
bank_name: m.bank_name,
bank_account: m.bank_account,
registered_address: m.registered_address,
registered_phone: m.registered_phone,
_raw: m,
}
}
})
}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button link type="info" @click="handleContactView(row)">联系人</el-button>
<el-button link type="info" @click="handleView(row)">详情</el-button>
<el-button link type="info" @click="handleInvoiceView(row)">开票信息</el-button>
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 编辑弹窗 -->
<Edit
v-model="dialogVisible"
:is-edit="isEdit"
:model="currentRow"
@saved="onSaved"
/>
<!-- 详情抽屉 -->
<Detail v-model="detailVisible" :model="currentRow" />
<!-- 联系人抽屉 -->
<Contact v-model="contactVisible" :model="currentRow" />
<!-- 开票信息抽屉 -->
<Invoice v-model="invoiceVisible" :model="currentRow" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Search } from '@element-plus/icons-vue';
import Edit from './components/edit.vue'
import Detail from './components/detail.vue'
import Contact from './components/contact.vue'
import Invoice from './components/invoice.vue'
import { listSuppliers, getSupplier, deleteSupplier } from '../../../../api/supplier.js'
const loading = ref(false);
const customerList = ref([]);
const searchQuery = ref('');
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
const dialogVisible = ref(false);
const detailVisible = ref(false);
const contactVisible = ref(false);
const invoiceVisible = ref(false);
const isEdit = ref(false);
const currentRow = ref(null);
function fetchCustomerList() {
loading.value = true;
const params = {
keyword: searchQuery.value,
page: currentPage.value,
pageSize: pageSize.value,
}
listSuppliers(params)
.then((res) => {
const resp = res && res.data ? res.data : res
if (resp && resp.code === 0 && resp.data) {
const rows = resp.data.list || []
total.value = resp.data.total || 0
customerList.value = rows.map((m) => ({
id: m.id,
name: m.supplier_name || m.name || '',
contact: m.contact_person || m.contact || '',
phone: m.contact_phone || m.phone || '',
email: m.contact_email || m.email || '',
address: m.address || '',
status: typeof m.status === 'number' ? m.status : (m.status === '0' ? 0 : 1),
_raw: m,
}))
} else {
customerList.value = []
total.value = 0
if (resp && resp.message) ElMessage.error(resp.message)
}
})
.finally(() => {
loading.value = false;
})
}
function handleAdd() {
isEdit.value = false;
currentRow.value = null;
dialogVisible.value = true;
}
function handleEdit(row) {
isEdit.value = true;
currentRow.value = { ...row };
dialogVisible.value = true;
}
function handleView(row) {
// 拉取最新详情再展示
getSupplier(row.id).then((res) => {
const resp = res && res.data ? res.data : res
if (resp && resp.code === 0 && resp.data) {
const m = resp.data
currentRow.value = {
id: m.id,
name: m.supplier_name || m.name || '',
contact: m.contact_person || m.contact || '',
phone: m.contact_phone || m.phone || '',
email: m.contact_email || m.email || '',
address: m.address || '',
status: typeof m.status === 'number' ? m.status : (m.status === '0' ? 0 : 1),
_raw: m,
}
detailVisible.value = true
}
})
}
function handleDelete(row) {
ElMessageBox.confirm('确定要删除该供应商吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteSupplier(row.id)
.then((res) => {
const resp = res && res.data ? res.data : res
if (resp && resp.code === 0) {
ElMessage.success('删除成功');
fetchCustomerList();
} else {
ElMessage.error((resp && resp.message) || '删除失败')
}
})
});
}
function onSaved() {
fetchCustomerList();
}
function handleSearch() {
currentPage.value = 1;
fetchCustomerList();
}
function handleRefresh() {
searchQuery.value = '';
currentPage.value = 1;
fetchCustomerList();
}
function handleSizeChange() {
fetchCustomerList();
}
function handleCurrentChange() {
fetchCustomerList();
}
onMounted(() => {
fetchCustomerList();
});
</script>
<style lang="scss" scoped>
.crm-customer {
padding: 20px;
height: 100%;
background: #f5f7fa;
.customer-container {
background: #fff;
border-radius: 8px;
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 20px;
.search-bar {
margin-left: auto;
width: 300px;
}
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
}
</style>
File diff suppressed because it is too large Load Diff
+41 -24
View File
@@ -85,6 +85,18 @@
</template>
</el-table-column>
<el-table-column prop="IsShow" label="是否显示" width="100" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.IsShow"
:active-value="1"
:inactive-value="0"
@change="handleShowChange(scope.row)"
:disabled="!scope.row.Id"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right" align="center">
<template #default="scope">
<el-button
@@ -185,6 +197,14 @@
/>
</el-form-item>
<el-form-item label="是否显示" prop="IsShow">
<el-switch
v-model="currentMenu.IsShow"
:active-value="1"
:inactive-value="0"
/>
</el-form-item>
<el-form-item label="权限标识" prop="Permission">
<el-input
v-model="currentMenu.Permission"
@@ -223,6 +243,7 @@ interface Menu {
ExternalUrl: string;
MenuType: 1 | 2 | 3; // 1:普通菜单 2:分组菜单 3:按钮菜单
Permission: string;
IsShow: 0 | 1;
CreateTime: string;
UpdateTime: string;
children?: Menu[];
@@ -255,6 +276,7 @@ const currentMenu = ref<Partial<Menu>>({
ExternalUrl: "",
MenuType: 1,
Permission: "",
IsShow: 1,
});
// 表单验证规则
@@ -303,14 +325,15 @@ const fetchMenus = async () => {
ParentId: item.parentId,
Icon: item.icon,
Order: item.order,
Status: 1,
Status: item.status ?? 1,
ComponentPath: item.componentPath || "",
IsExternal: item.isExternal || 0,
ExternalUrl: item.externalUrl || "",
MenuType: item.menuType,
Permission: item.permission || "",
CreateTime: "",
UpdateTime: "",
MenuType: item.menuType,
Permission: item.permission || "",
IsShow: item.isShow ?? 1,
CreateTime: "",
UpdateTime: "",
}));
// 按照参数Order的大小从小到大排序
@@ -447,25 +470,19 @@ const handleStatusChange = async (menu: Menu) => {
}
};
// 添加顶级菜单
const handleAddMenu = () => {
dialogTitle.value = "添加菜单";
currentMenu.value = {
Id: 0,
Name: "",
Path: "",
ParentId: 0,
Icon: "",
Order: 0,
Status: 1,
ComponentPath: "",
IsExternal: 0,
ExternalUrl: "",
MenuType: 1,
Permission: "",
};
dialogVisible.value = true;
};
// 处理是否显示变更
const handleShowChange = async (menu: Menu) => {
try {
const result = await updateMenu(menu.Id, { IsShow: menu.IsShow } as any)
if (!result.success) {
ElMessage.error(result.message)
menu.IsShow = menu.IsShow === 1 ? 0 : 1
}
} catch (error) {
ElMessage.error('更新是否显示失败: ' + (error as Error).message)
menu.IsShow = menu.IsShow === 1 ? 0 : 1
}
}
// 添加子菜单
const handleAddSubMenu = (parentMenu: Menu) => {