chore: initialize frontend miniapp repository

This commit is contained in:
lyf
2026-06-09 21:08:45 +08:00
commit a90f63cef0
107 changed files with 60454 additions and 0 deletions

View File

@@ -0,0 +1,390 @@
<template>
<view class="audio-player" v-if="visible">
<!-- 迷你播放器 (底部固定) -->
<view class="mini-player" @tap="handleExpand">
<!-- 封面图 -->
<view class="player-cover">
<image
class="cover-image"
:src="currentAudio?.image || '/static/exhibit-placeholder.jpg'"
mode="aspectFill"
/>
</view>
<!-- 播放信息 -->
<view class="player-info">
<text class="player-title">{{ currentAudio?.name || '讲解音频' }}</text>
<text class="player-subtitle">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</text>
</view>
<!-- 播放/暂停按钮 -->
<view class="player-controls">
<view class="control-btn play-btn" @tap.stop="togglePlay">
<svg v-if="isPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
</svg>
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
</svg>
</view>
<!-- 关闭按钮 -->
<view class="control-btn close-btn" @tap.stop="handleClose">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</view>
</view>
</view>
<!-- 播放进度条 -->
<view class="progress-bar">
<view class="progress-track" @tap="handleSeek">
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
<view class="progress-thumb" :style="{ left: progressPercent + '%' }"></view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
export interface AudioItem {
id: string
name: string
audioUrl: string
image?: string
duration?: number
}
const props = defineProps<{
visible?: boolean
audio?: AudioItem | null
autoPlay?: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
play: [audio: AudioItem]
pause: [audio: AudioItem]
ended: [audio: AudioItem]
timeUpdate: [currentTime: number]
}>()
// 状态
const currentAudio = ref<AudioItem | null>(null)
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const isLoading = ref(false)
// 内部音频上下文
let innerAudioContext: any = null
// 计算进度百分比
const progressPercent = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
// 格式化时间
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// 初始化音频上下文
const initAudio = () => {
if (typeof uni !== 'undefined' && uni.createInnerAudioContext) {
innerAudioContext = uni.createInnerAudioContext()
innerAudioContext.onPlay(() => {
isPlaying.value = true
isLoading.value = false
})
innerAudioContext.onPause(() => {
isPlaying.value = false
})
innerAudioContext.onStop(() => {
isPlaying.value = false
currentTime.value = 0
})
innerAudioContext.onEnded(() => {
isPlaying.value = false
currentTime.value = 0
if (currentAudio.value) {
emit('ended', currentAudio.value)
}
})
innerAudioContext.onTimeUpdate(() => {
currentTime.value = innerAudioContext.currentTime
duration.value = innerAudioContext.duration || 0
emit('timeUpdate', currentTime.value)
})
innerAudioContext.onError((err: any) => {
console.error('音频播放错误:', err)
isPlaying.value = false
isLoading.value = false
uni.showToast({
title: '音频加载失败',
icon: 'none'
})
})
innerAudioContext.onWaiting(() => {
isLoading.value = true
})
}
}
// 播放音频
const playAudio = (audio: AudioItem) => {
if (!innerAudioContext) {
initAudio()
}
if (innerAudioContext) {
currentAudio.value = audio
innerAudioContext.src = audio.audioUrl
innerAudioContext.play()
emit('play', audio)
}
}
// 暂停音频
const pauseAudio = () => {
if (innerAudioContext) {
innerAudioContext.pause()
if (currentAudio.value) {
emit('pause', currentAudio.value)
}
}
}
// 恢复播放
const resumeAudio = () => {
if (innerAudioContext) {
innerAudioContext.play()
}
}
// 停止音频
const stopAudio = () => {
if (innerAudioContext) {
innerAudioContext.stop()
currentAudio.value = null
currentTime.value = 0
duration.value = 0
}
}
// 切换播放/暂停
const togglePlay = () => {
if (isPlaying.value) {
pauseAudio()
} else {
if (currentAudio.value) {
resumeAudio()
}
}
}
// 跳转到指定位置
const seekTo = (percent: number) => {
if (innerAudioContext && duration.value > 0) {
const targetTime = (percent / 100) * duration.value
innerAudioContext.seek(targetTime)
currentTime.value = targetTime
}
}
// 进度条点击
const handleSeek = (e: any) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.detail.x || (e.touches && e.touches[0]?.clientX - rect.left) || 0
const percent = (x / rect.width) * 100
seekTo(Math.max(0, Math.min(100, percent)))
}
// 展开播放器
const handleExpand = () => {
// 展开逻辑 (如果需要全屏播放器)
console.log('展开播放器')
}
// 关闭播放器
const handleClose = () => {
stopAudio()
emit('update:visible', false)
}
// 监听外部 audio 属性变化
watch(() => props.audio, (newAudio) => {
if (newAudio && props.visible) {
playAudio(newAudio)
}
}, { immediate: true })
// 监听外部 visible 属性变化
watch(() => props.visible, (visible) => {
if (!visible) {
stopAudio()
}
})
onMounted(() => {
initAudio()
})
onUnmounted(() => {
if (innerAudioContext) {
innerAudioContext.destroy()
innerAudioContext = null
}
})
// 暴露方法给外部调用
defineExpose({
play: playAudio,
pause: pauseAudio,
stop: stopAudio,
seekTo: seekTo,
isPlaying: () => isPlaying.value,
currentTime: () => currentTime.value,
duration: () => duration.value
})
</script>
<style scoped lang="scss">
.audio-player {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: var(--museum-bg-surface);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
padding-bottom: env(safe-area-inset-bottom);
}
.mini-player {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 12px;
}
.player-cover {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: var(--radius-small);
overflow: hidden;
background-color: var(--museum-bg-light);
}
.cover-image {
width: 100%;
height: 100%;
}
.player-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.player-title {
font-size: 14px;
font-weight: 500;
color: var(--museum-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-subtitle {
font-size: 12px;
color: var(--museum-text-secondary);
margin-top: 2px;
}
.player-controls {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
&:active {
transform: scale(0.9);
opacity: 0.8;
}
}
.play-btn {
width: 40px;
height: 40px;
background-color: var(--museum-accent);
border-radius: 50%;
color: var(--museum-text-primary);
}
.close-btn {
width: 32px;
height: 32px;
color: var(--museum-text-secondary);
}
.progress-bar {
width: 100%;
padding: 0 16px 12px;
}
.progress-track {
position: relative;
width: 100%;
height: 4px;
background-color: var(--museum-border-light);
border-radius: 2px;
cursor: pointer;
}
.progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: var(--museum-accent);
border-radius: 2px;
transition: width 0.1s linear;
}
.progress-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background-color: var(--museum-accent);
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
</style>