Files
smartDriveEEUniApp/pages/furniture_customer/furniture_customer.vue
2026-01-11 08:10:32 +08:00

3497 lines
95 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="page">
<!-- #ifdef APP -->
<statusBar></statusBar>
<!-- #endif -->
<!-- 导航栏 -->
<uni-nav-bar
:fixed="true"
:statusBar="true"
title="AI销冠系统"
leftIcon="left"
@clickLeft="goBack"
color="#333"
backgroundColor="#FFFFFF">
</uni-nav-bar>
<view class="content">
<!-- 标签页 -->
<view class="tabs">
<view
class="tab-item"
:class="{ active: activeTab === 'service' }"
@click="switchTab('service')">
<text>服务记录</text>
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'customer' }"
@click="switchTab('customer')">
<text>客户</text>
</view>
</view>
<!-- 服务记录列表 -->
<ServiceList
v-if="activeTab === 'service'"
:list="serviceList"
:total="serviceListTotal"
@itemClick="viewDetail"
@filterClick="refreshServiceList"
@reachBottom="onServiceListReachBottom"
@menuAction="handleMenuAction"
@updateItem="handleUpdateItem" />
<!-- 客户列表 -->
<CustomerList
v-if="activeTab === 'customer'"
@itemClick="viewCustomerDetail" />
</view>
<!-- 客户档案弹窗 -->
<CustomerProfileModal
ref="customerProfileModal"
:profile="currentCustomerProfile"
@close="onCustomerProfileClose" />
<!-- 录音列表弹窗 -->
<uni-popup ref="audioListPopup" type="bottom" :mask-click="false">
<view class="audio-list-modal">
<!-- 顶部栏 -->
<view class="modal-header">
<view class="modal-close" @click="closeAudioList">
<uni-icons type="close" size="24" color="#333"></uni-icons>
</view>
<text class="modal-title">录音列表{{ audioSegmentsList.length }}</text>
<view style="width: 48rpx;"></view>
</view>
<!-- 录音列表内容 -->
<scroll-view class="audio-list-content" scroll-y enable-flex>
<view
class="audio-list-item"
v-for="(item, index) in audioSegmentsList"
:key="index">
<view class="audio-item-header">
<view class="audio-play-btn" @click.stop="toggleAudioPlay(item, index)">
<text class="audio-play-text" :class="playingAudioId === (item.id || item.segmentId) ? 'audio-play-text--playing' : ''">
{{ playingAudioId === (item.id || item.segmentId) ? '暂停' : '播放' }}
</text>
</view>
<text class="audio-item-name">{{ formatAudioFileName(item.audioFileOriginalName) }}</text>
</view>
<view class="audio-item-info">
<text class="audio-item-text" v-if="item.recordingText">{{ truncateText(item.recordingText, 30) }}</text>
<view class="audio-item-transcribe" v-else>
<view
class="transcribe-btn"
:class="{ 'transcribe-btn--loading': transcribingIds.includes(item.id || item.segmentId) }"
@click.stop="transcribeAudio(item, index)">
<text v-if="!transcribingIds.includes(item.id || item.segmentId)">转文本</text>
<text v-else>处理中...</text>
</view>
</view>
</view>
<view class="audio-item-meta">
<text class="audio-meta-item" v-if="item.endTime">{{ formatDateTime(item.endTime) }}</text>
<text class="audio-meta-item" v-if="item.duration">{{ formatDuration(item.duration) }}</text>
<text class="audio-meta-item" v-if="item.deviceNo">{{ item.deviceNo }}</text>
</view>
</view>
<view class="audio-list-empty" v-if="!audioSegmentsLoading && audioSegmentsList.length === 0">
<text>暂无录音记录</text>
</view>
</scroll-view>
</view>
</uni-popup>
<!-- 录音文本内容弹框放在录音列表弹框之后确保层级更高 -->
<uni-popup ref="textModalPopup" type="center" :mask-click="true" @change="onTextModalChange" class="text-modal-popup">
<view class="text-modal">
<view class="text-modal-header">
<text class="text-modal-title">录音文本内容</text>
<view class="text-modal-close" @click="closeTextModal">
<uni-icons type="close" size="24" color="#333"></uni-icons>
</view>
</view>
<scroll-view class="text-modal-content" scroll-y>
<text class="text-modal-text">{{ currentRecordingText || '暂无录音文本' }}</text>
</scroll-view>
</view>
</uni-popup>
<!-- 服务记录详情弹窗 -->
<uni-popup ref="serviceDetailPopup" type="bottom" :mask-click="false">
<view class="service-detail-modal">
<!-- 顶部栏 -->
<view class="modal-header">
<view class="modal-close" @click="closeDetail">
<uni-icons type="close" size="24" color="#333"></uni-icons>
</view>
<text class="modal-title">{{ currentServiceDetail.title }}</text>
<view class="modal-share" @click="shareDetail">
<uni-icons type="forward" size="20" color="#333"></uni-icons>
</view>
</view>
<!-- 日期时间 -->
<view class="modal-date">
<text>{{ currentServiceDetail.date }}</text>
</view>
<!-- AI总结横幅 -->
<view class="modal-banner">
<view class="banner-left">
<view class="banner-icon">
<uni-icons type="loop" size="18" color="#007AFF"></uni-icons>
</view>
<text class="banner-text">AI总结提炼关键, 为销售减负</text>
</view>
</view>
<!-- 音频播放器 -->
<view class="audio-player">
<view class="play-btn" @click="togglePlay">
<uni-icons :type="isPlaying ? 'pause-filled' : 'play-filled'" size="24" color="#007AFF"></uni-icons>
</view>
<view class="progress-bar">
<view class="progress-line">
<view class="progress-dot"></view>
</view>
</view>
<text class="audio-time">00:00:00/00:00:00</text>
<view class="speed-btn">
<text>DD 1.0</text>
</view>
</view>
<!-- 标签页 -->
<view class="detail-tabs">
<view
class="detail-tab-item"
:class="{ active: activeDetailTab === 'summary' }"
@click="switchDetailTab('summary')">
<text>会话总结</text>
</view>
<view
class="detail-tab-item"
:class="{ active: activeDetailTab === 'todo' }"
@click="switchDetailTab('todo')">
<text>智能待办</text>
</view>
<view
class="detail-tab-item"
:class="{ active: activeDetailTab === 'chapter' }"
@click="switchDetailTab('chapter')">
<text>章节概要</text>
</view>
<view
class="detail-tab-item"
:class="{ active: activeDetailTab === 'performance' }"
@click="switchDetailTab('performance')">
<text>服务表现</text>
</view>
</view>
<!-- 会话总结内容 -->
<ConversationSummaryFurniture
v-if="activeDetailTab === 'summary'"
:serviceItem="currentServiceDetail"
:key="`summary-${currentServiceDetail.id || currentServiceDetail.rawData?.id || Date.now()}`" />
<!-- 服务表现内容 -->
<scroll-view class="detail-content" scroll-y v-if="activeDetailTab === 'performance'">
<view class="performance-score">
<text class="score-label">本次服务评分</text>
<text class="score-value">{{ performanceData.totalScore }}</text>
</view>
<view
class="performance-category"
v-for="(category, categoryIndex) in performanceData.categories"
:key="categoryIndex">
<view class="category-header" @click="toggleCategory(categoryIndex)">
<view class="category-left">
<view class="category-bullet" :class="category.completed ? 'bullet-blue' : 'bullet-gray'"></view>
<text class="category-name">{{ category.name }}</text>
</view>
<uni-icons
:type="category.expanded ? 'up' : 'down'"
size="16"
color="#999">
</uni-icons>
</view>
<view class="category-content" v-if="category.expanded">
<!-- AI横幅如果有 -->
<view class="performance-banner" v-if="category.showBanner">
<view class="banner-left">
<view class="banner-icon">
<uni-icons type="loop" size="18" color="#007AFF"></uni-icons>
</view>
<text class="banner-text">AI总结提炼关键,为销售减负</text>
</view>
</view>
<!-- 音频播放器如果有 -->
<view class="performance-audio" v-if="category.showAudio">
<view class="audio-player">
<view class="play-btn" @click="togglePlay">
<uni-icons :type="isPlaying ? 'pause-filled' : 'play-filled'" size="24" color="#007AFF"></uni-icons>
</view>
<view class="progress-bar">
<view class="progress-line">
<view class="progress-dot"></view>
</view>
</view>
<text class="audio-time">00:00:00/00:00:00</text>
<view class="speed-btn">
<text>DD 1.0</text>
</view>
</view>
</view>
<!-- 子项列表 -->
<view
class="performance-item"
v-for="(item, itemIndex) in category.items"
:key="itemIndex"
:class="{ 'item-completed': item.completed }">
<view class="item-content" @click="toggleItem(categoryIndex, itemIndex)">
<view class="item-left">
<text class="item-name">{{ item.name }}</text>
<text class="item-status" :class="item.completed ? 'status-completed' : 'status-missing'" v-if="!item.completed">
{{ item.completed ? '已完成' : '未提及' }}
</text>
<text class="item-points">+{{ item.points }}</text>
</view>
<uni-icons type="down" size="16" color="#999"></uni-icons>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 智能待办内容 -->
<scroll-view class="detail-content" scroll-y v-if="activeDetailTab === 'todo'">
<view class="todo-list" v-if="todoList.length > 0">
<view
class="todo-item"
v-for="(item, index) in todoList"
:key="index"
:class="{
'todo-completed': item.status && (item.status.includes('完成') || item.status === '已完成'),
'todo-clickable': isTodoPending(item.status)
}">
<view class="todo-header">
<view class="todo-title-wrapper">
<view class="todo-status-dot" :class="getTodoStatusClass(item.status)"></view>
<text class="todo-title">{{ item.todoTitle || item.title || '待办事项' }}</text>
</view>
<view class="todo-status-wrapper" v-if="isTodoPending(item.status)">
<text
class="todo-status todo-status-clickable"
:class="getTodoStatusTextClass(item.status)"
:id="'todo-status-' + index"
@click.stop="toggleTodoMenu(index, $event)">
{{ item.status || '待处理' }}
<uni-icons type="down" size="14" color="#FF9800" style="margin-left: 4rpx;"></uni-icons>
</text>
</view>
<text
v-else
class="todo-status"
:class="getTodoStatusTextClass(item.status)">
{{ item.status || '待处理' }}
</text>
</view>
<view class="todo-content" v-if="item.todoContent || item.content || item.description">
<text>{{ item.todoContent || item.content || item.description }}</text>
</view>
<view class="todo-meta">
<text class="todo-meta-item" v-if="item.ownerName">所属人{{ item.ownerName }}</text>
<text class="todo-meta-item" v-if="item.ownerPhone">电话{{ item.ownerPhone }}</text>
<text class="todo-meta-item" v-if="item.PendingDate || item.pendingDate">待办{{ formatDateTime(item.PendingDate || item.pendingDate) }}</text>
</view>
</view>
</view>
<view class="empty-detail" v-else>
<text>暂无待办事项</text>
</view>
</scroll-view>
<!-- 下拉菜单移到modal层级避免被遮挡 -->
<view
class="todo-menu-mask"
v-if="activeTodoMenuIndex !== null"
@click="closeTodoMenu">
</view>
<view
class="todo-menu-fixed"
v-if="activeTodoMenuIndex !== null"
:style="todoMenuStyle"
@click.stop>
<view class="todo-menu-item" @click="handleTodoComplete(currentTodoItem, activeTodoMenuIndex)">
<text>处理完成</text>
</view>
<view class="todo-menu-item" @click="handleTodoDelay(currentTodoItem, activeTodoMenuIndex)">
<text>延期处理</text>
</view>
</view>
<!-- 章节概要内容 -->
<scroll-view class="detail-content" scroll-y v-if="activeDetailTab === 'chapter'">
<view class="chapter-summary-list" v-if="chapterSummaryList.length > 0">
<view
class="chapter-summary-item"
v-for="(item, index) in chapterSummaryList"
:key="index">
<view class="chapter-summary-title">
<text>{{ item.title || `章节${index + 1}` }}</text>
</view>
<view class="chapter-summary-section" v-if="item.customerConcerns">
<view class="chapter-summary-label">
<text>客户疑虑</text>
</view>
<view class="chapter-summary-content">
<text>{{ item.customerConcerns }}</text>
</view>
</view>
<view class="chapter-summary-section" v-if="item.answer">
<view class="chapter-summary-label">
<text>回答</text>
</view>
<view class="chapter-summary-content">
<text>{{ item.answer }}</text>
</view>
</view>
</view>
</view>
<view class="empty-detail" v-else>
<text>暂无章节概要内容</text>
</view>
</scroll-view>
<!-- 其他标签页内容 -->
<scroll-view class="detail-content" scroll-y v-if="activeDetailTab !== 'summary' && activeDetailTab !== 'performance' && activeDetailTab !== 'todo' && activeDetailTab !== 'chapter'">
<view class="empty-detail">
<text>{{ getDetailTabName(activeDetailTab) }}内容</text>
</view>
</scroll-view>
<!-- 底部客户信息 -->
<view class="modal-footer" @click="viewCustomer">
<uni-icons type="person" size="20" color="#666"></uni-icons>
<text class="footer-label">客户:</text>
<text class="footer-customer">{{ currentServiceDetail.customerName }}</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</view>
</uni-popup>
</view>
</template>
<script>
// #ifdef APP
import statusBar from "@/uni_modules/uni-nav-bar/components/uni-nav-bar/uni-status-bar";
// #endif
import CustomerList from './components/CustomerList.vue';
import ServiceList from './components/ServiceList.vue';
import CustomerProfileModal from './components/CustomerProfileModal.vue';
import ConversationSummaryFurniture from './components/ConversationSummaryFurniture.vue';
import { initCustomerProfile, initSummaryList, initPerformanceData } from './utils/dataInit.js';
import { getApiUrl, API_BASE_URL } from '@/common/config.js';
export default {
// #ifdef APP
components: {
statusBar
},
// #endif
components: {
CustomerList,
ServiceList,
CustomerProfileModal,
ConversationSummaryFurniture
},
data() {
return {
activeTab: 'customer',
activeDetailTab: 'summary',
isPlaying: false,
currentServiceDetail: {
title: '',
date: '',
duration: '',
description: '',
customerName: ''
},
currentCustomerProfile: {
name: '',
intent: '',
serviceCount: 0,
lastService: '',
phone: '',
serviceStaff: '',
wechatStatus: '',
address: '',
source: '',
followStage: '',
systemTags: [],
customTags: [],
intentDescription: ''
},
summaryList: [],
furnitureAnalysisList: [], // 家具意向分析数据
productCategoryList: [], // 产品类别列表
activeProductCategory: '', // 当前选中的产品类别
productRequirementMap: {}, // 产品需求映射 {categoryKey: {rows: [...]}}
summarySentence: '', // 一句话总结
dealKeyPoints: '', // 成交关键点
performanceData: {
totalScore: 81,
categories: []
},
serviceList: [],
serviceListTotal: 0,
serviceListPage: {
current: 1,
size: 10
},
todoList: [], // 待办事项列表
todoListPage: {
current: 1,
size: 10
},
todoListTotal: 0, // 待办事项总数
activeTodoMenuIndex: null, // 当前打开的下拉菜单索引
todoMenuStyle: {}, // 下拉菜单样式用于fixed定位
currentTodoItem: null, // 当前操作的待办事项
chapterSummaryList: [], // 章节概要列表包含summary1, summary2, summary3
audioSegmentsList: [], // 录音分段列表
audioSegmentsLoading: false, // 录音分段加载状态
playingAudioId: null, // 当前正在播放的录音ID
innerAudioContext: null, // 音频播放器实例
currentAudioUrl: null, // 当前音频URL用于清理Blob URL
showTextModal: false, // 是否显示文本内容弹框
currentRecordingText: '', // 当前显示的录音文本内容
transcribingIds: [] // 正在转文本的录音ID列表
}
},
computed: {
// 当前选中的产品需求
currentProductRequirement() {
if (!this.activeProductCategory) return null;
return this.productRequirementMap[this.activeProductCategory] || null;
}
},
onLoad() {
// 加载服务记录列表
if (this.activeTab === 'service') {
this.loadServiceList();
}
},
onUnload() {
// 组件销毁时,清理音频播放器
if (this.innerAudioContext) {
try {
this.innerAudioContext.stop();
this.innerAudioContext.destroy();
} catch (e) {
console.error('销毁音频播放器失败:', e);
}
this.innerAudioContext = null;
}
// #ifdef H5
// 清理Blob URL释放内存
if (this.currentAudioUrl && this.currentAudioUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(this.currentAudioUrl);
} catch (e) {
console.error('清理Blob URL失败:', e);
}
}
// #endif
this.currentAudioUrl = null;
this.playingAudioId = null;
},
methods: {
goBack() {
uni.navigateBack();
},
switchTab(tab) {
this.activeTab = tab;
// 切换到服务记录标签页时,如果列表为空则加载数据
if (tab === 'service' && this.serviceList.length === 0) {
this.loadServiceList();
}
// 客户列表数据由 CustomerList 组件自己管理,不需要在这里加载
},
// 刷新服务记录列表
refreshServiceList(filterParams = {}) {
// 重置分页到第一页
this.serviceListPage.current = 1;
// 重新加载数据,传递查询参数
this.loadServiceList(filterParams);
},
// 服务记录列表到底部加载更多
onServiceListReachBottom() {
// 如果已经加载全部数据,则不再请求
if (this.serviceList.length >= this.serviceListTotal) {
return;
}
// 下一页
this.serviceListPage.current += 1;
this.loadServiceList();
},
async viewDetail(item) {
// 查看详情
this.currentServiceDetail = { ...item };
// 重置待办事项列表
this.todoList = [];
this.todoListTotal = 0;
// 重置章节概要列表
this.chapterSummaryList = [];
// 如果当前标签页是智能待办,加载待办事项
if (this.activeDetailTab === 'todo') {
this.loadTodoList();
}
// 如果当前标签页是章节概要,加载章节概要数据
if (this.activeDetailTab === 'chapter') {
this.loadChapterSummary();
}
// 如果当前标签页是服务表现,加载服务表现数据
if (this.activeDetailTab === 'performance') {
this.loadPerformanceData();
}
// 会话总结数据由"会话总结家具"组件自动加载
this.$refs.serviceDetailPopup.open();
},
// 处理菜单操作
async handleMenuAction({ action, item }) {
console.log('父组件收到菜单操作:', action, item);
switch (action) {
case 'view':
// 查看详情:打开详情弹窗
this.viewDetail(item);
break;
case 'analysis':
// AI分析已经在 ServiceList 组件中处理,这里不需要处理
break;
case 'audio':
// 录音详情:加载录音分段列表
await this.loadAudioSegments(item);
break;
case 'delete':
// 删除服务记录
await this.deleteServiceRecord(item);
break;
default:
console.warn('未知的菜单操作:', action);
}
},
// 处理列表项更新
handleUpdateItem({ recordId, data }) {
// 在列表中找到对应的项并更新
const index = this.serviceList.findIndex(serviceItem =>
(serviceItem.id || serviceItem.rawData?.id) === recordId
);
if (index !== -1) {
this.$set(this.serviceList, index, data);
}
},
// 删除服务记录
async deleteServiceRecord(item) {
console.log('删除服务记录item:', item);
// 显示确认对话框
const res = await new Promise((resolve) => {
uni.showModal({
title: '确认删除',
content: '确定要删除这条服务记录吗?删除后无法恢复。',
confirmText: '删除',
confirmColor: '#FF3B30',
cancelText: '取消',
success: (result) => {
resolve(result.confirm);
},
fail: () => {
resolve(false);
}
});
});
if (!res) {
return; // 用户取消删除
}
try {
// 获取要删除的记录ID
const recordId = item.id || item.rawData?.id;
if (!recordId) {
uni.showToast({
title: '无法获取记录ID',
icon: 'none'
});
return;
}
// 调用删除接口:/api/audioManagement/delete/{id}
const url = getApiUrl(`/api/audioManagement/delete/${recordId}`);
// 获取认证信息
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 deleteRes = await uni.request({
url: url,
method: 'DELETE',
header: headers,
timeout: 10000
});
if (deleteRes.statusCode === 200 && deleteRes.data && deleteRes.data.success) {
uni.showToast({
title: '删除成功',
icon: 'success'
});
// 刷新列表
this.serviceListPage.current = 1;
this.serviceList = [];
await this.loadServiceList();
} else {
uni.showToast({
title: deleteRes.data?.message || '删除失败',
icon: 'none'
});
}
} catch (error) {
console.error('删除服务记录失败:', error);
uni.showToast({
title: `删除失败: ${error.errMsg || error.message || '未知错误'}`,
icon: 'none'
});
}
},
// 加载录音分段列表
async loadAudioSegments(item) {
try {
// 获取parentId从item的rawData中获取id或者直接使用id
const parentId = item.rawData?.id || item.id;
if (!parentId) {
uni.showToast({
title: '无法获取录音ID',
icon: 'none'
});
return;
}
this.audioSegmentsLoading = true;
this.audioSegmentsList = [];
uni.showLoading({
title: '加载中...'
});
// 调用接口:/api/audioManagementSegments/listByParentId?parentId={parentId}
const url = `${getApiUrl('/api/audioManagementSegments/listByParentId')}?parentId=${parentId}`;
// 获取认证信息
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: 'GET',
header: headers,
timeout: 10000
});
uni.hideLoading();
if (res.statusCode === 200 && res.data && res.data.success) {
const segments = res.data.data || [];
console.log('录音分段列表:', segments);
// 存储分段数据
this.audioSegmentsList = segments;
this.audioSegmentsLoading = false;
// 打开录音列表弹窗
this.$refs.audioListPopup.open();
} else {
uni.showToast({
title: res.data?.message || '获取录音分段失败',
icon: 'none'
});
}
} catch (error) {
uni.hideLoading();
this.audioSegmentsLoading = false;
console.error('获取录音分段列表失败:', error);
uni.showToast({
title: `获取录音分段失败: ${error.errMsg || error.message || '未知错误'}`,
icon: 'none'
});
}
},
// 关闭录音列表弹窗
closeAudioList() {
// 关闭弹窗时,如果正在播放,先停止播放
if (this.playingAudioId) {
this.stopAudioPlay({ id: this.playingAudioId });
}
this.$refs.audioListPopup.close();
},
// 切换录音播放/暂停
async toggleAudioPlay(item, index) {
try {
// 获取录音ID可能是 id 或 segmentId
const audioId = item.id || item.segmentId;
if (!audioId) {
console.warn('录音项缺少ID:', item);
uni.showToast({
title: '无法获取录音ID',
icon: 'none'
});
return;
}
// 如果点击的是当前正在播放的录音,则暂停
if (this.playingAudioId === audioId) {
await this.stopAudioPlay(item);
} else {
// 如果其他录音正在播放,先停止
if (this.playingAudioId) {
await this.stopAudioPlay({ id: this.playingAudioId });
}
// 显示文本内容弹框
this.currentRecordingText = item.recordingText || '';
this.showTextModal = true;
this.$refs.textModalPopup.open();
// 开始播放新的录音
await this.startAudioPlay(item);
}
} catch (error) {
console.error('切换播放状态失败:', error);
uni.showToast({
title: '操作失败',
icon: 'none'
});
}
},
// 关闭文本内容弹框
closeTextModal() {
this.showTextModal = false;
this.currentRecordingText = '';
this.$refs.textModalPopup.close();
},
// 文本弹框状态变化
onTextModalChange(e) {
if (!e.show) {
// 弹框关闭时,清除状态
this.showTextModal = false;
this.currentRecordingText = '';
}
},
// 开始播放录音
async startAudioPlay(item) {
try {
// 获取录音ID可能是 id 或 segmentId
const audioId = item.id || item.segmentId;
if (!audioId) {
console.warn('录音项缺少ID:', item);
uni.showToast({
title: '无法获取录音ID',
icon: 'none'
});
return;
}
// 如果已有播放器正在播放,先停止
if (this.innerAudioContext) {
this.innerAudioContext.stop();
this.innerAudioContext.destroy();
this.innerAudioContext = null;
}
uni.showLoading({
title: '加载中...'
});
// 获取认证信息
let tenantId = '';
let token = '';
try {
tenantId = uni.getStorageSync('backend-tenant-id') || '';
token = uni.getStorageSync('backend-token') || '';
} catch (e) {
console.error('获取认证信息失败:', e);
}
// 构建请求URL
// 根据后端接口定义:@PostMapping("/playSegmentById") 使用 @PathVariable String id
// 如果使用 @PathVariable路径应该是 /playSegmentById/{id}
// 先尝试路径参数方式:/api/audioManagementSegments/playSegmentById/{id}
const url = getApiUrl(`/api/audioManagementSegments/playSegmentById/${audioId}`);
console.log('请求音频URL:', url);
console.log('音频ID:', audioId);
// 构建请求头(参考其他请求的实现方式)
const headers = {
'Content-Type': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (tenantId) {
headers['X-Tenant-Id'] = tenantId;
}
console.log('请求头:', headers);
// 由于后端接口是POST方式且需要传递header而innerAudioContext无法设置请求头
// 所以先通过uni.request获取音频数据然后转换为可播放的URL
const res = await uni.request({
url: url,
method: 'POST',
header: headers,
responseType: 'arraybuffer', // 接收二进制数据
timeout: 30000 // 音频文件可能较大,增加超时时间
});
console.log('响应状态码:', res.statusCode);
console.log('响应头:', res.header);
if (res.statusCode !== 200) {
console.error('请求失败,状态码:', res.statusCode);
console.error('响应数据:', res.data);
// 如果是404可能是路径不对尝试其他方式
if (res.statusCode === 404) {
throw new Error(`接口路径不存在(404)请检查后端接口路径是否正确。当前URL: ${url}`);
}
throw new Error(`请求失败,状态码: ${res.statusCode}`);
}
// 从响应头获取Content-Type确定音频格式
// 如果响应头中没有Content-Type默认使用audio/mpeg
let audioType = 'audio/mpeg';
if (res.header && res.header['Content-Type']) {
audioType = res.header['Content-Type'];
} else if (res.header && res.header['content-type']) {
audioType = res.header['content-type'];
}
// 将arraybuffer转换为base64或blob URL
// #ifdef H5
// H5平台使用Blob URL
const blob = new Blob([res.data], { type: audioType });
const audioUrl = URL.createObjectURL(blob);
// #endif
// #ifndef H5
// 非H5平台使用base64
const base64 = uni.arrayBufferToBase64(res.data);
const audioUrl = `data:${audioType};base64,${base64}`;
// #endif
// 保存audioUrl到组件实例以便在回调中清理
this.currentAudioUrl = audioUrl;
// 创建音频播放器实例
const innerAudioContext = uni.createInnerAudioContext();
innerAudioContext.src = audioUrl;
// 监听播放事件
innerAudioContext.onPlay(() => {
console.log('开始播放音频');
uni.hideLoading();
this.playingAudioId = audioId;
uni.showToast({
title: '开始播放',
icon: 'success',
duration: 1000
});
});
// 监听播放错误
innerAudioContext.onError((error) => {
console.error('音频播放错误:', error);
uni.hideLoading();
this.playingAudioId = null;
innerAudioContext.destroy();
this.innerAudioContext = null;
// #ifdef H5
// 清理Blob URL释放内存
if (this.currentAudioUrl && this.currentAudioUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.currentAudioUrl);
}
// #endif
this.currentAudioUrl = null;
uni.showToast({
title: '播放失败,请检查网络或音频文件',
icon: 'none',
duration: 2000
});
});
// 监听播放结束
innerAudioContext.onEnded(() => {
console.log('音频播放结束');
this.playingAudioId = null;
innerAudioContext.destroy();
this.innerAudioContext = null;
// #ifdef H5
// 清理Blob URL释放内存
if (this.currentAudioUrl && this.currentAudioUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.currentAudioUrl);
}
// #endif
this.currentAudioUrl = null;
});
// 监听播放暂停
innerAudioContext.onPause(() => {
console.log('音频播放暂停');
this.playingAudioId = null;
});
// 存储播放器实例
this.innerAudioContext = innerAudioContext;
// 开始播放
innerAudioContext.play();
} catch (error) {
uni.hideLoading();
console.error('播放录音失败:', error);
if (this.innerAudioContext) {
this.innerAudioContext.destroy();
this.innerAudioContext = null;
}
this.playingAudioId = null;
uni.showToast({
title: `播放失败: ${error.errMsg || error.message || '未知错误'}`,
icon: 'none',
duration: 2000
});
}
},
// 停止播放录音
async stopAudioPlay(item) {
try {
// 停止音频播放器
if (this.innerAudioContext) {
this.innerAudioContext.stop();
this.innerAudioContext.destroy();
this.innerAudioContext = null;
}
// 清除播放状态
this.playingAudioId = null;
uni.showToast({
title: '已停止',
icon: 'success',
duration: 1000
});
} catch (error) {
// 即使出错,也清除播放状态
if (this.innerAudioContext) {
try {
this.innerAudioContext.stop();
this.innerAudioContext.destroy();
} catch (e) {
console.error('销毁音频播放器失败:', e);
}
this.innerAudioContext = null;
}
this.playingAudioId = null;
console.error('停止播放失败:', error);
uni.showToast({
title: '已停止',
icon: 'success',
duration: 1000
});
}
},
// 转文本
async transcribeAudio(item, index) {
try {
// 获取录音ID可能是 id 或 segmentId
const audioId = item.id || item.segmentId;
if (!audioId) {
uni.showToast({
title: '无法获取录音ID',
icon: 'none'
});
return;
}
// 如果正在转文本,则不再处理
if (this.transcribingIds.includes(audioId)) {
return;
}
// 添加到转文本列表
this.transcribingIds.push(audioId);
// 调用转文本接口:/api/audioManagementSegments/transcribe/{id}
const url = getApiUrl(`/api/audioManagementSegments/transcribe/${audioId}`);
// 获取认证信息
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',
header: headers,
timeout: 30000 // 转文本可能需要较长时间
});
// 从转文本列表中移除
const idIndex = this.transcribingIds.indexOf(audioId);
if (idIndex > -1) {
this.transcribingIds.splice(idIndex, 1);
}
if (res.statusCode === 200 && res.data && res.data.success) {
uni.showToast({
title: res.data.message || '转文本请求已提交',
icon: 'success',
duration: 2000
});
// 如果返回了更新后的数据,更新列表中的该项
if (res.data.data) {
// 更新列表中的该项
this.$set(this.audioSegmentsList, index, {
...item,
...res.data.data
});
} else {
// 如果没有返回数据,可以刷新整个列表
// 或者等待用户手动刷新
// 这里先不自动刷新,因为转文本是后台处理
}
} else {
uni.showToast({
title: res.data?.message || '转文本请求失败',
icon: 'none',
duration: 2000
});
}
} catch (error) {
// 从转文本列表中移除
const audioId = item.id || item.segmentId;
const idIndex = this.transcribingIds.indexOf(audioId);
if (idIndex > -1) {
this.transcribingIds.splice(idIndex, 1);
}
console.error('转文本失败:', error);
uni.showToast({
title: `转文本失败: ${error.errMsg || error.message || '未知错误'}`,
icon: 'none',
duration: 2000
});
}
},
// 格式化时长(秒转换为时分秒)
formatDuration(seconds) {
if (!seconds) return '00:00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
},
// 格式化日期时间
formatDateTime(dateTime) {
if (!dateTime) return '';
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');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
},
// 格式化录音文件名:只显示第一个中划线之后和最后一个中划线之前的内容
formatAudioFileName(fileName) {
if (!fileName) return '未命名录音';
// 找到第一个中划线的位置
const firstDashIndex = fileName.indexOf('-');
if (firstDashIndex === -1) {
// 如果没有中划线,直接返回原文件名
return fileName;
}
// 找到最后一个中划线的位置
const lastDashIndex = fileName.lastIndexOf('-');
if (firstDashIndex === lastDashIndex) {
// 如果只有一个中划线,返回中划线之后的内容
return fileName.substring(firstDashIndex + 1);
}
// 返回第一个中划线之后到最后一个中划线之前的内容
return fileName.substring(firstDashIndex + 1, lastDashIndex);
},
// 截断文本,只显示指定长度
truncateText(text, maxLength) {
if (!text) return '';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
},
closeDetail() {
this.$refs.serviceDetailPopup.close();
},
shareDetail() {
// 分享详情
uni.showToast({
title: '分享功能',
icon: 'none'
});
},
togglePlay() {
this.isPlaying = !this.isPlaying;
},
switchDetailTab(tab) {
this.activeDetailTab = tab;
// 切换到智能待办标签页时,加载待办事项数据
if (tab === 'todo') {
this.loadTodoList();
}
// 切换到章节概要标签页时,加载章节概要数据
if (tab === 'chapter') {
this.loadChapterSummary();
}
// 切换到服务表现标签页时,加载服务表现数据
if (tab === 'performance') {
this.loadPerformanceData();
}
},
getDetailTabName(tab) {
const names = {
'todo': '智能待办',
'chapter': '章节概要',
'performance': '服务表现'
};
return names[tab] || '';
},
initSummaryList(item) {
this.summaryList = initSummaryList(item);
this.performanceData = initPerformanceData();
},
// 加载家具意向分析数据
async loadFurnitureAnalysis(item) {
try {
// 获取parentId从rawData中获取id
const parentId = item.rawData?.id || item.id;
if (!parentId) {
console.warn('无法获取parentId');
this.furnitureAnalysisList = [];
return;
}
// 统一使用 getApiUrl根据 env.js 配置自动切换环境
const url = `${getApiUrl('/api/audioTextAnalysisFurniture/byParentId')}?parentId=${parentId}`;
const res = await uni.request({
url: url,
method: 'GET',
timeout: 10000
});
if (res.statusCode === 200 && res.data && res.data.success) {
this.furnitureAnalysisList = res.data.data || [];
// 如果有数据更新summaryList显示家具意向分析
if (this.furnitureAnalysisList.length > 0) {
this.updateSummaryWithFurnitureData();
}
} else {
console.warn('获取家具意向分析失败:', res.data?.message || '未知错误');
this.furnitureAnalysisList = [];
}
} catch (error) {
console.error('获取家具意向分析失败:', error);
this.furnitureAnalysisList = [];
}
},
// 用家具意向分析数据更新会话总结
updateSummaryWithFurnitureData() {
if (this.furnitureAnalysisList.length === 0) return;
// 从第一个家具意向分析数据中提取关键信息
const firstItem = this.furnitureAnalysisList[0];
// 构建会话总结列表,显示客户类型、家庭结构、一句话总结
const summaryItems = [];
// 客户类型
if (firstItem.customerType) {
summaryItems.push({
label: '客户类型',
value: firstItem.customerType,
rawData: firstItem
});
}
// 家庭结构
if (firstItem.familyStructure) {
summaryItems.push({
label: '家庭结构',
value: firstItem.familyStructure,
rawData: firstItem
});
}
// 一句话总结单独存储不在summaryList中
if (firstItem.summarySentence) {
this.summarySentence = firstItem.summarySentence;
}
// 成交关键点(可以从数据中提取或生成)
if (firstItem.dealKeyPoints) {
this.dealKeyPoints = firstItem.dealKeyPoints;
} else if (firstItem.summarySentence) {
// 如果数据中没有成交关键点,可以从一句话总结中提取关键信息
// 提取关键词:活动优惠、搭配、环保、安全、交付、退换、服务等
const keyWords = [];
const summary = firstItem.summarySentence;
if (summary.includes('优惠') || summary.includes('活动')) {
keyWords.push('活动优惠');
}
if (summary.includes('搭配')) {
keyWords.push('整套搭配');
}
if (summary.includes('环保')) {
keyWords.push('环保');
}
if (summary.includes('安全')) {
keyWords.push('安全');
}
if (summary.includes('交付') || summary.includes('周期')) {
keyWords.push('快速交付');
}
if (summary.includes('退换') || summary.includes('服务')) {
keyWords.push('无忧退换服务');
}
if (keyWords.length > 0) {
this.dealKeyPoints = keyWords.join('');
}
}
// 如果提取到了数据更新summaryList
if (summaryItems.length > 0) {
this.summaryList = summaryItems;
}
// 构建产品需求清单
this.buildProductRequirementList(firstItem);
},
// 构建产品需求清单(固定显示所有产品类别)
buildProductRequirementList(data) {
const categories = [
{ key: 'sofa', name: '沙发', field: 'sofa' },
{ key: 'bed', name: '床', field: 'bedAndMattress' },
{ key: 'mattress', name: '床垫', field: 'bedAndMattress' },
{ key: 'dining_table', name: '餐桌椅', field: 'diningTable' },
{ key: 'tea_table', name: '茶几', field: 'teaTable' },
{ key: 'tv_cabinet', name: '电视柜', field: 'tvCabinet' },
{ key: 'study_desk', name: '学习桌', field: 'studyDesk' },
{ key: 'cabinet', name: '橱柜', field: 'cabinet' },
{ key: 'wine_cabinet', name: '酒柜', field: 'wineCabinet' },
{ key: 'master_bedroom_cabinet', name: '主卧衣柜', field: 'masterBedroomCabinet' },
{ key: 'secondary_bedroom_cabinet', name: '次卧衣柜', field: 'secondaryBedroomCabinet' },
{ key: 'shoe_cabinet', name: '鞋柜', field: 'shoeCabinet' },
{ key: 'custom', name: '定制家居', field: null }
];
const requirementMap = {};
const categoryList = [];
categories.forEach(category => {
// 检查是否有该产品的数据
let hasData = false;
let productData = null;
if (category.key === 'bed' || category.key === 'mattress') {
// 床和床垫共用bedAndMattress字段
const fieldValue = data.bedAndMattress || data.bed_and_mattress || '';
if (fieldValue && fieldValue.trim()) {
hasData = true;
productData = this.parseProductJson(fieldValue);
}
} else if (category.key === 'custom') {
// 定制家居:暂时跳过,因为各个定制产品已有单独类别
// 如果需要显示定制家居汇总,可以在这里添加逻辑
hasData = false;
} else {
// 尝试驼峰命名和蛇形命名
const fieldValue = data[category.field] || data[this.toSnakeCase(category.field)] || '';
if (fieldValue && fieldValue.trim()) {
hasData = true;
productData = this.parseProductJson(fieldValue);
}
}
// 只有当有数据时才添加到列表中
if (hasData) {
categoryList.push({ key: category.key, name: category.name });
// 构建产品需求行
const rows = this.buildProductDimensionRows(productData, data, category.key);
requirementMap[category.key] = { rows };
}
});
this.productRequirementMap = requirementMap;
this.productCategoryList = categoryList;
if (categoryList.length > 0) {
this.activeProductCategory = categoryList[0].key;
}
},
// 解析产品JSON字符串
parseProductJson(jsonString) {
if (!jsonString || typeof jsonString !== 'string') {
return null;
}
try {
const parsed = JSON.parse(jsonString);
return parsed;
} catch (error) {
console.warn('解析产品JSON失败:', error, jsonString);
return null;
}
},
// 驼峰转蛇形命名
toSnakeCase(str) {
if (!str) return '';
// 在大写字母前加下划线,然后转小写,最后去掉开头的下划线
return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
},
// 切换产品类别
switchProductCategory(categoryKey) {
this.activeProductCategory = categoryKey;
},
// 获取产品类别名称
getProductCategoryName(categoryKey) {
const category = this.productCategoryList.find(item => item.key === categoryKey);
return category ? category.name : '';
},
// 获取所有产品类别名称(用于意向产品清单显示)
getProductCategoryNames() {
return this.productCategoryList.map(item => item.name).join('');
},
parseProductAttributes(productKey, requirementText, data) {
const attributes = {};
const text = (requirementText || '').trim();
if (!text) return attributes;
const matchFirst = (pattern) => {
const result = text.match(pattern);
return result ? result[0] : '';
};
switch (productKey) {
case 'sofa':
attributes.type = matchFirst(/(L型|L形|一字型|U型|U形|带贵妃榻|贵妃榻|三人位|四人位|转角|组合)/);
attributes.material = matchFirst(/(真皮|科技布|布艺|布|皮质|皮革|实木|板式|棉麻|绒布)/);
attributes.size = matchFirst(/(\d+\.?\d*)\s*(米|m|平米|平方米)/);
attributes.color = matchFirst(/(米白|白色|灰色|黑色|棕色|蓝色|绿色|暖色调|冷色调|灰白色系)/);
attributes.style = matchFirst(/(现代简约|简约现代|现代|北欧|中式|美式|欧式|工业风|原木风|简约|极简)/);
if (text.includes('储物') || text.includes('收纳')) attributes.feature = '储物';
if (text.includes('好打理')) attributes.remark = '要求好打理';
break;
case 'bed':
attributes.size = matchFirst(/(\d+\.?\d*)\s*(米|m)/);
attributes.material = matchFirst(/(实木|板式|软包|皮质|布艺)/);
attributes.style = data.decorationStyle || '';
if (text.includes('主卧')) attributes.remark = '主卧';
break;
case 'mattress':
attributes.size = matchFirst(/(\d+\.?\d*)\s*(米|m)/);
attributes.material = matchFirst(/(弹簧|记忆棉|乳胶|椰棕|海绵)/);
if (text.includes('分区支撑')) attributes.softness = '分区支撑(兼顾软硬需求)';
else if (text.includes('偏软') || text.includes('软')) attributes.softness = '偏软';
else if (text.includes('偏硬') || text.includes('硬')) attributes.softness = '偏硬';
break;
case 'dining_table':
if (text.includes('伸缩')) attributes.type = '可伸缩';
const rangeMatch = text.match(/(\d+\.?\d*)\s*[-~到]\s*(\d+\.?\d*)\s*米/);
if (rangeMatch) attributes.size = `${rangeMatch[1]}-${rangeMatch[2]}`;
else attributes.size = matchFirst(/(\d+\.?\d*)\s*米/);
attributes.chairCount = matchFirst(/(\d+)\s*把/);
if (text.includes('舒适')) attributes.feature = '舒适型';
attributes.style = data.decorationStyle || '';
break;
case 'tea_table':
if (text.includes('抽屉')) attributes.type = '带抽屉';
if (text.includes('储物') || text.includes('收纳')) attributes.feature = '储物';
attributes.style = data.decorationStyle || '';
break;
case 'tv_cabinet':
if (text.includes('组合')) attributes.type = '组合式';
if (text.includes('储物')) attributes.feature = '储物';
attributes.style = data.decorationStyle || '';
break;
case 'study_desk':
if (text.includes('可调节') || text.includes('调节高度')) attributes.type = '可调节高度';
if (text.includes('成长')) attributes.feature = '适应孩子成长';
break;
case 'wine_cabinet':
if (text.includes('恒温')) attributes.feature = '恒温';
const capacity = text.match(/(\d+)[-~到](\d+)\s*瓶|(\d+)\s*瓶/);
if (capacity) {
if (capacity[1] && capacity[2]) attributes.capacity = `${capacity[1]}-${capacity[2]}`;
else if (capacity[3]) attributes.capacity = `${capacity[3]}`;
}
break;
case 'master_bedroom_cabinet':
case 'secondary_bedroom_cabinet':
if (text.includes('定制') || text.includes('整墙')) attributes.type = text.includes('整墙') ? '整墙定制' : '定制';
attributes.size = matchFirst(/(\d+\.?\d*)\s*(米|m)/);
if (text.includes('衣帽间')) attributes.feature = '带衣帽间';
if (text.includes('主卧')) attributes.remark = '主卧';
if (text.includes('次卧')) attributes.remark = '次卧';
break;
case 'shoe_cabinet':
if (text.includes('窄款') || text.includes('高型')) attributes.type = '窄款高型';
attributes.size = matchFirst(/(\d+\.?\d*)\s*(米|m)/);
if (text.includes('带座') || text.includes('可坐')) attributes.feature = '带座功能';
if (text.includes('收纳空间')) attributes.remark = '大容量收纳';
break;
default:
attributes.description = text;
break;
}
if (!attributes.style && data && data.decorationStyle) attributes.style = data.decorationStyle;
if (!attributes.color && data && data.decorationStyle) {
const color = data.decorationStyle.match(/(米白|白色|灰色|黑色|棕色|蓝色|暖色调|冷色调|灰白色系|原木色)/);
if (color) attributes.color = color[0];
}
if (!attributes.description && text) attributes.description = text;
return attributes;
},
buildProductDimensionRows(productData = null, data = {}, categoryKey = '') {
const dimensionLabels = [
{ key: 'styleColor', label: '偏好风格与颜色', field: 'preferenceStyleAndColor' },
{ key: 'material', label: '材质偏好', field: 'materialPreference' },
{ key: 'focus', label: '客户重点关注', field: 'customerFocusAreas' },
{ key: 'price', label: '价格敏感度', field: 'priceSensitivity' },
{ key: 'campaign', label: '活动参与度', field: 'activityParticipation' },
{ key: 'ordered', label: '客户疑虑点', field: 'customerConcerns' },
{ key: 'service', label: '交付服务', field: 'deliveryService' },
{ key: 'competitor', label: '竞品反馈', field: 'competitorFeedback' },
{ key: 'delivery', label: '交付周期', field: 'deliveryCycle' },
{ key: 'appearance', label: '产品颜值', field: 'productAppearance' }
];
// 判断productData是否是解析后的JSON对象
// 通过检查是否包含后端返回的JSON字段来判断
const isParsedJson = productData &&
typeof productData === 'object' &&
!Array.isArray(productData) &&
productData !== null &&
('preferenceStyleAndColor' in productData ||
'materialPreference' in productData ||
'customerConcerns' in productData);
const getValue = (field) => {
if (isParsedJson) {
// 从解析后的JSON对象中获取字段
const value = productData[field];
// 如果值是字符串,去除首尾空格;如果是其他类型,转为字符串
if (value === null || value === undefined || value === '') {
return '暂无信息';
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || '暂无信息';
}
return String(value) || '暂无信息';
} else {
// 向后兼容从data对象中获取或使用默认值
// 这里productData可能是旧的attributes对象或null
const attributes = productData || {};
switch (field) {
case 'preferenceStyleAndColor':
const parts = [];
if (attributes.style) parts.push(attributes.style);
if (attributes.color) parts.push(attributes.color);
if (parts.length === 0 && data.decorationStyle) parts.push(data.decorationStyle);
return parts.join(' / ') || '暂无信息';
case 'materialPreference':
return attributes.material || '暂无信息';
case 'customerFocusAreas':
return attributes.feature || attributes.remark || '暂无信息';
case 'priceSensitivity':
return data.priceSensitivity || '暂无信息';
case 'activityParticipation':
return data.campaignPreference || data.activityParticipation || '暂无信息';
case 'customerConcerns':
return attributes.description || '暂无信息';
case 'deliveryService':
return data.serviceExpectation || '暂无信息';
case 'competitorFeedback':
return data.competitorFeedback || '暂无信息';
case 'deliveryCycle':
return data.deliveryCycle || '暂无信息';
case 'productAppearance':
return data.appearancePreference || attributes.style || '暂无信息';
default:
return '暂无信息';
}
}
};
return dimensionLabels.map(item => ({
label: item.label,
content: getValue(item.field) || '暂无信息'
}));
},
toggleCategory(index) {
this.performanceData.categories[index].expanded = !this.performanceData.categories[index].expanded;
},
toggleItem(categoryIndex, itemIndex) {
// 可以在这里添加展开子项详情的逻辑
console.log('查看子项详情', categoryIndex, itemIndex);
},
viewSummaryDetail(item) {
// 查看总结详情
console.log('查看总结详情', item);
},
viewCustomer() {
// 查看客户详情
this.closeDetail();
this.switchTab('customer');
},
viewCustomerDetail(item) {
// 查看客户详情
this.currentCustomerProfile = initCustomerProfile(item);
this.$refs.customerProfileModal.open();
},
// 加载待办事项列表
async loadTodoList(params = {}) {
try {
// 获取parentId从当前服务记录的rawData中获取id
const parentId = this.currentServiceDetail.rawData?.id || this.currentServiceDetail.id;
if (!parentId) {
console.warn('无法获取parentId无法加载待办事项');
this.todoList = [];
this.todoListTotal = 0;
return;
}
const queryParams = {
current: this.todoListPage.current,
size: this.todoListPage.size,
parentId: parentId
};
// 添加其他查询参数
if (params.status) {
queryParams.status = params.status;
}
if (params.todoTitle) {
queryParams.todoTitle = params.todoTitle;
}
if (params.ownerName) {
queryParams.ownerName = params.ownerName;
}
if (params.ownerPhone) {
queryParams.ownerPhone = params.ownerPhone;
}
if (params.createStartTime) {
queryParams.createStartTime = params.createStartTime;
}
if (params.createEndTime) {
queryParams.createEndTime = params.createEndTime;
}
if (params.updateStartTime) {
queryParams.updateStartTime = params.updateStartTime;
}
if (params.updateEndTime) {
queryParams.updateEndTime = params.updateEndTime;
}
// 构建查询参数
const queryString = Object.keys(queryParams)
.filter(key => queryParams[key] !== null && queryParams[key] !== undefined && queryParams[key] !== '')
.map(key => `${key}=${encodeURIComponent(queryParams[key])}`)
.join('&');
// 统一使用 getApiUrl根据 env.js 配置自动切换环境
const baseUrl = getApiUrl('/api/todoItem/list');
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
const res = await uni.request({
url: url,
method: 'GET',
timeout: 10000
});
if (res.statusCode === 200 && res.data && res.data.success) {
this.todoList = res.data.data || [];
this.todoListTotal = res.data.total || 0;
} else {
console.warn('获取待办事项失败:', res.data?.message || '未知错误');
this.todoList = [];
this.todoListTotal = 0;
uni.showToast({
title: res.data?.message || '获取待办事项失败',
icon: 'none'
});
}
} catch (error) {
console.error('获取待办事项失败:', error);
this.todoList = [];
this.todoListTotal = 0;
uni.showToast({
title: `获取待办事项失败: ${error.errMsg || error.message || '未知错误'}`,
icon: 'none',
duration: 3000
});
}
},
// 获取待办事项状态样式类
getTodoStatusClass(status) {
if (!status) return 'status-pending';
if (status.includes('完成') || status === '已完成') {
return 'status-completed';
}
if (status.includes('进行') || status === '进行中') {
return 'status-processing';
}
if (status.includes('待') || status === '待处理') {
return 'status-pending';
}
return 'status-pending';
},
// 获取待办事项状态文本样式类
getTodoStatusTextClass(status) {
if (!status) return 'status-text-pending';
if (status.includes('完成') || status === '已完成') {
return 'status-text-completed';
}
if (status.includes('进行') || status === '进行中') {
return 'status-text-processing';
}
if (status.includes('待') || status === '待处理') {
return 'status-text-pending';
}
return 'status-text-pending';
},
// 格式化日期时间
formatDateTime(dateTimeStr) {
if (!dateTimeStr) return '';
try {
const date = new Date(dateTimeStr);
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 (error) {
console.warn('格式化日期时间失败:', error);
return dateTimeStr;
}
},
// 判断待办事项是否为待处理状态
isTodoPending(status) {
if (!status) return true;
return status.includes('待') || status === '待处理' || status === '待处理';
},
// 切换待办事项下拉菜单
toggleTodoMenu(index, event) {
if (this.activeTodoMenuIndex === index) {
this.activeTodoMenuIndex = null;
this.currentTodoItem = null;
this.todoMenuStyle = {};
} else {
this.activeTodoMenuIndex = index;
this.currentTodoItem = this.todoList[index];
// 计算下拉菜单位置
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this);
query.select(`#todo-status-${index}`).boundingClientRect((rect) => {
if (rect) {
// 获取窗口信息
uni.getSystemInfo({
success: (res) => {
// 计算下拉菜单位置:在状态标签下方,右对齐
// rpx转px: 1rpx = 屏幕宽度 / 750
const rpxToPx = res.windowWidth / 750;
const menuWidth = 200 * rpxToPx; // 菜单宽度
const right = res.windowWidth - rect.right;
const top = rect.bottom + 4;
this.todoMenuStyle = {
right: `${right}px`,
top: `${top}px`,
width: `${menuWidth}px`
};
}
});
}
}).exec();
});
}
},
// 关闭待办事项下拉菜单
closeTodoMenu() {
this.activeTodoMenuIndex = null;
this.currentTodoItem = null;
this.todoMenuStyle = {};
},
// 处理完成待办事项
async handleTodoComplete(item, index) {
this.closeTodoMenu();
const todoId = item.id;
if (!todoId) {
uni.showToast({
title: '待办事项ID不存在',
icon: 'none'
});
return;
}
try {
// 统一使用 getApiUrl根据 env.js 配置自动切换环境
const url = getApiUrl('/api/todoItem/updateTodoStatus');
const res = await uni.request({
url: url,
method: 'POST',
data: {
id: todoId
},
timeout: 10000,
header: {
'Content-Type': 'application/json'
}
});
if (res.statusCode === 200 && res.data && res.data.success) {
uni.showToast({
title: '处理完成',
icon: 'success'
});
// 刷新待办事项列表
await this.loadTodoList();
} else {
uni.showToast({
title: res.data?.message || '处理失败',
icon: 'none'
});
}
} catch (error) {
console.error('处理待办事项失败:', error);
uni.showToast({
title: `处理失败: ${error.errMsg || error.message || '未知错误'}`,
icon: 'none',
duration: 3000
});
}
},
// 延期处理待办事项
async handleTodoDelay(item, index) {
this.closeTodoMenu();
const todoId = item.id;
if (!todoId) {
uni.showToast({
title: '待办事项ID不存在',
icon: 'none'
});
return;
}
try {
// 统一使用 getApiUrl根据 env.js 配置自动切换环境
const url = getApiUrl('/api/todoItem/delayProcessTodo');
const res = await uni.request({
url: url,
method: 'POST',
data: {
id: todoId
},
timeout: 10000,
header: {
'Content-Type': 'application/json'
}
});
if (res.statusCode === 200 && res.data && res.data.success) {
uni.showToast({
title: '已延期3天',
icon: 'success'
});
// 刷新待办事项列表
await this.loadTodoList();
} else {
uni.showToast({
title: res.data?.message || '延期失败',
icon: 'none'
});
}
} catch (error) {
console.error('延期待办事项失败:', error);
uni.showToast({
title: `延期失败: ${error.errMsg || error.message || '未知错误'}`,
icon: 'none',
duration: 3000
});
}
},
onCustomerProfileClose() {
// 客户档案关闭回调
},
// 加载章节概要数据
async loadChapterSummary() {
try {
// 获取parentId从当前服务记录的rawData中获取id
const parentId = this.currentServiceDetail.rawData?.id || this.currentServiceDetail.id;
if (!parentId) {
console.warn('无法获取parentId无法加载章节概要');
this.chapterSummaryList = [];
return;
}
// 统一使用 getApiUrl根据 env.js 配置自动切换环境
const url = `${getApiUrl('/api/audioTextAnalysisFurniture/byParentId')}?parentId=${parentId}`;
const res = await uni.request({
url: url,
method: 'GET',
timeout: 10000
});
if (res.statusCode === 200 && res.data && res.data.success) {
const data = res.data.data || [];
// 构建章节概要列表提取summary1, summary2, summary3
const summaryList = [];
// 如果返回的是数组,取第一个元素
const item = Array.isArray(data) && data.length > 0 ? data[0] : data;
if (item) {
// 解析summary1JSON字符串
if (item.summary1) {
try {
const summary1Data = typeof item.summary1 === 'string'
? JSON.parse(item.summary1)
: item.summary1;
if (summary1Data && (summary1Data.customerConcerns || summary1Data.answer)) {
summaryList.push({
title: '章节1',
customerConcerns: summary1Data.customerConcerns || '',
answer: summary1Data.answer || ''
});
}
} catch (error) {
console.warn('解析summary1失败:', error);
}
}
// 解析summary2JSON字符串
if (item.summary2) {
try {
const summary2Data = typeof item.summary2 === 'string'
? JSON.parse(item.summary2)
: item.summary2;
if (summary2Data && (summary2Data.customerConcerns || summary2Data.answer)) {
summaryList.push({
title: '章节2',
customerConcerns: summary2Data.customerConcerns || '',
answer: summary2Data.answer || ''
});
}
} catch (error) {
console.warn('解析summary2失败:', error);
}
}
// 解析summary3JSON字符串
if (item.summary3) {
try {
const summary3Data = typeof item.summary3 === 'string'
? JSON.parse(item.summary3)
: item.summary3;
if (summary3Data && (summary3Data.customerConcerns || summary3Data.answer)) {
summaryList.push({
title: '章节3',
customerConcerns: summary3Data.customerConcerns || '',
answer: summary3Data.answer || ''
});
}
} catch (error) {
console.warn('解析summary3失败:', error);
}
}
}
this.chapterSummaryList = summaryList;
} else {
console.warn('获取章节概要失败:', res.data?.message || '未知错误');
this.chapterSummaryList = [];
}
} catch (error) {
console.error('获取章节概要失败:', error);
this.chapterSummaryList = [];
}
},
// 加载服务表现数据
async loadPerformanceData() {
try {
// 获取parentId从当前服务记录的rawData中获取id
const parentId = this.currentServiceDetail.rawData?.id || this.currentServiceDetail.id;
if (!parentId) {
console.warn('无法获取parentId无法加载服务表现数据');
// 使用默认数据
this.performanceData = initPerformanceData();
return;
}
// 统一使用 getApiUrl根据 env.js 配置自动切换环境
const url = `${getApiUrl('/api/audioTextAnalysisSop/listByParentId')}?parentId=${parentId}`;
const res = await uni.request({
url: url,
method: 'GET',
timeout: 10000
});
if (res.statusCode === 200 && res.data && res.data.success) {
const data = res.data.data;
// 如果返回的是数组,取第一个元素
const sopData = Array.isArray(data) && data.length > 0 ? data[0] : data;
if (sopData) {
// 将后端数据转换为前端需要的格式
this.performanceData = this.convertSopDataToPerformanceData(sopData);
} else {
console.warn('服务表现数据为空');
// 使用默认数据
this.performanceData = initPerformanceData();
}
} else {
console.warn('获取服务表现数据失败:', res.data?.message || '未知错误');
// 使用默认数据
this.performanceData = initPerformanceData();
}
} catch (error) {
console.error('获取服务表现数据失败:', error);
// 使用默认数据
this.performanceData = initPerformanceData();
}
},
// 将后端SOP数据转换为前端performanceData格式
convertSopDataToPerformanceData(sopData) {
// 判断分数是否完成分数大于等于60分认为完成
const isCompleted = (score) => score !== null && score !== undefined && score >= 60;
// 收集所有子项的分数用于计算总分
const allScores = [
sopData.greetingIceBreaking,
sopData.brandIntroduction,
sopData.goldenThreeQuestions,
sopData.needsGuidance,
sopData.serviceProgression,
sopData.reassuranceHandbook,
sopData.threeLevelPricing,
sopData.objectionHandling,
sopData.activityImplantation,
sopData.closingCooperation,
sopData.proactiveWechatAdd,
sopData.politeFarewell
].filter(score => score !== null && score !== undefined);
// 计算总分优先使用后端返回的comprehensiveScore如果没有则计算所有子项的平均分
let totalScore = sopData.comprehensiveScore || sopData.comprehensive_score;
if (totalScore === null || totalScore === undefined) {
// 如果后端没有返回总分,则计算所有子项的平均分
if (allScores.length > 0) {
const sum = allScores.reduce((acc, score) => acc + score, 0);
totalScore = Math.round(sum / allScores.length);
} else {
totalScore = 0;
}
}
// 构建分类和子项数据
const categories = [
{
name: '迎宾探寻',
completed: isCompleted(sopData.greetingIceBreaking) || isCompleted(sopData.brandIntroduction),
expanded: true,
showBanner: false,
showAudio: false,
items: [
{
name: '破冰',
completed: isCompleted(sopData.greetingIceBreaking),
points: sopData.greetingIceBreaking || 0
},
{
name: '品牌介绍',
completed: isCompleted(sopData.brandIntroduction),
points: sopData.brandIntroduction || 0
}
]
},
{
name: '展厅体验',
completed: isCompleted(sopData.goldenThreeQuestions) || isCompleted(sopData.needsGuidance),
expanded: true,
showBanner: false,
showAudio: false,
items: [
{
name: '黄金三问',
completed: isCompleted(sopData.goldenThreeQuestions),
points: sopData.goldenThreeQuestions || 0
},
{
name: '需求引导',
completed: isCompleted(sopData.needsGuidance),
points: sopData.needsGuidance || 0
}
]
},
{
name: '落座规划',
completed: isCompleted(sopData.serviceProgression) || isCompleted(sopData.reassuranceHandbook),
expanded: true,
showBanner: false,
showAudio: false,
items: [
{
name: '服务递进',
completed: isCompleted(sopData.serviceProgression),
points: sopData.serviceProgression || 0
},
{
name: 'SGS放心手册',
completed: isCompleted(sopData.reassuranceHandbook),
points: sopData.reassuranceHandbook || 0
}
]
},
{
name: '方案报价',
completed: isCompleted(sopData.threeLevelPricing),
expanded: true,
showBanner: false,
showAudio: false,
items: [
{
name: '三级报价',
completed: isCompleted(sopData.threeLevelPricing),
points: sopData.threeLevelPricing || 0
}
]
},
{
name: '促单成交',
completed: isCompleted(sopData.objectionHandling),
expanded: true,
showBanner: false,
showAudio: false,
items: [
{
name: '解答异议',
completed: isCompleted(sopData.objectionHandling),
points: sopData.objectionHandling || 0
}
]
},
{
name: '送客跟进',
completed: isCompleted(sopData.activityImplantation) ||
isCompleted(sopData.closingCooperation) ||
isCompleted(sopData.proactiveWechatAdd) ||
isCompleted(sopData.politeFarewell),
expanded: true,
showBanner: false,
showAudio: false,
items: [
{
name: '活动植入',
completed: isCompleted(sopData.activityImplantation),
points: sopData.activityImplantation || 0
},
{
name: '压单配合',
completed: isCompleted(sopData.closingCooperation),
points: sopData.closingCooperation || 0
},
{
name: '主动添加微信',
completed: isCompleted(sopData.proactiveWechatAdd),
points: sopData.proactiveWechatAdd || 0
},
{
name: '邀约量尺时间',
completed: false,
points: 0
},
{
name: '礼貌道别',
completed: isCompleted(sopData.politeFarewell),
points: sopData.politeFarewell || 0
}
]
}
];
return {
totalScore: totalScore,
categories: categories
};
},
// 加载服务记录列表
async loadServiceList(params = {}) {
try {
// 构建查询参数对象(后端使用 @RequestParam参数需要作为 URL 查询参数传递)
const queryParams = {
current: this.serviceListPage.current,
size: this.serviceListPage.size,
serviceStatus: '服务结束' // 默认搜索条件:服务结束
};
// 添加查询参数:所属销售姓名和客户姓名
const trimmedSalesName = params.salesName?.trim();
const trimmedCustomerName = params.customerName?.trim();
if (trimmedSalesName) {
queryParams.salesName = trimmedSalesName;
}
if (trimmedCustomerName) {
queryParams.customerName = trimmedCustomerName;
}
// 将参数转换为 URL 查询字符串(因为后端使用 @RequestParam
const queryString = Object.keys(queryParams)
.filter(key => queryParams[key] !== null && queryParams[key] !== undefined && queryParams[key] !== '')
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`)
.join('&');
// 调试:打印请求参数
console.log('服务记录查询参数:', JSON.stringify(queryParams));
console.log('查询字符串:', queryString);
// 后端使用 @RequestParam参数需要作为 URL 查询参数传递
const baseUrl = getApiUrl('/api/audioManagement/list');
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
// 获取认证信息
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,
method: 'POST',
data: {}, // POST 请求体为空,参数都在 URL 查询字符串中
header: headers,
timeout: 10000
});
if (res.statusCode === 200 && res.data && res.data.success) {
// 转换后端数据为页面需要的格式
const mappedList = res.data.data.map(item => {
// 格式化时间:将 ISO 格式转换为 YYYY-MM-DD HH:mm:ss
let formattedTime = '';
if (item.createTime) {
const date = new Date(item.createTime);
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');
const seconds = String(date.getSeconds()).padStart(2, '0');
formattedTime = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
// 处理录音时长:确保转换为数字,如果是 null/undefined 则保留为 null
let durationValue = null;
if (item.duration !== null && item.duration !== undefined && item.duration !== '') {
const numDuration = Number(item.duration);
durationValue = isNaN(numDuration) ? null : numDuration;
}
return {
id: item.id,
title: `${item.salesName || ''}的服务记录`,
date: formattedTime ? `时间:${formattedTime}` : '',
description: item.summary || '',
customerName: item.customerName || '',
recordingName: item.recordingName || '', // 记录名称
duration: durationValue, // 录音时长分钟数字类型null 表示无数据
// 保留原始数据,用于详情页
rawData: item
};
});
// 分页:第一页或刷新时重置,其他情况追加
if (this.serviceListPage.current === 1) {
this.serviceList = mappedList;
} else {
this.serviceList = this.serviceList.concat(mappedList);
}
this.serviceListTotal = res.data.total || 0;
} else {
uni.showToast({
title: res.data.message || '获取服务记录失败',
icon: 'none'
});
}
} catch (error) {
console.error('获取服务记录失败:', error);
uni.showToast({
title: `获取服务记录失败: ${error.errMsg || error.message || '未知错误'}`,
icon: 'none',
duration: 3000
});
}
}
}
}
</script>
<style>
page {
background-color: #F5F5F5;
}
.page {
min-height: 100vh;
background-color: #F5F5F5;
}
.content {
padding-top: 0; /* 移除padding-top因为tab页和列表组件都使用绝对定位 */
margin-top: 0;
position: relative;
min-height: 100vh; /* 确保content有足够高度 */
}
/* 隐藏导航栏占位区域,避免产生空白 */
::v-deep .uni-navbar__placeholder {
display: none !important;
}
/* 标签页 - 使用绝对定位紧贴导航栏 */
.tabs {
display: flex;
background-color: #FFFFFF;
border-bottom: 1px solid #E0E0E0;
padding: 0 32rpx;
position: absolute;
top: 88rpx; /* 导航栏高度 */
left: 0;
right: 0;
z-index: 1;
}
.tab-item {
padding: 24rpx 32rpx;
position: relative;
}
.tab-item text {
font-size: 30rpx;
color: #666;
}
.tab-item.active text {
color: #333;
font-weight: 500;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 32rpx;
right: 32rpx;
height: 4rpx;
background-color: #007AFF;
border-radius: 2rpx;
}
/* 列表头部 */
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
background-color: #FFFFFF;
border-bottom: 1px solid #F0F0F0;
}
.total {
font-size: 28rpx;
color: #666;
}
.filter-btn {
padding: 8rpx 16rpx;
background-color: #F5F5F5;
border-radius: 8rpx;
}
.filter-btn text {
font-size: 24rpx;
color: #666;
}
/* 服务记录列表 */
.service-list {
height: calc(100vh - 420rpx);
background-color: #FFFFFF;
}
.service-item {
padding: 32rpx;
border-bottom: 1px solid #F0F0F0;
background-color: #FFFFFF;
}
.service-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.service-title {
font-size: 32rpx;
font-weight: 500;
color: #333;
}
.service-actions {
display: flex;
align-items: center;
}
.archive-text {
font-size: 26rpx;
color: #999;
margin-right: 8rpx;
}
.service-meta {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.service-date {
font-size: 24rpx;
color: #999;
margin-right: 16rpx;
}
.service-duration {
font-size: 24rpx;
color: #999;
}
.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;
}
/* 服务记录详情弹窗 */
.service-detail-modal {
background-color: #FFFFFF;
border-radius: 32rpx 32rpx 0 0;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
border-bottom: 1px solid #F0F0F0;
}
.modal-close,
.modal-share {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-title {
font-size: 36rpx;
font-weight: 600;
color: #333;
flex: 1;
text-align: center;
}
.modal-date {
padding: 16rpx 32rpx;
}
.modal-date text {
font-size: 24rpx;
color: #999;
}
.modal-banner {
background-color: #E3F2FD;
padding: 24rpx 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 32rpx 24rpx;
border-radius: 16rpx;
}
.modal-banner .banner-left {
display: flex;
align-items: center;
flex: 1;
}
.modal-banner .banner-icon {
width: 40rpx;
height: 40rpx;
background-color: #FFFFFF;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12rpx;
}
.modal-banner .banner-text {
font-size: 26rpx;
color: #1976D2;
flex: 1;
}
.modal-banner .banner-right {
font-size: 28rpx;
color: #1976D2;
font-weight: 500;
margin-left: 16rpx;
}
/* 音频播放器 */
.audio-player {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
margin: 0 32rpx 24rpx;
background-color: #F5F5F5;
border-radius: 16rpx;
}
.play-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
}
.progress-bar {
flex: 1;
height: 4rpx;
background-color: #E0E0E0;
border-radius: 2rpx;
margin: 0 16rpx;
position: relative;
}
.progress-line {
width: 30%;
height: 100%;
background-color: #007AFF;
border-radius: 2rpx;
position: relative;
}
.progress-dot {
position: absolute;
right: -8rpx;
top: 50%;
transform: translateY(-50%);
width: 16rpx;
height: 16rpx;
background-color: #007AFF;
border-radius: 50%;
border: 2rpx solid #FFFFFF;
}
.audio-time {
font-size: 24rpx;
color: #666;
margin-right: 16rpx;
min-width: 200rpx;
}
.speed-btn {
padding: 8rpx 16rpx;
background-color: #FFFFFF;
border-radius: 8rpx;
border: 1px solid #E0E0E0;
}
.speed-btn text {
font-size: 24rpx;
color: #333;
}
/* 详情标签页 */
.detail-tabs {
display: flex;
background-color: #FFFFFF;
border-bottom: 1px solid #E0E0E0;
padding: 0 32rpx;
overflow-x: auto;
position: sticky;
top: 0;
z-index: 10;
flex-shrink: 0;
}
.detail-tab-item {
padding: 24rpx 16rpx;
position: relative;
white-space: nowrap;
margin-right: 32rpx;
}
.detail-tab-item text {
font-size: 28rpx;
color: #666;
}
.detail-tab-item.active text {
color: #333;
font-weight: 500;
}
.detail-tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 16rpx;
right: 16rpx;
height: 4rpx;
background-color: #007AFF;
border-radius: 2rpx;
}
/* 详情内容 */
.detail-content {
flex: 1;
padding: 24rpx 32rpx;
background-color: #FFFFFF;
overflow-y: auto;
min-height: 0;
}
.summary-item {
padding: 24rpx 0;
border-bottom: 1px solid #F0F0F0;
display: flex;
flex-direction: column;
position: relative;
padding-right: 40rpx;
}
.summary-item:last-child {
border-bottom: none;
}
.summary-label {
margin-bottom: 12rpx;
}
.summary-label text {
font-size: 28rpx;
color: #666;
font-weight: 500;
}
.summary-value {
flex: 1;
}
.summary-value text {
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
.summary-item uni-icons {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.empty-detail {
padding: 80rpx 0;
text-align: center;
}
.empty-detail text {
font-size: 28rpx;
color: #999;
}
/* 底部客户信息 */
.modal-footer {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
border-top: 1px solid #F0F0F0;
background-color: #FFFFFF;
}
.footer-label {
font-size: 28rpx;
color: #666;
margin: 0 8rpx 0 12rpx;
}
.footer-customer {
font-size: 28rpx;
color: #1976D2;
flex: 1;
}
/* 服务表现样式 */
.performance-score {
text-align: center;
padding: 40rpx 32rpx;
background-color: #FFFFFF;
border-bottom: 1px solid #F0F0F0;
}
.score-label {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 16rpx;
}
.score-value {
display: block;
font-size: 72rpx;
font-weight: 600;
color: #007AFF;
}
.performance-category {
background-color: #FFFFFF;
border-bottom: 1px solid #F0F0F0;
}
.category-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
}
.category-left {
display: flex;
align-items: center;
flex: 1;
}
.category-bullet {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
margin-right: 16rpx;
}
.bullet-blue {
background-color: #007AFF;
}
.bullet-gray {
background-color: #999;
}
.category-name {
font-size: 30rpx;
color: #333;
font-weight: 500;
}
.category-content {
padding: 0 32rpx 32rpx;
}
.performance-banner {
background-color: #E3F2FD;
padding: 24rpx 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
border-radius: 16rpx;
}
.performance-banner .banner-left {
display: flex;
align-items: center;
flex: 1;
}
.performance-banner .banner-icon {
width: 40rpx;
height: 40rpx;
background-color: #FFFFFF;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12rpx;
}
.performance-banner .banner-text {
font-size: 26rpx;
color: #1976D2;
flex: 1;
}
.performance-banner .banner-right {
font-size: 28rpx;
color: #1976D2;
font-weight: 500;
margin-left: 16rpx;
}
.performance-audio {
margin-bottom: 24rpx;
}
.performance-audio .audio-player {
margin: 0;
}
.performance-item {
margin-bottom: 16rpx;
}
.performance-item.item-completed {
background-color: #E8F5E9;
border-radius: 12rpx;
padding: 16rpx;
}
.item-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
}
.item-left {
display: flex;
align-items: center;
flex: 1;
flex-wrap: wrap;
gap: 12rpx;
}
.item-name {
font-size: 28rpx;
color: #333;
margin-right: 16rpx;
}
.item-status {
font-size: 24rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.status-completed {
background-color: #4CAF50;
color: #FFFFFF;
}
.status-missing {
background-color: #F5F5F5;
color: #999;
}
.item-points {
font-size: 24rpx;
color: #007AFF;
font-weight: 500;
margin-left: 8rpx;
}
/* 产品类别标签页 */
.product-category-tabs {
background-color: #FFFFFF;
border-bottom: 1px solid #E0E0E0;
margin-bottom: 24rpx;
padding: 0;
}
.category-tabs-scroll {
white-space: nowrap;
width: 100%;
}
.category-tabs-wrapper {
display: inline-flex;
padding: 0 32rpx;
}
.category-tab-item {
padding: 24rpx 16rpx;
position: relative;
margin-right: 32rpx;
white-space: nowrap;
}
.category-tab-item text {
font-size: 28rpx;
color: #666;
}
.category-tab-item.active text {
color: #333;
font-weight: 500;
}
.category-tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 16rpx;
right: 16rpx;
height: 4rpx;
background-color: #007AFF;
border-radius: 2rpx;
}
/* 产品需求清单表格 */
.product-requirement-table {
background-color: #FFFFFF;
padding: 0;
}
.requirement-title {
padding: 24rpx 32rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
border-bottom: 1px solid #F0F0F0;
}
.requirement-table {
padding: 0 32rpx 24rpx;
}
.requirement-row {
display: flex;
padding: 24rpx 0;
border-bottom: 1px solid #F0F0F0;
}
.requirement-row:last-child {
border-bottom: none;
}
.requirement-label {
min-width: 200rpx;
width: 200rpx;
padding-right: 24rpx;
}
.requirement-label text {
font-size: 28rpx;
color: #666;
font-weight: 500;
}
.requirement-content {
flex: 1;
}
.requirement-content text {
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
/* 待办事项列表 */
.todo-list {
padding: 0;
}
.todo-item {
padding: 32rpx;
border-bottom: 1px solid #F0F0F0;
background-color: #FFFFFF;
}
.todo-item:last-child {
border-bottom: none;
}
.todo-item.todo-completed {
background-color: #F5F5F5;
opacity: 0.8;
}
.todo-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.todo-title-wrapper {
display: flex;
align-items: center;
flex: 1;
}
.todo-status-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
margin-right: 12rpx;
flex-shrink: 0;
}
.todo-status-dot.status-pending {
background-color: #FF9800;
}
.todo-status-dot.status-processing {
background-color: #2196F3;
}
.todo-status-dot.status-completed {
background-color: #4CAF50;
}
.todo-title {
font-size: 32rpx;
font-weight: 500;
color: #333;
flex: 1;
}
.todo-status {
font-size: 24rpx;
padding: 6rpx 16rpx;
border-radius: 12rpx;
white-space: nowrap;
}
.todo-status.status-text-pending {
background-color: #FFF3E0;
color: #FF9800;
}
.todo-status.status-text-processing {
background-color: #E3F2FD;
color: #2196F3;
}
.todo-status.status-text-completed {
background-color: #E8F5E9;
color: #4CAF50;
}
.todo-status-wrapper {
position: relative;
z-index: 10;
}
.todo-status-clickable {
display: flex;
align-items: center;
}
.todo-item.todo-clickable .todo-status-clickable {
position: relative;
}
.todo-menu-fixed {
position: fixed;
background-color: #FFFFFF;
border-radius: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
z-index: 10000;
overflow: hidden;
}
.todo-menu-item {
padding: 24rpx 32rpx;
border-bottom: 1px solid #F0F0F0;
transition: background-color 0.2s;
}
.todo-menu-fixed .todo-menu-item:last-child {
border-bottom: none;
}
.todo-menu-item:active {
background-color: #F5F5F5;
}
.todo-menu-item text {
font-size: 28rpx;
color: #333;
}
.todo-menu-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.01);
z-index: 9999;
}
.todo-content {
margin-bottom: 16rpx;
padding: 16rpx;
background-color: #F5F5F5;
border-radius: 8rpx;
}
.todo-content text {
font-size: 28rpx;
color: #666;
line-height: 1.6;
}
.todo-meta {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.todo-meta-item {
font-size: 24rpx;
color: #999;
}
/* 章节概要样式 */
.chapter-summary-list {
padding: 0;
}
.chapter-summary-item {
background-color: #FFFFFF;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 24rpx;
border: 1px solid #F0F0F0;
}
.chapter-summary-item:last-child {
margin-bottom: 0;
}
.chapter-summary-title {
margin-bottom: 24rpx;
padding-bottom: 16rpx;
border-bottom: 2px solid #007AFF;
}
.chapter-summary-title text {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.chapter-summary-section {
margin-bottom: 24rpx;
}
.chapter-summary-section:last-child {
margin-bottom: 0;
}
.chapter-summary-label {
margin-bottom: 12rpx;
}
.chapter-summary-label text {
font-size: 28rpx;
font-weight: 500;
color: #666;
}
.chapter-summary-content {
background-color: #F5F5F5;
border-radius: 12rpx;
padding: 20rpx;
}
.chapter-summary-content text {
font-size: 28rpx;
color: #333;
line-height: 1.8;
}
/* 录音列表弹窗样式 */
.audio-list-modal {
background-color: #FFFFFF;
border-radius: 32rpx 32rpx 0 0;
max-height: 90vh;
height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.audio-list-content {
flex: 1;
padding: 24rpx 32rpx;
box-sizing: border-box;
background-color: #F5F5F5;
min-height: 0; /* 配合 flex: 1 使用,确保可以滚动 */
height: 100%;
}
.audio-list-item {
background-color: #FFFFFF;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.audio-item-header {
margin-bottom: 16rpx;
display: flex;
align-items: center;
}
.audio-play-btn {
min-width: 80rpx;
height: 56rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
cursor: pointer;
border-radius: 8rpx;
transition: background-color 0.2s;
flex-shrink: 0;
background-color: #F5F5F5;
position: relative;
z-index: 1;
}
.audio-play-btn:active {
background-color: #E0E0E0;
}
.audio-play-text {
font-size: 24rpx;
color: #2A68FF;
font-weight: 500;
line-height: 1;
}
.audio-play-text--playing {
color: #FF3B30;
}
.audio-item-name {
font-size: 32rpx;
font-weight: 500;
color: #333;
flex: 1;
}
.audio-item-info {
margin-bottom: 16rpx;
}
.audio-item-text {
font-size: 28rpx;
color: #666;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.audio-item-text--empty {
color: #999;
font-style: italic;
}
.audio-item-transcribe {
display: flex;
align-items: center;
}
.transcribe-btn {
padding: 12rpx 24rpx;
background-color: #007AFF;
border-radius: 8rpx;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
}
.transcribe-btn:active {
background-color: #0056CC;
}
.transcribe-btn--loading {
opacity: 0.6;
background-color: #999;
cursor: not-allowed;
}
.transcribe-btn text {
font-size: 26rpx;
color: #FFFFFF;
font-weight: 500;
}
.audio-item-meta {
display: flex;
flex-wrap: nowrap;
gap: 16rpx;
padding-top: 16rpx;
border-top: 1px solid #F0F0F0;
align-items: center;
}
.audio-meta-item {
font-size: 24rpx;
color: #999;
white-space: nowrap;
flex-shrink: 0;
}
.audio-list-empty {
padding: 48rpx 0;
text-align: center;
color: #999;
font-size: 28rpx;
}
/* 录音文本内容弹框样式 */
/* 提高文本内容弹框的层级,确保显示在录音列表弹框上方 */
/* 通过深度选择器覆盖uni-popup的z-index */
.text-modal-popup {
z-index: 1000 !important;
}
.text-modal-popup .uni-popup {
z-index: 1000 !important;
}
.text-modal {
width: 100%;
height: 80vh;
max-height: 80vh;
background-color: #FFFFFF;
border-radius: 24rpx;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
position: relative;
}
.text-modal-header {
padding: 32rpx;
border-bottom: 1px solid #F0F0F0;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.text-modal-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.text-modal-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 50%;
transition: background-color 0.2s;
}
.text-modal-close:active {
background-color: #F5F5F5;
}
.text-modal-content {
flex: 1;
height: 0;
min-height: 0;
box-sizing: border-box;
}
.text-modal-text {
font-size: 28rpx;
color: #333;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
display: block;
padding: 32rpx;
box-sizing: border-box;
}
</style>