select-audio-new.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. <template>
  2. <div class="audio-selector">
  3. <!-- 未选择状态 -->
  4. <div
  5. v-if="!selectedValue"
  6. class="audio-empty-state"
  7. @click="showAudioModal = true"
  8. >
  9. <div class="empty-icon">
  10. <SoundOutlined />
  11. </div>
  12. <div class="empty-text empty-action">{{ placeholder || "点击选择音频" }}</div>
  13. <!-- <div class="empty-action">点击选择音频</div> -->
  14. </div>
  15. <!-- 已选择状态 -->
  16. <div
  17. v-else
  18. class="audio-selected-state"
  19. @click="showAudioModal = true"
  20. >
  21. <div class="selected-info">
  22. <div class="selected-name"><span v-if="buttonLabel">{{buttonLabel}}:</span> {{ selectedValue }}</div>
  23. <div class="selected-action">点击更换音频</div>
  24. </div>
  25. <div class="selected-controls">
  26. <PauseCircleOutlined
  27. v-if="currentPlayingAudio === selectedValue && isPlaying"
  28. class="control-btn pause-btn"
  29. @click.stop="pauseCurrentAudio"
  30. />
  31. <PlayCircleOutlined
  32. v-else
  33. class="control-btn play-btn"
  34. @click.stop="playSelectedAudio"
  35. />
  36. </div>
  37. </div>
  38. <!-- 音频选择弹窗 -->
  39. <a-modal
  40. v-model:open="showAudioModal"
  41. :title="`选择${buttonLabel}音频`"
  42. width="600px"
  43. :footer="null"
  44. @cancel="handleModalCancel"
  45. >
  46. <div class="audio-modal-content">
  47. <!-- 搜索框 -->
  48. <div class="search-section">
  49. <a-input
  50. v-model:value="searchKey"
  51. placeholder="搜索音频名称"
  52. @input="handleSearch"
  53. allowClear
  54. >
  55. <template #prefix>
  56. <SearchOutlined />
  57. </template>
  58. </a-input>
  59. </div>
  60. <!-- 音频列表 -->
  61. <div class="audio-list">
  62. <div
  63. v-for="audio in audioList"
  64. :key="audio.title"
  65. class="audio-item"
  66. :class="{ 'selected': audio.title === selectedValue }"
  67. @click="selectAudio(audio)"
  68. >
  69. <div class="audio-info">
  70. <div class="audio-name">{{ audio.title }}</div>
  71. <div class="audio-status">
  72. <!-- <span v-if="audioManager.isAudioCached(audio.url)" class="cached-tag">已缓存</span>
  73. <span v-else class="loading-tag">需加载</span> -->
  74. </div>
  75. </div>
  76. <div class="audio-controls">
  77. <PauseCircleOutlined
  78. v-if="audio.state === 'playing'"
  79. class="control-btn pause-btn"
  80. @click.stop="pauseAudio($event, audio)"
  81. />
  82. <PlayCircleOutlined
  83. v-else
  84. class="control-btn play-btn"
  85. @click.stop="playAudio($event, audio)"
  86. />
  87. </div>
  88. </div>
  89. <!-- 空状态 -->
  90. <div v-if="audioList.length === 0" class="empty-list">
  91. <div class="empty-icon">
  92. <SoundOutlined />
  93. </div>
  94. <div class="empty-text">暂无音频数据</div>
  95. </div>
  96. </div>
  97. </div>
  98. </a-modal>
  99. </div>
  100. </template>
  101. <script lang='ts' setup>
  102. import { ref, watch, onMounted, computed, nextTick } from 'vue'
  103. import {
  104. PlayCircleOutlined,
  105. PauseCircleOutlined,
  106. SoundOutlined,
  107. SearchOutlined
  108. } from '@ant-design/icons-vue'
  109. import { MediaController } from '@/controller'
  110. import { audioManager } from '@/utils/AudioManaer'
  111. import AudioDataManager from '@/utils/AudioDataManager'
  112. import { message } from 'ant-design-vue'
  113. interface Props {
  114. modelValue?: string
  115. placeholder?: string
  116. buttonLabel?: string
  117. }
  118. const props = withDefaults(defineProps<Props>(), {
  119. modelValue: '',
  120. placeholder: '请选择音频',
  121. buttonLabel: ''
  122. })
  123. const emits = defineEmits<{
  124. 'update:modelValue': [value: string]
  125. 'search': [text: string]
  126. 'change': [value: string]
  127. 'blur': []
  128. }>()
  129. const selectedValue = computed({
  130. get: () => props.modelValue,
  131. set: (value) => emits('update:modelValue', value)
  132. })
  133. // 弹窗状态
  134. const showAudioModal = ref(false)
  135. const searchKey = ref('')
  136. const audioList = ref<API.Audio[]>([])
  137. // 播放状态
  138. const currentPlayingAudio = ref('')
  139. const isPlaying = ref(false)
  140. // 使用音频数据管理器
  141. const audioDataManager = AudioDataManager.getInstance()
  142. // 获取音频列表
  143. const getAudioList = async () => {
  144. // try {
  145. if (!searchKey.value) {
  146. const cachedData = await audioDataManager.getAudioList({ page: 1, key: '' })
  147. audioList.value = cachedData as API.Audio[]
  148. } else {
  149. const { data } = await MediaController.getAudioList({ page: 1, key: searchKey.value })
  150. audioList.value = data as API.Audio[]
  151. }
  152. // if (audioList.value.length > 0) {
  153. // const audioUrls = audioList.value.map(audio => audio.url).filter(url => url)
  154. // audioManager.preloadAudioList(audioUrls).catch(error => {
  155. // console.error('预加载音频失败:', error)
  156. // })
  157. // }
  158. // } catch (error) {
  159. // console.error('获取音频列表失败:', error)
  160. // }
  161. }
  162. // 设置所有音频状态
  163. const setAudioListState = (state: 'playing' | 'paused' | 'stopped') => {
  164. audioList.value.forEach(audio => {
  165. audio.state = state
  166. })
  167. }
  168. // 选择音频
  169. const selectAudio = (audio: API.Audio) => {
  170. selectedValue.value = audio.title
  171. showAudioModal.value = false
  172. emits('change', audio.title)
  173. }
  174. // 播放选中的音频
  175. const playSelectedAudio = async () => {
  176. if (!selectedValue.value) return
  177. const { data } = await MediaController.getAudioUrlByName(selectedValue.value)
  178. if (!data) {
  179. message.error('音频不存在')
  180. return
  181. }
  182. await playAudio(new Event('click'), {
  183. title: selectedValue.value,
  184. url: data,
  185. state: 'stopped'
  186. })
  187. currentPlayingAudio.value = selectedValue.value
  188. isPlaying.value = true
  189. }
  190. // 暂停当前音频
  191. const pauseCurrentAudio = () => {
  192. try {
  193. audioManager.stop()
  194. setAudioListState('stopped')
  195. currentPlayingAudio.value = ''
  196. isPlaying.value = false
  197. } catch (error) {
  198. console.error('暂停音频失败:', error)
  199. }
  200. }
  201. // 播放音频
  202. const playAudio = async (e: Event, audio: API.Audio) => {
  203. e.stopPropagation()
  204. e.preventDefault()
  205. audioManager.stop()
  206. try {
  207. // 先设置所有音频为停止状态
  208. setAudioListState('stopped')
  209. currentPlayingAudio.value = ''
  210. isPlaying.value = false
  211. // 设置当前音频为播放状态
  212. audio.state = 'playing'
  213. // 强制DOM更新,解决鼠标悬停时图标不切换的问题
  214. await nextTick()
  215. // 检查音频是否已缓存
  216. const isCached = audioManager.isAudioCached(audio.url)
  217. console.log(`播放音频: ${audio.title}, 缓存状态: ${isCached ? '已缓存' : '需要加载'}`)
  218. // 播放音频,传入播放结束回调
  219. await audioManager.playFromUrl(audio.url, () => {
  220. // 播放结束时将当前音频状态设置为停止
  221. audio.state = 'stopped'
  222. currentPlayingAudio.value = ''
  223. isPlaying.value = false
  224. })
  225. // 更新播放状态
  226. currentPlayingAudio.value = audio.title
  227. isPlaying.value = true
  228. } catch (error) {
  229. console.error('播放音频失败:', error)
  230. audio.state = 'stopped'
  231. currentPlayingAudio.value = ''
  232. isPlaying.value = false
  233. }
  234. }
  235. // 暂停音频
  236. const pauseAudio = (e: Event, audio: API.Audio) => {
  237. e.stopPropagation()
  238. e.preventDefault()
  239. try {
  240. audioManager.stop()
  241. // 根据audioManager状态同步
  242. setAudioListState('stopped')
  243. currentPlayingAudio.value = ''
  244. isPlaying.value = false
  245. } catch (error) {
  246. console.error('暂停音频失败:', error)
  247. }
  248. }
  249. // 处理搜索
  250. let debounceTimer: ReturnType<typeof setTimeout> | null = null
  251. const handleSearch = () => {
  252. if (debounceTimer) clearTimeout(debounceTimer)
  253. debounceTimer = setTimeout(() => {
  254. getAudioList()
  255. emits('search', searchKey.value)
  256. }, 300)
  257. }
  258. // 处理弹窗取消
  259. const handleModalCancel = () => {
  260. audioManager.stop()
  261. searchKey.value = ''
  262. getAudioList()
  263. emits('blur')
  264. }
  265. // 初始化
  266. onMounted(() => {
  267. getAudioList()
  268. })
  269. // 监听搜索关键字变化
  270. watch(searchKey, () => {
  271. if (!searchKey.value) {
  272. getAudioList()
  273. }
  274. })
  275. </script>
  276. <style lang='less' scoped>
  277. .audio-selector {
  278. width: 100%;
  279. .audio-empty-state {
  280. height: 60px;
  281. display: flex;
  282. flex-direction: column;
  283. align-items: center;
  284. justify-content: center;
  285. padding: 12px;
  286. border: 2px dashed #d9d9d9;
  287. border-radius: 8px;
  288. cursor: pointer;
  289. transition: all 0.3s;
  290. background: #fafafa;
  291. &:hover {
  292. border-color: #1890ff;
  293. background: #f0f8ff;
  294. }
  295. .empty-icon {
  296. font-size: 24px;
  297. color: #bfbfbf;
  298. margin-bottom: 4px;
  299. }
  300. .empty-text {
  301. font-size: 13px;
  302. color: #666;
  303. margin-bottom: 2px;
  304. }
  305. .empty-action {
  306. font-size: 12px;
  307. color: #1890ff;
  308. }
  309. }
  310. .audio-selected-state {
  311. display: flex;
  312. align-items: center;
  313. justify-content: space-between;
  314. padding: 12px 16px;
  315. border: 1px solid #d9d9d9;
  316. border-radius: 6px;
  317. cursor: pointer;
  318. transition: all 0.3s;
  319. background: #fff;
  320. &:hover {
  321. border-color: #1890ff;
  322. box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
  323. }
  324. .selected-info {
  325. flex: 1;
  326. .selected-name {
  327. font-size: 14px;
  328. color: #262626;
  329. font-weight: 500;
  330. margin-bottom: 2px;
  331. }
  332. .selected-action {
  333. font-size: 12px;
  334. color: #8c8c8c;
  335. }
  336. }
  337. .selected-controls {
  338. .control-btn {
  339. font-size: 20px;
  340. cursor: pointer;
  341. transition: all 0.3s;
  342. &.play-btn {
  343. color: #1890ff;
  344. &:hover {
  345. color: #40a9ff;
  346. }
  347. }
  348. &.pause-btn {
  349. color: #ff4d4f;
  350. &:hover {
  351. color: #ff7875;
  352. }
  353. }
  354. }
  355. }
  356. }
  357. }
  358. .audio-modal-content {
  359. .search-section {
  360. margin-bottom: 16px;
  361. }
  362. .audio-list {
  363. max-height: 400px;
  364. overflow-y: auto;
  365. .audio-item {
  366. display: flex;
  367. align-items: center;
  368. justify-content: space-between;
  369. padding: 12px;
  370. border: 1px solid #f0f0f0;
  371. border-radius: 6px;
  372. margin-bottom: 8px;
  373. cursor: pointer;
  374. transition: all 0.3s;
  375. &:hover {
  376. background: #f5f5f5;
  377. border-color: #d9d9d9;
  378. }
  379. &.selected {
  380. background: #e6f7ff;
  381. border-color: #1890ff;
  382. }
  383. .audio-info {
  384. flex: 1;
  385. .audio-name {
  386. font-size: 14px;
  387. color: #262626;
  388. margin-bottom: 4px;
  389. }
  390. .audio-status {
  391. .cached-tag {
  392. font-size: 12px;
  393. color: #52c41a;
  394. background: #f6ffed;
  395. padding: 2px 6px;
  396. border-radius: 4px;
  397. border: 1px solid #b7eb8f;
  398. }
  399. .loading-tag {
  400. font-size: 12px;
  401. color: #faad14;
  402. background: #fffbe6;
  403. padding: 2px 6px;
  404. border-radius: 4px;
  405. border: 1px solid #ffe58f;
  406. }
  407. }
  408. }
  409. .audio-controls {
  410. .control-btn {
  411. font-size: 18px;
  412. cursor: pointer;
  413. transition: all 0.3s;
  414. &.play-btn {
  415. color: #1890ff;
  416. &:hover {
  417. color: #40a9ff;
  418. }
  419. }
  420. &.pause-btn {
  421. color: #ff4d4f;
  422. &:hover {
  423. color: #ff7875;
  424. }
  425. }
  426. }
  427. }
  428. }
  429. .empty-list {
  430. display: flex;
  431. flex-direction: column;
  432. align-items: center;
  433. justify-content: center;
  434. padding: 40px;
  435. color: #bfbfbf;
  436. .empty-icon {
  437. font-size: 48px;
  438. margin-bottom: 16px;
  439. }
  440. .empty-text {
  441. font-size: 14px;
  442. }
  443. }
  444. }
  445. }
  446. </style>