Przeglądaj źródła

feat(audio): 添加通话录音播放器组件

闪电 10 miesięcy temu
rodzic
commit
58d345afe2
2 zmienionych plików z 270 dodań i 14 usunięć
  1. 97 14
      src/assets/style.css
  2. 173 0
      src/components/callLogs/audio.vue

+ 97 - 14
src/assets/style.css

@@ -885,6 +885,10 @@ video {
885 885
   width: 3rem;
886 886
 }
887 887
 
888
+.w-16 {
889
+  width: 4rem;
890
+}
891
+
888 892
 .w-2\/5 {
889 893
   width: 40%;
890 894
 }
@@ -957,18 +961,12 @@ video {
957 961
   flex-shrink: 1;
958 962
 }
959 963
 
960
-.transform {
961
-  transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
962
-}
963
-
964
-@keyframes pulse {
965
-  50% {
966
-    opacity: .5;
967
-  }
964
+.flex-grow {
965
+  flex-grow: 1;
968 966
 }
969 967
 
970
-.animate-pulse {
971
-  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
968
+.transform {
969
+  transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
972 970
 }
973 971
 
974 972
 @keyframes spin {
@@ -1127,10 +1125,6 @@ video {
1127 1125
   border-radius: 0.5rem;
1128 1126
 }
1129 1127
 
1130
-.rounded {
1131
-  border-radius: 0.25rem;
1132
-}
1133
-
1134 1128
 .rounded-t-lg {
1135 1129
   border-top-left-radius: 0.5rem;
1136 1130
   border-top-right-radius: 0.5rem;
@@ -1148,6 +1142,11 @@ video {
1148 1142
   border-top-width: 1px;
1149 1143
 }
1150 1144
 
1145
+.border-blue-200 {
1146
+  --tw-border-opacity: 1;
1147
+  border-color: rgb(191 219 254 / var(--tw-border-opacity, 1));
1148
+}
1149
+
1151 1150
 .border-blue-500 {
1152 1151
   --tw-border-opacity: 1;
1153 1152
   border-color: rgb(59 130 246 / var(--tw-border-opacity, 1));
@@ -1158,6 +1157,11 @@ video {
1158 1157
   border-color: rgb(243 244 246 / var(--tw-border-opacity, 1));
1159 1158
 }
1160 1159
 
1160
+.border-gray-300 {
1161
+  --tw-border-opacity: 1;
1162
+  border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
1163
+}
1164
+
1161 1165
 .border-green-500 {
1162 1166
   --tw-border-opacity: 1;
1163 1167
   border-color: rgb(34 197 94 / var(--tw-border-opacity, 1));
@@ -1260,22 +1264,42 @@ video {
1260 1264
   background-image: linear-gradient(to top, var(--tw-gradient-stops));
1261 1265
 }
1262 1266
 
1267
+.from-blue-50 {
1268
+  --tw-gradient-from: #eff6ff var(--tw-gradient-from-position);
1269
+  --tw-gradient-to: rgb(239 246 255 / 0) var(--tw-gradient-to-position);
1270
+  --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
1271
+}
1272
+
1263 1273
 .from-blue-500 {
1264 1274
   --tw-gradient-from: #3b82f6 var(--tw-gradient-from-position);
1265 1275
   --tw-gradient-to: rgb(59 130 246 / 0) var(--tw-gradient-to-position);
1266 1276
   --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
1267 1277
 }
1268 1278
 
1279
+.from-gray-100 {
1280
+  --tw-gradient-from: #f3f4f6 var(--tw-gradient-from-position);
1281
+  --tw-gradient-to: rgb(243 244 246 / 0) var(--tw-gradient-to-position);
1282
+  --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
1283
+}
1284
+
1269 1285
 .from-white {
1270 1286
   --tw-gradient-from: #fff var(--tw-gradient-from-position);
1271 1287
   --tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);
1272 1288
   --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
1273 1289
 }
1274 1290
 
1291
+.to-blue-100 {
1292
+  --tw-gradient-to: #dbeafe var(--tw-gradient-to-position);
1293
+}
1294
+
1275 1295
 .to-blue-600 {
1276 1296
   --tw-gradient-to: #2563eb var(--tw-gradient-to-position);
1277 1297
 }
1278 1298
 
1299
+.to-gray-200 {
1300
+  --tw-gradient-to: #e5e7eb var(--tw-gradient-to-position);
1301
+}
1302
+
1279 1303
 .to-transparent {
1280 1304
   --tw-gradient-to: transparent var(--tw-gradient-to-position);
1281 1305
 }
@@ -1305,11 +1329,21 @@ video {
1305 1329
   padding: 1.5rem;
1306 1330
 }
1307 1331
 
1332
+.px-2 {
1333
+  padding-left: 0.5rem;
1334
+  padding-right: 0.5rem;
1335
+}
1336
+
1308 1337
 .px-4 {
1309 1338
   padding-left: 1rem;
1310 1339
   padding-right: 1rem;
1311 1340
 }
1312 1341
 
1342
+.py-1 {
1343
+  padding-top: 0.25rem;
1344
+  padding-bottom: 0.25rem;
1345
+}
1346
+
1313 1347
 .py-2 {
1314 1348
   padding-top: 0.5rem;
1315 1349
   padding-bottom: 0.5rem;
@@ -1428,6 +1462,10 @@ video {
1428 1462
   font-style: italic;
1429 1463
 }
1430 1464
 
1465
+.leading-relaxed {
1466
+  line-height: 1.625;
1467
+}
1468
+
1431 1469
 .text-blue-300 {
1432 1470
   --tw-text-opacity: 1;
1433 1471
   color: rgb(147 197 253 / var(--tw-text-opacity, 1));
@@ -1532,6 +1570,10 @@ video {
1532 1570
   -moz-osx-font-smoothing: grayscale;
1533 1571
 }
1534 1572
 
1573
+.opacity-0 {
1574
+  opacity: 0;
1575
+}
1576
+
1535 1577
 .opacity-70 {
1536 1578
   opacity: 0.7;
1537 1579
 }
@@ -1600,6 +1642,18 @@ video {
1600 1642
   transition-duration: 150ms;
1601 1643
 }
1602 1644
 
1645
+.transition-colors {
1646
+  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
1647
+  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
1648
+  transition-duration: 150ms;
1649
+}
1650
+
1651
+.transition-opacity {
1652
+  transition-property: opacity;
1653
+  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
1654
+  transition-duration: 150ms;
1655
+}
1656
+
1603 1657
 .transition-shadow {
1604 1658
   transition-property: box-shadow;
1605 1659
   transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -1646,11 +1700,36 @@ video {
1646 1700
   background-color: rgb(22 163 74 / var(--tw-bg-opacity, 1));
1647 1701
 }
1648 1702
 
1703
+.hover\:from-blue-100:hover {
1704
+  --tw-gradient-from: #dbeafe var(--tw-gradient-from-position);
1705
+  --tw-gradient-to: rgb(219 234 254 / 0) var(--tw-gradient-to-position);
1706
+  --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
1707
+}
1708
+
1709
+.hover\:from-gray-200:hover {
1710
+  --tw-gradient-from: #e5e7eb var(--tw-gradient-from-position);
1711
+  --tw-gradient-to: rgb(229 231 235 / 0) var(--tw-gradient-to-position);
1712
+  --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
1713
+}
1714
+
1715
+.hover\:to-blue-200:hover {
1716
+  --tw-gradient-to: #bfdbfe var(--tw-gradient-to-position);
1717
+}
1718
+
1719
+.hover\:to-gray-300:hover {
1720
+  --tw-gradient-to: #d1d5db var(--tw-gradient-to-position);
1721
+}
1722
+
1649 1723
 .hover\:text-blue-800:hover {
1650 1724
   --tw-text-opacity: 1;
1651 1725
   color: rgb(30 64 175 / var(--tw-text-opacity, 1));
1652 1726
 }
1653 1727
 
1728
+.hover\:text-gray-600:hover {
1729
+  --tw-text-opacity: 1;
1730
+  color: rgb(75 85 99 / var(--tw-text-opacity, 1));
1731
+}
1732
+
1654 1733
 .hover\:text-white:hover {
1655 1734
   --tw-text-opacity: 1;
1656 1735
   color: rgb(255 255 255 / var(--tw-text-opacity, 1));
@@ -1666,4 +1745,8 @@ video {
1666 1745
   --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
1667 1746
   --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);
1668 1747
   box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
1748
+}
1749
+
1750
+.group:hover .group-hover\:opacity-100 {
1751
+  opacity: 1;
1669 1752
 }

+ 173 - 0
src/components/callLogs/audio.vue

@@ -0,0 +1,173 @@
1
+<template>
2
+    <div class="h-full bg-white p-6 flex flex-col">
3
+        <!-- 头部 -->
4
+        <div class="flex justify-between items-center mb-8">
5
+          <h2 class="text-xl font-medium">通话录音</h2>
6
+          <el-icon class="cursor-pointer text-gray-400 hover:text-gray-600" @click="drawerVisible = false">
7
+            <Close />
8
+          </el-icon>
9
+        </div>
10
+        <!-- 音频播放器 -->
11
+        <div class="bg-gray-50 rounded-lg p-6 mb-6">
12
+          <div class="flex justify-center mb-4">
13
+            <el-button class="w-16 h-16 !rounded-full flex items-center justify-center"
14
+              :type="isPlaying ? 'danger' : 'primary'" @click="togglePlay">
15
+              <el-icon class="text-2xl">
16
+                <component :is="isPlaying ? 'Pause' : 'VideoPlay'" />
17
+              </el-icon>
18
+            </el-button>
19
+          </div>
20
+          <!-- 进度条 -->
21
+          <div class="mb-2">
22
+            <el-slider v-model="progress" :max="100" @change="handleProgressChange" />
23
+          </div>
24
+          <!-- 时间显示 -->
25
+          <div class="flex justify-between text-sm text-gray-500">
26
+            <span>{{ formatTime(currentTime) }}</span>
27
+            <span>{{ formatTime(duration) }}</span>
28
+          </div>
29
+        </div>
30
+        <!-- 操作按钮 -->
31
+        <div class="mb-6">
32
+          <el-button type="primary" class="w-full !rounded-button whitespace-nowrap" @click="handleDownload">
33
+            <el-icon class="mr-2">
34
+              <Download />
35
+            </el-icon>
36
+            下载录音
37
+          </el-button>
38
+        </div>
39
+        <!-- 转写内容 -->
40
+        <div class="bg-white rounded-lg p-4 flex flex-col flex-grow overflow-hidden">
41
+          <h3 class="text-lg font-medium mb-4">语音转写内容</h3>
42
+          <div class="text-gray-600 leading-relaxed overflow-y-auto flex-grow space-y-2">
43
+            <div v-for="(message, index) in transcriptionMessages" :key="index" @click="jumpToTime(message.startTime)"
44
+              :class="[
45
+                'p-3 transition-colors rounded-lg cursor-pointer group',
46
+                message.role === 'agent' ? 'bg-gradient-to-r from-gray-100 to-gray-200 hover:from-gray-200 hover:to-gray-300' : 'bg-gradient-to-r from-blue-50 to-blue-100 hover:from-blue-100 hover:to-blue-200'
47
+              ]">
48
+              <div class="flex justify-between items-center mb-2">
49
+                <div class="flex items-center gap-2">
50
+                  <div class="text-xs text-gray-400">{{ formatTime(message.startTime) }}</div>
51
+                  <div :class="[
52
+                    'px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap',
53
+                    message.role === 'agent' ? 'bg-gray-200 text-gray-700 border border-gray-300' : 'bg-blue-100 text-blue-600 border border-blue-200'
54
+                  ]">
55
+                    {{ message.role === 'agent' ? '客服' : '客户' }}
56
+                  </div>
57
+                </div>
58
+                <el-button class="!rounded-button opacity-0 group-hover:opacity-100 transition-opacity" size="small"
59
+                  type="text" @click.stop="playSegment(message.startTime, message.endTime)">
60
+                  <el-icon class="text-lg">
61
+                    <VideoPlay />
62
+                  </el-icon>
63
+                </el-button>
64
+              </div>
65
+              <div class="text-gray-700">{{ message.content }}</div>
66
+              <div class="text-xs text-gray-400 mt-1">置信度: {{ message.confidence }}%</div>
67
+            </div>
68
+          </div>
69
+        </div>
70
+      </div>
71
+</template>
72
+
73
+<script lang="ts" setup>
74
+import { ref } from 'vue';
75
+import { ElMessage } from 'element-plus';
76
+import { Close, VideoPlay, Pause, Download } from '@element-plus/icons-vue';
77
+const drawerVisible = ref(false);
78
+const deleteDialogVisible = ref(false);
79
+const isPlaying = ref(false);
80
+const progress = ref(0);
81
+const currentTime = ref(0);
82
+const duration = ref(180); // 示例时长3分钟
83
+const transcriptionMessages = ref([
84
+  { role: 'agent', content: '您好,这里是顾客服务中心,很高兴为您服务。', startTime: 0, endTime: 4, confidence: 98 },
85
+  { role: 'customer', content: '你好,我想咨询一下我前天购买的产品退货问题。', startTime: 5, endTime: 10, confidence: 96 },
86
+  { role: 'agent', content: '好的,请问您能提供一下订单号码吗?', startTime: 11, endTime: 15, confidence: 97 },
87
+  { role: 'customer', content: '是的,订单号是2023112509876。', startTime: 16, endTime: 20, confidence: 99 },
88
+  { role: 'agent', content: '好的,我已经查询到您的订单信息。请问您想退货的原因是什么呢?', startTime: 21, endTime: 27, confidence: 98 },
89
+  { role: 'customer', content: '产品规格和我预期的不太一样,我想换一个大一点的型号。', startTime: 28, endTime: 35, confidence: 95 },
90
+  { role: 'agent', content: '明白了。根据我们的退换货政策,您的商品符合七天无理由退换货条件。我这边帮您申请退货,您收到退货地址后,请在三天内寄出商品。', startTime: 36, endTime: 48, confidence: 97 },
91
+  { role: 'customer', content: '好的,麻烦你了。请问退款大概需要多久?', startTime: 49, endTime: 54, confidence: 98 },
92
+  { role: 'agent', content: '商品寄出后,我们收到并确认商品完好,会在1-3个工作日内为您办理退款。退款会原路返回至您的支付账户。', startTime: 55, endTime: 65, confidence: 96 },
93
+  { role: 'customer', content: '明白了,谢谢你的解释。', startTime: 66, endTime: 70, confidence: 99 },
94
+  { role: 'agent', content: '不客气,很高兴能帮到您。如果您还有其他问题,随时可以联系我们。祝您生活愉快!', startTime: 71, endTime: 80, confidence: 97 }
95
+]);
96
+const formatTime = (seconds: number): string => {
97
+  const minutes = Math.floor(seconds / 60);
98
+  const remainingSeconds = Math.floor(seconds % 60);
99
+  return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
100
+};
101
+const togglePlay = () => {
102
+  isPlaying.value = !isPlaying.value;
103
+  if (isPlaying.value) {
104
+    // 模拟播放进度
105
+    const timer = setInterval(() => {
106
+      if (progress.value >= 100) {
107
+        clearInterval(timer);
108
+        isPlaying.value = false;
109
+        progress.value = 0;
110
+        currentTime.value = 0;
111
+        return;
112
+      }
113
+      progress.value += 1;
114
+      currentTime.value = (progress.value / 100) * duration.value;
115
+    }, 1000);
116
+  }
117
+};
118
+const handleProgressChange = (value: number) => {
119
+  currentTime.value = (value / 100) * duration.value;
120
+};
121
+const jumpToTime = (time: number) => {
122
+  progress.value = (time / duration.value) * 100;
123
+  currentTime.value = time;
124
+};
125
+const playSegment = (startTime: number, endTime: number) => {
126
+  progress.value = (startTime / duration.value) * 100;
127
+  currentTime.value = startTime;
128
+  isPlaying.value = true;
129
+  // 模拟播放到指定片段结束
130
+  const timer = setInterval(() => {
131
+    if (currentTime.value >= endTime || !isPlaying.value) {
132
+      clearInterval(timer);
133
+      isPlaying.value = false;
134
+      return;
135
+    }
136
+    progress.value = (currentTime.value / duration.value) * 100;
137
+    currentTime.value += 1;
138
+  }, 1000);
139
+};
140
+const handleDownload = () => {
141
+  ElMessage.success('录音文件下载中...');
142
+};
143
+const handleDelete = () => {
144
+  deleteDialogVisible.value = true;
145
+};
146
+const confirmDelete = () => {
147
+  deleteDialogVisible.value = false;
148
+  drawerVisible.value = false;
149
+  ElMessage.success('录音已删除');
150
+};
151
+const openDrawer = () => {
152
+  drawerVisible.value = true;
153
+  progress.value = 0;
154
+  currentTime.value = 0;
155
+  isPlaying.value = false;
156
+};
157
+</script>
158
+<style scoped>
159
+.el-drawer__body {
160
+  padding: 0;
161
+  overflow: hidden;
162
+}
163
+
164
+/* 隐藏滚动条但保留滚动功能 */
165
+.overflow-y-auto::-webkit-scrollbar {
166
+  display: none;
167
+}
168
+
169
+.overflow-y-auto {
170
+  -ms-overflow-style: none;
171
+  scrollbar-width: none;
172
+}
173
+</style>