OfficeSystem/pages/customer/follow/detail/index.vue

872 lines
21 KiB
Vue
Raw Normal View History

2025-11-10 09:43:29 +08:00
<template>
<view class="followup-detail-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-content">
<text class="nav-btn" @click="handleBack"></text>
<text class="nav-title">跟进详情</text>
<text class="nav-btn" style="opacity: 0;">占位</text>
</view>
</view>
<!-- 内容区域 -->
<scroll-view class="content-scroll" scroll-y v-if="!loading">
<view style="padding: 16px">
<!-- 跟进人信息卡片 -->
<view class="info-card">
<view class="card-header">
<image
class="user-avatar"
:src="followupDetail.userAvatar || '/static/default-avatar.png'"
mode="aspectFill"
/>
<view class="user-info">
<text class="user-name">{{ followupDetail.userName || '--' }}</text>
<text class="user-role">销售经理</text>
</view>
2025-11-10 10:02:02 +08:00
2025-11-10 09:43:29 +08:00
</view>
</view>
<!-- 客户信息 -->
<view class="info-card" v-if="followupDetail.customerName">
<view class="card-title">客户信息</view>
<view class="info-row">
<text class="info-label">客户名称</text>
<text class="info-value">{{ followupDetail.customerName }}</text>
</view>
<view class="info-row" v-if="followupDetail.customerId">
<text class="info-label">客户ID</text>
<text class="info-value">{{ followupDetail.customerId }}</text>
</view>
</view>
<!-- 跟进内容 -->
<view class="info-card">
<view class="card-title">跟进内容</view>
<view class="content-text">{{ followupDetail.content || '暂无内容' }}</view>
<!-- 图片展示一行最多三个 -->
<view class="followup-images-wrapper" v-if="followupImages && followupImages.length > 0">
<view
class="followup-image-item"
v-for="(imageUrl, imgIndex) in followupImages"
:key="imgIndex"
@click="previewFollowupImages(followupImages, imgIndex)"
>
<image
:src="imageUrl"
mode="aspectFill"
class="followup-image"
/>
</view>
</view>
</view>
<!-- 附件列表 -->
<view class="info-card" v-if="followupAttachments.length > 0">
<view class="card-title">附件</view>
<view class="attachments-list">
<view
class="attachment-item"
v-for="(file, index) in followupAttachments"
:key="index"
@click="previewAttachment(file)"
>
<text class="file-icon">{{ getFileIcon(file.name || file.path) }}</text>
<view class="file-info">
<text class="file-name">{{ file.name || getFileNameFromUrl(file.path) }}</text>
<text class="file-size" v-if="file.size">{{ formatFileSize(file.size) }}</text>
</view>
<text class="preview-arrow"></text>
</view>
</view>
</view>
2025-11-10 09:43:29 +08:00
<!-- 下次跟进 -->
<view class="info-card" v-if="followupDetail.nextFollowTime">
2025-11-10 10:02:02 +08:00
<view class="card-title">时间信息</view>
2025-11-10 09:43:29 +08:00
<view class="info-row">
<text class="info-label">跟进时间</text>
2025-11-10 10:02:02 +08:00
<text class="info-value">{{ formatDateTime(followupDetail.followTime) }}</text>
</view>
<view class="info-row">
<text class="info-label">下次跟进</text>
2025-11-10 09:43:29 +08:00
<text class="info-value">{{ formatDateTime(followupDetail.nextFollowTime) }}</text>
</view>
2025-11-10 10:02:02 +08:00
<view class="info-row" v-if="followupDetail.createTime">
<text class="info-label">创建时间</text>
<text class="info-value">{{ followupDetail.createTime }}</text>
2025-11-10 09:43:29 +08:00
</view>
</view>
<!-- 客户状态信息 -->
<view class="info-card" v-if="followupDetail.status || followupDetail.customerStatus">
<view class="card-title">状态信息</view>
<view class="info-row" v-if="followupDetail.status">
<text class="info-label">跟进状态</text>
<view class="status-badge" :class="getStatusClass(followupDetail.status)">
<text>{{ getStatusText(followupDetail.status) }}</text>
</view>
</view>
<view class="info-row" v-if="followupDetail.customerStatus">
<text class="info-label">客户状态</text>
<view class="status-badge" :class="getCustomerStatusClass(followupDetail.customerStatus)">
<text>{{ getCustomerStatusText(followupDetail.customerStatus) }}</text>
</view>
</view>
<view class="info-row" v-if="followupDetail.intentLevel">
<text class="info-label">意向强度</text>
<text class="info-value">{{ getIntentStrengthText(followupDetail.intentLevel) }}</text>
</view>
<view class="info-row" v-if="followupDetail.customerIntentLevel">
<text class="info-label">客户意向强度</text>
<text class="info-value">{{ getIntentStrengthText(followupDetail.customerIntentLevel) }}</text>
</view>
<view class="info-row" v-if="followupDetail.intents">
<text class="info-label">客户意向</text>
<text class="info-value">{{ formatIntents(followupDetail.intents) }}</text>
</view>
2025-11-10 10:02:02 +08:00
<view class="info-row" v-if="followupDetail.followType || followupDetail.followMethod || followupDetail.type">
<text class="info-label">跟进方式</text>
<text class="info-value">{{ getFollowTypeText(followupDetail.followType || followupDetail.followMethod || followupDetail.type) }}</text>
</view>
2025-11-10 09:43:29 +08:00
</view>
<!-- 备注信息 -->
<view class="info-card" v-if="followupDetail.remark">
<view class="card-title">备注</view>
<view class="content-text">{{ followupDetail.remark }}</view>
</view>
<!-- 客户分析 -->
<view class="info-card" v-if="hasAnalysis">
<view class="card-title">客户分析</view>
<view class="info-row" v-if="followupDetail.concern">
<text class="info-label">顾虑点</text>
<text class="info-value">{{ followupDetail.concern }}</text>
</view>
<view class="info-row" v-if="followupDetail.pain">
<text class="info-label">痛点</text>
<text class="info-value">{{ followupDetail.pain }}</text>
</view>
<view class="info-row" v-if="followupDetail.attention">
<text class="info-label">关注点</text>
<text class="info-value">{{ followupDetail.attention }}</text>
</view>
<view class="info-row" v-if="followupDetail.demand">
<text class="info-label">需求点</text>
<text class="info-value">{{ followupDetail.demand }}</text>
</view>
</view>
2025-11-10 10:02:02 +08:00
2025-11-10 09:43:29 +08:00
</view>
</scroll-view>
<!-- 加载状态 -->
<view class="loading-container" v-if="loading">
<text class="loading-text">加载中...</text>
</view>
<!-- 错误状态 -->
<view class="error-container" v-if="error">
<text class="error-text">{{ error }}</text>
<view class="retry-btn" @click="loadFollowupDetail">
<text>重试</text>
</view>
</view>
2025-11-11 15:37:39 +08:00
<!-- 底部操作栏 -->
<view class="bottom-actions">
<view class="action-btn edit-btn" @click="handleEdit">
<text>编辑</text>
</view>
<view class="action-btn delete-btn" @click="handleDelete">
<text>删除</text>
</view>
</view>
2025-11-10 09:43:29 +08:00
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
2025-11-12 15:33:53 +08:00
import { getFollowupDetail, getCustomerFollowTypeDict, deleteFollowup } from '@/api/customer';
2025-11-11 11:19:52 +08:00
import {
getCustomerStatusText,
getCustomerStatusClass,
2025-11-11 12:00:18 +08:00
getIntentLevelText,
getFollowTypeText as getFollowTypeTextFromMapping
2025-11-11 11:19:52 +08:00
} from '@/utils/customerMappings';
2025-11-10 09:43:29 +08:00
// 页面参数
const followId = ref('');
const followupDetail = ref({});
const loading = ref(false);
const error = ref('');
2025-11-10 10:02:02 +08:00
// 跟进方式字典数据
const followTypeOptions = ref([]);
2025-11-10 09:43:29 +08:00
// 计算是否有客户分析信息
const hasAnalysis = computed(() => {
return followupDetail.value.concern ||
followupDetail.value.pain ||
followupDetail.value.attention ||
followupDetail.value.demand;
});
// 计算图片列表(支持多种字段名和格式)
const followupImages = computed(() => {
const detail = followupDetail.value;
// 处理 picture 字段(字符串格式,逗号分隔)
if (detail.picture && typeof detail.picture === 'string' && detail.picture.trim()) {
const images = detail.picture.split(',').map(url => url.trim()).filter(url => url);
if (images.length > 0) {
return images;
}
}
// 支持数组格式的图片字段
if (detail.pictures && Array.isArray(detail.pictures) && detail.pictures.length > 0) {
return detail.pictures;
}
if (detail.images && Array.isArray(detail.images) && detail.images.length > 0) {
return detail.images;
}
if (detail.imageAttachments && Array.isArray(detail.imageAttachments) && detail.imageAttachments.length > 0) {
return detail.imageAttachments;
}
return [];
});
// 计算附件列表
const followupAttachments = computed(() => {
const detail = followupDetail.value;
const attachments = [];
const normalizeAttachment = (item) => {
if (!item) return null;
if (typeof item === 'string') {
const trimmed = item.trim();
if (!trimmed) return null;
return {
path: trimmed,
name: getFileNameFromUrl(trimmed),
size: 0
};
}
if (typeof item === 'object' && item.path) {
return {
path: item.path,
name: item.name || getFileNameFromUrl(item.path),
size: item.size || 0
};
}
return null;
};
const attachSources = [
detail.attaches,
detail.attachments,
detail.files
];
attachSources.forEach((source) => {
if (!source) return;
if (typeof source === 'string') {
source.split(',').forEach((url) => {
const normalized = normalizeAttachment(url);
if (normalized) attachments.push(normalized);
});
} else if (Array.isArray(source)) {
source.forEach((item) => {
const normalized = normalizeAttachment(item);
if (normalized) attachments.push(normalized);
});
}
});
return attachments;
});
2025-11-10 09:43:29 +08:00
// 获取页面参数
onLoad((options) => {
if (options && options.followId) {
followupDetail.value.followId = options.followId;
followId.value = options.followId;
loadFollowupDetail();
} else if (options && options.id) {
// 兼容 id 参数
followupDetail.value.followId = options.id;
followId.value = options.id;
loadFollowupDetail();
} else {
error.value = '缺少跟进ID参数';
}
});
2025-11-10 10:02:02 +08:00
// 加载跟进方式字典数据
const loadFollowTypeDict = async () => {
try {
const res = await getCustomerFollowTypeDict();
if (res && Array.isArray(res)) {
followTypeOptions.value = res;
}
} catch (err) {
console.error('加载跟进方式字典失败:', err);
}
};
2025-11-10 09:43:29 +08:00
// 加载跟进详情
const loadFollowupDetail = async () => {
if (!followId.value) return;
loading.value = true;
error.value = '';
try {
2025-11-10 10:02:02 +08:00
// 并行加载跟进详情和字典数据
const [res] = await Promise.all([
getFollowupDetail(followId.value),
loadFollowTypeDict()
]);
2025-11-10 09:43:29 +08:00
if (res) {
followupDetail.value = res;
} else {
error.value = '获取跟进详情失败';
}
} catch (err) {
console.error('加载跟进详情失败:', err);
error.value = err?.message || '加载跟进详情失败,请重试';
uni.$uv.toast(error.value);
} finally {
loading.value = false;
}
};
// 格式化日期时间
const formatDateTime = (dateTime) => {
if (!dateTime) return '--';
try {
const date = new Date(dateTime);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
} catch (e) {
return dateTime;
}
};
// 格式化客户意向
const formatIntents = (intents) => {
if (!intents) return '--';
if (Array.isArray(intents)) {
return intents.length > 0 ? intents.join('、') : '--';
}
if (typeof intents === 'string') {
return intents || '--';
}
return '--';
};
2025-11-11 11:19:52 +08:00
// 使用统一映射函数(保持原有函数名以兼容模板)
const getStatusClass = getCustomerStatusClass;
const getStatusText = getCustomerStatusText;
// getCustomerStatusClass 和 getCustomerStatusText 直接使用导入的函数
const getIntentStrengthText = getIntentLevelText;
2025-11-10 09:43:29 +08:00
2025-11-11 12:00:18 +08:00
// 使用统一映射函数获取跟进方式文本
2025-11-10 10:02:02 +08:00
const getFollowTypeText = (followTypeValue) => {
2025-11-11 12:00:18 +08:00
return getFollowTypeTextFromMapping(followTypeValue, followTypeOptions.value);
2025-11-10 10:02:02 +08:00
};
2025-11-10 09:43:29 +08:00
// 预览图片
const previewFollowupImages = (images, currentIndex) => {
if (!images || images.length === 0) return;
uni.previewImage({
urls: images,
current: currentIndex
});
};
// 获取文件名
function getFileNameFromUrl(url = '') {
try {
const decodedUrl = decodeURIComponent(url);
const parts = decodedUrl.split('/');
return parts.pop() || decodedUrl;
} catch (err) {
return url;
}
}
// 获取文件图标
function getFileIcon(filename = '') {
const ext = filename.split('.').pop()?.toLowerCase() || '';
const iconMap = {
pdf: '📄',
doc: '📝',
docx: '📝',
xls: '📊',
xlsx: '📊',
ppt: '📈',
pptx: '📈',
txt: '📄',
zip: '📦',
rar: '📦',
jpg: '🖼️',
jpeg: '🖼️',
png: '🖼️',
gif: '🖼️',
mp4: '🎞️',
mp3: '🎵'
};
return iconMap[ext] || '📁';
}
// 格式化文件大小
function formatFileSize(bytes) {
if (!bytes || bytes <= 0) return '';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}
// 预览或下载附件
const previewAttachment = (file) => {
if (!file || !file.path) {
uni.showToast({
title: '文件不存在',
icon: 'none'
});
return;
}
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
const filename = file.name || getFileNameFromUrl(file.path);
const ext = filename.split('.').pop()?.toLowerCase() || '';
if (imageExts.includes(ext)) {
uni.previewImage({
urls: [file.path],
current: file.path
});
return;
}
// #ifdef H5
window.open(file.path, '_blank');
// #endif
// #ifdef APP-PLUS
if (typeof plus !== 'undefined' && plus.runtime) {
plus.runtime.openURL(file.path);
return;
}
// #endif
// 其他平台尝试下载并打开
uni.downloadFile({
url: file.path,
success: (res) => {
if (res.statusCode === 200) {
uni.openDocument({
filePath: res.tempFilePath,
success: () => {
console.log('打开附件成功');
},
fail: (err) => {
console.error('打开附件失败:', err);
uni.showToast({
title: '无法打开此文件',
icon: 'none'
});
}
});
} else {
uni.showToast({
title: '下载文件失败',
icon: 'none'
});
}
},
fail: (err) => {
console.error('下载附件失败:', err);
uni.showToast({
title: '下载文件失败',
icon: 'none'
});
}
});
};
2025-11-10 09:43:29 +08:00
// 返回
const handleBack = () => {
uni.navigateBack();
};
2025-11-11 15:37:39 +08:00
// 编辑
const handleEdit = () => {
if (!followId.value) {
uni.$uv?.toast?.('缺少跟进ID');
return;
}
uni.navigateTo({
url: `/pages/customer/follow/edit/index?followId=${followId.value}`
});
};
// 删除
const handleDelete = () => {
if (!followId.value) {
uni.$uv?.toast?.('缺少跟进ID');
return;
}
uni.showModal({
title: '确认删除',
content: '确定要删除这条跟进记录吗?删除后无法恢复。',
confirmText: '删除',
confirmColor: '#f56c6c',
success: async (res) => {
if (res.confirm) {
try {
await deleteFollowup(followId.value);
uni.$uv?.toast?.('删除成功');
// 返回上一页(通常为列表或详情来源页)
uni.navigateBack();
} catch (err) {
console.error('删除跟进记录失败:', err);
uni.$uv?.toast?.(err?.message || '删除失败,请重试');
}
}
}
});
};
2025-11-10 09:43:29 +08:00
// 组件挂载
onMounted(() => {
// 数据已在 onLoad 中加载
});
</script>
<style lang="scss" scoped>
.followup-detail-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.custom-navbar {
background-color: #fff;
padding-top: var(--status-bar-height, 0);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
z-index: 100;
}
.navbar-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 16px;
}
.nav-btn {
font-size: 24px;
color: #333;
font-weight: bold;
min-width: 44px;
text-align: center;
}
.nav-title {
font-size: 18px;
font-weight: 600;
color: #333;
flex: 1;
text-align: center;
}
.content-scroll {
flex: 1;
overflow-y: auto;
2025-11-11 15:37:39 +08:00
padding-bottom: 72px; /* 预留底部操作栏空间 */
2025-11-10 09:43:29 +08:00
}
.info-card {
background-color: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 16px;
}
.user-avatar {
width: 48px;
height: 48px;
border-radius: 24px;
margin-right: 12px;
background-color: #e0e0e0;
border: 2px solid #f5f5f5;
}
.user-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.user-name {
font-size: 16px;
color: #333;
font-weight: 600;
}
.user-role {
font-size: 12px;
color: #999;
}
.follow-time {
display: flex;
align-items: center;
}
.time-text {
font-size: 12px;
color: #999;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.info-row {
display: flex;
align-items: flex-start;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.info-label {
font-size: 14px;
color: #999;
min-width: 80px;
margin-right: 12px;
}
.info-value {
flex: 1;
font-size: 14px;
color: #333;
word-break: break-word;
}
.content-text {
font-size: 15px;
color: #555;
line-height: 1.7;
word-break: break-word;
white-space: pre-wrap;
margin-bottom: 12px;
}
/* 图片展示(一行最多三个) */
.followup-images-wrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.followup-image-item {
/* 一行三个:每个图片宽度 = (100% - 2个gap) / 3 */
width: calc((100% - 16px) / 3);
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
background-color: #e0e0e0;
flex-shrink: 0;
}
.followup-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachments-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.attachment-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.file-icon {
font-size: 24px;
margin-right: 12px;
}
.file-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.file-name {
font-size: 14px;
color: #333;
word-break: break-word;
}
.file-size {
font-size: 12px;
color: #999;
}
.preview-arrow {
font-size: 20px;
color: #bbb;
margin-left: 12px;
}
2025-11-10 09:43:29 +08:00
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
2025-11-10 14:31:32 +08:00
&.status-potential {
background-color: #fdf6ec;
color: #e6a23c;
2025-11-10 09:43:29 +08:00
}
2025-11-10 14:31:32 +08:00
&.status-intent {
background-color: #ecf5ff;
color: #409eff;
}
&.status-deal {
background-color: #f0f9ff;
color: #67c23a;
}
&.status-invalid {
background-color: #fef0f0;
color: #f56c6c;
2025-11-10 09:43:29 +08:00
}
}
.loading-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.loading-text {
font-size: 14px;
color: #999;
}
.error-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.error-text {
font-size: 14px;
color: #f56c6c;
margin-bottom: 20px;
text-align: center;
}
.retry-btn {
padding: 8px 24px;
background-color: #1976d2;
color: #fff;
border-radius: 4px;
font-size: 14px;
&:active {
opacity: 0.8;
}
}
2025-11-11 15:37:39 +08:00
/* 底部操作栏与按钮(左右各占一半) */
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
z-index: 200;
background-color: #fff;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
.action-btn {
flex: 1;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.edit-btn {
color: #1976d2;
border-right: 1px solid #f0f0f0;
}
.delete-btn {
color: #f56c6c;
}
2025-11-10 09:43:29 +08:00
</style>