增加商机线索管理修复若干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;
}