Files
smartDriveEEUniApp/pages-subpackage/furniture_reception/serviceListFurniture.vue
2026-04-18 21:53:48 +08:00

1576 lines
40 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">
<!-- 工具条参考服务记录放在 scroll-view 避免顶部空白/滚动错位 -->
<view class="service-status-toolbar">
<text class="toolbar-total">{{ serviceStatusTotal }}</text>
<input
class="toolbar-input"
v-model="serviceStatusQuery.salesName"
placeholder="所属销售姓名"
placeholder-style="color: #b0b0b0"
confirm-type="search"
@confirm="onServiceStatusSearch"
/>
<input
class="toolbar-input"
v-model="serviceStatusQuery.customerName"
placeholder="客户姓名"
placeholder-style="color: #b0b0b0"
confirm-type="search"
@confirm="onServiceStatusSearch"
/>
<input
v-if="isAdmin"
class="toolbar-input"
v-model="serviceStatusQuery.salesPhone"
placeholder="销售电话"
placeholder-style="color: #b0b0b0"
confirm-type="search"
@confirm="onServiceStatusSearch"
/>
<view class="toolbar-actions">
<view
v-if="showReceptionEntryShortcut"
class="toolbar-btn toolbar-btn--reception"
@click="goReceptionEntry"
>
<uni-icons type="home" size="18" color="#2A68FF"></uni-icons>
<text>接待</text>
</view>
<view class="toolbar-btn toolbar-btn--refresh" @click="onServiceStatusRefresh">
<uni-icons type="refresh" size="18" color="#2A68FF"></uni-icons>
<text>刷新</text>
</view>
</view>
</view>
<!-- 服务状态列表 -->
<scroll-view
class="service-list"
scroll-y
enable-flex
@scrolltolower="onServiceStatusReachBottom">
<view
class="service-card"
v-for="(item, index) in serviceStatusList"
:key="item.id || index"
@click="viewServiceDetail(item)">
<view class="card-header">
<view class="staff-info">
<view class="staff-avatar">
<text class="avatar-text">{{ item.staffName ? item.staffName.charAt(0) : '未' }}</text>
</view>
<text class="staff-name">{{ item.staffName || '未分配销售' }}</text>
</view>
<view class="service-status-indicator">
<uni-icons type="bars" size="16" color="#007AFF"></uni-icons>
<text class="service-status-indicator__text">{{ recordStateLabel }}</text>
</view>
</view>
<view class="service-title" v-if="item.title">
<text class="service-title__text">{{ item.title }}</text>
</view>
<view class="customer-info">
<text class="customer-name">客户{{ item.customerName || '未知客户' }}</text>
<text class="customer-phone" v-if="item.customerPhone">电话{{ item.customerPhone }}</text>
</view>
<view class="customer-tags">
<view
class="tag-item"
:class="'tag-' + tag.color"
v-for="(tag, tagIndex) in item.tags"
:key="tagIndex">
<text>{{ tag.text }}</text>
</view>
</view>
<!-- AI提醒 -->
<view class="ai-alert" v-if="item.alert" :class="item.alert.type === 'risk' ? 'alert-risk-box' : 'alert-reminder-box'">
<view class="alert-header">
<view class="alert-icon">
<view class="icon-circle"></view>
</view>
<text class="alert-title">{{ item.alert.title }}</text>
</view>
<view class="alert-content" :class="item.alert.type === 'risk' ? 'alert-risk-text' : 'alert-reminder-text'">
<text v-if="item.alert.type === 'risk'">{{ item.alert.message }}</text>
<view v-else class="alert-list">
<view class="alert-item" v-for="(msg, msgIndex) in item.alert.messages" :key="msgIndex">
<text>{{ msg }}</text>
</view>
</view>
</view>
</view>
<view class="service-duration">
<text>{{ item.durationText }}</text>
</view>
<view class="service-actions-row">
<view
v-if="!isRecordingItem(item.id)"
class="service-action-btn service-action-btn--record"
@click.stop="startPhoneRecord(item)"
>
<text>录音</text>
</view>
<view
v-else
class="service-action-btn service-action-btn--recording"
@click.stop="stopPhoneRecordAndUpload(item)"
>
<text>停止录音</text>
</view>
<view class="service-action-btn service-action-btn--supplement" @click.stop="showSupplementDialog(item)">
<text>补录客户</text>
</view>
<view class="service-action-btn service-action-btn--upload" @click.stop="chooseAndUploadFile(item)">
<text>{{ isUploading(item.id) ? '上传中' : '上传录音' }}</text>
</view>
<view class="service-action-btn service-action-btn--finish" @click.stop="finishService(item)">
<text>结束服务</text>
</view>
</view>
</view>
<view class="service-status-empty" v-if="!serviceStatusLoading && !serviceStatusList.length">
<text>{{ emptyListHint }}</text>
</view>
<view class="service-status-loading-more" v-if="serviceStatusLoading && serviceStatusList.length">
<text>正在加载更多...</text>
</view>
</scroll-view>
<!-- 补录弹窗 -->
<view class="supplement-dialog-mask" v-if="showSupplementModal" @click="closeSupplementDialog">
<view class="supplement-dialog" @click.stop>
<view class="supplement-dialog__header">
<text class="supplement-dialog__title">补录信息</text>
<view class="supplement-dialog__close" @click="closeSupplementDialog">
<uni-icons type="close" size="20" color="#999"></uni-icons>
</view>
</view>
<view class="supplement-dialog__body">
<view class="supplement-form-item">
<text class="supplement-form-item__label">客户姓名</text>
<input
class="supplement-form-item__input"
v-model="supplementForm.customerName"
placeholder="请输入客户姓名"
placeholder-style="color: #9ca3af"
/>
</view>
<view class="supplement-form-item">
<text class="supplement-form-item__label">客户电话</text>
<input
class="supplement-form-item__input"
v-model="supplementForm.contact"
type="number"
placeholder="请输入客户电话"
placeholder-style="color: #9ca3af"
/>
</view>
<view class="supplement-form-item">
<text class="supplement-form-item__label">录音名</text>
<input
class="supplement-form-item__input"
v-model="supplementForm.recordingName"
placeholder="请输入录音名"
placeholder-style="color: #9ca3af"
/>
</view>
<view class="supplement-form-item">
<text class="supplement-form-item__label">客户详细地址</text>
<textarea
class="supplement-form-item__textarea"
v-model="supplementForm.remarks"
placeholder="请输入客户详细地址"
placeholder-style="color: #9ca3af"
:maxlength="500"
/>
</view>
</view>
<view class="supplement-dialog__footer">
<view class="supplement-dialog__btn supplement-dialog__btn--cancel" @click="closeSupplementDialog">
<text>取消</text>
</view>
<view class="supplement-dialog__btn supplement-dialog__btn--save" @click="saveSupplement">
<text>保存</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { getApiUrl } from "@/common/config.js";
import { store } from "@/common/store.js";
function firstNonEmptyString(...parts) {
for (const p of parts) {
if (p === undefined || p === null) continue;
const s = String(p).trim();
if (s) return s;
}
return "";
}
export default {
props: {
/** 卡片右侧状态文案默认与接待页「服务中」Tab 一致 */
recordStateLabel: {
type: String,
default: '服务中',
},
emptyListHint: {
type: String,
default: '暂无服务中记录',
},
/** 是否在工具栏显示进入完整「接待」页的快捷入口 */
showReceptionEntryShortcut: {
type: Boolean,
default: false,
},
/** 卡片右侧是否显示状态横杠图标与 recordStateLabel 文案 */
showRecordStateIndicator: {
type: Boolean,
default: true,
},
},
data() {
return {
serviceStatusLoading: false,
serviceStatusTotal: 0,
serviceStatusPage: {
current: 1,
size: 10
},
serviceStatusQuery: {
salesName: '',
customerName: '',
salesPhone: ''
},
isAdmin: false,
currentUserPhone: '',
serviceStatusList: [],
// 补录相关
showSupplementModal: false,
currentSupplementItem: null,
uploadingIds: [],
supplementForm: {
customerName: "",
contact: "",
recordingName: "",
remarks: ""
},
recorderSupported: false,
recorderManager: null,
recordingServiceId: null,
recordingContext: null
}
},
mounted() {
// 检查用户角色
this.checkUserRole();
// 获取当前用户手机号
this.loadCurrentUserPhone();
this.initServiceRecorder();
this.fetchServiceStatusList();
},
beforeUnmount() {
if (this.recorderManager && this.recordingServiceId) {
try {
this.recorderManager.stop();
} catch (e) {
/* ignore */
}
}
},
methods: {
initServiceRecorder() {
this.recorderSupported = typeof uni.getRecorderManager === 'function';
if (!this.recorderSupported) {
return;
}
this.recorderManager = uni.getRecorderManager();
// 用户点击「停止」后由系统回调此处,再将临时录音文件上传到 /api/audio/upload
this.recorderManager.onStop((res) => {
const ctx = this.recordingContext;
this.recordingServiceId = null;
this.recordingContext = null;
const tempFilePath = res.tempFilePath || '';
if (!ctx || !ctx.id) {
return;
}
if (!tempFilePath) {
uni.showToast({ title: '未获取到录音文件', icon: 'none' });
return;
}
const name = `service_record_${ctx.id}_${Date.now()}.mp3`;
this.uploadFileForRecord(ctx, { path: tempFilePath, name });
});
this.recorderManager.onError(() => {
this.recordingServiceId = null;
this.recordingContext = null;
uni.showToast({ title: '录音出错', icon: 'none' });
});
},
isRecordingItem(recordId) {
if (!recordId || !this.recordingServiceId) {
return false;
}
return String(recordId) === this.recordingServiceId;
},
startPhoneRecord(item) {
if (!this.recorderSupported || !this.recorderManager) {
uni.showToast({
title: '当前环境不支持录音,请使用微信小程序或 App',
icon: 'none'
});
return;
}
if (!item || !item.id) {
uni.showToast({
title: '无法获取服务记录ID',
icon: 'none'
});
return;
}
if (this.recordingServiceId) {
uni.showToast({ title: '请先停止当前录音', icon: 'none' });
return;
}
if (this.isUploading(item.id)) {
uni.showToast({ title: '正在上传,请稍候', icon: 'none' });
return;
}
this.recordingContext = { ...item };
this.recordingServiceId = String(item.id);
try {
this.recorderManager.start({
duration: 600000,
sampleRate: 44100,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
});
} catch (e) {
this.recordingServiceId = null;
this.recordingContext = null;
uni.showToast({ title: '无法开始录音', icon: 'none' });
}
},
/** 结束录音onStop 回调中会调用 uploadFileForRecord 上传至后端 */
stopPhoneRecordAndUpload(item) {
if (!this.recorderManager || !item?.id) {
return;
}
if (String(item.id) !== this.recordingServiceId) {
return;
}
this.recorderManager.stop();
},
/**
* 检查用户角色判断是否包含admin
* 只要角色中包含admin字符串如admin_furniture就视为admin角色
*/
checkUserRole() {
try {
const normalize = (val) =>
typeof val === 'string'
? val
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
: [];
const storedRole = uni.getStorageSync('backend-role-name') || '';
const loginResp = uni.getStorageSync('backend-login-response') || {};
const respRole = loginResp.roleName || '';
const roles = [...normalize(storedRole), ...normalize(respRole)];
// 检查角色数组中是否有任何角色包含 'admin' 字符串
this.isAdmin = roles.some(role => role.includes('admin'));
console.log('用户角色检查:', { storedRole, respRole, roles, isAdmin: this.isAdmin });
} catch (e) {
console.error('检查用户角色失败:', e);
this.isAdmin = false;
}
},
/**
* 加载当前用户手机号或登录账户
* 优先使用手机号,如果没有手机号则使用登录账户
*/
loadCurrentUserPhone() {
try {
const loginResponse = uni.getStorageSync('backend-login-response') || {};
// 优先使用手机号如果没有手机号则使用登录账户userName
if (loginResponse.phone) {
this.currentUserPhone = loginResponse.phone;
} else if (loginResponse.userName) {
this.currentUserPhone = loginResponse.userName;
} else {
this.currentUserPhone = '';
}
} catch (e) {
console.error('加载当前用户手机号或登录账户失败:', e);
this.currentUserPhone = '';
}
},
onServiceStatusSearch() {
this.serviceStatusPage.current = 1;
this.fetchServiceStatusList({ force: true });
},
onServiceStatusRefresh() {
this.serviceStatusPage.current = 1;
this.fetchServiceStatusList({ force: true });
},
goReceptionEntry() {
const url = '/pages-subpackage/furniture_reception/furniture_reception_entry?tab=reception';
uni.navigateTo({
url,
fail: () => {
uni.showToast({ title: '跳转接待失败', icon: 'none' });
},
});
},
onServiceStatusReachBottom() {
// 已在加载中或全部加载完成则不再触发
if (this.serviceStatusLoading) return;
if (this.serviceStatusList.length >= this.serviceStatusTotal) return;
this.serviceStatusPage.current += 1;
this.fetchServiceStatusList();
},
async fetchServiceStatusList({ force = false } = {}) {
if (this.serviceStatusLoading && !force) {
return;
}
this.serviceStatusLoading = true;
try {
// 构建查询参数对象
const queryParams = {
current: this.serviceStatusPage.current,
size: this.serviceStatusPage.size,
serviceStatus: '服务中' // 默认搜索条件
};
const trimmedSalesName = this.serviceStatusQuery?.salesName?.trim();
const trimmedCustomerName = this.serviceStatusQuery?.customerName?.trim();
if (trimmedSalesName) {
queryParams.salesName = trimmedSalesName;
}
if (trimmedCustomerName) {
queryParams.customerName = trimmedCustomerName;
}
// 根据角色处理销售电话查询条件
if (this.isAdmin) {
// 如果是admin使用搜索框中的销售电话
const trimmedSalesPhone = this.serviceStatusQuery?.salesPhone?.trim();
if (trimmedSalesPhone) {
queryParams.salesPhone = trimmedSalesPhone;
}
} else {
// 如果不是admin默认添加当前登录人的手机号或登录账户
if (this.currentUserPhone) {
queryParams.salesPhone = this.currentUserPhone;
}
}
// 确保 serviceStatus 始终存在
queryParams.serviceStatus = '服务中';
// 将参数转换为 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: 30000 // 增加超时时间到30秒
});
if (res.statusCode === 200 && res.data && res.data.success) {
const records = Array.isArray(res.data.data) ? res.data.data : [];
if (records.length === 0) {
this.serviceStatusList = [];
this.serviceStatusTotal = 0;
return;
}
const mapped = records
.map(item => this.buildServiceStatusItem(item))
.filter(item => item !== null); // 过滤掉无效的数据
// 第一页或强制刷新时重置列表,否则追加
if (this.serviceStatusPage.current === 1 || force) {
// 使用 Vue 3 的响应式更新方式
this.serviceStatusList = [...mapped];
} else {
this.serviceStatusList = [...this.serviceStatusList, ...mapped];
}
this.serviceStatusTotal = Number(res.data.total) || 0;
} else {
this.serviceStatusList = [];
this.serviceStatusTotal = 0;
uni.showToast({
title: res.data?.message || '获取服务中列表失败',
icon: 'none'
});
}
} catch (error) {
console.error('获取服务中列表失败:', error);
// 根据错误类型提供更详细的错误信息
let errorMessage = '获取服务状态失败,请稍后重试';
if (error.errMsg) {
if (error.errMsg.includes('timeout')) {
errorMessage = '请求超时,请检查网络连接后重试';
} else if (error.errMsg.includes('fail')) {
errorMessage = '网络请求失败,请检查网络连接';
}
}
uni.showToast({
title: errorMessage,
icon: 'none',
duration: 3000
});
} finally {
this.serviceStatusLoading = false;
}
},
buildServiceStatusItem(record = {}) {
if (!record || typeof record !== 'object') {
return null;
}
const formattedCreateTime = this.formatDateTime(record.createTime);
const tags = [];
if (record.recordingName) {
tags.push({ text: `录音:${record.recordingName}`, color: 'blue' });
}
if (record.intentionLevel) {
tags.push({ text: `意向:${record.intentionLevel}`, color: 'orange' });
}
if (record.projectName) {
tags.push({ text: `项目:${record.projectName}`, color: 'blue' });
}
const title =
firstNonEmptyString(record.recordingName) ||
(firstNonEmptyString(record.customerName) ? `${String(record.customerName).trim()}的接待记录` : '') ||
'';
return {
id: record.id || '',
staffName: record.salesName || '未分配销售',
status: record.syncStatus || '服务中',
customerName: record.customerName || '',
customerPhone: record.customerPhone || '',
customerId: record.customerId || '',
recordingName: record.recordingName || '',
title,
remarks: record.remarks || '',
tags,
durationText: formattedCreateTime ? `开始时间:${formattedCreateTime}` : '暂无开始时间'
};
},
formatDateTime(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
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}`;
},
viewServiceDetail(item) {
// 查看服务详情
console.log('查看服务详情', item);
},
isUploading(recordId) {
if (!recordId) return false;
return this.uploadingIds.includes(String(recordId));
},
/** 仅允许扩展名为 .mp3 的文件(不区分大小写) */
isMp3UploadFile(selected) {
if (!selected || !selected.path) return false;
const pickBase = (s) => {
if (!s || typeof s !== 'string') return '';
const seg = s.split('/').pop() || s.split('\\').pop() || s;
return seg.trim();
};
const bases = [pickBase(selected.name), pickBase(selected.path)].filter(Boolean);
for (const base of bases) {
if (base.toLowerCase().endsWith('.mp3')) {
return true;
}
}
return false;
},
async chooseAndUploadFile(item) {
if (!item || !item.id) {
uni.showToast({
title: '无法获取服务记录ID',
icon: 'none'
});
return;
}
if (this.isUploading(item.id)) {
return;
}
const selectedFile = await this.selectUploadFile();
if (!selectedFile || !selectedFile.path) {
return;
}
await this.uploadFileForRecord(item, selectedFile);
},
selectUploadFile() {
return new Promise((resolve) => {
const normalizeSelected = (res) => {
const tempFiles = Array.isArray(res?.tempFiles) ? res.tempFiles : [];
if (!tempFiles.length) return null;
const file = tempFiles[0] || {};
const filePath = file.path || file.tempFilePath || file.url || '';
const fileName = file.name || filePath.split('/').pop() || `service_file_${Date.now()}`;
if (!filePath) return null;
return {
path: filePath,
name: fileName
};
};
const onSuccess = (res) => {
const selected = normalizeSelected(res);
if (!selected) {
uni.showToast({
title: '未选择有效文件',
icon: 'none'
});
resolve(null);
return;
}
if (!this.isMp3UploadFile(selected)) {
uni.showToast({
title: '仅支持上传 MP3 文件',
icon: 'none'
});
resolve(null);
return;
}
resolve(selected);
};
const onFail = (err) => {
const errMsg = err?.errMsg || '';
if (errMsg.includes('cancel')) {
resolve(null);
return;
}
console.error('选择文件失败:', err);
uni.showToast({
title: '选择文件失败',
icon: 'none'
});
resolve(null);
};
if (typeof uni.chooseMessageFile === 'function') {
uni.chooseMessageFile({
count: 1,
type: 'file',
success: onSuccess,
fail: onFail
});
return;
}
if (typeof uni.chooseFile === 'function') {
uni.chooseFile({
count: 1,
extension: ['.mp3'],
success: onSuccess,
fail: onFail
});
return;
}
uni.chooseImage({
count: 1,
success: onSuccess,
fail: onFail
});
});
},
getAuthHeaders() {
let tenantId = '';
let token = '';
try {
tenantId = uni.getStorageSync('backend-tenant-id') || '';
token = uni.getStorageSync('backend-token') || '';
} catch (e) {
console.error('获取认证信息失败:', e);
}
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (tenantId) {
headers['X-Tenant-Id'] = tenantId;
}
return headers;
},
async uploadFileForRecord(item, selectedFile) {
const recordId = String(item.id);
this.uploadingIds = [...this.uploadingIds, recordId];
try {
uni.showLoading({
title: '上传中...'
});
const uploadRes = await uni.uploadFile({
url: getApiUrl('/api/audio/upload'),
filePath: selectedFile.path,
name: 'file',
formData: {
id: item.id,
audioId: item.id,
customerId: item.customerId || '',
customerName: item.customerName || '',
fileName: selectedFile.name || `service_file_${Date.now()}`
},
header: this.getAuthHeaders(),
timeout: 60000
});
let result = {};
try {
result = typeof uploadRes?.data === 'string' ? JSON.parse(uploadRes.data) : (uploadRes?.data || {});
} catch (e) {
result = uploadRes?.data || {};
}
if (uploadRes?.statusCode === 200 && result?.success !== false) {
uni.showToast({
title: '上传成功',
icon: 'success'
});
this.serviceStatusPage.current = 1;
this.fetchServiceStatusList({ force: true });
return;
}
uni.showToast({
title: result?.message || '上传失败',
icon: 'none'
});
} catch (error) {
console.error('上传文件失败:', error);
uni.showToast({
title: error?.errMsg || error?.message || '上传失败',
icon: 'none'
});
} finally {
uni.hideLoading();
this.uploadingIds = this.uploadingIds.filter(id => id !== recordId);
}
},
async finishService(item) {
if (!item || !item.id) {
uni.showToast({
title: '无法获取服务记录ID',
icon: 'none'
});
return;
}
uni.showModal({
title: '确认结束',
content: `确定要结束这条服务记录吗?`,
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({
title: '结束中...'
});
const url = getApiUrl('/api/audioManagement/finishServiceById');
// 获取认证信息
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 requestData = {
id: item.id
};
const finishRes = await uni.request({
url: url,
method: 'POST',
data: requestData,
header: headers,
timeout: 30000
});
uni.hideLoading();
if (finishRes.statusCode === 200 && finishRes.data && finishRes.data.success) {
const rawSuccessMsg = finishRes.data?.message || '结束服务成功';
const successTitle = rawSuccessMsg
.replace(/[A-Za-z]+/g, '')
.split(/\n/)
.map((line) => line.trim())
.filter(Boolean)
.join('\n') || '结束服务成功';
uni.showToast({
title: successTitle,
icon: 'success'
});
// 结束成功后刷新列表
this.serviceStatusPage.current = 1;
this.fetchServiceStatusList({ force: true });
} else {
uni.showToast({
title: finishRes.data?.message || '结束服务失败',
icon: 'none'
});
}
} catch (error) {
uni.hideLoading();
console.error('结束服务失败:', error);
// 根据错误类型提供更详细的错误信息
let errorMessage = '结束服务失败,请重试';
if (error.errMsg) {
if (error.errMsg.includes('timeout')) {
errorMessage = '请求超时,请检查网络连接后重试';
} else if (error.errMsg.includes('fail')) {
errorMessage = '网络请求失败,请检查网络连接';
}
}
uni.showToast({
title: errorMessage,
icon: 'none',
duration: 3000
});
}
}
}
});
},
showSupplementDialog(item) {
this.currentSupplementItem = item;
// 初始化表单数据,如果有现有数据则填充
this.supplementForm = {
customerName: item.customerName || "",
contact: item.customerPhone || "",
recordingName: item.recordingName || "",
remarks: item.remarks || ""
};
this.showSupplementModal = true;
},
closeSupplementDialog() {
this.showSupplementModal = false;
this.currentSupplementItem = null;
this.supplementForm = {
customerName: "",
contact: "",
recordingName: "",
remarks: ""
};
},
async saveSupplement() {
// 验证电话格式(如果填写了电话)
const trimmedContact = this.supplementForm.contact?.trim();
if (trimmedContact && !this.isValidPhoneNumber(trimmedContact)) {
uni.showToast({
title: "请输入有效的客户电话",
icon: "none"
});
return;
}
if (!this.currentSupplementItem || !this.currentSupplementItem.id) {
uni.showToast({
title: '无法获取服务记录ID',
icon: 'none'
});
return;
}
try {
uni.showLoading({
title: "保存中..."
});
// 获取认证信息
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;
}
let loginResponse = {};
try {
loginResponse = uni.getStorageSync("backend-login-response") || {};
} catch (e) {
console.error("读取登录信息失败:", e);
}
const ui = store.userInfo || {};
// 与接待页、列表筛选一致:销售电话优先手机号,否则登录账号
const salesPhone = firstNonEmptyString(
loginResponse.phone,
loginResponse.userName,
ui.username
);
// 姓名:优先后端姓名类字段,再退回昵称、登录名
const salesName = firstNonEmptyString(
loginResponse.realName,
loginResponse.name,
loginResponse.nickName,
ui.nickname,
loginResponse.userName,
ui.username
);
// 构建请求参数必须包含id
const params = {
id: this.currentSupplementItem.id,
customerId: this.currentSupplementItem.customerId || "",
customerName: this.supplementForm.customerName?.trim() || "",
customerPhone: trimmedContact || "",
recordingName: this.supplementForm.recordingName?.trim() || "",
remarks: this.supplementForm.remarks?.trim() || "",
salesPhone: salesPhone,
salesName: salesName
};
// 调用补录接口
const res = await uni.request({
url: getApiUrl('/api/audioManagement/updateForCustomerInfo'),
method: "PUT",
data: params,
header: headers,
timeout: 30000
});
uni.hideLoading();
if (res.statusCode === 200 && res.data && res.data.success) {
uni.showToast({
title: "补录成功",
icon: "success"
});
// 关闭弹窗
this.closeSupplementDialog();
// 刷新列表
this.serviceStatusPage.current = 1;
this.fetchServiceStatusList({ force: true });
} else {
uni.showToast({
title: res.data?.message || "补录失败",
icon: "none"
});
}
} catch (error) {
uni.hideLoading();
console.error("补录失败:", error);
let errorMessage = '补录失败,请重试';
if (error.errMsg) {
if (error.errMsg.includes('timeout')) {
errorMessage = '请求超时,请检查网络连接后重试';
} else if (error.errMsg.includes('fail')) {
errorMessage = '网络请求失败,请检查网络连接';
}
}
uni.showToast({
title: errorMessage,
icon: 'none',
duration: 3000
});
}
},
isValidPhoneNumber(phone) {
const normalizedPhone = phone?.trim();
if (!normalizedPhone) {
return false;
}
return /^1[3-9]\d{9}$/.test(normalizedPhone);
}
}
}
</script>
<style scoped>
/* 容器布局(参考「服务记录」组件):工具条固定在上,列表滚动占满剩余高度 */
.service-list-container {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
background-color: #F5F5F5;
/* 只保留左右 padding让 toolbar 紧贴 tab下方不再出现大空白 */
padding: 0 16rpx;
box-sizing: border-box;
}
.service-status-toolbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8rpx;
padding: 12rpx 8rpx;
margin-bottom: 24rpx;
background-color: #F8F8FA;
border-radius: 8rpx;
border: 1px solid #EFEFF2;
overflow-x: auto;
min-height: 64rpx;
box-sizing: border-box;
flex-shrink: 0;
}
.service-list {
flex: 1;
background-color: transparent;
box-sizing: border-box;
overflow-y: auto;
}
.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;
}
.service-card {
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);
overflow: visible;
position: relative;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
width: 100%;
box-sizing: border-box;
padding: 0;
margin: 0;
}
.service-title {
margin-top: 16rpx;
margin-bottom: 8rpx;
}
.service-title__text {
font-size: 28rpx;
font-weight: 600;
color: #111827;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.staff-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
margin: 0;
padding: 0;
}
.staff-avatar {
width: 64rpx;
height: 64rpx;
background-color: #2196F3;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
margin-left: 0;
margin-top: 0;
margin-bottom: 0;
flex-shrink: 0;
}
.avatar-text {
font-size: 32rpx;
color: #FFFFFF;
font-weight: 500;
}
.staff-name {
font-size: 32rpx;
font-weight: 500;
color: #333;
}
.service-status-indicator {
display: inline-flex;
align-items: center;
gap: 6rpx;
flex-shrink: 0;
}
.service-status-indicator__text {
font-size: 26rpx;
color: #007AFF;
white-space: nowrap;
}
.service-actions-row {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.service-action-btn {
padding: 8rpx 18rpx;
border-radius: 10rpx;
font-size: 24rpx;
line-height: 1.2;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background-color: #F8FAFF;
}
.service-action-btn--record {
color: #007AFF;
border-color: rgba(0, 122, 255, 0.22);
}
.service-action-btn--recording {
color: #FF3B30;
border-color: rgba(255, 59, 48, 0.25);
}
.service-action-btn--supplement {
color: #10B981;
border-color: rgba(16, 185, 129, 0.25);
}
.service-action-btn--upload {
color: #7C3AED;
border-color: rgba(124, 58, 237, 0.25);
}
.service-action-btn--finish {
color: #EF4444;
border-color: rgba(239, 68, 68, 0.28);
background-color: #FFF7F7;
}
.customer-tags {
display: flex;
flex-wrap: wrap;
margin-bottom: 20rpx;
gap: 12rpx;
box-sizing: border-box;
margin-left: 0;
margin-right: 0;
padding: 0;
width: 100%;
max-width: 100%;
}
.customer-info {
margin-bottom: 16rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
box-sizing: border-box;
margin-left: 0;
margin-right: 0;
padding: 0;
width: 100%;
max-width: 100%;
}
.customer-name,
.customer-phone {
font-size: 28rpx;
color: #555;
}
.tag-item {
padding: 8rpx 16rpx;
border-radius: 8rpx;
box-sizing: border-box;
word-wrap: break-word;
word-break: break-all;
display: inline-block;
max-width: 100%;
}
.tag-blue {
background-color: #E3F2FD;
}
.tag-blue text {
font-size: 24rpx;
color: #1976D2;
}
.tag-orange {
background-color: #FFF3E0;
}
.tag-orange text {
font-size: 24rpx;
color: #F57C00;
}
/* AI提醒 */
.ai-alert {
border-radius: 8rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.alert-risk-box {
background-color: #FFF5F5;
}
.alert-reminder-box {
background-color: #F5F5F5;
}
.alert-header {
display: flex;
align-items: center;
margin-bottom: 12rpx;
}
.alert-icon {
margin-right: 8rpx;
}
.icon-circle {
width: 24rpx;
height: 24rpx;
border: 2rpx solid #333;
border-radius: 50%;
position: relative;
}
.icon-circle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8rpx;
height: 8rpx;
background-color: #333;
border-radius: 50%;
}
.alert-title {
font-size: 28rpx;
font-weight: 500;
color: #333;
}
.alert-content {
font-size: 26rpx;
line-height: 1.6;
}
.alert-risk-text {
color: #FF5722;
}
.alert-reminder-text {
color: #666;
}
.alert-list {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.alert-item {
display: flex;
align-items: flex-start;
}
.alert-item::before {
content: '•';
margin-right: 8rpx;
color: #666;
}
.alert-item text {
font-size: 26rpx;
color: #666;
line-height: 1.6;
}
.service-duration {
padding-top: 16rpx;
border-top: 1px solid #F0F0F0;
}
.service-duration text {
font-size: 26rpx;
color: #999;
}
.service-status-empty {
padding: 48rpx 0;
text-align: center;
color: #999;
font-size: 28rpx;
}
.service-status-loading-more {
padding: 48rpx 0;
text-align: center;
color: #999;
font-size: 28rpx;
}
/* 补录弹窗样式 */
.supplement-dialog-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.supplement-dialog {
width: 640rpx;
max-height: 80vh;
background-color: #FFFFFF;
border-radius: 24rpx;
overflow: hidden;
display: flex;
flex-direction: column;
}
.supplement-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 32rpx 24rpx 32rpx;
border-bottom: 1px solid #F0F0F0;
}
.supplement-dialog__title {
font-size: 32rpx;
font-weight: 500;
color: #333;
}
.supplement-dialog__close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.supplement-dialog__body {
flex: 1;
padding: 32rpx;
overflow-y: auto;
}
.supplement-form-item {
margin-bottom: 32rpx;
}
.supplement-form-item:last-child {
margin-bottom: 0;
}
.supplement-form-item__label {
display: block;
font-size: 28rpx;
color: #333;
font-weight: 500;
margin-bottom: 16rpx;
}
.supplement-form-item__input {
width: 100%;
height: 88rpx;
background-color: #f9fafb;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #333;
box-sizing: border-box;
}
.supplement-form-item__textarea {
width: 100%;
min-height: 160rpx;
background-color: #f9fafb;
border-radius: 12rpx;
padding: 24rpx;
font-size: 28rpx;
color: #333;
box-sizing: border-box;
line-height: 1.6;
}
.supplement-dialog__footer {
display: flex;
gap: 24rpx;
padding: 24rpx 32rpx 32rpx 32rpx;
border-top: 1px solid #F0F0F0;
}
.supplement-dialog__btn {
flex: 1;
height: 88rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 500;
}
.supplement-dialog__btn--cancel {
background-color: #f3f4f6;
color: #6b7280;
}
.supplement-dialog__btn--save {
background-color: #4C8DFF;
color: #ffffff;
}
.supplement-dialog__btn:active {
opacity: 0.7;
}
</style>