Files
smartDriveEEUniApp/pages-subpackage/furniture_customer/components/ServiceList.vue
2026-04-19 21:00:05 +08:00

613 lines
15 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="service-list-container">
<view class="service-status-toolbar">
<text class="toolbar-total">{{ total || list.length }}</text>
<input
class="toolbar-input"
v-model="salesName"
placeholder="所属销售姓名"
placeholder-style="color: #b0b0b0"
confirm-type="search"
@confirm="handleRefresh"
@input="onInputChange"
/>
<input
class="toolbar-input"
v-model="customerName"
placeholder="客户姓名"
placeholder-style="color: #b0b0b0"
confirm-type="search"
@confirm="handleRefresh"
@input="onInputChange"
/>
<view class="toolbar-actions">
<view class="toolbar-btn toolbar-btn--refresh" @click="handleRefresh">
<uni-icons type="refresh" size="18" color="#2A68FF"></uni-icons>
<text>刷新</text>
</view>
</view>
</view>
<scroll-view class="service-list" scroll-y @scrolltolower="onReachBottom">
<view
class="service-item"
v-for="(item, index) in list"
:key="index"
:class="{ 'service-item--active': selectedServiceItem && selectedServiceItem.id === item.id }"
@click="handleItemClick(item)">
<view class="service-header">
<text class="service-title">{{ item.title }}</text>
<view class="service-menu-button" @click.stop="onMenuButtonClick(item)">
<uni-icons type="more-filled" size="20" color="#999"></uni-icons>
</view>
</view>
<view class="service-meta">
<text class="service-date">{{ item.date }}</text>
<text
class="service-duration"
v-if="durationPresent(item.duration)">
录音时长{{ formatRecordingMinutes(item.duration) }} 分钟
</text>
<text
class="service-duration"
v-if="getRecordingFileExtension(item)">
格式{{ getRecordingFileExtension(item) }}
</text>
</view>
<view class="service-recording-name" v-if="item.recordingName">
<text class="recording-name-label">记录名称</text>
<text class="recording-name-value">{{ item.recordingName }}</text>
</view>
<view class="service-recording-text" v-if="getRecordingText(item)">
<text class="recording-text-label">录音文本</text>
<view class="recording-text-body">{{ getRecordingText(item) }}</view>
</view>
<view class="service-content">
<text class="service-desc">{{ item.description }}</text>
</view>
<view class="service-customer">
<view class="customer-avatar">
<text class="avatar-text">{{ item.customerName.charAt(0) }}</text>
</view>
<text class="customer-name">{{ item.customerName }}</text>
</view>
<!-- 操作菜单 -->
<view
class="service-action-menu"
v-if="selectedServiceItem && selectedServiceItem.id === item.id && showServiceActionMenu"
@click.stop>
<view class="service-action-menu-divider"></view>
<view class="service-action-menu-item" @click="handleView(item)">
<text>查看</text>
</view>
<view class="service-action-menu-divider"></view>
<view class="service-action-menu-item" @click="handleAnalysis(item)">
<text>AI分析</text>
</view>
<view class="service-action-menu-divider"></view>
<view class="service-action-menu-item" @click="handleAnalysisDetail(item)">
<text>AI精提</text>
</view>
<view class="service-action-menu-divider"></view>
<view class="service-action-menu-item" @click="handleAudio(item)">
<text>录音</text>
</view>
</view>
</view>
</scroll-view>
<!-- 遮罩层点击关闭菜单 -->
<view
class="service-action-menu-mask"
v-if="showServiceActionMenu"
@click="closeServiceActionMenu">
</view>
</view>
</template>
<script>
import { getApiUrl } from '@/common/config.js';
export default {
name: 'ServiceList',
props: {
list: {
type: Array,
default: () => []
},
total: {
type: Number,
default: 0
}
},
data() {
return {
salesName: '',
customerName: '',
selectedServiceItem: null,
showServiceActionMenu: false
}
},
methods: {
durationPresent(val) {
if (val === null || val === undefined || val === '') return false;
const n = Number(val);
return !Number.isNaN(n) && n >= 0;
},
/** 后端 duration 为分钟(可小数),页面原样按分钟展示,最多保留两位小数 */
formatRecordingMinutes(minutes) {
const n = Number(minutes);
if (Number.isNaN(n) || n < 0) return '0';
return String(parseFloat(n.toFixed(2)));
},
formatFileExtension(ext) {
if (ext == null || ext === '') return '';
const s = String(ext).trim();
if (!s) return '';
return s.startsWith('.') ? s.slice(1) : s;
},
getRecordingFileExtension(item) {
const raw = item.rawData || {};
const fromField = this.formatFileExtension(item.audioFileExtension ?? raw.audioFileExtension);
if (fromField) return fromField;
const name = raw.audioFileOriginalName;
if (name && typeof name === 'string') {
const idx = name.lastIndexOf('.');
if (idx !== -1 && idx < name.length - 1) {
return name.slice(idx + 1).trim().toLowerCase();
}
}
return '';
},
getRecordingText(item) {
const raw = item.rawData || {};
const t = item.recordingText ?? raw.recordingText;
if (t == null || t === '') return '';
const s = String(t).trim();
return s;
},
onInputChange() {
// 输入框变化时,可以在这里添加实时搜索逻辑(如果需要)
},
handleRefresh() {
// 触发刷新事件,并传递查询参数
this.$emit('filterClick', {
salesName: this.salesName.trim(),
customerName: this.customerName.trim()
});
},
onReachBottom() {
this.$emit('reachBottom');
},
// 菜单按钮点击
onMenuButtonClick(item) {
// 如果已经选中当前项,则关闭菜单
if (this.selectedServiceItem && this.selectedServiceItem.id === item.id && this.showServiceActionMenu) {
this.closeServiceActionMenu();
return;
}
// 显示菜单
this.selectedServiceItem = item;
this.showServiceActionMenu = true;
},
// 关闭菜单
closeServiceActionMenu() {
this.showServiceActionMenu = false;
this.selectedServiceItem = null;
},
// 处理删除
handleDelete(item) {
this.closeServiceActionMenu();
this.$emit('menuAction', {
action: 'delete',
item: item
});
},
// 处理查看
handleView(item) {
this.closeServiceActionMenu();
this.$emit('menuAction', {
action: 'view',
item: item
});
},
// 处理AI分析与 AI精提 共用请求体 AudioManagement仅接口路径与文案不同
async postAudioManagementAi(item, { apiPath, loadingTitle, successFallback, errorLabel }) {
this.closeServiceActionMenu();
try {
const recordId = item.id || item.rawData?.id;
if (!recordId) {
uni.showToast({
title: '无法获取记录ID',
icon: 'none'
});
return;
}
const audioManagement = item.rawData || {
id: recordId
};
uni.showLoading({
title: loadingTitle
});
const url = getApiUrl(apiPath);
let tenantId = '';
let token = '';
try {
tenantId = uni.getStorageSync('backend-tenant-id') || '';
token = uni.getStorageSync('backend-token') || '';
} catch (e) {
console.error('获取认证信息失败:', e);
}
const headers = {
'Content-Type': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (tenantId) {
headers['X-Tenant-Id'] = tenantId;
}
const res = await uni.request({
url: url,
method: 'POST',
data: audioManagement,
header: headers,
timeout: 60000
});
uni.hideLoading();
if (res.statusCode === 200 && res.data && res.data.success) {
uni.showToast({
title: res.data.message || successFallback,
icon: 'success',
duration: 2000
});
if (res.data.data) {
const d = res.data.data;
const updatedItem = {
...item,
rawData: d,
description: d.summary || item.description,
duration: d.duration != null ? d.duration : item.duration,
audioFileExtension: d.audioFileExtension != null ? d.audioFileExtension : item.audioFileExtension,
recordingText: d.recordingText != null ? d.recordingText : item.recordingText
};
this.$emit('updateItem', {
recordId: recordId,
data: updatedItem
});
this.$emit('itemClick', updatedItem);
} else {
this.$emit('itemClick', item);
}
} else {
uni.showToast({
title: res.data?.message || `${errorLabel}失败`,
icon: 'none',
duration: 2000
});
}
} catch (error) {
uni.hideLoading();
console.error(`${errorLabel}失败:`, error);
uni.showToast({
title: `${errorLabel}失败: ${error.errMsg || error.message || '未知错误'}`,
icon: 'none',
duration: 2000
});
}
},
handleAnalysis(item) {
return this.postAudioManagementAi(item, {
apiPath: '/api/audioManagement/AIAnlyzById',
loadingTitle: 'AI分析中...',
successFallback: 'AI分析请求已提交',
errorLabel: 'AI分析'
});
},
handleAnalysisDetail(item) {
return this.postAudioManagementAi(item, {
apiPath: '/api/audioManagement/AIAnlyzByIdForDetail',
loadingTitle: 'AI精提中...',
successFallback: 'AI精提请求已提交',
errorLabel: 'AI精提'
});
},
// 处理录音
handleAudio(item) {
this.closeServiceActionMenu();
this.$emit('menuAction', {
action: 'audio',
item: item
});
},
// 处理列表项点击
handleItemClick(item) {
// 如果菜单正在显示,不触发列表项点击
if (this.showServiceActionMenu) {
return;
}
// 触发列表项点击事件,打开详情页面
this.$emit('itemClick', item);
}
}
}
</script>
<style scoped>
.service-list-container {
/* 使用绝对定位从tab页底部开始到底部导航栏结束 */
position: absolute;
top: 0; /* top值通过父组件的内联样式动态设置 */
left: 0;
right: 0;
bottom: 96rpx; /* 底部导航栏高度 */
display: flex;
flex-direction: column;
background-color: #F5F5F5;
padding: 0 16rpx; /* 移除顶部padding只保留左右padding让toolbar紧贴tab */
box-sizing: border-box;
}
.service-status-toolbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8rpx;
padding: 12rpx 8rpx;
margin-bottom: 24rpx; /* 与列表项间距保持一致24rpx */
background-color: #F8F8FA;
border-radius: 8rpx;
border: 1px solid #EFEFF2;
overflow-x: auto;
min-height: 64rpx;
box-sizing: border-box;
flex-shrink: 0; /* 防止被压缩 */
}
.toolbar-total {
font-size: 24rpx;
color: #666;
white-space: nowrap;
flex-shrink: 0;
margin-right: 8rpx; /* 增加右边距,与搜索框保持距离 */
line-height: 1.2;
padding: 0 4rpx; /* 增加左右内边距 */
}
.toolbar-input {
flex: 1;
min-width: 120rpx; /* 增加最小宽度,避免被挤压变形 */
max-width: 200rpx; /* 设置最大宽度,保持合理比例 */
height: 56rpx; /* 稍微增加高度,提升可用性 */
line-height: 56rpx;
background-color: #F5F6FA;
border-radius: 8rpx;
padding: 0 12rpx; /* 增加左右内边距 */
font-size: 24rpx;
border: 1px solid transparent;
box-sizing: border-box;
flex-shrink: 1; /* 允许适当收缩,但保持最小宽度 */
}
.toolbar-actions {
display: flex;
align-items: center;
margin-left: auto;
flex-shrink: 0;
gap: 8rpx; /* 增加按钮之间的间距 */
}
.toolbar-btn {
padding: 0 12rpx; /* 增加左右内边距 */
height: 56rpx; /* 与输入框高度保持一致 */
min-width: 56rpx;
color: #2A68FF;
display: flex;
align-items: center;
justify-content: center;
gap: 4rpx; /* 增加图标和文字之间的间距 */
font-size: 24rpx;
font-weight: 400;
line-height: 1;
box-sizing: border-box;
}
.toolbar-btn text {
color: #2A68FF;
}
.service-list {
/* 使用flex填充剩余空间 */
flex: 1;
background-color: transparent;
box-sizing: border-box;
overflow-y: auto;
}
.service-item {
width: 100%;
box-sizing: border-box;
background-color: #FFFFFF;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
position: relative;
}
.service-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
position: relative;
}
.service-title {
font-size: 32rpx;
font-weight: 500;
color: #333;
}
.service-menu-button {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.service-action-menu {
position: absolute;
top: 60rpx;
right: 32rpx;
width: 160rpx;
background-color: #FFFFFF;
border-radius: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
z-index: 100;
overflow: hidden;
margin-right: 0;
}
.service-action-menu-item {
padding: 24rpx 32rpx;
font-size: 28rpx;
color: #333;
text-align: center;
background-color: #FFFFFF;
}
.service-action-menu-item:active {
background-color: #F5F5F5;
}
.service-action-menu-item--danger {
color: #FF5722;
}
.service-action-menu-divider {
height: 1rpx;
background-color: #E0E0E0;
margin: 0 16rpx;
}
.service-action-menu-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: transparent;
z-index: 99;
}
.service-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 16rpx;
gap: 16rpx;
}
.service-date {
font-size: 24rpx;
color: #999;
}
.service-duration {
font-size: 24rpx;
color: #999;
}
.service-recording-name {
display: flex;
align-items: center;
margin-bottom: 16rpx;
font-size: 24rpx;
color: #666;
}
.recording-name-label {
color: #999;
margin-right: 8rpx;
}
.recording-name-value {
color: #333;
}
.service-recording-text {
margin-bottom: 16rpx;
font-size: 24rpx;
}
.recording-text-label {
color: #999;
display: block;
margin-bottom: 8rpx;
}
.recording-text-body {
color: #333;
line-height: 1.5;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.service-content {
margin-bottom: 24rpx;
}
.service-desc {
font-size: 28rpx;
color: #666;
line-height: 1.6;
}
.service-customer {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.customer-avatar {
width: 64rpx;
height: 64rpx;
background-color: #2196F3;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
}
.avatar-text {
font-size: 32rpx;
color: #FFFFFF;
font-weight: 500;
}
.customer-name {
font-size: 28rpx;
color: #333;
}
.ai-tag {
padding-top: 16rpx;
border-top: 1px solid #F0F0F0;
}
.ai-tag text {
font-size: 24rpx;
color: #999;
}
</style>