增加商机线索管理修复若干bug
This commit is contained in:
Generated
+7
@@ -24,6 +24,7 @@
|
||||
"os": "^0.1.2",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"pinia": "^3.0.3",
|
||||
"pinyin-pro": "^3.29.3",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"v-viewer": "^3.0.11",
|
||||
"vue": "^3.5.22",
|
||||
@@ -6555,6 +6556,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinyin-pro": {
|
||||
"version": "3.29.3",
|
||||
"resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.29.3.tgz",
|
||||
"integrity": "sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"os": "^0.1.2",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"pinia": "^3.0.3",
|
||||
"pinyin-pro": "^3.29.3",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"v-viewer": "^3.0.11",
|
||||
"vue": "^3.5.22",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* CRM 业务管线接口:线索 → 商机 → 项目,回访贯穿全流程。
|
||||
*
|
||||
* 关联类型 related_type:1=线索 2=商机 3=项目
|
||||
*/
|
||||
|
||||
/* --------------------------------- 线索 --------------------------------- */
|
||||
|
||||
export function getClueList(params) {
|
||||
return request({ url: "/backend/crm/clue/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getClueDetail(id) {
|
||||
return request({ url: `/backend/crm/clue/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createClue(data) {
|
||||
return request({ url: "/backend/crm/clue", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateClue(id, data) {
|
||||
return request({ url: `/backend/crm/clue/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteClue(id) {
|
||||
return request({ url: `/backend/crm/clue/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 线索转化商机:会把线索客户名称落地为正式客户,并锁定线索 */
|
||||
export function convertClueToBusiness(id, data) {
|
||||
return request({ url: `/backend/crm/clue/${id}/convert`, method: "post", data });
|
||||
}
|
||||
|
||||
/* --------------------------------- 商机 --------------------------------- */
|
||||
|
||||
export function getBusinessList(params) {
|
||||
return request({ url: "/backend/crm/business/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getBusinessDetail(id) {
|
||||
return request({ url: `/backend/crm/business/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createBusiness(data) {
|
||||
return request({ url: "/backend/crm/business", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateBusiness(id, data) {
|
||||
return request({ url: `/backend/crm/business/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteBusiness(id) {
|
||||
return request({ url: `/backend/crm/business/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 商机转化项目 */
|
||||
export function convertBusinessToProject(id, data) {
|
||||
return request({ url: `/backend/crm/business/${id}/convert`, method: "post", data });
|
||||
}
|
||||
|
||||
/* --------------------------------- 项目 --------------------------------- */
|
||||
|
||||
export function getProjectList(params) {
|
||||
return request({ url: "/backend/crm/project/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function getProjectDetail(id) {
|
||||
return request({ url: `/backend/crm/project/${id}`, method: "get" });
|
||||
}
|
||||
|
||||
export function createProject(data) {
|
||||
return request({ url: "/backend/crm/project", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateProject(id, data) {
|
||||
return request({ url: `/backend/crm/project/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteProject(id) {
|
||||
return request({ url: `/backend/crm/project/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/* --------------------------------- 回访 --------------------------------- */
|
||||
|
||||
export function getFollowList(params) {
|
||||
return request({ url: "/backend/crm/follow/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function addFollow(data) {
|
||||
return request({ url: "/backend/crm/follow/add", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateFollow(data) {
|
||||
return request({ url: "/backend/crm/follow/edit", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteFollow(data) {
|
||||
return request({ url: "/backend/crm/follow/delete", method: "post", data });
|
||||
}
|
||||
|
||||
/* --------------------------------- 附件 --------------------------------- */
|
||||
|
||||
export function getAttachList(params) {
|
||||
return request({ url: "/backend/crm/attach/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function addAttach(data) {
|
||||
return request({ url: "/backend/crm/attach/add", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteAttach(data) {
|
||||
return request({ url: "/backend/crm/attach/delete", method: "post", data });
|
||||
}
|
||||
|
||||
/* ------------------------------ 联系人(实体) ------------------------------ */
|
||||
|
||||
export function getEntityContactList(params) {
|
||||
return request({ url: "/backend/crm/entity/contact/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function addEntityContact(data) {
|
||||
return request({ url: "/backend/crm/entity/contact/add", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateEntityContact(data) {
|
||||
return request({ url: "/backend/crm/entity/contact/edit", method: "post", data });
|
||||
}
|
||||
|
||||
export function deleteEntityContact(data) {
|
||||
return request({ url: "/backend/crm/entity/contact/delete", method: "post", data });
|
||||
}
|
||||
|
||||
/* ------------------------------- 操作日志 ------------------------------- */
|
||||
|
||||
export function getCrmOperateLogs(params) {
|
||||
return request({ url: "/backend/crm/oplog/list", method: "get", params });
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="商机转化项目"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleClose"
|
||||
>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="转化后商机将被锁定,不能再编辑,后续操作请在项目管理中进行。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="项目名称" prop="project_name">
|
||||
<el-input v-model="form.project_name" placeholder="请输入项目名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目编号">
|
||||
<el-input v-model="form.project_no" placeholder="请输入项目编号(选填)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目金额">
|
||||
<el-input v-model="form.amount" placeholder="请输入项目金额">
|
||||
<template #append>元</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker v-model="form.start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束日期">
|
||||
<el-date-picker v-model="form.end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注(选填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确认转化</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { convertBusinessToProject } from "@/api/crmPipeline";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
business: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["update:visible", "success"]);
|
||||
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
project_name: "",
|
||||
project_no: "",
|
||||
amount: "",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const rules = {
|
||||
project_name: [{ required: true, message: "请输入项目名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val && props.business) {
|
||||
form.project_name = props.business.business_name || "";
|
||||
form.project_no = "";
|
||||
form.amount = props.business.amount || "";
|
||||
form.start_date = "";
|
||||
form.end_date = "";
|
||||
form.remark = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res = await convertBusinessToProject(props.business.id, {
|
||||
...form,
|
||||
amount: Number(form.amount) || 0,
|
||||
});
|
||||
ElMessage.success("转化成功");
|
||||
emit("success", res?.data);
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "转化失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="isEdit ? '编辑商机' : '新增商机'"
|
||||
width="760px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleClose"
|
||||
@opened="handleOpened"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" label-position="right">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商机名称" prop="business_name">
|
||||
<el-input v-model="form.business_name" placeholder="请输入商机名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户" prop="customer_id">
|
||||
<el-select
|
||||
v-model="form.customer_id"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchCustomers"
|
||||
:loading="customerLoading"
|
||||
placeholder="输入客户名称搜索"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="c in customerOptions" :key="c.id" :label="c.customer_name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户来源" prop="source">
|
||||
<el-select v-model="form.source" clearable placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in CLUE_SOURCE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商机阶段" prop="stage">
|
||||
<el-select v-model="form.stage" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in BUSINESS_STAGE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商机级别" prop="level">
|
||||
<el-select v-model="form.level" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in PIPELINE_LEVEL_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="预计金额" prop="amount">
|
||||
<el-input v-model="form.amount" placeholder="请输入预计金额">
|
||||
<template #append>元</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="预计成交日期" prop="expect_deal_date">
|
||||
<el-date-picker
|
||||
v-model="form.expect_deal_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下次联系时间" prop="next_contact_time">
|
||||
<el-date-picker
|
||||
v-model="form.next_contact_time"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="选择时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人" prop="owner_user_id">
|
||||
<el-select v-model="form.owner_user_id" filterable placeholder="请选择负责人" style="width: 100%">
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.name" :value="String(u.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户行业" prop="industry">
|
||||
<el-input v-model="form.industry" placeholder="如:互联网、制造业" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人" prop="contact_person">
|
||||
<el-input v-model="form.contact_person" placeholder="请输入对接人" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人职位" prop="contact_position">
|
||||
<el-input v-model="form.contact_position" placeholder="如:采购经理、技术总监" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人手机" prop="contact_phone">
|
||||
<el-input v-model="form.contact_phone" placeholder="请输入手机号" maxlength="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人微信" prop="contact_wechat">
|
||||
<el-input v-model="form.contact_wechat" placeholder="请输入微信号" maxlength="64" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人QQ" prop="contact_qq">
|
||||
<el-input v-model="form.contact_qq" placeholder="请输入QQ号" maxlength="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="客户地址" prop="address">
|
||||
<el-input v-model="form.address" placeholder="请输入客户地址" maxlength="255" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注(选填)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { createBusiness, updateBusiness } from "@/api/crmPipeline";
|
||||
import { getCrmCustomerList } from "@/api/crm";
|
||||
import { getAllUsers } from "@/api/user";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
CLUE_SOURCE_OPTIONS,
|
||||
BUSINESS_STAGE_OPTIONS,
|
||||
PIPELINE_LEVEL_OPTIONS,
|
||||
} from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
editData: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["update:visible", "success"]);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const internalId = ref(null);
|
||||
const userOptions = ref([]);
|
||||
const customerOptions = ref([]);
|
||||
const customerLoading = ref(false);
|
||||
|
||||
const defaultForm = () => ({
|
||||
business_name: "",
|
||||
customer_id: "",
|
||||
customer_name: "",
|
||||
source: "",
|
||||
owner_user_id: "",
|
||||
owner_user_name: "",
|
||||
industry: "",
|
||||
stage: "1",
|
||||
level: "2",
|
||||
amount: "",
|
||||
expect_deal_date: "",
|
||||
next_contact_time: "",
|
||||
contact_person: "",
|
||||
contact_position: "",
|
||||
contact_phone: "",
|
||||
contact_wechat: "",
|
||||
contact_qq: "",
|
||||
address: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
const rules = {
|
||||
business_name: [{ required: true, message: "请输入商机名称", trigger: "blur" }],
|
||||
customer_id: [{ required: true, message: "请选择客户", trigger: "change" }],
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.editData) {
|
||||
isEdit.value = true;
|
||||
internalId.value = props.editData.id;
|
||||
Object.assign(form, defaultForm(), props.editData);
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
internalId.value = null;
|
||||
Object.assign(form, defaultForm());
|
||||
form.owner_user_id = authStore.user?.id ? String(authStore.user.id) : "";
|
||||
form.owner_user_name = authStore.user?.name || "";
|
||||
}
|
||||
loadUsers();
|
||||
searchCustomers(form.customer_name || "");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function loadUsers() {
|
||||
if (userOptions.value.length) return;
|
||||
try {
|
||||
const res = await getAllUsers();
|
||||
const data = res?.data || {};
|
||||
const list = Array.isArray(data) ? data : data.list || [];
|
||||
userOptions.value = list.map((u) => ({ id: u.uid || u.id, name: u.name || u.account || `用户${u.uid || u.id}` }));
|
||||
} catch (e) {
|
||||
userOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function searchCustomers(keyword) {
|
||||
customerLoading.value = true;
|
||||
try {
|
||||
const res = await getCrmCustomerList({ page: 1, pageSize: 20, keyword: keyword || "" });
|
||||
const data = res?.data || {};
|
||||
customerOptions.value = (data.list || []).map((c) => ({ id: c.id, customer_name: c.customer_name }));
|
||||
} catch (e) {
|
||||
customerOptions.value = [];
|
||||
} finally {
|
||||
customerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function handleOpened() {
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
const owner = userOptions.value.find((u) => String(u.id) === String(form.owner_user_id));
|
||||
if (owner) form.owner_user_name = owner.name;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = { ...form, amount: Number(form.amount) || 0 };
|
||||
if (internalId.value) {
|
||||
await updateBusiness(internalId.value, payload);
|
||||
ElMessage.success("更新成功");
|
||||
} else {
|
||||
await createBusiness(payload);
|
||||
ElMessage.success("创建成功");
|
||||
}
|
||||
emit("success");
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "操作失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="crm-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>商机管理</h2>
|
||||
<p>推进商机阶段,达成后可转化为项目;转化后商机将锁定不可编辑</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增商机</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-form :inline="true" :model="filters" @submit.prevent>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
clearable
|
||||
placeholder="搜索商机名称 / 客户 / 对接人"
|
||||
:prefix-icon="Search"
|
||||
style="width: 260px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="商机阶段">
|
||||
<el-select v-model="filters.stage" clearable placeholder="全部" style="width: 130px">
|
||||
<el-option v-for="i in BUSINESS_STAGE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="级别">
|
||||
<el-select v-model="filters.level" clearable placeholder="全部" style="width: 110px">
|
||||
<el-option v-for="i in PIPELINE_LEVEL_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 130px">
|
||||
<el-option v-for="i in BUSINESS_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="table-container" v-loading="loading">
|
||||
<el-table :data="tableData" stripe border row-key="id">
|
||||
<el-table-column label="商机名称" min-width="170" show-overflow-tooltip fixed>
|
||||
<template #default="{ row }">
|
||||
<span class="name-link" @click="openDetail(row)">{{ row.business_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="customer_name" label="客户名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="商机阶段" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="businessStageTag(row.stage)" size="small">{{ businessStageText(row.stage) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="级别" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="pipelineLevelTag(row.level)" size="small" effect="plain">
|
||||
{{ pipelineLevelText(row.level) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预计金额" width="130" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预计成交日期" width="130" align="center">
|
||||
<template #default="{ row }">{{ formatDateOnly(row.expect_deal_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" width="100">
|
||||
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下次联系时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="businessStatusTag(row.status)" size="small">{{ businessStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" :disabled="row.locked === 1" @click="openEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="warning"
|
||||
size="small"
|
||||
:disabled="row.locked === 1 || row.status === 2"
|
||||
@click="openConvert(row)"
|
||||
>
|
||||
转项目
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无商机" :image-size="80" /></template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSearch"
|
||||
@current-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BusinessEdit v-model:visible="editVisible" :edit-data="currentRow" @success="fetchList" />
|
||||
<BusinessConvert v-model:visible="convertVisible" :business="currentRow" @success="handleConvertSuccess" />
|
||||
|
||||
<el-dialog v-model="detailVisible" :title="currentRow?.business_name || '商机详情'" width="720px">
|
||||
<el-descriptions v-if="currentRow" :column="2" border size="small">
|
||||
<el-descriptions-item label="商机名称" :span="2">{{ currentRow.business_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户名称">{{ currentRow.customer_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户来源">{{ clueSourceText(currentRow.source) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商机阶段">
|
||||
<el-tag :type="businessStageTag(currentRow.stage)" size="small">{{ businessStageText(currentRow.stage) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="级别">
|
||||
<el-tag :type="pipelineLevelTag(currentRow.level)" size="small">{{ pipelineLevelText(currentRow.level) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="预计金额">{{ formatMoney(currentRow.amount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预计成交日期">{{ formatDateOnly(currentRow.expect_deal_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">{{ currentRow.owner_user_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下次联系时间">{{ formatDateTime(currentRow.next_contact_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人">{{ currentRow.contact_person || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人职位">{{ currentRow.contact_position || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人手机">{{ currentRow.contact_phone || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户地址" :span="2">{{ currentRow.address || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="businessStatusTag(currentRow.status)" size="small">{{ businessStatusText(currentRow.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="转化时间">{{ formatDateTime(currentRow.convert_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ currentRow.remark || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||
import { getBusinessList, deleteBusiness } from "@/api/crmPipeline";
|
||||
import BusinessEdit from "./components/edit.vue";
|
||||
import BusinessConvert from "./components/convert.vue";
|
||||
import {
|
||||
BUSINESS_STAGE_OPTIONS,
|
||||
BUSINESS_STATUS_OPTIONS,
|
||||
PIPELINE_LEVEL_OPTIONS,
|
||||
businessStageText,
|
||||
businessStageTag,
|
||||
businessStatusText,
|
||||
businessStatusTag,
|
||||
pipelineLevelText,
|
||||
pipelineLevelTag,
|
||||
clueSourceText,
|
||||
formatMoney,
|
||||
formatDateOnly,
|
||||
formatDateTime,
|
||||
} from "../dict";
|
||||
|
||||
const loading = ref(false);
|
||||
const tableData = ref([]);
|
||||
const editVisible = ref(false);
|
||||
const convertVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const currentRow = ref(null);
|
||||
|
||||
const filters = reactive({ keyword: "", stage: "", level: "", status: "" });
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getBusinessList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters,
|
||||
});
|
||||
tableData.value = res?.data?.list || [];
|
||||
pagination.total = res?.data?.total || 0;
|
||||
} catch (e) {
|
||||
tableData.value = [];
|
||||
pagination.total = 0;
|
||||
ElMessage.error(e.message || "查询失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = "";
|
||||
filters.stage = "";
|
||||
filters.level = "";
|
||||
filters.status = "";
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
currentRow.value = null;
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
currentRow.value = { ...row };
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
function openConvert(row) {
|
||||
currentRow.value = { ...row };
|
||||
convertVisible.value = true;
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
currentRow.value = { ...row };
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
function handleConvertSuccess() {
|
||||
fetchList();
|
||||
ElMessage.success("商机已转化为项目,可在项目管理中继续跟进");
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除商机「${row.business_name}」吗?删除后不可恢复。`, "删除确认", {
|
||||
type: "warning",
|
||||
});
|
||||
await deleteBusiness(row.id);
|
||||
ElMessage.success("删除成功");
|
||||
fetchList();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped src="../styles/crm-page.less"></style>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="线索转化商机"
|
||||
width="720px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleClose"
|
||||
>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="转化后线索将被锁定,不能再编辑,后续操作请在商机中进行。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-divider content-position="left">客户落地</el-divider>
|
||||
<el-form-item label="客户处理">
|
||||
<el-radio-group v-model="form.customer_mode">
|
||||
<el-radio value="create">转为正式客户</el-radio>
|
||||
<el-radio value="existing">关联已有客户</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="form.customer_mode === 'create'">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户名称" prop="customer.customer_name">
|
||||
<el-input v-model="form.customer.customer_name" placeholder="请输入客户名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户类型">
|
||||
<el-select v-model="form.customer.customer_type" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in CUSTOMER_TYPE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="所属行业">
|
||||
<el-input v-model="form.customer.industry" placeholder="请输入行业" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人">
|
||||
<el-input v-model="form.customer.contact_person" placeholder="请输入对接人" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="form.customer.contact_phone" placeholder="请输入联系电话" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="客户地址">
|
||||
<el-input v-model="form.customer.address" placeholder="请输入客户地址" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<el-form-item v-else label="关联客户" prop="customer_id">
|
||||
<el-select
|
||||
v-model="form.customer_id"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchCustomers"
|
||||
:loading="customerLoading"
|
||||
placeholder="输入客户名称搜索"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="c in customerOptions" :key="c.id" :label="c.customer_name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">商机信息</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商机名称" prop="business_name">
|
||||
<el-input v-model="form.business_name" placeholder="请输入商机名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商机阶段">
|
||||
<el-select v-model="form.stage" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in BUSINESS_STAGE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商机级别">
|
||||
<el-select v-model="form.level" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in PIPELINE_LEVEL_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="预计金额">
|
||||
<el-input v-model="form.amount" placeholder="请输入预计金额(元)">
|
||||
<template #append>元</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="预计成交日期">
|
||||
<el-date-picker
|
||||
v-model="form.expect_deal_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下次联系时间">
|
||||
<el-date-picker
|
||||
v-model="form.next_contact_time"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="选择时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注(选填)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确认转化</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { convertClueToBusiness } from "@/api/crmPipeline";
|
||||
import { getCrmCustomerList } from "@/api/crm";
|
||||
import {
|
||||
CUSTOMER_TYPE_OPTIONS,
|
||||
BUSINESS_STAGE_OPTIONS,
|
||||
PIPELINE_LEVEL_OPTIONS,
|
||||
} from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
clue: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["update:visible", "success"]);
|
||||
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const customerLoading = ref(false);
|
||||
const customerOptions = ref([]);
|
||||
|
||||
const defaultForm = () => ({
|
||||
business_name: "",
|
||||
stage: "1",
|
||||
level: "2",
|
||||
amount: "",
|
||||
expect_deal_date: "",
|
||||
next_contact_time: "",
|
||||
remark: "",
|
||||
customer_mode: "create",
|
||||
customer_id: "",
|
||||
customer: {
|
||||
customer_name: "",
|
||||
customer_type: "1",
|
||||
industry: "",
|
||||
contact_person: "",
|
||||
contact_phone: "",
|
||||
contact_email: "",
|
||||
address: "",
|
||||
remark: "",
|
||||
},
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
const validateCustomerName = (rule, value, callback) => {
|
||||
if (form.customer_mode === "create" && !value) {
|
||||
callback(new Error("请输入客户名称"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const rules = {
|
||||
business_name: [{ required: true, message: "请输入商机名称", trigger: "blur" }],
|
||||
"customer.customer_name": [{ validator: validateCustomerName, trigger: "blur" }],
|
||||
customer_id: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (form.customer_mode === "existing" && !value) {
|
||||
callback(new Error("请选择关联客户"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "change",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val && props.clue) {
|
||||
Object.assign(form, defaultForm());
|
||||
form.business_name = props.clue.clue_name || "";
|
||||
form.level = props.clue.clue_level || "2";
|
||||
form.next_contact_time = props.clue.next_contact_time
|
||||
? String(props.clue.next_contact_time).replace("T", " ").slice(0, 19)
|
||||
: "";
|
||||
form.customer.customer_name = props.clue.customer_name || "";
|
||||
form.customer.industry = props.clue.industry || "";
|
||||
form.customer.contact_person = props.clue.contact_person || "";
|
||||
form.customer.contact_phone = props.clue.contact_phone || "";
|
||||
form.customer.address = props.clue.address || "";
|
||||
customerOptions.value = [];
|
||||
searchCustomers("");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function searchCustomers(keyword) {
|
||||
customerLoading.value = true;
|
||||
try {
|
||||
const res = await getCrmCustomerList({ page: 1, pageSize: 20, keyword: keyword || "" });
|
||||
const data = res?.data || {};
|
||||
customerOptions.value = (data.list || []).map((c) => ({ id: c.id, customer_name: c.customer_name }));
|
||||
} catch (e) {
|
||||
customerOptions.value = [];
|
||||
} finally {
|
||||
customerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
business_name: form.business_name,
|
||||
stage: form.stage,
|
||||
level: form.level,
|
||||
amount: Number(form.amount) || 0,
|
||||
expect_deal_date: form.expect_deal_date,
|
||||
next_contact_time: form.next_contact_time,
|
||||
remark: form.remark,
|
||||
customer_mode: form.customer_mode,
|
||||
customer_id: form.customer_id || 0,
|
||||
customer: form.customer,
|
||||
};
|
||||
const res = await convertClueToBusiness(props.clue.id, payload);
|
||||
ElMessage.success("转化成功");
|
||||
emit("success", res?.data);
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "转化失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,554 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="visible"
|
||||
:title="clue?.clue_name || '线索详情'"
|
||||
direction="rtl"
|
||||
size="900px"
|
||||
@update:model-value="handleClose"
|
||||
@opened="loadAll"
|
||||
>
|
||||
<div v-if="clue" class="clue-detail">
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 基本信息 -->
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="线索名称" :span="2">{{ clue.clue_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户名称">{{ clue.customer_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户来源">{{ clueSourceText(clue.source) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户级别">
|
||||
<el-tag :type="pipelineLevelTag(clue.clue_level)" size="small">
|
||||
{{ pipelineLevelText(clue.clue_level) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">{{ clue.owner_user_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户行业">{{ clue.industry || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下次联系时间">{{ formatDateTime(clue.next_contact_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户对接人">{{ clue.contact_person || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人职位">{{ clue.contact_position || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人手机">{{ clue.contact_phone || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人微信">{{ clue.contact_wechat || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人QQ">{{ clue.contact_qq || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公司地址" :span="2">{{ clue.address || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="clueStatusTag(clue.status)" size="small">{{ clueStatusText(clue.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="转化时间">{{ formatDateTime(clue.convert_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ clue.remark || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(clue.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(clue.update_time) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 联系人 -->
|
||||
<el-tab-pane :label="`联系人 (${contacts.length})`" name="contact">
|
||||
<div class="tab-toolbar">
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="openContactEdit(null)">新增联系人</el-button>
|
||||
</div>
|
||||
<el-table :data="contacts" v-loading="loading.contact" stripe border size="small">
|
||||
<el-table-column label="姓名" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.contact_name }}
|
||||
<el-tag v-if="Number(row.is_primary) === 1" type="danger" size="small" effect="plain">主</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="position" label="职位" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="mobile" label="手机号" width="130" />
|
||||
<el-table-column prop="wechat" label="微信" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="qq" label="QQ" width="110" />
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openContactEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleContactDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无联系人" :image-size="60" /></template>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 跟进记录 -->
|
||||
<el-tab-pane :label="`跟进记录 (${follows.length})`" name="follow">
|
||||
<div class="tab-toolbar">
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="openFollowEdit(null)">新增回访</el-button>
|
||||
</div>
|
||||
<el-table :data="follows" v-loading="loading.follow" stripe border size="small">
|
||||
<el-table-column label="回访时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.follow_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="方式" width="80" align="center">
|
||||
<template #default="{ row }">{{ followTypeText(row.follow_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回访内容" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="follow-content" v-html="row.content"></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下次联系" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="owner_user_name" label="回访人" width="100" />
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openFollowEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleFollowDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无跟进记录" :image-size="60" /></template>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 附件 -->
|
||||
<el-tab-pane :label="`附件 (${attaches.length})`" name="attach">
|
||||
<div class="tab-toolbar">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="handleUpload"
|
||||
:disabled="uploading"
|
||||
>
|
||||
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
|
||||
</el-upload>
|
||||
<span class="tip">用于存放前期资料,如方案、报价单、图纸等</span>
|
||||
</div>
|
||||
<el-table :data="attaches" v-loading="loading.attach" stripe border size="small">
|
||||
<el-table-column label="文件名" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<a :href="row.file_url" target="_blank" class="file-link">{{ row.file_name || "-" }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatFileSize(row.file_size) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="uploader_name" label="上传人" width="100" />
|
||||
<el-table-column label="上传时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" size="small" @click="handleAttachDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无附件" :image-size="60" /></template>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 操作日志 -->
|
||||
<el-tab-pane label="操作日志" name="log">
|
||||
<el-timeline v-loading="loading.log">
|
||||
<el-timeline-item
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
:timestamp="formatDateTime(log.create_time)"
|
||||
placement="top"
|
||||
>
|
||||
<div class="log-item">
|
||||
<span class="log-user">{{ log.operator_name || "系统" }}</span>
|
||||
<span>{{ log.content || log.action }}</span>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
<el-empty v-if="!logs.length && !loading.log" description="暂无操作日志" :image-size="60" />
|
||||
</el-timeline>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 联系人编辑 -->
|
||||
<el-dialog v-model="contactDialog" :title="contactForm.id ? '编辑联系人' : '新增联系人'" width="520px" append-to-body>
|
||||
<el-form :model="contactForm" label-width="90px">
|
||||
<el-form-item label="姓名" required>
|
||||
<el-input v-model="contactForm.contact_name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="职位"><el-input v-model="contactForm.position" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="contactForm.mobile" /></el-form-item>
|
||||
<el-form-item label="微信"><el-input v-model="contactForm.wechat" /></el-form-item>
|
||||
<el-form-item label="QQ"><el-input v-model="contactForm.qq" /></el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="contactForm.email" /></el-form-item>
|
||||
<el-form-item label="主联系人">
|
||||
<el-switch v-model="contactForm.is_primary" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="contactForm.remark" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="contactDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleContactSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 回访编辑 -->
|
||||
<el-dialog v-model="followDialog" :title="followForm.id ? '编辑回访' : '新增回访'" width="520px" append-to-body>
|
||||
<el-form :model="followForm" label-width="90px">
|
||||
<el-form-item label="回访方式">
|
||||
<el-select v-model="followForm.follow_type" style="width: 100%">
|
||||
<el-option v-for="i in FOLLOW_TYPE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="回访时间">
|
||||
<el-date-picker
|
||||
v-model="followForm.follow_time"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="回访内容" required>
|
||||
<RichContentEditor
|
||||
v-model="followForm.content"
|
||||
:min-height="120"
|
||||
placeholder="请输入回访内容,可粘贴或上传图片"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="下次联系">
|
||||
<el-date-picker
|
||||
v-model="followForm.next_contact_time"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="followDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleFollowSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Upload } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getEntityContactList,
|
||||
addEntityContact,
|
||||
updateEntityContact,
|
||||
deleteEntityContact,
|
||||
getFollowList,
|
||||
addFollow,
|
||||
updateFollow,
|
||||
deleteFollow,
|
||||
getAttachList,
|
||||
addAttach,
|
||||
deleteAttach,
|
||||
getCrmOperateLogs,
|
||||
} from "@/api/crmPipeline";
|
||||
import { uploadFile } from "@/api/file";
|
||||
import RichContentEditor from "../../components/RichContentEditor.vue";
|
||||
import {
|
||||
clueSourceText,
|
||||
pipelineLevelText,
|
||||
pipelineLevelTag,
|
||||
clueStatusText,
|
||||
clueStatusTag,
|
||||
followTypeText,
|
||||
FOLLOW_TYPE_OPTIONS,
|
||||
formatDateTime,
|
||||
} from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
clue: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["update:visible", "refresh"]);
|
||||
|
||||
const RELATED_TYPE = 1; // 线索
|
||||
|
||||
const activeTab = ref("basic");
|
||||
const loading = reactive({ contact: false, follow: false, attach: false, log: false });
|
||||
const contacts = ref([]);
|
||||
const follows = ref([]);
|
||||
const attaches = ref([]);
|
||||
const logs = ref([]);
|
||||
const saving = ref(false);
|
||||
const uploading = ref(false);
|
||||
|
||||
const contactDialog = ref(false);
|
||||
const contactForm = reactive({
|
||||
id: null,
|
||||
contact_name: "",
|
||||
position: "",
|
||||
mobile: "",
|
||||
wechat: "",
|
||||
qq: "",
|
||||
email: "",
|
||||
is_primary: 0,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const followDialog = ref(false);
|
||||
const followForm = reactive({
|
||||
id: null,
|
||||
follow_type: "1",
|
||||
follow_time: "",
|
||||
content: "",
|
||||
next_contact_time: "",
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
activeTab.value = "basic";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function currentId() {
|
||||
return props.clue?.id;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
if (!currentId()) return;
|
||||
loadContacts();
|
||||
loadFollows();
|
||||
loadAttaches();
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
/* ------------------------------ 联系人 ------------------------------ */
|
||||
async function loadContacts() {
|
||||
loading.contact = true;
|
||||
try {
|
||||
const res = await getEntityContactList({ related_type: RELATED_TYPE, related_id: currentId() });
|
||||
contacts.value = res?.data?.list || [];
|
||||
} catch (e) {
|
||||
contacts.value = [];
|
||||
} finally {
|
||||
loading.contact = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openContactEdit(row) {
|
||||
Object.assign(contactForm, {
|
||||
id: null,
|
||||
contact_name: "",
|
||||
position: "",
|
||||
mobile: "",
|
||||
wechat: "",
|
||||
qq: "",
|
||||
email: "",
|
||||
is_primary: 0,
|
||||
remark: "",
|
||||
});
|
||||
if (row) Object.assign(contactForm, row);
|
||||
contactDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleContactSave() {
|
||||
if (!contactForm.contact_name) {
|
||||
ElMessage.warning("请输入联系人姓名");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
...contactForm,
|
||||
related_type: RELATED_TYPE,
|
||||
related_id: currentId(),
|
||||
};
|
||||
if (contactForm.id) {
|
||||
await updateEntityContact(payload);
|
||||
} else {
|
||||
await addEntityContact(payload);
|
||||
}
|
||||
ElMessage.success("保存成功");
|
||||
contactDialog.value = false;
|
||||
loadContacts();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContactDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除联系人「${row.contact_name}」吗?`, "删除确认", { type: "warning" });
|
||||
await deleteEntityContact({ id: row.id });
|
||||
ElMessage.success("删除成功");
|
||||
loadContacts();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ 跟进记录 ------------------------------ */
|
||||
async function loadFollows() {
|
||||
loading.follow = true;
|
||||
try {
|
||||
const res = await getFollowList({ related_type: RELATED_TYPE, related_id: currentId(), pageSize: 100 });
|
||||
follows.value = res?.data?.list || [];
|
||||
} catch (e) {
|
||||
follows.value = [];
|
||||
} finally {
|
||||
loading.follow = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openFollowEdit(row) {
|
||||
Object.assign(followForm, { id: null, follow_type: "1", follow_time: "", content: "", next_contact_time: "" });
|
||||
if (row) Object.assign(followForm, row);
|
||||
followDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleFollowSave() {
|
||||
const plainText = String(followForm.content || "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
if (!plainText && !/<img/i.test(followForm.content || "")) {
|
||||
ElMessage.warning("请输入回访内容");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
if (followForm.id) {
|
||||
await updateFollow({ ...followForm });
|
||||
} else {
|
||||
await addFollow({
|
||||
...followForm,
|
||||
related_type: RELATED_TYPE,
|
||||
related_id: currentId(),
|
||||
related_name: props.clue?.clue_name || "",
|
||||
});
|
||||
}
|
||||
ElMessage.success("保存成功");
|
||||
followDialog.value = false;
|
||||
loadFollows();
|
||||
emit("refresh");
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFollowDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定删除该回访记录吗?", "删除确认", { type: "warning" });
|
||||
await deleteFollow({ id: row.id });
|
||||
ElMessage.success("删除成功");
|
||||
loadFollows();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------- 附件 -------------------------------- */
|
||||
async function loadAttaches() {
|
||||
loading.attach = true;
|
||||
try {
|
||||
const res = await getAttachList({ related_type: RELATED_TYPE, related_id: currentId() });
|
||||
attaches.value = res?.data?.list || [];
|
||||
} catch (e) {
|
||||
attaches.value = [];
|
||||
} finally {
|
||||
loading.attach = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(options) {
|
||||
const file = options.file;
|
||||
uploading.value = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await uploadFile(fd);
|
||||
const data = res?.data || {};
|
||||
await addAttach({
|
||||
related_type: RELATED_TYPE,
|
||||
related_id: currentId(),
|
||||
file_id: data.id || 0,
|
||||
file_name: data.name || file.name,
|
||||
file_url: data.url || "",
|
||||
file_size: file.size || 0,
|
||||
});
|
||||
ElMessage.success("上传成功");
|
||||
loadAttaches();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除附件「${row.file_name}」吗?`, "删除确认", { type: "warning" });
|
||||
await deleteAttach({ id: row.id });
|
||||
ElMessage.success("删除成功");
|
||||
loadAttaches();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ 操作日志 ------------------------------ */
|
||||
async function loadLogs() {
|
||||
loading.log = true;
|
||||
try {
|
||||
const res = await getCrmOperateLogs({ related_type: RELATED_TYPE, related_id: currentId() });
|
||||
logs.value = res?.data?.list || [];
|
||||
} catch (e) {
|
||||
logs.value = [];
|
||||
} finally {
|
||||
loading.log = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(size) {
|
||||
const n = Number(size) || 0;
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.clue-detail {
|
||||
.tab-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.file-link {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.log-item {
|
||||
.log-user {
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.follow-content {
|
||||
word-break: break-word;
|
||||
|
||||
:deep(img) {
|
||||
max-width: 160px;
|
||||
max-height: 120px;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
cursor: zoom-in;
|
||||
vertical-align: middle;
|
||||
margin: 2px 4px 2px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="isEdit ? '编辑线索' : '新增线索'"
|
||||
width="760px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleClose"
|
||||
@opened="handleOpened"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" label-position="right">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="线索名称" prop="clue_name">
|
||||
<el-input v-model="form.clue_name" placeholder="请输入线索名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户名称" prop="customer_name">
|
||||
<div class="name-row">
|
||||
<el-input v-model="form.customer_name" placeholder="请输入客户名称" maxlength="100" />
|
||||
<a
|
||||
class="aiqicha-link"
|
||||
:href="aiqichaUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="到爱企查查询客户资料"
|
||||
>
|
||||
爱企查
|
||||
</a>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户来源" prop="source">
|
||||
<el-select v-model="form.source" clearable placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in CLUE_SOURCE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户级别" prop="clue_level">
|
||||
<el-select v-model="form.clue_level" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in PIPELINE_LEVEL_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人" prop="owner_user_id">
|
||||
<el-select v-model="form.owner_user_id" filterable placeholder="请选择负责人" style="width: 100%">
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.name" :value="String(u.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户行业" prop="industry">
|
||||
<el-input v-model="form.industry" placeholder="如:互联网、制造业" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="公司地址" prop="address">
|
||||
<el-input v-model="form.address" placeholder="请输入公司地址" maxlength="255" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下次联系时间" prop="next_contact_time">
|
||||
<el-date-picker
|
||||
v-model="form.next_contact_time"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="选择时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人" prop="contact_person">
|
||||
<el-input v-model="form.contact_person" placeholder="请输入对接人" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人职位" prop="contact_position">
|
||||
<el-input v-model="form.contact_position" placeholder="如:采购经理、技术总监" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人手机" prop="contact_phone">
|
||||
<el-input v-model="form.contact_phone" placeholder="请输入手机号" maxlength="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人微信" prop="contact_wechat">
|
||||
<el-input v-model="form.contact_wechat" placeholder="请输入微信号" maxlength="64" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人QQ" prop="contact_qq">
|
||||
<el-input v-model="form.contact_qq" placeholder="请输入QQ号" maxlength="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注(选填)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { createClue, updateClue } from "@/api/crmPipeline";
|
||||
import { getAllUsers } from "@/api/user";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
CLUE_SOURCE_OPTIONS,
|
||||
PIPELINE_LEVEL_OPTIONS,
|
||||
} from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
editData: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["update:visible", "success"]);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const internalId = ref(null);
|
||||
const userOptions = ref([]);
|
||||
|
||||
const defaultForm = () => ({
|
||||
clue_name: "",
|
||||
customer_name: "",
|
||||
source: "",
|
||||
owner_user_id: "",
|
||||
owner_user_name: "",
|
||||
industry: "",
|
||||
next_contact_time: "",
|
||||
clue_level: "2",
|
||||
contact_person: "",
|
||||
contact_position: "",
|
||||
contact_phone: "",
|
||||
contact_wechat: "",
|
||||
contact_qq: "",
|
||||
address: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
/** 爱企查搜索链接:带上当前填写的客户名称,新窗口打开 */
|
||||
const aiqichaUrl = computed(
|
||||
() => `https://aiqicha.baidu.com/s?q=${encodeURIComponent(form.customer_name || "")}`
|
||||
);
|
||||
|
||||
const rules = {
|
||||
clue_name: [{ required: true, message: "请输入线索名称", trigger: "blur" }],
|
||||
customer_name: [{ required: true, message: "请输入客户名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.editData) {
|
||||
isEdit.value = true;
|
||||
internalId.value = props.editData.id;
|
||||
Object.assign(form, defaultForm(), props.editData);
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
internalId.value = null;
|
||||
Object.assign(form, defaultForm());
|
||||
// 负责人默认为当前用户
|
||||
form.owner_user_id = authStore.user?.id ? String(authStore.user.id) : "";
|
||||
form.owner_user_name = authStore.user?.name || "";
|
||||
}
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function loadUsers() {
|
||||
if (userOptions.value.length) return;
|
||||
try {
|
||||
const res = await getAllUsers();
|
||||
const data = res?.data || {};
|
||||
const list = Array.isArray(data) ? data : data.list || [];
|
||||
userOptions.value = list.map((u) => ({
|
||||
id: u.uid || u.id,
|
||||
name: u.name || u.account || `用户${u.uid || u.id}`,
|
||||
}));
|
||||
} catch (e) {
|
||||
userOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function handleOpened() {
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
const owner = userOptions.value.find((u) => String(u.id) === String(form.owner_user_id));
|
||||
if (owner) form.owner_user_name = owner.name;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = { ...form };
|
||||
if (internalId.value) {
|
||||
await updateClue(internalId.value, payload);
|
||||
ElMessage.success("更新成功");
|
||||
} else {
|
||||
await createClue(payload);
|
||||
ElMessage.success("创建成功");
|
||||
}
|
||||
emit("success");
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "操作失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.aiqicha-link {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="crm-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>线索管理</h2>
|
||||
<p>登记潜在客户线索,跟进后可转化为商机;转化后线索将锁定不可编辑</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增线索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-form :inline="true" :model="filters" @submit.prevent>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
clearable
|
||||
placeholder="搜索线索名称 / 客户名称 / 对接人"
|
||||
:prefix-icon="Search"
|
||||
style="width: 260px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户来源">
|
||||
<el-select v-model="filters.source" clearable placeholder="全部" style="width: 130px">
|
||||
<el-option v-for="i in CLUE_SOURCE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户级别">
|
||||
<el-select v-model="filters.clue_level" clearable placeholder="全部" style="width: 120px">
|
||||
<el-option v-for="i in PIPELINE_LEVEL_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 120px">
|
||||
<el-option v-for="i in CLUE_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="table-container" v-loading="loading">
|
||||
<el-table :data="tableData" stripe border row-key="id">
|
||||
<el-table-column label="线索名称" min-width="170" show-overflow-tooltip fixed>
|
||||
<template #default="{ row }">
|
||||
<span class="name-link" @click="openDetail(row)">{{ row.clue_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="customer_name" label="客户名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="客户来源" width="110" align="center">
|
||||
<template #default="{ row }">{{ clueSourceText(row.source) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户级别" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="pipelineLevelTag(row.clue_level)" size="small">
|
||||
{{ pipelineLevelText(row.clue_level) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="industry" label="客户行业" width="120" show-overflow-tooltip />
|
||||
<el-table-column label="负责人" width="100">
|
||||
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对接人" width="100">
|
||||
<template #default="{ row }">{{ row.contact_person || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对接人手机" width="130">
|
||||
<template #default="{ row }">{{ row.contact_phone || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下次联系时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="clueStatusTag(row.status)" size="small">{{ clueStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" :disabled="row.locked === 1" @click="openEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="warning"
|
||||
size="small"
|
||||
:disabled="row.locked === 1 || row.status === 2"
|
||||
@click="openConvert(row)"
|
||||
>
|
||||
转化
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无线索" :image-size="80" /></template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSearch"
|
||||
@current-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClueEdit v-model:visible="editVisible" :edit-data="currentEditData" @success="fetchList" />
|
||||
<ClueConvert v-model:visible="convertVisible" :clue="currentEditData" @success="handleConvertSuccess" />
|
||||
<ClueDetail
|
||||
v-model:visible="detailVisible"
|
||||
:clue="currentEditData"
|
||||
@refresh="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||
import { getClueList, deleteClue } from "@/api/crmPipeline";
|
||||
import ClueEdit from "./components/edit.vue";
|
||||
import ClueConvert from "./components/convert.vue";
|
||||
import ClueDetail from "./components/detail.vue";
|
||||
import {
|
||||
CLUE_SOURCE_OPTIONS,
|
||||
CLUE_STATUS_OPTIONS,
|
||||
PIPELINE_LEVEL_OPTIONS,
|
||||
clueSourceText,
|
||||
pipelineLevelText,
|
||||
pipelineLevelTag,
|
||||
clueStatusText,
|
||||
clueStatusTag,
|
||||
formatDateTime,
|
||||
} from "../dict";
|
||||
|
||||
const loading = ref(false);
|
||||
const tableData = ref([]);
|
||||
const editVisible = ref(false);
|
||||
const convertVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const currentEditData = ref(null);
|
||||
|
||||
const filters = reactive({
|
||||
keyword: "",
|
||||
source: "",
|
||||
clue_level: "",
|
||||
status: "",
|
||||
});
|
||||
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getClueList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters,
|
||||
});
|
||||
tableData.value = res?.data?.list || [];
|
||||
pagination.total = res?.data?.total || 0;
|
||||
} catch (e) {
|
||||
tableData.value = [];
|
||||
pagination.total = 0;
|
||||
ElMessage.error(e.message || "查询失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = "";
|
||||
filters.source = "";
|
||||
filters.clue_level = "";
|
||||
filters.status = "";
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
currentEditData.value = null;
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
currentEditData.value = { ...row };
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
function openConvert(row) {
|
||||
currentEditData.value = { ...row };
|
||||
convertVisible.value = true;
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
currentEditData.value = { ...row };
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
function handleConvertSuccess() {
|
||||
fetchList();
|
||||
ElMessage.success("线索已转化,可在商机管理中继续跟进");
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除线索「${row.clue_name}」吗?删除后不可恢复。`, "删除确认", {
|
||||
type: "warning",
|
||||
});
|
||||
await deleteClue(row.id);
|
||||
ElMessage.success("删除成功");
|
||||
fetchList();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped src="../styles/crm-page.less"></style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="rich-editor">
|
||||
<div class="editor-toolbar">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="handleUploadRequest"
|
||||
accept="image/*"
|
||||
:disabled="uploading"
|
||||
>
|
||||
<el-button size="small" :icon="Picture" :loading="uploading">上传图片</el-button>
|
||||
</el-upload>
|
||||
<span class="editor-tip">支持 Ctrl+V 直接粘贴截图</span>
|
||||
</div>
|
||||
<div
|
||||
ref="editorRef"
|
||||
class="editor-body"
|
||||
contenteditable="true"
|
||||
:style="{ minHeight: minHeight + 'px' }"
|
||||
:data-placeholder="placeholder"
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
@keyup="saveSelection"
|
||||
@mouseup="saveSelection"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Picture } from "@element-plus/icons-vue";
|
||||
import { uploadFile } from "@/api/file";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: "" },
|
||||
placeholder: { type: String, default: "请输入内容,可粘贴或上传图片" },
|
||||
minHeight: { type: Number, default: 120 },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const editorRef = ref(null);
|
||||
const uploading = ref(false);
|
||||
let savedRange = null;
|
||||
|
||||
function syncFromModel() {
|
||||
if (!editorRef.value) return;
|
||||
const html = props.modelValue || "";
|
||||
if (editorRef.value.innerHTML !== html) {
|
||||
editorRef.value.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(syncFromModel);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
// 编辑中不同步,避免光标跳动;失焦或外部赋值时才同步
|
||||
if (editorRef.value && document.activeElement !== editorRef.value) {
|
||||
syncFromModel();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleInput() {
|
||||
emit("update:modelValue", editorRef.value?.innerHTML || "");
|
||||
}
|
||||
|
||||
function saveSelection() {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0 && editorRef.value?.contains(sel.anchorNode)) {
|
||||
savedRange = sel.getRangeAt(0).cloneRange();
|
||||
}
|
||||
}
|
||||
|
||||
function insertHtml(html) {
|
||||
const editor = editorRef.value;
|
||||
if (!editor) return;
|
||||
editor.focus();
|
||||
const sel = window.getSelection();
|
||||
let range = null;
|
||||
if (sel && sel.rangeCount > 0 && editor.contains(sel.anchorNode)) {
|
||||
range = sel.getRangeAt(0);
|
||||
} else if (savedRange && editor.contains(savedRange.startContainer)) {
|
||||
range = savedRange;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
if (!range) {
|
||||
editor.insertAdjacentHTML("beforeend", html);
|
||||
handleInput();
|
||||
return;
|
||||
}
|
||||
range.deleteContents();
|
||||
const holder = document.createElement("div");
|
||||
holder.innerHTML = html;
|
||||
const frag = document.createDocumentFragment();
|
||||
let node;
|
||||
let lastNode = null;
|
||||
while ((node = holder.firstChild)) {
|
||||
lastNode = frag.appendChild(node);
|
||||
}
|
||||
range.insertNode(frag);
|
||||
if (lastNode) {
|
||||
range.setStartAfter(lastNode);
|
||||
range.setEnd(range.endContainer, range.endOffset);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
savedRange = range.cloneRange();
|
||||
}
|
||||
handleInput();
|
||||
}
|
||||
|
||||
async function uploadImage(file) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await uploadFile(fd, { scope: "user" });
|
||||
if (res?.code !== 200 && res?.code !== 201) {
|
||||
throw new Error(res?.msg || "图片上传失败");
|
||||
}
|
||||
const data = res?.data || {};
|
||||
return data.url || data.src || "";
|
||||
}
|
||||
|
||||
async function insertImageFile(file) {
|
||||
uploading.value = true;
|
||||
try {
|
||||
const url = await uploadImage(file);
|
||||
if (!url) throw new Error("图片地址为空");
|
||||
insertHtml(`<img src="${url}" style="max-width:100%;height:auto;" />`);
|
||||
ElMessage.success("图片已插入");
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "图片上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUploadRequest(options) {
|
||||
return insertImageFile(options?.file);
|
||||
}
|
||||
|
||||
function handlePaste(e) {
|
||||
const items = Array.from(e.clipboardData?.items || []);
|
||||
const files = items
|
||||
.filter((it) => it.kind === "file" && String(it.type || "").startsWith("image/"))
|
||||
.map((it) => it.getAsFile())
|
||||
.filter(Boolean);
|
||||
if (!files.length) return;
|
||||
e.preventDefault();
|
||||
saveSelection();
|
||||
insertImageFile(files[0]);
|
||||
}
|
||||
|
||||
defineExpose({ getHtml: () => editorRef.value?.innerHTML || "" });
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.rich-editor {
|
||||
width: 100%;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 10px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
.editor-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
overflow-y: auto;
|
||||
max-height: 320px;
|
||||
|
||||
&:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -46,9 +46,20 @@
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="letter-bar">
|
||||
<span
|
||||
v-for="item in letterBar"
|
||||
:key="item"
|
||||
class="letter-item"
|
||||
:class="{ active: activeLetter === item, empty: item !== '全部' && !letterCounts[item] }"
|
||||
@click="handleLetterClick(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-container" v-loading="loading">
|
||||
<el-table :data="tableData" stripe border>
|
||||
<el-table-column type="index" label="#" width="56" align="center" />
|
||||
<el-table :data="pagedContacts" stripe border>
|
||||
<el-table-column label="姓名" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="name-cell-wrapper">
|
||||
@@ -78,18 +89,9 @@
|
||||
<el-table-column label="更新时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ formatDateTime(row.update_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" align="center" fixed="right">
|
||||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
v-if="Number(row.is_primary) !== 1"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleSetPrimary(row)"
|
||||
>
|
||||
设为主联系人
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -102,11 +104,10 @@
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:total="totalCount"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSearch"
|
||||
@current-change="fetchList"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,10 +166,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ref, reactive, computed, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||
import { listContacts, updateContact, deleteContact } from "@/api/contact";
|
||||
import { pinyin } from "pinyin-pro";
|
||||
import { listContacts, deleteContact } from "@/api/contact";
|
||||
import ContactEdit from "./components/edit.vue";
|
||||
import {
|
||||
RELATED_TYPE_OPTIONS,
|
||||
@@ -178,7 +180,6 @@ import {
|
||||
} from "../dict";
|
||||
|
||||
const loading = ref(false);
|
||||
const tableData = ref([]);
|
||||
const editVisible = ref(false);
|
||||
const currentEditData = ref(null);
|
||||
|
||||
@@ -186,6 +187,16 @@ const currentEditData = ref(null);
|
||||
const detailVisible = ref(false);
|
||||
const detailData = ref(null);
|
||||
|
||||
// 全量联系人(用于本地 A-Z 排序 / 首字母筛选)
|
||||
const allContacts = ref([]);
|
||||
|
||||
// A-Z 索引
|
||||
const collator = new Intl.Collator("zh-Hans-CN", { sensitivity: "base" });
|
||||
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
.split("")
|
||||
.filter((l) => !["I", "U", "V"].includes(l));
|
||||
const activeLetter = ref("全部");
|
||||
|
||||
const filters = reactive({
|
||||
keyword: "",
|
||||
related_type: "",
|
||||
@@ -195,28 +206,93 @@ const filters = reactive({
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
/** 取姓名首字母:中文按拼音、英文按字母,数字/符号/无法识别归 # */
|
||||
function initialOf(name) {
|
||||
const s = String(name || "").trim();
|
||||
if (!s) return "#";
|
||||
const first = s.charAt(0);
|
||||
if (/[a-zA-Z]/.test(first)) return first.toUpperCase();
|
||||
if (!/[\u4e00-\u9fa5]/.test(first)) return "#";
|
||||
try {
|
||||
const py = pinyin(first, { pattern: "first", toneType: "none" });
|
||||
const letter = String(py || "").charAt(0).toUpperCase();
|
||||
return /[A-Z]/.test(letter) ? letter : "#";
|
||||
} catch (e) {
|
||||
return "#";
|
||||
}
|
||||
}
|
||||
|
||||
function rankOf(letter) {
|
||||
return letter === "#" ? 99 : letter.charCodeAt(0) - 65;
|
||||
}
|
||||
|
||||
/** A-Z 排序(「全部」视图同样按 A-Z) */
|
||||
const sortedContacts = computed(() => {
|
||||
const list = [...allContacts.value];
|
||||
list.sort((a, b) => {
|
||||
const la = initialOf(a.contact_name);
|
||||
const lb = initialOf(b.contact_name);
|
||||
if (la !== lb) return rankOf(la) - rankOf(lb);
|
||||
return collator.compare(a.contact_name || "", b.contact_name || "");
|
||||
});
|
||||
return list;
|
||||
});
|
||||
|
||||
/** 各首字母数量 */
|
||||
const letterCounts = computed(() => {
|
||||
const map = {};
|
||||
for (const c of allContacts.value) {
|
||||
const l = initialOf(c.contact_name);
|
||||
map[l] = (map[l] || 0) + 1;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** 顶部索引:全部 + A-Z(存在 # 数据时追加 #) */
|
||||
const letterBar = computed(() => [
|
||||
"全部",
|
||||
...LETTERS,
|
||||
...(letterCounts.value["#"] ? ["#"] : []),
|
||||
]);
|
||||
|
||||
/** 按首字母筛选(全部 → 不筛选) */
|
||||
const filteredContacts = computed(() => {
|
||||
if (activeLetter.value === "全部") return sortedContacts.value;
|
||||
return sortedContacts.value.filter((c) => initialOf(c.contact_name) === activeLetter.value);
|
||||
});
|
||||
|
||||
/** 本地分页 */
|
||||
const pagedContacts = computed(() => {
|
||||
const start = (pagination.page - 1) * pagination.pageSize;
|
||||
return filteredContacts.value.slice(start, start + pagination.pageSize);
|
||||
});
|
||||
|
||||
const totalCount = computed(() => filteredContacts.value.length);
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
|
||||
/** 拉取全量联系人(分页循环,最多 50 页),本地做 A-Z 排序与筛选 */
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await listContacts({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters,
|
||||
});
|
||||
const data = res?.data || {};
|
||||
const list = Array.isArray(data) ? data : data.list || [];
|
||||
tableData.value = list;
|
||||
pagination.total = data.total || list.length;
|
||||
const pageSize = 100;
|
||||
let all = [];
|
||||
let total = 0;
|
||||
for (let page = 1; page <= 50; page++) {
|
||||
const res = await listContacts({ page, pageSize, ...filters });
|
||||
const data = res?.data || {};
|
||||
const list = Array.isArray(data) ? data : data.list || [];
|
||||
all = all.concat(list);
|
||||
total = data.total || all.length;
|
||||
if (list.length === 0 || all.length >= total) break;
|
||||
}
|
||||
allContacts.value = all;
|
||||
} catch (e) {
|
||||
tableData.value = [];
|
||||
pagination.total = 0;
|
||||
allContacts.value = [];
|
||||
ElMessage.error(e.message || "查询失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -232,9 +308,19 @@ function resetFilters() {
|
||||
filters.keyword = "";
|
||||
filters.related_type = "";
|
||||
filters.is_primary = "";
|
||||
activeLetter.value = "全部";
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleLetterClick(item) {
|
||||
activeLetter.value = item;
|
||||
pagination.page = 1;
|
||||
}
|
||||
|
||||
function handlePageSizeChange() {
|
||||
pagination.page = 1;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
currentEditData.value = null;
|
||||
editVisible.value = true;
|
||||
@@ -250,16 +336,6 @@ function openEdit(row) {
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleSetPrimary(row) {
|
||||
try {
|
||||
await updateContact({ ...row, is_primary: 1 });
|
||||
ElMessage.success("已设为主联系人");
|
||||
fetchList();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
@@ -318,6 +394,47 @@ async function handleDelete(row) {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.letter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
|
||||
.letter-item {
|
||||
min-width: 28px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&.empty {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.name-cell-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -135,3 +135,136 @@ export function formatDateTime(val) {
|
||||
const min = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${m}-${day} ${h}:${min}`;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* CRM 业务管线:线索 / 商机 / 项目 / 回访
|
||||
* 关联类型 related_type:1=线索 2=商机 3=项目
|
||||
* ===================================================================== */
|
||||
|
||||
/** 客户来源 */
|
||||
export const CLUE_SOURCE_OPTIONS = [
|
||||
{ label: "官网咨询", value: "1" },
|
||||
{ label: "电话咨询", value: "2" },
|
||||
{ label: "朋友介绍", value: "3" },
|
||||
{ label: "展会活动", value: "4" },
|
||||
{ label: "广告投放", value: "5" },
|
||||
{ label: "陌生拜访", value: "6" },
|
||||
{ label: "其他", value: "7" },
|
||||
];
|
||||
|
||||
/** 客户级别(线索/商机共用):1重点 2普通 3非优先 */
|
||||
export const PIPELINE_LEVEL_OPTIONS = [
|
||||
{ label: "重点", value: "1" },
|
||||
{ label: "普通", value: "2" },
|
||||
{ label: "非优先", value: "3" },
|
||||
];
|
||||
|
||||
/** 线索状态 */
|
||||
export const CLUE_STATUS_OPTIONS = [
|
||||
{ label: "跟进中", value: "1" },
|
||||
{ label: "已转化", value: "2" },
|
||||
{ label: "已关闭", value: "3" },
|
||||
];
|
||||
|
||||
/** 商机阶段 */
|
||||
export const BUSINESS_STAGE_OPTIONS = [
|
||||
{ label: "初步接洽", value: "1" },
|
||||
{ label: "需求确认", value: "2" },
|
||||
{ label: "方案报价", value: "3" },
|
||||
{ label: "商务谈判", value: "4" },
|
||||
{ label: "赢单", value: "5" },
|
||||
{ label: "输单", value: "6" },
|
||||
];
|
||||
|
||||
/** 商机状态 */
|
||||
export const BUSINESS_STATUS_OPTIONS = [
|
||||
{ label: "跟进中", value: "1" },
|
||||
{ label: "已转化项目", value: "2" },
|
||||
{ label: "已关闭", value: "3" },
|
||||
];
|
||||
|
||||
/** 项目状态 */
|
||||
export const PROJECT_STATUS_OPTIONS = [
|
||||
{ label: "未开始", value: "1" },
|
||||
{ label: "进行中", value: "2" },
|
||||
{ label: "已完成", value: "3" },
|
||||
{ label: "已暂停", value: "4" },
|
||||
];
|
||||
|
||||
/** 回访方式 */
|
||||
export const FOLLOW_TYPE_OPTIONS = [
|
||||
{ label: "电话", value: "1" },
|
||||
{ label: "微信", value: "2" },
|
||||
{ label: "上门", value: "3" },
|
||||
{ label: "邮件", value: "4" },
|
||||
{ label: "其他", value: "5" },
|
||||
];
|
||||
|
||||
/** 回访关联对象类型 */
|
||||
export const PIPELINE_RELATED_TYPE_OPTIONS = [
|
||||
{ label: "线索", value: 1 },
|
||||
{ label: "商机", value: 2 },
|
||||
{ label: "项目", value: 3 },
|
||||
];
|
||||
|
||||
const CLUE_SOURCE_MAP = CLUE_SOURCE_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
|
||||
const PIPELINE_LEVEL_MAP = PIPELINE_LEVEL_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
|
||||
const CLUE_STATUS_MAP = CLUE_STATUS_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
|
||||
const BUSINESS_STAGE_MAP = BUSINESS_STAGE_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
|
||||
const BUSINESS_STATUS_MAP = BUSINESS_STATUS_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
|
||||
const PROJECT_STATUS_MAP = PROJECT_STATUS_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
|
||||
const FOLLOW_TYPE_MAP = FOLLOW_TYPE_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
|
||||
|
||||
const PIPELINE_LEVEL_TAG = { 1: "danger", 2: "warning", 3: "info" };
|
||||
const CLUE_STATUS_TAG = { 1: "primary", 2: "success", 3: "info" };
|
||||
const BUSINESS_STAGE_TAG = { 1: "info", 2: "primary", 3: "warning", 4: "warning", 5: "success", 6: "danger" };
|
||||
const BUSINESS_STATUS_TAG = { 1: "primary", 2: "success", 3: "info" };
|
||||
const PROJECT_STATUS_TAG = { 1: "info", 2: "primary", 3: "success", 4: "warning" };
|
||||
|
||||
export const clueSourceText = (val) => CLUE_SOURCE_MAP[normalize(val)] || "-";
|
||||
export const pipelineLevelText = (val) => PIPELINE_LEVEL_MAP[normalize(val)] || "-";
|
||||
export const pipelineLevelTag = (val) => PIPELINE_LEVEL_TAG[normalize(val)] || "info";
|
||||
export const clueStatusText = (val) => CLUE_STATUS_MAP[normalize(val)] || "-";
|
||||
export const clueStatusTag = (val) => CLUE_STATUS_TAG[normalize(val)] || "info";
|
||||
export const businessStageText = (val) => BUSINESS_STAGE_MAP[normalize(val)] || "-";
|
||||
export const businessStageTag = (val) => BUSINESS_STAGE_TAG[normalize(val)] || "info";
|
||||
export const businessStatusText = (val) => BUSINESS_STATUS_MAP[normalize(val)] || "-";
|
||||
export const businessStatusTag = (val) => BUSINESS_STATUS_TAG[normalize(val)] || "info";
|
||||
export const projectStatusText = (val) => PROJECT_STATUS_MAP[normalize(val)] || "-";
|
||||
export const projectStatusTag = (val) => PROJECT_STATUS_TAG[normalize(val)] || "info";
|
||||
export const followTypeText = (val) => FOLLOW_TYPE_MAP[normalize(val)] || "-";
|
||||
|
||||
export const pipelineRelatedTypeText = (val) => {
|
||||
const n = normalize(val);
|
||||
return n === "1" ? "线索" : n === "2" ? "商机" : n === "3" ? "项目" : "-";
|
||||
};
|
||||
|
||||
/** 金额格式化:12345.6 -> 12,345.60 */
|
||||
export function formatMoney(val) {
|
||||
if (val === undefined || val === null || val === "") return "-";
|
||||
const n = Number(val);
|
||||
if (isNaN(n)) return String(val);
|
||||
return n.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** 仅日期(YYYY-MM-DD) */
|
||||
export function formatDateOnly(val) {
|
||||
if (!val) return "-";
|
||||
const d = new Date(val);
|
||||
if (isNaN(d.getTime())) return String(val).slice(0, 10);
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** 富文本转纯文本预览(用于列表展示,含图片时返回 [图片]) */
|
||||
export function stripHtml(val) {
|
||||
if (!val) return "-";
|
||||
const raw = String(val);
|
||||
const text = raw
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
return /<img/i.test(raw) ? "[图片]" : "-";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="isEdit ? '编辑回访' : '新增回访'"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="关联类型" prop="related_type">
|
||||
<el-radio-group v-model="form.related_type" :disabled="isEdit" @change="handleTypeChange">
|
||||
<el-radio v-for="i in PIPELINE_RELATED_TYPE_OPTIONS" :key="i.value" :value="i.value">
|
||||
{{ i.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="relatedLabel" prop="related_id">
|
||||
<el-select
|
||||
v-model="form.related_id"
|
||||
filterable
|
||||
remote
|
||||
:disabled="isEdit"
|
||||
:remote-method="searchRelated"
|
||||
:loading="relatedLoading"
|
||||
:placeholder="`搜索${relatedLabel}`"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="o in relatedOptions" :key="o.id" :label="o.name" :value="o.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="回访方式" prop="follow_type">
|
||||
<el-select v-model="form.follow_type" style="width: 100%">
|
||||
<el-option v-for="i in FOLLOW_TYPE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="回访时间" prop="follow_time">
|
||||
<el-date-picker
|
||||
v-model="form.follow_time"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="选择时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="回访内容" prop="content">
|
||||
<RichContentEditor
|
||||
v-model="form.content"
|
||||
:min-height="140"
|
||||
placeholder="请输入回访内容,可粘贴或上传图片"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="下次联系" prop="next_contact_time">
|
||||
<el-date-picker
|
||||
v-model="form.next_contact_time"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="选择时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { addFollow, updateFollow, getClueList, getBusinessList, getProjectList } from "@/api/crmPipeline";
|
||||
import { PIPELINE_RELATED_TYPE_OPTIONS, FOLLOW_TYPE_OPTIONS } from "../../dict";
|
||||
import RichContentEditor from "../../components/RichContentEditor.vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
editData: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["update:visible", "success"]);
|
||||
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const relatedLoading = ref(false);
|
||||
const relatedOptions = ref([]);
|
||||
|
||||
const defaultForm = () => ({
|
||||
id: null,
|
||||
related_type: 1,
|
||||
related_id: "",
|
||||
related_name: "",
|
||||
follow_type: "1",
|
||||
follow_time: "",
|
||||
content: "",
|
||||
next_contact_time: "",
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
const relatedLabel = computed(() => {
|
||||
const t = PIPELINE_RELATED_TYPE_OPTIONS.find((i) => i.value === Number(form.related_type));
|
||||
return t ? t.label : "关联对象";
|
||||
});
|
||||
|
||||
const validateContent = (rule, value, callback) => {
|
||||
const text = String(value || "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
const hasImg = /<img/i.test(value || "");
|
||||
if (!text && !hasImg) {
|
||||
callback(new Error("请输入回访内容"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const rules = {
|
||||
related_type: [{ required: true, message: "请选择关联类型", trigger: "change" }],
|
||||
related_id: [{ required: true, message: "请选择关联对象", trigger: "change" }],
|
||||
follow_type: [{ required: true, message: "请选择回访方式", trigger: "change" }],
|
||||
content: [{ validator: validateContent, trigger: "change" }],
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
Object.assign(form, defaultForm());
|
||||
if (props.editData) {
|
||||
isEdit.value = true;
|
||||
Object.assign(form, props.editData);
|
||||
searchRelated("");
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
form.follow_time = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(
|
||||
now.getHours()
|
||||
)}:${pad(now.getMinutes())}:00`;
|
||||
searchRelated("");
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleTypeChange() {
|
||||
form.related_id = "";
|
||||
form.related_name = "";
|
||||
relatedOptions.value = [];
|
||||
searchRelated("");
|
||||
}
|
||||
|
||||
async function searchRelated(keyword) {
|
||||
relatedLoading.value = true;
|
||||
try {
|
||||
const type = Number(form.related_type);
|
||||
let res;
|
||||
if (type === 1) res = await getClueList({ page: 1, pageSize: 20, keyword: keyword || "" });
|
||||
else if (type === 2) res = await getBusinessList({ page: 1, pageSize: 20, keyword: keyword || "" });
|
||||
else res = await getProjectList({ page: 1, pageSize: 20, keyword: keyword || "" });
|
||||
const list = res?.data?.list || [];
|
||||
relatedOptions.value = list.map((o) => ({
|
||||
id: o.id,
|
||||
name: o.clue_name || o.business_name || o.project_name || `#${o.id}`,
|
||||
}));
|
||||
} catch (e) {
|
||||
relatedOptions.value = [];
|
||||
} finally {
|
||||
relatedLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const selected = relatedOptions.value.find((o) => o.id === form.related_id);
|
||||
const payload = {
|
||||
...form,
|
||||
related_name: form.related_name || selected?.name || "",
|
||||
};
|
||||
if (form.id) {
|
||||
await updateFollow(payload);
|
||||
ElMessage.success("更新成功");
|
||||
} else {
|
||||
await addFollow(payload);
|
||||
ElMessage.success("新增成功");
|
||||
}
|
||||
emit("success");
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "操作失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="crm-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>回访记录</h2>
|
||||
<p>回访贯穿线索、商机、项目全流程,记录每一次客户沟通</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增回访</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-form :inline="true" :model="filters" @submit.prevent>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
clearable
|
||||
placeholder="搜索关联对象 / 回访内容"
|
||||
:prefix-icon="Search"
|
||||
style="width: 260px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联类型">
|
||||
<el-select v-model="filters.related_type" clearable placeholder="全部" style="width: 120px">
|
||||
<el-option
|
||||
v-for="i in PIPELINE_RELATED_TYPE_OPTIONS"
|
||||
:key="i.value"
|
||||
:label="i.label"
|
||||
:value="i.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="回访方式">
|
||||
<el-select v-model="filters.follow_type" clearable placeholder="全部" style="width: 120px">
|
||||
<el-option v-for="i in FOLLOW_TYPE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="table-container" v-loading="loading">
|
||||
<el-table :data="tableData" stripe border row-key="id">
|
||||
<el-table-column label="关联类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ pipelineRelatedTypeText(row.related_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="related_name" label="关联对象" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="回访方式" width="100" align="center">
|
||||
<template #default="{ row }">{{ followTypeText(row.follow_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回访时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ formatDateTime(row.follow_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回访内容" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ stripHtml(row.content) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下次联系时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="owner_user_name" label="回访人" width="100" />
|
||||
<el-table-column label="操作" width="130" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无回访记录" :image-size="80" /></template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSearch"
|
||||
@current-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FollowEdit v-model:visible="editVisible" :edit-data="currentRow" @success="fetchList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||
import { getFollowList, deleteFollow } from "@/api/crmPipeline";
|
||||
import FollowEdit from "./components/edit.vue";
|
||||
import {
|
||||
PIPELINE_RELATED_TYPE_OPTIONS,
|
||||
FOLLOW_TYPE_OPTIONS,
|
||||
followTypeText,
|
||||
pipelineRelatedTypeText,
|
||||
formatDateTime,
|
||||
stripHtml,
|
||||
} from "../dict";
|
||||
|
||||
const loading = ref(false);
|
||||
const tableData = ref([]);
|
||||
const editVisible = ref(false);
|
||||
const currentRow = ref(null);
|
||||
|
||||
const filters = reactive({ keyword: "", related_type: "", follow_type: "" });
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getFollowList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters,
|
||||
});
|
||||
tableData.value = res?.data?.list || [];
|
||||
pagination.total = res?.data?.total || 0;
|
||||
} catch (e) {
|
||||
tableData.value = [];
|
||||
pagination.total = 0;
|
||||
ElMessage.error(e.message || "查询失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = "";
|
||||
filters.related_type = "";
|
||||
filters.follow_type = "";
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
currentRow.value = null;
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
currentRow.value = { ...row };
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定删除该回访记录吗?", "删除确认", { type: "warning" });
|
||||
await deleteFollow({ id: row.id });
|
||||
ElMessage.success("删除成功");
|
||||
fetchList();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped src="../styles/crm-page.less"></style>
|
||||
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="isEdit ? '编辑项目' : '新增项目'"
|
||||
width="760px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleClose"
|
||||
@opened="handleOpened"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" label-position="right">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目名称" prop="project_name">
|
||||
<el-input v-model="form.project_name" placeholder="请输入项目名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目编号" prop="project_no">
|
||||
<el-input v-model="form.project_no" placeholder="请输入项目编号" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户" prop="customer_id">
|
||||
<el-select
|
||||
v-model="form.customer_id"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
:remote-method="searchCustomers"
|
||||
:loading="customerLoading"
|
||||
placeholder="输入客户名称搜索"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="c in customerOptions" :key="c.id" :label="c.customer_name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户行业" prop="industry">
|
||||
<el-input v-model="form.industry" placeholder="如:互联网、制造业" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目金额" prop="amount">
|
||||
<el-input v-model="form.amount" placeholder="请输入项目金额">
|
||||
<template #append>元</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目状态" prop="status">
|
||||
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in PROJECT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人" prop="owner_user_id">
|
||||
<el-select v-model="form.owner_user_id" filterable placeholder="请选择负责人" style="width: 100%">
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.name" :value="String(u.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开始日期" prop="start_date">
|
||||
<el-date-picker v-model="form.start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="结束日期" prop="end_date">
|
||||
<el-date-picker v-model="form.end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人" prop="contact_person">
|
||||
<el-input v-model="form.contact_person" placeholder="请输入对接人" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人职位" prop="contact_position">
|
||||
<el-input v-model="form.contact_position" placeholder="如:采购经理、技术总监" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人手机" prop="contact_phone">
|
||||
<el-input v-model="form.contact_phone" placeholder="请输入手机号" maxlength="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="项目地址" prop="address">
|
||||
<el-input v-model="form.address" placeholder="请输入项目地址" maxlength="255" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注(选填)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { createProject, updateProject } from "@/api/crmPipeline";
|
||||
import { getCrmCustomerList } from "@/api/crm";
|
||||
import { getAllUsers } from "@/api/user";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { PROJECT_STATUS_OPTIONS } from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
editData: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["update:visible", "success"]);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const internalId = ref(null);
|
||||
const userOptions = ref([]);
|
||||
const customerOptions = ref([]);
|
||||
const customerLoading = ref(false);
|
||||
|
||||
const defaultForm = () => ({
|
||||
project_name: "",
|
||||
project_no: "",
|
||||
customer_id: "",
|
||||
customer_name: "",
|
||||
owner_user_id: "",
|
||||
owner_user_name: "",
|
||||
industry: "",
|
||||
amount: "",
|
||||
status: 1,
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
contact_person: "",
|
||||
contact_position: "",
|
||||
contact_phone: "",
|
||||
address: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
const rules = {
|
||||
project_name: [{ required: true, message: "请输入项目名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.editData) {
|
||||
isEdit.value = true;
|
||||
internalId.value = props.editData.id;
|
||||
Object.assign(form, defaultForm(), props.editData);
|
||||
form.status = Number(props.editData.status) || 1;
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
internalId.value = null;
|
||||
Object.assign(form, defaultForm());
|
||||
form.owner_user_id = authStore.user?.id ? String(authStore.user.id) : "";
|
||||
form.owner_user_name = authStore.user?.name || "";
|
||||
}
|
||||
loadUsers();
|
||||
searchCustomers(form.customer_name || "");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function loadUsers() {
|
||||
if (userOptions.value.length) return;
|
||||
try {
|
||||
const res = await getAllUsers();
|
||||
const data = res?.data || {};
|
||||
const list = Array.isArray(data) ? data : data.list || [];
|
||||
userOptions.value = list.map((u) => ({ id: u.uid || u.id, name: u.name || u.account || `用户${u.uid || u.id}` }));
|
||||
} catch (e) {
|
||||
userOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function searchCustomers(keyword) {
|
||||
customerLoading.value = true;
|
||||
try {
|
||||
const res = await getCrmCustomerList({ page: 1, pageSize: 20, keyword: keyword || "" });
|
||||
const data = res?.data || {};
|
||||
customerOptions.value = (data.list || []).map((c) => ({ id: c.id, customer_name: c.customer_name }));
|
||||
} catch (e) {
|
||||
customerOptions.value = [];
|
||||
} finally {
|
||||
customerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function handleOpened() {
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
const owner = userOptions.value.find((u) => String(u.id) === String(form.owner_user_id));
|
||||
if (owner) form.owner_user_name = owner.name;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = { ...form, amount: Number(form.amount) || 0 };
|
||||
if (internalId.value) {
|
||||
await updateProject(internalId.value, payload);
|
||||
ElMessage.success("更新成功");
|
||||
} else {
|
||||
await createProject(payload);
|
||||
ElMessage.success("创建成功");
|
||||
}
|
||||
emit("success");
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "操作失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="crm-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>项目管理</h2>
|
||||
<p>由商机转化而来的项目,跟踪项目执行进度与状态</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增项目</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-form :inline="true" :model="filters" @submit.prevent>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
clearable
|
||||
placeholder="搜索项目名称 / 编号 / 客户"
|
||||
:prefix-icon="Search"
|
||||
style="width: 260px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目状态">
|
||||
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 130px">
|
||||
<el-option v-for="i in PROJECT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="table-container" v-loading="loading">
|
||||
<el-table :data="tableData" stripe border row-key="id">
|
||||
<el-table-column label="项目名称" min-width="180" show-overflow-tooltip fixed>
|
||||
<template #default="{ row }">
|
||||
<span class="name-link" @click="openDetail(row)">{{ row.project_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="project_no" label="项目编号" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="customer_name" label="客户名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="项目金额" width="130" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="projectStatusTag(row.status)" size="small">{{ projectStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开始日期" width="120" align="center">
|
||||
<template #default="{ row }">{{ formatDateOnly(row.start_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束日期" width="120" align="center">
|
||||
<template #default="{ row }">{{ formatDateOnly(row.end_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" width="100">
|
||||
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对接人" width="100">
|
||||
<template #default="{ row }">{{ row.contact_person || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="210" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!row.customer_id"
|
||||
@click="openContactBook(row)"
|
||||
>
|
||||
通讯录
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无项目" :image-size="80" /></template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSearch"
|
||||
@current-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectEdit v-model:visible="editVisible" :edit-data="currentRow" @success="fetchList" />
|
||||
|
||||
<!-- 项目联系人:直接读写正式公司联系人库(与 ERP/CRM 客户通讯录共用同一份数据) -->
|
||||
<ContactBook
|
||||
v-model:visible="contactBookVisible"
|
||||
company-type="customer"
|
||||
:company-id="contactBookCompanyId"
|
||||
:company-name="contactBookCompanyName"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="detailVisible" :title="currentRow?.project_name || '项目详情'" width="720px">
|
||||
<el-descriptions v-if="currentRow" :column="2" border size="small">
|
||||
<el-descriptions-item label="项目名称" :span="2">{{ currentRow.project_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目编号">{{ currentRow.project_no || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户名称">{{ currentRow.customer_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目金额">{{ formatMoney(currentRow.amount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目状态">
|
||||
<el-tag :type="projectStatusTag(currentRow.status)" size="small">
|
||||
{{ projectStatusText(currentRow.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="开始日期">{{ formatDateOnly(currentRow.start_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束日期">{{ formatDateOnly(currentRow.end_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">{{ currentRow.owner_user_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人">{{ currentRow.contact_person || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人职位">{{ currentRow.contact_position || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人手机">{{ currentRow.contact_phone || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目地址" :span="2">{{ currentRow.address || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ currentRow.remark || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||
import { getProjectList, deleteProject } from "@/api/crmPipeline";
|
||||
import ProjectEdit from "./components/edit.vue";
|
||||
import ContactBook from "../../erp/components/contactBook.vue";
|
||||
import {
|
||||
PROJECT_STATUS_OPTIONS,
|
||||
projectStatusText,
|
||||
projectStatusTag,
|
||||
formatMoney,
|
||||
formatDateOnly,
|
||||
} from "../dict";
|
||||
|
||||
const loading = ref(false);
|
||||
const tableData = ref([]);
|
||||
const editVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const currentRow = ref(null);
|
||||
|
||||
// 项目联系人(正式库)
|
||||
const contactBookVisible = ref(false);
|
||||
const contactBookCompanyId = ref(null);
|
||||
const contactBookCompanyName = ref("");
|
||||
|
||||
const filters = reactive({ keyword: "", status: "" });
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getProjectList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters,
|
||||
});
|
||||
tableData.value = res?.data?.list || [];
|
||||
pagination.total = res?.data?.total || 0;
|
||||
} catch (e) {
|
||||
tableData.value = [];
|
||||
pagination.total = 0;
|
||||
ElMessage.error(e.message || "查询失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = "";
|
||||
filters.status = "";
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
currentRow.value = null;
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
currentRow.value = { ...row };
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
currentRow.value = { ...row };
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
function openContactBook(row) {
|
||||
if (!row.customer_id) {
|
||||
ElMessage.warning("该项目未关联正式客户,暂无正式联系人");
|
||||
return;
|
||||
}
|
||||
contactBookCompanyId.value = row.customer_id;
|
||||
contactBookCompanyName.value = row.customer_name || "";
|
||||
contactBookVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除项目「${row.project_name}」吗?删除后不可恢复。`, "删除确认", {
|
||||
type: "warning",
|
||||
});
|
||||
await deleteProject(row.id);
|
||||
ElMessage.success("删除成功");
|
||||
fetchList();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped src="../styles/crm-page.less"></style>
|
||||
@@ -0,0 +1,60 @@
|
||||
/* CRM 业务管线页面通用样式(线索 / 商机 / 项目 / 回访) */
|
||||
.crm-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
background: var(--el-bg-color);
|
||||
padding: 16px 16px 0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: var(--el-bg-color);
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.name-link {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: var(--el-color-primary);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
Reference in New Issue
Block a user