Browse Source

提交代码

zlwu 1 năm trước cách đây
mục cha
commit
0c2bda4451
2 tập tin đã thay đổi với 1011 bổ sung và 4 xóa
  1. 1005 0
      web/src/views/crm/clue/detailDrawer.vue
  2. 6 4
      web/src/views/crm/clue/index.vue

+ 1005 - 0
web/src/views/crm/clue/detailDrawer.vue

@@ -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>

+ 6 - 4
web/src/views/crm/clue/index.vue

@@ -192,6 +192,9 @@
     <!-- 批量分配对话框 -->
     <AssignForm ref="assignFormRef" @success="getList" />
 
+    <!-- 详情抽屉 -->
+    <DetailDrawer ref="detailDrawerRef" @success="getList" />
+
     <!-- 导入对话框 -->
     <el-dialog title="线索导入" v-model="importDialog.visible" width="500px" append-to-body>
       <el-upload
@@ -241,6 +244,7 @@ import ClueForm from './form.vue';
 import FollowForm from './followForm.vue';
 import ConvertForm from './convertForm.vue';
 import AssignForm from './assignForm.vue';
+import DetailDrawer from './detailDrawer.vue';
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
@@ -286,6 +290,7 @@ const followFormRef = ref();
 const convertFormRef = ref();
 const assignFormRef = ref();
 const queryFormRef = ref();
+const detailDrawerRef = ref();
 
 // 导入对话框
 const importDialog = reactive({
@@ -414,10 +419,7 @@ const handleUpdate = async (row?: ClueVO) => {
 
 /** 查看详情 */
 const handleView = (row: ClueVO) => {
-  proxy?.$router.push({
-    path: '/crm/clue/detail',
-    query: { id: row.id }
-  });
+  detailDrawerRef.value.open(row.id);
 };
 
 /** 添加跟进记录 */