增加商机线索管理修复若干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;
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendCrmBusinessController CRM 商机管理
|
||||
type BackendCrmBusinessController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
type businessPayload struct {
|
||||
BusinessName string `json:"business_name"`
|
||||
CustomerID uint64 `json:"customer_id"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
Source string `json:"source"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
OwnerUserName string `json:"owner_user_name"`
|
||||
Industry string `json:"industry"`
|
||||
Stage string `json:"stage"`
|
||||
Amount float64 `json:"amount"`
|
||||
ExpectDealDate string `json:"expect_deal_date"`
|
||||
NextContactTime string `json:"next_contact_time"`
|
||||
Level string `json:"level"`
|
||||
ContactPerson string `json:"contact_person"`
|
||||
ContactPosition string `json:"contact_position"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
ContactWechat string `json:"contact_wechat"`
|
||||
ContactQQ string `json:"contact_qq"`
|
||||
Address string `json:"address"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// List GET /backend/crm/business/list
|
||||
func (c *BackendCrmBusinessController) List() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
stage := strings.TrimSpace(c.GetString("stage"))
|
||||
level := strings.TrimSpace(c.GetString("level"))
|
||||
status := strings.TrimSpace(c.GetString("status"))
|
||||
ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
|
||||
|
||||
tenantID := pipelineTenantID(claims)
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
if keyword != "" {
|
||||
kw := orm.NewCondition().
|
||||
Or("business_name__contains", keyword).
|
||||
Or("customer_name__contains", keyword).
|
||||
Or("contact_person__contains", keyword)
|
||||
cond = cond.AndCond(kw)
|
||||
}
|
||||
if stage != "" {
|
||||
cond = cond.And("stage", stage)
|
||||
}
|
||||
if level != "" {
|
||||
cond = cond.And("level", level)
|
||||
}
|
||||
if status != "" {
|
||||
cond = cond.And("status", status)
|
||||
}
|
||||
if ownerID != "" {
|
||||
cond = cond.And("owner_user_id", ownerID)
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.TenantCrmBusiness)).SetCond(cond)
|
||||
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmBusiness
|
||||
if total > 0 {
|
||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// Detail GET /backend/crm/business/:id
|
||||
func (c *BackendCrmBusinessController) Detail() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
var biz models.TenantCrmBusiness
|
||||
err = models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
|
||||
Filter("delete_time__isnull", true).One(&biz)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, biz)
|
||||
}
|
||||
|
||||
// Create POST /backend/crm/business
|
||||
func (c *BackendCrmBusinessController) Create() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p businessPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.BusinessName) == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机名称不能为空")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.CustomerName) == "" && p.CustomerID == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "请选择或填写客户")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := pipelineTenantID(claims)
|
||||
customerName := strings.TrimSpace(p.CustomerName)
|
||||
var custIDVal *uint64
|
||||
if p.CustomerID > 0 {
|
||||
var cust models.TenantCrmCustomer
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&cust); err == nil {
|
||||
cid := cust.ID
|
||||
custIDVal = &cid
|
||||
customerName = cust.CustomerName
|
||||
}
|
||||
}
|
||||
if customerName == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "客户名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
ownerID := firstNonEmpty(p.OwnerUserID, pipelineUID(claims))
|
||||
ownerName := firstNonEmpty(p.OwnerUserName, claims.Username)
|
||||
stage := firstNonEmpty(p.Stage, "1")
|
||||
level := firstNonEmpty(p.Level, "2")
|
||||
now := time.Now()
|
||||
|
||||
biz := models.TenantCrmBusiness{
|
||||
TenantID: tenantID,
|
||||
BusinessName: strings.TrimSpace(p.BusinessName),
|
||||
CustomerID: custIDVal,
|
||||
CustomerName: customerName,
|
||||
Source: strings.TrimSpace(p.Source),
|
||||
OwnerUserID: ownerID,
|
||||
OwnerUserName: ownerName,
|
||||
Industry: strings.TrimSpace(p.Industry),
|
||||
Stage: stage,
|
||||
Amount: p.Amount,
|
||||
ExpectDealDate: parsePipelineDate(p.ExpectDealDate),
|
||||
NextContactTime: parsePipelineDateTime(p.NextContactTime),
|
||||
Level: level,
|
||||
ContactPerson: strings.TrimSpace(p.ContactPerson),
|
||||
ContactPosition: strings.TrimSpace(p.ContactPosition),
|
||||
ContactPhone: strings.TrimSpace(p.ContactPhone),
|
||||
ContactWechat: strings.TrimSpace(p.ContactWechat),
|
||||
ContactQQ: strings.TrimSpace(p.ContactQQ),
|
||||
Address: strings.TrimSpace(p.Address),
|
||||
Status: 1,
|
||||
Remark: p.Remark,
|
||||
CreateUserID: pipelineUID(claims),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
id, err := models.Orm.Insert(&biz)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 2, uint64(id), "create", "创建商机:"+biz.BusinessName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// Update PUT /backend/crm/business/:id
|
||||
func (c *BackendCrmBusinessController) Update() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p businessPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var biz models.TenantCrmBusiness
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&biz); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
if biz.Locked == 1 {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已转为项目并锁定,不能编辑")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.BusinessName) == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
biz.BusinessName = strings.TrimSpace(p.BusinessName)
|
||||
if p.CustomerID > 0 {
|
||||
var cust models.TenantCrmCustomer
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&cust); err == nil {
|
||||
cid := cust.ID
|
||||
biz.CustomerID = &cid
|
||||
biz.CustomerName = cust.CustomerName
|
||||
}
|
||||
} else if strings.TrimSpace(p.CustomerName) != "" {
|
||||
biz.CustomerName = strings.TrimSpace(p.CustomerName)
|
||||
}
|
||||
biz.Source = strings.TrimSpace(p.Source)
|
||||
if strings.TrimSpace(p.OwnerUserID) != "" {
|
||||
biz.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
|
||||
}
|
||||
if strings.TrimSpace(p.OwnerUserName) != "" {
|
||||
biz.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
|
||||
}
|
||||
biz.Industry = strings.TrimSpace(p.Industry)
|
||||
if strings.TrimSpace(p.Stage) != "" {
|
||||
biz.Stage = strings.TrimSpace(p.Stage)
|
||||
}
|
||||
biz.Amount = p.Amount
|
||||
biz.ExpectDealDate = parsePipelineDate(p.ExpectDealDate)
|
||||
biz.NextContactTime = parsePipelineDateTime(p.NextContactTime)
|
||||
if strings.TrimSpace(p.Level) != "" {
|
||||
biz.Level = strings.TrimSpace(p.Level)
|
||||
}
|
||||
biz.ContactPerson = strings.TrimSpace(p.ContactPerson)
|
||||
biz.ContactPosition = strings.TrimSpace(p.ContactPosition)
|
||||
biz.ContactPhone = strings.TrimSpace(p.ContactPhone)
|
||||
biz.ContactWechat = strings.TrimSpace(p.ContactWechat)
|
||||
biz.ContactQQ = strings.TrimSpace(p.ContactQQ)
|
||||
biz.Address = strings.TrimSpace(p.Address)
|
||||
biz.Remark = p.Remark
|
||||
biz.UpdateTime = time.Now()
|
||||
|
||||
if _, err := models.Orm.Update(&biz); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 2, biz.ID, "update", "更新商机:"+biz.BusinessName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID})
|
||||
}
|
||||
|
||||
// Delete DELETE /backend/crm/business/:id
|
||||
func (c *BackendCrmBusinessController) Delete() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var biz models.TenantCrmBusiness
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&biz); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
if !canDeleteCrmRecord(claims, biz.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该商机")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_, err = models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, nil)
|
||||
}
|
||||
|
||||
// Convert POST /backend/crm/business/:id/convert 商机转项目
|
||||
func (c *BackendCrmBusinessController) Convert() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
ProjectName string `json:"project_name"`
|
||||
ProjectNo string `json:"project_no"`
|
||||
Amount float64 `json:"amount"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var biz models.TenantCrmBusiness
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&biz); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
if biz.Locked == 1 || biz.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已转化,不能重复转化")
|
||||
return
|
||||
}
|
||||
|
||||
projectName := strings.TrimSpace(p.ProjectName)
|
||||
if projectName == "" {
|
||||
projectName = biz.BusinessName
|
||||
}
|
||||
amount := p.Amount
|
||||
if amount == 0 {
|
||||
amount = biz.Amount
|
||||
}
|
||||
now := time.Now()
|
||||
bizIDVal := biz.ID
|
||||
proj := models.TenantCrmProject{
|
||||
TenantID: tenantID,
|
||||
ProjectName: projectName,
|
||||
ProjectNo: strings.TrimSpace(p.ProjectNo),
|
||||
BusinessID: &bizIDVal,
|
||||
CustomerID: biz.CustomerID,
|
||||
CustomerName: biz.CustomerName,
|
||||
OwnerUserID: biz.OwnerUserID,
|
||||
OwnerUserName: biz.OwnerUserName,
|
||||
Industry: biz.Industry,
|
||||
Amount: amount,
|
||||
Status: 1,
|
||||
StartDate: parsePipelineDate(p.StartDate),
|
||||
EndDate: parsePipelineDate(p.EndDate),
|
||||
ContactPerson: biz.ContactPerson,
|
||||
ContactPosition: biz.ContactPosition,
|
||||
ContactPhone: biz.ContactPhone,
|
||||
Address: biz.Address,
|
||||
Remark: p.Remark,
|
||||
CreateUserID: pipelineUID(claims),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
projID, err := models.Orm.Insert(&proj)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "创建项目失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 锁定商机
|
||||
biz.Status = 2
|
||||
biz.Locked = 1
|
||||
pid := uint64(projID)
|
||||
biz.ProjectID = &pid
|
||||
biz.ConvertTime = &now
|
||||
biz.UpdateTime = now
|
||||
_, _ = models.Orm.Update(&biz, "status", "locked", "project_id", "convert_time", "update_time")
|
||||
|
||||
crmWriteLog(tenantID, 2, biz.ID, "convert", "商机转化为项目:"+projectName, claims)
|
||||
crmWriteLog(tenantID, 3, uint64(projID), "create", "由商机「"+biz.BusinessName+"」转化生成", claims)
|
||||
|
||||
// 生成项目时,把「商机 + 来源线索」过程库中的联系人去重同步到正式公司联系人库
|
||||
if biz.CustomerID != nil && *biz.CustomerID > 0 {
|
||||
var contacts []models.TenantCrmEntityContact
|
||||
var bizContacts []models.TenantCrmEntityContact
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("related_type", int8(2)).
|
||||
Filter("related_id", biz.ID).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&bizContacts)
|
||||
contacts = append(contacts, bizContacts...)
|
||||
if biz.ClueID != nil && *biz.ClueID > 0 {
|
||||
var clueContacts []models.TenantCrmEntityContact
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("related_type", int8(1)).
|
||||
Filter("related_id", *biz.ClueID).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&clueContacts)
|
||||
contacts = append(contacts, clueContacts...)
|
||||
}
|
||||
insertedN, mergedN := syncContactsToCompany(tenantID, *biz.CustomerID, contacts)
|
||||
if insertedN+mergedN > 0 {
|
||||
crmWriteLog(tenantID, 3, uint64(projID), "sync-contacts",
|
||||
fmt.Sprintf("同步联系人到正式库:新增 %d,合并 %d", insertedN, mergedN), claims)
|
||||
}
|
||||
}
|
||||
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"project_id": projID})
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendCrmClueController CRM 线索管理
|
||||
type BackendCrmClueController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// cluePayload 线索新增/编辑请求体
|
||||
type cluePayload struct {
|
||||
ClueName string `json:"clue_name"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
Source string `json:"source"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
OwnerUserName string `json:"owner_user_name"`
|
||||
Industry string `json:"industry"`
|
||||
NextContactTime string `json:"next_contact_time"`
|
||||
ClueLevel string `json:"clue_level"`
|
||||
ContactPerson string `json:"contact_person"`
|
||||
ContactPosition string `json:"contact_position"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
ContactWechat string `json:"contact_wechat"`
|
||||
ContactQQ string `json:"contact_qq"`
|
||||
Address string `json:"address"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// List GET /backend/crm/clue/list
|
||||
func (c *BackendCrmClueController) List() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
source := strings.TrimSpace(c.GetString("source"))
|
||||
level := strings.TrimSpace(c.GetString("clue_level"))
|
||||
status := strings.TrimSpace(c.GetString("status"))
|
||||
ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
|
||||
|
||||
tenantID := pipelineTenantID(claims)
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
if keyword != "" {
|
||||
kw := orm.NewCondition().
|
||||
Or("clue_name__contains", keyword).
|
||||
Or("customer_name__contains", keyword).
|
||||
Or("contact_person__contains", keyword).
|
||||
Or("contact_phone__contains", keyword)
|
||||
cond = cond.AndCond(kw)
|
||||
}
|
||||
if source != "" {
|
||||
cond = cond.And("source", source)
|
||||
}
|
||||
if level != "" {
|
||||
cond = cond.And("clue_level", level)
|
||||
}
|
||||
if status != "" {
|
||||
cond = cond.And("status", status)
|
||||
}
|
||||
if ownerID != "" {
|
||||
cond = cond.And("owner_user_id", ownerID)
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.TenantCrmClue)).SetCond(cond)
|
||||
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmClue
|
||||
if total > 0 {
|
||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// Detail GET /backend/crm/clue/:id
|
||||
func (c *BackendCrmClueController) Detail() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
var clue models.TenantCrmClue
|
||||
err = models.Orm.QueryTable(new(models.TenantCrmClue)).
|
||||
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
|
||||
Filter("delete_time__isnull", true).One(&clue)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, clue)
|
||||
}
|
||||
|
||||
// Create POST /backend/crm/clue
|
||||
func (c *BackendCrmClueController) Create() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p cluePayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.ClueName) == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索名称不能为空")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.CustomerName) == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "客户名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
ownerID := strings.TrimSpace(p.OwnerUserID)
|
||||
ownerName := strings.TrimSpace(p.OwnerUserName)
|
||||
if ownerID == "" {
|
||||
ownerID = pipelineUID(claims)
|
||||
}
|
||||
if ownerName == "" {
|
||||
ownerName = claims.Username
|
||||
}
|
||||
level := strings.TrimSpace(p.ClueLevel)
|
||||
if level == "" {
|
||||
level = "2"
|
||||
}
|
||||
|
||||
clue := models.TenantCrmClue{
|
||||
TenantID: pipelineTenantID(claims),
|
||||
ClueName: strings.TrimSpace(p.ClueName),
|
||||
CustomerName: strings.TrimSpace(p.CustomerName),
|
||||
Source: strings.TrimSpace(p.Source),
|
||||
OwnerUserID: ownerID,
|
||||
OwnerUserName: ownerName,
|
||||
Industry: strings.TrimSpace(p.Industry),
|
||||
NextContactTime: parsePipelineDateTime(p.NextContactTime),
|
||||
ClueLevel: level,
|
||||
ContactPerson: strings.TrimSpace(p.ContactPerson),
|
||||
ContactPosition: strings.TrimSpace(p.ContactPosition),
|
||||
ContactPhone: strings.TrimSpace(p.ContactPhone),
|
||||
ContactWechat: strings.TrimSpace(p.ContactWechat),
|
||||
ContactQQ: strings.TrimSpace(p.ContactQQ),
|
||||
Address: strings.TrimSpace(p.Address),
|
||||
Status: 1,
|
||||
Locked: 0,
|
||||
Remark: p.Remark,
|
||||
CreateUserID: pipelineUID(claims),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
id, err := models.Orm.Insert(&clue)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(clue.TenantID, 1, uint64(id), "create", "创建线索:"+clue.ClueName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// Update PUT /backend/crm/clue/:id
|
||||
func (c *BackendCrmClueController) Update() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p cluePayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
var clue models.TenantCrmClue
|
||||
err = models.Orm.QueryTable(new(models.TenantCrmClue)).
|
||||
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
|
||||
Filter("delete_time__isnull", true).One(&clue)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||
return
|
||||
}
|
||||
if clue.Locked == 1 {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能编辑")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.ClueName) == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
clue.ClueName = strings.TrimSpace(p.ClueName)
|
||||
clue.CustomerName = strings.TrimSpace(p.CustomerName)
|
||||
clue.Source = strings.TrimSpace(p.Source)
|
||||
if strings.TrimSpace(p.OwnerUserID) != "" {
|
||||
clue.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
|
||||
}
|
||||
if strings.TrimSpace(p.OwnerUserName) != "" {
|
||||
clue.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
|
||||
}
|
||||
clue.Industry = strings.TrimSpace(p.Industry)
|
||||
clue.NextContactTime = parsePipelineDateTime(p.NextContactTime)
|
||||
if strings.TrimSpace(p.ClueLevel) != "" {
|
||||
clue.ClueLevel = strings.TrimSpace(p.ClueLevel)
|
||||
}
|
||||
clue.ContactPerson = strings.TrimSpace(p.ContactPerson)
|
||||
clue.ContactPosition = strings.TrimSpace(p.ContactPosition)
|
||||
clue.ContactPhone = strings.TrimSpace(p.ContactPhone)
|
||||
clue.ContactWechat = strings.TrimSpace(p.ContactWechat)
|
||||
clue.ContactQQ = strings.TrimSpace(p.ContactQQ)
|
||||
clue.Address = strings.TrimSpace(p.Address)
|
||||
clue.Remark = p.Remark
|
||||
clue.UpdateTime = time.Now()
|
||||
|
||||
if _, err := models.Orm.Update(&clue); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(clue.TenantID, 1, clue.ID, "update", "更新线索:"+clue.ClueName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": clue.ID})
|
||||
}
|
||||
|
||||
// Delete DELETE /backend/crm/clue/:id
|
||||
func (c *BackendCrmClueController) Delete() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
var clue models.TenantCrmClue
|
||||
err = models.Orm.QueryTable(new(models.TenantCrmClue)).
|
||||
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
|
||||
Filter("delete_time__isnull", true).One(&clue)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||
return
|
||||
}
|
||||
if !canDeleteCrmRecord(claims, clue.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该线索")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_, err = models.Orm.QueryTable(new(models.TenantCrmClue)).
|
||||
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
|
||||
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, nil)
|
||||
}
|
||||
|
||||
// Convert POST /backend/crm/clue/:id/convert
|
||||
// 线索转商机:把线索的客户名称落地为正式客户(或关联已有客户),再创建商机并锁定线索。
|
||||
func (c *BackendCrmClueController) Convert() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
|
||||
var p struct {
|
||||
BusinessName string `json:"business_name"`
|
||||
Stage string `json:"stage"`
|
||||
Amount float64 `json:"amount"`
|
||||
ExpectDealDate string `json:"expect_deal_date"`
|
||||
NextContactTime string `json:"next_contact_time"`
|
||||
Level string `json:"level"`
|
||||
Remark string `json:"remark"`
|
||||
CustomerMode string `json:"customer_mode"` // create=新建客户(默认) / existing=关联已有客户
|
||||
CustomerID uint64 `json:"customer_id"`
|
||||
Customer struct {
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerType string `json:"customer_type"`
|
||||
Industry string `json:"industry"`
|
||||
ContactPerson string `json:"contact_person"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
ContactEmail string `json:"contact_email"`
|
||||
Address string `json:"address"`
|
||||
Remark string `json:"remark"`
|
||||
} `json:"customer"`
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var clue models.TenantCrmClue
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmClue)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&clue); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||
return
|
||||
}
|
||||
if clue.Locked == 1 || clue.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索已转化,不能重复转化")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 处理客户:新建正式客户 或 关联已有客户
|
||||
customerName := strings.TrimSpace(p.Customer.CustomerName)
|
||||
if customerName == "" {
|
||||
customerName = clue.CustomerName
|
||||
}
|
||||
var customerID uint64
|
||||
if strings.TrimSpace(p.CustomerMode) == "existing" && p.CustomerID > 0 {
|
||||
var cust models.TenantCrmCustomer
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&cust); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "关联的客户未找到")
|
||||
return
|
||||
}
|
||||
customerID = cust.ID
|
||||
customerName = cust.CustomerName
|
||||
} else {
|
||||
if customerName == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "客户名称不能为空")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
custType := strings.TrimSpace(p.Customer.CustomerType)
|
||||
if custType == "" {
|
||||
custType = "1"
|
||||
}
|
||||
cust := models.TenantCrmCustomer{
|
||||
TenantID: tenantID,
|
||||
CustomerName: customerName,
|
||||
CustomerType: custType,
|
||||
ContactPerson: firstNonEmpty(p.Customer.ContactPerson, clue.ContactPerson),
|
||||
ContactPhone: firstNonEmpty(p.Customer.ContactPhone, clue.ContactPhone),
|
||||
ContactEmail: strings.TrimSpace(p.Customer.ContactEmail),
|
||||
CustomerLevel: "3",
|
||||
Industry: firstNonEmpty(p.Customer.Industry, clue.Industry),
|
||||
Address: firstNonEmpty(p.Customer.Address, clue.Address),
|
||||
Status: "1",
|
||||
IsDraft: 0,
|
||||
Remark: strings.TrimSpace(p.Customer.Remark),
|
||||
OwnerUserID: clue.OwnerUserID,
|
||||
OwnerUserName: clue.OwnerUserName,
|
||||
CreateUserID: pipelineUID(claims),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
newID, err := models.Orm.Insert(&cust)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "生成正式客户失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
customerID = uint64(newID)
|
||||
crmWriteLog(tenantID, 1, clue.ID, "convert-customer", "线索转化生成正式客户:"+customerName, claims)
|
||||
}
|
||||
|
||||
// 2. 创建商机
|
||||
businessName := strings.TrimSpace(p.BusinessName)
|
||||
if businessName == "" {
|
||||
businessName = clue.ClueName
|
||||
}
|
||||
level := strings.TrimSpace(p.Level)
|
||||
if level == "" {
|
||||
level = clue.ClueLevel
|
||||
}
|
||||
stage := strings.TrimSpace(p.Stage)
|
||||
if stage == "" {
|
||||
stage = "1"
|
||||
}
|
||||
nextContact := parsePipelineDateTime(p.NextContactTime)
|
||||
if nextContact == nil {
|
||||
nextContact = clue.NextContactTime
|
||||
}
|
||||
now := time.Now()
|
||||
clueIDVal := clue.ID
|
||||
custIDVal := customerID
|
||||
biz := models.TenantCrmBusiness{
|
||||
TenantID: tenantID,
|
||||
BusinessName: businessName,
|
||||
ClueID: &clueIDVal,
|
||||
CustomerID: &custIDVal,
|
||||
CustomerName: customerName,
|
||||
Source: clue.Source,
|
||||
OwnerUserID: clue.OwnerUserID,
|
||||
OwnerUserName: clue.OwnerUserName,
|
||||
Industry: clue.Industry,
|
||||
Stage: stage,
|
||||
Amount: p.Amount,
|
||||
ExpectDealDate: parsePipelineDate(p.ExpectDealDate),
|
||||
NextContactTime: nextContact,
|
||||
Level: level,
|
||||
ContactPerson: clue.ContactPerson,
|
||||
ContactPosition: clue.ContactPosition,
|
||||
ContactPhone: clue.ContactPhone,
|
||||
ContactWechat: clue.ContactWechat,
|
||||
ContactQQ: clue.ContactQQ,
|
||||
Address: clue.Address,
|
||||
Status: 1,
|
||||
Locked: 0,
|
||||
Remark: p.Remark,
|
||||
CreateUserID: pipelineUID(claims),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
bizID, err := models.Orm.Insert(&biz)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "创建商机失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 锁定线索
|
||||
clue.Status = 2
|
||||
clue.Locked = 1
|
||||
bid := uint64(bizID)
|
||||
clue.BusinessID = &bid
|
||||
clue.ConvertTime = &now
|
||||
clue.UpdateTime = now
|
||||
_, _ = models.Orm.Update(&clue, "status", "locked", "business_id", "convert_time", "update_time")
|
||||
|
||||
crmWriteLog(tenantID, 1, clue.ID, "convert", "线索转化为商机:"+businessName, claims)
|
||||
crmWriteLog(tenantID, 2, uint64(bizID), "create", "由线索「"+clue.ClueName+"」转化生成", claims)
|
||||
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"business_id": bizID, "customer_id": customerID,
|
||||
})
|
||||
}
|
||||
|
||||
// firstNonEmpty 返回第一个非空字符串。
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// CRM 业务管线(线索/商机/项目/回访)公共辅助方法。
|
||||
// related_type 约定:1=线索 2=商机 3=项目
|
||||
|
||||
// pipelineClaims 解析租户端后台 JWT。
|
||||
func pipelineClaims(c *beego.Controller) (*jwtutil.Claims, error) {
|
||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
return nil, fmt.Errorf("未登录")
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return nil, fmt.Errorf("认证信息格式错误")
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效的token")
|
||||
}
|
||||
if claims.UserType != "backend" && claims.UserType != "platform" {
|
||||
return nil, fmt.Errorf("无权访问")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// pipelineOk 统一成功响应。
|
||||
func pipelineOk(c *beego.Controller, data interface{}) {
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// pipelineErr 统一错误响应。
|
||||
func pipelineErr(c *beego.Controller, httpStatus, bizCode int, msg string) {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// pipelineTenantID 返回当前租户ID字符串。
|
||||
func pipelineTenantID(claims *jwtutil.Claims) string {
|
||||
return fmt.Sprintf("%d", claims.TenantId)
|
||||
}
|
||||
|
||||
// pipelineUID 返回当前用户ID字符串。
|
||||
func pipelineUID(claims *jwtutil.Claims) string {
|
||||
return fmt.Sprintf("%d", claims.UserID)
|
||||
}
|
||||
|
||||
// parsePipelineDateTime 解析时间:支持 "2006-01-02 15:04:05"、"2006-01-02T15:04:05"、"2006-01-02"。
|
||||
func parsePipelineDateTime(s string) *time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePipelineDate 解析日期(仅取年月日,返回当天零点)。
|
||||
func parsePipelineDate(s string) *time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil {
|
||||
return &t
|
||||
}
|
||||
return parsePipelineDateTime(s)
|
||||
}
|
||||
|
||||
// crmWriteLog 写入 CRM 操作日志(失败静默忽略,不影响主流程)。
|
||||
func crmWriteLog(tenantID string, relatedType int8, relatedID uint64, action, content string, claims *jwtutil.Claims) {
|
||||
if models.Orm == nil || relatedID == 0 {
|
||||
return
|
||||
}
|
||||
operatorID, operatorName := "", ""
|
||||
if claims != nil {
|
||||
operatorID = pipelineUID(claims)
|
||||
operatorName = claims.Username
|
||||
}
|
||||
log := models.TenantCrmOperateLog{
|
||||
TenantID: tenantID,
|
||||
RelatedType: relatedType,
|
||||
RelatedID: relatedID,
|
||||
Action: action,
|
||||
Content: content,
|
||||
OperatorID: operatorID,
|
||||
OperatorName: operatorName,
|
||||
CreateTime: time.Now(),
|
||||
}
|
||||
_, _ = models.Orm.Insert(&log)
|
||||
}
|
||||
|
||||
// relatedTypeText 关联类型文案。
|
||||
func relatedTypeText(t int8) string {
|
||||
switch t {
|
||||
case 1:
|
||||
return "线索"
|
||||
case 2:
|
||||
return "商机"
|
||||
case 3:
|
||||
return "项目"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
// mobileInMobiles 判断手机号是否已存在于 mobiles(JSON 数组或普通字符串)中。
|
||||
func mobileInMobiles(mobile, mobiles string) bool {
|
||||
mobile = strings.TrimSpace(mobile)
|
||||
if mobile == "" || strings.TrimSpace(mobiles) == "" {
|
||||
return false
|
||||
}
|
||||
if firstMobile(mobiles) == mobile {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(mobiles, mobile)
|
||||
}
|
||||
|
||||
// mergeCompanyContact 合并过程库联系人到正式库联系人:仅补齐正式库空缺字段,不覆盖已有数据。
|
||||
func mergeCompanyContact(dst *models.ErpCompanyContact, src models.TenantCrmEntityContact) {
|
||||
changed := false
|
||||
if dst.Wechat == "" && strings.TrimSpace(src.Wechat) != "" {
|
||||
dst.Wechat = strings.TrimSpace(src.Wechat)
|
||||
changed = true
|
||||
}
|
||||
if dst.QQ == "" && strings.TrimSpace(src.QQ) != "" {
|
||||
dst.QQ = strings.TrimSpace(src.QQ)
|
||||
changed = true
|
||||
}
|
||||
if dst.Email == "" && strings.TrimSpace(src.Email) != "" {
|
||||
dst.Email = strings.TrimSpace(src.Email)
|
||||
changed = true
|
||||
}
|
||||
if dst.Position == "" && strings.TrimSpace(src.Position) != "" {
|
||||
dst.Position = strings.TrimSpace(src.Position)
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(dst.Mobiles) == "" && strings.TrimSpace(src.Mobile) != "" {
|
||||
dst.Mobiles = buildMobiles(src.Mobile)
|
||||
changed = true
|
||||
}
|
||||
if dst.Remark == "" && strings.TrimSpace(src.Remark) != "" {
|
||||
dst.Remark = strings.TrimSpace(src.Remark)
|
||||
changed = true
|
||||
}
|
||||
if src.IsPrimary == 1 && dst.IsPrimary != 1 {
|
||||
dst.IsPrimary = 1
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
dst.UpdateTime = time.Now()
|
||||
_, _ = models.Orm.Update(dst)
|
||||
}
|
||||
}
|
||||
|
||||
// syncContactsToCompany 把线索/商机过程库(yz_tenant_crm_entity_contact)的联系人
|
||||
// 去重同步到正式公司联系人库(yz_backend_contact_company,company_type=customer)。
|
||||
// 去重键:company_type + company_id + name + mobile;命中则补齐字段,否则新增。
|
||||
// 返回 (新增数, 合并数)。
|
||||
func syncContactsToCompany(tenantID string, customerID uint64, contacts []models.TenantCrmEntityContact) (int, int) {
|
||||
if models.Orm == nil || customerID == 0 || len(contacts) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
inserted, merged := 0, 0
|
||||
seen := map[string]bool{}
|
||||
for _, src := range contacts {
|
||||
name := strings.TrimSpace(src.ContactName)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
mobile := strings.TrimSpace(src.Mobile)
|
||||
// 本次待同步集合内去重(同名同手机只处理一次)
|
||||
key := name + "|" + mobile
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
var existingList []models.ErpCompanyContact
|
||||
_, _ = models.Orm.QueryTable(new(models.ErpCompanyContact)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("company_type", "customer").
|
||||
Filter("company_id", customerID).
|
||||
Filter("name", name).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&existingList)
|
||||
|
||||
matched := false
|
||||
for i := range existingList {
|
||||
// 有手机号时必须手机号匹配;无手机号时按同名视为同一人
|
||||
if mobile == "" || mobileInMobiles(mobile, existingList[i].Mobiles) {
|
||||
mergeCompanyContact(&existingList[i], src)
|
||||
merged++
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
continue
|
||||
}
|
||||
|
||||
row := models.ErpCompanyContact{
|
||||
TenantID: tenantID,
|
||||
CompanyType: "customer",
|
||||
CompanyID: customerID,
|
||||
Name: name,
|
||||
Phone: "",
|
||||
Mobiles: buildMobiles(mobile),
|
||||
Wechat: strings.TrimSpace(src.Wechat),
|
||||
QQ: strings.TrimSpace(src.QQ),
|
||||
Email: strings.TrimSpace(src.Email),
|
||||
Position: strings.TrimSpace(src.Position),
|
||||
IsPrimary: src.IsPrimary,
|
||||
Status: 1,
|
||||
Remark: strings.TrimSpace(src.Remark),
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
if _, err := models.Orm.Insert(&row); err == nil {
|
||||
inserted++
|
||||
}
|
||||
}
|
||||
return inserted, merged
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendCrmProjectController CRM 项目管理
|
||||
type BackendCrmProjectController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
type projectPayload struct {
|
||||
ProjectName string `json:"project_name"`
|
||||
ProjectNo string `json:"project_no"`
|
||||
CustomerID uint64 `json:"customer_id"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
OwnerUserName string `json:"owner_user_name"`
|
||||
Industry string `json:"industry"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status int8 `json:"status"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
ContactPerson string `json:"contact_person"`
|
||||
ContactPosition string `json:"contact_position"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
Address string `json:"address"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// List GET /backend/crm/project/list
|
||||
func (c *BackendCrmProjectController) List() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
status := strings.TrimSpace(c.GetString("status"))
|
||||
ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
|
||||
|
||||
tenantID := pipelineTenantID(claims)
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
if keyword != "" {
|
||||
kw := orm.NewCondition().
|
||||
Or("project_name__contains", keyword).
|
||||
Or("project_no__contains", keyword).
|
||||
Or("customer_name__contains", keyword)
|
||||
cond = cond.AndCond(kw)
|
||||
}
|
||||
if status != "" {
|
||||
cond = cond.And("status", status)
|
||||
}
|
||||
if ownerID != "" {
|
||||
cond = cond.And("owner_user_id", ownerID)
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond)
|
||||
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmProject
|
||||
if total > 0 {
|
||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// Detail GET /backend/crm/project/:id
|
||||
func (c *BackendCrmProjectController) Detail() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
var proj models.TenantCrmProject
|
||||
err = models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
|
||||
Filter("delete_time__isnull", true).One(&proj)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "项目未找到")
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, proj)
|
||||
}
|
||||
|
||||
// Create POST /backend/crm/project
|
||||
func (c *BackendCrmProjectController) Create() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p projectPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.ProjectName) == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "项目名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var custIDVal *uint64
|
||||
customerName := strings.TrimSpace(p.CustomerName)
|
||||
if p.CustomerID > 0 {
|
||||
var cust models.TenantCrmCustomer
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&cust); err == nil {
|
||||
cid := cust.ID
|
||||
custIDVal = &cid
|
||||
customerName = cust.CustomerName
|
||||
}
|
||||
}
|
||||
status := p.Status
|
||||
if status == 0 {
|
||||
status = 1
|
||||
}
|
||||
now := time.Now()
|
||||
proj := models.TenantCrmProject{
|
||||
TenantID: tenantID,
|
||||
ProjectName: strings.TrimSpace(p.ProjectName),
|
||||
ProjectNo: strings.TrimSpace(p.ProjectNo),
|
||||
CustomerID: custIDVal,
|
||||
CustomerName: customerName,
|
||||
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
|
||||
OwnerUserName: firstNonEmpty(p.OwnerUserName, claims.Username),
|
||||
Industry: strings.TrimSpace(p.Industry),
|
||||
Amount: p.Amount,
|
||||
Status: status,
|
||||
StartDate: parsePipelineDate(p.StartDate),
|
||||
EndDate: parsePipelineDate(p.EndDate),
|
||||
ContactPerson: strings.TrimSpace(p.ContactPerson),
|
||||
ContactPosition: strings.TrimSpace(p.ContactPosition),
|
||||
ContactPhone: strings.TrimSpace(p.ContactPhone),
|
||||
Address: strings.TrimSpace(p.Address),
|
||||
Remark: p.Remark,
|
||||
CreateUserID: pipelineUID(claims),
|
||||
CreateTime: now,
|
||||
UpdateTime: now,
|
||||
}
|
||||
id, err := models.Orm.Insert(&proj)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 3, uint64(id), "create", "创建项目:"+proj.ProjectName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// Update PUT /backend/crm/project/:id
|
||||
func (c *BackendCrmProjectController) Update() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p projectPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var proj models.TenantCrmProject
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&proj); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "项目未找到")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.ProjectName) == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "项目名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
proj.ProjectName = strings.TrimSpace(p.ProjectName)
|
||||
proj.ProjectNo = strings.TrimSpace(p.ProjectNo)
|
||||
if p.CustomerID > 0 {
|
||||
var cust models.TenantCrmCustomer
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmCustomer)).
|
||||
Filter("id", p.CustomerID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&cust); err == nil {
|
||||
cid := cust.ID
|
||||
proj.CustomerID = &cid
|
||||
proj.CustomerName = cust.CustomerName
|
||||
}
|
||||
} else if strings.TrimSpace(p.CustomerName) != "" {
|
||||
proj.CustomerName = strings.TrimSpace(p.CustomerName)
|
||||
}
|
||||
if strings.TrimSpace(p.OwnerUserID) != "" {
|
||||
proj.OwnerUserID = strings.TrimSpace(p.OwnerUserID)
|
||||
}
|
||||
if strings.TrimSpace(p.OwnerUserName) != "" {
|
||||
proj.OwnerUserName = strings.TrimSpace(p.OwnerUserName)
|
||||
}
|
||||
proj.Industry = strings.TrimSpace(p.Industry)
|
||||
proj.Amount = p.Amount
|
||||
if p.Status != 0 {
|
||||
proj.Status = p.Status
|
||||
}
|
||||
proj.StartDate = parsePipelineDate(p.StartDate)
|
||||
proj.EndDate = parsePipelineDate(p.EndDate)
|
||||
proj.ContactPerson = strings.TrimSpace(p.ContactPerson)
|
||||
proj.ContactPosition = strings.TrimSpace(p.ContactPosition)
|
||||
proj.ContactPhone = strings.TrimSpace(p.ContactPhone)
|
||||
proj.Address = strings.TrimSpace(p.Address)
|
||||
proj.Remark = p.Remark
|
||||
proj.UpdateTime = time.Now()
|
||||
|
||||
if _, err := models.Orm.Update(&proj); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 3, proj.ID, "update", "更新项目:"+proj.ProjectName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID})
|
||||
}
|
||||
|
||||
// Delete DELETE /backend/crm/project/:id
|
||||
func (c *BackendCrmProjectController) Delete() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var proj models.TenantCrmProject
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&proj); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "项目未找到")
|
||||
return
|
||||
}
|
||||
if !canDeleteCrmRecord(claims, proj.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该项目")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_, err = models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, nil)
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// ============================== 回访 / 跟进记录 ==============================
|
||||
|
||||
// BackendCrmFollowController 回访记录(贯穿线索/商机/项目)
|
||||
type BackendCrmFollowController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// List GET /backend/crm/follow/list
|
||||
func (c *BackendCrmFollowController) List() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
relatedType := strings.TrimSpace(c.GetString("related_type"))
|
||||
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
||||
followType := strings.TrimSpace(c.GetString("follow_type"))
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
|
||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||
if relatedType != "" {
|
||||
cond = cond.And("related_type", relatedType)
|
||||
}
|
||||
if relatedID != "" && relatedID != "0" {
|
||||
cond = cond.And("related_id", relatedID)
|
||||
}
|
||||
if followType != "" {
|
||||
cond = cond.And("follow_type", followType)
|
||||
}
|
||||
if keyword != "" {
|
||||
kw := orm.NewCondition().
|
||||
Or("related_name__contains", keyword).
|
||||
Or("content__contains", keyword)
|
||||
cond = cond.AndCond(kw)
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.TenantCrmFollow)).SetCond(cond)
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmFollow
|
||||
if total > 0 {
|
||||
_, _ = qs.OrderBy("-follow_time", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
type followPayload struct {
|
||||
ID uint64 `json:"id"`
|
||||
RelatedType int8 `json:"related_type"`
|
||||
RelatedID uint64 `json:"related_id"`
|
||||
RelatedName string `json:"related_name"`
|
||||
FollowType string `json:"follow_type"`
|
||||
FollowTime string `json:"follow_time"`
|
||||
Content string `json:"content"`
|
||||
NextContactTime string `json:"next_contact_time"`
|
||||
}
|
||||
|
||||
// Add POST /backend/crm/follow/add
|
||||
func (c *BackendCrmFollowController) Add() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p followPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if p.RelatedID == 0 || (p.RelatedType != 1 && p.RelatedType != 2 && p.RelatedType != 3) {
|
||||
pipelineErr(&c.Controller, 400, 400, "请指定关联对象")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.Content) == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "回访内容不能为空")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
followTime := parsePipelineDateTime(p.FollowTime)
|
||||
if followTime == nil {
|
||||
t := time.Now()
|
||||
followTime = &t
|
||||
}
|
||||
nextContact := parsePipelineDateTime(p.NextContactTime)
|
||||
row := models.TenantCrmFollow{
|
||||
TenantID: tenantID,
|
||||
RelatedType: p.RelatedType,
|
||||
RelatedID: p.RelatedID,
|
||||
RelatedName: strings.TrimSpace(p.RelatedName),
|
||||
FollowType: firstNonEmpty(p.FollowType, "1"),
|
||||
FollowTime: followTime,
|
||||
Content: p.Content,
|
||||
NextContactTime: nextContact,
|
||||
OwnerUserID: pipelineUID(claims),
|
||||
OwnerUserName: claims.Username,
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
id, err := models.Orm.Insert(&row)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "新增失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 同步更新关联对象的下次联系时间
|
||||
if nextContact != nil {
|
||||
syncNextContact(tenantID, p.RelatedType, p.RelatedID, nextContact)
|
||||
}
|
||||
crmWriteLog(tenantID, p.RelatedType, p.RelatedID, "follow", "新增回访记录:"+strings.TrimSpace(p.RelatedName), claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// Edit POST /backend/crm/follow/edit
|
||||
func (c *BackendCrmFollowController) Edit() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p followPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if p.ID == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "缺少ID")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var row models.TenantCrmFollow
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmFollow)).
|
||||
Filter("id", p.ID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&row); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "回访记录未找到")
|
||||
return
|
||||
}
|
||||
row.FollowType = firstNonEmpty(p.FollowType, row.FollowType)
|
||||
if t := parsePipelineDateTime(p.FollowTime); t != nil {
|
||||
row.FollowTime = t
|
||||
}
|
||||
row.Content = p.Content
|
||||
row.NextContactTime = parsePipelineDateTime(p.NextContactTime)
|
||||
row.UpdateTime = time.Now()
|
||||
if _, err := models.Orm.Update(&row); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
|
||||
}
|
||||
|
||||
// Delete POST /backend/crm/follow/delete
|
||||
func (c *BackendCrmFollowController) Delete() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
ID uint64 `json:"id"`
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
num, err := models.Orm.QueryTable(new(models.TenantCrmFollow)).
|
||||
Filter("id", p.ID).Filter("tenant_id", pipelineTenantID(claims)).
|
||||
Filter("delete_time__isnull", true).
|
||||
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if num == 0 {
|
||||
pipelineErr(&c.Controller, 404, 404, "回访记录未找到")
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, nil)
|
||||
}
|
||||
|
||||
// syncNextContact 回访后同步更新线索/商机的下次联系时间。
|
||||
func syncNextContact(tenantID string, relatedType int8, relatedID uint64, next *time.Time) {
|
||||
if models.Orm == nil || next == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
switch relatedType {
|
||||
case 1:
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmClue)).
|
||||
Filter("id", relatedID).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"next_contact_time": next, "update_time": now})
|
||||
case 2:
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", relatedID).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"next_contact_time": next, "update_time": now})
|
||||
}
|
||||
}
|
||||
|
||||
// ================================= 附件 =================================
|
||||
|
||||
// BackendCrmAttachController 附件(线索/商机/项目)
|
||||
type BackendCrmAttachController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// List GET /backend/crm/attach/list
|
||||
func (c *BackendCrmAttachController) List() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
relatedType := strings.TrimSpace(c.GetString("related_type"))
|
||||
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
||||
cond := orm.NewCondition().
|
||||
And("tenant_id", pipelineTenantID(claims)).
|
||||
And("delete_time__isnull", true)
|
||||
if relatedType != "" {
|
||||
cond = cond.And("related_type", relatedType)
|
||||
}
|
||||
if relatedID != "" {
|
||||
cond = cond.And("related_id", relatedID)
|
||||
}
|
||||
var list []models.TenantCrmAttach
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(cond).OrderBy("-id").All(&list)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
|
||||
}
|
||||
|
||||
// Add POST /backend/crm/attach/add
|
||||
func (c *BackendCrmAttachController) Add() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
RelatedType int8 `json:"related_type"`
|
||||
RelatedID uint64 `json:"related_id"`
|
||||
FileID uint64 `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileURL string `json:"file_url"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if p.RelatedID == 0 || strings.TrimSpace(p.FileURL) == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "附件信息不完整")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var fileIDPtr *uint64
|
||||
if p.FileID > 0 {
|
||||
fid := p.FileID
|
||||
fileIDPtr = &fid
|
||||
}
|
||||
row := models.TenantCrmAttach{
|
||||
TenantID: tenantID,
|
||||
RelatedType: p.RelatedType,
|
||||
RelatedID: p.RelatedID,
|
||||
FileID: fileIDPtr,
|
||||
FileName: strings.TrimSpace(p.FileName),
|
||||
FileURL: strings.TrimSpace(p.FileURL),
|
||||
FileSize: p.FileSize,
|
||||
UploaderID: pipelineUID(claims),
|
||||
UploaderName: claims.Username,
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
id, err := models.Orm.Insert(&row)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "新增失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, p.RelatedType, p.RelatedID, "attach", "新增附件:"+row.FileName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// Delete POST /backend/crm/attach/delete
|
||||
func (c *BackendCrmAttachController) Delete() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
ID uint64 `json:"id"`
|
||||
}
|
||||
raw := c.Ctx.Input.RequestBody
|
||||
if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
num, err := models.Orm.QueryTable(new(models.TenantCrmAttach)).
|
||||
Filter("id", p.ID).Filter("tenant_id", pipelineTenantID(claims)).
|
||||
Filter("delete_time__isnull", true).
|
||||
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if num == 0 {
|
||||
pipelineErr(&c.Controller, 404, 404, "附件未找到")
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, nil)
|
||||
}
|
||||
|
||||
// ========================== 联系人(线索/商机/项目) ==========================
|
||||
|
||||
// BackendCrmEntityContactController 实体联系人
|
||||
type BackendCrmEntityContactController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// List GET /backend/crm/entity/contact/list
|
||||
func (c *BackendCrmEntityContactController) List() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
relatedType := strings.TrimSpace(c.GetString("related_type"))
|
||||
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
||||
cond := orm.NewCondition().
|
||||
And("tenant_id", pipelineTenantID(claims)).
|
||||
And("delete_time__isnull", true)
|
||||
if relatedType != "" {
|
||||
cond = cond.And("related_type", relatedType)
|
||||
}
|
||||
if relatedID != "" {
|
||||
cond = cond.And("related_id", relatedID)
|
||||
}
|
||||
var list []models.TenantCrmEntityContact
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).SetCond(cond).
|
||||
OrderBy("-is_primary", "-id").All(&list)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
|
||||
}
|
||||
|
||||
type entityContactPayload struct {
|
||||
ID uint64 `json:"id"`
|
||||
RelatedType int8 `json:"related_type"`
|
||||
RelatedID uint64 `json:"related_id"`
|
||||
ContactName string `json:"contact_name"`
|
||||
Position string `json:"position"`
|
||||
Mobile string `json:"mobile"`
|
||||
Wechat string `json:"wechat"`
|
||||
QQ string `json:"qq"`
|
||||
Email string `json:"email"`
|
||||
IsPrimary int8 `json:"is_primary"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// Add POST /backend/crm/entity/contact/add
|
||||
func (c *BackendCrmEntityContactController) Add() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
raw := c.Ctx.Input.RequestBody
|
||||
var p entityContactPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.ContactName) == "" || p.RelatedID == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "联系人姓名和关联对象不能为空")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
row := models.TenantCrmEntityContact{
|
||||
TenantID: tenantID,
|
||||
RelatedType: p.RelatedType,
|
||||
RelatedID: p.RelatedID,
|
||||
ContactName: strings.TrimSpace(p.ContactName),
|
||||
Position: strings.TrimSpace(p.Position),
|
||||
Mobile: strings.TrimSpace(p.Mobile),
|
||||
Wechat: strings.TrimSpace(p.Wechat),
|
||||
QQ: strings.TrimSpace(p.QQ),
|
||||
Email: strings.TrimSpace(p.Email),
|
||||
IsPrimary: p.IsPrimary,
|
||||
Remark: strings.TrimSpace(p.Remark),
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
id, err := models.Orm.Insert(&row)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "新增失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if p.IsPrimary == 1 {
|
||||
clearOtherEntityPrimary(tenantID, p.RelatedType, p.RelatedID, uint64(id))
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// Edit POST /backend/crm/entity/contact/edit
|
||||
func (c *BackendCrmEntityContactController) Edit() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
raw := c.Ctx.Input.RequestBody
|
||||
var p entityContactPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var row models.TenantCrmEntityContact
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmEntityContact)).
|
||||
Filter("id", p.ID).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&row); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "联系人未找到")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.ContactName) == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "联系人姓名不能为空")
|
||||
return
|
||||
}
|
||||
row.ContactName = strings.TrimSpace(p.ContactName)
|
||||
row.Position = strings.TrimSpace(p.Position)
|
||||
row.Mobile = strings.TrimSpace(p.Mobile)
|
||||
row.Wechat = strings.TrimSpace(p.Wechat)
|
||||
row.QQ = strings.TrimSpace(p.QQ)
|
||||
row.Email = strings.TrimSpace(p.Email)
|
||||
row.IsPrimary = p.IsPrimary
|
||||
row.Remark = strings.TrimSpace(p.Remark)
|
||||
row.UpdateTime = time.Now()
|
||||
if _, err := models.Orm.Update(&row); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if p.IsPrimary == 1 {
|
||||
clearOtherEntityPrimary(tenantID, row.RelatedType, row.RelatedID, row.ID)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID})
|
||||
}
|
||||
|
||||
// Delete POST /backend/crm/entity/contact/delete
|
||||
func (c *BackendCrmEntityContactController) Delete() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
ID uint64 `json:"id"`
|
||||
}
|
||||
raw := c.Ctx.Input.RequestBody
|
||||
if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
num, err := models.Orm.QueryTable(new(models.TenantCrmEntityContact)).
|
||||
Filter("id", p.ID).Filter("tenant_id", pipelineTenantID(claims)).
|
||||
Filter("delete_time__isnull", true).
|
||||
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if num == 0 {
|
||||
pipelineErr(&c.Controller, 404, 404, "联系人未找到")
|
||||
return
|
||||
}
|
||||
pipelineOk(&c.Controller, nil)
|
||||
}
|
||||
|
||||
func clearOtherEntityPrimary(tenantID string, relatedType int8, relatedID, excludeID uint64) {
|
||||
if models.Orm == nil {
|
||||
return
|
||||
}
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).
|
||||
Filter("tenant_id", tenantID).
|
||||
Filter("related_type", relatedType).
|
||||
Filter("related_id", relatedID).
|
||||
Filter("delete_time__isnull", true).
|
||||
Exclude("id", excludeID).
|
||||
Update(map[string]interface{}{"is_primary": 0})
|
||||
}
|
||||
|
||||
// ============================== 操作日志 ==============================
|
||||
|
||||
// BackendCrmOperateLogController 操作日志
|
||||
type BackendCrmOperateLogController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// List GET /backend/crm/oplog/list
|
||||
func (c *BackendCrmOperateLogController) List() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
relatedType := strings.TrimSpace(c.GetString("related_type"))
|
||||
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
||||
cond := orm.NewCondition().And("tenant_id", pipelineTenantID(claims))
|
||||
if relatedType != "" {
|
||||
cond = cond.And("related_type", relatedType)
|
||||
}
|
||||
if relatedID != "" {
|
||||
cond = cond.And("related_id", relatedID)
|
||||
}
|
||||
var list []models.TenantCrmOperateLog
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmOperateLog)).SetCond(cond).OrderBy("-id").All(&list)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
|
||||
}
|
||||
@@ -2,7 +2,8 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ErpCompanyContact 客户/供应商联系人表
|
||||
// ErpCompanyContact 正式客户/供应商联系人通用表: yz_backend_contact_company
|
||||
// 全模块共用的「公司联系人」正式库(客户/供应商对接人)。
|
||||
type ErpCompanyContact struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
@@ -28,5 +29,5 @@ type ErpCompanyContact struct {
|
||||
}
|
||||
|
||||
func (m *ErpCompanyContact) TableName() string {
|
||||
return "yz_backend_erp_company_contact"
|
||||
return "yz_backend_contact_company"
|
||||
}
|
||||
|
||||
@@ -74,6 +74,13 @@ func Init(_ string) {
|
||||
new(BackendUserNotifyConfig),
|
||||
new(TenantCrmCustomer),
|
||||
new(TenantCrmSupplier),
|
||||
new(TenantCrmClue),
|
||||
new(TenantCrmBusiness),
|
||||
new(TenantCrmProject),
|
||||
new(TenantCrmFollow),
|
||||
new(TenantCrmAttach),
|
||||
new(TenantCrmEntityContact),
|
||||
new(TenantCrmOperateLog),
|
||||
new(ErpAccountSet),
|
||||
new(ErpNormalSetting),
|
||||
new(ErpCompanyContact),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmAttach 附件表: yz_tenant_crm_attach
|
||||
// related_type: 1线索 / 2商机 / 3项目
|
||||
type TenantCrmAttach struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
RelatedType int8 `orm:"column(related_type)" json:"related_type"`
|
||||
RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
|
||||
FileID *uint64 `orm:"column(file_id);null" json:"file_id"`
|
||||
FileName string `orm:"column(file_name);size(255)" json:"file_name"`
|
||||
FileURL string `orm:"column(file_url);size(500)" json:"file_url"`
|
||||
FileSize int64 `orm:"column(file_size);default(0)" json:"file_size"`
|
||||
UploaderID string `orm:"column(uploader_id);size(64)" json:"uploader_id"`
|
||||
UploaderName string `orm:"column(uploader_name);size(128)" json:"uploader_name"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *TenantCrmAttach) TableName() string {
|
||||
return "yz_tenant_crm_attach"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmBusiness 商机表: yz_tenant_crm_business
|
||||
type TenantCrmBusiness struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
BusinessName string `orm:"column(business_name);size(100)" json:"business_name"` // 商机名称
|
||||
ClueID *uint64 `orm:"column(clue_id);null" json:"clue_id"` // 来源线索ID
|
||||
CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"` // 正式客户ID
|
||||
CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
|
||||
Source string `orm:"column(source);size(50)" json:"source"`
|
||||
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
|
||||
OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
|
||||
Industry string `orm:"column(industry);size(50)" json:"industry"`
|
||||
Stage string `orm:"column(stage);size(20);default(1)" json:"stage"` // 商机阶段
|
||||
Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"`
|
||||
ExpectDealDate *time.Time `orm:"column(expect_deal_date);type(date);null" json:"expect_deal_date"`
|
||||
NextContactTime *time.Time `orm:"column(next_contact_time);type(datetime);null" json:"next_contact_time"`
|
||||
Level string `orm:"column(level);size(20);default(2)" json:"level"`
|
||||
ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"`
|
||||
ContactPosition string `orm:"column(contact_position);size(50)" json:"contact_position"` // 对接人职位
|
||||
ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"`
|
||||
ContactWechat string `orm:"column(contact_wechat);size(64)" json:"contact_wechat"`
|
||||
ContactQQ string `orm:"column(contact_qq);size(20)" json:"contact_qq"`
|
||||
Address string `orm:"column(address);size(255)" json:"address"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1跟进中/2已转化项目/3已关闭
|
||||
Locked int8 `orm:"column(locked);default(0)" json:"locked"` // 转化项目后锁定
|
||||
ProjectID *uint64 `orm:"column(project_id);null" json:"project_id"` // 转化后的项目ID
|
||||
ConvertTime *time.Time `orm:"column(convert_time);type(datetime);null" json:"convert_time"`
|
||||
Remark string `orm:"column(remark);type(text);null" json:"remark"`
|
||||
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *TenantCrmBusiness) TableName() string {
|
||||
return "yz_tenant_crm_business"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmClue 线索表: yz_tenant_crm_clue
|
||||
// 线索阶段的「客户名称」不关联客户管理,转化商机时才落地为正式客户。
|
||||
type TenantCrmClue struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
ClueName string `orm:"column(clue_name);size(100)" json:"clue_name"` // 线索名称
|
||||
CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"` // 客户名称(暂不关联客户管理)
|
||||
Source string `orm:"column(source);size(50)" json:"source"` // 客户来源
|
||||
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"` // 负责人
|
||||
OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
|
||||
Industry string `orm:"column(industry);size(50)" json:"industry"` // 客户行业
|
||||
NextContactTime *time.Time `orm:"column(next_contact_time);type(datetime);null" json:"next_contact_time"` // 下次联系时间
|
||||
ClueLevel string `orm:"column(clue_level);size(20);default(2)" json:"clue_level"` // 客户级别:1重点/2普通/3非优先
|
||||
ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"` // 客户对接人
|
||||
ContactPosition string `orm:"column(contact_position);size(50)" json:"contact_position"` // 对接人职位
|
||||
ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"` // 对接人手机
|
||||
ContactWechat string `orm:"column(contact_wechat);size(64)" json:"contact_wechat"` // 对接人微信
|
||||
ContactQQ string `orm:"column(contact_qq);size(20)" json:"contact_qq"` // 对接人QQ
|
||||
Address string `orm:"column(address);size(255)" json:"address"` // 公司地址
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1跟进中/2已转化/3已关闭
|
||||
Locked int8 `orm:"column(locked);default(0)" json:"locked"` // 0否/1是(转化后锁定不可编辑)
|
||||
BusinessID *uint64 `orm:"column(business_id);null" json:"business_id"` // 转化后的商机ID
|
||||
ConvertTime *time.Time `orm:"column(convert_time);type(datetime);null" json:"convert_time"` // 转化时间
|
||||
Remark string `orm:"column(remark);type(text);null" json:"remark"`
|
||||
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *TenantCrmClue) TableName() string {
|
||||
return "yz_tenant_crm_clue"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmEntityContact 联系人表: yz_tenant_crm_entity_contact
|
||||
// related_type: 1线索 / 2商机 / 3项目
|
||||
type TenantCrmEntityContact struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
RelatedType int8 `orm:"column(related_type)" json:"related_type"`
|
||||
RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
|
||||
ContactName string `orm:"column(contact_name);size(50)" json:"contact_name"`
|
||||
Position string `orm:"column(position);size(50)" json:"position"`
|
||||
Mobile string `orm:"column(mobile);size(20)" json:"mobile"`
|
||||
Wechat string `orm:"column(wechat);size(64)" json:"wechat"`
|
||||
QQ string `orm:"column(qq);size(20)" json:"qq"`
|
||||
Email string `orm:"column(email);size(100)" json:"email"`
|
||||
IsPrimary int8 `orm:"column(is_primary);default(0)" json:"is_primary"`
|
||||
Remark string `orm:"column(remark);size(500)" json:"remark"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *TenantCrmEntityContact) TableName() string {
|
||||
return "yz_tenant_crm_entity_contact"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmFollow 回访/跟进记录表: yz_tenant_crm_follow
|
||||
// related_type: 1线索 / 2商机 / 3项目
|
||||
type TenantCrmFollow struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
RelatedType int8 `orm:"column(related_type)" json:"related_type"`
|
||||
RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
|
||||
RelatedName string `orm:"column(related_name);size(100)" json:"related_name"`
|
||||
FollowType string `orm:"column(follow_type);size(20);default(1)" json:"follow_type"` // 回访方式
|
||||
FollowTime *time.Time `orm:"column(follow_time);type(datetime);null" json:"follow_time"`
|
||||
Content string `orm:"column(content);type(text);null" json:"content"`
|
||||
NextContactTime *time.Time `orm:"column(next_contact_time);type(datetime);null" json:"next_contact_time"`
|
||||
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
|
||||
OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *TenantCrmFollow) TableName() string {
|
||||
return "yz_tenant_crm_follow"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmOperateLog 操作日志表: yz_tenant_crm_operate_log
|
||||
// related_type: 1线索 / 2商机 / 3项目
|
||||
type TenantCrmOperateLog struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
RelatedType int8 `orm:"column(related_type)" json:"related_type"`
|
||||
RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
|
||||
Action string `orm:"column(action);size(50)" json:"action"`
|
||||
Content string `orm:"column(content);size(500)" json:"content"`
|
||||
OperatorID string `orm:"column(operator_id);size(64)" json:"operator_id"`
|
||||
OperatorName string `orm:"column(operator_name);size(128)" json:"operator_name"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
}
|
||||
|
||||
func (m *TenantCrmOperateLog) TableName() string {
|
||||
return "yz_tenant_crm_operate_log"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// TenantCrmProject 项目表: yz_tenant_crm_project
|
||||
type TenantCrmProject struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||
ProjectName string `orm:"column(project_name);size(100)" json:"project_name"` // 项目名称
|
||||
ProjectNo string `orm:"column(project_no);size(50)" json:"project_no"` // 项目编号
|
||||
BusinessID *uint64 `orm:"column(business_id);null" json:"business_id"` // 来源商机ID
|
||||
CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"`
|
||||
CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
|
||||
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
|
||||
OwnerUserName string `orm:"column(owner_user_name);size(128)" json:"owner_user_name"`
|
||||
Industry string `orm:"column(industry);size(50)" json:"industry"`
|
||||
Amount float64 `orm:"column(amount);digits(14);decimals(2);default(0)" json:"amount"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 1未开始/2进行中/3已完成/4已暂停
|
||||
StartDate *time.Time `orm:"column(start_date);type(date);null" json:"start_date"`
|
||||
EndDate *time.Time `orm:"column(end_date);type(date);null" json:"end_date"`
|
||||
ContactPerson string `orm:"column(contact_person);size(50)" json:"contact_person"`
|
||||
ContactPosition string `orm:"column(contact_position);size(50)" json:"contact_position"` // 对接人职位
|
||||
ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"`
|
||||
Address string `orm:"column(address);size(255)" json:"address"`
|
||||
Remark string `orm:"column(remark);type(text);null" json:"remark"`
|
||||
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *TenantCrmProject) TableName() string {
|
||||
return "yz_tenant_crm_project"
|
||||
}
|
||||
@@ -384,6 +384,43 @@ func registerOrganizationRoutes(module string) {
|
||||
beego.Router("/backend/crm/pool/claim", &controllers.BackendCrmPoolController{}, "post:Claim")
|
||||
beego.Router("/backend/crm/pool/assign", &controllers.BackendCrmPoolController{}, "post:Assign")
|
||||
|
||||
// CRM线索管理(线索 -> 商机转化)
|
||||
beego.Router("/backend/crm/clue/list", &controllers.BackendCrmClueController{}, "get:List")
|
||||
beego.Router("/backend/crm/clue", &controllers.BackendCrmClueController{}, "post:Create")
|
||||
beego.Router("/backend/crm/clue/:id", &controllers.BackendCrmClueController{}, "get:Detail;put:Update;delete:Delete")
|
||||
beego.Router("/backend/crm/clue/:id/convert", &controllers.BackendCrmClueController{}, "post:Convert")
|
||||
|
||||
// CRM商机管理(商机 -> 项目转化)
|
||||
beego.Router("/backend/crm/business/list", &controllers.BackendCrmBusinessController{}, "get:List")
|
||||
beego.Router("/backend/crm/business", &controllers.BackendCrmBusinessController{}, "post:Create")
|
||||
beego.Router("/backend/crm/business/:id", &controllers.BackendCrmBusinessController{}, "get:Detail;put:Update;delete:Delete")
|
||||
beego.Router("/backend/crm/business/:id/convert", &controllers.BackendCrmBusinessController{}, "post:Convert")
|
||||
|
||||
// CRM项目管理
|
||||
beego.Router("/backend/crm/project/list", &controllers.BackendCrmProjectController{}, "get:List")
|
||||
beego.Router("/backend/crm/project", &controllers.BackendCrmProjectController{}, "post:Create")
|
||||
beego.Router("/backend/crm/project/:id", &controllers.BackendCrmProjectController{}, "get:Detail;put:Update;delete:Delete")
|
||||
|
||||
// CRM回访记录(贯穿线索/商机/项目)
|
||||
beego.Router("/backend/crm/follow/list", &controllers.BackendCrmFollowController{}, "get:List")
|
||||
beego.Router("/backend/crm/follow/add", &controllers.BackendCrmFollowController{}, "post:Add")
|
||||
beego.Router("/backend/crm/follow/edit", &controllers.BackendCrmFollowController{}, "post:Edit")
|
||||
beego.Router("/backend/crm/follow/delete", &controllers.BackendCrmFollowController{}, "post:Delete")
|
||||
|
||||
// CRM附件(线索/商机/项目资料)
|
||||
beego.Router("/backend/crm/attach/list", &controllers.BackendCrmAttachController{}, "get:List")
|
||||
beego.Router("/backend/crm/attach/add", &controllers.BackendCrmAttachController{}, "post:Add")
|
||||
beego.Router("/backend/crm/attach/delete", &controllers.BackendCrmAttachController{}, "post:Delete")
|
||||
|
||||
// CRM联系人(线索/商机/项目的对接人列表)
|
||||
beego.Router("/backend/crm/entity/contact/list", &controllers.BackendCrmEntityContactController{}, "get:List")
|
||||
beego.Router("/backend/crm/entity/contact/add", &controllers.BackendCrmEntityContactController{}, "post:Add")
|
||||
beego.Router("/backend/crm/entity/contact/edit", &controllers.BackendCrmEntityContactController{}, "post:Edit")
|
||||
beego.Router("/backend/crm/entity/contact/delete", &controllers.BackendCrmEntityContactController{}, "post:Delete")
|
||||
|
||||
// CRM操作日志
|
||||
beego.Router("/backend/crm/oplog/list", &controllers.BackendCrmOperateLogController{}, "get:List")
|
||||
|
||||
// ERP供应商管理
|
||||
beego.Router("/backend/erp/supplier/list", &controllers.BackendErpSupplierController{}, "get:List")
|
||||
beego.Router("/backend/erp/supplier", &controllers.BackendErpSupplierController{}, "post:Create")
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
-- =============================================================================
|
||||
-- 联系人正式库统一:正式客户/供应商联系人通用表 yz_backend_contact_company
|
||||
--
|
||||
-- 目标:
|
||||
-- 1. 正式客户/供应商联系人统一到 `yz_backend_contact_company`
|
||||
-- (backend 端「公司联系人」通用表,命名与 yz_backend_contact 保持一致);
|
||||
-- 2. 兼容历史表名 `yz_backend_erp_company_contact`:
|
||||
-- - 若新表不存在、旧表存在,直接 RENAME(结构与数据 100% 保留);
|
||||
-- - 若两表并存,则去重合并进新表,旧表改名为 *_bak 备份保留;
|
||||
-- 3. 迁移历史死表 `yz_tenant_crm_contact`(若存在且有数据):去重并入新表,
|
||||
-- 原表改名为 `yz_tenant_crm_contact_bak` 保留;
|
||||
-- 4. 线索/商机联系人仍然只存过程库 `yz_tenant_crm_entity_contact`(本脚本不动)。
|
||||
--
|
||||
-- 去重键(判定为同一联系人):tenant_id + company_type + company_id + name + mobiles
|
||||
-- 说明:
|
||||
-- - 脚本幂等,可重复执行;
|
||||
-- - 任何迁移都是「先备份原表 / 或直接改名」再按去重键补插,绝不删除数据;
|
||||
-- - 执行本脚本的同时需要部署新版后端(模型表名已指向 yz_backend_contact_company)。
|
||||
-- =============================================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 记录初始状态
|
||||
SET @has_new := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_contact_company'
|
||||
);
|
||||
SET @has_old_company := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact'
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. 新表不存在、旧表存在 → 直接改名(保留全部结构、索引、自增、数据)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SET @sql := IF(@has_new = 0 AND @has_old_company > 0,
|
||||
'RENAME TABLE `yz_backend_erp_company_contact` TO `yz_backend_contact_company`',
|
||||
'SELECT ''step1: 跳过直接改名'' AS note');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 重新计算状态
|
||||
SET @has_new := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_contact_company'
|
||||
);
|
||||
SET @has_old_company := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact'
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. 确保正式表存在(新表也不存在、旧表也不存在时创建)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_contact_company` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
`tenant_id` varchar(64) NOT NULL DEFAULT '' COMMENT '租户ID',
|
||||
`company_type` varchar(20) NOT NULL DEFAULT '' COMMENT '公司类型:customer=客户,supplier=供应商',
|
||||
`company_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '公司ID(客户/供应商ID)',
|
||||
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '联系人姓名',
|
||||
`gender` tinyint(4) NOT NULL DEFAULT 0 COMMENT '性别:0-未知 1-男 2-女',
|
||||
`phone` varchar(20) NOT NULL DEFAULT '' COMMENT '座机',
|
||||
`mobiles` text COMMENT '手机号,JSON数组,最多5个',
|
||||
`wechat` varchar(50) NOT NULL DEFAULT '' COMMENT '微信',
|
||||
`qq` varchar(50) NOT NULL DEFAULT '' COMMENT 'QQ',
|
||||
`dingtalk` varchar(50) NOT NULL DEFAULT '' COMMENT '钉钉',
|
||||
`email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱',
|
||||
`department` varchar(50) NOT NULL DEFAULT '' COMMENT '部门',
|
||||
`position` varchar(50) NOT NULL DEFAULT '' COMMENT '职位',
|
||||
`home_address` varchar(255) NOT NULL DEFAULT '' COMMENT '家庭住址',
|
||||
`is_primary` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否主联系人:0-否 1-是',
|
||||
`status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:0-离职 1-在职',
|
||||
`remark` text COMMENT '备注',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_company` (`tenant_id`,`company_type`,`company_id`) COMMENT '按公司查询联系人',
|
||||
KEY `idx_name` (`tenant_id`,`name`) COMMENT '按姓名查询',
|
||||
KEY `idx_delete_time` (`delete_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='正式客户/供应商联系人通用表(backend 公司联系人)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. 两表并存时:旧表 yz_backend_erp_company_contact 去重并入新表,再改名备份
|
||||
-- -----------------------------------------------------------------------------
|
||||
SET @sql := IF(@has_old_company > 0,
|
||||
'INSERT INTO `yz_backend_contact_company`
|
||||
(`tenant_id`,`company_type`,`company_id`,`name`,`gender`,`phone`,`mobiles`,`wechat`,`qq`,`dingtalk`,`email`,`department`,`position`,`home_address`,`is_primary`,`status`,`remark`,`create_time`,`update_time`,`delete_time`)
|
||||
SELECT
|
||||
CAST(o.`tenant_id` AS CHAR), o.`company_type`, o.`company_id`, o.`name`, o.`gender`, o.`phone`, o.`mobiles`,
|
||||
IFNULL(o.`wechat`, ''''), IFNULL(o.`qq`, ''''), IFNULL(o.`dingtalk`, ''''), IFNULL(o.`email`, ''''),
|
||||
IFNULL(o.`department`, ''''), IFNULL(o.`position`, ''''), IFNULL(o.`home_address`, ''''),
|
||||
IFNULL(o.`is_primary`, 0), IFNULL(o.`status`, 1), o.`remark`,
|
||||
IFNULL(o.`create_time`, NOW()), IFNULL(o.`update_time`, NOW()), o.`delete_time`
|
||||
FROM `yz_backend_erp_company_contact` o
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `yz_backend_contact_company` n
|
||||
WHERE n.`tenant_id` = CAST(o.`tenant_id` AS CHAR)
|
||||
AND n.`company_type` = o.`company_type`
|
||||
AND n.`company_id` = o.`company_id`
|
||||
AND n.`name` = o.`name`
|
||||
AND IFNULL(n.`mobiles`, '''') = IFNULL(o.`mobiles`, '''')
|
||||
)',
|
||||
'SELECT ''step3: 无并存旧公司联系人表,跳过合并'' AS note');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @bak_company_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact_bak'
|
||||
);
|
||||
SET @sql := IF(@has_old_company > 0 AND @bak_company_exists = 0,
|
||||
'RENAME TABLE `yz_backend_erp_company_contact` TO `yz_backend_erp_company_contact_bak`',
|
||||
'SELECT ''step3: 跳过改名备份'' AS note');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. 历史死表 yz_tenant_crm_contact(related_type 1=客户/2=供应商)→ 新表,原表备份
|
||||
-- mobiles 由单个 mobile 转为 JSON 数组;相关字段做空值兜底。
|
||||
-- -----------------------------------------------------------------------------
|
||||
SET @has_dead := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_contact'
|
||||
);
|
||||
|
||||
SET @sql := IF(@has_dead > 0,
|
||||
'INSERT INTO `yz_backend_contact_company`
|
||||
(`tenant_id`,`company_type`,`company_id`,`name`,`gender`,`phone`,`mobiles`,`wechat`,`qq`,`dingtalk`,`email`,`department`,`position`,`home_address`,`is_primary`,`status`,`remark`,`create_time`,`update_time`,`delete_time`)
|
||||
SELECT
|
||||
CAST(o.`tenant_id` AS CHAR),
|
||||
IF(o.`related_type` = 2, ''supplier'', ''customer''),
|
||||
o.`related_id`, o.`contact_name`, IFNULL(o.`gender`, 0), IFNULL(o.`phone`, ''''),
|
||||
CASE WHEN o.`mobile` IS NULL OR o.`mobile` = '''' THEN NULL ELSE CONCAT(''["'', o.`mobile`, ''"]'') END,
|
||||
'''', '''', '''', IFNULL(o.`email`, ''''), IFNULL(o.`department`, ''''), IFNULL(o.`position`, ''''), '''',
|
||||
IFNULL(o.`is_primary`, 0), 1, IFNULL(o.`remark`, ''''),
|
||||
IFNULL(o.`create_time`, NOW()), IFNULL(o.`update_time`, NOW()),
|
||||
CASE WHEN IFNULL(o.`is_deleted`, 0) = 1 THEN NOW() ELSE NULL END
|
||||
FROM `yz_tenant_crm_contact` o
|
||||
WHERE o.`contact_name` IS NOT NULL AND o.`contact_name` <> ''''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `yz_backend_contact_company` n
|
||||
WHERE n.`tenant_id` = CAST(o.`tenant_id` AS CHAR)
|
||||
AND n.`company_type` = IF(o.`related_type` = 2, ''supplier'', ''customer'')
|
||||
AND n.`company_id` = o.`related_id`
|
||||
AND n.`name` = o.`contact_name`
|
||||
AND IFNULL(n.`mobiles`, '''') = IFNULL(
|
||||
CASE WHEN o.`mobile` IS NULL OR o.`mobile` = '''' THEN NULL ELSE CONCAT(''["'', o.`mobile`, ''"]'') END, '''')
|
||||
)',
|
||||
'SELECT ''步骤4: 无 yz_tenant_crm_contact,跳过'' AS note');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @bak_dead_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_contact_bak'
|
||||
);
|
||||
SET @sql := IF(@has_dead > 0 AND @bak_dead_exists = 0,
|
||||
'RENAME TABLE `yz_tenant_crm_contact` TO `yz_tenant_crm_contact_bak`',
|
||||
'SELECT ''步骤4: 跳过改名备份'' AS note');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. 重复数据检测(只查询、不修改/不删除,供人工核对)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT
|
||||
`tenant_id`,
|
||||
`company_type`,
|
||||
`company_id`,
|
||||
`name`,
|
||||
IFNULL(`mobiles`, '') AS `mobiles`,
|
||||
COUNT(*) AS `dup_count`,
|
||||
GROUP_CONCAT(`id` ORDER BY `id`) AS `dup_ids`
|
||||
FROM `yz_backend_contact_company`
|
||||
WHERE `delete_time` IS NULL
|
||||
GROUP BY `tenant_id`, `company_type`, `company_id`, `name`, IFNULL(`mobiles`, '')
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. 迁移结果核对(新表与备份表条数)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SET @bak_company_exists2 := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_erp_company_contact_bak'
|
||||
);
|
||||
SET @bak_dead_exists2 := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_contact_bak'
|
||||
);
|
||||
|
||||
SELECT 'yz_backend_contact_company' AS `table_name`, COUNT(*) AS `row_count`
|
||||
FROM `yz_backend_contact_company`;
|
||||
|
||||
SET @sql := IF(@bak_company_exists2 > 0,
|
||||
'SELECT ''yz_backend_erp_company_contact_bak'' AS `table_name`, COUNT(*) AS `row_count` FROM `yz_backend_erp_company_contact_bak`',
|
||||
'SELECT ''yz_backend_erp_company_contact_bak(不存在)'' AS `table_name`, 0 AS `row_count`');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql := IF(@bak_dead_exists2 > 0,
|
||||
'SELECT ''yz_tenant_crm_contact_bak'' AS `table_name`, COUNT(*) AS `row_count` FROM `yz_tenant_crm_contact_bak`',
|
||||
'SELECT ''yz_tenant_crm_contact_bak(不存在)'' AS `table_name`, 0 AS `row_count`');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 为 CRM 业务管线(线索/商机/项目)增加「对接人职位」字段 contact_position。
|
||||
-- 若已执行过 yz_backend_crm_pipeline.sql(表已存在),执行本脚本即可;
|
||||
-- 若尚未建表,直接执行 yz_backend_crm_pipeline.sql 已包含该字段,无需本脚本。
|
||||
-- 脚本可重复执行:列已存在时 MySQL 报 duplicate column,忽略即可。
|
||||
|
||||
ALTER TABLE `yz_tenant_crm_clue`
|
||||
ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位' AFTER `contact_person`;
|
||||
|
||||
ALTER TABLE `yz_tenant_crm_business`
|
||||
ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位' AFTER `contact_person`;
|
||||
|
||||
ALTER TABLE `yz_tenant_crm_project`
|
||||
ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位' AFTER `contact_person`;
|
||||
@@ -0,0 +1,233 @@
|
||||
-- =============================================================================
|
||||
-- CRM 业务管线:线索 → 商机 → 项目,回访贯穿全流程
|
||||
-- 对应租户端 backend 页面:/apps/crm/clue、/apps/crm/business、/apps/crm/project、/apps/crm/follow
|
||||
-- 说明:
|
||||
-- 1. 线索的「客户名称」暂不关联客户管理(线索阶段尚无价值),
|
||||
-- 线索转化为商机时才把客户名称落地为正式客户(yz_backend_erp_customer)。
|
||||
-- 2. 线索转化商机后会被锁定(locked=1),后续操作都在商机进行。
|
||||
-- 3. 商机转化项目后同样锁定(locked=1)。
|
||||
-- 4. 回访/跟进记录(yz_tenant_crm_follow)与附件、联系人、操作日志均为通用多态表,
|
||||
-- 通过 related_type(1线索/2商机/3项目) + related_id 关联。
|
||||
-- 可重复执行(DROP TABLE IF EXISTS)。
|
||||
-- =============================================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 线索表
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS `yz_tenant_crm_clue`;
|
||||
CREATE TABLE `yz_tenant_crm_clue` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||
`clue_name` varchar(100) NOT NULL COMMENT '线索名称',
|
||||
`customer_name` varchar(100) NOT NULL DEFAULT '' COMMENT '客户名称(线索阶段不关联客户管理)',
|
||||
`source` varchar(50) NOT NULL DEFAULT '' COMMENT '客户来源(1官网咨询/2电话咨询/3朋友介绍/4展会/5广告投放/6陌生拜访/7其他)',
|
||||
`owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
|
||||
`owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
|
||||
`industry` varchar(50) NOT NULL DEFAULT '' COMMENT '客户行业',
|
||||
`next_contact_time` datetime DEFAULT NULL COMMENT '下次联系时间',
|
||||
`clue_level` varchar(20) NOT NULL DEFAULT '2' COMMENT '客户级别:1重点/2普通/3非优先',
|
||||
`contact_person` varchar(50) NOT NULL DEFAULT '' COMMENT '客户对接人',
|
||||
`contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位',
|
||||
`contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人手机',
|
||||
`contact_wechat` varchar(64) NOT NULL DEFAULT '' COMMENT '对接人微信',
|
||||
`contact_qq` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人QQ',
|
||||
`address` varchar(255) NOT NULL DEFAULT '' COMMENT '公司地址',
|
||||
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1跟进中/2已转化/3已关闭',
|
||||
`locked` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否锁定:0否/1是(转化后锁定不可编辑)',
|
||||
`business_id` bigint(20) DEFAULT NULL COMMENT '转化后的商机ID',
|
||||
`convert_time` datetime DEFAULT NULL COMMENT '转化时间',
|
||||
`remark` text COMMENT '备注',
|
||||
`create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_clue_name` (`tenant_id`,`clue_name`),
|
||||
KEY `idx_owner` (`tenant_id`,`owner_user_id`),
|
||||
KEY `idx_status` (`tenant_id`,`status`),
|
||||
KEY `idx_delete_time` (`delete_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM线索表';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 商机表
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS `yz_tenant_crm_business`;
|
||||
CREATE TABLE `yz_tenant_crm_business` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||
`business_name` varchar(100) NOT NULL COMMENT '商机名称',
|
||||
`clue_id` bigint(20) DEFAULT NULL COMMENT '来源线索ID',
|
||||
`customer_id` bigint(20) DEFAULT NULL COMMENT '正式客户ID(yz_backend_erp_customer.id)',
|
||||
`customer_name` varchar(100) NOT NULL DEFAULT '' COMMENT '客户名称',
|
||||
`source` varchar(50) NOT NULL DEFAULT '' COMMENT '客户来源',
|
||||
`owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
|
||||
`owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
|
||||
`industry` varchar(50) NOT NULL DEFAULT '' COMMENT '客户行业',
|
||||
`stage` varchar(20) NOT NULL DEFAULT '1' COMMENT '商机阶段:1初步接洽/2需求确认/3方案报价/4商务谈判/5赢单/6输单',
|
||||
`amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '预计金额(元)',
|
||||
`expect_deal_date` date DEFAULT NULL COMMENT '预计成交日期',
|
||||
`next_contact_time` datetime DEFAULT NULL COMMENT '下次联系时间',
|
||||
`level` varchar(20) NOT NULL DEFAULT '2' COMMENT '商机级别:1重点/2普通/3非优先',
|
||||
`contact_person` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人',
|
||||
`contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位',
|
||||
`contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人手机',
|
||||
`contact_wechat` varchar(64) NOT NULL DEFAULT '' COMMENT '对接人微信',
|
||||
`contact_qq` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人QQ',
|
||||
`address` varchar(255) NOT NULL DEFAULT '' COMMENT '客户地址',
|
||||
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1跟进中/2已转化项目/3已关闭',
|
||||
`locked` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否锁定:0否/1是(转化项目后锁定不可编辑)',
|
||||
`project_id` bigint(20) DEFAULT NULL COMMENT '转化后的项目ID',
|
||||
`convert_time` datetime DEFAULT NULL COMMENT '转化时间',
|
||||
`remark` text COMMENT '备注',
|
||||
`create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_business_name` (`tenant_id`,`business_name`),
|
||||
KEY `idx_clue` (`clue_id`),
|
||||
KEY `idx_customer` (`customer_id`),
|
||||
KEY `idx_owner` (`tenant_id`,`owner_user_id`),
|
||||
KEY `idx_status` (`tenant_id`,`status`),
|
||||
KEY `idx_delete_time` (`delete_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM商机表';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 项目表
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS `yz_tenant_crm_project`;
|
||||
CREATE TABLE `yz_tenant_crm_project` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||
`project_name` varchar(100) NOT NULL COMMENT '项目名称',
|
||||
`project_no` varchar(50) NOT NULL DEFAULT '' COMMENT '项目编号',
|
||||
`business_id` bigint(20) DEFAULT NULL COMMENT '来源商机ID',
|
||||
`customer_id` bigint(20) DEFAULT NULL COMMENT '客户ID',
|
||||
`customer_name` varchar(100) NOT NULL DEFAULT '' COMMENT '客户名称',
|
||||
`owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '负责人用户ID',
|
||||
`owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '负责人姓名',
|
||||
`industry` varchar(50) NOT NULL DEFAULT '' COMMENT '客户行业',
|
||||
`amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '项目金额(元)',
|
||||
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1未开始/2进行中/3已完成/4已暂停',
|
||||
`start_date` date DEFAULT NULL COMMENT '开始日期',
|
||||
`end_date` date DEFAULT NULL COMMENT '结束日期',
|
||||
`contact_person` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人',
|
||||
`contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位',
|
||||
`contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人手机',
|
||||
`address` varchar(255) NOT NULL DEFAULT '' COMMENT '项目地址',
|
||||
`remark` text COMMENT '备注',
|
||||
`create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_project_name` (`tenant_id`,`project_name`),
|
||||
KEY `idx_business` (`business_id`),
|
||||
KEY `idx_owner` (`tenant_id`,`owner_user_id`),
|
||||
KEY `idx_status` (`tenant_id`,`status`),
|
||||
KEY `idx_delete_time` (`delete_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM项目表';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 回访/跟进记录表(贯穿线索、商机、项目)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS `yz_tenant_crm_follow`;
|
||||
CREATE TABLE `yz_tenant_crm_follow` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
||||
`related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
|
||||
`related_name` varchar(100) NOT NULL DEFAULT '' COMMENT '关联对象名称(冗余,便于回访列表展示)',
|
||||
`follow_type` varchar(20) NOT NULL DEFAULT '1' COMMENT '回访方式:1电话/2微信/3上门/4邮件/5其他',
|
||||
`follow_time` datetime DEFAULT NULL COMMENT '回访时间',
|
||||
`content` text COMMENT '回访内容',
|
||||
`next_contact_time` datetime DEFAULT NULL COMMENT '下次联系时间',
|
||||
`owner_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '回访人用户ID',
|
||||
`owner_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '回访人姓名',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_related` (`tenant_id`,`related_type`,`related_id`),
|
||||
KEY `idx_follow_time` (`tenant_id`,`follow_time`),
|
||||
KEY `idx_delete_time` (`delete_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM回访/跟进记录表';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 附件表(多态:线索/商机/项目的资料)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS `yz_tenant_crm_attach`;
|
||||
CREATE TABLE `yz_tenant_crm_attach` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
||||
`related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
|
||||
`file_id` bigint(20) DEFAULT NULL COMMENT '文件ID(yz_system_file.id,可空)',
|
||||
`file_name` varchar(255) NOT NULL DEFAULT '' COMMENT '文件名',
|
||||
`file_url` varchar(500) NOT NULL DEFAULT '' COMMENT '文件访问地址',
|
||||
`file_size` bigint(20) NOT NULL DEFAULT '0' COMMENT '文件大小(字节)',
|
||||
`uploader_id` varchar(64) NOT NULL DEFAULT '' COMMENT '上传人用户ID',
|
||||
`uploader_name` varchar(128) NOT NULL DEFAULT '' COMMENT '上传人姓名',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_related` (`tenant_id`,`related_type`,`related_id`),
|
||||
KEY `idx_delete_time` (`delete_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM附件表';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 联系人表(多态:线索/商机/项目的对接人列表)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS `yz_tenant_crm_entity_contact`;
|
||||
CREATE TABLE `yz_tenant_crm_entity_contact` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
||||
`related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
|
||||
`contact_name` varchar(50) NOT NULL COMMENT '联系人姓名',
|
||||
`position` varchar(50) NOT NULL DEFAULT '' COMMENT '职位',
|
||||
`mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '手机号',
|
||||
`wechat` varchar(64) NOT NULL DEFAULT '' COMMENT '微信',
|
||||
`qq` varchar(20) NOT NULL DEFAULT '' COMMENT 'QQ',
|
||||
`email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱',
|
||||
`is_primary` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否主联系人:0否/1是',
|
||||
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_related` (`tenant_id`,`related_type`,`related_id`),
|
||||
KEY `idx_delete_time` (`delete_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM联系人表(线索/商机/项目)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 操作日志表(多态:记录线索/商机/项目的关键操作,用于详情页操作日志 Tab)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS `yz_tenant_crm_operate_log`;
|
||||
CREATE TABLE `yz_tenant_crm_operate_log` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
||||
`related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
|
||||
`action` varchar(50) NOT NULL DEFAULT '' COMMENT '操作动作:create/update/convert/delete 等',
|
||||
`content` varchar(500) NOT NULL DEFAULT '' COMMENT '操作描述',
|
||||
`operator_id` varchar(64) NOT NULL DEFAULT '' COMMENT '操作人用户ID',
|
||||
`operator_name` varchar(128) NOT NULL DEFAULT '' COMMENT '操作人姓名',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_id` (`tenant_id`),
|
||||
KEY `idx_related` (`tenant_id`,`related_type`,`related_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户CRM操作日志表';
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
Reference in New Issue
Block a user