优化线索、商机、项目相关开发

This commit is contained in:
2026-09-11 17:00:23 +08:00
parent 9c07af2b59
commit 840673bda5
55 changed files with 4866 additions and 625 deletions
+1 -2
View File
@@ -21,14 +21,12 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
@@ -46,6 +44,7 @@ declare module 'vue' {
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
+36
View File
@@ -82,6 +82,42 @@ export function deleteProject(id) {
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) {
@@ -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(/&nbsp;/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>
+21 -34
View File
@@ -3,7 +3,7 @@
<div class="page-header">
<div>
<h2>商机管理</h2>
<p>推进商机阶段,达成后可转化为项目;转化后商机将锁定不可编辑</p>
<p>推进商机阶段,达成后可转化为项目</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
@@ -47,6 +47,11 @@
<div class="table-container" v-loading="loading">
<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>
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.business_name }}</span>
@@ -77,11 +82,6 @@
<el-table-column label="下次联系时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
</el-table-column>
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="businessStatusTag(row.status)" size="small">{{ businessStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :disabled="row.locked === 1" @click="openEdit(row)">
@@ -96,7 +96,15 @@
>
转项目
</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>
</el-table-column>
<template #empty><el-empty description="暂无商机" :image-size="80" /></template>
@@ -118,32 +126,11 @@
<BusinessEdit v-model:visible="editVisible" :edit-data="currentRow" @success="fetchList" />
<BusinessConvert v-model:visible="convertVisible" :business="currentRow" @success="handleConvertSuccess" />
<el-dialog v-model="detailVisible" :title="currentRow?.business_name || '商机详情'" width="720px">
<el-descriptions v-if="currentRow" :column="2" border size="small">
<el-descriptions-item label="商机名称" :span="2">{{ currentRow.business_name }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ currentRow.customer_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="客户来源">{{ clueSourceText(currentRow.source) }}</el-descriptions-item>
<el-descriptions-item label="商机阶段">
<el-tag :type="businessStageTag(currentRow.stage)" size="small">{{ businessStageText(currentRow.stage) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="级别">
<el-tag :type="pipelineLevelTag(currentRow.level)" size="small">{{ pipelineLevelText(currentRow.level) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="预计金额">{{ formatMoney(currentRow.amount) }}</el-descriptions-item>
<el-descriptions-item label="预计成交日期">{{ formatDateOnly(currentRow.expect_deal_date) }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ currentRow.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="下次联系时间">{{ formatDateTime(currentRow.next_contact_time) }}</el-descriptions-item>
<el-descriptions-item label="对接人">{{ currentRow.contact_person || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人职位">{{ currentRow.contact_position || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人手机">{{ currentRow.contact_phone || "-" }}</el-descriptions-item>
<el-descriptions-item label="客户地址" :span="2">{{ currentRow.address || "-" }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="businessStatusTag(currentRow.status)" size="small">{{ businessStatusText(currentRow.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="转化时间">{{ formatDateTime(currentRow.convert_time) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ currentRow.remark || "-" }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<BusinessDetail
v-model:visible="detailVisible"
:business="currentRow"
@refresh="fetchList"
/>
</div>
</template>
@@ -154,6 +141,7 @@ import { Plus, Search, Refresh } from "@element-plus/icons-vue";
import { getBusinessList, deleteBusiness } from "@/api/crmPipeline";
import BusinessEdit from "./components/edit.vue";
import BusinessConvert from "./components/convert.vue";
import BusinessDetail from "./components/detail.vue";
import {
BUSINESS_STAGE_OPTIONS,
BUSINESS_STATUS_OPTIONS,
@@ -164,7 +152,6 @@ import {
businessStatusTag,
pipelineLevelText,
pipelineLevelTag,
clueSourceText,
formatMoney,
formatDateOnly,
formatDateTime,
@@ -10,7 +10,7 @@
type="warning"
:closable="false"
show-icon
title="转化后线索将被锁定,不能再编辑,后续操作请在商机中进行。"
title="转化后线索将被锁定;联系人 / 跟进记录 / 附件会按阶段在商机中累积展示,后续操作请在商机中进行。"
style="margin-bottom: 16px"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
@@ -8,241 +8,69 @@
@opened="loadAll"
>
<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-descriptions :column="2" border size="small">
<el-descriptions-item label="线索名称" :span="2">{{ clue.clue_name }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ clue.customer_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="客户来源">{{ clueSourceText(clue.source) }}</el-descriptions-item>
<el-descriptions-item label="客户级别">
<el-tag :type="pipelineLevelTag(clue.clue_level)" size="small">
{{ pipelineLevelText(clue.clue_level) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="负责人">{{ clue.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="客户行业">{{ clue.industry || "-" }}</el-descriptions-item>
<el-descriptions-item label="下次联系时间">{{ formatDateTime(clue.next_contact_time) }}</el-descriptions-item>
<el-descriptions-item label="客户对接人">{{ clue.contact_person || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人职位">{{ clue.contact_position || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人手机">{{ clue.contact_phone || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人微信">{{ clue.contact_wechat || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人QQ">{{ clue.contact_qq || "-" }}</el-descriptions-item>
<el-descriptions-item label="公司地址" :span="2">{{ clue.address || "-" }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="clueStatusTag(clue.status)" size="small">{{ clueStatusText(clue.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="转化时间">{{ formatDateTime(clue.convert_time) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ clue.remark || "-" }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(clue.create_time) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(clue.update_time) }}</el-descriptions-item>
</el-descriptions>
<DetailBasic :clue="clue" />
</el-tab-pane>
<!-- 联系人 -->
<el-tab-pane :label="`联系人 (${contacts.length})`" name="contact">
<div class="tab-toolbar">
<el-button type="primary" size="small" :icon="Plus" @click="openContactEdit(null)">新增联系人</el-button>
</div>
<el-table :data="contacts" v-loading="loading.contact" stripe border size="small">
<el-table-column label="姓名" width="140">
<template #default="{ row }">
{{ row.contact_name }}
<el-tag v-if="Number(row.is_primary) === 1" type="danger" size="small" effect="plain">主</el-tag>
</template>
</el-table-column>
<el-table-column prop="position" label="职位" width="120" show-overflow-tooltip />
<el-table-column prop="mobile" label="手机号" width="130" />
<el-table-column prop="wechat" label="微信" width="120" show-overflow-tooltip />
<el-table-column prop="qq" label="QQ" width="110" />
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openContactEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleContactDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无联系人" :image-size="60" /></template>
</el-table>
<el-tab-pane :label="`联系人 (${counts.contact})`" name="contact">
<DetailContact
ref="contactRef"
:related-id="clue.id"
:related-type="RELATED_TYPE"
@count="counts.contact = $event"
/>
</el-tab-pane>
<!-- 跟进记录 -->
<el-tab-pane :label="`跟进记录 (${follows.length})`" name="follow">
<div class="tab-toolbar">
<el-button type="primary" size="small" :icon="Plus" @click="openFollowEdit(null)">新增回访</el-button>
</div>
<el-table :data="follows" v-loading="loading.follow" stripe border size="small">
<el-table-column label="回访时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.follow_time) }}</template>
</el-table-column>
<el-table-column label="方式" width="80" align="center">
<template #default="{ row }">{{ followTypeText(row.follow_type) }}</template>
</el-table-column>
<el-table-column label="回访内容" min-width="240">
<template #default="{ row }">
<div class="follow-content" v-html="row.content"></div>
</template>
</el-table-column>
<el-table-column label="下次联系" width="160">
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
</el-table-column>
<el-table-column prop="owner_user_name" label="回访人" width="100" />
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openFollowEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleFollowDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无跟进记录" :image-size="60" /></template>
</el-table>
<el-tab-pane :label="`跟进记录 (${counts.follow})`" name="follow">
<DetailFollow
ref="followRef"
:related-id="clue.id"
:related-type="RELATED_TYPE"
:related-name="clue.clue_name || ''"
@count="counts.follow = $event"
@saved="emit('refresh')"
/>
</el-tab-pane>
<!-- 附件 -->
<el-tab-pane :label="`附件 (${attaches.length})`" name="attach">
<div class="tab-toolbar">
<el-upload
:show-file-list="false"
:http-request="handleUpload"
:disabled="uploading"
>
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
</el-upload>
<span class="tip">用于存放前期资料,如方案、报价单、图纸等</span>
</div>
<el-table :data="attaches" v-loading="loading.attach" stripe border size="small">
<el-table-column label="文件名" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<a :href="row.file_url" target="_blank" class="file-link">{{ row.file_name || "-" }}</a>
</template>
</el-table-column>
<el-table-column label="大小" width="100" align="right">
<template #default="{ row }">{{ formatFileSize(row.file_size) }}</template>
</el-table-column>
<el-table-column prop="uploader_name" label="上传人" width="100" />
<el-table-column label="上传时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleAttachDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无附件" :image-size="60" /></template>
</el-table>
<el-tab-pane :label="`附件 (${counts.attach})`" name="attach">
<DetailAttach
ref="attachRef"
:related-id="clue.id"
:related-type="RELATED_TYPE"
tip="用于存放前期资料,如方案、报价单、图纸等"
@count="counts.attach = $event"
/>
</el-tab-pane>
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="log">
<el-timeline v-loading="loading.log">
<el-timeline-item
v-for="log in logs"
:key="log.id"
:timestamp="formatDateTime(log.create_time)"
placement="top"
>
<div class="log-item">
<span class="log-user">{{ log.operator_name || "系统" }}</span>
<span>{{ log.content || log.action }}</span>
</div>
</el-timeline-item>
<el-empty v-if="!logs.length && !loading.log" description="暂无操作日志" :image-size="60" />
</el-timeline>
<DetailLog ref="logRef" :related-id="clue.id" :related-type="RELATED_TYPE" />
</el-tab-pane>
</el-tabs>
</div>
<!-- 联系人编辑 -->
<el-dialog v-model="contactDialog" :title="contactForm.id ? '编辑联系人' : '新增联系人'" width="520px" append-to-body>
<el-form :model="contactForm" label-width="90px">
<el-form-item label="姓名" required>
<el-input v-model="contactForm.contact_name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="职位"><el-input v-model="contactForm.position" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="contactForm.mobile" /></el-form-item>
<el-form-item label="微信"><el-input v-model="contactForm.wechat" /></el-form-item>
<el-form-item label="QQ"><el-input v-model="contactForm.qq" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="contactForm.email" /></el-form-item>
<el-form-item label="主联系人">
<el-switch v-model="contactForm.is_primary" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="备注"><el-input v-model="contactForm.remark" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="contactDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleContactSave">保存</el-button>
</template>
</el-dialog>
<!-- 回访编辑 -->
<el-dialog v-model="followDialog" :title="followForm.id ? '编辑回访' : '新增回访'" width="520px" append-to-body>
<el-form :model="followForm" label-width="90px">
<el-form-item label="回访方式">
<el-select v-model="followForm.follow_type" style="width: 100%">
<el-option v-for="i in FOLLOW_TYPE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="回访时间">
<el-date-picker
v-model="followForm.follow_time"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="回访内容" required>
<RichContentEditor
v-model="followForm.content"
:min-height="120"
placeholder="请输入回访内容,可粘贴或上传图片"
/>
</el-form-item>
<el-form-item label="下次联系">
<el-date-picker
v-model="followForm.next_contact_time"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="followDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleFollowSave">保存</el-button>
</template>
</el-dialog>
</el-drawer>
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Upload } from "@element-plus/icons-vue";
import {
getEntityContactList,
addEntityContact,
updateEntityContact,
deleteEntityContact,
getFollowList,
addFollow,
updateFollow,
deleteFollow,
getAttachList,
addAttach,
deleteAttach,
getCrmOperateLogs,
} from "@/api/crmPipeline";
import { uploadFile } from "@/api/file";
import RichContentEditor from "../../components/RichContentEditor.vue";
import {
clueSourceText,
pipelineLevelText,
pipelineLevelTag,
clueStatusText,
clueStatusTag,
followTypeText,
FOLLOW_TYPE_OPTIONS,
formatDateTime,
} from "../../dict";
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 },
@@ -250,44 +78,25 @@ const props = defineProps({
});
const emit = defineEmits(["update:visible", "refresh"]);
const RELATED_TYPE = 1; // 线索
// 关联类型:1=线索
const RELATED_TYPE = 1;
const activeTab = ref("basic");
const loading = reactive({ contact: false, follow: false, attach: false, log: false });
const contacts = ref([]);
const follows = ref([]);
const attaches = ref([]);
const logs = ref([]);
const saving = ref(false);
const uploading = ref(false);
const counts = reactive({ contact: 0, follow: 0, attach: 0 });
const contactDialog = ref(false);
const contactForm = reactive({
id: null,
contact_name: "",
position: "",
mobile: "",
wechat: "",
qq: "",
email: "",
is_primary: 0,
remark: "",
});
const followDialog = ref(false);
const followForm = reactive({
id: null,
follow_type: "1",
follow_time: "",
content: "",
next_contact_time: "",
});
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;
}
}
);
@@ -296,258 +105,69 @@ function handleClose() {
emit("update:visible", false);
}
function currentId() {
return props.clue?.id;
/** 抽屉打开后刷新各 Tab 数据 */
function loadAll() {
if (!props.clue?.id) return;
contactRef.value?.reload();
followRef.value?.reload();
attachRef.value?.reload();
logRef.value?.reload();
}
async function loadAll() {
if (!currentId()) return;
loadContacts();
loadFollows();
loadAttaches();
loadLogs();
}
/* ------------------------------ 联系人 ------------------------------ */
async function loadContacts() {
loading.contact = true;
try {
const res = await getEntityContactList({ related_type: RELATED_TYPE, related_id: currentId() });
contacts.value = res?.data?.list || [];
} catch (e) {
contacts.value = [];
} finally {
loading.contact = false;
}
}
function openContactEdit(row) {
Object.assign(contactForm, {
id: null,
contact_name: "",
position: "",
mobile: "",
wechat: "",
qq: "",
email: "",
is_primary: 0,
remark: "",
});
if (row) Object.assign(contactForm, row);
contactDialog.value = true;
}
async function handleContactSave() {
if (!contactForm.contact_name) {
ElMessage.warning("请输入联系人姓名");
return;
}
saving.value = true;
try {
const payload = {
...contactForm,
related_type: RELATED_TYPE,
related_id: currentId(),
};
if (contactForm.id) {
await updateEntityContact(payload);
} else {
await addEntityContact(payload);
}
ElMessage.success("保存成功");
contactDialog.value = false;
loadContacts();
} catch (e) {
ElMessage.error(e.message || "保存失败");
} finally {
saving.value = false;
}
}
async function handleContactDelete(row) {
try {
await ElMessageBox.confirm(`确定删除联系人「${row.contact_name}」吗?`, "删除确认", { type: "warning" });
await deleteEntityContact({ id: row.id });
ElMessage.success("删除成功");
loadContacts();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
/* ------------------------------ 跟进记录 ------------------------------ */
async function loadFollows() {
loading.follow = true;
try {
const res = await getFollowList({ related_type: RELATED_TYPE, related_id: currentId(), pageSize: 100 });
follows.value = res?.data?.list || [];
} catch (e) {
follows.value = [];
} finally {
loading.follow = false;
}
}
function openFollowEdit(row) {
Object.assign(followForm, { id: null, follow_type: "1", follow_time: "", content: "", next_contact_time: "" });
if (row) Object.assign(followForm, row);
followDialog.value = true;
}
async function handleFollowSave() {
const plainText = String(followForm.content || "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
if (!plainText && !/<img/i.test(followForm.content || "")) {
ElMessage.warning("请输入回访内容");
return;
}
saving.value = true;
try {
if (followForm.id) {
await updateFollow({ ...followForm });
} else {
await addFollow({
...followForm,
related_type: RELATED_TYPE,
related_id: currentId(),
related_name: props.clue?.clue_name || "",
});
}
ElMessage.success("保存成功");
followDialog.value = false;
loadFollows();
emit("refresh");
} catch (e) {
ElMessage.error(e.message || "保存失败");
} finally {
saving.value = false;
}
}
async function handleFollowDelete(row) {
try {
await ElMessageBox.confirm("确定删除该回访记录吗?", "删除确认", { type: "warning" });
await deleteFollow({ id: row.id });
ElMessage.success("删除成功");
loadFollows();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
/* -------------------------------- 附件 -------------------------------- */
async function loadAttaches() {
loading.attach = true;
try {
const res = await getAttachList({ related_type: RELATED_TYPE, related_id: currentId() });
attaches.value = res?.data?.list || [];
} catch (e) {
attaches.value = [];
} finally {
loading.attach = false;
}
}
async function handleUpload(options) {
const file = options.file;
uploading.value = true;
try {
const fd = new FormData();
fd.append("file", file);
const res = await uploadFile(fd);
const data = res?.data || {};
await addAttach({
related_type: RELATED_TYPE,
related_id: currentId(),
file_id: data.id || 0,
file_name: data.name || file.name,
file_url: data.url || "",
file_size: file.size || 0,
});
ElMessage.success("上传成功");
loadAttaches();
} catch (e) {
ElMessage.error(e.message || "上传失败");
} finally {
uploading.value = false;
}
}
async function handleAttachDelete(row) {
try {
await ElMessageBox.confirm(`确定删除附件「${row.file_name}」吗?`, "删除确认", { type: "warning" });
await deleteAttach({ id: row.id });
ElMessage.success("删除成功");
loadAttaches();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
/* ------------------------------ 操作日志 ------------------------------ */
async function loadLogs() {
loading.log = true;
try {
const res = await getCrmOperateLogs({ related_type: RELATED_TYPE, related_id: currentId() });
logs.value = res?.data?.list || [];
} catch (e) {
logs.value = [];
} finally {
loading.log = false;
}
}
function formatFileSize(size) {
const n = Number(size) || 0;
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
defineExpose({ loadAll });
</script>
<style lang="less" scoped>
:deep(.el-drawer__body) {
overflow-x: hidden;
}
.clue-detail {
.tab-toolbar {
.detail-tabs {
// 横向 flex:导航固定宽 + 内容区占满剩余宽度
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
width: 100%;
min-width: 0;
.tip {
font-size: 12px;
color: var(--el-text-color-secondary);
// 关键:内容区占满「容器剩余宽度」并允许收缩,内部表格才能随容器自适应
:deep(.el-tabs__content) {
flex: 1;
min-width: 0;
overflow: hidden;
padding-left: 4px;
}
}
.file-link {
color: var(--el-color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
:deep(.el-tabs__header) {
margin-right: 12px;
flex-shrink: 0;
}
}
.log-item {
.log-user {
font-weight: 600;
margin-right: 8px;
:deep(.el-tabs__item) {
justify-content: flex-start;
text-align: left;
padding: 0 12px;
min-width: 96px;
font-size: 13px;
}
}
.follow-content {
word-break: break-word;
:deep(.el-tab-pane) {
width: 100%;
min-width: 0;
}
:deep(img) {
max-width: 160px;
max-height: 120px;
height: auto;
border-radius: 4px;
cursor: zoom-in;
vertical-align: middle;
margin: 2px 4px 2px 0;
:deep(.el-table) {
width: 100%;
// 操作列:链接按钮去掉自带内边距、缩小间距,避免「编辑/删除」被挤成两行
.el-button.is-link {
padding: 0;
height: auto;
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(/&nbsp;/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>
+15 -7
View File
@@ -3,7 +3,7 @@
<div class="page-header">
<div>
<h2>线索管理</h2>
<p>登记潜在客户线索,跟进后可转化为商机;转化后线索将锁定不可编辑</p>
<p>登记潜在客户线索,跟进后可转化为商机</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
@@ -47,6 +47,11 @@
<div class="table-container" v-loading="loading">
<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>
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.clue_name }}</span>
@@ -76,11 +81,6 @@
<el-table-column label="下次联系时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="clueStatusTag(row.status)" size="small">{{ clueStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :disabled="row.locked === 1" @click="openEdit(row)">
@@ -95,7 +95,15 @@
>
转化
</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>
</el-table-column>
<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_phone" label="联系电话" width="130" />
<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 label="状态" width="90" align="center">
<template #default="{ row }">
@@ -167,7 +167,7 @@
{{ statusText(detailData.status) }}
</el-tag>
</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.paid_capital ? detailData.paid_capital + ' 万元' : '-' }}</el-descriptions-item>
<el-descriptions-item label="成立日期">{{ formatDate(detailData.establish_date) }}</el-descriptions-item>
+6
View File
@@ -239,6 +239,12 @@ export const pipelineRelatedTypeText = (val) => {
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 */
export function formatMoney(val) {
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-select>
</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-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" />
@@ -90,6 +103,8 @@ const defaultForm = () => ({
related_type: 1,
related_id: "",
related_name: "",
// 所属阶段:1线索/2商机/3项目,默认跟随关联类型,可手动调整
stage: 1,
follow_type: "1",
follow_time: "",
content: "",
@@ -131,6 +146,8 @@ watch(
if (props.editData) {
isEdit.value = true;
Object.assign(form, props.editData);
// 编辑:默认保留记录原本的阶段(缺失时按关联类型回退),可手动修正
form.stage = Number(props.editData.stage || props.editData.related_type || 1);
searchRelated("");
} else {
isEdit.value = false;
@@ -148,6 +165,8 @@ watch(
function handleTypeChange() {
form.related_id = "";
form.related_name = "";
// 切换关联类型时,阶段默认跟随新类型(仍可手动调整)
form.stage = Number(form.related_type);
relatedOptions.value = [];
searchRelated("");
}
@@ -189,6 +208,8 @@ async function handleSubmit() {
const payload = {
...form,
related_name: form.related_name || selected?.name || "",
// 回访阶段:允许与关联类型不同(如补录早期阶段的沟通),默认跟随关联类型
stage: Number(form.stage) || Number(form.related_type),
};
if (form.id) {
await updateFollow(payload);
+117 -4
View File
@@ -49,7 +49,16 @@
<el-table :data="tableData" stripe border row-key="id">
<el-table-column label="关联类型" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ pipelineRelatedTypeText(row.related_type) }}</el-tag>
<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>
</el-table-column>
<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">
<template #default="{ row }">{{ formatDateTime(row.follow_time) }}</template>
</el-table-column>
<el-table-column label="回访内容" min-width="240" show-overflow-tooltip>
<template #default="{ row }">{{ stripHtml(row.content) }}</template>
<el-table-column label="回访内容" min-width="240">
<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 label="下次联系时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
@@ -89,12 +102,46 @@
</div>
<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>
</template>
<script setup>
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 { getFollowList, deleteFollow } from "@/api/crmPipeline";
import FollowEdit from "./components/edit.vue";
@@ -103,6 +150,8 @@ import {
FOLLOW_TYPE_OPTIONS,
followTypeText,
pipelineRelatedTypeText,
pipelineStageText,
pipelineStageTag,
formatDateTime,
stripHtml,
} from "../dict";
@@ -112,6 +161,15 @@ const tableData = ref([]);
const editVisible = ref(false);
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 pagination = reactive({ page: 1, pageSize: 20, total: 0 });
@@ -160,6 +218,33 @@ function openEdit(row) {
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) {
try {
await ElMessageBox.confirm("确定删除该回访记录吗?", "删除确认", { type: "warning" });
@@ -173,3 +258,31 @@ async function handleDelete(row) {
</script>
<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(/&nbsp;/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>
+4 -23
View File
@@ -66,7 +66,7 @@
</el-table-column>
<el-table-column label="操作" width="210" align="center" fixed="right">
<template #default="{ row }">
<el-button
<!-- <el-button
link
type="primary"
size="small"
@@ -74,7 +74,7 @@
@click="openContactBook(row)"
>
通讯录
</el-button>
</el-button> -->
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
@@ -105,27 +105,7 @@
:company-name="contactBookCompanyName"
/>
<el-dialog v-model="detailVisible" :title="currentRow?.project_name || '项目详情'" width="720px">
<el-descriptions v-if="currentRow" :column="2" border size="small">
<el-descriptions-item label="项目名称" :span="2">{{ currentRow.project_name }}</el-descriptions-item>
<el-descriptions-item label="项目编号">{{ currentRow.project_no || "-" }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ currentRow.customer_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="项目金额">{{ formatMoney(currentRow.amount) }}</el-descriptions-item>
<el-descriptions-item label="项目状态">
<el-tag :type="projectStatusTag(currentRow.status)" size="small">
{{ projectStatusText(currentRow.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="开始日期">{{ formatDateOnly(currentRow.start_date) }}</el-descriptions-item>
<el-descriptions-item label="结束日期">{{ formatDateOnly(currentRow.end_date) }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ currentRow.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人">{{ currentRow.contact_person || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人职位">{{ currentRow.contact_position || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人手机">{{ currentRow.contact_phone || "-" }}</el-descriptions-item>
<el-descriptions-item label="项目地址" :span="2">{{ currentRow.address || "-" }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ currentRow.remark || "-" }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<ProjectDetail v-model:visible="detailVisible" :project="currentRow" @refresh="fetchList" />
</div>
</template>
@@ -135,6 +115,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
import { getProjectList, deleteProject } from "@/api/crmPipeline";
import ProjectEdit from "./components/edit.vue";
import ProjectDetail from "./components/detail.vue";
import ContactBook from "../../erp/components/contactBook.vue";
import {
PROJECT_STATUS_OPTIONS,