优化线索、商机、项目相关开发
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
mysql: [Warning] Using a password on the command line interface can be insecure.
|
||||||
Vendored
+1
-2
@@ -21,14 +21,12 @@ declare module 'vue' {
|
|||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||||
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
|
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
|
||||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
|
||||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
@@ -46,6 +44,7 @@ declare module 'vue' {
|
|||||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElImage: typeof import('element-plus/es')['ElImage']
|
ElImage: typeof import('element-plus/es')['ElImage']
|
||||||
|
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElLink: typeof import('element-plus/es')['ElLink']
|
ElLink: typeof import('element-plus/es')['ElLink']
|
||||||
|
|||||||
@@ -82,6 +82,42 @@ export function deleteProject(id) {
|
|||||||
return request({ url: `/backend/crm/project/${id}`, method: "delete" });
|
return request({ url: `/backend/crm/project/${id}`, method: "delete" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------- 项目文档(OA 文档库) --------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目文档列表:对应文档库「共享文档 / 项目文档 / {项目名称}」文件夹(含子文件夹)。
|
||||||
|
* 存量项目在首次调用时自动补建文件夹。
|
||||||
|
*/
|
||||||
|
export function getProjectDocs(id, params) {
|
||||||
|
return request({ url: `/backend/crm/project/${id}/docs`, method: "get", params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传项目文档:文件先经 POST /backend/uploadfile 上传,
|
||||||
|
* 再调用本接口写入文档库项目文件夹并同步登记 CRM 附件。
|
||||||
|
*/
|
||||||
|
export function uploadProjectDoc(id, data) {
|
||||||
|
return request({ url: `/backend/crm/project/${id}/doc-upload`, method: "post", data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除项目文档:同步软删文档库文档与对应 CRM 附件记录 */
|
||||||
|
export function deleteProjectDoc(id, data) {
|
||||||
|
return request({ url: `/backend/crm/project/${id}/doc-delete`, method: "post", data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建项目文档文件夹(对应文档库中项目文件夹下的子分类),用于分类存放文档。
|
||||||
|
* data: { name, parent_id },parent_id 缺省时建在项目根文件夹下。
|
||||||
|
*/
|
||||||
|
export function createProjectDocFolder(id, data) {
|
||||||
|
return request({ url: `/backend/crm/project/${id}/doc-folder`, method: "post", data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除项目文档文件夹(需为空:无子文件夹、无文档;项目根文件夹不可删除) */
|
||||||
|
export function deleteProjectDocFolder(id, data) {
|
||||||
|
return request({ url: `/backend/crm/project/${id}/doc-folder-delete`, method: "post", data });
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------- 回访 --------------------------------- */
|
/* --------------------------------- 回访 --------------------------------- */
|
||||||
|
|
||||||
export function getFollowList(params) {
|
export function getFollowList(params) {
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
:model-value="visible"
|
||||||
|
:title="business?.business_name || '商机详情'"
|
||||||
|
direction="rtl"
|
||||||
|
size="900px"
|
||||||
|
@update:model-value="handleClose"
|
||||||
|
@opened="loadAll"
|
||||||
|
>
|
||||||
|
<div v-if="business" class="biz-detail">
|
||||||
|
<el-tabs v-model="activeTab" tab-position="left" class="detail-tabs">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<el-tab-pane label="基本信息" name="basic">
|
||||||
|
<DetailBasic :business="business" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 联系人 -->
|
||||||
|
<el-tab-pane :label="`联系人 (${counts.contact})`" name="contact">
|
||||||
|
<DetailContact
|
||||||
|
ref="contactRef"
|
||||||
|
:related-id="business.id"
|
||||||
|
:related-type="RELATED_TYPE"
|
||||||
|
:include-source="true"
|
||||||
|
tip="按阶段累积展示(线索 + 商机),转为项目后会同步到正式客户通讯录"
|
||||||
|
@count="counts.contact = $event"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 跟进记录 -->
|
||||||
|
<el-tab-pane :label="`跟进记录 (${counts.follow})`" name="follow">
|
||||||
|
<DetailFollow
|
||||||
|
ref="followRef"
|
||||||
|
:related-id="business.id"
|
||||||
|
:related-type="RELATED_TYPE"
|
||||||
|
:related-name="business.business_name || ''"
|
||||||
|
:include-source="true"
|
||||||
|
@count="counts.follow = $event"
|
||||||
|
@saved="emit('refresh')"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 附件 -->
|
||||||
|
<el-tab-pane :label="`附件 (${counts.attach})`" name="attach">
|
||||||
|
<DetailAttach
|
||||||
|
ref="attachRef"
|
||||||
|
:related-id="business.id"
|
||||||
|
:related-type="RELATED_TYPE"
|
||||||
|
:include-source="true"
|
||||||
|
tip="用于存放商机相关资料,如方案、报价单、合同草稿等"
|
||||||
|
@count="counts.attach = $event"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 操作日志 -->
|
||||||
|
<el-tab-pane label="操作日志" name="log">
|
||||||
|
<DetailLog ref="logRef" :related-id="business.id" :related-type="RELATED_TYPE" />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from "vue";
|
||||||
|
import DetailBasic from "./detail_basic.vue";
|
||||||
|
import DetailContact from "./detail_contact.vue";
|
||||||
|
import DetailFollow from "./detail_follow.vue";
|
||||||
|
import DetailAttach from "./detail_attach.vue";
|
||||||
|
import DetailLog from "./detail_log.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
business: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["update:visible", "refresh"]);
|
||||||
|
|
||||||
|
// 关联类型:2=商机
|
||||||
|
const RELATED_TYPE = 2;
|
||||||
|
|
||||||
|
const activeTab = ref("basic");
|
||||||
|
const counts = reactive({ contact: 0, follow: 0, attach: 0 });
|
||||||
|
|
||||||
|
const contactRef = ref();
|
||||||
|
const followRef = ref();
|
||||||
|
const attachRef = ref();
|
||||||
|
const logRef = ref();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
activeTab.value = "basic";
|
||||||
|
counts.contact = 0;
|
||||||
|
counts.follow = 0;
|
||||||
|
counts.attach = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit("update:visible", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 抽屉打开后刷新各 Tab 数据 */
|
||||||
|
function loadAll() {
|
||||||
|
if (!props.business?.id) return;
|
||||||
|
contactRef.value?.reload();
|
||||||
|
followRef.value?.reload();
|
||||||
|
attachRef.value?.reload();
|
||||||
|
logRef.value?.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ loadAll });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
:deep(.el-drawer__body) {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-detail {
|
||||||
|
.detail-tabs {
|
||||||
|
// 横向 flex:导航固定宽 + 内容区占满剩余宽度
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
// 关键:内容区占满「容器剩余宽度」并允许收缩,内部表格才能随容器自适应
|
||||||
|
:deep(.el-tabs__content) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__header) {
|
||||||
|
margin-right: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0 12px;
|
||||||
|
min-width: 96px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tab-pane) {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// 操作列:链接按钮去掉自带内边距、缩小间距,避免「编辑/删除」被挤成两行
|
||||||
|
.el-button.is-link {
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button + .el-button {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-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 v-if="tip" class="tip">{{ tip }}</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe border size="small">
|
||||||
|
<el-table-column label="文件名" min-width="170" 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 v-if="showStage" label="阶段" min-width="62" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" effect="plain" :type="pipelineStageTag(row.related_type)">
|
||||||
|
{{ pipelineStageText(row.related_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="大小" min-width="78" align="right">
|
||||||
|
<template #default="{ row }">{{ formatFileSize(row.file_size) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="uploader_name" label="上传人" min-width="78" show-overflow-tooltip />
|
||||||
|
<el-table-column label="上传时间" min-width="140">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="76" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty><el-empty description="暂无附件" :image-size="60" /></template>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { Upload } from "@element-plus/icons-vue";
|
||||||
|
import { getAttachList, addAttach, deleteAttach } from "@/api/crmPipeline";
|
||||||
|
import { uploadFile } from "@/api/file";
|
||||||
|
import { formatDateTime, pipelineStageText, pipelineStageTag } from "../../dict";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 关联对象ID
|
||||||
|
relatedId: { type: [Number, String], default: null },
|
||||||
|
// 关联类型:1=线索 2=商机 3=项目
|
||||||
|
relatedType: { type: Number, default: 2 },
|
||||||
|
// 工具栏提示文案
|
||||||
|
tip: { type: String, default: "" },
|
||||||
|
// 是否累积展示上游链路的附件(线索 + 商机)
|
||||||
|
includeSource: { type: Boolean, default: false },
|
||||||
|
// 是否展示「阶段」列
|
||||||
|
showStage: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["count"]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.relatedId) {
|
||||||
|
list.value = [];
|
||||||
|
emit("count", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
};
|
||||||
|
if (props.includeSource) params.include_source = 1;
|
||||||
|
const res = await getAttachList(params);
|
||||||
|
list.value = res?.data?.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
emit("count", list.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
file_id: data.id || 0,
|
||||||
|
file_name: data.name || file.name,
|
||||||
|
file_url: data.url || "",
|
||||||
|
file_size: file.size || 0,
|
||||||
|
});
|
||||||
|
ElMessage.success("上传成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || "上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除附件「${row.file_name}」吗?`, "删除确认", { type: "warning" });
|
||||||
|
await deleteAttach({ id: row.id });
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<el-descriptions v-if="business" :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="商机名称" :span="2">{{ business.business_name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="客户名称">{{ business.customer_name || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="客户来源">{{ clueSourceText(business.source) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商机阶段">
|
||||||
|
<el-tag :type="businessStageTag(business.stage)" size="small">
|
||||||
|
{{ businessStageText(business.stage) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商机级别">
|
||||||
|
<el-tag :type="pipelineLevelTag(business.level)" size="small" effect="plain">
|
||||||
|
{{ pipelineLevelText(business.level) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="预计金额">{{ formatMoney(business.amount) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="预计成交日期">{{ formatDateOnly(business.expect_deal_date) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="负责人">{{ business.owner_user_name || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="客户行业">{{ business.industry || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="下次联系时间">{{ formatDateTime(business.next_contact_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="来源线索">
|
||||||
|
{{ business.clue_name || (business.clue_id ? `#${business.clue_id}` : "-") }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接人">{{ business.contact_person || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接人职位">{{ business.contact_position || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接人手机">{{ business.contact_phone || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接人微信">{{ business.contact_wechat || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接人QQ">{{ business.contact_qq || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="客户地址" :span="2">{{ business.address || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag :type="businessStatusTag(business.status)" size="small">
|
||||||
|
{{ businessStatusText(business.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="转化时间">{{ formatDateTime(business.convert_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatDateTime(business.create_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新时间">{{ formatDateTime(business.update_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注" :span="2">{{ business.remark || "-" }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
clueSourceText,
|
||||||
|
pipelineLevelText,
|
||||||
|
pipelineLevelTag,
|
||||||
|
businessStageText,
|
||||||
|
businessStageTag,
|
||||||
|
businessStatusText,
|
||||||
|
businessStatusTag,
|
||||||
|
formatDateTime,
|
||||||
|
formatMoney,
|
||||||
|
formatDateOnly,
|
||||||
|
} from "../../dict";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
// 商机详情对象
|
||||||
|
business: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-contact">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<el-button type="primary" size="small" :icon="Plus" @click="openEdit(null)">新增联系人</el-button>
|
||||||
|
<span v-if="tip" class="tip">{{ tip }}</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe border size="small">
|
||||||
|
<el-table-column label="姓名" min-width="105">
|
||||||
|
<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="职位" min-width="85" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="mobile" label="手机号" min-width="110" />
|
||||||
|
<el-table-column prop="wechat" label="微信" min-width="95" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="qq" label="QQ" min-width="85" />
|
||||||
|
<el-table-column prop="remark" label="备注" min-width="95" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="96" align="center">
|
||||||
|
<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="60" /></template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 联系人编辑 -->
|
||||||
|
<el-dialog v-model="dialog" :title="form.id ? '编辑联系人' : '新增联系人'" width="520px" append-to-body>
|
||||||
|
<el-form :model="form" label-width="90px">
|
||||||
|
<el-form-item label="姓名" required>
|
||||||
|
<el-input v-model="form.contact_name" placeholder="请输入姓名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="职位"><el-input v-model="form.position" /></el-form-item>
|
||||||
|
<el-form-item label="手机号"><el-input v-model="form.mobile" /></el-form-item>
|
||||||
|
<el-form-item label="微信"><el-input v-model="form.wechat" /></el-form-item>
|
||||||
|
<el-form-item label="QQ"><el-input v-model="form.qq" /></el-form-item>
|
||||||
|
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||||
|
<el-form-item label="主联系人">
|
||||||
|
<el-switch v-model="form.is_primary" :active-value="1" :inactive-value="0" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { Plus } from "@element-plus/icons-vue";
|
||||||
|
import {
|
||||||
|
getEntityContactList,
|
||||||
|
addEntityContact,
|
||||||
|
updateEntityContact,
|
||||||
|
deleteEntityContact,
|
||||||
|
} from "@/api/crmPipeline";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 关联对象ID(商机ID)
|
||||||
|
relatedId: { type: [Number, String], default: null },
|
||||||
|
// 关联类型:1=线索 2=商机 3=项目
|
||||||
|
relatedType: { type: Number, default: 2 },
|
||||||
|
// 工具栏提示文案
|
||||||
|
tip: { type: String, default: "" },
|
||||||
|
// 是否累积展示上游链路的联系人(线索 + 商机)
|
||||||
|
includeSource: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["count"]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
const dialog = ref(false);
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
id: null,
|
||||||
|
contact_name: "",
|
||||||
|
position: "",
|
||||||
|
mobile: "",
|
||||||
|
wechat: "",
|
||||||
|
qq: "",
|
||||||
|
email: "",
|
||||||
|
is_primary: 0,
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
const form = reactive(defaultForm());
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.relatedId) {
|
||||||
|
list.value = [];
|
||||||
|
emit("count", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
};
|
||||||
|
if (props.includeSource) params.include_source = 1;
|
||||||
|
const res = await getEntityContactList(params);
|
||||||
|
list.value = res?.data?.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
emit("count", list.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row) {
|
||||||
|
Object.assign(form, defaultForm());
|
||||||
|
if (row) Object.assign(form, row);
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.contact_name) {
|
||||||
|
ElMessage.warning("请输入联系人姓名");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
};
|
||||||
|
if (form.id) {
|
||||||
|
await updateEntityContact(payload);
|
||||||
|
} else {
|
||||||
|
await addEntityContact(payload);
|
||||||
|
}
|
||||||
|
ElMessage.success("保存成功");
|
||||||
|
dialog.value = false;
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除联系人「${row.contact_name}」吗?`, "删除确认", { type: "warning" });
|
||||||
|
await deleteEntityContact({ id: row.id });
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tab-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-follow">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<el-button type="primary" size="small" :icon="Plus" @click="openEdit(null)">新增回访</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe border size="small">
|
||||||
|
<el-table-column label="回访时间" min-width="132">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.follow_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="阶段" min-width="58" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" effect="plain" :type="pipelineStageTag(row.stage || row.related_type)">
|
||||||
|
{{ pipelineStageText(row.stage || row.related_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="方式" min-width="58" align="center">
|
||||||
|
<template #default="{ row }">{{ followTypeText(row.follow_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="回访内容" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="follow-content" v-html="row._textHtml"></div>
|
||||||
|
<div v-if="row._images && row._images.length" class="follow-images">
|
||||||
|
<div
|
||||||
|
v-for="(url, idx) in row._images"
|
||||||
|
:key="idx"
|
||||||
|
class="follow-image"
|
||||||
|
@click="openPreview(row._images, idx)"
|
||||||
|
>
|
||||||
|
<img :src="url" alt="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="下次联系" min-width="132">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="owner_user_name" label="回访人" min-width="70" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="96" align="center">
|
||||||
|
<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="60" /></template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 回访编辑 -->
|
||||||
|
<el-dialog v-model="dialog" :title="form.id ? '编辑回访' : '新增回访'" width="520px" append-to-body>
|
||||||
|
<el-form :model="form" label-width="90px">
|
||||||
|
<el-form-item label="回访阶段">
|
||||||
|
<el-select v-model="form.stage" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="i in PIPELINE_RELATED_TYPE_OPTIONS"
|
||||||
|
:key="i.value"
|
||||||
|
:label="i.label"
|
||||||
|
:value="i.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<div style="font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.4; margin-top: 2px">
|
||||||
|
按该次沟通实际发生的阶段记录,默认当前阶段
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="回访方式">
|
||||||
|
<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="回访时间">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.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="form.content"
|
||||||
|
:min-height="120"
|
||||||
|
placeholder="请输入回访内容,可粘贴或上传图片"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="下次联系">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.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="dialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 跟进记录图片放大预览 -->
|
||||||
|
<el-image-viewer
|
||||||
|
v-if="previewVisible"
|
||||||
|
:url-list="previewUrls"
|
||||||
|
:initial-index="previewIndex"
|
||||||
|
:z-index="5000"
|
||||||
|
@close="previewVisible = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox, ElImageViewer } from "element-plus";
|
||||||
|
import { Plus } from "@element-plus/icons-vue";
|
||||||
|
import { getFollowList, addFollow, updateFollow, deleteFollow } from "@/api/crmPipeline";
|
||||||
|
import RichContentEditor from "../../components/RichContentEditor.vue";
|
||||||
|
import {
|
||||||
|
followTypeText,
|
||||||
|
FOLLOW_TYPE_OPTIONS,
|
||||||
|
PIPELINE_RELATED_TYPE_OPTIONS,
|
||||||
|
pipelineStageText,
|
||||||
|
pipelineStageTag,
|
||||||
|
formatDateTime,
|
||||||
|
} from "../../dict";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 关联对象ID
|
||||||
|
relatedId: { type: [Number, String], default: null },
|
||||||
|
// 关联类型:1=线索 2=商机 3=项目
|
||||||
|
relatedType: { type: Number, default: 2 },
|
||||||
|
// 关联对象名称(新增回访时冗余记录)
|
||||||
|
relatedName: { type: String, default: "" },
|
||||||
|
// 是否累积展示上游链路的回访记录
|
||||||
|
includeSource: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["count", "saved"]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
const dialog = ref(false);
|
||||||
|
|
||||||
|
// 图片预览
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const previewUrls = ref([]);
|
||||||
|
const previewIndex = ref(0);
|
||||||
|
|
||||||
|
/** 拆分富文本:文本 HTML + 图片地址数组(图片单独用统一小方块展示) */
|
||||||
|
function splitFollowContent(html) {
|
||||||
|
const urls = [];
|
||||||
|
const textHtml = String(html || "").replace(/<img[^>]*src=["']([^"']+)["'][^>]*>/gi, (m, src) => {
|
||||||
|
if (src) urls.push(src);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
return { textHtml, urls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击图片方块 → 放大预览(支持同一条记录内多图切换) */
|
||||||
|
function openPreview(urls, index) {
|
||||||
|
if (!urls || !urls.length) return;
|
||||||
|
previewUrls.value = urls;
|
||||||
|
previewIndex.value = index < 0 ? 0 : index;
|
||||||
|
previewVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
id: null,
|
||||||
|
// 所属阶段:1线索/2商机/3项目,默认取当前详情页所在阶段
|
||||||
|
stage: null,
|
||||||
|
follow_type: "1",
|
||||||
|
follow_time: "",
|
||||||
|
content: "",
|
||||||
|
next_contact_time: "",
|
||||||
|
});
|
||||||
|
const form = reactive(defaultForm());
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.relatedId) {
|
||||||
|
list.value = [];
|
||||||
|
emit("count", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
pageSize: 100,
|
||||||
|
};
|
||||||
|
if (props.includeSource) params.include_source = 1;
|
||||||
|
const res = await getFollowList(params);
|
||||||
|
const rows = res?.data?.list || [];
|
||||||
|
list.value = rows.map((row) => {
|
||||||
|
const { textHtml, urls } = splitFollowContent(row.content);
|
||||||
|
return { ...row, _textHtml: textHtml, _images: urls };
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
emit("count", list.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row) {
|
||||||
|
Object.assign(form, defaultForm());
|
||||||
|
if (row) {
|
||||||
|
Object.assign(form, row);
|
||||||
|
// 编辑:默认保留记录原本的阶段,可手动修正
|
||||||
|
form.stage = row.stage || row.related_type || props.relatedType;
|
||||||
|
} else {
|
||||||
|
// 新增:默认取当前详情页所在阶段(线索/商机/项目)
|
||||||
|
form.stage = props.relatedType;
|
||||||
|
}
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const plainText = String(form.content || "")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.trim();
|
||||||
|
if (!plainText && !/<img/i.test(form.content || "")) {
|
||||||
|
ElMessage.warning("请输入回访内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (form.id) {
|
||||||
|
// 编辑:同步提交修正后的阶段(后端仅在传入合法阶段时更新)
|
||||||
|
await updateFollow({ ...form, stage: form.stage || undefined });
|
||||||
|
} else {
|
||||||
|
await addFollow({
|
||||||
|
...form,
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
related_name: props.relatedName,
|
||||||
|
stage: form.stage || props.relatedType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ElMessage.success("保存成功");
|
||||||
|
dialog.value = false;
|
||||||
|
reload();
|
||||||
|
emit("saved");
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm("确定删除该回访记录吗?", "删除确认", { type: "warning" });
|
||||||
|
await deleteFollow({ id: row.id });
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tab-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-content {
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 2px 6px 2px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-images {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
.follow-image {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: zoom-in;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<el-timeline v-loading="loading">
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="log in list"
|
||||||
|
: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="!list.length && !loading" description="暂无操作日志" :image-size="60" />
|
||||||
|
</el-timeline>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { getCrmOperateLogs } from "@/api/crmPipeline";
|
||||||
|
import { formatDateTime } from "../../dict";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 关联对象ID
|
||||||
|
relatedId: { type: [Number, String], default: null },
|
||||||
|
// 关联类型:1=线索 2=商机 3=项目
|
||||||
|
relatedType: { type: Number, default: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.relatedId) {
|
||||||
|
list.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getCrmOperateLogs({
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
});
|
||||||
|
list.value = res?.data?.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.log-item {
|
||||||
|
.log-user {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>商机管理</h2>
|
<h2>商机管理</h2>
|
||||||
<p>推进商机阶段,达成后可转化为项目;转化后商机将锁定不可编辑</p>
|
<p>推进商机阶段,达成后可转化为项目</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||||||
@@ -47,6 +47,11 @@
|
|||||||
|
|
||||||
<div class="table-container" v-loading="loading">
|
<div class="table-container" v-loading="loading">
|
||||||
<el-table :data="tableData" stripe border row-key="id">
|
<el-table :data="tableData" stripe border row-key="id">
|
||||||
|
<el-table-column label="状态" width="110" align="center" fixed>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="businessStatusTag(row.status)" size="small">{{ businessStatusText(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="商机名称" min-width="170" show-overflow-tooltip fixed>
|
<el-table-column label="商机名称" min-width="170" show-overflow-tooltip fixed>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="name-link" @click="openDetail(row)">{{ row.business_name }}</span>
|
<span class="name-link" @click="openDetail(row)">{{ row.business_name }}</span>
|
||||||
@@ -77,11 +82,6 @@
|
|||||||
<el-table-column label="下次联系时间" width="160" align="center">
|
<el-table-column label="下次联系时间" width="160" align="center">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="操作" width="220" align="center" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" :disabled="row.locked === 1" @click="openEdit(row)">
|
<el-button link type="primary" size="small" :disabled="row.locked === 1" @click="openEdit(row)">
|
||||||
@@ -96,7 +96,15 @@
|
|||||||
>
|
>
|
||||||
转项目
|
转项目
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="row.locked === 1 || row.status === 2"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty><el-empty description="暂无商机" :image-size="80" /></template>
|
<template #empty><el-empty description="暂无商机" :image-size="80" /></template>
|
||||||
@@ -118,32 +126,11 @@
|
|||||||
<BusinessEdit v-model:visible="editVisible" :edit-data="currentRow" @success="fetchList" />
|
<BusinessEdit v-model:visible="editVisible" :edit-data="currentRow" @success="fetchList" />
|
||||||
<BusinessConvert v-model:visible="convertVisible" :business="currentRow" @success="handleConvertSuccess" />
|
<BusinessConvert v-model:visible="convertVisible" :business="currentRow" @success="handleConvertSuccess" />
|
||||||
|
|
||||||
<el-dialog v-model="detailVisible" :title="currentRow?.business_name || '商机详情'" width="720px">
|
<BusinessDetail
|
||||||
<el-descriptions v-if="currentRow" :column="2" border size="small">
|
v-model:visible="detailVisible"
|
||||||
<el-descriptions-item label="商机名称" :span="2">{{ currentRow.business_name }}</el-descriptions-item>
|
:business="currentRow"
|
||||||
<el-descriptions-item label="客户名称">{{ currentRow.customer_name || "-" }}</el-descriptions-item>
|
@refresh="fetchList"
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -154,6 +141,7 @@ import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
|||||||
import { getBusinessList, deleteBusiness } from "@/api/crmPipeline";
|
import { getBusinessList, deleteBusiness } from "@/api/crmPipeline";
|
||||||
import BusinessEdit from "./components/edit.vue";
|
import BusinessEdit from "./components/edit.vue";
|
||||||
import BusinessConvert from "./components/convert.vue";
|
import BusinessConvert from "./components/convert.vue";
|
||||||
|
import BusinessDetail from "./components/detail.vue";
|
||||||
import {
|
import {
|
||||||
BUSINESS_STAGE_OPTIONS,
|
BUSINESS_STAGE_OPTIONS,
|
||||||
BUSINESS_STATUS_OPTIONS,
|
BUSINESS_STATUS_OPTIONS,
|
||||||
@@ -164,7 +152,6 @@ import {
|
|||||||
businessStatusTag,
|
businessStatusTag,
|
||||||
pipelineLevelText,
|
pipelineLevelText,
|
||||||
pipelineLevelTag,
|
pipelineLevelTag,
|
||||||
clueSourceText,
|
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatDateOnly,
|
formatDateOnly,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
type="warning"
|
type="warning"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
title="转化后线索将被锁定,不能再编辑,后续操作请在商机中进行。"
|
title="转化后线索将被锁定;联系人 / 跟进记录 / 附件会按阶段在商机中累积展示,后续操作请在商机中进行。"
|
||||||
style="margin-bottom: 16px"
|
style="margin-bottom: 16px"
|
||||||
/>
|
/>
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||||
|
|||||||
@@ -8,241 +8,69 @@
|
|||||||
@opened="loadAll"
|
@opened="loadAll"
|
||||||
>
|
>
|
||||||
<div v-if="clue" class="clue-detail">
|
<div v-if="clue" class="clue-detail">
|
||||||
<el-tabs v-model="activeTab">
|
<el-alert
|
||||||
|
v-if="Number(clue.locked) === 1 || Number(clue.status) === 2"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="该线索已转化为商机并锁定,后续请在商机管理中操作;商机详情会按阶段展示线索 + 商机的联系人、跟进记录与附件"
|
||||||
|
style="margin-bottom: 12px"
|
||||||
|
/>
|
||||||
|
<el-tabs v-model="activeTab" tab-position="left" class="detail-tabs">
|
||||||
<!-- 基本信息 -->
|
<!-- 基本信息 -->
|
||||||
<el-tab-pane label="基本信息" name="basic">
|
<el-tab-pane label="基本信息" name="basic">
|
||||||
<el-descriptions :column="2" border size="small">
|
<DetailBasic :clue="clue" />
|
||||||
<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>
|
||||||
|
|
||||||
<!-- 联系人 -->
|
<!-- 联系人 -->
|
||||||
<el-tab-pane :label="`联系人 (${contacts.length})`" name="contact">
|
<el-tab-pane :label="`联系人 (${counts.contact})`" name="contact">
|
||||||
<div class="tab-toolbar">
|
<DetailContact
|
||||||
<el-button type="primary" size="small" :icon="Plus" @click="openContactEdit(null)">新增联系人</el-button>
|
ref="contactRef"
|
||||||
</div>
|
:related-id="clue.id"
|
||||||
<el-table :data="contacts" v-loading="loading.contact" stripe border size="small">
|
:related-type="RELATED_TYPE"
|
||||||
<el-table-column label="姓名" width="140">
|
@count="counts.contact = $event"
|
||||||
<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>
|
||||||
|
|
||||||
<!-- 跟进记录 -->
|
<!-- 跟进记录 -->
|
||||||
<el-tab-pane :label="`跟进记录 (${follows.length})`" name="follow">
|
<el-tab-pane :label="`跟进记录 (${counts.follow})`" name="follow">
|
||||||
<div class="tab-toolbar">
|
<DetailFollow
|
||||||
<el-button type="primary" size="small" :icon="Plus" @click="openFollowEdit(null)">新增回访</el-button>
|
ref="followRef"
|
||||||
</div>
|
:related-id="clue.id"
|
||||||
<el-table :data="follows" v-loading="loading.follow" stripe border size="small">
|
:related-type="RELATED_TYPE"
|
||||||
<el-table-column label="回访时间" width="160">
|
:related-name="clue.clue_name || ''"
|
||||||
<template #default="{ row }">{{ formatDateTime(row.follow_time) }}</template>
|
@count="counts.follow = $event"
|
||||||
</el-table-column>
|
@saved="emit('refresh')"
|
||||||
<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>
|
||||||
|
|
||||||
<!-- 附件 -->
|
<!-- 附件 -->
|
||||||
<el-tab-pane :label="`附件 (${attaches.length})`" name="attach">
|
<el-tab-pane :label="`附件 (${counts.attach})`" name="attach">
|
||||||
<div class="tab-toolbar">
|
<DetailAttach
|
||||||
<el-upload
|
ref="attachRef"
|
||||||
:show-file-list="false"
|
:related-id="clue.id"
|
||||||
:http-request="handleUpload"
|
:related-type="RELATED_TYPE"
|
||||||
:disabled="uploading"
|
tip="用于存放前期资料,如方案、报价单、图纸等"
|
||||||
>
|
@count="counts.attach = $event"
|
||||||
<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>
|
||||||
|
|
||||||
<!-- 操作日志 -->
|
<!-- 操作日志 -->
|
||||||
<el-tab-pane label="操作日志" name="log">
|
<el-tab-pane label="操作日志" name="log">
|
||||||
<el-timeline v-loading="loading.log">
|
<DetailLog ref="logRef" :related-id="clue.id" :related-type="RELATED_TYPE" />
|
||||||
<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-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</div>
|
</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>
|
</el-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, watch } from "vue";
|
import { ref, reactive, watch } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import DetailBasic from "./detail_basic.vue";
|
||||||
import { Plus, Upload } from "@element-plus/icons-vue";
|
import DetailContact from "./detail_contact.vue";
|
||||||
import {
|
import DetailFollow from "./detail_follow.vue";
|
||||||
getEntityContactList,
|
import DetailAttach from "./detail_attach.vue";
|
||||||
addEntityContact,
|
import DetailLog from "./detail_log.vue";
|
||||||
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({
|
const props = defineProps({
|
||||||
visible: { type: Boolean, default: false },
|
visible: { type: Boolean, default: false },
|
||||||
@@ -250,44 +78,25 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
const emit = defineEmits(["update:visible", "refresh"]);
|
const emit = defineEmits(["update:visible", "refresh"]);
|
||||||
|
|
||||||
const RELATED_TYPE = 1; // 线索
|
// 关联类型:1=线索
|
||||||
|
const RELATED_TYPE = 1;
|
||||||
|
|
||||||
const activeTab = ref("basic");
|
const activeTab = ref("basic");
|
||||||
const loading = reactive({ contact: false, follow: false, attach: false, log: false });
|
const counts = reactive({ contact: 0, follow: 0, attach: 0 });
|
||||||
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 contactRef = ref();
|
||||||
const contactForm = reactive({
|
const followRef = ref();
|
||||||
id: null,
|
const attachRef = ref();
|
||||||
contact_name: "",
|
const logRef = ref();
|
||||||
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(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
activeTab.value = "basic";
|
activeTab.value = "basic";
|
||||||
|
counts.contact = 0;
|
||||||
|
counts.follow = 0;
|
||||||
|
counts.attach = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -296,258 +105,69 @@ function handleClose() {
|
|||||||
emit("update:visible", false);
|
emit("update:visible", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentId() {
|
/** 抽屉打开后刷新各 Tab 数据 */
|
||||||
return props.clue?.id;
|
function loadAll() {
|
||||||
|
if (!props.clue?.id) return;
|
||||||
|
contactRef.value?.reload();
|
||||||
|
followRef.value?.reload();
|
||||||
|
attachRef.value?.reload();
|
||||||
|
logRef.value?.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
defineExpose({ loadAll });
|
||||||
if (!currentId()) return;
|
|
||||||
loadContacts();
|
|
||||||
loadFollows();
|
|
||||||
loadAttaches();
|
|
||||||
loadLogs();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------ 联系人 ------------------------------ */
|
|
||||||
async function loadContacts() {
|
|
||||||
loading.contact = true;
|
|
||||||
try {
|
|
||||||
const res = await getEntityContactList({ related_type: RELATED_TYPE, related_id: currentId() });
|
|
||||||
contacts.value = res?.data?.list || [];
|
|
||||||
} catch (e) {
|
|
||||||
contacts.value = [];
|
|
||||||
} finally {
|
|
||||||
loading.contact = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openContactEdit(row) {
|
|
||||||
Object.assign(contactForm, {
|
|
||||||
id: null,
|
|
||||||
contact_name: "",
|
|
||||||
position: "",
|
|
||||||
mobile: "",
|
|
||||||
wechat: "",
|
|
||||||
qq: "",
|
|
||||||
email: "",
|
|
||||||
is_primary: 0,
|
|
||||||
remark: "",
|
|
||||||
});
|
|
||||||
if (row) Object.assign(contactForm, row);
|
|
||||||
contactDialog.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleContactSave() {
|
|
||||||
if (!contactForm.contact_name) {
|
|
||||||
ElMessage.warning("请输入联系人姓名");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
...contactForm,
|
|
||||||
related_type: RELATED_TYPE,
|
|
||||||
related_id: currentId(),
|
|
||||||
};
|
|
||||||
if (contactForm.id) {
|
|
||||||
await updateEntityContact(payload);
|
|
||||||
} else {
|
|
||||||
await addEntityContact(payload);
|
|
||||||
}
|
|
||||||
ElMessage.success("保存成功");
|
|
||||||
contactDialog.value = false;
|
|
||||||
loadContacts();
|
|
||||||
} catch (e) {
|
|
||||||
ElMessage.error(e.message || "保存失败");
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleContactDelete(row) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定删除联系人「${row.contact_name}」吗?`, "删除确认", { type: "warning" });
|
|
||||||
await deleteEntityContact({ id: row.id });
|
|
||||||
ElMessage.success("删除成功");
|
|
||||||
loadContacts();
|
|
||||||
} catch (e) {
|
|
||||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------ 跟进记录 ------------------------------ */
|
|
||||||
async function loadFollows() {
|
|
||||||
loading.follow = true;
|
|
||||||
try {
|
|
||||||
const res = await getFollowList({ related_type: RELATED_TYPE, related_id: currentId(), pageSize: 100 });
|
|
||||||
follows.value = res?.data?.list || [];
|
|
||||||
} catch (e) {
|
|
||||||
follows.value = [];
|
|
||||||
} finally {
|
|
||||||
loading.follow = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFollowEdit(row) {
|
|
||||||
Object.assign(followForm, { id: null, follow_type: "1", follow_time: "", content: "", next_contact_time: "" });
|
|
||||||
if (row) Object.assign(followForm, row);
|
|
||||||
followDialog.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFollowSave() {
|
|
||||||
const plainText = String(followForm.content || "")
|
|
||||||
.replace(/<[^>]*>/g, "")
|
|
||||||
.replace(/ /g, " ")
|
|
||||||
.trim();
|
|
||||||
if (!plainText && !/<img/i.test(followForm.content || "")) {
|
|
||||||
ElMessage.warning("请输入回访内容");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
if (followForm.id) {
|
|
||||||
await updateFollow({ ...followForm });
|
|
||||||
} else {
|
|
||||||
await addFollow({
|
|
||||||
...followForm,
|
|
||||||
related_type: RELATED_TYPE,
|
|
||||||
related_id: currentId(),
|
|
||||||
related_name: props.clue?.clue_name || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ElMessage.success("保存成功");
|
|
||||||
followDialog.value = false;
|
|
||||||
loadFollows();
|
|
||||||
emit("refresh");
|
|
||||||
} catch (e) {
|
|
||||||
ElMessage.error(e.message || "保存失败");
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFollowDelete(row) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm("确定删除该回访记录吗?", "删除确认", { type: "warning" });
|
|
||||||
await deleteFollow({ id: row.id });
|
|
||||||
ElMessage.success("删除成功");
|
|
||||||
loadFollows();
|
|
||||||
} catch (e) {
|
|
||||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------- 附件 -------------------------------- */
|
|
||||||
async function loadAttaches() {
|
|
||||||
loading.attach = true;
|
|
||||||
try {
|
|
||||||
const res = await getAttachList({ related_type: RELATED_TYPE, related_id: currentId() });
|
|
||||||
attaches.value = res?.data?.list || [];
|
|
||||||
} catch (e) {
|
|
||||||
attaches.value = [];
|
|
||||||
} finally {
|
|
||||||
loading.attach = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpload(options) {
|
|
||||||
const file = options.file;
|
|
||||||
uploading.value = true;
|
|
||||||
try {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("file", file);
|
|
||||||
const res = await uploadFile(fd);
|
|
||||||
const data = res?.data || {};
|
|
||||||
await addAttach({
|
|
||||||
related_type: RELATED_TYPE,
|
|
||||||
related_id: currentId(),
|
|
||||||
file_id: data.id || 0,
|
|
||||||
file_name: data.name || file.name,
|
|
||||||
file_url: data.url || "",
|
|
||||||
file_size: file.size || 0,
|
|
||||||
});
|
|
||||||
ElMessage.success("上传成功");
|
|
||||||
loadAttaches();
|
|
||||||
} catch (e) {
|
|
||||||
ElMessage.error(e.message || "上传失败");
|
|
||||||
} finally {
|
|
||||||
uploading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAttachDelete(row) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定删除附件「${row.file_name}」吗?`, "删除确认", { type: "warning" });
|
|
||||||
await deleteAttach({ id: row.id });
|
|
||||||
ElMessage.success("删除成功");
|
|
||||||
loadAttaches();
|
|
||||||
} catch (e) {
|
|
||||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------ 操作日志 ------------------------------ */
|
|
||||||
async function loadLogs() {
|
|
||||||
loading.log = true;
|
|
||||||
try {
|
|
||||||
const res = await getCrmOperateLogs({ related_type: RELATED_TYPE, related_id: currentId() });
|
|
||||||
logs.value = res?.data?.list || [];
|
|
||||||
} catch (e) {
|
|
||||||
logs.value = [];
|
|
||||||
} finally {
|
|
||||||
loading.log = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(size) {
|
|
||||||
const n = Number(size) || 0;
|
|
||||||
if (n < 1024) return `${n} B`;
|
|
||||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
:deep(.el-drawer__body) {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.clue-detail {
|
.clue-detail {
|
||||||
.tab-toolbar {
|
.detail-tabs {
|
||||||
|
// 横向 flex:导航固定宽 + 内容区占满剩余宽度
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
width: 100%;
|
||||||
gap: 12px;
|
min-width: 0;
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
.tip {
|
// 关键:内容区占满「容器剩余宽度」并允许收缩,内部表格才能随容器自适应
|
||||||
font-size: 12px;
|
:deep(.el-tabs__content) {
|
||||||
color: var(--el-text-color-secondary);
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-left: 4px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.file-link {
|
:deep(.el-tabs__header) {
|
||||||
color: var(--el-color-primary);
|
margin-right: 12px;
|
||||||
text-decoration: none;
|
flex-shrink: 0;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.log-item {
|
:deep(.el-tabs__item) {
|
||||||
.log-user {
|
justify-content: flex-start;
|
||||||
font-weight: 600;
|
text-align: left;
|
||||||
margin-right: 8px;
|
padding: 0 12px;
|
||||||
|
min-width: 96px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.follow-content {
|
:deep(.el-tab-pane) {
|
||||||
word-break: break-word;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(img) {
|
:deep(.el-table) {
|
||||||
max-width: 160px;
|
width: 100%;
|
||||||
max-height: 120px;
|
|
||||||
height: auto;
|
// 操作列:链接按钮去掉自带内边距、缩小间距,避免「编辑/删除」被挤成两行
|
||||||
border-radius: 4px;
|
.el-button.is-link {
|
||||||
cursor: zoom-in;
|
padding: 0;
|
||||||
vertical-align: middle;
|
height: auto;
|
||||||
margin: 2px 4px 2px 0;
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button + .el-button {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-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 v-if="tip" class="tip">{{ tip }}</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe border size="small">
|
||||||
|
<el-table-column label="文件名" min-width="170" 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 v-if="showStage" label="阶段" min-width="62" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" effect="plain" :type="pipelineStageTag(row.related_type)">
|
||||||
|
{{ pipelineStageText(row.related_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="大小" min-width="78" align="right">
|
||||||
|
<template #default="{ row }">{{ formatFileSize(row.file_size) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="uploader_name" label="上传人" min-width="78" show-overflow-tooltip />
|
||||||
|
<el-table-column label="上传时间" min-width="140">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="76" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty><el-empty description="暂无附件" :image-size="60" /></template>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { Upload } from "@element-plus/icons-vue";
|
||||||
|
import { getAttachList, addAttach, deleteAttach } from "@/api/crmPipeline";
|
||||||
|
import { uploadFile } from "@/api/file";
|
||||||
|
import { formatDateTime, pipelineStageText, pipelineStageTag } from "../../dict";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 关联对象ID
|
||||||
|
relatedId: { type: [Number, String], default: null },
|
||||||
|
// 关联类型:1=线索 2=商机 3=项目
|
||||||
|
relatedType: { type: Number, default: 1 },
|
||||||
|
// 工具栏提示文案
|
||||||
|
tip: { type: String, default: "" },
|
||||||
|
// 是否累积展示上游链路的附件(商机/项目)
|
||||||
|
includeSource: { type: Boolean, default: false },
|
||||||
|
// 是否展示「阶段」列(项目)
|
||||||
|
showStage: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["count"]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.relatedId) {
|
||||||
|
list.value = [];
|
||||||
|
emit("count", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
};
|
||||||
|
if (props.includeSource) params.include_source = 1;
|
||||||
|
const res = await getAttachList(params);
|
||||||
|
list.value = res?.data?.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
emit("count", list.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
file_id: data.id || 0,
|
||||||
|
file_name: data.name || file.name,
|
||||||
|
file_url: data.url || "",
|
||||||
|
file_size: file.size || 0,
|
||||||
|
});
|
||||||
|
ElMessage.success("上传成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || "上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除附件「${row.file_name}」吗?`, "删除确认", { type: "warning" });
|
||||||
|
await deleteAttach({ id: row.id });
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<el-descriptions v-if="clue" :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="创建时间">{{ formatDateTime(clue.create_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新时间">{{ formatDateTime(clue.update_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注" :span="2">{{ clue.remark || "-" }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
clueSourceText,
|
||||||
|
pipelineLevelText,
|
||||||
|
pipelineLevelTag,
|
||||||
|
clueStatusText,
|
||||||
|
clueStatusTag,
|
||||||
|
formatDateTime,
|
||||||
|
} from "../../dict";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
// 线索详情对象
|
||||||
|
clue: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-contact">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<el-button type="primary" size="small" :icon="Plus" @click="openEdit(null)">新增联系人</el-button>
|
||||||
|
<span v-if="tip" class="tip">{{ tip }}</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe border size="small">
|
||||||
|
<el-table-column label="姓名" min-width="105">
|
||||||
|
<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="职位" min-width="85" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="mobile" label="手机号" min-width="110" />
|
||||||
|
<el-table-column prop="wechat" label="微信" min-width="95" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="qq" label="QQ" min-width="85" />
|
||||||
|
<el-table-column prop="remark" label="备注" min-width="95" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="96" align="center">
|
||||||
|
<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="60" /></template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 联系人编辑 -->
|
||||||
|
<el-dialog v-model="dialog" :title="form.id ? '编辑联系人' : '新增联系人'" width="520px" append-to-body>
|
||||||
|
<el-form :model="form" label-width="90px">
|
||||||
|
<el-form-item label="姓名" required>
|
||||||
|
<el-input v-model="form.contact_name" placeholder="请输入姓名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="职位"><el-input v-model="form.position" /></el-form-item>
|
||||||
|
<el-form-item label="手机号"><el-input v-model="form.mobile" /></el-form-item>
|
||||||
|
<el-form-item label="微信"><el-input v-model="form.wechat" /></el-form-item>
|
||||||
|
<el-form-item label="QQ"><el-input v-model="form.qq" /></el-form-item>
|
||||||
|
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||||
|
<el-form-item label="主联系人">
|
||||||
|
<el-switch v-model="form.is_primary" :active-value="1" :inactive-value="0" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { Plus } from "@element-plus/icons-vue";
|
||||||
|
import {
|
||||||
|
getEntityContactList,
|
||||||
|
addEntityContact,
|
||||||
|
updateEntityContact,
|
||||||
|
deleteEntityContact,
|
||||||
|
} from "@/api/crmPipeline";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 关联对象ID(线索ID)
|
||||||
|
relatedId: { type: [Number, String], default: null },
|
||||||
|
// 关联类型:1=线索 2=商机 3=项目
|
||||||
|
relatedType: { type: Number, default: 1 },
|
||||||
|
// 工具栏提示文案
|
||||||
|
tip: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["count"]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
const dialog = ref(false);
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
id: null,
|
||||||
|
contact_name: "",
|
||||||
|
position: "",
|
||||||
|
mobile: "",
|
||||||
|
wechat: "",
|
||||||
|
qq: "",
|
||||||
|
email: "",
|
||||||
|
is_primary: 0,
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
const form = reactive(defaultForm());
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.relatedId) {
|
||||||
|
list.value = [];
|
||||||
|
emit("count", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getEntityContactList({
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
});
|
||||||
|
list.value = res?.data?.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
emit("count", list.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row) {
|
||||||
|
Object.assign(form, defaultForm());
|
||||||
|
if (row) Object.assign(form, row);
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.contact_name) {
|
||||||
|
ElMessage.warning("请输入联系人姓名");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
};
|
||||||
|
if (form.id) {
|
||||||
|
await updateEntityContact(payload);
|
||||||
|
} else {
|
||||||
|
await addEntityContact(payload);
|
||||||
|
}
|
||||||
|
ElMessage.success("保存成功");
|
||||||
|
dialog.value = false;
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除联系人「${row.contact_name}」吗?`, "删除确认", { type: "warning" });
|
||||||
|
await deleteEntityContact({ id: row.id });
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tab-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-follow">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<el-button type="primary" size="small" :icon="Plus" @click="openEdit(null)">新增回访</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe border size="small">
|
||||||
|
<el-table-column label="回访时间" min-width="132">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.follow_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="阶段" min-width="58" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" effect="plain" :type="pipelineStageTag(row.stage || row.related_type)">
|
||||||
|
{{ pipelineStageText(row.stage || row.related_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="方式" min-width="58" align="center">
|
||||||
|
<template #default="{ row }">{{ followTypeText(row.follow_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="回访内容" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="follow-content" v-html="row._textHtml"></div>
|
||||||
|
<div v-if="row._images && row._images.length" class="follow-images">
|
||||||
|
<div
|
||||||
|
v-for="(url, idx) in row._images"
|
||||||
|
:key="idx"
|
||||||
|
class="follow-image"
|
||||||
|
@click="openPreview(row._images, idx)"
|
||||||
|
>
|
||||||
|
<img :src="url" alt="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="下次联系" min-width="132">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="owner_user_name" label="回访人" min-width="70" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="96" align="center">
|
||||||
|
<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="60" /></template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 回访编辑 -->
|
||||||
|
<el-dialog v-model="dialog" :title="form.id ? '编辑回访' : '新增回访'" width="520px" append-to-body>
|
||||||
|
<el-form :model="form" label-width="90px">
|
||||||
|
<el-form-item label="回访阶段">
|
||||||
|
<el-select v-model="form.stage" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="i in PIPELINE_RELATED_TYPE_OPTIONS"
|
||||||
|
:key="i.value"
|
||||||
|
:label="i.label"
|
||||||
|
:value="i.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<div style="font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.4; margin-top: 2px">
|
||||||
|
按该次沟通实际发生的阶段记录,默认当前阶段
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="回访方式">
|
||||||
|
<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="回访时间">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.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="form.content"
|
||||||
|
:min-height="120"
|
||||||
|
placeholder="请输入回访内容,可粘贴或上传图片"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="下次联系">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.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="dialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 跟进记录图片放大预览 -->
|
||||||
|
<el-image-viewer
|
||||||
|
v-if="previewVisible"
|
||||||
|
:url-list="previewUrls"
|
||||||
|
:initial-index="previewIndex"
|
||||||
|
:z-index="5000"
|
||||||
|
@close="previewVisible = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox, ElImageViewer } from "element-plus";
|
||||||
|
import { Plus } from "@element-plus/icons-vue";
|
||||||
|
import { getFollowList, addFollow, updateFollow, deleteFollow } from "@/api/crmPipeline";
|
||||||
|
import RichContentEditor from "../../components/RichContentEditor.vue";
|
||||||
|
import {
|
||||||
|
followTypeText,
|
||||||
|
FOLLOW_TYPE_OPTIONS,
|
||||||
|
PIPELINE_RELATED_TYPE_OPTIONS,
|
||||||
|
pipelineStageText,
|
||||||
|
pipelineStageTag,
|
||||||
|
formatDateTime,
|
||||||
|
} from "../../dict";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 关联对象ID
|
||||||
|
relatedId: { type: [Number, String], default: null },
|
||||||
|
// 关联类型:1=线索 2=商机 3=项目
|
||||||
|
relatedType: { type: Number, default: 1 },
|
||||||
|
// 关联对象名称(新增回访时冗余记录)
|
||||||
|
relatedName: { type: String, default: "" },
|
||||||
|
// 是否累积展示上游链路的回访记录
|
||||||
|
includeSource: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["count", "saved"]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
const dialog = ref(false);
|
||||||
|
|
||||||
|
// 图片预览
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const previewUrls = ref([]);
|
||||||
|
const previewIndex = ref(0);
|
||||||
|
|
||||||
|
/** 拆分富文本:文本 HTML + 图片地址数组(图片单独用统一小方块展示) */
|
||||||
|
function splitFollowContent(html) {
|
||||||
|
const urls = [];
|
||||||
|
const textHtml = String(html || "").replace(/<img[^>]*src=["']([^"']+)["'][^>]*>/gi, (m, src) => {
|
||||||
|
if (src) urls.push(src);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
return { textHtml, urls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击图片方块 → 放大预览(支持同一条记录内多图切换) */
|
||||||
|
function openPreview(urls, index) {
|
||||||
|
if (!urls || !urls.length) return;
|
||||||
|
previewUrls.value = urls;
|
||||||
|
previewIndex.value = index < 0 ? 0 : index;
|
||||||
|
previewVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
id: null,
|
||||||
|
// 所属阶段:1线索/2商机/3项目,默认取当前详情页所在阶段
|
||||||
|
stage: null,
|
||||||
|
follow_type: "1",
|
||||||
|
follow_time: "",
|
||||||
|
content: "",
|
||||||
|
next_contact_time: "",
|
||||||
|
});
|
||||||
|
const form = reactive(defaultForm());
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.relatedId) {
|
||||||
|
list.value = [];
|
||||||
|
emit("count", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
pageSize: 100,
|
||||||
|
};
|
||||||
|
if (props.includeSource) params.include_source = 1;
|
||||||
|
const res = await getFollowList(params);
|
||||||
|
const rows = res?.data?.list || [];
|
||||||
|
list.value = rows.map((row) => {
|
||||||
|
const { textHtml, urls } = splitFollowContent(row.content);
|
||||||
|
return { ...row, _textHtml: textHtml, _images: urls };
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
emit("count", list.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row) {
|
||||||
|
Object.assign(form, defaultForm());
|
||||||
|
if (row) {
|
||||||
|
Object.assign(form, row);
|
||||||
|
// 编辑:默认保留记录原本的阶段,可手动修正
|
||||||
|
form.stage = row.stage || row.related_type || props.relatedType;
|
||||||
|
} else {
|
||||||
|
// 新增:默认取当前详情页所在阶段(线索/商机/项目)
|
||||||
|
form.stage = props.relatedType;
|
||||||
|
}
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const plainText = String(form.content || "")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.trim();
|
||||||
|
if (!plainText && !/<img/i.test(form.content || "")) {
|
||||||
|
ElMessage.warning("请输入回访内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (form.id) {
|
||||||
|
// 编辑:同步提交修正后的阶段(后端仅在传入合法阶段时更新)
|
||||||
|
await updateFollow({ ...form, stage: form.stage || undefined });
|
||||||
|
} else {
|
||||||
|
await addFollow({
|
||||||
|
...form,
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
related_name: props.relatedName,
|
||||||
|
stage: form.stage || props.relatedType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ElMessage.success("保存成功");
|
||||||
|
dialog.value = false;
|
||||||
|
reload();
|
||||||
|
emit("saved");
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm("确定删除该回访记录吗?", "删除确认", { type: "warning" });
|
||||||
|
await deleteFollow({ id: row.id });
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tab-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-content {
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 2px 6px 2px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-images {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
.follow-image {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: zoom-in;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<el-timeline v-loading="loading">
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="log in list"
|
||||||
|
: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="!list.length && !loading" description="暂无操作日志" :image-size="60" />
|
||||||
|
</el-timeline>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { getCrmOperateLogs } from "@/api/crmPipeline";
|
||||||
|
import { formatDateTime } from "../../dict";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 关联对象ID
|
||||||
|
relatedId: { type: [Number, String], default: null },
|
||||||
|
// 关联类型:1=线索 2=商机 3=项目
|
||||||
|
relatedType: { type: Number, default: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.relatedId) {
|
||||||
|
list.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getCrmOperateLogs({
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
});
|
||||||
|
list.value = res?.data?.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.log-item {
|
||||||
|
.log-user {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>线索管理</h2>
|
<h2>线索管理</h2>
|
||||||
<p>登记潜在客户线索,跟进后可转化为商机;转化后线索将锁定不可编辑</p>
|
<p>登记潜在客户线索,跟进后可转化为商机</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||||||
@@ -47,6 +47,11 @@
|
|||||||
|
|
||||||
<div class="table-container" v-loading="loading">
|
<div class="table-container" v-loading="loading">
|
||||||
<el-table :data="tableData" stripe border row-key="id">
|
<el-table :data="tableData" stripe border row-key="id">
|
||||||
|
<el-table-column label="状态" width="90" align="center" fixed>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="clueStatusTag(row.status)" size="small">{{ clueStatusText(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="线索名称" min-width="170" show-overflow-tooltip fixed>
|
<el-table-column label="线索名称" min-width="170" show-overflow-tooltip fixed>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="name-link" @click="openDetail(row)">{{ row.clue_name }}</span>
|
<span class="name-link" @click="openDetail(row)">{{ row.clue_name }}</span>
|
||||||
@@ -76,11 +81,6 @@
|
|||||||
<el-table-column label="下次联系时间" width="160" align="center">
|
<el-table-column label="下次联系时间" width="160" align="center">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="操作" width="220" align="center" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" :disabled="row.locked === 1" @click="openEdit(row)">
|
<el-button link type="primary" size="small" :disabled="row.locked === 1" @click="openEdit(row)">
|
||||||
@@ -95,7 +95,15 @@
|
|||||||
>
|
>
|
||||||
转化
|
转化
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="row.locked === 1 || row.status === 2"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty><el-empty description="暂无线索" :image-size="80" /></template>
|
<template #empty><el-empty description="暂无线索" :image-size="80" /></template>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
<el-table-column prop="contact_person" label="联系人" width="100" />
|
<el-table-column prop="contact_person" label="联系人" width="100" />
|
||||||
<el-table-column prop="contact_phone" label="联系电话" width="130" />
|
<el-table-column prop="contact_phone" label="联系电话" width="130" />
|
||||||
<el-table-column label="负责人" width="100">
|
<el-table-column label="负责人" width="100">
|
||||||
<template #default="{ row }">{{ row.owner_name || '未分配' }}</template>
|
<template #default="{ row }">{{ row.owner_user_name || '未分配' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="90" align="center">
|
<el-table-column label="状态" width="90" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
{{ statusText(detailData.status) }}
|
{{ statusText(detailData.status) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="负责人">{{ detailData.owner_name || '未分配' }}</el-descriptions-item>
|
<el-descriptions-item label="负责人">{{ detailData.owner_user_name || '未分配' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="注册资本">{{ detailData.registered_capital ? detailData.registered_capital + ' 万元' : '-' }}</el-descriptions-item>
|
<el-descriptions-item label="注册资本">{{ detailData.registered_capital ? detailData.registered_capital + ' 万元' : '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="实缴资本">{{ detailData.paid_capital ? detailData.paid_capital + ' 万元' : '-' }}</el-descriptions-item>
|
<el-descriptions-item label="实缴资本">{{ detailData.paid_capital ? detailData.paid_capital + ' 万元' : '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="成立日期">{{ formatDate(detailData.establish_date) }}</el-descriptions-item>
|
<el-descriptions-item label="成立日期">{{ formatDate(detailData.establish_date) }}</el-descriptions-item>
|
||||||
|
|||||||
@@ -239,6 +239,12 @@ export const pipelineRelatedTypeText = (val) => {
|
|||||||
return n === "1" ? "线索" : n === "2" ? "商机" : n === "3" ? "项目" : "-";
|
return n === "1" ? "线索" : n === "2" ? "商机" : n === "3" ? "项目" : "-";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PIPELINE_STAGE_TAG = { 1: "info", 2: "primary", 3: "success" };
|
||||||
|
|
||||||
|
/** 数据所属阶段(跟进记录 stage:1线索/2商机/3项目) */
|
||||||
|
export const pipelineStageText = (val) => pipelineRelatedTypeText(val);
|
||||||
|
export const pipelineStageTag = (val) => PIPELINE_STAGE_TAG[normalize(val)] || "info";
|
||||||
|
|
||||||
/** 金额格式化:12345.6 -> 12,345.60 */
|
/** 金额格式化:12345.6 -> 12,345.60 */
|
||||||
export function formatMoney(val) {
|
export function formatMoney(val) {
|
||||||
if (val === undefined || val === null || val === "") return "-";
|
if (val === undefined || val === null || val === "") return "-";
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
<el-option v-for="o in relatedOptions" :key="o.id" :label="o.name" :value="o.id" />
|
<el-option v-for="o in relatedOptions" :key="o.id" :label="o.name" :value="o.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="回访阶段" prop="stage">
|
||||||
|
<el-select v-model="form.stage" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="i in PIPELINE_RELATED_TYPE_OPTIONS"
|
||||||
|
:key="i.value"
|
||||||
|
:label="i.label"
|
||||||
|
:value="i.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<div style="font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.4; margin-top: 2px">
|
||||||
|
按该次沟通实际发生的阶段记录,默认跟随关联类型
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="回访方式" prop="follow_type">
|
<el-form-item label="回访方式" prop="follow_type">
|
||||||
<el-select v-model="form.follow_type" style="width: 100%">
|
<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-option v-for="i in FOLLOW_TYPE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||||
@@ -90,6 +103,8 @@ const defaultForm = () => ({
|
|||||||
related_type: 1,
|
related_type: 1,
|
||||||
related_id: "",
|
related_id: "",
|
||||||
related_name: "",
|
related_name: "",
|
||||||
|
// 所属阶段:1线索/2商机/3项目,默认跟随关联类型,可手动调整
|
||||||
|
stage: 1,
|
||||||
follow_type: "1",
|
follow_type: "1",
|
||||||
follow_time: "",
|
follow_time: "",
|
||||||
content: "",
|
content: "",
|
||||||
@@ -131,6 +146,8 @@ watch(
|
|||||||
if (props.editData) {
|
if (props.editData) {
|
||||||
isEdit.value = true;
|
isEdit.value = true;
|
||||||
Object.assign(form, props.editData);
|
Object.assign(form, props.editData);
|
||||||
|
// 编辑:默认保留记录原本的阶段(缺失时按关联类型回退),可手动修正
|
||||||
|
form.stage = Number(props.editData.stage || props.editData.related_type || 1);
|
||||||
searchRelated("");
|
searchRelated("");
|
||||||
} else {
|
} else {
|
||||||
isEdit.value = false;
|
isEdit.value = false;
|
||||||
@@ -148,6 +165,8 @@ watch(
|
|||||||
function handleTypeChange() {
|
function handleTypeChange() {
|
||||||
form.related_id = "";
|
form.related_id = "";
|
||||||
form.related_name = "";
|
form.related_name = "";
|
||||||
|
// 切换关联类型时,阶段默认跟随新类型(仍可手动调整)
|
||||||
|
form.stage = Number(form.related_type);
|
||||||
relatedOptions.value = [];
|
relatedOptions.value = [];
|
||||||
searchRelated("");
|
searchRelated("");
|
||||||
}
|
}
|
||||||
@@ -189,6 +208,8 @@ async function handleSubmit() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
...form,
|
...form,
|
||||||
related_name: form.related_name || selected?.name || "",
|
related_name: form.related_name || selected?.name || "",
|
||||||
|
// 回访阶段:允许与关联类型不同(如补录早期阶段的沟通),默认跟随关联类型
|
||||||
|
stage: Number(form.stage) || Number(form.related_type),
|
||||||
};
|
};
|
||||||
if (form.id) {
|
if (form.id) {
|
||||||
await updateFollow(payload);
|
await updateFollow(payload);
|
||||||
|
|||||||
@@ -49,7 +49,16 @@
|
|||||||
<el-table :data="tableData" stripe border row-key="id">
|
<el-table :data="tableData" stripe border row-key="id">
|
||||||
<el-table-column label="关联类型" width="100" align="center">
|
<el-table-column label="关联类型" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag size="small" effect="plain">{{ pipelineRelatedTypeText(row.related_type) }}</el-tag>
|
<el-tag size="small" effect="plain" :type="pipelineStageTag(row.related_type)">
|
||||||
|
{{ pipelineRelatedTypeText(row.related_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="阶段" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" effect="plain" :type="pipelineStageTag(row.stage || row.related_type)">
|
||||||
|
{{ pipelineStageText(row.stage || row.related_type) }}
|
||||||
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="related_name" label="关联对象" min-width="180" show-overflow-tooltip />
|
<el-table-column prop="related_name" label="关联对象" min-width="180" show-overflow-tooltip />
|
||||||
@@ -59,8 +68,12 @@
|
|||||||
<el-table-column label="回访时间" width="160" align="center">
|
<el-table-column label="回访时间" width="160" align="center">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.follow_time) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.follow_time) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="回访内容" min-width="240" show-overflow-tooltip>
|
<el-table-column label="回访内容" min-width="240">
|
||||||
<template #default="{ row }">{{ stripHtml(row.content) }}</template>
|
<template #default="{ row }">
|
||||||
|
<div class="content-cell" :title="stripHtml(row.content)" @click="openDetail(row)">
|
||||||
|
{{ stripHtml(row.content) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="下次联系时间" width="160" align="center">
|
<el-table-column label="下次联系时间" width="160" align="center">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
||||||
@@ -89,12 +102,46 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FollowEdit v-model:visible="editVisible" :edit-data="currentRow" @success="fetchList" />
|
<FollowEdit v-model:visible="editVisible" :edit-data="currentRow" @success="fetchList" />
|
||||||
|
|
||||||
|
<!-- 回访详情 -->
|
||||||
|
<el-dialog v-model="detailVisible" :title="detailData?.related_name || '回访详情'" width="680px">
|
||||||
|
<el-descriptions v-if="detailData" :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="关联类型">
|
||||||
|
<el-tag size="small" effect="plain" :type="pipelineStageTag(detailData.related_type)">
|
||||||
|
{{ pipelineRelatedTypeText(detailData.related_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="关联对象">{{ detailData.related_name || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="回访方式">{{ followTypeText(detailData.follow_type) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="回访时间">{{ formatDateTime(detailData.follow_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="回访人">{{ detailData.owner_user_name || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="下次联系时间">
|
||||||
|
{{ formatDateTime(detailData.next_contact_time) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="回访内容" :span="2">
|
||||||
|
<div class="detail-content" v-html="detailData.content" @click="handleContentClick"></div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="detailVisible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="openEditFromDetail">编辑</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 回访内容图片放大预览 -->
|
||||||
|
<el-image-viewer
|
||||||
|
v-if="previewVisible"
|
||||||
|
:url-list="previewUrls"
|
||||||
|
:initial-index="previewIndex"
|
||||||
|
:z-index="5000"
|
||||||
|
@close="previewVisible = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from "vue";
|
import { ref, reactive, onMounted } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox, ElImageViewer } from "element-plus";
|
||||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||||
import { getFollowList, deleteFollow } from "@/api/crmPipeline";
|
import { getFollowList, deleteFollow } from "@/api/crmPipeline";
|
||||||
import FollowEdit from "./components/edit.vue";
|
import FollowEdit from "./components/edit.vue";
|
||||||
@@ -103,6 +150,8 @@ import {
|
|||||||
FOLLOW_TYPE_OPTIONS,
|
FOLLOW_TYPE_OPTIONS,
|
||||||
followTypeText,
|
followTypeText,
|
||||||
pipelineRelatedTypeText,
|
pipelineRelatedTypeText,
|
||||||
|
pipelineStageText,
|
||||||
|
pipelineStageTag,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
stripHtml,
|
stripHtml,
|
||||||
} from "../dict";
|
} from "../dict";
|
||||||
@@ -112,6 +161,15 @@ const tableData = ref([]);
|
|||||||
const editVisible = ref(false);
|
const editVisible = ref(false);
|
||||||
const currentRow = ref(null);
|
const currentRow = ref(null);
|
||||||
|
|
||||||
|
// 详情弹窗
|
||||||
|
const detailVisible = ref(false);
|
||||||
|
const detailData = ref(null);
|
||||||
|
|
||||||
|
// 图片预览
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const previewUrls = ref([]);
|
||||||
|
const previewIndex = ref(0);
|
||||||
|
|
||||||
const filters = reactive({ keyword: "", related_type: "", follow_type: "" });
|
const filters = reactive({ keyword: "", related_type: "", follow_type: "" });
|
||||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||||
|
|
||||||
@@ -160,6 +218,33 @@ function openEdit(row) {
|
|||||||
editVisible.value = true;
|
editVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDetail(row) {
|
||||||
|
detailData.value = { ...row };
|
||||||
|
detailVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditFromDetail() {
|
||||||
|
if (!detailData.value) return;
|
||||||
|
currentRow.value = { ...detailData.value };
|
||||||
|
detailVisible.value = false;
|
||||||
|
editVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击详情里的图片 → 放大预览(支持同一条记录内多图切换) */
|
||||||
|
function handleContentClick(e) {
|
||||||
|
const target = e.target;
|
||||||
|
if (!target || String(target.tagName || "").toUpperCase() !== "IMG") return;
|
||||||
|
const container = e.currentTarget;
|
||||||
|
if (!container) return;
|
||||||
|
const imgs = Array.from(container.querySelectorAll("img"));
|
||||||
|
const urls = imgs.map((img) => img.getAttribute("src")).filter(Boolean);
|
||||||
|
if (!urls.length) return;
|
||||||
|
const index = imgs.indexOf(target);
|
||||||
|
previewUrls.value = urls;
|
||||||
|
previewIndex.value = index < 0 ? 0 : index;
|
||||||
|
previewVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(row) {
|
async function handleDelete(row) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm("确定删除该回访记录吗?", "删除确认", { type: "warning" });
|
await ElMessageBox.confirm("确定删除该回访记录吗?", "删除确认", { type: "warning" });
|
||||||
@@ -173,3 +258,31 @@ async function handleDelete(row) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped src="../styles/crm-page.less"></style>
|
<style lang="less" scoped src="../styles/crm-page.less"></style>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.content-cell {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 220px;
|
||||||
|
max-height: 160px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 2px 4px 2px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
:model-value="visible"
|
||||||
|
:title="project?.project_name || '项目详情'"
|
||||||
|
direction="rtl"
|
||||||
|
size="900px"
|
||||||
|
@update:model-value="handleClose"
|
||||||
|
@opened="loadAll"
|
||||||
|
>
|
||||||
|
<div v-if="project" class="project-detail">
|
||||||
|
<el-tabs v-model="activeTab" tab-position="left" class="detail-tabs">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<el-tab-pane label="基本信息" name="basic">
|
||||||
|
<DetailBasic :project="project" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 联系人(正式客户通讯录) -->
|
||||||
|
<el-tab-pane :label="`联系人 (${counts.contact})`" name="contact">
|
||||||
|
<DetailContact
|
||||||
|
ref="contactRef"
|
||||||
|
:company-id="project.customer_id"
|
||||||
|
company-type="customer"
|
||||||
|
@count="counts.contact = $event"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 跟进记录(链路累积:线索 + 商机 + 项目) -->
|
||||||
|
<el-tab-pane :label="`跟进记录 (${counts.follow})`" name="follow">
|
||||||
|
<DetailFollow
|
||||||
|
ref="followRef"
|
||||||
|
:related-id="project.id"
|
||||||
|
:related-type="RELATED_TYPE"
|
||||||
|
:related-name="project.project_name || ''"
|
||||||
|
:include-source="true"
|
||||||
|
@count="counts.follow = $event"
|
||||||
|
@saved="emit('refresh')"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 附件(链路累积:线索 + 商机 + 项目) -->
|
||||||
|
<el-tab-pane :label="`附件 (${counts.attach})`" name="attach">
|
||||||
|
<DetailAttach
|
||||||
|
ref="attachRef"
|
||||||
|
:related-id="project.id"
|
||||||
|
:related-type="RELATED_TYPE"
|
||||||
|
:include-source="true"
|
||||||
|
:show-stage="true"
|
||||||
|
@count="counts.attach = $event"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 项目文档(同步到文档库:共享文档 / 项目文档 / {项目名称}) -->
|
||||||
|
<el-tab-pane :label="`项目文档 (${counts.docs})`" name="docs">
|
||||||
|
<DetailDocs
|
||||||
|
ref="docsRef"
|
||||||
|
:project-id="project.id"
|
||||||
|
:project-name="project.project_name || ''"
|
||||||
|
@count="counts.docs = $event"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 操作日志 -->
|
||||||
|
<el-tab-pane label="操作日志" name="log">
|
||||||
|
<DetailLog ref="logRef" :related-id="project.id" :related-type="RELATED_TYPE" />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from "vue";
|
||||||
|
import DetailBasic from "./detail_basic.vue";
|
||||||
|
import DetailContact from "./detail_contact.vue";
|
||||||
|
import DetailFollow from "./detail_follow.vue";
|
||||||
|
import DetailAttach from "./detail_attach.vue";
|
||||||
|
import DetailDocs from "./detail_docs.vue";
|
||||||
|
import DetailLog from "./detail_log.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
project: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["update:visible", "refresh"]);
|
||||||
|
|
||||||
|
// 关联类型:3=项目
|
||||||
|
const RELATED_TYPE = 3;
|
||||||
|
|
||||||
|
const activeTab = ref("basic");
|
||||||
|
const counts = reactive({ contact: 0, follow: 0, attach: 0, docs: 0 });
|
||||||
|
|
||||||
|
const contactRef = ref();
|
||||||
|
const followRef = ref();
|
||||||
|
const attachRef = ref();
|
||||||
|
const docsRef = ref();
|
||||||
|
const logRef = ref();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
activeTab.value = "basic";
|
||||||
|
counts.contact = 0;
|
||||||
|
counts.follow = 0;
|
||||||
|
counts.attach = 0;
|
||||||
|
counts.docs = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit("update:visible", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 抽屉打开后刷新各 Tab 数据 */
|
||||||
|
function loadAll() {
|
||||||
|
if (!props.project?.id) return;
|
||||||
|
contactRef.value?.reload();
|
||||||
|
followRef.value?.reload();
|
||||||
|
attachRef.value?.reload();
|
||||||
|
docsRef.value?.reload();
|
||||||
|
logRef.value?.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ loadAll });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
:deep(.el-drawer__body) {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail {
|
||||||
|
.detail-tabs {
|
||||||
|
// 横向 flex:导航固定宽 + 内容区占满剩余宽度
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
// 关键:内容区占满「容器剩余宽度」并允许收缩,内部表格才能随容器自适应
|
||||||
|
:deep(.el-tabs__content) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__header) {
|
||||||
|
margin-right: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0 12px;
|
||||||
|
min-width: 96px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tab-pane) {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// 操作列:链接按钮去掉自带内边距、缩小间距,避免「编辑/删除」被挤成两行
|
||||||
|
.el-button.is-link {
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button + .el-button {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-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 v-if="tip" class="tip">{{ tip }}</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe border size="small">
|
||||||
|
<el-table-column label="文件名" min-width="170" 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 v-if="showStage" label="阶段" min-width="62" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" effect="plain" :type="pipelineStageTag(row.related_type)">
|
||||||
|
{{ pipelineStageText(row.related_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="大小" min-width="78" align="right">
|
||||||
|
<template #default="{ row }">{{ formatFileSize(row.file_size) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="uploader_name" label="上传人" min-width="78" show-overflow-tooltip />
|
||||||
|
<el-table-column label="上传时间" min-width="140">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="76" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty><el-empty description="暂无附件" :image-size="60" /></template>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { Upload } from "@element-plus/icons-vue";
|
||||||
|
import { getAttachList, addAttach, deleteAttach } from "@/api/crmPipeline";
|
||||||
|
import { uploadFile } from "@/api/file";
|
||||||
|
import { formatDateTime, pipelineStageText, pipelineStageTag } from "../../dict";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 关联对象ID
|
||||||
|
relatedId: { type: [Number, String], default: null },
|
||||||
|
// 关联类型:1=线索 2=商机 3=项目
|
||||||
|
relatedType: { type: Number, default: 3 },
|
||||||
|
// 工具栏提示文案
|
||||||
|
tip: { type: String, default: "" },
|
||||||
|
// 是否累积展示上游链路的附件(线索 + 商机 + 项目)
|
||||||
|
includeSource: { type: Boolean, default: false },
|
||||||
|
// 是否展示「阶段」列
|
||||||
|
showStage: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["count"]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.relatedId) {
|
||||||
|
list.value = [];
|
||||||
|
emit("count", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
};
|
||||||
|
if (props.includeSource) params.include_source = 1;
|
||||||
|
const res = await getAttachList(params);
|
||||||
|
list.value = res?.data?.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
emit("count", list.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
file_id: data.id || 0,
|
||||||
|
file_name: data.name || file.name,
|
||||||
|
file_url: data.url || "",
|
||||||
|
file_size: file.size || 0,
|
||||||
|
});
|
||||||
|
ElMessage.success("上传成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || "上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除附件「${row.file_name}」吗?`, "删除确认", { type: "warning" });
|
||||||
|
await deleteAttach({ id: row.id });
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<el-descriptions v-if="project" :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="项目名称" :span="2">{{ project.project_name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="项目编号">{{ project.project_no || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="客户名称">{{ project.customer_name || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="项目金额">{{ formatMoney(project.amount) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="项目状态">
|
||||||
|
<el-tag :type="projectStatusTag(project.status)" size="small">
|
||||||
|
{{ projectStatusText(project.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="负责人">{{ project.owner_user_name || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="客户行业">{{ project.industry || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="开始日期">{{ formatDateOnly(project.start_date) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="结束日期">{{ formatDateOnly(project.end_date) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="来源商机">
|
||||||
|
{{ project.business_name || (project.business_id ? `#${project.business_id}` : "-") }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接人">{{ project.contact_person || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接人职位">{{ project.contact_position || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="对接人手机">{{ project.contact_phone || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="项目地址" :span="2">{{ project.address || "-" }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatDateTime(project.create_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新时间">{{ formatDateTime(project.update_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注" :span="2">{{ project.remark || "-" }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
projectStatusText,
|
||||||
|
projectStatusTag,
|
||||||
|
formatDateTime,
|
||||||
|
formatMoney,
|
||||||
|
formatDateOnly,
|
||||||
|
} from "../../dict";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
// 项目详情对象
|
||||||
|
project: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-contact">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:icon="Plus"
|
||||||
|
:disabled="!companyId"
|
||||||
|
@click="openEdit(null)"
|
||||||
|
>
|
||||||
|
新增联系人
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
v-if="!companyId"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="该项目未关联正式客户,暂无正式联系人"
|
||||||
|
style="margin-bottom: 12px"
|
||||||
|
/>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe border size="small">
|
||||||
|
<el-table-column label="姓名" min-width="95">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.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 label="性别" min-width="50" align="center">
|
||||||
|
<template #default="{ row }">{{ genderText(row.gender) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" min-width="60" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="Number(row.status) === 1 ? 'success' : 'info'" size="small">
|
||||||
|
{{ Number(row.status) === 1 ? "在职" : "离职" }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="department" label="部门" min-width="70" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="position" label="职位" min-width="70" show-overflow-tooltip />
|
||||||
|
<el-table-column label="手机" min-width="100" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ formatMobiles(row.mobiles) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="phone" label="座机" min-width="78" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="email" label="邮箱" min-width="100" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="96" align="center">
|
||||||
|
<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="60" /></template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 联系人编辑(参数与 ERP 通讯录一致) -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialog"
|
||||||
|
:title="form.id ? '编辑联系人' : '新增联系人'"
|
||||||
|
width="640px"
|
||||||
|
append-to-body
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" label-position="right">
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="姓名" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="请输入姓名" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="性别" prop="gender">
|
||||||
|
<el-select v-model="form.gender" placeholder="请选择" style="width: 100%">
|
||||||
|
<el-option label="未知" :value="0" />
|
||||||
|
<el-option label="男" :value="1" />
|
||||||
|
<el-option label="女" :value="2" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="部门" prop="department">
|
||||||
|
<el-input v-model="form.department" placeholder="请输入部门" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="职位" prop="position">
|
||||||
|
<el-input v-model="form.position" placeholder="请输入职位" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="座机" prop="phone">
|
||||||
|
<el-input v-model="form.phone" placeholder="请输入座机号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="邮箱" prop="email">
|
||||||
|
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 手机(动态,最多5个) -->
|
||||||
|
<el-form-item label="手机">
|
||||||
|
<div class="mobile-list">
|
||||||
|
<div v-for="(m, idx) in form.mobiles" :key="idx" class="mobile-item">
|
||||||
|
<el-input v-model="form.mobiles[idx]" placeholder="请输入手机号" style="flex: 1" />
|
||||||
|
<el-button :icon="Delete" :disabled="form.mobiles.length <= 1" @click="removeMobile(idx)" />
|
||||||
|
</div>
|
||||||
|
<el-button v-if="form.mobiles.length < 5" type="primary" plain :icon="Plus" @click="addMobile">
|
||||||
|
添加手机号({{ form.mobiles.length }}/5)
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="微信" prop="wechat">
|
||||||
|
<el-input v-model="form.wechat" placeholder="请输入微信号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="QQ" prop="qq">
|
||||||
|
<el-input v-model="form.qq" placeholder="请输入QQ号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="钉钉" prop="dingtalk">
|
||||||
|
<el-input v-model="form.dingtalk" placeholder="请输入钉钉号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="主要联系人" prop="is_primary">
|
||||||
|
<el-switch
|
||||||
|
v-model="form.is_primary"
|
||||||
|
:active-value="1"
|
||||||
|
:inactive-value="0"
|
||||||
|
active-text="是"
|
||||||
|
inactive-text="否"
|
||||||
|
/>
|
||||||
|
</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 label="在职" :value="1" />
|
||||||
|
<el-option label="离职" :value="0" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="家庭住址" prop="home_address">
|
||||||
|
<el-input v-model="form.home_address" placeholder="请输入家庭住址" />
|
||||||
|
</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="dialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { Plus, Delete } from "@element-plus/icons-vue";
|
||||||
|
import {
|
||||||
|
getCompanyContactList,
|
||||||
|
createCompanyContact,
|
||||||
|
updateCompanyContact,
|
||||||
|
deleteCompanyContact,
|
||||||
|
} from "@/api/companyContact";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 正式客户ID(项目关联的 customer_id)
|
||||||
|
companyId: { type: [Number, String], default: null },
|
||||||
|
// 正式客户类型
|
||||||
|
companyType: { type: String, default: "customer" },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["count"]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
const dialog = ref(false);
|
||||||
|
const formRef = ref();
|
||||||
|
|
||||||
|
// 联系人表单:字段与 ERP 通讯录(contactBook.vue)完全一致
|
||||||
|
const defaultForm = () => ({
|
||||||
|
id: null,
|
||||||
|
name: "",
|
||||||
|
gender: 0,
|
||||||
|
phone: "",
|
||||||
|
mobiles: [""],
|
||||||
|
wechat: "",
|
||||||
|
qq: "",
|
||||||
|
dingtalk: "",
|
||||||
|
email: "",
|
||||||
|
department: "",
|
||||||
|
position: "",
|
||||||
|
home_address: "",
|
||||||
|
is_primary: 0,
|
||||||
|
status: 1,
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
const form = reactive(defaultForm());
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function genderText(g) {
|
||||||
|
if (Number(g) === 1) return "男";
|
||||||
|
if (Number(g) === 2) return "女";
|
||||||
|
return "未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMobiles(mobiles) {
|
||||||
|
if (!mobiles) return "-";
|
||||||
|
try {
|
||||||
|
const arr = typeof mobiles === "string" ? JSON.parse(mobiles) : mobiles;
|
||||||
|
if (!Array.isArray(arr)) return "-";
|
||||||
|
return arr.filter((m) => m && String(m).trim()).join("、") || "-";
|
||||||
|
} catch (e) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMobile() {
|
||||||
|
if (form.mobiles.length < 5) form.mobiles.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMobile(idx) {
|
||||||
|
if (form.mobiles.length > 1) form.mobiles.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.companyId) {
|
||||||
|
list.value = [];
|
||||||
|
emit("count", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getCompanyContactList({
|
||||||
|
company_type: props.companyType,
|
||||||
|
company_id: props.companyId,
|
||||||
|
});
|
||||||
|
const data = res?.data || {};
|
||||||
|
list.value = Array.isArray(data) ? data : data.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
emit("count", list.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row) {
|
||||||
|
if (!props.companyId) {
|
||||||
|
ElMessage.warning("该项目未关联正式客户,暂无正式联系人");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.assign(form, defaultForm());
|
||||||
|
if (row) {
|
||||||
|
let mobiles = [""];
|
||||||
|
try {
|
||||||
|
mobiles = row.mobiles ? (typeof row.mobiles === "string" ? JSON.parse(row.mobiles) : row.mobiles) : [""];
|
||||||
|
if (!Array.isArray(mobiles) || mobiles.length === 0) mobiles = [""];
|
||||||
|
} catch (e) {
|
||||||
|
mobiles = [""];
|
||||||
|
}
|
||||||
|
Object.assign(form, {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name || "",
|
||||||
|
gender: Number(row.gender) || 0,
|
||||||
|
phone: row.phone || "",
|
||||||
|
mobiles,
|
||||||
|
wechat: row.wechat || "",
|
||||||
|
qq: row.qq || "",
|
||||||
|
dingtalk: row.dingtalk || "",
|
||||||
|
email: row.email || "",
|
||||||
|
department: row.department || "",
|
||||||
|
position: row.position || "",
|
||||||
|
home_address: row.home_address || "",
|
||||||
|
is_primary: Number(row.is_primary) === 1 ? 1 : 0,
|
||||||
|
status: row.status === undefined || row.status === null ? 1 : Number(row.status),
|
||||||
|
remark: row.remark || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (formRef.value) {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate();
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 过滤空手机号后按 JSON 数组提交(与 ERP 通讯录一致)
|
||||||
|
const validMobiles = form.mobiles.filter((m) => m && String(m).trim());
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
company_type: props.companyType,
|
||||||
|
company_id: Number(props.companyId),
|
||||||
|
name: form.name,
|
||||||
|
gender: form.gender,
|
||||||
|
phone: form.phone,
|
||||||
|
mobiles: JSON.stringify(validMobiles),
|
||||||
|
wechat: form.wechat,
|
||||||
|
qq: form.qq,
|
||||||
|
dingtalk: form.dingtalk,
|
||||||
|
email: form.email,
|
||||||
|
department: form.department,
|
||||||
|
position: form.position,
|
||||||
|
home_address: form.home_address,
|
||||||
|
is_primary: form.is_primary,
|
||||||
|
status: form.status,
|
||||||
|
remark: form.remark,
|
||||||
|
};
|
||||||
|
if (form.id) {
|
||||||
|
await updateCompanyContact(form.id, payload);
|
||||||
|
} else {
|
||||||
|
await createCompanyContact(payload);
|
||||||
|
}
|
||||||
|
ElMessage.success("保存成功");
|
||||||
|
dialog.value = false;
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除联系人「${row.name}」吗?`, "删除确认", { type: "warning" });
|
||||||
|
await deleteCompanyContact(row.id);
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tab-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-list {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.mobile-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-docs">
|
||||||
|
<div class="docs-layout">
|
||||||
|
<!-- 左侧:文件夹分类树 -->
|
||||||
|
<aside class="docs-side">
|
||||||
|
<div class="side-header">
|
||||||
|
<span class="side-title">
|
||||||
|
<el-icon><Folder /></el-icon>
|
||||||
|
文件夹分类
|
||||||
|
</span>
|
||||||
|
<el-tooltip content="在当前选中的文件夹下新建子文件夹" placement="top">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:icon="FolderAdd"
|
||||||
|
@click="handleCreateFolder"
|
||||||
|
>新建</el-button
|
||||||
|
>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<el-scrollbar class="side-tree">
|
||||||
|
<el-tree
|
||||||
|
ref="treeRef"
|
||||||
|
:data="folderTree"
|
||||||
|
node-key="id"
|
||||||
|
:props="{ label: 'name', children: 'children' }"
|
||||||
|
highlight-current
|
||||||
|
default-expand-all
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
@node-click="handleNodeClick"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<span class="tree-node">
|
||||||
|
<el-icon class="tree-node-icon"><Folder /></el-icon>
|
||||||
|
<span class="tree-node-label" :title="data.name">{{ data.name }}</span>
|
||||||
|
<span v-if="data.doc_count > 0" class="tree-node-count">{{ data.doc_count }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</el-scrollbar>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 右侧:当前文件夹内容与文档列表 -->
|
||||||
|
<section class="docs-main">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<div class="current-folder">
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
<span class="current-folder-name" :title="currentFolderLabel">
|
||||||
|
{{ currentFolderLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:icon="FolderDelete"
|
||||||
|
:disabled="!isSubFolder"
|
||||||
|
@click="handleDeleteFolder"
|
||||||
|
>删除文件夹</el-button
|
||||||
|
>
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="handleUpload"
|
||||||
|
:disabled="uploading"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">
|
||||||
|
上传项目文档
|
||||||
|
</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<el-button size="small" :icon="Refresh" :disabled="loading" @click="reload">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="list" v-loading="loading" stripe border size="small">
|
||||||
|
<el-table-column label="文件名" min-width="150" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<a :href="row.file_url" target="_blank" class="file-link">
|
||||||
|
{{ row.file_name || row.title || "-" }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类型" width="64" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" effect="plain">{{ docTypeText(row.doc_type) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="所在文件夹" min-width="88" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.folder_name || projectName || "-" }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="大小" min-width="72" align="right">
|
||||||
|
<template #default="{ row }">{{ formatFileSize(row.size) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="creator_name" label="上传人" min-width="70" show-overflow-tooltip />
|
||||||
|
<el-table-column label="上传时间" min-width="132">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="64" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty><el-empty description="暂无项目文档" :image-size="60" /></template>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, ref } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Refresh,
|
||||||
|
FolderAdd,
|
||||||
|
FolderDelete,
|
||||||
|
Folder,
|
||||||
|
FolderOpened,
|
||||||
|
} from "@element-plus/icons-vue";
|
||||||
|
import {
|
||||||
|
getProjectDocs,
|
||||||
|
uploadProjectDoc,
|
||||||
|
deleteProjectDoc,
|
||||||
|
createProjectDocFolder,
|
||||||
|
deleteProjectDocFolder,
|
||||||
|
} from "@/api/crmPipeline";
|
||||||
|
import { uploadFile } from "@/api/file";
|
||||||
|
import { formatDateTime } from "../../dict";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 项目ID
|
||||||
|
projectId: { type: [Number, String], default: null },
|
||||||
|
// 项目名称(用于提示文案与文件夹名兜底)
|
||||||
|
projectName: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["count"]);
|
||||||
|
|
||||||
|
// doc_type: 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频
|
||||||
|
const DOC_TYPE_TEXT = {
|
||||||
|
0: "其他",
|
||||||
|
1: "文档",
|
||||||
|
2: "表格",
|
||||||
|
3: "演示",
|
||||||
|
4: "PDF",
|
||||||
|
5: "图片",
|
||||||
|
6: "压缩包",
|
||||||
|
7: "视频",
|
||||||
|
8: "音频",
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const pending = ref(0);
|
||||||
|
const list = ref([]);
|
||||||
|
// 项目文件夹树(根为项目文件夹,子节点为项目内新建的文件夹=文档库子分类)
|
||||||
|
const folderTree = ref([]);
|
||||||
|
// 项目根文件夹分类ID
|
||||||
|
const rootId = ref(0);
|
||||||
|
// 当前选中的文件夹(默认为项目根文件夹=项目名称;同时作为上传/新建的目标位置)
|
||||||
|
const selectedFolder = ref(null);
|
||||||
|
const treeRef = ref(null);
|
||||||
|
|
||||||
|
// 选中的是否为「项目内子文件夹」(项目根文件夹不可删除)
|
||||||
|
const isSubFolder = computed(
|
||||||
|
() => !!selectedFolder.value && selectedFolder.value !== rootId.value
|
||||||
|
);
|
||||||
|
const currentFolderName = computed(() => {
|
||||||
|
if (!selectedFolder.value) return "项目根文件夹";
|
||||||
|
return findFolder(folderTree.value, selectedFolder.value)?.name || "项目根文件夹";
|
||||||
|
});
|
||||||
|
// 右侧标题:当前浏览的文件夹
|
||||||
|
const currentFolderLabel = computed(() => {
|
||||||
|
if (selectedFolder.value === rootId.value) {
|
||||||
|
return props.projectName || "项目根文件夹";
|
||||||
|
}
|
||||||
|
return findFolder(folderTree.value, selectedFolder.value)?.name || "项目根文件夹";
|
||||||
|
});
|
||||||
|
|
||||||
|
function findFolder(nodes, id) {
|
||||||
|
for (const node of nodes || []) {
|
||||||
|
if (node.id === id) return node;
|
||||||
|
const hit = findFolder(node.children, id);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 左侧树节点点击:切换筛选文件夹 */
|
||||||
|
function handleNodeClick(data) {
|
||||||
|
if (!data.id || data.id === selectedFolder.value) return;
|
||||||
|
selectedFolder.value = data.id;
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步左侧树的高亮节点 */
|
||||||
|
function syncTreeHighlight() {
|
||||||
|
nextTick(() => {
|
||||||
|
treeRef.value?.setCurrentKey(selectedFolder.value || rootId.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.projectId) {
|
||||||
|
list.value = [];
|
||||||
|
emit("count", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = { page: 1, pageSize: 200 };
|
||||||
|
if (selectedFolder.value) params.category_id = selectedFolder.value;
|
||||||
|
const res = await getProjectDocs(props.projectId, params);
|
||||||
|
const data = res?.data || {};
|
||||||
|
rootId.value = data.category_id || 0;
|
||||||
|
list.value = data.list || [];
|
||||||
|
folderTree.value = data.folders || [];
|
||||||
|
// 兜底:默认选中项目根文件夹(项目名称);选中的文件夹已被删除时回到根
|
||||||
|
if (!selectedFolder.value || !findFolder(folderTree.value, selectedFolder.value)) {
|
||||||
|
selectedFolder.value = rootId.value;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
syncTreeHighlight();
|
||||||
|
emit("count", list.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extOf(name) {
|
||||||
|
const n = String(name || "");
|
||||||
|
const i = n.lastIndexOf(".");
|
||||||
|
return i > -1 ? n.slice(i + 1).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function docTypeText(type) {
|
||||||
|
return DOC_TYPE_TEXT[Number(type)] || "其他";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(size) {
|
||||||
|
const n = Number(size) || 0;
|
||||||
|
if (!n) return "-";
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建文件夹:在选中文件夹(缺省为项目根文件夹)下创建子文件夹,同步为文档库分类 */
|
||||||
|
async function handleCreateFolder() {
|
||||||
|
if (!rootId.value) {
|
||||||
|
ElMessage.warning("项目文件夹尚未就绪,请刷新后重试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
`将在「${currentFolderName.value}」下创建子文件夹(同步到文档库分类)`,
|
||||||
|
"新建文件夹",
|
||||||
|
{
|
||||||
|
confirmButtonText: "创建",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
inputPlaceholder: "请输入文件夹名称",
|
||||||
|
inputValidator: (v) => (String(v || "").trim() ? true : "文件夹名称不能为空"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const name = String(value || "").trim();
|
||||||
|
const res = await createProjectDocFolder(props.projectId, {
|
||||||
|
name,
|
||||||
|
parent_id: selectedFolder.value || rootId.value,
|
||||||
|
});
|
||||||
|
if (res?.code !== 200) throw new Error(res?.msg || "创建失败");
|
||||||
|
ElMessage.success(`文件夹「${name}」已创建并同步到文档库`);
|
||||||
|
// 新建后定位到新文件夹(reload 返回的树中查找同名新建项)
|
||||||
|
const created = res?.data?.id;
|
||||||
|
if (created) selectedFolder.value = Number(created);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e?.message || "创建失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除文件夹:仅限项目内子文件夹,且需为空(无子文件夹、无文档) */
|
||||||
|
async function handleDeleteFolder() {
|
||||||
|
if (!isSubFolder.value) return;
|
||||||
|
const name = findFolder(folderTree.value, selectedFolder.value)?.name || "该文件夹";
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除文件夹「${name}」吗?需为空文件夹(无子文件夹、无文档),文档库中同步删除。`,
|
||||||
|
"删除确认",
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
const res = await deleteProjectDocFolder(props.projectId, {
|
||||||
|
folder_id: selectedFolder.value,
|
||||||
|
});
|
||||||
|
if (res?.code !== 200) throw new Error(res?.msg || "删除失败");
|
||||||
|
ElMessage.success("文件夹已删除");
|
||||||
|
selectedFolder.value = null;
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e?.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传项目文档:文件先经 /backend/uploadfile 落地,
|
||||||
|
* 再经项目接口同步写入文档库「当前选中的文件夹」(缺省为项目根文件夹)。
|
||||||
|
*/
|
||||||
|
async function handleUpload(options) {
|
||||||
|
const file = options.file;
|
||||||
|
if (!file || !props.projectId) return;
|
||||||
|
const targetFolder = selectedFolder.value || rootId.value || 0;
|
||||||
|
pending.value += 1;
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const upRes = await uploadFile(fd);
|
||||||
|
// 秒传命中时返回 code=201,同样携带 url/id/name
|
||||||
|
if (upRes?.code !== 200 && upRes?.code !== 201) {
|
||||||
|
throw new Error(upRes?.msg || "上传失败");
|
||||||
|
}
|
||||||
|
const data = upRes?.data || {};
|
||||||
|
const name = data.name || file.name;
|
||||||
|
const res = await uploadProjectDoc(props.projectId, {
|
||||||
|
file_id: data.id || 0,
|
||||||
|
file_name: name,
|
||||||
|
file_url: data.url || "",
|
||||||
|
file_size: file.size || 0,
|
||||||
|
ext: extOf(name),
|
||||||
|
category_id: targetFolder,
|
||||||
|
});
|
||||||
|
if (res?.code !== 200) {
|
||||||
|
throw new Error(res?.msg || "同步文档库失败");
|
||||||
|
}
|
||||||
|
ElMessage.success(`「${file.name}」已上传到「${currentFolderName.value}」并同步到文档库`);
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(`「${file.name}」${e?.message || "上传失败"}`);
|
||||||
|
} finally {
|
||||||
|
pending.value -= 1;
|
||||||
|
uploading.value = pending.value > 0;
|
||||||
|
if (pending.value === 0) reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除文档「${row.file_name || row.title}」吗?文档库中的文件将一并删除。`,
|
||||||
|
"删除确认",
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
const res = await deleteProjectDoc(props.projectId, { doc_id: row.id });
|
||||||
|
if (res?.code !== 200) throw new Error(res?.msg || "删除失败");
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e?.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.docs-layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧:文件夹分类树 */
|
||||||
|
.docs-side {
|
||||||
|
width: 190px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--el-fill-color-extra-light);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.side-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
|
||||||
|
.side-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-tree {
|
||||||
|
flex: 1;
|
||||||
|
max-height: 420px;
|
||||||
|
|
||||||
|
:deep(.el-tree) {
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 4px;
|
||||||
|
--el-tree-node-content-height: 28px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 4px;
|
||||||
|
|
||||||
|
.tree-node-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 16px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧:文档列表 */
|
||||||
|
.docs-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.current-folder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-folder-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-link {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail-follow">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<el-button type="primary" size="small" :icon="Plus" @click="openEdit(null)">新增回访</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe border size="small">
|
||||||
|
<el-table-column label="回访时间" min-width="132">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.follow_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="阶段" min-width="58" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" effect="plain" :type="pipelineStageTag(row.stage || row.related_type)">
|
||||||
|
{{ pipelineStageText(row.stage || row.related_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="方式" min-width="58" align="center">
|
||||||
|
<template #default="{ row }">{{ followTypeText(row.follow_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="回访内容" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="follow-content" v-html="row._textHtml"></div>
|
||||||
|
<div v-if="row._images && row._images.length" class="follow-images">
|
||||||
|
<div
|
||||||
|
v-for="(url, idx) in row._images"
|
||||||
|
:key="idx"
|
||||||
|
class="follow-image"
|
||||||
|
@click="openPreview(row._images, idx)"
|
||||||
|
>
|
||||||
|
<img :src="url" alt="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="下次联系" min-width="132">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="owner_user_name" label="回访人" min-width="70" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="96" align="center">
|
||||||
|
<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="60" /></template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 回访编辑 -->
|
||||||
|
<el-dialog v-model="dialog" :title="form.id ? '编辑回访' : '新增回访'" width="520px" append-to-body>
|
||||||
|
<el-form :model="form" label-width="90px">
|
||||||
|
<el-form-item label="回访阶段">
|
||||||
|
<el-select v-model="form.stage" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="i in PIPELINE_RELATED_TYPE_OPTIONS"
|
||||||
|
:key="i.value"
|
||||||
|
:label="i.label"
|
||||||
|
:value="i.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<div style="font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.4; margin-top: 2px">
|
||||||
|
按该次沟通实际发生的阶段记录,默认当前阶段
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="回访方式">
|
||||||
|
<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="回访时间">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.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="form.content"
|
||||||
|
:min-height="120"
|
||||||
|
placeholder="请输入回访内容,可粘贴或上传图片"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="下次联系">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.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="dialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 跟进记录图片放大预览 -->
|
||||||
|
<el-image-viewer
|
||||||
|
v-if="previewVisible"
|
||||||
|
:url-list="previewUrls"
|
||||||
|
:initial-index="previewIndex"
|
||||||
|
:z-index="5000"
|
||||||
|
@close="previewVisible = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox, ElImageViewer } from "element-plus";
|
||||||
|
import { Plus } from "@element-plus/icons-vue";
|
||||||
|
import { getFollowList, addFollow, updateFollow, deleteFollow } from "@/api/crmPipeline";
|
||||||
|
import RichContentEditor from "../../components/RichContentEditor.vue";
|
||||||
|
import {
|
||||||
|
followTypeText,
|
||||||
|
FOLLOW_TYPE_OPTIONS,
|
||||||
|
PIPELINE_RELATED_TYPE_OPTIONS,
|
||||||
|
pipelineStageText,
|
||||||
|
pipelineStageTag,
|
||||||
|
formatDateTime,
|
||||||
|
} from "../../dict";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 关联对象ID
|
||||||
|
relatedId: { type: [Number, String], default: null },
|
||||||
|
// 关联类型:1=线索 2=商机 3=项目
|
||||||
|
relatedType: { type: Number, default: 3 },
|
||||||
|
// 关联对象名称(新增回访时冗余记录)
|
||||||
|
relatedName: { type: String, default: "" },
|
||||||
|
// 是否累积展示上游链路的回访记录
|
||||||
|
includeSource: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["count", "saved"]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
const dialog = ref(false);
|
||||||
|
|
||||||
|
// 图片预览
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const previewUrls = ref([]);
|
||||||
|
const previewIndex = ref(0);
|
||||||
|
|
||||||
|
/** 拆分富文本:文本 HTML + 图片地址数组(图片单独用统一小方块展示) */
|
||||||
|
function splitFollowContent(html) {
|
||||||
|
const urls = [];
|
||||||
|
const textHtml = String(html || "").replace(/<img[^>]*src=["']([^"']+)["'][^>]*>/gi, (m, src) => {
|
||||||
|
if (src) urls.push(src);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
return { textHtml, urls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击图片方块 → 放大预览(支持同一条记录内多图切换) */
|
||||||
|
function openPreview(urls, index) {
|
||||||
|
if (!urls || !urls.length) return;
|
||||||
|
previewUrls.value = urls;
|
||||||
|
previewIndex.value = index < 0 ? 0 : index;
|
||||||
|
previewVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
id: null,
|
||||||
|
// 所属阶段:1线索/2商机/3项目,默认取当前详情页所在阶段
|
||||||
|
stage: null,
|
||||||
|
follow_type: "1",
|
||||||
|
follow_time: "",
|
||||||
|
content: "",
|
||||||
|
next_contact_time: "",
|
||||||
|
});
|
||||||
|
const form = reactive(defaultForm());
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.relatedId) {
|
||||||
|
list.value = [];
|
||||||
|
emit("count", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
pageSize: 100,
|
||||||
|
};
|
||||||
|
if (props.includeSource) params.include_source = 1;
|
||||||
|
const res = await getFollowList(params);
|
||||||
|
const rows = res?.data?.list || [];
|
||||||
|
list.value = rows.map((row) => {
|
||||||
|
const { textHtml, urls } = splitFollowContent(row.content);
|
||||||
|
return { ...row, _textHtml: textHtml, _images: urls };
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
emit("count", list.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row) {
|
||||||
|
Object.assign(form, defaultForm());
|
||||||
|
if (row) {
|
||||||
|
Object.assign(form, row);
|
||||||
|
// 编辑:默认保留记录原本的阶段,可手动修正
|
||||||
|
form.stage = row.stage || row.related_type || props.relatedType;
|
||||||
|
} else {
|
||||||
|
// 新增:默认取当前详情页所在阶段(线索/商机/项目)
|
||||||
|
form.stage = props.relatedType;
|
||||||
|
}
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const plainText = String(form.content || "")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.trim();
|
||||||
|
if (!plainText && !/<img/i.test(form.content || "")) {
|
||||||
|
ElMessage.warning("请输入回访内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (form.id) {
|
||||||
|
// 编辑:同步提交修正后的阶段(后端仅在传入合法阶段时更新)
|
||||||
|
await updateFollow({ ...form, stage: form.stage || undefined });
|
||||||
|
} else {
|
||||||
|
await addFollow({
|
||||||
|
...form,
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
related_name: props.relatedName,
|
||||||
|
stage: form.stage || props.relatedType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ElMessage.success("保存成功");
|
||||||
|
dialog.value = false;
|
||||||
|
reload();
|
||||||
|
emit("saved");
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm("确定删除该回访记录吗?", "删除确认", { type: "warning" });
|
||||||
|
await deleteFollow({ id: row.id });
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.tab-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-content {
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 2px 6px 2px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-images {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
.follow-image {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: zoom-in;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<el-timeline v-loading="loading">
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="log in list"
|
||||||
|
: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="!list.length && !loading" description="暂无操作日志" :image-size="60" />
|
||||||
|
</el-timeline>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { getCrmOperateLogs } from "@/api/crmPipeline";
|
||||||
|
import { formatDateTime } from "../../dict";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 关联对象ID
|
||||||
|
relatedId: { type: [Number, String], default: null },
|
||||||
|
// 关联类型:1=线索 2=商机 3=项目
|
||||||
|
relatedType: { type: Number, default: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const list = ref([]);
|
||||||
|
|
||||||
|
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||||
|
async function reload() {
|
||||||
|
if (!props.relatedId) {
|
||||||
|
list.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getCrmOperateLogs({
|
||||||
|
related_type: props.relatedType,
|
||||||
|
related_id: props.relatedId,
|
||||||
|
});
|
||||||
|
list.value = res?.data?.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
list.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.log-item {
|
||||||
|
.log-user {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="210" align="center" fixed="right">
|
<el-table-column label="操作" width="210" align="center" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<!-- <el-button
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
@click="openContactBook(row)"
|
@click="openContactBook(row)"
|
||||||
>
|
>
|
||||||
通讯录
|
通讯录
|
||||||
</el-button>
|
</el-button> -->
|
||||||
<el-button link type="primary" size="small" @click="openEdit(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>
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -105,27 +105,7 @@
|
|||||||
:company-name="contactBookCompanyName"
|
:company-name="contactBookCompanyName"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-dialog v-model="detailVisible" :title="currentRow?.project_name || '项目详情'" width="720px">
|
<ProjectDetail v-model:visible="detailVisible" :project="currentRow" @refresh="fetchList" />
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -135,6 +115,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
|
|||||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||||
import { getProjectList, deleteProject } from "@/api/crmPipeline";
|
import { getProjectList, deleteProject } from "@/api/crmPipeline";
|
||||||
import ProjectEdit from "./components/edit.vue";
|
import ProjectEdit from "./components/edit.vue";
|
||||||
|
import ProjectDetail from "./components/detail.vue";
|
||||||
import ContactBook from "../../erp/components/contactBook.vue";
|
import ContactBook from "../../erp/components/contactBook.vue";
|
||||||
import {
|
import {
|
||||||
PROJECT_STATUS_OPTIONS,
|
PROJECT_STATUS_OPTIONS,
|
||||||
|
|||||||
@@ -88,8 +88,10 @@ func (c *BackendCrmBusinessController) List() {
|
|||||||
total, _ := qs.Count()
|
total, _ := qs.Count()
|
||||||
var list []models.TenantCrmBusiness
|
var list []models.TenantCrmBusiness
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
// 未转化的排前面,已转化的(locked=1)沉到列表下方
|
||||||
|
_, _ = qs.OrderBy("locked", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||||
}
|
}
|
||||||
|
attachClueName(tenantID, list)
|
||||||
pipelineOk(&c.Controller, map[string]interface{}{
|
pipelineOk(&c.Controller, map[string]interface{}{
|
||||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||||
})
|
})
|
||||||
@@ -115,6 +117,9 @@ func (c *BackendCrmBusinessController) Detail() {
|
|||||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tmp := []models.TenantCrmBusiness{biz}
|
||||||
|
attachClueName(pipelineTenantID(claims), tmp)
|
||||||
|
biz.ClueName = tmp[0].ClueName
|
||||||
pipelineOk(&c.Controller, biz)
|
pipelineOk(&c.Controller, biz)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +164,7 @@ func (c *BackendCrmBusinessController) Create() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ownerID := firstNonEmpty(p.OwnerUserID, pipelineUID(claims))
|
ownerID := firstNonEmpty(p.OwnerUserID, pipelineUID(claims))
|
||||||
ownerName := firstNonEmpty(p.OwnerUserName, claims.Username)
|
ownerName := firstNonEmpty(p.OwnerUserName, resolveUserName(claims))
|
||||||
stage := firstNonEmpty(p.Stage, "1")
|
stage := firstNonEmpty(p.Stage, "1")
|
||||||
level := firstNonEmpty(p.Level, "2")
|
level := firstNonEmpty(p.Level, "2")
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -301,6 +306,10 @@ func (c *BackendCrmBusinessController) Delete() {
|
|||||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if biz.Locked == 1 || biz.Status == 2 {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能删除")
|
||||||
|
return
|
||||||
|
}
|
||||||
if !canDeleteCrmRecord(claims, biz.CreateUserID) {
|
if !canDeleteCrmRecord(claims, biz.CreateUserID) {
|
||||||
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该商机")
|
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该商机")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ func (c *BackendCrmClueController) List() {
|
|||||||
total, _ := qs.Count()
|
total, _ := qs.Count()
|
||||||
var list []models.TenantCrmClue
|
var list []models.TenantCrmClue
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
// 未转化的排前面,已转化的(locked=1)沉到列表下方
|
||||||
|
_, _ = qs.OrderBy("locked", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||||
}
|
}
|
||||||
pipelineOk(&c.Controller, map[string]interface{}{
|
pipelineOk(&c.Controller, map[string]interface{}{
|
||||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||||
@@ -145,7 +146,7 @@ func (c *BackendCrmClueController) Create() {
|
|||||||
ownerID = pipelineUID(claims)
|
ownerID = pipelineUID(claims)
|
||||||
}
|
}
|
||||||
if ownerName == "" {
|
if ownerName == "" {
|
||||||
ownerName = claims.Username
|
ownerName = resolveUserName(claims)
|
||||||
}
|
}
|
||||||
level := strings.TrimSpace(p.ClueLevel)
|
level := strings.TrimSpace(p.ClueLevel)
|
||||||
if level == "" {
|
if level == "" {
|
||||||
@@ -271,6 +272,10 @@ func (c *BackendCrmClueController) Delete() {
|
|||||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if clue.Locked == 1 || clue.Status == 2 {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能删除")
|
||||||
|
return
|
||||||
|
}
|
||||||
if !canDeleteCrmRecord(claims, clue.CreateUserID) {
|
if !canDeleteCrmRecord(claims, clue.CreateUserID) {
|
||||||
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该线索")
|
pipelineErr(&c.Controller, 403, 403, "只有创建人、租户管理员或平台管理员可以删除该线索")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,10 +7,28 @@ import (
|
|||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
"server/pkg/jwtutil"
|
"server/pkg/jwtutil"
|
||||||
|
"server/services"
|
||||||
|
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// resolveUserName 按租户 + 用户ID 解析用户显示名(优先真实姓名,退化到账号,最后退化到登录名)。
|
||||||
|
// 注意:JWT 里的 Username 是登录账号,直接存会导致前端"负责人"显示成账号。
|
||||||
|
func resolveUserName(claims *jwtutil.Claims) string {
|
||||||
|
if claims == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
tid := uint64(claims.TenantId)
|
||||||
|
account, name := services.OperationLogUser(&tid, uint64(claims.UserID))
|
||||||
|
if strings.TrimSpace(name) != "" {
|
||||||
|
return strings.TrimSpace(name)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(account) != "" {
|
||||||
|
return strings.TrimSpace(account)
|
||||||
|
}
|
||||||
|
return claims.Username
|
||||||
|
}
|
||||||
|
|
||||||
// CRM 业务管线(线索/商机/项目/回访)公共辅助方法。
|
// CRM 业务管线(线索/商机/项目/回访)公共辅助方法。
|
||||||
// related_type 约定:1=线索 2=商机 3=项目
|
// related_type 约定:1=线索 2=商机 3=项目
|
||||||
|
|
||||||
@@ -98,7 +116,7 @@ func crmWriteLog(tenantID string, relatedType int8, relatedID uint64, action, co
|
|||||||
operatorID, operatorName := "", ""
|
operatorID, operatorName := "", ""
|
||||||
if claims != nil {
|
if claims != nil {
|
||||||
operatorID = pipelineUID(claims)
|
operatorID = pipelineUID(claims)
|
||||||
operatorName = claims.Username
|
operatorName = resolveUserName(claims)
|
||||||
}
|
}
|
||||||
log := models.TenantCrmOperateLog{
|
log := models.TenantCrmOperateLog{
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
@@ -245,3 +263,117 @@ func syncContactsToCompany(tenantID string, customerID uint64, contacts []models
|
|||||||
}
|
}
|
||||||
return inserted, merged
|
return inserted, merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// relatedRef 关联对象引用(related_type + related_id)
|
||||||
|
type relatedRef struct {
|
||||||
|
Type int8
|
||||||
|
ID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// relatedChain 返回详情页需要展示的关联链路(上游 + 自身),顺序:线索 → 商机 → 项目。
|
||||||
|
// 线索 → [线索]
|
||||||
|
// 商机 → [线索, 商机]
|
||||||
|
// 项目 → [线索, 商机, 项目]
|
||||||
|
// 过程数据(联系人 / 跟进记录 / 附件)只存在创建时的对象上、不做搬迁,
|
||||||
|
// 由上层按链路累积查询,从而在商机里能看到线索阶段的数据,在项目里能看到全部阶段的数据。
|
||||||
|
func relatedChain(tenantID string, relatedType int8, relatedID uint64) []relatedRef {
|
||||||
|
if models.Orm == nil || relatedID == 0 {
|
||||||
|
return []relatedRef{{Type: relatedType, ID: relatedID}}
|
||||||
|
}
|
||||||
|
refs := make([]relatedRef, 0, 3)
|
||||||
|
switch relatedType {
|
||||||
|
case 2: // 商机:来源线索 + 自身
|
||||||
|
var biz models.TenantCrmBusiness
|
||||||
|
if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||||
|
Filter("id", relatedID).Filter("tenant_id", tenantID).
|
||||||
|
Filter("delete_time__isnull", true).One(&biz); err == nil {
|
||||||
|
if biz.ClueID != nil && *biz.ClueID > 0 {
|
||||||
|
refs = append(refs, relatedRef{Type: 1, ID: *biz.ClueID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refs = append(refs, relatedRef{Type: 2, ID: relatedID})
|
||||||
|
case 3: // 项目:来源商机 + 商机的来源线索 + 自身
|
||||||
|
var proj models.TenantCrmProject
|
||||||
|
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||||
|
Filter("id", relatedID).Filter("tenant_id", tenantID).
|
||||||
|
Filter("delete_time__isnull", true).One(&proj); err == nil {
|
||||||
|
if proj.BusinessID != nil && *proj.BusinessID > 0 {
|
||||||
|
var biz models.TenantCrmBusiness
|
||||||
|
if err2 := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||||
|
Filter("id", *proj.BusinessID).Filter("tenant_id", tenantID).
|
||||||
|
Filter("delete_time__isnull", true).One(&biz); err2 == nil {
|
||||||
|
if biz.ClueID != nil && *biz.ClueID > 0 {
|
||||||
|
refs = append(refs, relatedRef{Type: 1, ID: *biz.ClueID})
|
||||||
|
}
|
||||||
|
refs = append(refs, relatedRef{Type: 2, ID: biz.ID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refs = append(refs, relatedRef{Type: 3, ID: relatedID})
|
||||||
|
default: // 线索:仅自身
|
||||||
|
refs = append(refs, relatedRef{Type: relatedType, ID: relatedID})
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachBusinessName 批量填充项目的「来源商机名称」(非表字段,仅用于展示)。
|
||||||
|
func attachBusinessName(tenantID string, list []models.TenantCrmProject) {
|
||||||
|
ids := make([]uint64, 0, len(list))
|
||||||
|
seen := map[uint64]bool{}
|
||||||
|
for _, p := range list {
|
||||||
|
if p.BusinessID != nil && *p.BusinessID > 0 && !seen[*p.BusinessID] {
|
||||||
|
seen[*p.BusinessID] = true
|
||||||
|
ids = append(ids, *p.BusinessID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []models.TenantCrmBusiness
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||||
|
Filter("tenant_id", tenantID).
|
||||||
|
Filter("id__in", ids).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
All(&rows)
|
||||||
|
nameMap := make(map[uint64]string, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
nameMap[r.ID] = r.BusinessName
|
||||||
|
}
|
||||||
|
for i := range list {
|
||||||
|
if list[i].BusinessID != nil {
|
||||||
|
list[i].BusinessName = nameMap[*list[i].BusinessID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachClueName 批量填充商机的「来源线索名称」(非表字段,仅用于展示)。
|
||||||
|
func attachClueName(tenantID string, list []models.TenantCrmBusiness) {
|
||||||
|
ids := make([]uint64, 0, len(list))
|
||||||
|
seen := map[uint64]bool{}
|
||||||
|
for _, b := range list {
|
||||||
|
if b.ClueID != nil && *b.ClueID > 0 && !seen[*b.ClueID] {
|
||||||
|
seen[*b.ClueID] = true
|
||||||
|
ids = append(ids, *b.ClueID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []models.TenantCrmClue
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.TenantCrmClue)).
|
||||||
|
Filter("tenant_id", tenantID).
|
||||||
|
Filter("id__in", ids).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
All(&rows)
|
||||||
|
nameMap := make(map[uint64]string, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
nameMap[r.ID] = r.ClueName
|
||||||
|
}
|
||||||
|
for i := range list {
|
||||||
|
if list[i].ClueID != nil {
|
||||||
|
list[i].ClueName = nameMap[*list[i].ClueID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func (c *BackendCrmPoolController) Claim() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cust.OwnerUserID = uid
|
cust.OwnerUserID = uid
|
||||||
cust.OwnerUserName = claims.Username
|
cust.OwnerUserName = resolveUserName(claims)
|
||||||
cust.InPool = 0
|
cust.InPool = 0
|
||||||
cust.PoolInTime = nil
|
cust.PoolInTime = nil
|
||||||
cust.PoolReason = ""
|
cust.PoolReason = ""
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
"server/services"
|
||||||
|
|
||||||
"github.com/beego/beego/v2/client/orm"
|
"github.com/beego/beego/v2/client/orm"
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
@@ -78,6 +81,7 @@ func (c *BackendCrmProjectController) List() {
|
|||||||
if total > 0 {
|
if total > 0 {
|
||||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||||
}
|
}
|
||||||
|
attachBusinessName(tenantID, list)
|
||||||
pipelineOk(&c.Controller, map[string]interface{}{
|
pipelineOk(&c.Controller, map[string]interface{}{
|
||||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||||
})
|
})
|
||||||
@@ -103,6 +107,15 @@ func (c *BackendCrmProjectController) Detail() {
|
|||||||
pipelineErr(&c.Controller, 404, 404, "项目未找到")
|
pipelineErr(&c.Controller, 404, 404, "项目未找到")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tmp := []models.TenantCrmProject{proj}
|
||||||
|
attachBusinessName(pipelineTenantID(claims), tmp)
|
||||||
|
proj.BusinessName = tmp[0].BusinessName
|
||||||
|
// 存量项目兜底:确保文档库「共享文档 / 项目文档」下已建立同名文件夹(失败静默,不影响详情)
|
||||||
|
if proj.DocCategoryID == 0 {
|
||||||
|
if cid, err := services.EnsureCrmProjectDocCategory(claims.TenantId, proj.ID, proj.ProjectName, 0); err == nil && cid > 0 {
|
||||||
|
proj.DocCategoryID = cid
|
||||||
|
}
|
||||||
|
}
|
||||||
pipelineOk(&c.Controller, proj)
|
pipelineOk(&c.Controller, proj)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +162,7 @@ func (c *BackendCrmProjectController) Create() {
|
|||||||
CustomerID: custIDVal,
|
CustomerID: custIDVal,
|
||||||
CustomerName: customerName,
|
CustomerName: customerName,
|
||||||
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
|
OwnerUserID: firstNonEmpty(p.OwnerUserID, pipelineUID(claims)),
|
||||||
OwnerUserName: firstNonEmpty(p.OwnerUserName, claims.Username),
|
OwnerUserName: firstNonEmpty(p.OwnerUserName, resolveUserName(claims)),
|
||||||
Industry: strings.TrimSpace(p.Industry),
|
Industry: strings.TrimSpace(p.Industry),
|
||||||
Amount: p.Amount,
|
Amount: p.Amount,
|
||||||
Status: status,
|
Status: status,
|
||||||
@@ -169,8 +182,12 @@ func (c *BackendCrmProjectController) Create() {
|
|||||||
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
|
pipelineErr(&c.Controller, 500, 500, "创建失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 项目建立后,同步在文档库「共享文档 / 项目文档」下建立同名文件夹(失败不影响项目创建)
|
||||||
|
if cid, err := services.EnsureCrmProjectDocCategory(claims.TenantId, uint64(id), proj.ProjectName, 0); err == nil {
|
||||||
|
proj.DocCategoryID = cid
|
||||||
|
}
|
||||||
crmWriteLog(tenantID, 3, uint64(id), "create", "创建项目:"+proj.ProjectName, claims)
|
crmWriteLog(tenantID, 3, uint64(id), "create", "创建项目:"+proj.ProjectName, claims)
|
||||||
pipelineOk(&c.Controller, map[string]interface{}{"id": id})
|
pipelineOk(&c.Controller, map[string]interface{}{"id": id, "doc_category_id": proj.DocCategoryID})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update PUT /backend/crm/project/:id
|
// Update PUT /backend/crm/project/:id
|
||||||
@@ -242,6 +259,10 @@ func (c *BackendCrmProjectController) Update() {
|
|||||||
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 项目改名时同步重命名文档库中的项目文件夹(失败静默,不影响项目更新)
|
||||||
|
if proj.DocCategoryID > 0 {
|
||||||
|
_ = services.RenameCrmProjectDocCategory(claims.TenantId, proj.DocCategoryID, proj.ProjectName)
|
||||||
|
}
|
||||||
crmWriteLog(tenantID, 3, proj.ID, "update", "更新项目:"+proj.ProjectName, claims)
|
crmWriteLog(tenantID, 3, proj.ID, "update", "更新项目:"+proj.ProjectName, claims)
|
||||||
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID})
|
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID})
|
||||||
}
|
}
|
||||||
@@ -280,3 +301,341 @@ func (c *BackendCrmProjectController) Delete() {
|
|||||||
}
|
}
|
||||||
pipelineOk(&c.Controller, nil)
|
pipelineOk(&c.Controller, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================== 项目文档(OA 文档库绑定) ==========================
|
||||||
|
|
||||||
|
// projectDocItem 项目文档条目:文档库文档 + 所在文件夹名称。
|
||||||
|
type projectDocItem struct {
|
||||||
|
models.OaDoc
|
||||||
|
FolderName string `json:"folder_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectDocContext 解析项目并确保其在文档库「共享文档 / 项目文档」下的同名文件夹存在。
|
||||||
|
// 存量项目(doc_category_id=0)在首次访问项目文档时自动补建,保证历史项目也能在文档库中看到。
|
||||||
|
// 失败时已写出错误响应,返回 ok=false。
|
||||||
|
func (c *BackendCrmProjectController) projectDocContext(claims *jwtutil.Claims) (*models.TenantCrmProject, uint64, bool) {
|
||||||
|
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||||
|
if id == 0 {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
var proj models.TenantCrmProject
|
||||||
|
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||||
|
Filter("id", id).Filter("tenant_id", pipelineTenantID(claims)).
|
||||||
|
Filter("delete_time__isnull", true).One(&proj); err != nil {
|
||||||
|
pipelineErr(&c.Controller, 404, 404, "项目未找到")
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
cid, err := services.EnsureCrmProjectDocCategory(claims.TenantId, proj.ID, proj.ProjectName, proj.DocCategoryID)
|
||||||
|
if err != nil || cid == 0 {
|
||||||
|
pipelineErr(&c.Controller, 500, 500, "项目文档文件夹准备失败")
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
proj.DocCategoryID = cid
|
||||||
|
return &proj, cid, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocList GET /backend/crm/project/:id/docs
|
||||||
|
// 项目文档列表:读取文档库中该项目文件夹(含子文件夹)下的全部文档,与文档库内容实时一致。
|
||||||
|
func (c *BackendCrmProjectController) DocList() {
|
||||||
|
claims, err := pipelineClaims(&c.Controller)
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proj, cid, ok := c.projectDocContext(claims)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, _ := c.GetInt("page", 1)
|
||||||
|
pageSize, _ := c.GetInt("pageSize", 20)
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 200 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
actor := services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
|
||||||
|
// 可选按文件夹筛选(含其子文件夹);不属于项目文件夹的参数回落到项目根,避免越权查询
|
||||||
|
filterCategory := cid
|
||||||
|
if v, _ := c.GetUint64("category_id"); v > 0 && services.CrmProjectDocCategoryInScope(actor, cid, v) {
|
||||||
|
filterCategory = v
|
||||||
|
}
|
||||||
|
list, total, err := services.OaDocList(actor, services.OaDocListParams{
|
||||||
|
Actor: actor,
|
||||||
|
Scope: models.DocScopeShared,
|
||||||
|
Keyword: strings.TrimSpace(c.GetString("keyword")),
|
||||||
|
CategoryID: filterCategory,
|
||||||
|
Status: -1,
|
||||||
|
DocType: -1,
|
||||||
|
Star: -1,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 附带所在文件夹名称(项目文件夹本身或其中子分类)
|
||||||
|
nameMap := services.CrmProjectDocCategoryNameMap(claims.TenantId)
|
||||||
|
items := make([]projectDocItem, 0, len(list))
|
||||||
|
for _, d := range list {
|
||||||
|
folder := nameMap[d.CategoryID]
|
||||||
|
if folder == "" {
|
||||||
|
folder = proj.ProjectName
|
||||||
|
}
|
||||||
|
items = append(items, projectDocItem{OaDoc: d, FolderName: folder})
|
||||||
|
}
|
||||||
|
// 项目文件夹树(根为项目文件夹),供前端选择/新建子文件夹
|
||||||
|
folders, err := services.CrmProjectDocFolderTree(actor, cid)
|
||||||
|
if err != nil {
|
||||||
|
folders = []*services.ProjectDocFolder{}
|
||||||
|
}
|
||||||
|
pipelineOk(&c.Controller, map[string]interface{}{
|
||||||
|
"category_id": cid,
|
||||||
|
"category_name": proj.ProjectName,
|
||||||
|
"folders": folders,
|
||||||
|
"list": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"pageSize": pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocUpload POST /backend/crm/project/:id/doc-upload
|
||||||
|
// 上传项目文档:文件先经 POST /backend/uploadfile 上传,再调用本接口把元数据写入文档库项目文件夹,
|
||||||
|
// 同时同步登记一条 CRM 附件,保证项目「附件」链路同样可见。
|
||||||
|
func (c *BackendCrmProjectController) DocUpload() {
|
||||||
|
claims, err := pipelineClaims(&c.Controller)
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proj, cid, ok := c.projectDocContext(claims)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
FileID uint64 `json:"file_id"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileURL string `json:"file_url"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
Ext string `json:"ext"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
CategoryID uint64 `json:"category_id"` // 目标文件夹,0=项目根文件夹
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileName := strings.TrimSpace(p.FileName)
|
||||||
|
fileURL := strings.TrimSpace(p.FileURL)
|
||||||
|
if fileName == "" || fileURL == "" {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "文件信息不完整")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(p.Ext), "."))
|
||||||
|
if ext == "" {
|
||||||
|
ext = strings.ToLower(strings.TrimPrefix(filepath.Ext(fileName), "."))
|
||||||
|
}
|
||||||
|
var size uint64
|
||||||
|
if p.FileSize > 0 {
|
||||||
|
size = uint64(p.FileSize)
|
||||||
|
}
|
||||||
|
operatorName := resolveUserName(claims)
|
||||||
|
actor := services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
|
||||||
|
// 目标文件夹:默认项目根文件夹;指定时必须在项目文件夹内
|
||||||
|
targetCategory := cid
|
||||||
|
if p.CategoryID > 0 {
|
||||||
|
if !services.CrmProjectDocCategoryInScope(actor, cid, p.CategoryID) {
|
||||||
|
pipelineErr(&c.Controller, 403, 403, "只能上传到项目自己的文件夹内")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetCategory = p.CategoryID
|
||||||
|
}
|
||||||
|
doc, err := services.OaDocCreate(actor, operatorName, services.OaDocSaveParams{
|
||||||
|
SetCategory: true,
|
||||||
|
CategoryID: targetCategory,
|
||||||
|
SetVisibility: true,
|
||||||
|
Visibility: models.DocVisibilityPublic,
|
||||||
|
Title: strings.TrimSpace(p.Title),
|
||||||
|
FileID: p.FileID,
|
||||||
|
FileURL: fileURL,
|
||||||
|
FileName: fileName,
|
||||||
|
Ext: ext,
|
||||||
|
Size: size,
|
||||||
|
Summary: strings.TrimSpace(p.Summary),
|
||||||
|
Status: models.DocStatusPublish,
|
||||||
|
OwnerID: uint64(claims.UserID),
|
||||||
|
OwnerName: operatorName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "同步文档库失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 同步登记 CRM 附件(失败不影响文档库结果)
|
||||||
|
now := time.Now()
|
||||||
|
var fileIDPtr *uint64
|
||||||
|
if doc.FileID > 0 {
|
||||||
|
fid := doc.FileID
|
||||||
|
fileIDPtr = &fid
|
||||||
|
}
|
||||||
|
_, _ = models.Orm.Insert(&models.TenantCrmAttach{
|
||||||
|
TenantID: pipelineTenantID(claims),
|
||||||
|
RelatedType: 3,
|
||||||
|
RelatedID: proj.ID,
|
||||||
|
FileID: fileIDPtr,
|
||||||
|
FileName: doc.FileName,
|
||||||
|
FileURL: doc.FileURL,
|
||||||
|
FileSize: int64(doc.Size),
|
||||||
|
UploaderID: pipelineUID(claims),
|
||||||
|
UploaderName: operatorName,
|
||||||
|
CreateTime: now,
|
||||||
|
UpdateTime: now,
|
||||||
|
})
|
||||||
|
crmWriteLog(pipelineTenantID(claims), 3, proj.ID, "doc", "上传项目文档:"+doc.Title, claims)
|
||||||
|
pipelineOk(&c.Controller, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocDelete POST /backend/crm/project/:id/doc-delete
|
||||||
|
// 删除项目文档:同步软删文档库文档与对应的 CRM 附件记录。
|
||||||
|
func (c *BackendCrmProjectController) DocDelete() {
|
||||||
|
claims, err := pipelineClaims(&c.Controller)
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proj, cid, ok := c.projectDocContext(claims)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
DocID uint64 `json:"doc_id"`
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil || p.DocID == 0 {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
|
||||||
|
doc, err := services.OaDocGet(actor, p.DocID)
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 404, 404, "文档不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 仅允许删除本项目文件夹(含子文件夹)内的文档
|
||||||
|
if !services.CrmProjectDocCategoryInScope(actor, cid, doc.CategoryID) {
|
||||||
|
pipelineErr(&c.Controller, 403, 403, "该文档不属于当前项目")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := services.OaDocDelete(actor, []uint64{doc.ID}); err != nil {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 同步软删 CRM 附件记录(按文件ID匹配,缺失时按文件名+地址匹配)
|
||||||
|
now := time.Now()
|
||||||
|
q := models.Orm.QueryTable(new(models.TenantCrmAttach)).
|
||||||
|
Filter("tenant_id", pipelineTenantID(claims)).
|
||||||
|
Filter("related_type", 3).
|
||||||
|
Filter("related_id", proj.ID).
|
||||||
|
Filter("delete_time__isnull", true)
|
||||||
|
if doc.FileID > 0 {
|
||||||
|
q = q.Filter("file_id", doc.FileID)
|
||||||
|
} else {
|
||||||
|
q = q.Filter("file_name", doc.FileName).Filter("file_url", doc.FileURL)
|
||||||
|
}
|
||||||
|
_, _ = q.Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||||
|
crmWriteLog(pipelineTenantID(claims), 3, proj.ID, "doc", "删除项目文档:"+doc.Title, claims)
|
||||||
|
pipelineOk(&c.Controller, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocFolderCreate POST /backend/crm/project/:id/doc-folder
|
||||||
|
// 在项目文件夹下新建子文件夹(对应文档库中的文档分类),用于分类存放项目文档。
|
||||||
|
// parent_id 缺省时建在项目根文件夹下;只允许在项目自己的文件夹树内创建。
|
||||||
|
func (c *BackendCrmProjectController) DocFolderCreate() {
|
||||||
|
claims, err := pipelineClaims(&c.Controller)
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proj, cid, ok := c.projectDocContext(claims)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ParentID uint64 `json:"parent_id"`
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(p.Name)
|
||||||
|
if name == "" {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "请输入文件夹名称")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parentID := p.ParentID
|
||||||
|
if parentID == 0 {
|
||||||
|
parentID = cid
|
||||||
|
}
|
||||||
|
actor := services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
|
||||||
|
if !services.CrmProjectDocCategoryInScope(actor, cid, parentID) {
|
||||||
|
pipelineErr(&c.Controller, 403, 403, "只能在项目文件夹内创建子文件夹")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := services.OaDocCategoryCreate(actor, models.DocScopeShared, parentID, name, "项目文档:"+proj.ProjectName, 0)
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
crmWriteLog(pipelineTenantID(claims), 3, proj.ID, "doc-folder", "新建项目文档文件夹:"+item.Name, claims)
|
||||||
|
pipelineOk(&c.Controller, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocFolderDelete POST /backend/crm/project/:id/doc-folder-delete
|
||||||
|
// 删除项目内的子文件夹(需为空:无子文件夹、无文档);项目根文件夹不可删除。
|
||||||
|
func (c *BackendCrmProjectController) DocFolderDelete() {
|
||||||
|
claims, err := pipelineClaims(&c.Controller)
|
||||||
|
if err != nil {
|
||||||
|
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proj, cid, ok := c.projectDocContext(claims)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
FolderID uint64 `json:"folder_id"`
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil || p.FolderID == 0 {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.FolderID == cid {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, "项目根文件夹不可删除")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
|
||||||
|
if !services.CrmProjectDocCategoryInScope(actor, cid, p.FolderID) {
|
||||||
|
pipelineErr(&c.Controller, 403, 403, "该文件夹不属于当前项目")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var folder models.OaDocCategory
|
||||||
|
if err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||||
|
Filter("tid", claims.TenantId).Filter("id", p.FolderID).
|
||||||
|
Filter("is_deleted", 0).One(&folder); err != nil {
|
||||||
|
pipelineErr(&c.Controller, 404, 404, "文件夹不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := services.OaDocCategoryDelete(actor, p.FolderID); err != nil {
|
||||||
|
pipelineErr(&c.Controller, 400, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
crmWriteLog(pipelineTenantID(claims), 3, proj.ID, "doc-folder", "删除项目文档文件夹:"+folder.Name, claims)
|
||||||
|
pipelineOk(&c.Controller, nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -39,6 +41,39 @@ func (c *BackendCrmFollowController) List() {
|
|||||||
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
||||||
followType := strings.TrimSpace(c.GetString("follow_type"))
|
followType := strings.TrimSpace(c.GetString("follow_type"))
|
||||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
includeSource := strings.TrimSpace(c.GetString("include_source")) == "1"
|
||||||
|
|
||||||
|
// 详情页链路模式(include_source=1):按 线索→商机→项目 累积返回上游 + 自身的数据
|
||||||
|
if includeSource && relatedType != "" && relatedID != "" && relatedID != "0" {
|
||||||
|
rid, _ := strconv.ParseUint(relatedID, 10, 64)
|
||||||
|
rt, _ := strconv.Atoi(relatedType)
|
||||||
|
refs := relatedChain(tenantID, int8(rt), rid)
|
||||||
|
var merged []models.TenantCrmFollow
|
||||||
|
for _, r := range refs {
|
||||||
|
var rows []models.TenantCrmFollow
|
||||||
|
sub := orm.NewCondition().
|
||||||
|
And("tenant_id", tenantID).
|
||||||
|
And("delete_time__isnull", true).
|
||||||
|
And("related_type", r.Type).
|
||||||
|
And("related_id", r.ID)
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.TenantCrmFollow)).SetCond(sub).All(&rows)
|
||||||
|
merged = append(merged, rows...)
|
||||||
|
}
|
||||||
|
sort.Slice(merged, func(i, j int) bool {
|
||||||
|
ti, tj := merged[i].FollowTime, merged[j].FollowTime
|
||||||
|
if ti != nil && tj != nil && !ti.Equal(*tj) {
|
||||||
|
return ti.After(*tj)
|
||||||
|
}
|
||||||
|
if (ti == nil) != (tj == nil) {
|
||||||
|
return ti != nil
|
||||||
|
}
|
||||||
|
return merged[i].ID > merged[j].ID
|
||||||
|
})
|
||||||
|
pipelineOk(&c.Controller, map[string]interface{}{
|
||||||
|
"list": merged, "total": len(merged), "page": 1, "pageSize": len(merged),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||||
if relatedType != "" {
|
if relatedType != "" {
|
||||||
@@ -72,6 +107,7 @@ type followPayload struct {
|
|||||||
RelatedType int8 `json:"related_type"`
|
RelatedType int8 `json:"related_type"`
|
||||||
RelatedID uint64 `json:"related_id"`
|
RelatedID uint64 `json:"related_id"`
|
||||||
RelatedName string `json:"related_name"`
|
RelatedName string `json:"related_name"`
|
||||||
|
Stage int8 `json:"stage"` // 创建阶段:不传时默认取 related_type
|
||||||
FollowType string `json:"follow_type"`
|
FollowType string `json:"follow_type"`
|
||||||
FollowTime string `json:"follow_time"`
|
FollowTime string `json:"follow_time"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
@@ -106,17 +142,23 @@ func (c *BackendCrmFollowController) Add() {
|
|||||||
followTime = &t
|
followTime = &t
|
||||||
}
|
}
|
||||||
nextContact := parsePipelineDateTime(p.NextContactTime)
|
nextContact := parsePipelineDateTime(p.NextContactTime)
|
||||||
|
// 创建阶段:默认取当前关联类型(线索/商机/项目)
|
||||||
|
stage := p.Stage
|
||||||
|
if stage != 1 && stage != 2 && stage != 3 {
|
||||||
|
stage = p.RelatedType
|
||||||
|
}
|
||||||
row := models.TenantCrmFollow{
|
row := models.TenantCrmFollow{
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
RelatedType: p.RelatedType,
|
RelatedType: p.RelatedType,
|
||||||
RelatedID: p.RelatedID,
|
RelatedID: p.RelatedID,
|
||||||
RelatedName: strings.TrimSpace(p.RelatedName),
|
RelatedName: strings.TrimSpace(p.RelatedName),
|
||||||
|
Stage: stage,
|
||||||
FollowType: firstNonEmpty(p.FollowType, "1"),
|
FollowType: firstNonEmpty(p.FollowType, "1"),
|
||||||
FollowTime: followTime,
|
FollowTime: followTime,
|
||||||
Content: p.Content,
|
Content: p.Content,
|
||||||
NextContactTime: nextContact,
|
NextContactTime: nextContact,
|
||||||
OwnerUserID: pipelineUID(claims),
|
OwnerUserID: pipelineUID(claims),
|
||||||
OwnerUserName: claims.Username,
|
OwnerUserName: resolveUserName(claims),
|
||||||
CreateTime: time.Now(),
|
CreateTime: time.Now(),
|
||||||
UpdateTime: time.Now(),
|
UpdateTime: time.Now(),
|
||||||
}
|
}
|
||||||
@@ -159,6 +201,11 @@ func (c *BackendCrmFollowController) Edit() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
row.FollowType = firstNonEmpty(p.FollowType, row.FollowType)
|
row.FollowType = firstNonEmpty(p.FollowType, row.FollowType)
|
||||||
|
// 回访阶段(所属阶段)允许修正:仅在传入合法阶段(1线索/2商机/3项目)时更新,
|
||||||
|
// 保证"记录创建时所在阶段"可被纠正为沟通实际发生的阶段。
|
||||||
|
if p.Stage == 1 || p.Stage == 2 || p.Stage == 3 {
|
||||||
|
row.Stage = p.Stage
|
||||||
|
}
|
||||||
if t := parsePipelineDateTime(p.FollowTime); t != nil {
|
if t := parsePipelineDateTime(p.FollowTime); t != nil {
|
||||||
row.FollowTime = t
|
row.FollowTime = t
|
||||||
}
|
}
|
||||||
@@ -237,17 +284,35 @@ func (c *BackendCrmAttachController) List() {
|
|||||||
}
|
}
|
||||||
relatedType := strings.TrimSpace(c.GetString("related_type"))
|
relatedType := strings.TrimSpace(c.GetString("related_type"))
|
||||||
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
||||||
cond := orm.NewCondition().
|
includeSource := strings.TrimSpace(c.GetString("include_source")) == "1"
|
||||||
And("tenant_id", pipelineTenantID(claims)).
|
tenantID := pipelineTenantID(claims)
|
||||||
And("delete_time__isnull", true)
|
|
||||||
if relatedType != "" {
|
|
||||||
cond = cond.And("related_type", relatedType)
|
|
||||||
}
|
|
||||||
if relatedID != "" {
|
|
||||||
cond = cond.And("related_id", relatedID)
|
|
||||||
}
|
|
||||||
var list []models.TenantCrmAttach
|
var list []models.TenantCrmAttach
|
||||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(cond).OrderBy("-id").All(&list)
|
if includeSource && relatedType != "" && relatedID != "" && relatedID != "0" {
|
||||||
|
// 链路模式:线索 → 商机 → 项目 累积
|
||||||
|
rid, _ := strconv.ParseUint(relatedID, 10, 64)
|
||||||
|
rt, _ := strconv.Atoi(relatedType)
|
||||||
|
for _, r := range relatedChain(tenantID, int8(rt), rid) {
|
||||||
|
var rows []models.TenantCrmAttach
|
||||||
|
sub := orm.NewCondition().
|
||||||
|
And("tenant_id", tenantID).
|
||||||
|
And("delete_time__isnull", true).
|
||||||
|
And("related_type", r.Type).
|
||||||
|
And("related_id", r.ID)
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(sub).All(&rows)
|
||||||
|
list = append(list, rows...)
|
||||||
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool { return list[i].ID > list[j].ID })
|
||||||
|
} else {
|
||||||
|
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||||
|
if relatedType != "" {
|
||||||
|
cond = cond.And("related_type", relatedType)
|
||||||
|
}
|
||||||
|
if relatedID != "" {
|
||||||
|
cond = cond.And("related_id", relatedID)
|
||||||
|
}
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.TenantCrmAttach)).SetCond(cond).OrderBy("-id").All(&list)
|
||||||
|
}
|
||||||
pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
|
pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +355,7 @@ func (c *BackendCrmAttachController) Add() {
|
|||||||
FileURL: strings.TrimSpace(p.FileURL),
|
FileURL: strings.TrimSpace(p.FileURL),
|
||||||
FileSize: p.FileSize,
|
FileSize: p.FileSize,
|
||||||
UploaderID: pipelineUID(claims),
|
UploaderID: pipelineUID(claims),
|
||||||
UploaderName: claims.Username,
|
UploaderName: resolveUserName(claims),
|
||||||
CreateTime: time.Now(),
|
CreateTime: time.Now(),
|
||||||
UpdateTime: time.Now(),
|
UpdateTime: time.Now(),
|
||||||
}
|
}
|
||||||
@@ -350,18 +415,41 @@ func (c *BackendCrmEntityContactController) List() {
|
|||||||
}
|
}
|
||||||
relatedType := strings.TrimSpace(c.GetString("related_type"))
|
relatedType := strings.TrimSpace(c.GetString("related_type"))
|
||||||
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
relatedID := strings.TrimSpace(c.GetString("related_id"))
|
||||||
cond := orm.NewCondition().
|
includeSource := strings.TrimSpace(c.GetString("include_source")) == "1"
|
||||||
And("tenant_id", pipelineTenantID(claims)).
|
tenantID := pipelineTenantID(claims)
|
||||||
And("delete_time__isnull", true)
|
|
||||||
if relatedType != "" {
|
|
||||||
cond = cond.And("related_type", relatedType)
|
|
||||||
}
|
|
||||||
if relatedID != "" {
|
|
||||||
cond = cond.And("related_id", relatedID)
|
|
||||||
}
|
|
||||||
var list []models.TenantCrmEntityContact
|
var list []models.TenantCrmEntityContact
|
||||||
_, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).SetCond(cond).
|
if includeSource && relatedType != "" && relatedID != "" && relatedID != "0" {
|
||||||
OrderBy("-is_primary", "-id").All(&list)
|
// 链路模式:线索 → 商机 → 项目 累积
|
||||||
|
rid, _ := strconv.ParseUint(relatedID, 10, 64)
|
||||||
|
rt, _ := strconv.Atoi(relatedType)
|
||||||
|
for _, r := range relatedChain(tenantID, int8(rt), rid) {
|
||||||
|
var rows []models.TenantCrmEntityContact
|
||||||
|
sub := orm.NewCondition().
|
||||||
|
And("tenant_id", tenantID).
|
||||||
|
And("delete_time__isnull", true).
|
||||||
|
And("related_type", r.Type).
|
||||||
|
And("related_id", r.ID)
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).SetCond(sub).All(&rows)
|
||||||
|
list = append(list, rows...)
|
||||||
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
if list[i].IsPrimary != list[j].IsPrimary {
|
||||||
|
return list[i].IsPrimary > list[j].IsPrimary
|
||||||
|
}
|
||||||
|
return list[i].ID > list[j].ID
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
cond := orm.NewCondition().And("tenant_id", tenantID).And("delete_time__isnull", true)
|
||||||
|
if relatedType != "" {
|
||||||
|
cond = cond.And("related_type", relatedType)
|
||||||
|
}
|
||||||
|
if relatedID != "" {
|
||||||
|
cond = cond.And("related_id", relatedID)
|
||||||
|
}
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.TenantCrmEntityContact)).SetCond(cond).
|
||||||
|
OrderBy("-is_primary", "-id").All(&list)
|
||||||
|
}
|
||||||
pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
|
pipelineOk(&c.Controller, map[string]interface{}{"list": list, "total": len(list)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ func (c *BackendErpCustomerController) Create() {
|
|||||||
RegisteredAddress: strings.TrimSpace(p.RegisteredAddress),
|
RegisteredAddress: strings.TrimSpace(p.RegisteredAddress),
|
||||||
RegisteredPhone: strings.TrimSpace(p.RegisteredPhone),
|
RegisteredPhone: strings.TrimSpace(p.RegisteredPhone),
|
||||||
OwnerUserID: fmt.Sprintf("%d", claims.UserID),
|
OwnerUserID: fmt.Sprintf("%d", claims.UserID),
|
||||||
OwnerUserName: claims.Username,
|
OwnerUserName: resolveUserName(claims),
|
||||||
CreateUserID: fmt.Sprintf("%d", claims.UserID),
|
CreateUserID: fmt.Sprintf("%d", claims.UserID),
|
||||||
CreateTime: time.Now(),
|
CreateTime: time.Now(),
|
||||||
UpdateTime: time.Now(),
|
UpdateTime: time.Now(),
|
||||||
|
|||||||
@@ -141,6 +141,15 @@ func Init(_ string) {
|
|||||||
EnsureTenantUserOrgColumn()
|
EnsureTenantUserOrgColumn()
|
||||||
EnsureCrmCustomerPoolColumns()
|
EnsureCrmCustomerPoolColumns()
|
||||||
EnsureCrmCreateUserColumn()
|
EnsureCrmCreateUserColumn()
|
||||||
|
EnsureCrmProjectDocColumn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureCrmProjectDocColumn 补齐项目表的文档库文件夹分类字段(项目文档绑定 OA 文档库)。
|
||||||
|
// 表不存在或列已存在时忽略错误。
|
||||||
|
func EnsureCrmProjectDocColumn() {
|
||||||
|
sql := "ALTER TABLE " + new(TenantCrmProject).TableName() +
|
||||||
|
" ADD COLUMN doc_category_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '关联OA文档库项目文件夹分类ID'"
|
||||||
|
_, _ = Orm.Raw(sql).Exec()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// TenantCrmAttach 附件表: yz_tenant_crm_attach
|
// TenantCrmAttach 附件表: yz_backend_crm_attach
|
||||||
// related_type: 1线索 / 2商机 / 3项目
|
// related_type: 1线索 / 2商机 / 3项目
|
||||||
type TenantCrmAttach struct {
|
type TenantCrmAttach struct {
|
||||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
@@ -21,5 +21,5 @@ type TenantCrmAttach struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *TenantCrmAttach) TableName() string {
|
func (m *TenantCrmAttach) TableName() string {
|
||||||
return "yz_tenant_crm_attach"
|
return "yz_backend_crm_attach"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// TenantCrmBusiness 商机表: yz_tenant_crm_business
|
// TenantCrmBusiness 商机表: yz_backend_crm_business
|
||||||
type TenantCrmBusiness struct {
|
type TenantCrmBusiness struct {
|
||||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||||
BusinessName string `orm:"column(business_name);size(100)" json:"business_name"` // 商机名称
|
BusinessName string `orm:"column(business_name);size(100)" json:"business_name"` // 商机名称
|
||||||
ClueID *uint64 `orm:"column(clue_id);null" json:"clue_id"` // 来源线索ID
|
ClueID *uint64 `orm:"column(clue_id);null" json:"clue_id"` // 来源线索ID
|
||||||
|
ClueName string `orm:"-" json:"clue_name"` // 来源线索名称(非表字段,查询时填充)
|
||||||
CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"` // 正式客户ID
|
CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"` // 正式客户ID
|
||||||
CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
|
CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
|
||||||
Source string `orm:"column(source);size(50)" json:"source"`
|
Source string `orm:"column(source);size(50)" json:"source"`
|
||||||
@@ -37,5 +38,5 @@ type TenantCrmBusiness struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *TenantCrmBusiness) TableName() string {
|
func (m *TenantCrmBusiness) TableName() string {
|
||||||
return "yz_tenant_crm_business"
|
return "yz_backend_crm_business"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// TenantCrmClue 线索表: yz_tenant_crm_clue
|
// TenantCrmClue 线索表: yz_backend_crm_clue
|
||||||
// 线索阶段的「客户名称」不关联客户管理,转化商机时才落地为正式客户。
|
// 线索阶段的「客户名称」不关联客户管理,转化商机时才落地为正式客户。
|
||||||
type TenantCrmClue struct {
|
type TenantCrmClue struct {
|
||||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
@@ -33,5 +33,5 @@ type TenantCrmClue struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *TenantCrmClue) TableName() string {
|
func (m *TenantCrmClue) TableName() string {
|
||||||
return "yz_tenant_crm_clue"
|
return "yz_backend_crm_clue"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// TenantCrmEntityContact 联系人表: yz_tenant_crm_entity_contact
|
// TenantCrmEntityContact 联系人表: yz_backend_crm_entity_contact
|
||||||
// related_type: 1线索 / 2商机 / 3项目
|
// related_type: 1线索 / 2商机 / 3项目
|
||||||
type TenantCrmEntityContact struct {
|
type TenantCrmEntityContact struct {
|
||||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
@@ -23,5 +23,5 @@ type TenantCrmEntityContact struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *TenantCrmEntityContact) TableName() string {
|
func (m *TenantCrmEntityContact) TableName() string {
|
||||||
return "yz_tenant_crm_entity_contact"
|
return "yz_backend_crm_entity_contact"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// TenantCrmFollow 回访/跟进记录表: yz_tenant_crm_follow
|
// TenantCrmFollow 回访/跟进记录表: yz_backend_crm_follow
|
||||||
// related_type: 1线索 / 2商机 / 3项目
|
// related_type: 1线索 / 2商机 / 3项目
|
||||||
type TenantCrmFollow struct {
|
type TenantCrmFollow struct {
|
||||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
@@ -10,6 +10,7 @@ type TenantCrmFollow struct {
|
|||||||
RelatedType int8 `orm:"column(related_type)" json:"related_type"`
|
RelatedType int8 `orm:"column(related_type)" json:"related_type"`
|
||||||
RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
|
RelatedID uint64 `orm:"column(related_id)" json:"related_id"`
|
||||||
RelatedName string `orm:"column(related_name);size(100)" json:"related_name"`
|
RelatedName string `orm:"column(related_name);size(100)" json:"related_name"`
|
||||||
|
Stage int8 `orm:"column(stage);default(1)" json:"stage"` // 创建阶段:1线索/2商机/3项目
|
||||||
FollowType string `orm:"column(follow_type);size(20);default(1)" json:"follow_type"` // 回访方式
|
FollowType string `orm:"column(follow_type);size(20);default(1)" json:"follow_type"` // 回访方式
|
||||||
FollowTime *time.Time `orm:"column(follow_time);type(datetime);null" json:"follow_time"`
|
FollowTime *time.Time `orm:"column(follow_time);type(datetime);null" json:"follow_time"`
|
||||||
Content string `orm:"column(content);type(text);null" json:"content"`
|
Content string `orm:"column(content);type(text);null" json:"content"`
|
||||||
@@ -22,5 +23,5 @@ type TenantCrmFollow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *TenantCrmFollow) TableName() string {
|
func (m *TenantCrmFollow) TableName() string {
|
||||||
return "yz_tenant_crm_follow"
|
return "yz_backend_crm_follow"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// TenantCrmOperateLog 操作日志表: yz_tenant_crm_operate_log
|
// TenantCrmOperateLog 操作日志表: yz_backend_crm_operate_log
|
||||||
// related_type: 1线索 / 2商机 / 3项目
|
// related_type: 1线索 / 2商机 / 3项目
|
||||||
type TenantCrmOperateLog struct {
|
type TenantCrmOperateLog struct {
|
||||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
@@ -17,5 +17,5 @@ type TenantCrmOperateLog struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *TenantCrmOperateLog) TableName() string {
|
func (m *TenantCrmOperateLog) TableName() string {
|
||||||
return "yz_tenant_crm_operate_log"
|
return "yz_backend_crm_operate_log"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// TenantCrmProject 项目表: yz_tenant_crm_project
|
// TenantCrmProject 项目表: yz_backend_crm_project
|
||||||
type TenantCrmProject struct {
|
type TenantCrmProject struct {
|
||||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
|
||||||
ProjectName string `orm:"column(project_name);size(100)" json:"project_name"` // 项目名称
|
ProjectName string `orm:"column(project_name);size(100)" json:"project_name"` // 项目名称
|
||||||
ProjectNo string `orm:"column(project_no);size(50)" json:"project_no"` // 项目编号
|
ProjectNo string `orm:"column(project_no);size(50)" json:"project_no"` // 项目编号
|
||||||
BusinessID *uint64 `orm:"column(business_id);null" json:"business_id"` // 来源商机ID
|
BusinessID *uint64 `orm:"column(business_id);null" json:"business_id"` // 来源商机ID
|
||||||
|
BusinessName string `orm:"-" json:"business_name"` // 来源商机名称(非表字段,查询时填充)
|
||||||
CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"`
|
CustomerID *uint64 `orm:"column(customer_id);null" json:"customer_id"`
|
||||||
CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
|
CustomerName string `orm:"column(customer_name);size(100)" json:"customer_name"`
|
||||||
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
|
OwnerUserID string `orm:"column(owner_user_id);size(64)" json:"owner_user_id"`
|
||||||
@@ -23,6 +24,9 @@ type TenantCrmProject struct {
|
|||||||
ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"`
|
ContactPhone string `orm:"column(contact_phone);size(20)" json:"contact_phone"`
|
||||||
Address string `orm:"column(address);size(255)" json:"address"`
|
Address string `orm:"column(address);size(255)" json:"address"`
|
||||||
Remark string `orm:"column(remark);type(text);null" json:"remark"`
|
Remark string `orm:"column(remark);type(text);null" json:"remark"`
|
||||||
|
// DocCategoryID 项目在 OA 文档库的文件夹分类ID:
|
||||||
|
// 指向 yz_backend_oa_doc_category 中「共享文档 / 项目文档 / {项目名称}」这条记录(0 表示尚未建立)。
|
||||||
|
DocCategoryID uint64 `orm:"column(doc_category_id);default(0)" json:"doc_category_id"`
|
||||||
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
|
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
|
||||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||||
@@ -30,5 +34,5 @@ type TenantCrmProject struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *TenantCrmProject) TableName() string {
|
func (m *TenantCrmProject) TableName() string {
|
||||||
return "yz_tenant_crm_project"
|
return "yz_backend_crm_project"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,6 +401,13 @@ func registerOrganizationRoutes(module string) {
|
|||||||
beego.Router("/backend/crm/project", &controllers.BackendCrmProjectController{}, "post:Create")
|
beego.Router("/backend/crm/project", &controllers.BackendCrmProjectController{}, "post:Create")
|
||||||
beego.Router("/backend/crm/project/:id", &controllers.BackendCrmProjectController{}, "get:Detail;put:Update;delete:Delete")
|
beego.Router("/backend/crm/project/:id", &controllers.BackendCrmProjectController{}, "get:Detail;put:Update;delete:Delete")
|
||||||
|
|
||||||
|
// CRM项目文档(与OA文档库「共享文档/项目文档」绑定:列表/上传同步/删除同步/文件夹管理)
|
||||||
|
beego.Router("/backend/crm/project/:id/docs", &controllers.BackendCrmProjectController{}, "get:DocList")
|
||||||
|
beego.Router("/backend/crm/project/:id/doc-upload", &controllers.BackendCrmProjectController{}, "post:DocUpload")
|
||||||
|
beego.Router("/backend/crm/project/:id/doc-delete", &controllers.BackendCrmProjectController{}, "post:DocDelete")
|
||||||
|
beego.Router("/backend/crm/project/:id/doc-folder", &controllers.BackendCrmProjectController{}, "post:DocFolderCreate")
|
||||||
|
beego.Router("/backend/crm/project/:id/doc-folder-delete", &controllers.BackendCrmProjectController{}, "post:DocFolderDelete")
|
||||||
|
|
||||||
// CRM回访记录(贯穿线索/商机/项目)
|
// CRM回访记录(贯穿线索/商机/项目)
|
||||||
beego.Router("/backend/crm/follow/list", &controllers.BackendCrmFollowController{}, "get:List")
|
beego.Router("/backend/crm/follow/list", &controllers.BackendCrmFollowController{}, "get:List")
|
||||||
beego.Router("/backend/crm/follow/add", &controllers.BackendCrmFollowController{}, "post:Add")
|
beego.Router("/backend/crm/follow/add", &controllers.BackendCrmFollowController{}, "post:Add")
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 项目文档(CRM 项目 ↔ OA 文档库)绑定业务逻辑。
|
||||||
|
//
|
||||||
|
// 约定:每个项目在文档库「共享文档 / 项目文档」下拥有一个与项目同名的文件夹,
|
||||||
|
// 项目管理侧负责调用与文件上传,文件统一写入文档库(yz_backend_oa_doc),
|
||||||
|
// 文档库侧负责浏览、图谱与共享。项目表用 doc_category_id 记住自己文件夹的分类ID,
|
||||||
|
// 项目改名时同步重命名文件夹;存量项目在首次访问项目文档时自动补建。
|
||||||
|
|
||||||
|
// EnsureCrmProjectDocCategory 确保项目在文档库「共享文档 / 项目文档」下拥有同名文件夹,返回文件夹分类ID。
|
||||||
|
//
|
||||||
|
// 兼容存量项目:项目上的分类ID为空(0)或指向已删除分类时自动补建,并回写项目表;
|
||||||
|
// 若「项目文档」下已有同名且未被其它项目占用的文件夹则复用,避免重复建目录。
|
||||||
|
// 失败时返回 error(由调用方决定是否阻断,建议静默忽略,不影响项目主流程)。
|
||||||
|
func EnsureCrmProjectDocCategory(tid int, projectID uint64, projectName string, currentCategoryID uint64) (uint64, error) {
|
||||||
|
if models.Orm == nil || tid <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
projectName = strings.TrimSpace(projectName)
|
||||||
|
if projectName == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
models.EnsureOaDocumentTables()
|
||||||
|
models.EnsureOaDocDefaultCategories(tid)
|
||||||
|
|
||||||
|
// 1. 已绑定且分类仍有效:直接返回
|
||||||
|
if currentCategoryID > 0 {
|
||||||
|
n, err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("id", currentCategoryID).
|
||||||
|
Filter("is_deleted", 0).Count()
|
||||||
|
if err == nil && n > 0 {
|
||||||
|
return currentCategoryID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 定位「项目文档」内置分类(首次访问时已由 EnsureOaDocDefaultCategories 补建)
|
||||||
|
rootID, err := crmProjectDocRootCategory(tid)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 复用「项目文档」下同名且未被其它项目占用的文件夹(同名项目共存时各用各的)
|
||||||
|
var sameName []models.OaDocCategory
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("parent_id", rootID).
|
||||||
|
Filter("scope", models.DocScopeShared).
|
||||||
|
Filter("user_id", 0).
|
||||||
|
Filter("name", projectName).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
OrderBy("id").All(&sameName); err != nil && err != orm.ErrNoRows {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, c := range sameName {
|
||||||
|
bound, err := crmProjectDocCategoryBound(tid, c.ID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !bound {
|
||||||
|
return c.ID, crmProjectDocBindCategory(tid, projectID, c.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 新建项目同名文件夹
|
||||||
|
item := &models.OaDocCategory{
|
||||||
|
Tid: tid,
|
||||||
|
UserID: 0,
|
||||||
|
ParentID: rootID,
|
||||||
|
Name: projectName,
|
||||||
|
Remark: "项目文档:" + projectName,
|
||||||
|
Sort: 0,
|
||||||
|
IsSystem: models.DocCategoryNormal,
|
||||||
|
Scope: models.DocScopeShared,
|
||||||
|
IsDeleted: 0,
|
||||||
|
}
|
||||||
|
id, err := models.Orm.Insert(item)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
categoryID := uint64(id)
|
||||||
|
return categoryID, crmProjectDocBindCategory(tid, projectID, categoryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameCrmProjectDocCategory 项目改名时同步重命名文档库中的项目文件夹(失败由调用方处理)。
|
||||||
|
func RenameCrmProjectDocCategory(tid int, categoryID uint64, newName string) error {
|
||||||
|
newName = strings.TrimSpace(newName)
|
||||||
|
if models.Orm == nil || tid <= 0 || categoryID == 0 || newName == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var item models.OaDocCategory
|
||||||
|
if err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||||
|
Filter("tid", tid).Filter("id", categoryID).
|
||||||
|
Filter("is_deleted", 0).One(&item); err != nil {
|
||||||
|
return ErrDocCategoryNotFound
|
||||||
|
}
|
||||||
|
if item.Name == newName {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
_, err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||||
|
Filter("id", categoryID).Filter("tid", tid).
|
||||||
|
Update(orm.Params{"name": newName, "update_time": now})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrmProjectDocCategoryNameMap 返回租户内未删除分类的 ID -> 名称映射(项目文档列表展示文件夹名用)。
|
||||||
|
func CrmProjectDocCategoryNameMap(tid int) map[uint64]string {
|
||||||
|
result := make(map[uint64]string)
|
||||||
|
if models.Orm == nil || tid <= 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var list []models.OaDocCategory
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||||
|
Filter("tid", tid).Filter("is_deleted", 0).All(&list); err != nil && err != orm.ErrNoRows {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, c := range list {
|
||||||
|
result[c.ID] = c.Name
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrmProjectDocCategoryInScope 判断分类是否位于项目文件夹内(含项目文件夹自身)。
|
||||||
|
// 用于约束项目侧的文件夹创建、文件上传与删除只能在项目自己的目录下操作。
|
||||||
|
func CrmProjectDocCategoryInScope(a OaDocActor, rootID, categoryID uint64) bool {
|
||||||
|
if rootID == 0 || categoryID == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if categoryID == rootID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
children, err := OaDocCategoryDescendants(a, rootID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, id := range children {
|
||||||
|
if id == categoryID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectDocFolder 项目文档文件夹树节点(含该文件夹直接挂载的文档数量)。
|
||||||
|
type ProjectDocFolder struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
ParentID uint64 `json:"parent_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DocCount int64 `json:"doc_count"`
|
||||||
|
Children []*ProjectDocFolder `json:"children"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrmProjectDocFolderTree 构建项目文件夹树(根为项目文件夹,含全部子孙文件夹)。
|
||||||
|
// 「项目文档」下的同名文件夹即项目根,用户在项目内新建的文件夹为其子分类。
|
||||||
|
func CrmProjectDocFolderTree(a OaDocActor, rootID uint64) ([]*ProjectDocFolder, error) {
|
||||||
|
models.EnsureOaDocumentTables()
|
||||||
|
empty := []*ProjectDocFolder{}
|
||||||
|
if rootID == 0 {
|
||||||
|
return empty, nil
|
||||||
|
}
|
||||||
|
var list []models.OaDocCategory
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||||
|
Filter("tid", a.Tid).Filter("is_deleted", 0).OrderBy("id").All(&list); err != nil && err != orm.ErrNoRows {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nodes := make(map[uint64]*ProjectDocFolder, len(list))
|
||||||
|
for i := range list {
|
||||||
|
// 项目文件夹属于共享空间(scope=shared、user_id=0)
|
||||||
|
if list[i].Scope != models.DocScopeShared || list[i].UserID != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nodes[list[i].ID] = &ProjectDocFolder{
|
||||||
|
ID: list[i].ID,
|
||||||
|
ParentID: list[i].ParentID,
|
||||||
|
Name: list[i].Name,
|
||||||
|
Children: []*ProjectDocFolder{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root, ok := nodes[rootID]
|
||||||
|
if !ok {
|
||||||
|
return empty, nil
|
||||||
|
}
|
||||||
|
// 按 id 顺序挂接父子关系;只有能与根连通的节点才会出现在项目子树中
|
||||||
|
for i := range list {
|
||||||
|
node, ok := nodes[list[i].ID]
|
||||||
|
if !ok || node.ID == rootID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if parent, ok := nodes[list[i].ParentID]; ok {
|
||||||
|
parent.Children = append(parent.Children, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 附带各文件夹直接挂载的文档数量(仅展示用,不汇总子孙)
|
||||||
|
if counts, err := OaDocCountByCategory(a, models.DocScopeShared, -1); err == nil {
|
||||||
|
for id, cnt := range counts {
|
||||||
|
if n, ok := nodes[id]; ok {
|
||||||
|
n.DocCount = cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []*ProjectDocFolder{root}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// crmProjectDocRootCategory 查找租户「项目文档」内置分类;缺失时兜底重建(正常由文档库初始化保证存在)。
|
||||||
|
func crmProjectDocRootCategory(tid int) (uint64, error) {
|
||||||
|
var root models.OaDocCategory
|
||||||
|
err := models.Orm.QueryTable(new(models.OaDocCategory)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("scope", models.DocScopeShared).
|
||||||
|
Filter("is_system", models.DocCategorySystem).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
OrderBy("id").One(&root)
|
||||||
|
if err == nil {
|
||||||
|
return root.ID, nil
|
||||||
|
}
|
||||||
|
id, insertErr := models.Orm.Insert(&models.OaDocCategory{
|
||||||
|
Tid: tid,
|
||||||
|
UserID: 0,
|
||||||
|
ParentID: 0,
|
||||||
|
Name: models.DocCategoryNameProject,
|
||||||
|
Sort: 0,
|
||||||
|
Remark: "系统内置分类,不可删除",
|
||||||
|
IsSystem: models.DocCategorySystem,
|
||||||
|
Scope: models.DocScopeShared,
|
||||||
|
IsDeleted: 0,
|
||||||
|
})
|
||||||
|
if insertErr != nil {
|
||||||
|
return 0, insertErr
|
||||||
|
}
|
||||||
|
return uint64(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// crmProjectDocCategoryBound 判断分类是否已被同租户的其它项目占用(excludeProjectID 为当前项目)。
|
||||||
|
func crmProjectDocCategoryBound(tid int, categoryID, excludeProjectID uint64) (bool, error) {
|
||||||
|
qs := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||||
|
Filter("tenant_id", strconv.Itoa(tid)).
|
||||||
|
Filter("doc_category_id", categoryID).
|
||||||
|
Filter("delete_time__isnull", true)
|
||||||
|
if excludeProjectID > 0 {
|
||||||
|
qs = qs.Exclude("id", excludeProjectID)
|
||||||
|
}
|
||||||
|
n, err := qs.Count()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// crmProjectDocBindCategory 回写项目与文档分类的绑定关系(projectID=0 时跳过)。
|
||||||
|
func crmProjectDocBindCategory(tid int, projectID, categoryID uint64) error {
|
||||||
|
if projectID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||||
|
Filter("id", projectID).
|
||||||
|
Filter("tenant_id", strconv.Itoa(tid)).
|
||||||
|
Update(orm.Params{"doc_category_id": categoryID, "update_time": time.Now()})
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
-- - 若两表并存,则去重合并进新表,旧表改名为 *_bak 备份保留;
|
-- - 若两表并存,则去重合并进新表,旧表改名为 *_bak 备份保留;
|
||||||
-- 3. 迁移历史死表 `yz_tenant_crm_contact`(若存在且有数据):去重并入新表,
|
-- 3. 迁移历史死表 `yz_tenant_crm_contact`(若存在且有数据):去重并入新表,
|
||||||
-- 原表改名为 `yz_tenant_crm_contact_bak` 保留;
|
-- 原表改名为 `yz_tenant_crm_contact_bak` 保留;
|
||||||
-- 4. 线索/商机联系人仍然只存过程库 `yz_tenant_crm_entity_contact`(本脚本不动)。
|
-- 4. 线索/商机联系人仍然只存过程库 `yz_backend_crm_entity_contact`(本脚本不动)。
|
||||||
--
|
--
|
||||||
-- 去重键(判定为同一联系人):tenant_id + company_type + company_id + name + mobiles
|
-- 去重键(判定为同一联系人):tenant_id + company_type + company_id + name + mobiles
|
||||||
-- 说明:
|
-- 说明:
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 跟进记录(回访)增加「创建阶段」字段 stage
|
||||||
|
--
|
||||||
|
-- 目的:
|
||||||
|
-- 过程数据(跟进记录)不做搬迁,按 线索 → 商机 → 项目 链路累积展示;
|
||||||
|
-- stage 记录该条记录创建时所处的阶段,用于详情列表标明数据来自哪个阶段。
|
||||||
|
--
|
||||||
|
-- 取值:1=线索,2=商机,3=项目
|
||||||
|
-- 说明:
|
||||||
|
-- - 脚本幂等:列已存在则跳过新增;
|
||||||
|
-- - 回填只在首次新增列时执行一次(历史数据按当前 related_type 近似回填)。
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
SET @has_stage := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'yz_backend_crm_follow'
|
||||||
|
AND COLUMN_NAME = 'stage'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 1. 新增列(已存在则跳过)
|
||||||
|
SET @sql := IF(@has_stage = 0,
|
||||||
|
'ALTER TABLE `yz_backend_crm_follow` ADD COLUMN `stage` tinyint(4) NOT NULL DEFAULT 1 COMMENT ''创建阶段:1线索/2商机/3项目'' AFTER `related_name`',
|
||||||
|
'SELECT ''skip: stage 列已存在'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 2. 历史数据回填(仅在首次新增列时执行;按当前 related_type 近似推断)
|
||||||
|
SET @sql := IF(@has_stage = 0,
|
||||||
|
'UPDATE `yz_backend_crm_follow` SET `stage` = `related_type` WHERE `related_type` IN (2, 3)',
|
||||||
|
'SELECT ''skip: 回填已完成过'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 3. 核对:各阶段记录数
|
||||||
|
SELECT `stage`, COUNT(*) AS `cnt`
|
||||||
|
FROM `yz_backend_crm_follow`
|
||||||
|
WHERE `delete_time` IS NULL
|
||||||
|
GROUP BY `stage`
|
||||||
|
ORDER BY `stage`;
|
||||||
@@ -1,13 +1,42 @@
|
|||||||
-- 为 CRM 业务管线(线索/商机/项目)增加「对接人职位」字段 contact_position。
|
-- =============================================================================
|
||||||
-- 若已执行过 yz_backend_crm_pipeline.sql(表已存在),执行本脚本即可;
|
-- 为 CRM 业务管线(线索/商机/项目)增加「对接人职位」字段 contact_position
|
||||||
-- 若尚未建表,直接执行 yz_backend_crm_pipeline.sql 已包含该字段,无需本脚本。
|
--
|
||||||
-- 脚本可重复执行:列已存在时 MySQL 报 duplicate column,忽略即可。
|
-- 说明:
|
||||||
|
-- - 幂等:列已存在时自动跳过,不会报 Duplicate column 错误;
|
||||||
|
-- - 只增加字段,不改动任何已有数据;
|
||||||
|
-- - 若尚未建表,直接执行 yz_backend_crm_pipeline.sql 已包含该字段,无需本脚本。
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
ALTER TABLE `yz_tenant_crm_clue`
|
SET NAMES utf8mb4;
|
||||||
ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位' AFTER `contact_person`;
|
|
||||||
|
|
||||||
ALTER TABLE `yz_tenant_crm_business`
|
-- 1. 线索
|
||||||
ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位' AFTER `contact_person`;
|
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_clue' AND COLUMN_NAME = 'contact_position');
|
||||||
|
SET @sql := IF(@has_col = 0,
|
||||||
|
'ALTER TABLE `yz_backend_crm_clue` ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '''' COMMENT ''对接人职位'' AFTER `contact_person`',
|
||||||
|
'SELECT ''skip: yz_backend_crm_clue.contact_position 已存在'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
ALTER TABLE `yz_tenant_crm_project`
|
-- 2. 商机
|
||||||
ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '' COMMENT '对接人职位' AFTER `contact_person`;
|
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_business' AND COLUMN_NAME = 'contact_position');
|
||||||
|
SET @sql := IF(@has_col = 0,
|
||||||
|
'ALTER TABLE `yz_backend_crm_business` ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '''' COMMENT ''对接人职位'' AFTER `contact_person`',
|
||||||
|
'SELECT ''skip: yz_backend_crm_business.contact_position 已存在'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 3. 项目
|
||||||
|
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project' AND COLUMN_NAME = 'contact_position');
|
||||||
|
SET @sql := IF(@has_col = 0,
|
||||||
|
'ALTER TABLE `yz_backend_crm_project` ADD COLUMN `contact_position` varchar(50) NOT NULL DEFAULT '''' COMMENT ''对接人职位'' AFTER `contact_person`',
|
||||||
|
'SELECT ''skip: yz_backend_crm_project.contact_position 已存在'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 4. 核对:三张表的 contact_position 字段
|
||||||
|
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME IN ('yz_backend_crm_clue', 'yz_backend_crm_business', 'yz_backend_crm_project')
|
||||||
|
AND COLUMN_NAME = 'contact_position'
|
||||||
|
ORDER BY TABLE_NAME;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 项目文档(CRM 项目 ↔ OA 文档库绑定)
|
||||||
|
--
|
||||||
|
-- 在项目表上增加文档库文件夹分类ID:
|
||||||
|
-- 指向 yz_backend_oa_doc_category 中「共享文档 / 项目文档 / {项目名称}」这条分类。
|
||||||
|
-- 项目建立时自动在文档库中创建同名文件夹并回写该列;
|
||||||
|
-- 存量项目(为 0)在打开「项目文档」或项目详情时自动补建。
|
||||||
|
-- 项目改名时同步重命名文档库文件夹。
|
||||||
|
--
|
||||||
|
-- 注:新建库时该列已包含在 yz_backend_crm_pipeline.sql 中,此脚本用于已有库升级。
|
||||||
|
-- 后端启动时也会自动补列(models.EnsureCrmProjectDocColumn),可不必手工执行。
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
ALTER TABLE `yz_backend_crm_project`
|
||||||
|
ADD COLUMN `doc_category_id` bigint(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联OA文档库项目文件夹分类ID(共享文档/项目文档/{项目名称})';
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- CRM 业务管线表名规范化:yz_tenant_crm_* → yz_backend_crm_*
|
||||||
|
--
|
||||||
|
-- 目的:与 yz_backend_erp_*、yz_backend_contact_company 等保持统一前缀,便于查找维护。
|
||||||
|
--
|
||||||
|
-- 映射:
|
||||||
|
-- yz_tenant_crm_clue → yz_backend_crm_clue
|
||||||
|
-- yz_tenant_crm_business → yz_backend_crm_business
|
||||||
|
-- yz_tenant_crm_project → yz_backend_crm_project
|
||||||
|
-- yz_tenant_crm_follow → yz_backend_crm_follow
|
||||||
|
-- yz_tenant_crm_attach → yz_backend_crm_attach
|
||||||
|
-- yz_tenant_crm_entity_contact → yz_backend_crm_entity_contact
|
||||||
|
-- yz_tenant_crm_operate_log → yz_backend_crm_operate_log
|
||||||
|
--
|
||||||
|
-- 说明:
|
||||||
|
-- - RENAME TABLE 是原子改名,数据、索引、自增全部原样保留,不会丢数据;
|
||||||
|
-- - 幂等:新表已存在或旧表不存在时自动跳过;
|
||||||
|
-- - 注意:需与新版后端一起发布(模型表名已指向 yz_backend_crm_*)。
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
-- 1. 线索
|
||||||
|
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_clue');
|
||||||
|
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_clue');
|
||||||
|
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_clue` TO `yz_backend_crm_clue`', 'SELECT ''skip: clue'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 2. 商机
|
||||||
|
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_business');
|
||||||
|
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_business');
|
||||||
|
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_business` TO `yz_backend_crm_business`', 'SELECT ''skip: business'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 3. 项目
|
||||||
|
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_project');
|
||||||
|
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_project');
|
||||||
|
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_project` TO `yz_backend_crm_project`', 'SELECT ''skip: project'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 4. 回访/跟进记录
|
||||||
|
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_follow');
|
||||||
|
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_follow');
|
||||||
|
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_follow` TO `yz_backend_crm_follow`', 'SELECT ''skip: follow'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 5. 附件
|
||||||
|
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_attach');
|
||||||
|
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_attach');
|
||||||
|
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_attach` TO `yz_backend_crm_attach`', 'SELECT ''skip: attach'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 6. 联系人
|
||||||
|
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_entity_contact');
|
||||||
|
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_entity_contact');
|
||||||
|
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_entity_contact` TO `yz_backend_crm_entity_contact`', 'SELECT ''skip: entity_contact'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 7. 操作日志
|
||||||
|
SET @has_old := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_tenant_crm_operate_log');
|
||||||
|
SET @has_new := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_crm_operate_log');
|
||||||
|
SET @sql := IF(@has_old > 0 AND @has_new = 0, 'RENAME TABLE `yz_tenant_crm_operate_log` TO `yz_backend_crm_operate_log`', 'SELECT ''skip: operate_log'' AS note');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 8. 核对结果
|
||||||
|
SELECT TABLE_NAME, TABLE_ROWS, CREATE_TIME
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'yz_%crm_%'
|
||||||
|
ORDER BY TABLE_NAME;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 修正「负责人 / 回访人 / 上传人」姓名:把登录账号刷成真实姓名
|
||||||
|
--
|
||||||
|
-- 背景:
|
||||||
|
-- 历史代码把 JWT 里的 Username(登录账号,如 hero920103)写进了
|
||||||
|
-- owner_user_name / last_owner_name 等字段,导致界面显示账号而不是人名。
|
||||||
|
-- 后端已改为按 uid 查 yz_system_tenant_user.name 写入真实姓名,
|
||||||
|
-- 本脚本用于修正存量数据。
|
||||||
|
--
|
||||||
|
-- 说明:
|
||||||
|
-- - 按 tenant_id + owner_user_id(uid) 关联 yz_system_tenant_user 取 name;
|
||||||
|
-- - 只更新「能匹配到用户且姓名非空」的记录,不动其它数据;
|
||||||
|
-- - 幂等,可重复执行(姓名已一致的行不会被更新)。
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
-- 1. 客户:负责人姓名
|
||||||
|
UPDATE `yz_backend_erp_customer` c
|
||||||
|
JOIN `yz_system_tenant_user` u
|
||||||
|
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
|
||||||
|
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
|
||||||
|
SET c.`owner_user_name` = u.`name`
|
||||||
|
WHERE c.`owner_user_id` IS NOT NULL AND c.`owner_user_id` <> ''
|
||||||
|
AND u.`name` IS NOT NULL AND u.`name` <> ''
|
||||||
|
AND (c.`owner_user_name` IS NULL OR c.`owner_user_name` <> u.`name`);
|
||||||
|
|
||||||
|
-- 2. 客户:公海记录里的「原负责人」姓名
|
||||||
|
UPDATE `yz_backend_erp_customer` c
|
||||||
|
JOIN `yz_system_tenant_user` u
|
||||||
|
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
|
||||||
|
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
|
||||||
|
SET c.`last_owner_name` = u.`name`
|
||||||
|
WHERE c.`owner_user_id` IS NOT NULL AND c.`owner_user_id` <> ''
|
||||||
|
AND u.`name` IS NOT NULL AND u.`name` <> ''
|
||||||
|
AND c.`last_owner_name` IS NOT NULL AND c.`last_owner_name` <> ''
|
||||||
|
AND c.`last_owner_name` <> u.`name`;
|
||||||
|
|
||||||
|
-- 3. 线索:负责人姓名
|
||||||
|
UPDATE `yz_backend_crm_clue` c
|
||||||
|
JOIN `yz_system_tenant_user` u
|
||||||
|
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
|
||||||
|
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
|
||||||
|
SET c.`owner_user_name` = u.`name`
|
||||||
|
WHERE c.`owner_user_id` <> ''
|
||||||
|
AND u.`name` IS NOT NULL AND u.`name` <> ''
|
||||||
|
AND (c.`owner_user_name` IS NULL OR c.`owner_user_name` <> u.`name`);
|
||||||
|
|
||||||
|
-- 4. 商机:负责人姓名
|
||||||
|
UPDATE `yz_backend_crm_business` c
|
||||||
|
JOIN `yz_system_tenant_user` u
|
||||||
|
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
|
||||||
|
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
|
||||||
|
SET c.`owner_user_name` = u.`name`
|
||||||
|
WHERE c.`owner_user_id` <> ''
|
||||||
|
AND u.`name` IS NOT NULL AND u.`name` <> ''
|
||||||
|
AND (c.`owner_user_name` IS NULL OR c.`owner_user_name` <> u.`name`);
|
||||||
|
|
||||||
|
-- 5. 项目:负责人姓名
|
||||||
|
UPDATE `yz_backend_crm_project` c
|
||||||
|
JOIN `yz_system_tenant_user` u
|
||||||
|
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
|
||||||
|
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
|
||||||
|
SET c.`owner_user_name` = u.`name`
|
||||||
|
WHERE c.`owner_user_id` <> ''
|
||||||
|
AND u.`name` IS NOT NULL AND u.`name` <> ''
|
||||||
|
AND (c.`owner_user_name` IS NULL OR c.`owner_user_name` <> u.`name`);
|
||||||
|
|
||||||
|
-- 6. 回访记录:回访人姓名
|
||||||
|
UPDATE `yz_backend_crm_follow` c
|
||||||
|
JOIN `yz_system_tenant_user` u
|
||||||
|
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
|
||||||
|
AND u.`uid` = CAST(c.`owner_user_id` AS UNSIGNED)
|
||||||
|
SET c.`owner_user_name` = u.`name`
|
||||||
|
WHERE c.`owner_user_id` <> ''
|
||||||
|
AND u.`name` IS NOT NULL AND u.`name` <> ''
|
||||||
|
AND (c.`owner_user_name` IS NULL OR c.`owner_user_name` <> u.`name`);
|
||||||
|
|
||||||
|
-- 7. 附件:上传人姓名
|
||||||
|
UPDATE `yz_backend_crm_attach` c
|
||||||
|
JOIN `yz_system_tenant_user` u
|
||||||
|
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
|
||||||
|
AND u.`uid` = CAST(c.`uploader_id` AS UNSIGNED)
|
||||||
|
SET c.`uploader_name` = u.`name`
|
||||||
|
WHERE c.`uploader_id` <> ''
|
||||||
|
AND u.`name` IS NOT NULL AND u.`name` <> ''
|
||||||
|
AND (c.`uploader_name` IS NULL OR c.`uploader_name` <> u.`name`);
|
||||||
|
|
||||||
|
-- 8. 操作日志:操作人姓名
|
||||||
|
UPDATE `yz_backend_crm_operate_log` c
|
||||||
|
JOIN `yz_system_tenant_user` u
|
||||||
|
ON u.`tid` = CAST(c.`tenant_id` AS UNSIGNED)
|
||||||
|
AND u.`uid` = CAST(c.`operator_id` AS UNSIGNED)
|
||||||
|
SET c.`operator_name` = u.`name`
|
||||||
|
WHERE c.`operator_id` <> ''
|
||||||
|
AND u.`name` IS NOT NULL AND u.`name` <> ''
|
||||||
|
AND (c.`operator_name` IS NULL OR c.`operator_name` <> u.`name`);
|
||||||
|
|
||||||
|
-- 9. 核对:客户负责人分布
|
||||||
|
SELECT `owner_user_id`, `owner_user_name`, COUNT(*) AS `cnt`
|
||||||
|
FROM `yz_backend_erp_customer`
|
||||||
|
GROUP BY `owner_user_id`, `owner_user_name`;
|
||||||
@@ -6,19 +6,19 @@
|
|||||||
-- 线索转化为商机时才把客户名称落地为正式客户(yz_backend_erp_customer)。
|
-- 线索转化为商机时才把客户名称落地为正式客户(yz_backend_erp_customer)。
|
||||||
-- 2. 线索转化商机后会被锁定(locked=1),后续操作都在商机进行。
|
-- 2. 线索转化商机后会被锁定(locked=1),后续操作都在商机进行。
|
||||||
-- 3. 商机转化项目后同样锁定(locked=1)。
|
-- 3. 商机转化项目后同样锁定(locked=1)。
|
||||||
-- 4. 回访/跟进记录(yz_tenant_crm_follow)与附件、联系人、操作日志均为通用多态表,
|
-- 4. 回访/跟进记录(yz_backend_crm_follow)与附件、联系人、操作日志均为通用多态表,
|
||||||
-- 通过 related_type(1线索/2商机/3项目) + related_id 关联。
|
-- 通过 related_type(1线索/2商机/3项目) + related_id 关联。
|
||||||
-- 可重复执行(DROP TABLE IF EXISTS)。
|
-- ⚠️ 安全说明(重要):
|
||||||
|
-- 本脚本只做 CREATE TABLE IF NOT EXISTS,**不会删除或覆盖任何已有数据**,可重复执行。
|
||||||
|
-- 如需给已存在的表新增字段,请勿重跑本脚本,改用对应的 alter_*.sql 增量脚本。
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
SET NAMES utf8mb4;
|
SET NAMES utf8mb4;
|
||||||
SET FOREIGN_KEY_CHECKS = 0;
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 线索表
|
-- 线索表
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
DROP TABLE IF EXISTS `yz_tenant_crm_clue`;
|
CREATE TABLE IF NOT EXISTS `yz_backend_crm_clue` (
|
||||||
CREATE TABLE `yz_tenant_crm_clue` (
|
|
||||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||||
`clue_name` varchar(100) NOT NULL COMMENT '线索名称',
|
`clue_name` varchar(100) NOT NULL COMMENT '线索名称',
|
||||||
@@ -55,8 +55,7 @@ CREATE TABLE `yz_tenant_crm_clue` (
|
|||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 商机表
|
-- 商机表
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
DROP TABLE IF EXISTS `yz_tenant_crm_business`;
|
CREATE TABLE IF NOT EXISTS `yz_backend_crm_business` (
|
||||||
CREATE TABLE `yz_tenant_crm_business` (
|
|
||||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||||
`business_name` varchar(100) NOT NULL COMMENT '商机名称',
|
`business_name` varchar(100) NOT NULL COMMENT '商机名称',
|
||||||
@@ -100,8 +99,7 @@ CREATE TABLE `yz_tenant_crm_business` (
|
|||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 项目表
|
-- 项目表
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
DROP TABLE IF EXISTS `yz_tenant_crm_project`;
|
CREATE TABLE IF NOT EXISTS `yz_backend_crm_project` (
|
||||||
CREATE TABLE `yz_tenant_crm_project` (
|
|
||||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||||
`project_name` varchar(100) NOT NULL COMMENT '项目名称',
|
`project_name` varchar(100) NOT NULL COMMENT '项目名称',
|
||||||
@@ -121,6 +119,7 @@ CREATE TABLE `yz_tenant_crm_project` (
|
|||||||
`contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人手机',
|
`contact_phone` varchar(20) NOT NULL DEFAULT '' COMMENT '对接人手机',
|
||||||
`address` varchar(255) NOT NULL DEFAULT '' COMMENT '项目地址',
|
`address` varchar(255) NOT NULL DEFAULT '' COMMENT '项目地址',
|
||||||
`remark` text COMMENT '备注',
|
`remark` text COMMENT '备注',
|
||||||
|
`doc_category_id` bigint(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联OA文档库项目文件夹分类ID(共享文档/项目文档/{项目名称})',
|
||||||
`create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
|
`create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人用户ID',
|
||||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
@@ -137,13 +136,13 @@ CREATE TABLE `yz_tenant_crm_project` (
|
|||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 回访/跟进记录表(贯穿线索、商机、项目)
|
-- 回访/跟进记录表(贯穿线索、商机、项目)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
DROP TABLE IF EXISTS `yz_tenant_crm_follow`;
|
CREATE TABLE IF NOT EXISTS `yz_backend_crm_follow` (
|
||||||
CREATE TABLE `yz_tenant_crm_follow` (
|
|
||||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||||
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
||||||
`related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
|
`related_id` bigint(20) NOT NULL COMMENT '关联记录ID',
|
||||||
`related_name` varchar(100) NOT NULL DEFAULT '' COMMENT '关联对象名称(冗余,便于回访列表展示)',
|
`related_name` varchar(100) NOT NULL DEFAULT '' COMMENT '关联对象名称(冗余,便于回访列表展示)',
|
||||||
|
`stage` tinyint(4) NOT NULL DEFAULT '1' COMMENT '创建阶段:1线索/2商机/3项目',
|
||||||
`follow_type` varchar(20) NOT NULL DEFAULT '1' COMMENT '回访方式:1电话/2微信/3上门/4邮件/5其他',
|
`follow_type` varchar(20) NOT NULL DEFAULT '1' COMMENT '回访方式:1电话/2微信/3上门/4邮件/5其他',
|
||||||
`follow_time` datetime DEFAULT NULL COMMENT '回访时间',
|
`follow_time` datetime DEFAULT NULL COMMENT '回访时间',
|
||||||
`content` text COMMENT '回访内容',
|
`content` text COMMENT '回访内容',
|
||||||
@@ -163,8 +162,7 @@ CREATE TABLE `yz_tenant_crm_follow` (
|
|||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 附件表(多态:线索/商机/项目的资料)
|
-- 附件表(多态:线索/商机/项目的资料)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
DROP TABLE IF EXISTS `yz_tenant_crm_attach`;
|
CREATE TABLE IF NOT EXISTS `yz_backend_crm_attach` (
|
||||||
CREATE TABLE `yz_tenant_crm_attach` (
|
|
||||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||||
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
||||||
@@ -187,8 +185,7 @@ CREATE TABLE `yz_tenant_crm_attach` (
|
|||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 联系人表(多态:线索/商机/项目的对接人列表)
|
-- 联系人表(多态:线索/商机/项目的对接人列表)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
DROP TABLE IF EXISTS `yz_tenant_crm_entity_contact`;
|
CREATE TABLE IF NOT EXISTS `yz_backend_crm_entity_contact` (
|
||||||
CREATE TABLE `yz_tenant_crm_entity_contact` (
|
|
||||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||||
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
||||||
@@ -213,8 +210,7 @@ CREATE TABLE `yz_tenant_crm_entity_contact` (
|
|||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 操作日志表(多态:记录线索/商机/项目的关键操作,用于详情页操作日志 Tab)
|
-- 操作日志表(多态:记录线索/商机/项目的关键操作,用于详情页操作日志 Tab)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
DROP TABLE IF EXISTS `yz_tenant_crm_operate_log`;
|
CREATE TABLE IF NOT EXISTS `yz_backend_crm_operate_log` (
|
||||||
CREATE TABLE `yz_tenant_crm_operate_log` (
|
|
||||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
|
||||||
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
`related_type` tinyint(4) NOT NULL COMMENT '关联类型:1线索/2商机/3项目',
|
||||||
|
|||||||
Reference in New Issue
Block a user