2008 lines
57 KiB
Vue
2008 lines
57 KiB
Vue
<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>上传录音</text>
|
||
</view>
|
||
<view class="service-action-btn service-action-btn--finish" @click.stop="finishService(item)">
|
||
<text>结束服务</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="isRecordingItem(item.id)" class="recording-realtime">
|
||
<text>已录音:{{ formatRecordingElapsed(recordingElapsedSeconds) }}</text>
|
||
<text>当前大小:{{ formatRecordingSize(recordingEstimatedSizeBytes) }}</text>
|
||
</view>
|
||
<view v-if="getUploadResultList(item.id).length" class="upload-result-list">
|
||
<view
|
||
v-for="segment in getUploadResultList(item.id)"
|
||
:key="segment.id"
|
||
class="upload-result-item"
|
||
>
|
||
<text class="upload-result-item__main">
|
||
第{{ segment.segmentNo }}段({{ segment.durationText || '--:--' }} / {{ segment.sizeText || '--' }}):{{ segment.statusText }}
|
||
</text>
|
||
<text v-if="segment.message" class="upload-result-item__sub">{{ segment.message }}</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 "";
|
||
}
|
||
|
||
// 跨页面录音会话缓存(模块级):页面销毁后仍保留录音状态与分段上传结果
|
||
const recordingSessionCache = {
|
||
recordingServiceId: null,
|
||
recordingContext: null,
|
||
recordingElapsedSeconds: 0,
|
||
recordingEstimatedSizeBytes: 0,
|
||
recordingManualStopRequested: false,
|
||
recordingCurrentSegmentNo: 0,
|
||
recordingCurrentSegmentStartAt: 0,
|
||
recordingCurrentSegmentElapsedSeconds: 0,
|
||
uploadingCountMap: {},
|
||
uploadResultMap: {},
|
||
};
|
||
|
||
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: recordingSessionCache.recordingServiceId,
|
||
recordingContext: recordingSessionCache.recordingContext,
|
||
recordingElapsedSeconds: recordingSessionCache.recordingElapsedSeconds,
|
||
recordingEstimatedSizeBytes: recordingSessionCache.recordingEstimatedSizeBytes,
|
||
recordingTicker: null,
|
||
keepScreenOnEnabled: false,
|
||
// 上传前压缩策略:从录音源头降低采样率/码率,减少上传文件体积
|
||
recordingSampleRate: 16000,
|
||
recordingEncodeBitRate: 32000,
|
||
recordingSegmentDurationMs: 5 * 60 * 1000,
|
||
recordingManualStopRequested: recordingSessionCache.recordingManualStopRequested,
|
||
recordingCurrentSegmentNo: recordingSessionCache.recordingCurrentSegmentNo,
|
||
recordingCurrentSegmentStartAt: recordingSessionCache.recordingCurrentSegmentStartAt,
|
||
recordingCurrentSegmentElapsedSeconds: recordingSessionCache.recordingCurrentSegmentElapsedSeconds,
|
||
uploadingCountMap: { ...recordingSessionCache.uploadingCountMap },
|
||
uploadResultMap: { ...recordingSessionCache.uploadResultMap }
|
||
}
|
||
},
|
||
mounted() {
|
||
// 检查用户角色
|
||
this.checkUserRole();
|
||
// 获取当前用户手机号
|
||
this.loadCurrentUserPhone();
|
||
this.initServiceRecorder();
|
||
this.restoreRecordingSessionFromCache();
|
||
if (this.recordingServiceId && this.recordingContext?.id) {
|
||
this.startRecordingTicker({ reset: false });
|
||
}
|
||
this.fetchServiceStatusList();
|
||
},
|
||
beforeUnmount() {
|
||
// 跨页面持续录音:离开页面仅暂停UI定时器,不清空录音显示状态
|
||
this.pauseRecordingTicker();
|
||
// 跨页面持续录音:离开页面时不主动 stop
|
||
this.persistRecordingSessionToCache();
|
||
},
|
||
methods: {
|
||
persistRecordingSessionToCache() {
|
||
recordingSessionCache.recordingServiceId = this.recordingServiceId || null;
|
||
recordingSessionCache.recordingContext = this.recordingContext ? { ...this.recordingContext } : null;
|
||
recordingSessionCache.recordingElapsedSeconds = Number(this.recordingElapsedSeconds) || 0;
|
||
recordingSessionCache.recordingEstimatedSizeBytes = Number(this.recordingEstimatedSizeBytes) || 0;
|
||
recordingSessionCache.recordingManualStopRequested = !!this.recordingManualStopRequested;
|
||
recordingSessionCache.recordingCurrentSegmentNo = Number(this.recordingCurrentSegmentNo) || 0;
|
||
recordingSessionCache.recordingCurrentSegmentStartAt = Number(this.recordingCurrentSegmentStartAt) || 0;
|
||
recordingSessionCache.recordingCurrentSegmentElapsedSeconds = Number(this.recordingCurrentSegmentElapsedSeconds) || 0;
|
||
recordingSessionCache.uploadingCountMap = { ...this.uploadingCountMap };
|
||
recordingSessionCache.uploadResultMap = { ...this.uploadResultMap };
|
||
},
|
||
restoreRecordingSessionFromCache() {
|
||
this.recordingServiceId = recordingSessionCache.recordingServiceId || null;
|
||
this.recordingContext = recordingSessionCache.recordingContext ? { ...recordingSessionCache.recordingContext } : null;
|
||
this.recordingElapsedSeconds = Number(recordingSessionCache.recordingElapsedSeconds) || 0;
|
||
this.recordingEstimatedSizeBytes = Number(recordingSessionCache.recordingEstimatedSizeBytes) || 0;
|
||
this.recordingManualStopRequested = !!recordingSessionCache.recordingManualStopRequested;
|
||
this.recordingCurrentSegmentNo = Number(recordingSessionCache.recordingCurrentSegmentNo) || 0;
|
||
this.recordingCurrentSegmentStartAt = Number(recordingSessionCache.recordingCurrentSegmentStartAt) || 0;
|
||
this.recordingCurrentSegmentElapsedSeconds = Number(recordingSessionCache.recordingCurrentSegmentElapsedSeconds) || 0;
|
||
this.uploadingCountMap = { ...recordingSessionCache.uploadingCountMap };
|
||
this.uploadResultMap = { ...recordingSessionCache.uploadResultMap };
|
||
},
|
||
initServiceRecorder() {
|
||
this.recorderSupported = typeof uni.getRecorderManager === 'function';
|
||
if (!this.recorderSupported) {
|
||
return;
|
||
}
|
||
this.ensureRecorderManager();
|
||
this.bindRecorderEvents();
|
||
},
|
||
ensureRecorderManager() {
|
||
if (!this.recorderSupported) {
|
||
return false;
|
||
}
|
||
if (!this.recorderManager) {
|
||
this.recorderManager = uni.getRecorderManager();
|
||
}
|
||
return !!this.recorderManager;
|
||
},
|
||
bindRecorderEvents() {
|
||
if (!this.ensureRecorderManager()) {
|
||
return;
|
||
}
|
||
// 先解绑,避免重复绑定或被其它页面覆盖后残留旧回调
|
||
if (typeof this.recorderManager.offStop === 'function') {
|
||
this.recorderManager.offStop();
|
||
}
|
||
if (typeof this.recorderManager.offError === 'function') {
|
||
this.recorderManager.offError();
|
||
}
|
||
// 录音每段停止后回调:静默上传,若非手动停止则继续下一段
|
||
this.recorderManager.onStop((res) => {
|
||
const ctx = this.recordingContext;
|
||
const recordId = this.recordingServiceId;
|
||
const currentSegmentNo = this.recordingCurrentSegmentNo;
|
||
const currentSegmentElapsedSeconds = this.recordingCurrentSegmentElapsedSeconds || this.recordingElapsedSeconds || 0;
|
||
const shouldContinue =
|
||
!!recordId &&
|
||
!!ctx &&
|
||
!!ctx.id &&
|
||
!this.recordingManualStopRequested;
|
||
const normalizedSegmentNo = currentSegmentNo > 0 ? currentSegmentNo : 1;
|
||
this.stopRecordingTicker();
|
||
const tempFilePath = res.tempFilePath || '';
|
||
if (ctx && ctx.id) {
|
||
const name = this.getAudioUploadFileName({ segmentNo: normalizedSegmentNo });
|
||
if (!tempFilePath) {
|
||
this.pushUploadResult(String(ctx.id), {
|
||
id: `seg_${String(ctx.id)}_${normalizedSegmentNo}_${Date.now()}`,
|
||
segmentNo: normalizedSegmentNo,
|
||
durationText: this.formatRecordingElapsed(currentSegmentElapsedSeconds),
|
||
sizeText: '--',
|
||
status: 'failed',
|
||
statusText: '上传失败',
|
||
message: '未获取到录音文件(已重试1次)',
|
||
});
|
||
} else {
|
||
this.uploadFileForRecord(
|
||
ctx,
|
||
{
|
||
path: tempFilePath,
|
||
name,
|
||
size: Number(res.fileSize) || 0,
|
||
},
|
||
{
|
||
silent: true,
|
||
segmentNo: normalizedSegmentNo,
|
||
durationSeconds: currentSegmentElapsedSeconds,
|
||
retryOnceOnFail: true,
|
||
}
|
||
);
|
||
}
|
||
}
|
||
if (shouldContinue) {
|
||
this.setKeepScreenOn(true);
|
||
setTimeout(() => {
|
||
this.startNewSegmentRecording();
|
||
}, 80);
|
||
return;
|
||
}
|
||
this.recordingServiceId = null;
|
||
this.recordingContext = null;
|
||
this.recordingManualStopRequested = false;
|
||
this.recordingCurrentSegmentNo = 0;
|
||
this.recordingCurrentSegmentStartAt = 0;
|
||
this.recordingCurrentSegmentElapsedSeconds = 0;
|
||
this.setKeepScreenOn(false);
|
||
this.persistRecordingSessionToCache();
|
||
});
|
||
this.recorderManager.onError(() => {
|
||
this.recordingServiceId = null;
|
||
this.recordingContext = null;
|
||
this.recordingManualStopRequested = false;
|
||
this.recordingCurrentSegmentNo = 0;
|
||
this.recordingCurrentSegmentStartAt = 0;
|
||
this.recordingCurrentSegmentElapsedSeconds = 0;
|
||
this.stopRecordingTicker();
|
||
this.setKeepScreenOn(false);
|
||
this.persistRecordingSessionToCache();
|
||
uni.showToast({ title: '录音出错', icon: 'none' });
|
||
});
|
||
},
|
||
setKeepScreenOn(enabled) {
|
||
if (typeof uni.setKeepScreenOn !== 'function') {
|
||
return;
|
||
}
|
||
uni.setKeepScreenOn({
|
||
keepScreenOn: !!enabled,
|
||
success: () => {
|
||
this.keepScreenOnEnabled = !!enabled;
|
||
},
|
||
fail: () => {
|
||
this.keepScreenOnEnabled = false;
|
||
},
|
||
});
|
||
},
|
||
startRecordingTicker(options = {}) {
|
||
const { reset = true } = options;
|
||
this.pauseRecordingTicker();
|
||
if (reset) {
|
||
this.recordingElapsedSeconds = 0;
|
||
this.recordingEstimatedSizeBytes = 0;
|
||
} else if (this.recordingCurrentSegmentStartAt) {
|
||
const resumedElapsed = Math.max(
|
||
0,
|
||
Math.floor((Date.now() - Number(this.recordingCurrentSegmentStartAt)) / 1000)
|
||
);
|
||
this.recordingElapsedSeconds = resumedElapsed;
|
||
const bytesPerSecond = (Number(this.recordingEncodeBitRate) || 32000) / 8;
|
||
this.recordingEstimatedSizeBytes = Math.floor(resumedElapsed * bytesPerSecond);
|
||
}
|
||
const bytesPerSecond = (Number(this.recordingEncodeBitRate) || 32000) / 8;
|
||
this.recordingTicker = setInterval(() => {
|
||
if (this.recordingCurrentSegmentStartAt) {
|
||
this.recordingElapsedSeconds = Math.max(
|
||
0,
|
||
Math.floor((Date.now() - Number(this.recordingCurrentSegmentStartAt)) / 1000)
|
||
);
|
||
} else {
|
||
this.recordingElapsedSeconds += 1;
|
||
}
|
||
this.recordingCurrentSegmentElapsedSeconds = this.recordingElapsedSeconds;
|
||
this.recordingEstimatedSizeBytes = Math.floor(this.recordingElapsedSeconds * bytesPerSecond);
|
||
this.persistRecordingSessionToCache();
|
||
}, 1000);
|
||
},
|
||
pauseRecordingTicker() {
|
||
if (this.recordingTicker) {
|
||
clearInterval(this.recordingTicker);
|
||
}
|
||
this.recordingTicker = null;
|
||
},
|
||
stopRecordingTicker() {
|
||
this.pauseRecordingTicker();
|
||
this.recordingCurrentSegmentElapsedSeconds = this.recordingElapsedSeconds;
|
||
this.recordingElapsedSeconds = 0;
|
||
this.recordingEstimatedSizeBytes = 0;
|
||
this.persistRecordingSessionToCache();
|
||
},
|
||
startNewSegmentRecording() {
|
||
if (!this.recorderManager || !this.recordingServiceId || !this.recordingContext?.id) {
|
||
return;
|
||
}
|
||
this.recordingCurrentSegmentNo += 1;
|
||
this.recordingCurrentSegmentStartAt = Date.now();
|
||
this.recordingCurrentSegmentElapsedSeconds = 0;
|
||
try {
|
||
this.recorderManager.start({
|
||
duration: this.recordingSegmentDurationMs,
|
||
sampleRate: this.recordingSampleRate,
|
||
numberOfChannels: 1,
|
||
encodeBitRate: this.recordingEncodeBitRate,
|
||
format: 'mp3'
|
||
});
|
||
this.startRecordingTicker();
|
||
this.persistRecordingSessionToCache();
|
||
} catch (e) {
|
||
this.recordingServiceId = null;
|
||
this.recordingContext = null;
|
||
this.recordingManualStopRequested = false;
|
||
this.recordingCurrentSegmentNo = 0;
|
||
this.recordingCurrentSegmentStartAt = 0;
|
||
this.recordingCurrentSegmentElapsedSeconds = 0;
|
||
this.stopRecordingTicker();
|
||
this.setKeepScreenOn(false);
|
||
this.persistRecordingSessionToCache();
|
||
uni.showToast({ title: '无法开始录音', icon: 'none' });
|
||
}
|
||
},
|
||
formatRecordingElapsed(seconds) {
|
||
const safeSeconds = Math.max(0, Number(seconds) || 0);
|
||
const minutes = String(Math.floor(safeSeconds / 60)).padStart(2, '0');
|
||
const remainSeconds = String(safeSeconds % 60).padStart(2, '0');
|
||
return `${minutes}:${remainSeconds}`;
|
||
},
|
||
formatRecordingSize(bytes) {
|
||
const size = Math.max(0, Number(bytes) || 0);
|
||
if (size < 1024) {
|
||
return `${size} B`;
|
||
}
|
||
if (size < 1024 * 1024) {
|
||
return `${(size / 1024).toFixed(1)} KB`;
|
||
}
|
||
return `${(size / (1024 * 1024)).toFixed(2)} MB`;
|
||
},
|
||
isRecordingItem(recordId) {
|
||
if (!recordId || !this.recordingServiceId) {
|
||
return false;
|
||
}
|
||
return String(recordId) === this.recordingServiceId;
|
||
},
|
||
startPhoneRecord(item) {
|
||
// 返回页面后可能被其它页面覆盖录音回调,这里每次录音前重新绑定一次
|
||
this.ensureRecorderManager();
|
||
this.bindRecorderEvents();
|
||
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.recordingManualStopRequested = false;
|
||
this.recordingCurrentSegmentNo = 0;
|
||
this.recordingCurrentSegmentStartAt = 0;
|
||
this.recordingCurrentSegmentElapsedSeconds = 0;
|
||
this.setUploadResultList(String(item.id), []);
|
||
this.recordingContext = { ...item };
|
||
this.recordingServiceId = String(item.id);
|
||
this.setKeepScreenOn(true);
|
||
this.persistRecordingSessionToCache();
|
||
this.startNewSegmentRecording();
|
||
},
|
||
/** 结束录音;onStop 回调中会调用 uploadFileForRecord 上传至后端 */
|
||
stopPhoneRecordAndUpload(item) {
|
||
if (!this.recorderManager || !item?.id) {
|
||
return;
|
||
}
|
||
if (String(item.id) !== this.recordingServiceId) {
|
||
return;
|
||
}
|
||
this.recordingManualStopRequested = true;
|
||
this.persistRecordingSessionToCache();
|
||
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 Number(this.uploadingCountMap[String(recordId)] || 0) > 0;
|
||
},
|
||
increaseUploadingCount(recordId) {
|
||
const key = String(recordId);
|
||
const next = Number(this.uploadingCountMap[key] || 0) + 1;
|
||
this.uploadingCountMap = { ...this.uploadingCountMap, [key]: next };
|
||
this.persistRecordingSessionToCache();
|
||
},
|
||
decreaseUploadingCount(recordId) {
|
||
const key = String(recordId);
|
||
const current = Number(this.uploadingCountMap[key] || 0);
|
||
const next = Math.max(0, current - 1);
|
||
this.uploadingCountMap = { ...this.uploadingCountMap, [key]: next };
|
||
this.persistRecordingSessionToCache();
|
||
},
|
||
getUploadResultList(recordId) {
|
||
const key = String(recordId || '');
|
||
const list = this.uploadResultMap[key];
|
||
return Array.isArray(list) ? list : [];
|
||
},
|
||
setUploadResultList(recordId, list) {
|
||
const key = String(recordId || '');
|
||
this.uploadResultMap = { ...this.uploadResultMap, [key]: Array.isArray(list) ? list : [] };
|
||
this.persistRecordingSessionToCache();
|
||
},
|
||
pushUploadResult(recordId, segment) {
|
||
const list = this.getUploadResultList(recordId);
|
||
this.setUploadResultList(recordId, [...list, segment]);
|
||
},
|
||
updateUploadResult(recordId, segmentResultId, patch = {}) {
|
||
const list = this.getUploadResultList(recordId);
|
||
const nextList = list.map((it) => {
|
||
if (it.id !== segmentResultId) {
|
||
return it;
|
||
}
|
||
return { ...it, ...patch };
|
||
});
|
||
this.setUploadResultList(recordId, nextList);
|
||
},
|
||
/** 仅允许扩展名为 .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;
|
||
},
|
||
/** 上传音频文件名:租户id_手机号_年月日时分秒(租户id、手机号后各有一个下划线;多段录音段号>1 时末尾再追加 _段号) */
|
||
getAudioUploadFileName(options = {}) {
|
||
let tenantId = '';
|
||
let phone = '';
|
||
try {
|
||
tenantId = String(uni.getStorageSync('backend-tenant-id') || '').trim();
|
||
} catch (e) {}
|
||
try {
|
||
const raw = this.currentUserPhone || '';
|
||
const login = uni.getStorageSync('backend-login-response') || {};
|
||
const fallback = login.phone || login.userName || '';
|
||
phone = String(raw || fallback || '').replace(/\D/g, '');
|
||
} catch (e) {}
|
||
const d = new Date();
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||
const seg = Number(options.segmentNo);
|
||
const segSuffix = Number.isFinite(seg) && seg > 1 ? `_${seg}` : '';
|
||
const ext = typeof options.ext === 'string' && options.ext ? options.ext : '.mp3';
|
||
const dotExt = ext.startsWith('.') ? ext : `.${ext}`;
|
||
const tenantPrefix = tenantId ? `${tenantId}_` : '';
|
||
const phonePrefix = phone ? `${phone}_` : '';
|
||
const base = `${tenantPrefix}${phonePrefix}${ts}${segSuffix}`.replace(/[/\\:*?"<>|]/g, '_') || `upload_${ts}${segSuffix}`;
|
||
return `${base}${dotExt}`;
|
||
},
|
||
/**
|
||
* 微信小程序等环境下 multipart 的原始文件名来自本地路径最后一段;临时路径常为随机串甚至带异常后缀。
|
||
* 复制到 USER_DATA_PATH 并使用规范文件名再上传,后端 getOriginalFilename / 落盘名才能与 fileName 参数一致。
|
||
*/
|
||
prepareAudioFileForUpload(tempPath, displayFileName) {
|
||
return new Promise((resolve) => {
|
||
if (!tempPath || !displayFileName) {
|
||
resolve({ uploadPath: tempPath, tempCopyPath: null });
|
||
return;
|
||
}
|
||
const baseName = (() => {
|
||
const seg = String(tempPath).split(/[/\\]/).pop() || '';
|
||
return seg.split('?')[0] || '';
|
||
})();
|
||
const needsRename =
|
||
baseName !== displayFileName ||
|
||
baseName.includes('=') ||
|
||
/\.durationTime=/i.test(baseName);
|
||
let userDataPath = '';
|
||
try {
|
||
userDataPath = (uni.env && uni.env.USER_DATA_PATH) || '';
|
||
} catch (e) {}
|
||
if (!userDataPath && typeof wx !== 'undefined' && wx.env) {
|
||
userDataPath = wx.env.USER_DATA_PATH || '';
|
||
}
|
||
if (!needsRename || !userDataPath || typeof uni.getFileSystemManager !== 'function') {
|
||
resolve({ uploadPath: tempPath, tempCopyPath: null });
|
||
return;
|
||
}
|
||
const destPath = `${String(userDataPath).replace(/\/+$/, '')}/${displayFileName}`;
|
||
uni.getFileSystemManager().copyFile({
|
||
srcPath: tempPath,
|
||
destPath,
|
||
success: () => resolve({ uploadPath: destPath, tempCopyPath: destPath }),
|
||
fail: (err) => {
|
||
console.warn('prepareAudioFileForUpload copyFile failed:', err);
|
||
resolve({ uploadPath: tempPath, tempCopyPath: null });
|
||
},
|
||
});
|
||
});
|
||
},
|
||
safeUnlinkAudioCopy(filePath) {
|
||
if (!filePath || typeof uni.getFileSystemManager !== 'function') {
|
||
return;
|
||
}
|
||
try {
|
||
uni.getFileSystemManager().unlink({ filePath, fail: () => {} });
|
||
} catch (e) {}
|
||
},
|
||
async uploadFileForRecord(item, selectedFile, options = {}) {
|
||
const recordId = String(item.id);
|
||
const segmentNo = Number(options.segmentNo) || 1;
|
||
const durationSeconds = Math.max(0, Number(options.durationSeconds) || 0);
|
||
const audioDurationMinutesStr = (() => {
|
||
const m = durationSeconds / 60;
|
||
return Number.isFinite(m) ? String(Math.round(m * 1e6) / 1e6) : '0';
|
||
})();
|
||
const fileSize = Math.max(0, Number(selectedFile?.size) || 0);
|
||
const resultId = `seg_${recordId}_${segmentNo}_${Date.now()}`;
|
||
this.pushUploadResult(recordId, {
|
||
id: resultId,
|
||
segmentNo,
|
||
durationText: this.formatRecordingElapsed(durationSeconds),
|
||
sizeText: this.formatRecordingSize(fileSize),
|
||
status: 'uploading',
|
||
statusText: '上传中',
|
||
message: '',
|
||
});
|
||
this.increaseUploadingCount(recordId);
|
||
let tempCopyPath = null;
|
||
try {
|
||
const targetFileName = this.getAudioUploadFileName({ segmentNo });
|
||
const prep = await this.prepareAudioFileForUpload(selectedFile.path, targetFileName);
|
||
const uploadPath = prep.uploadPath;
|
||
tempCopyPath = prep.tempCopyPath;
|
||
const maxAttempt = options.retryOnceOnFail ? 2 : 1;
|
||
let lastErrorMessage = '';
|
||
for (let attempt = 1; attempt <= maxAttempt; attempt += 1) {
|
||
const uploadRes = await uni.uploadFile({
|
||
url: getApiUrl('/api/audio/upload'),
|
||
filePath: uploadPath,
|
||
name: 'file',
|
||
formData: {
|
||
id: item.id,
|
||
audioId: item.id,
|
||
audioDuration: audioDurationMinutesStr,
|
||
customerId: item.customerId || '',
|
||
customerName: item.customerName || '',
|
||
fileName: targetFileName,
|
||
compress: true
|
||
},
|
||
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) {
|
||
this.updateUploadResult(recordId, resultId, {
|
||
status: 'success',
|
||
statusText: '上传成功',
|
||
message: attempt > 1 ? `第${attempt}次尝试成功` : '',
|
||
});
|
||
this.serviceStatusPage.current = 1;
|
||
this.fetchServiceStatusList({ force: true });
|
||
return true;
|
||
}
|
||
lastErrorMessage = result?.message || `上传失败(HTTP ${uploadRes?.statusCode || '--'})`;
|
||
if (attempt < maxAttempt) {
|
||
this.updateUploadResult(recordId, resultId, {
|
||
status: 'retrying',
|
||
statusText: '重试中',
|
||
message: `${lastErrorMessage},正在重试`,
|
||
});
|
||
}
|
||
}
|
||
this.updateUploadResult(recordId, resultId, {
|
||
status: 'failed',
|
||
statusText: '上传失败',
|
||
message: `${lastErrorMessage || '未知错误'}(已重试1次)`,
|
||
});
|
||
return false;
|
||
} catch (error) {
|
||
console.error('上传文件失败:', error);
|
||
this.updateUploadResult(recordId, resultId, {
|
||
status: 'failed',
|
||
statusText: '上传失败',
|
||
message: `${error?.errMsg || error?.message || '未知错误'}(已重试1次)`,
|
||
});
|
||
return false;
|
||
} finally {
|
||
this.safeUnlinkAudioCopy(tempCopyPath);
|
||
this.decreaseUploadingCount(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;
|
||
}
|
||
|
||
.recording-realtime {
|
||
margin-top: 12rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.recording-realtime text {
|
||
font-size: 24rpx;
|
||
color: #FF3B30;
|
||
}
|
||
|
||
.upload-result-list {
|
||
margin-top: 12rpx;
|
||
padding: 12rpx 16rpx;
|
||
background-color: #F8FAFF;
|
||
border-radius: 10rpx;
|
||
border: 1px solid #E7EEFF;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.upload-result-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.upload-result-item__main {
|
||
font-size: 24rpx;
|
||
color: #374151;
|
||
}
|
||
|
||
.upload-result-item__sub {
|
||
font-size: 22rpx;
|
||
color: #6B7280;
|
||
}
|
||
|
||
/* 补录弹窗样式 */
|
||
.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>
|