OfficeSystem/pages/customer/follow/detail/index.vue
2025-11-11 12:00:18 +08:00

794 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
</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>
<!-- 下次跟进 -->
<view class="info-card" v-if="followupDetail.nextFollowTime">
<view class="card-title">时间信息</view>
<view class="info-row">
<text class="info-label">跟进时间</text>
<text class="info-value">{{ formatDateTime(followupDetail.followTime) }}</text>
</view>
<view class="info-row">
<text class="info-label">下次跟进</text>
<text class="info-value">{{ formatDateTime(followupDetail.nextFollowTime) }}</text>
</view>
<view class="info-row" v-if="followupDetail.createTime">
<text class="info-label">创建时间</text>
<text class="info-value">{{ followupDetail.createTime }}</text>
</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>
<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>
</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>
</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>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { getFollowupDetail, getCustomerFollowTypeDict } from '@/common/api/customer';
import {
getCustomerStatusText,
getCustomerStatusClass,
getIntentLevelText,
getFollowTypeText as getFollowTypeTextFromMapping
} from '@/utils/customerMappings';
// 页面参数
const followId = ref('');
const followupDetail = ref({});
const loading = ref(false);
const error = ref('');
// 跟进方式字典数据
const followTypeOptions = ref([]);
// 计算是否有客户分析信息
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;
});
// 获取页面参数
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参数';
}
});
// 加载跟进方式字典数据
const loadFollowTypeDict = async () => {
try {
const res = await getCustomerFollowTypeDict();
if (res && Array.isArray(res)) {
followTypeOptions.value = res;
}
} catch (err) {
console.error('加载跟进方式字典失败:', err);
}
};
// 加载跟进详情
const loadFollowupDetail = async () => {
if (!followId.value) return;
loading.value = true;
error.value = '';
try {
// 并行加载跟进详情和字典数据
const [res] = await Promise.all([
getFollowupDetail(followId.value),
loadFollowTypeDict()
]);
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 '--';
};
// 使用统一映射函数(保持原有函数名以兼容模板)
const getStatusClass = getCustomerStatusClass;
const getStatusText = getCustomerStatusText;
// getCustomerStatusClass 和 getCustomerStatusText 直接使用导入的函数
const getIntentStrengthText = getIntentLevelText;
// 使用统一映射函数获取跟进方式文本
const getFollowTypeText = (followTypeValue) => {
return getFollowTypeTextFromMapping(followTypeValue, followTypeOptions.value);
};
// 预览图片
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'
});
}
});
};
// 返回
const handleBack = () => {
uni.navigateBack();
};
// 组件挂载
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;
}
.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;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
&.status-potential {
background-color: #fdf6ec;
color: #e6a23c;
}
&.status-intent {
background-color: #ecf5ff;
color: #409eff;
}
&.status-deal {
background-color: #f0f9ff;
color: #67c23a;
}
&.status-invalid {
background-color: #fef0f0;
color: #f56c6c;
}
}
.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;
}
}
</style>