fix(ai): 补全音乐播放器进度交互

- 音乐列表改用 typed provide/inject + MusicSong
- audioBar 绑定真实 audio 元数据,支持进度显示、拖动 seek 与切歌重载
- 同步适配 web-antd、web-ele、web-antdv-next

对齐 Vue3 管理后台 0970806dc
migration
YunaiV 2026-06-21 08:18:12 -07:00
parent 1a3de7e97a
commit c2707a499a
12 changed files with 267 additions and 78 deletions

View File

@ -1,17 +1,22 @@
<script lang="ts" setup>
import { inject, reactive, ref } from 'vue';
import type { MusicSong } from '../types';
import { computed, inject, nextTick, reactive, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatPast } from '@vben/utils';
import { Image, Slider } from 'ant-design-vue';
import { currentSongKey } from '../types';
defineOptions({ name: 'AiMusicAudioBarIndex' });
const currentSong = inject<any>('currentSong', {});
const currentSong = inject(currentSongKey, ref<MusicSong>({}));
const currentAudioUrl = computed(() => currentSong.value.audioUrl || undefined);
const audioRef = ref<HTMLAudioElement | null>(null);
const audioProgress = ref(0);
const audioDuration = ref(0);
const audioProps = reactive<any>({
autoplay: true,
paused: false,
@ -21,6 +26,17 @@ const audioProps = reactive<any>({
volume: 50,
}); // https://www.runoob.com/tags/ref-av-dom.html
function formatAudioTime(seconds: number) {
if (!Number.isFinite(seconds)) {
return '00:00';
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
.toString()
.padStart(2, '0')}`;
}
function toggleStatus(type: string) {
audioProps[type] = !audioProps[type];
if (type === 'paused' && audioRef.value) {
@ -33,9 +49,40 @@ function toggleStatus(type: string) {
}
/** 更新播放位置 */
function audioTimeUpdate(args: any) {
audioProps.currentTime = formatPast(new Date(args.timeStamp), 'mm:ss');
function audioTimeUpdate() {
if (!audioRef.value) {
return;
}
audioProgress.value = audioRef.value.currentTime;
audioProps.currentTime = formatAudioTime(audioRef.value.currentTime);
}
function audioLoadedMetadata() {
if (!audioRef.value) {
return;
}
audioDuration.value = audioRef.value.duration;
audioProps.duration = formatAudioTime(audioRef.value.duration);
}
function handleProgressChange(value: number | [number, number]) {
if (!audioRef.value || Array.isArray(value)) {
return;
}
audioRef.value.currentTime = value;
audioProgress.value = value;
audioProps.currentTime = formatAudioTime(value);
}
watch(currentAudioUrl, () => {
audioProgress.value = 0;
audioDuration.value = 0;
audioProps.currentTime = '00:00';
audioProps.duration = '00:00';
nextTick(() => {
audioRef.value?.load();
});
});
</script>
<template>
@ -49,8 +96,10 @@ function audioTimeUpdate(args: any) {
:width="45"
/>
<div>
<div>{{ currentSong.name }}</div>
<div class="text-xs text-gray-400">{{ currentSong.singer }}</div>
<div>{{ currentSong.title || '暂无音乐' }}</div>
<div class="text-xs text-gray-400">
{{ currentSong.singer || currentSong.desc }}
</div>
</div>
</div>
<!-- 音频controls -->
@ -74,19 +123,26 @@ function audioTimeUpdate(args: any) {
/>
<div class="flex items-center gap-4">
<span>{{ audioProps.currentTime }}</span>
<Slider v-model:value="audioProgress" color="#409eff" class="!w-40" />
<Slider
v-model:value="audioProgress"
:max="audioDuration"
color="#409eff"
class="!w-40"
@change="handleProgressChange"
/>
<span>{{ audioProps.duration }}</span>
</div>
<!-- 音频 -->
<audio
v-bind="audioProps"
:src="currentAudioUrl"
:autoplay="audioProps.autoplay"
:muted="audioProps.muted"
ref="audioRef"
controls
v-show="!audioProps"
@timeupdate="audioTimeUpdate"
>
<!-- <source :src="audioUrl" /> -->
</audio>
@loadedmetadata="audioLoadedMetadata"
></audio>
</div>
<div class="flex items-center gap-4">
<IconifyIcon

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { MusicSong } from './types';
import { provide, ref } from 'vue';
@ -8,14 +9,15 @@ import { Col, Empty, Row, TabPane, Tabs } from 'ant-design-vue';
import audioBar from './audioBar/index.vue';
import songCard from './songCard/index.vue';
import songInfo from './songInfo/index.vue';
import { currentSongKey } from './types';
defineOptions({ name: 'AiMusicListIndex' });
const currentType = ref('mine');
const loading = ref(false); // loading
const currentSong = ref({}); //
const mySongList = ref<Recordable<any>[]>([]);
const squareSongList = ref<Recordable<any>[]>([]);
const currentSong = ref<MusicSong>({}); //
const mySongList = ref<MusicSong[]>([]);
const squareSongList = ref<MusicSong[]>([]);
function generateMusic(_formData: Recordable<any>) {
loading.value = true;
@ -45,7 +47,7 @@ function generateMusic(_formData: Recordable<any>) {
}, 3000);
}
function setCurrentSong(music: Recordable<any>) {
function setCurrentSong(music: MusicSong) {
currentSong.value = music;
}
@ -53,7 +55,7 @@ defineExpose({
generateMusic,
});
provide('currentSong', currentSong);
provide(currentSongKey, currentSong);
</script>
<template>

View File

@ -1,22 +1,23 @@
<script lang="ts" setup>
import { inject } from 'vue';
import type { MusicSong } from '../types';
import { inject, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Image } from 'ant-design-vue';
import { currentSongKey } from '../types';
defineOptions({ name: 'AiMusicSongCardIndex' });
defineProps({
songInfo: {
type: Object,
default: () => ({}),
},
withDefaults(defineProps<{ songInfo?: MusicSong }>(), {
songInfo: () => ({}),
});
const emits = defineEmits(['play']);
const currentSong = inject<any>('currentSong', {});
const currentSong = inject(currentSongKey, ref<MusicSong>({}));
function playSong() {
emits('play');

View File

@ -1,11 +1,15 @@
<script lang="ts" setup>
import { inject } from 'vue';
import type { MusicSong } from '../types';
import { inject, ref } from 'vue';
import { Button, Card, Image } from 'ant-design-vue';
import { currentSongKey } from '../types';
defineOptions({ name: 'AiMusicSongInfoIndex' });
const currentSong = inject<any>('currentSong', {});
const currentSong = inject(currentSongKey, ref<MusicSong>({}));
</script>
<template>

View File

@ -1,17 +1,22 @@
<script lang="ts" setup>
import { inject, reactive, ref } from 'vue';
import type { MusicSong } from '../types';
import { computed, inject, nextTick, reactive, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatPast } from '@vben/utils';
import { Image, Slider } from 'antdv-next';
import { currentSongKey } from '../types';
defineOptions({ name: 'AiMusicAudioBarIndex' });
const currentSong = inject<any>('currentSong', {});
const currentSong = inject(currentSongKey, ref<MusicSong>({}));
const currentAudioUrl = computed(() => currentSong.value.audioUrl || undefined);
const audioRef = ref<HTMLAudioElement | null>(null);
const audioProgress = ref(0);
const audioDuration = ref(0);
const audioProps = reactive<any>({
autoplay: true,
paused: false,
@ -21,6 +26,17 @@ const audioProps = reactive<any>({
volume: 50,
}); // https://www.runoob.com/tags/ref-av-dom.html
function formatAudioTime(seconds: number) {
if (!Number.isFinite(seconds)) {
return '00:00';
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
.toString()
.padStart(2, '0')}`;
}
function toggleStatus(type: string) {
audioProps[type] = !audioProps[type];
if (type === 'paused' && audioRef.value) {
@ -33,9 +49,40 @@ function toggleStatus(type: string) {
}
/** 更新播放位置 */
function audioTimeUpdate(args: any) {
audioProps.currentTime = formatPast(new Date(args.timeStamp), 'mm:ss');
function audioTimeUpdate() {
if (!audioRef.value) {
return;
}
audioProgress.value = audioRef.value.currentTime;
audioProps.currentTime = formatAudioTime(audioRef.value.currentTime);
}
function audioLoadedMetadata() {
if (!audioRef.value) {
return;
}
audioDuration.value = audioRef.value.duration;
audioProps.duration = formatAudioTime(audioRef.value.duration);
}
function handleProgressChange(value: number | [number, number]) {
if (!audioRef.value || Array.isArray(value)) {
return;
}
audioRef.value.currentTime = value;
audioProgress.value = value;
audioProps.currentTime = formatAudioTime(value);
}
watch(currentAudioUrl, () => {
audioProgress.value = 0;
audioDuration.value = 0;
audioProps.currentTime = '00:00';
audioProps.duration = '00:00';
nextTick(() => {
audioRef.value?.load();
});
});
</script>
<template>
@ -49,8 +96,10 @@ function audioTimeUpdate(args: any) {
:width="45"
/>
<div>
<div>{{ currentSong.name }}</div>
<div class="text-xs text-gray-400">{{ currentSong.singer }}</div>
<div>{{ currentSong.title || '暂无音乐' }}</div>
<div class="text-xs text-gray-400">
{{ currentSong.singer || currentSong.desc }}
</div>
</div>
</div>
<!-- 音频controls -->
@ -74,19 +123,26 @@ function audioTimeUpdate(args: any) {
/>
<div class="flex items-center gap-4">
<span>{{ audioProps.currentTime }}</span>
<Slider v-model:value="audioProgress" color="#409eff" class="!w-40" />
<Slider
v-model:value="audioProgress"
:max="audioDuration"
color="#409eff"
class="!w-40"
@change="handleProgressChange"
/>
<span>{{ audioProps.duration }}</span>
</div>
<!-- 音频 -->
<audio
v-bind="audioProps"
:src="currentAudioUrl"
:autoplay="audioProps.autoplay"
:muted="audioProps.muted"
ref="audioRef"
controls
v-show="!audioProps"
@timeupdate="audioTimeUpdate"
>
<!-- <source :src="audioUrl" /> -->
</audio>
@loadedmetadata="audioLoadedMetadata"
></audio>
</div>
<div class="flex items-center gap-4">
<IconifyIcon

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { MusicSong } from './types';
import { provide, ref } from 'vue';
@ -8,14 +9,15 @@ import { Col, Empty, Row, TabPane, Tabs } from 'antdv-next';
import audioBar from './audioBar/index.vue';
import songCard from './songCard/index.vue';
import songInfo from './songInfo/index.vue';
import { currentSongKey } from './types';
defineOptions({ name: 'AiMusicListIndex' });
const currentType = ref('mine');
const loading = ref(false); // loading
const currentSong = ref({}); //
const mySongList = ref<Recordable<any>[]>([]);
const squareSongList = ref<Recordable<any>[]>([]);
const currentSong = ref<MusicSong>({}); //
const mySongList = ref<MusicSong[]>([]);
const squareSongList = ref<MusicSong[]>([]);
function generateMusic(_formData: Recordable<any>) {
loading.value = true;
@ -45,7 +47,7 @@ function generateMusic(_formData: Recordable<any>) {
}, 3000);
}
function setCurrentSong(music: Recordable<any>) {
function setCurrentSong(music: MusicSong) {
currentSong.value = music;
}
@ -53,7 +55,7 @@ defineExpose({
generateMusic,
});
provide('currentSong', currentSong);
provide(currentSongKey, currentSong);
</script>
<template>

View File

@ -1,22 +1,23 @@
<script lang="ts" setup>
import { inject } from 'vue';
import type { MusicSong } from '../types';
import { inject, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Image } from 'antdv-next';
import { currentSongKey } from '../types';
defineOptions({ name: 'AiMusicSongCardIndex' });
defineProps({
songInfo: {
type: Object,
default: () => ({}),
},
withDefaults(defineProps<{ songInfo?: MusicSong }>(), {
songInfo: () => ({}),
});
const emits = defineEmits(['play']);
const currentSong = inject<any>('currentSong', {});
const currentSong = inject(currentSongKey, ref<MusicSong>({}));
function playSong() {
emits('play');

View File

@ -1,11 +1,15 @@
<script lang="ts" setup>
import { inject } from 'vue';
import type { MusicSong } from '../types';
import { inject, ref } from 'vue';
import { Button, Card, Image } from 'antdv-next';
import { currentSongKey } from '../types';
defineOptions({ name: 'AiMusicSongInfoIndex' });
const currentSong = inject<any>('currentSong', {});
const currentSong = inject(currentSongKey, ref<MusicSong>({}));
</script>
<template>

View File

@ -1,17 +1,22 @@
<script lang="ts" setup>
import { inject, reactive, ref } from 'vue';
import type { MusicSong } from '../types';
import { computed, inject, nextTick, reactive, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatPast } from '@vben/utils';
import { ElImage, ElSlider } from 'element-plus';
import { currentSongKey } from '../types';
defineOptions({ name: 'AiMusicAudioBarIndex' });
const currentSong = inject<any>('currentSong', {});
const currentSong = inject(currentSongKey, ref<MusicSong>({}));
const currentAudioUrl = computed(() => currentSong.value.audioUrl || undefined);
const audioRef = ref<HTMLAudioElement | null>(null);
const audioProgress = ref(0);
const audioDuration = ref(0);
const audioProps = reactive<any>({
autoplay: true,
paused: false,
@ -21,6 +26,17 @@ const audioProps = reactive<any>({
volume: 50,
}); // https://www.runoob.com/tags/ref-av-dom.html
function formatAudioTime(seconds: number) {
if (!Number.isFinite(seconds)) {
return '00:00';
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
.toString()
.padStart(2, '0')}`;
}
function toggleStatus(type: string) {
audioProps[type] = !audioProps[type];
if (type === 'paused' && audioRef.value) {
@ -33,9 +49,40 @@ function toggleStatus(type: string) {
}
/** 更新播放位置 */
function audioTimeUpdate(args: any) {
audioProps.currentTime = formatPast(new Date(args.timeStamp), 'mm:ss');
function audioTimeUpdate() {
if (!audioRef.value) {
return;
}
audioProgress.value = audioRef.value.currentTime;
audioProps.currentTime = formatAudioTime(audioRef.value.currentTime);
}
function audioLoadedMetadata() {
if (!audioRef.value) {
return;
}
audioDuration.value = audioRef.value.duration;
audioProps.duration = formatAudioTime(audioRef.value.duration);
}
function handleProgressChange(value: number | number[]) {
if (!audioRef.value || Array.isArray(value)) {
return;
}
audioRef.value.currentTime = value;
audioProgress.value = value;
audioProps.currentTime = formatAudioTime(value);
}
watch(currentAudioUrl, () => {
audioProgress.value = 0;
audioDuration.value = 0;
audioProps.currentTime = '00:00';
audioProps.duration = '00:00';
nextTick(() => {
audioRef.value?.load();
});
});
</script>
<template>
@ -49,8 +96,10 @@ function audioTimeUpdate(args: any) {
class="!w-[45px]"
/>
<div>
<div>{{ currentSong.name }}</div>
<div class="text-xs text-gray-400">{{ currentSong.singer }}</div>
<div>{{ currentSong.title || '暂无音乐' }}</div>
<div class="text-xs text-gray-400">
{{ currentSong.singer || currentSong.desc }}
</div>
</div>
</div>
<!-- 音频controls -->
@ -74,19 +123,26 @@ function audioTimeUpdate(args: any) {
/>
<div class="flex items-center gap-4">
<span>{{ audioProps.currentTime }}</span>
<ElSlider v-model="audioProgress" color="#409eff" class="!w-40" />
<ElSlider
v-model="audioProgress"
:max="audioDuration"
color="#409eff"
class="!w-40"
@change="handleProgressChange"
/>
<span>{{ audioProps.duration }}</span>
</div>
<!-- 音频 -->
<audio
v-bind="audioProps"
:src="currentAudioUrl"
:autoplay="audioProps.autoplay"
:muted="audioProps.muted"
ref="audioRef"
controls
v-show="!audioProps"
@timeupdate="audioTimeUpdate"
>
<!-- <source :src="audioUrl" /> -->
</audio>
@loadedmetadata="audioLoadedMetadata"
></audio>
</div>
<div class="flex items-center gap-4">
<IconifyIcon

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { MusicSong } from './types';
import { provide, ref } from 'vue';
@ -8,14 +9,15 @@ import { ElCol, ElEmpty, ElRow, ElTabPane, ElTabs } from 'element-plus';
import audioBar from './audioBar/index.vue';
import songCard from './songCard/index.vue';
import songInfo from './songInfo/index.vue';
import { currentSongKey } from './types';
defineOptions({ name: 'AiMusicListIndex' });
const currentType = ref('mine');
const loading = ref(false); // loading
const currentSong = ref({}); //
const mySongList = ref<Recordable<any>[]>([]);
const squareSongList = ref<Recordable<any>[]>([]);
const currentSong = ref<MusicSong>({}); //
const mySongList = ref<MusicSong[]>([]);
const squareSongList = ref<MusicSong[]>([]);
function generateMusic(_formData: Recordable<any>) {
loading.value = true;
@ -45,7 +47,7 @@ function generateMusic(_formData: Recordable<any>) {
}, 3000);
}
function setCurrentSong(music: Recordable<any>) {
function setCurrentSong(music: MusicSong) {
currentSong.value = music;
}
@ -53,7 +55,7 @@ defineExpose({
generateMusic,
});
provide('currentSong', currentSong);
provide(currentSongKey, currentSong);
</script>
<template>

View File

@ -1,22 +1,23 @@
<script lang="ts" setup>
import { inject } from 'vue';
import type { MusicSong } from '../types';
import { inject, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { ElImage } from 'element-plus';
import { currentSongKey } from '../types';
defineOptions({ name: 'AiMusicSongCardIndex' });
defineProps({
songInfo: {
type: Object,
default: () => ({}),
},
withDefaults(defineProps<{ songInfo?: MusicSong }>(), {
songInfo: () => ({}),
});
const emits = defineEmits(['play']);
const currentSong = inject<any>('currentSong', {});
const currentSong = inject(currentSongKey, ref<MusicSong>({}));
function playSong() {
emits('play');

View File

@ -1,11 +1,15 @@
<script lang="ts" setup>
import { inject } from 'vue';
import type { MusicSong } from '../types';
import { inject, ref } from 'vue';
import { ElButton, ElCard, ElImage } from 'element-plus';
import { currentSongKey } from '../types';
defineOptions({ name: 'AiMusicSongInfoIndex' });
const currentSong = inject<any>('currentSong', {});
const currentSong = inject(currentSongKey, ref<MusicSong>({}));
</script>
<template>