|
|
@@ -0,0 +1,1005 @@
|
|
|
+<template>
|
|
|
+ <el-drawer v-model="drawerVisible" title="线索详情" size="80%" @close="handleClose">
|
|
|
+ <div class="drawer-container">
|
|
|
+ <el-row :gutter="20">
|
|
|
+ <!-- 左侧:线索基本信息 -->
|
|
|
+ <el-col :span="8">
|
|
|
+ <el-card shadow="hover" class="mb-4">
|
|
|
+ <template #header>
|
|
|
+ <div class="flex justify-between items-center">
|
|
|
+ <span class="font-bold">基本信息</span>
|
|
|
+ <el-button link type="primary" icon="Edit" @click="handleEdit" :disabled="clueInfo.isConverted === 1"> 修改 </el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-descriptions :column="1" border>
|
|
|
+ <el-descriptions-item label="线索姓名">
|
|
|
+ <span class="font-semibold text-lg">{{ clueInfo.clueName }}</span>
|
|
|
+ <el-tag v-if="clueInfo.title" size="small" class="ml-2">
|
|
|
+ <dict-tag :options="crm_title" :value="clueInfo.title" />
|
|
|
+ </el-tag>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="公司名称">{{ clueInfo.company }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="职位">{{ clueInfo.position }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="手机号">
|
|
|
+ <span>{{ clueInfo.mobile }}</span>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="邮箱">
|
|
|
+ <span>{{ clueInfo.email }}</span>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="客户网站">
|
|
|
+ <el-link v-if="clueInfo.website" :href="clueInfo.website" target="_blank" type="primary">
|
|
|
+ {{ clueInfo.website }}
|
|
|
+ </el-link>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="所属行业">
|
|
|
+ <dict-tag :options="crm_industry" :value="clueInfo.industry" />
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="地址">
|
|
|
+ {{ [clueInfo.province, clueInfo.city, clueInfo.district, clueInfo.address].filter(Boolean).join(' ') }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="所在园区">{{ clueInfo.parkArea }}</el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <el-card shadow="hover" class="mb-4">
|
|
|
+ <template #header>
|
|
|
+ <span class="font-bold">线索信息</span>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-descriptions :column="1" border>
|
|
|
+ <el-descriptions-item label="线索等级">
|
|
|
+ <dict-tag :options="crm_clue_level" :value="clueInfo.level" />
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="线索来源">
|
|
|
+ <dict-tag :options="crm_clue_source" :value="clueInfo.source" />
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="线索状态">
|
|
|
+ <dict-tag :options="crm_clue_status" :value="clueInfo.status" />
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="负责人">{{ clueInfo.ownerUserName }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="下次联系时间">
|
|
|
+ <span :class="isOverdue ? 'text-red-500 font-bold' : ''">
|
|
|
+ {{ clueInfo.nextTime }}
|
|
|
+ <el-tag v-if="isOverdue" type="danger" size="small" class="ml-2">逾期</el-tag>
|
|
|
+ <el-tag v-else-if="isToday" type="warning" size="small" class="ml-2">今天</el-tag>
|
|
|
+ </span>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="创建时间">{{ clueInfo.createTime }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="停留天数"> {{ calculateDays(clueInfo.createTime) }}天 </el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <el-card shadow="hover" class="mb-4">
|
|
|
+ <template #header>
|
|
|
+ <span class="font-bold">需求信息</span>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-descriptions :column="1" border>
|
|
|
+ <el-descriptions-item label="意向产品">
|
|
|
+ <el-tag v-for="product in intentProductList" :key="product" class="mr-2">
|
|
|
+ {{ product }}
|
|
|
+ </el-tag>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="需求描述">
|
|
|
+ <div class="whitespace-pre-wrap">{{ clueInfo.requirement }}</div>
|
|
|
+ </el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <el-card shadow="hover">
|
|
|
+ <template #header>
|
|
|
+ <span class="font-bold">跟进统计</span>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-descriptions :column="1" border>
|
|
|
+ <el-descriptions-item label="跟进次数">
|
|
|
+ <span class="text-primary font-bold text-lg">{{ clueInfo.followCount }}</span> 次
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="最后跟进时间">
|
|
|
+ {{ clueInfo.lastTime }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="转化条件">
|
|
|
+ <el-tag v-if="canConvert" type="success">✅ 已满足</el-tag>
|
|
|
+ <el-tag v-else type="info">⏳ 未满足</el-tag>
|
|
|
+ </el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+
|
|
|
+ <div v-if="canConvert" class="mt-4">
|
|
|
+ <el-button type="primary" @click="handleConvert" :disabled="clueInfo.isConverted === 1" block> 立即转化为客户 </el-button>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+
|
|
|
+ <!-- 右侧:跟进记录等Tab -->
|
|
|
+ <el-col :span="16">
|
|
|
+ <el-card shadow="hover">
|
|
|
+ <template #header>
|
|
|
+ <div class="flex justify-between items-center">
|
|
|
+ <el-button type="primary" icon="Plus" @click="handleAddFollow" :disabled="clueInfo.isConverted === 1"> 添加跟进记录 </el-button>
|
|
|
+ <div>
|
|
|
+ <el-button type="warning" icon="Refresh" @click="handleConvert" :disabled="clueInfo.isConverted === 1"> 转为客户 </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-tabs v-model="activeTab">
|
|
|
+ <el-tab-pane label="跟进记录" name="follow">
|
|
|
+ <el-timeline v-if="followList.length > 0">
|
|
|
+ <el-timeline-item v-for="item in followList" :key="item.id" :timestamp="item.createTime" placement="top">
|
|
|
+ <el-card>
|
|
|
+ <template #header>
|
|
|
+ <div class="flex justify-between items-center">
|
|
|
+ <div>
|
|
|
+ <el-tag type="info" size="small">
|
|
|
+ <dict-tag :options="crm_follow_type" :value="item.followType" />
|
|
|
+ </el-tag>
|
|
|
+ <span class="ml-2 font-bold">{{ item.createByName }}</span>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <el-button link type="primary" size="small" icon="Edit" @click="handleEditFollow(item)" v-if="item.createBy === userId">
|
|
|
+ 修改
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ link
|
|
|
+ type="danger"
|
|
|
+ size="small"
|
|
|
+ icon="Delete"
|
|
|
+ @click="handleDeleteFollow(item)"
|
|
|
+ v-if="item.createBy === userId"
|
|
|
+ >
|
|
|
+ 删除
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <div class="whitespace-pre-wrap">{{ item.followContent }}</div>
|
|
|
+ <div v-if="item.nextTime" class="mt-2">
|
|
|
+ <el-icon><Clock /></el-icon>
|
|
|
+ <span class="ml-1 text-sm text-gray-500"> 下次联系时间:{{ item.nextTime }} </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 附件展示区域 -->
|
|
|
+ <div v-if="item.attachmentList && item.attachmentList.length > 0" class="attachment-section">
|
|
|
+ <div class="attachment-header">
|
|
|
+ <el-icon><Paperclip /></el-icon>
|
|
|
+ <span class="attachment-title">附件 ({{ item.attachmentList.length }})</span>
|
|
|
+ </div>
|
|
|
+ <div class="attachment-list">
|
|
|
+ <div v-for="(file, index) in item.attachmentList" :key="index" class="upload-file-card">
|
|
|
+ <!-- 缩略图区域 -->
|
|
|
+ <div class="upload-file-thumb">
|
|
|
+ <ImagePreview v-if="isImageFile(file)" :src="file.ossId" class="upload-thumb-image" :isOssId="true" />
|
|
|
+ <div v-else class="upload-file-placeholder">
|
|
|
+ <el-icon><Document /></el-icon>
|
|
|
+ <span class="file-ext">{{ getFileExt(file.name) }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 文件名称 -->
|
|
|
+ <div class="upload-file-name" :title="file.name">
|
|
|
+ {{ getShortFileName(file.name) }}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 下载按钮 -->
|
|
|
+ <div class="upload-file-card-actions">
|
|
|
+ <el-button type="success" size="small" @click.stop="handleDownloadAttachment(file)">
|
|
|
+ <el-icon><Download /></el-icon>
|
|
|
+ 下载
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-timeline-item>
|
|
|
+ </el-timeline>
|
|
|
+
|
|
|
+ <el-empty v-else description="暂无跟进记录" />
|
|
|
+
|
|
|
+ <!-- 分页 -->
|
|
|
+ <pagination
|
|
|
+ v-show="followTotal > 0"
|
|
|
+ :total="followTotal"
|
|
|
+ v-model:page="followQuery.pageNum"
|
|
|
+ v-model:limit="followQuery.pageSize"
|
|
|
+ @pagination="getFollowList"
|
|
|
+ />
|
|
|
+ </el-tab-pane>
|
|
|
+
|
|
|
+ <el-tab-pane label="操作日志" name="log">
|
|
|
+ <!-- 操作日志列表 -->
|
|
|
+ <el-table v-loading="logLoading" :data="operLogList" stripe>
|
|
|
+ <el-table-column label="操作时间" align="center" prop="operTime" width="160" />
|
|
|
+ <el-table-column label="操作模块" align="center" prop="title" width="120" />
|
|
|
+ <el-table-column label="操作类型" align="center" width="100">
|
|
|
+ <template #default="scope">
|
|
|
+ <dict-tag :options="sys_oper_type" :value="scope.row.businessType" />
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作人员" align="center" prop="operName" width="120" />
|
|
|
+ <el-table-column label="操作IP" align="center" prop="operIp" width="130" />
|
|
|
+ <el-table-column label="操作地点" align="center" prop="operLocation" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="操作状态" align="center" width="100">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-tag v-if="scope.row.status === 0" type="success">成功</el-tag>
|
|
|
+ <el-tag v-else type="danger">失败</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="耗时" align="center" width="100">
|
|
|
+ <template #default="scope">
|
|
|
+ <span>{{ scope.row.costTime }}ms</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" align="center" width="100">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-button link type="primary" @click="handleViewLogDetail(scope.row)"> 详情 </el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <!-- 操作日志分页 -->
|
|
|
+ <pagination
|
|
|
+ v-show="logTotal > 0"
|
|
|
+ :total="logTotal"
|
|
|
+ v-model:page="logQuery.pageNum"
|
|
|
+ v-model:limit="logQuery.pageSize"
|
|
|
+ @pagination="getOperLogList"
|
|
|
+ />
|
|
|
+
|
|
|
+ <el-empty v-if="operLogList.length === 0 && !logLoading" description="暂无操作日志" />
|
|
|
+ </el-tab-pane>
|
|
|
+
|
|
|
+ <el-tab-pane label="附件" name="attachment">
|
|
|
+ <!-- 附件统计 -->
|
|
|
+ <div v-if="allAttachments.length > 0" class="attachment-stats mb-4">
|
|
|
+ <el-icon class="mr-2"><Folder /></el-icon>
|
|
|
+ <span class="text-base font-semibold">共 {{ allAttachments.length }} 个附件</span>
|
|
|
+ <el-button type="primary" size="small" class="ml-4" @click="handleBatchDownload">
|
|
|
+ <el-icon><Download /></el-icon>
|
|
|
+ 批量下载
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 附件列表 -->
|
|
|
+ <div v-if="allAttachments.length > 0" class="all-attachments-list">
|
|
|
+ <div v-for="(file, index) in allAttachments" :key="index" class="upload-file-card">
|
|
|
+ <!-- 缩略图区域 -->
|
|
|
+ <div class="upload-file-thumb">
|
|
|
+ <ImagePreview v-if="isImageFile(file)" :src="file.ossId" class="upload-thumb-image" :isOssId="true" />
|
|
|
+ <div v-else class="upload-file-placeholder">
|
|
|
+ <el-icon><Document /></el-icon>
|
|
|
+ <span class="file-ext">{{ getFileExt(file.name) }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 文件信息 -->
|
|
|
+ <div class="upload-file-info">
|
|
|
+ <div class="upload-file-name" :title="file.name">
|
|
|
+ {{ file.name }}
|
|
|
+ </div>
|
|
|
+ <div class="upload-file-meta">
|
|
|
+ <span class="text-xs text-gray-500">来自: {{ file.followTime }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 操作按钮 -->
|
|
|
+ <div class="upload-file-card-actions">
|
|
|
+ <el-button type="primary" size="small" @click.stop="handleDownloadAttachment(file)">
|
|
|
+ <el-icon><Download /></el-icon>
|
|
|
+ 下载
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-empty v-else description="暂无附件" />
|
|
|
+ </el-tab-pane>
|
|
|
+ </el-tabs>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 编辑线索对话框 -->
|
|
|
+ <ClueForm ref="clueFormRef" @success="getClueInfo" />
|
|
|
+
|
|
|
+ <!-- 添加跟进记录对话框 -->
|
|
|
+ <FollowForm ref="followFormRef" @success="handleFollowSuccess" />
|
|
|
+
|
|
|
+ <!-- 转为客户对话框 -->
|
|
|
+ <ConvertForm ref="convertFormRef" @success="handleConvertSuccess" />
|
|
|
+ </el-drawer>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts" name="ClueDetailDrawer">
|
|
|
+import { ref, reactive, computed, watch, getCurrentInstance, toRefs } from 'vue';
|
|
|
+import { ElMessage, ElMessageBox } from 'element-plus';
|
|
|
+import { getClue, listClueFollow, delClueFollow } from '@/api/crm/clue';
|
|
|
+import type { ClueVO, ClueFollowVO, ClueFollowQuery } from '@/api/crm/clue/types';
|
|
|
+import { list as listOperLog } from '@/api/monitor/operlog';
|
|
|
+import type { OperLogVO, OperLogQuery } from '@/api/monitor/operlog/types';
|
|
|
+import { ComponentInternalInstance } from 'vue';
|
|
|
+import { useDict } from '@/utils/dict';
|
|
|
+import { useUserStore } from '@/store/modules/user';
|
|
|
+import ImagePreview from '@/components/ImagePreview/index.vue';
|
|
|
+import request from '@/utils/request';
|
|
|
+import ClueForm from './form.vue';
|
|
|
+import FollowForm from './followForm.vue';
|
|
|
+import ConvertForm from './convertForm.vue';
|
|
|
+
|
|
|
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
|
|
|
+const userStore = useUserStore();
|
|
|
+
|
|
|
+// 定义 emit
|
|
|
+const emit = defineEmits(['success']);
|
|
|
+
|
|
|
+// 字典数据
|
|
|
+const { crm_clue_source, crm_clue_level, crm_clue_status, crm_budget, crm_follow_type, crm_title, crm_industry, sys_oper_type } = toRefs<any>(
|
|
|
+ proxy?.useDict(
|
|
|
+ 'crm_clue_source',
|
|
|
+ 'crm_clue_level',
|
|
|
+ 'crm_clue_status',
|
|
|
+ 'crm_budget',
|
|
|
+ 'crm_follow_type',
|
|
|
+ 'crm_title',
|
|
|
+ 'crm_industry',
|
|
|
+ 'sys_oper_type'
|
|
|
+ )
|
|
|
+);
|
|
|
+
|
|
|
+// 当前用户ID
|
|
|
+const userId = computed(() => userStore.userId);
|
|
|
+
|
|
|
+// Drawer显示控制
|
|
|
+const drawerVisible = ref(false);
|
|
|
+
|
|
|
+// 线索ID(使用字符串避免大整数精度丢失)
|
|
|
+const clueId = ref<number | string>('');
|
|
|
+
|
|
|
+// 线索信息
|
|
|
+const clueInfo = ref<ClueVO>({} as ClueVO);
|
|
|
+
|
|
|
+// 跟进记录列表(带处理后的附件信息)
|
|
|
+interface FollowListItem extends ClueFollowVO {
|
|
|
+ attachmentList?: Array<{
|
|
|
+ ossId: string;
|
|
|
+ name: string;
|
|
|
+ url: string;
|
|
|
+ }>;
|
|
|
+}
|
|
|
+
|
|
|
+const followList = ref<FollowListItem[]>([]);
|
|
|
+const followTotal = ref(0);
|
|
|
+const followQuery = ref<ClueFollowQuery>({
|
|
|
+ clueId: '',
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 10
|
|
|
+});
|
|
|
+
|
|
|
+// 当前Tab
|
|
|
+const activeTab = ref('follow');
|
|
|
+
|
|
|
+// 操作日志相关
|
|
|
+const operLogList = ref<OperLogVO[]>([]);
|
|
|
+const logTotal = ref(0);
|
|
|
+const logLoading = ref(false);
|
|
|
+const logQuery = ref<OperLogQuery>({
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 10,
|
|
|
+ title: '',
|
|
|
+ operName: '',
|
|
|
+ businessType: '',
|
|
|
+ status: '',
|
|
|
+ operIp: '',
|
|
|
+ orderByColumn: 'oper_id',
|
|
|
+ isAsc: 'descending'
|
|
|
+});
|
|
|
+
|
|
|
+// 所有附件列表(汇总)
|
|
|
+interface AttachmentItem {
|
|
|
+ ossId: string;
|
|
|
+ name: string;
|
|
|
+ url: string;
|
|
|
+ followTime: string; // 跟进记录时间
|
|
|
+ followId: number; // 跟进记录ID
|
|
|
+}
|
|
|
+const allAttachments = ref<AttachmentItem[]>([]);
|
|
|
+
|
|
|
+// 对话框引用
|
|
|
+const clueFormRef = ref();
|
|
|
+const followFormRef = ref();
|
|
|
+const convertFormRef = ref();
|
|
|
+
|
|
|
+// 意向产品列表
|
|
|
+const intentProductList = computed(() => {
|
|
|
+ return clueInfo.value.intentProducts ? clueInfo.value.intentProducts.split(',') : [];
|
|
|
+});
|
|
|
+
|
|
|
+// 是否逾期
|
|
|
+const isOverdue = computed(() => {
|
|
|
+ if (!clueInfo.value.nextTime) return false;
|
|
|
+ return new Date(clueInfo.value.nextTime) < new Date();
|
|
|
+});
|
|
|
+
|
|
|
+// 是否今天
|
|
|
+const isToday = computed(() => {
|
|
|
+ if (!clueInfo.value.nextTime) return false;
|
|
|
+ const today = new Date().toISOString().split('T')[0];
|
|
|
+ const nextDate = clueInfo.value.nextTime.split(' ')[0];
|
|
|
+ return today === nextDate;
|
|
|
+});
|
|
|
+
|
|
|
+// 是否满足转化条件
|
|
|
+const canConvert = computed(() => {
|
|
|
+ return clueInfo.value.isConverted === 0 && clueInfo.value.followCount && clueInfo.value.followCount >= 1 && clueInfo.value.status !== 'invalid';
|
|
|
+});
|
|
|
+
|
|
|
+/** 计算天数 */
|
|
|
+const calculateDays = (createTime: string) => {
|
|
|
+ if (!createTime) return 0;
|
|
|
+ const create = new Date(createTime);
|
|
|
+ const now = new Date();
|
|
|
+ const diff = now.getTime() - create.getTime();
|
|
|
+ return Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
|
+};
|
|
|
+
|
|
|
+/** 获取线索信息 */
|
|
|
+const getClueInfo = async () => {
|
|
|
+ try {
|
|
|
+ const res = await getClue(clueId.value);
|
|
|
+ clueInfo.value = res.data;
|
|
|
+ } catch (error) {
|
|
|
+ console.error(error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/** 获取跟进记录列表 */
|
|
|
+const getFollowList = async () => {
|
|
|
+ try {
|
|
|
+ followQuery.value.clueId = clueId.value;
|
|
|
+ const res = await listClueFollow(followQuery.value);
|
|
|
+
|
|
|
+ // 先设置基本数据
|
|
|
+ followList.value = res.rows.map((item) => ({ ...item }));
|
|
|
+ followTotal.value = res.total;
|
|
|
+
|
|
|
+ // 异步处理附件信息(不阻塞数据展示)
|
|
|
+ res.rows.forEach(async (item, index) => {
|
|
|
+ if (item.attachments && item.attachments.trim()) {
|
|
|
+ try {
|
|
|
+ const ossIds = item.attachments
|
|
|
+ .split(',')
|
|
|
+ .map((id: string) => id.trim())
|
|
|
+ .filter((id: string) => id);
|
|
|
+
|
|
|
+ if (ossIds.length > 0) {
|
|
|
+ const attachmentList = await loadAttachmentsForFollow(ossIds);
|
|
|
+ // 更新对应项的附件列表
|
|
|
+ if (followList.value[index]) {
|
|
|
+ followList.value[index].attachmentList = attachmentList;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载附件失败:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取跟进记录失败:', error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/** 编辑线索 */
|
|
|
+const handleEdit = () => {
|
|
|
+ clueFormRef.value.open(clueId.value);
|
|
|
+};
|
|
|
+
|
|
|
+/** 添加跟进记录 */
|
|
|
+const handleAddFollow = () => {
|
|
|
+ followFormRef.value.open(clueId.value);
|
|
|
+};
|
|
|
+
|
|
|
+/** 跟进成功回调 */
|
|
|
+const handleFollowSuccess = () => {
|
|
|
+ getClueInfo();
|
|
|
+ getFollowList();
|
|
|
+};
|
|
|
+
|
|
|
+/** 编辑跟进记录 */
|
|
|
+const handleEditFollow = (item: ClueFollowVO) => {
|
|
|
+ followFormRef.value.open(clueId.value, item.id);
|
|
|
+};
|
|
|
+
|
|
|
+/** 删除跟进记录 */
|
|
|
+const handleDeleteFollow = async (item: ClueFollowVO) => {
|
|
|
+ try {
|
|
|
+ await proxy?.$modal.confirm('是否确认删除该跟进记录?');
|
|
|
+ await delClueFollow(item.id);
|
|
|
+ proxy?.$modal.msgSuccess('删除成功');
|
|
|
+ getClueInfo();
|
|
|
+ getFollowList();
|
|
|
+ } catch (error) {
|
|
|
+ console.error(error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/** 转为客户 */
|
|
|
+const handleConvert = () => {
|
|
|
+ convertFormRef.value.open(clueId.value);
|
|
|
+};
|
|
|
+
|
|
|
+/** 转化成功回调 */
|
|
|
+const handleConvertSuccess = () => {
|
|
|
+ proxy?.$modal.msgSuccess('转化成功');
|
|
|
+ getClueInfo();
|
|
|
+ emit('success');
|
|
|
+};
|
|
|
+
|
|
|
+/** 加载跟进记录的附件信息 */
|
|
|
+const loadAttachmentsForFollow = async (ossIds: string[]) => {
|
|
|
+ if (ossIds.length === 0) return [];
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await request({
|
|
|
+ url: `/resource/oss/listByIds/${ossIds.join(',')}`,
|
|
|
+ method: 'get'
|
|
|
+ });
|
|
|
+
|
|
|
+ if (response.code === 200 && response.data) {
|
|
|
+ return response.data.map((item: any) => ({
|
|
|
+ ossId: item.ossId,
|
|
|
+ name: item.originalName || '附件',
|
|
|
+ url: item.url || item.ossId
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载附件详细信息失败:', error);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果加载失败,返回默认信息
|
|
|
+ return ossIds.map((ossId, index) => ({
|
|
|
+ ossId,
|
|
|
+ name: `附件${index + 1}`,
|
|
|
+ url: ossId
|
|
|
+ }));
|
|
|
+};
|
|
|
+
|
|
|
+/** 判断是否为图片文件 */
|
|
|
+const isImageFile = (file: any) => {
|
|
|
+ if (!file || !file.name) return false;
|
|
|
+ const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
|
|
|
+ const ext = getFileExt(file.name).toLowerCase();
|
|
|
+ return imageExts.includes(ext);
|
|
|
+};
|
|
|
+
|
|
|
+/** 获取文件扩展名 */
|
|
|
+const getFileExt = (fileName: string) => {
|
|
|
+ if (!fileName) return 'FILE';
|
|
|
+ const ext = fileName.split('.').pop()?.toUpperCase();
|
|
|
+ return ext || 'FILE';
|
|
|
+};
|
|
|
+
|
|
|
+/** 获取缩写的文件名(保留后缀) */
|
|
|
+const getShortFileName = (fileName: string) => {
|
|
|
+ if (!fileName) return '';
|
|
|
+
|
|
|
+ const lastDotIndex = fileName.lastIndexOf('.');
|
|
|
+ if (lastDotIndex === -1) {
|
|
|
+ return fileName.length > 15 ? fileName.substring(0, 15) + '...' : fileName;
|
|
|
+ }
|
|
|
+
|
|
|
+ const name = fileName.substring(0, lastDotIndex);
|
|
|
+ const ext = fileName.substring(lastDotIndex);
|
|
|
+
|
|
|
+ if (name.length <= 12) {
|
|
|
+ return fileName;
|
|
|
+ }
|
|
|
+
|
|
|
+ return name.substring(0, 12) + '...' + ext;
|
|
|
+};
|
|
|
+
|
|
|
+/** 下载附件 */
|
|
|
+const handleDownloadAttachment = async (file: any) => {
|
|
|
+ if (!file.ossId) {
|
|
|
+ proxy?.$modal.msgError('无法获取文件下载地址');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await request({
|
|
|
+ url: '/resource/oss/download/' + file.ossId,
|
|
|
+ method: 'get',
|
|
|
+ responseType: 'blob',
|
|
|
+ timeout: 300000
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response) {
|
|
|
+ throw new Error('响应数据为空');
|
|
|
+ }
|
|
|
+
|
|
|
+ let blobData: Blob;
|
|
|
+ if (response instanceof Blob) {
|
|
|
+ blobData = response;
|
|
|
+ } else if (response.data instanceof Blob) {
|
|
|
+ blobData = response.data;
|
|
|
+ } else {
|
|
|
+ throw new Error('无效的响应数据格式');
|
|
|
+ }
|
|
|
+
|
|
|
+ const url = window.URL.createObjectURL(blobData);
|
|
|
+ const link = document.createElement('a');
|
|
|
+ link.href = url;
|
|
|
+ link.download = file.name || '附件';
|
|
|
+ link.style.display = 'none';
|
|
|
+ document.body.appendChild(link);
|
|
|
+ link.click();
|
|
|
+ document.body.removeChild(link);
|
|
|
+ window.URL.revokeObjectURL(url);
|
|
|
+
|
|
|
+ proxy?.$modal.msgSuccess('下载成功');
|
|
|
+ } catch (error) {
|
|
|
+ console.error('下载文件失败:', error);
|
|
|
+ proxy?.$modal.msgError('下载文件失败,请稍后重试');
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/** 获取操作日志列表 */
|
|
|
+const getOperLogList = async () => {
|
|
|
+ logLoading.value = true;
|
|
|
+ try {
|
|
|
+ // 通过线索ID筛选操作日志(在operUrl中包含线索ID)
|
|
|
+ const query = {
|
|
|
+ ...logQuery.value,
|
|
|
+ title: '线索管理' // 只显示线索管理模块的日志
|
|
|
+ };
|
|
|
+ const res = await listOperLog(query);
|
|
|
+
|
|
|
+ // 过滤出与当前线索相关的日志(URL包含当前线索ID)
|
|
|
+ const clueIdStr = String(clueId.value);
|
|
|
+ operLogList.value = res.rows.filter(
|
|
|
+ (log: OperLogVO) =>
|
|
|
+ log.operUrl &&
|
|
|
+ log.operUrl.includes(`/crm/clue`) &&
|
|
|
+ (log.operUrl.includes(`/${clueIdStr}`) || log.operParam?.includes(`"id":${clueIdStr}`) || log.operParam?.includes(`"clueId":${clueIdStr}`))
|
|
|
+ );
|
|
|
+
|
|
|
+ logTotal.value = operLogList.value.length;
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取操作日志失败:', error);
|
|
|
+ } finally {
|
|
|
+ logLoading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/** 查看日志详情 */
|
|
|
+const handleViewLogDetail = (row: OperLogVO) => {
|
|
|
+ const content = `
|
|
|
+ <div style="text-align: left; max-height: 500px; overflow-y: auto;">
|
|
|
+ <p><strong>操作模块:</strong>${row.title}</p>
|
|
|
+ <p><strong>操作人员:</strong>${row.operName}</p>
|
|
|
+ <p><strong>操作时间:</strong>${row.operTime}</p>
|
|
|
+ <p><strong>请求方法:</strong>${row.method}</p>
|
|
|
+ <p><strong>请求URL:</strong>${row.operUrl}</p>
|
|
|
+ <p><strong>操作IP:</strong>${row.operIp}</p>
|
|
|
+ <p><strong>操作地点:</strong>${row.operLocation}</p>
|
|
|
+ <p><strong>请求参数:</strong></p>
|
|
|
+ <pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto;">${row.operParam || '无'}</pre>
|
|
|
+ ${row.status === 1 ? `<p><strong style="color: red;">错误信息:</strong>${row.errorMsg}</p>` : ''}
|
|
|
+ <p><strong>返回结果:</strong></p>
|
|
|
+ <pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto;">${row.jsonResult || '无'}</pre>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ ElMessageBox.alert(content, '操作日志详情', {
|
|
|
+ dangerouslyUseHTMLString: true,
|
|
|
+ confirmButtonText: '关闭'
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+/** 加载所有附件(从所有跟进记录中汇总) */
|
|
|
+const loadAllAttachments = async () => {
|
|
|
+ allAttachments.value = [];
|
|
|
+
|
|
|
+ // 获取所有跟进记录(不分页)
|
|
|
+ try {
|
|
|
+ const res = await listClueFollow({
|
|
|
+ clueId: clueId.value,
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 999
|
|
|
+ });
|
|
|
+
|
|
|
+ // 遍历所有跟进记录,收集附件
|
|
|
+ const attachmentPromises = res.rows
|
|
|
+ .filter((item) => item.attachments && item.attachments.trim())
|
|
|
+ .map(async (item) => {
|
|
|
+ const ossIds = item.attachments
|
|
|
+ .split(',')
|
|
|
+ .map((id: string) => id.trim())
|
|
|
+ .filter((id: string) => id);
|
|
|
+
|
|
|
+ if (ossIds.length > 0) {
|
|
|
+ const attachmentList = await loadAttachmentsForFollow(ossIds);
|
|
|
+ return attachmentList.map((att) => ({
|
|
|
+ ...att,
|
|
|
+ followTime: item.createTime || '',
|
|
|
+ followId: item.id || 0
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ return [];
|
|
|
+ });
|
|
|
+
|
|
|
+ const results = await Promise.all(attachmentPromises);
|
|
|
+ allAttachments.value = results.flat();
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载附件列表失败:', error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/** 批量下载附件 */
|
|
|
+const handleBatchDownload = async () => {
|
|
|
+ if (allAttachments.value.length === 0) {
|
|
|
+ proxy?.$modal.msgWarning('没有可下载的附件');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ await proxy?.$modal.confirm(`确认要下载全部 ${allAttachments.value.length} 个附件吗?`);
|
|
|
+ proxy?.$modal.msgSuccess('开始批量下载,请稍候...');
|
|
|
+
|
|
|
+ // 依次下载所有附件
|
|
|
+ allAttachments.value.forEach((file, index) => {
|
|
|
+ setTimeout(() => {
|
|
|
+ handleDownloadAttachment(file);
|
|
|
+ }, index * 500); // 每个文件间隔500ms下载,避免并发过多
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ // 用户取消
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/** 监听Tab切换,懒加载数据 */
|
|
|
+watch(activeTab, (newTab) => {
|
|
|
+ if (newTab === 'log' && operLogList.value.length === 0) {
|
|
|
+ getOperLogList();
|
|
|
+ } else if (newTab === 'attachment' && allAttachments.value.length === 0) {
|
|
|
+ loadAllAttachments();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+/** 关闭Drawer */
|
|
|
+const handleClose = () => {
|
|
|
+ // 重置数据
|
|
|
+ activeTab.value = 'follow';
|
|
|
+ followList.value = [];
|
|
|
+ followTotal.value = 0;
|
|
|
+ operLogList.value = [];
|
|
|
+ logTotal.value = 0;
|
|
|
+ allAttachments.value = [];
|
|
|
+};
|
|
|
+
|
|
|
+/** 打开Drawer */
|
|
|
+const open = (id: number | string) => {
|
|
|
+ clueId.value = id;
|
|
|
+ drawerVisible.value = true;
|
|
|
+
|
|
|
+ // 加载数据
|
|
|
+ getClueInfo();
|
|
|
+ getFollowList();
|
|
|
+};
|
|
|
+
|
|
|
+// 暴露方法
|
|
|
+defineExpose({
|
|
|
+ open
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.drawer-container {
|
|
|
+ padding: 0 10px;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-descriptions__label) {
|
|
|
+ font-weight: 600;
|
|
|
+ width: 120px;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-timeline-item__timestamp) {
|
|
|
+ color: #909399;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 附件展示样式(参考宿舍卫生检查样式) */
|
|
|
+.attachment-section {
|
|
|
+ margin-top: 16px;
|
|
|
+ padding: 16px;
|
|
|
+ background: #fafafa;
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ border-radius: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.attachment-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ padding-bottom: 8px;
|
|
|
+ border-bottom: 1px solid #e4e7ed;
|
|
|
+}
|
|
|
+
|
|
|
+.attachment-title {
|
|
|
+ margin-left: 6px;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.attachment-list {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 文件卡片样式 */
|
|
|
+.upload-file-card {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #fff;
|
|
|
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
|
+ transition: all 0.3s;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-file-card:hover {
|
|
|
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
|
|
+ transform: translateY(-2px);
|
|
|
+}
|
|
|
+
|
|
|
+.upload-file-thumb {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ height: 110px;
|
|
|
+ background: #f5f5f5;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-thumb-image {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-file-placeholder {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #909399;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-file-placeholder .el-icon {
|
|
|
+ font-size: 28px;
|
|
|
+ margin-bottom: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-file-placeholder .file-ext {
|
|
|
+ font-size: 12px;
|
|
|
+ text-transform: uppercase;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-file-name {
|
|
|
+ padding: 6px 8px;
|
|
|
+ font-size: 11px;
|
|
|
+ color: #303133;
|
|
|
+ text-align: center;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ background: #fafafa;
|
|
|
+ border-top: 1px solid #f0f0f0;
|
|
|
+ line-height: 1.2;
|
|
|
+ height: 32px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ word-break: break-all;
|
|
|
+ max-width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-file-card-actions {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ padding: 6px 8px;
|
|
|
+ background: #fafafa;
|
|
|
+ border-top: 1px solid #f0f0f0;
|
|
|
+ transition: all 0.3s;
|
|
|
+ height: 38px;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-file-card-actions .el-button {
|
|
|
+ padding: 4px 12px;
|
|
|
+ border: 1px solid #e0e0e0;
|
|
|
+ background: #fff;
|
|
|
+ font-size: 12px;
|
|
|
+ border-radius: 3px;
|
|
|
+ transition: all 0.3s;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-file-card-actions .el-button:hover {
|
|
|
+ background: #67c23a;
|
|
|
+ border-color: #67c23a;
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+
|
|
|
+/* 附件统计区域 */
|
|
|
+.attachment-stats {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 16px;
|
|
|
+ background: #f0f9ff;
|
|
|
+ border: 1px solid #bfdbfe;
|
|
|
+ border-radius: 8px;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 附件汇总列表样式 */
|
|
|
+.all-attachments-list {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
|
+ gap: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.all-attachments-list .upload-file-card {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.all-attachments-list .upload-file-thumb {
|
|
|
+ height: 140px;
|
|
|
+}
|
|
|
+
|
|
|
+.all-attachments-list .upload-file-info {
|
|
|
+ flex: 1;
|
|
|
+ padding: 8px;
|
|
|
+ background: #fafafa;
|
|
|
+ border-top: 1px solid #f0f0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.all-attachments-list .upload-file-name {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #303133;
|
|
|
+ margin-bottom: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ background: transparent;
|
|
|
+ border: none;
|
|
|
+ padding: 0;
|
|
|
+ height: auto;
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+
|
|
|
+.all-attachments-list .upload-file-meta {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+/* 操作日志表格样式 */
|
|
|
+:deep(.el-table) {
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-table .el-table__header-wrapper th) {
|
|
|
+ background-color: #f5f7fa;
|
|
|
+ color: #606266;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+</style>
|