增加商机线索管理修复若干bug

This commit is contained in:
2026-09-11 11:11:43 +08:00
parent b9cdabe045
commit 9c07af2b59
39 changed files with 6219 additions and 42 deletions
+7
View File
@@ -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",
+1
View File
@@ -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",
+139
View File
@@ -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(/&nbsp;/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>
+238
View File
@@ -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>
+157 -40
View File
@@ -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;
+133
View File
@@ -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(/&nbsp;/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(/&nbsp;/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>
+175
View File
@@ -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;
}
+438
View File
@@ -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})
}
+476
View File
@@ -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
}
+282
View File
@@ -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)
}
+540
View File
@@ -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)})
}
+3 -2
View File
@@ -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"
}
+7
View File
@@ -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),
+25
View File
@@ -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"
}
+41
View File
@@ -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"
}
+37
View File
@@ -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"
}
+27
View File
@@ -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"
}
+26
View File
@@ -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"
}
+21
View File
@@ -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"
}
+34
View File
@@ -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"
}
+37
View File
@@ -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")
+198
View File
@@ -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`;
+233
View File
@@ -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;