初次提交
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div :style="{ height }" class="apple-music-lyrics">
|
||||
<!-- 使用自定义的Apple Music风格歌词显示 -->
|
||||
<div v-if="!isLoading && !error && currentLyrics.length" class="lyrics-wrapper">
|
||||
<div ref="lyricContainer" class="custom-lyric-player">
|
||||
<div
|
||||
v-for="(line, index) in currentLyrics"
|
||||
:key="index"
|
||||
:class="[
|
||||
'lyric-line',
|
||||
{
|
||||
active: index === currentLyricIndex,
|
||||
passed: index < currentLyricIndex,
|
||||
upcoming: index > currentLyricIndex
|
||||
}
|
||||
]"
|
||||
:style="getLyricLineStyle(index)"
|
||||
@click="handleLineClick(line, index)"
|
||||
>
|
||||
<div class="main-lyric">{{ line.content || '' }}</div>
|
||||
<div
|
||||
v-if="
|
||||
showTranslation &&
|
||||
translationLyrics[index]?.content &&
|
||||
translationLyrics[index].content !== '//'
|
||||
"
|
||||
class="translation-lyric"
|
||||
>
|
||||
{{ translationLyrics[index].content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isLoading" class="placeholder">
|
||||
<div class="loading-spinner" />
|
||||
<p>歌词加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="placeholder error">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="placeholder">
|
||||
<p>暂无歌词</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
// 歌词数据
|
||||
currentLyrics: { type: Array, default: () => [] },
|
||||
translationLyrics: { type: Array, default: () => [] },
|
||||
wordByWordLyrics: { type: Array, default: () => [] },
|
||||
|
||||
// 播放状态
|
||||
currentTime: { type: Number, default: 0 },
|
||||
currentLyricIndex: { type: Number, default: -1 },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
|
||||
// 样式配置
|
||||
height: { type: String, default: '400px' },
|
||||
fontSize: { type: Number, default: 18 },
|
||||
lineHeight: { type: Number, default: 1.6 },
|
||||
activeLineColor: { type: String, default: '#ffffff' },
|
||||
inactiveLineColor: { type: String, default: 'rgba(255, 255, 255, 0.6)' },
|
||||
backgroundColor: { type: String, default: 'transparent' },
|
||||
|
||||
// 交互配置
|
||||
allowSeek: { type: Boolean, default: true },
|
||||
|
||||
// 显示选项
|
||||
showTranslation: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['seek'])
|
||||
|
||||
const lyricContainer = ref(null)
|
||||
|
||||
// 处理歌词行点击
|
||||
const handleLineClick = (line, index) => {
|
||||
if (props.allowSeek && line.time !== undefined) {
|
||||
emit('seek', line.time / 1000) // 转换为秒
|
||||
}
|
||||
}
|
||||
|
||||
// 获取歌词行样式
|
||||
const getLyricLineStyle = (index) => {
|
||||
const isActive = index === props.currentLyricIndex
|
||||
const isPassed = index < props.currentLyricIndex
|
||||
|
||||
return {
|
||||
fontSize: `${props.fontSize}px`,
|
||||
lineHeight: props.lineHeight,
|
||||
color: isActive ? props.activeLineColor : props.inactiveLineColor,
|
||||
opacity: isPassed ? 0.4 : isActive ? 1 : 0.7,
|
||||
transform: isActive ? 'scale(1.05)' : 'scale(1)',
|
||||
fontWeight: isActive ? '600' : '400'
|
||||
}
|
||||
}
|
||||
|
||||
// 自动滚动到当前歌词
|
||||
watch(
|
||||
() => props.currentLyricIndex,
|
||||
(newIndex) => {
|
||||
if (newIndex >= 0 && lyricContainer.value) {
|
||||
nextTick(() => {
|
||||
const container = lyricContainer.value
|
||||
const activeLine = container.querySelector('.lyric-line.active')
|
||||
|
||||
if (activeLine) {
|
||||
const containerHeight = container.clientHeight
|
||||
const lineTop = activeLine.offsetTop
|
||||
const lineHeight = activeLine.clientHeight
|
||||
|
||||
// 计算滚动位置,让当前歌词居中显示
|
||||
const scrollTop = lineTop - containerHeight / 2 + lineHeight / 2
|
||||
|
||||
container.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.apple-music-lyrics {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lyrics-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.custom-lyric-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 60px 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
/* 隐藏滚动条但保持滚动功能 */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.custom-lyric-player::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lyric-line {
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lyric-line:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.lyric-line.active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.main-lyric {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
margin-bottom: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.translation-lyric {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.8;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.lyric-line.active .translation-lyric {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.placeholder.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-top: 2px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Apple Music风格的渐变效果 */
|
||||
.custom-lyric-player::before,
|
||||
.custom-lyric-player::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.custom-lyric-player::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent);
|
||||
}
|
||||
|
||||
.custom-lyric-player::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
}
|
||||
|
||||
/* 深色主题适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.placeholder {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.custom-lyric-player {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.lyric-line {
|
||||
margin: 12px 0;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<audio
|
||||
v-if="song?.musicUrl"
|
||||
ref="audioPlayer"
|
||||
:src="song.musicUrl"
|
||||
crossorigin="anonymous"
|
||||
autoplay
|
||||
preload="auto"
|
||||
referrerpolicy="no-referrer"
|
||||
@canplay="$emit('canplay', $event)"
|
||||
@ended="$emit('ended', $event)"
|
||||
@error="$emit('error', $event)"
|
||||
@loadedmetadata="$emit('loadedmetadata', $event)"
|
||||
@loadstart="$emit('loadstart', $event)"
|
||||
@pause="$emit('pause', $event)"
|
||||
@play="$emit('play', $event)"
|
||||
@timeupdate="$emit('timeupdate', $event)"
|
||||
/>
|
||||
<!-- 如果没有 musicUrl(如哔哩哔哩视频),创建一个虚拟的 audio 元素 -->
|
||||
<audio v-else-if="song" ref="audioPlayer" style="display: none" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
song: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'timeupdate',
|
||||
'ended',
|
||||
'loadedmetadata',
|
||||
'error',
|
||||
'play',
|
||||
'pause',
|
||||
'loadstart',
|
||||
'canplay'
|
||||
])
|
||||
|
||||
const audioPlayer = ref(null)
|
||||
|
||||
// 错误消息常量,避免使用魔法字符串
|
||||
const ERROR_MESSAGES = {
|
||||
BILIBILI_NO_AUDIO: 'No audio URL available for Bilibili video'
|
||||
}
|
||||
|
||||
// 监听 song 变化,如果没有 musicUrl 则立即触发错误
|
||||
watch(
|
||||
() => props.song,
|
||||
(newSong, oldSong) => {
|
||||
if (newSong && !newSong.musicUrl) {
|
||||
// 使用 nextTick 确保 DOM 已更新
|
||||
nextTick(() => {
|
||||
// 创建一个自定义错误对象
|
||||
const error = new Error(ERROR_MESSAGES.BILIBILI_NO_AUDIO)
|
||||
error.name = 'BilibiliNoAudioError'
|
||||
|
||||
// 立即触发错误事件
|
||||
emit('error', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
audioPlayer
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<div class="player-controls">
|
||||
<div class="progress-container">
|
||||
<div class="control-with-progress">
|
||||
<!-- 上一首按钮 -->
|
||||
<button
|
||||
:disabled="!hasPrevious"
|
||||
:title="hasPrevious ? '上一首' : '没有上一首'"
|
||||
class="control-btn prev-btn"
|
||||
@click="$emit('previous')"
|
||||
>
|
||||
<Icon :size="24" color="white" name="skip-back" />
|
||||
</button>
|
||||
|
||||
<!-- 播放/暂停按钮 -->
|
||||
<button
|
||||
:disabled="hasError || isLoadingTrack"
|
||||
class="control-btn play-pause-btn"
|
||||
@click="$emit('togglePlay')"
|
||||
>
|
||||
<div v-if="isLoadingTrack" class="loading-spinner" />
|
||||
<Icon v-else-if="isPlaying" :size="18" color="white" name="pause" />
|
||||
<Icon v-else :size="18" color="white" name="play" />
|
||||
</button>
|
||||
|
||||
<!-- 下一首按钮 -->
|
||||
<button
|
||||
:disabled="!hasNext"
|
||||
:title="hasNext ? '下一首' : '没有下一首'"
|
||||
class="control-btn next-btn"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
<Icon :size="24" color="white" name="skip-forward" />
|
||||
</button>
|
||||
|
||||
<div class="progress-container-wrapper">
|
||||
<div
|
||||
ref="progressBar"
|
||||
class="progress-bar"
|
||||
@click="handleProgressClick"
|
||||
@mousedown="handleProgressMouseDown"
|
||||
@touchstart="handleProgressTouchStart"
|
||||
>
|
||||
<div
|
||||
:class="{ dragging: isDragging }"
|
||||
:style="{ '--progress-width': `${progress}%` }"
|
||||
class="progress"
|
||||
>
|
||||
<div :class="{ dragging: isDragging }" class="progress-thumb" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="time-display">
|
||||
<span class="current-time">{{ formatTime(currentTime) }}</span>
|
||||
<span class="duration">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
isPlaying: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasPrevious: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasNext: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
progress: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
isDragging: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
currentTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isLoadingTrack: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'togglePlay',
|
||||
'previous',
|
||||
'next',
|
||||
'startDrag',
|
||||
'startTouchDrag',
|
||||
'seekToPosition'
|
||||
])
|
||||
|
||||
const progressBar = ref(null)
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time || isNaN(time)) return '0:00'
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 进度条事件处理
|
||||
const handleProgressMouseDown = (event) => {
|
||||
emit('startDrag', event, progressBar.value)
|
||||
}
|
||||
|
||||
const handleProgressTouchStart = (event) => {
|
||||
emit('startTouchDrag', event, progressBar.value)
|
||||
}
|
||||
|
||||
const handleProgressClick = (event) => {
|
||||
// 防止拖拽时触发点击
|
||||
if (props.isDragging) return
|
||||
emit('seekToPosition', event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
progressBar
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-controls {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-with-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.control-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.control-btn:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.control-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.play-pause-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-container-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 20px; /* 增加触摸区域 */
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
touch-action: none; /* 防止移动端滚动 */
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.progress::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: var(--progress-width, 0%);
|
||||
background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
|
||||
border-radius: 2px;
|
||||
transition: width 0.05s linear;
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
.progress-thumb {
|
||||
position: absolute;
|
||||
left: calc(var(--progress-width, 0%) - 6px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
will-change: opacity, transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress,
|
||||
.progress.dragging {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-thumb,
|
||||
.progress-thumb.dragging {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.progress-thumb.dragging {
|
||||
transform: translateY(-50%) scale(1.2);
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.progress-bar {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.progress-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: calc(var(--progress-width, 0%) - 8px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.progress-bar:active .progress {
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.current-time,
|
||||
.duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="player-info">
|
||||
<div class="cover-container" title="点击打开歌词" @click="openLyrics">
|
||||
<template v-if="song.cover && !coverError">
|
||||
<img
|
||||
:src="convertToHttps(song.cover)"
|
||||
alt="封面"
|
||||
class="player-cover"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="handleImageError"
|
||||
>
|
||||
</template>
|
||||
<div v-else class="text-cover">
|
||||
{{ getFirstChar(song.title || '') }}
|
||||
</div>
|
||||
<!-- 悬浮提示层 -->
|
||||
<div class="lyrics-overlay">
|
||||
<svg
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 5h18v2H3V5zm0 4h14v2H3V9zm0 4h18v2H3v-2zm0 4h10v2H3v-2z"
|
||||
fill="currentColor"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<circle cx="20" cy="11" fill="currentColor" opacity="0.7" r="2" />
|
||||
<circle cx="18" cy="15" fill="currentColor" opacity="0.7" r="1.5" />
|
||||
</svg>
|
||||
<span class="lyrics-text">歌词</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="player-text">
|
||||
<h4>{{ song.title }}</h4>
|
||||
<p>{{ song.artist }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
|
||||
const props = defineProps({
|
||||
song: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['openLyrics'])
|
||||
|
||||
const coverError = ref(false)
|
||||
|
||||
const handleImageError = () => {
|
||||
coverError.value = true
|
||||
}
|
||||
|
||||
const getFirstChar = (title) => {
|
||||
if (!title) return '音'
|
||||
return title.trim().charAt(0)
|
||||
}
|
||||
|
||||
const openLyrics = () => {
|
||||
emit('openLyrics')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cover-container {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.cover-container:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.lyrics-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
color: white;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cover-container:hover .lyrics-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lyrics-text {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.player-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 添加辉光效果 */
|
||||
box-shadow:
|
||||
0 0 10px rgba(255, 255, 255, 0.3),
|
||||
0 0 20px rgba(255, 255, 255, 0.2),
|
||||
0 0 30px rgba(255, 255, 255, 0.1);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.player-cover:hover {
|
||||
box-shadow:
|
||||
0 0 15px rgba(255, 255, 255, 0.4),
|
||||
0 0 30px rgba(255, 255, 255, 0.3),
|
||||
0 0 45px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.text-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.player-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.player-text h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.player-text p {
|
||||
margin: 2px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[10000] flex items-center justify-center p-4"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/90 backdrop-blur-sm" />
|
||||
|
||||
<div class="relative w-full max-w-6xl h-[90vh] flex flex-col" @click.stop>
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="flex items-center justify-end gap-2 mb-3">
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold transition-all flex items-center gap-2"
|
||||
title="在新标签页打开"
|
||||
@click="openInNewTab"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
在哔哩哔哩打开
|
||||
</button>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-zinc-800/80 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-100 transition-all"
|
||||
title="关闭"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- iframe 容器 -->
|
||||
<div class="flex-1 rounded-2xl overflow-hidden bg-black shadow-2xl">
|
||||
<iframe
|
||||
v-if="iframeUrl"
|
||||
:src="iframeUrl"
|
||||
class="w-full h-full border-none"
|
||||
allowfullscreen="true"
|
||||
sandbox="allow-top-navigation-by-user-activation allow-same-origin allow-forms allow-scripts"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-500">
|
||||
<div class="text-center">
|
||||
<Icon name="alert-circle" :size="48" class="mx-auto mb-4 opacity-20" />
|
||||
<p class="text-sm font-bold">无法加载视频</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
bvid: String,
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 构建 iframe URL(使用移动端播放器)
|
||||
const iframeUrl = computed(() => {
|
||||
if (!props.bvid) return null
|
||||
|
||||
// 使用移动端播放器,更简洁且不会跳转
|
||||
let url = `https://www.bilibili.com/blackboard/html5mobileplayer.html?bvid=${props.bvid}`
|
||||
|
||||
// 添加分P参数
|
||||
if (props.page && props.page > 1) {
|
||||
url += `&page=${props.page}`
|
||||
}
|
||||
|
||||
// 添加其他参数
|
||||
url += '&as_wide=1' // 宽屏模式
|
||||
|
||||
return url
|
||||
})
|
||||
|
||||
// 构建完整的哔哩哔哩 URL
|
||||
const bilibiliUrl = computed(() => {
|
||||
if (!props.bvid) return null
|
||||
|
||||
let url = `https://www.bilibili.com/video/${props.bvid}/`
|
||||
|
||||
if (props.page && props.page > 1) {
|
||||
url += `?p=${props.page}`
|
||||
}
|
||||
|
||||
return url
|
||||
})
|
||||
|
||||
const openInNewTab = () => {
|
||||
if (bilibiliUrl.value) {
|
||||
window.open(bilibiliUrl.value, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 确保 iframe 正确显示 */
|
||||
iframe {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative" :class="className">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 bg-zinc-950 border rounded-lg transition-all cursor-pointer select-none"
|
||||
:class="[
|
||||
isOpen
|
||||
? 'border-blue-500/50 bg-blue-600/5 shadow-lg'
|
||||
: 'border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span
|
||||
v-if="label"
|
||||
class="text-[8px] font-black uppercase tracking-widest leading-none mb-0.5 transition-colors"
|
||||
:class="isOpen ? 'text-blue-400' : 'text-zinc-600'"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<span class="text-[11px] font-bold text-zinc-300 truncate">{{ displayLabel }}</span>
|
||||
</div>
|
||||
<div class="transition-transform duration-200" :class="{ 'rotate-180': isOpen }">
|
||||
<ChevronDown :size="12" :class="isOpen ? 'text-blue-400' : 'text-zinc-700'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="dropdownRef"
|
||||
:style="dropdownStyle"
|
||||
class="fixed z-[9999] p-1 bg-[#0c0c0e] border border-zinc-800 rounded-lg shadow-2xl backdrop-blur-xl origin-top"
|
||||
>
|
||||
<div class="max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||
<button
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
class="w-full flex items-center justify-between px-3 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="[
|
||||
isSelected(option)
|
||||
? 'bg-blue-600/10 text-blue-400'
|
||||
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40'
|
||||
]"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
<Icon v-if="isSelected(option)" name="check" :size="12" class="shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
modelValue: [String, Number, Object],
|
||||
value: [String, Number, Object],
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
className: String,
|
||||
labelKey: {
|
||||
type: String,
|
||||
default: 'label'
|
||||
},
|
||||
valueKey: {
|
||||
type: String,
|
||||
default: 'value'
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请选择'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:value', 'change'])
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref(null)
|
||||
const dropdownRef = ref(null)
|
||||
const dropdownStyle = ref({})
|
||||
|
||||
// 统一获取当前值
|
||||
const currentValue = computed(() => {
|
||||
if (props.modelValue !== undefined) return props.modelValue
|
||||
return props.value
|
||||
})
|
||||
|
||||
// 规范化选项为 { label, value } 格式
|
||||
const normalizedOptions = computed(() => {
|
||||
return props.options.map((option) => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return {
|
||||
label: option[props.labelKey] || option.label,
|
||||
value: option[props.valueKey] !== undefined ? option[props.valueKey] : option.value,
|
||||
original: option
|
||||
}
|
||||
}
|
||||
return { label: option, value: option, original: option }
|
||||
})
|
||||
})
|
||||
|
||||
// 获取当前显示标签
|
||||
const displayLabel = computed(() => {
|
||||
const selected = normalizedOptions.value.find((opt) => opt.value === currentValue.value)
|
||||
return selected
|
||||
? selected.label
|
||||
: currentValue.value && typeof currentValue.value !== 'object'
|
||||
? currentValue.value
|
||||
: props.placeholder
|
||||
})
|
||||
|
||||
const isSelected = (option) => {
|
||||
return option.value === currentValue.value
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!isOpen.value || !containerRef.value) return
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
|
||||
// 简单的位置计算,默认向下弹出
|
||||
// 检查底部空间
|
||||
const windowHeight = window.innerHeight
|
||||
const spaceBelow = windowHeight - rect.bottom
|
||||
|
||||
const top = rect.bottom + 4
|
||||
|
||||
// 如果底部空间不足且顶部空间充足,则向上弹出
|
||||
if (spaceBelow < 220 && rect.top > 220) {
|
||||
// 暂时保持向下,或者使用 fixed 定位让它尽量可见
|
||||
}
|
||||
|
||||
dropdownStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${rect.left}px`,
|
||||
width: `${rect.width}px`,
|
||||
minWidth: '120px' // 最小宽度
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
if (isOpen.value) {
|
||||
isOpen.value = false
|
||||
} else {
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
const selectOption = (option) => {
|
||||
emit('update:modelValue', option.value)
|
||||
emit('update:value', option.value)
|
||||
emit('change', option.value)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
// 检查点击是否在容器内部或者下拉框内部
|
||||
const isClickInContainer = containerRef.value && containerRef.value.contains(event.target)
|
||||
const isClickInDropdown = dropdownRef.value && dropdownRef.value.contains(event.target)
|
||||
|
||||
if (!isClickInContainer && !isClickInDropdown) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleScrollOrResize = () => {
|
||||
if (isOpen.value) {
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 isOpen 变化来添加/移除事件监听
|
||||
watch(isOpen, (val) => {
|
||||
if (val) {
|
||||
window.addEventListener('scroll', handleScrollOrResize, true) // capture=true 以捕获子元素的滚动
|
||||
window.addEventListener('resize', handleScrollOrResize)
|
||||
} else {
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true)
|
||||
window.removeEventListener('resize', handleScrollOrResize)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true)
|
||||
window.removeEventListener('resize', handleScrollOrResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,658 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 表格工具栏 -->
|
||||
<div
|
||||
v-if="showToolbar"
|
||||
class="flex flex-col sm:flex-row items-center justify-between gap-4 px-1"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="toolbar-left">
|
||||
<div
|
||||
v-if="selectedRows.length > 0"
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-blue-600/10 border border-blue-500/20 rounded-lg"
|
||||
>
|
||||
<span class="text-[10px] font-black text-blue-400 uppercase tracking-widest"
|
||||
>已选择 {{ selectedRows.length }} 项</span
|
||||
>
|
||||
<button
|
||||
class="p-0.5 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
@click="$emit('clear-selection')"
|
||||
>
|
||||
<X :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 w-full sm:w-auto">
|
||||
<slot name="toolbar-right">
|
||||
<button
|
||||
v-if="refreshable"
|
||||
:disabled="loading"
|
||||
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 hover:text-zinc-200 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all disabled:opacity-50"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<RefreshCw :size="14" :class="{ 'animate-spin': loading }" />
|
||||
<span>刷新数据</span>
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格主容器 -->
|
||||
<div
|
||||
class="relative bg-zinc-900/50 border border-zinc-800 rounded-xl overflow-hidden shadow-2xl shadow-black/20"
|
||||
>
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950/60 backdrop-blur-[2px] animate-in fade-in duration-300"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin mb-4"
|
||||
/>
|
||||
<span class="text-[10px] font-black text-zinc-400 uppercase tracking-widest">{{
|
||||
loadingText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
<div class="hidden md:block overflow-x-auto custom-scrollbar">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-zinc-800 bg-zinc-900/50">
|
||||
<th v-if="selectable" class="p-4 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isAllSelected"
|
||||
class="w-4 h-4 rounded border-zinc-700 bg-zinc-950 text-blue-600 focus:ring-0 focus:ring-offset-0 transition-all cursor-pointer"
|
||||
@change="toggleSelectAll"
|
||||
>
|
||||
</th>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
column.class,
|
||||
'p-4 text-[10px] font-black text-zinc-600 uppercase tracking-widest'
|
||||
]"
|
||||
:style="{ width: column.width }"
|
||||
>
|
||||
{{ column.title }}
|
||||
</th>
|
||||
<th
|
||||
v-if="hasActions"
|
||||
class="p-4 text-[10px] font-black text-zinc-600 uppercase tracking-widest text-right"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-800/50">
|
||||
<tr v-if="!loading && data.length === 0">
|
||||
<td :colspan="totalColumns" class="p-20 text-center">
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center gap-3 text-zinc-700">
|
||||
<Database :size="40" stroke-width="1" />
|
||||
<span class="text-[10px] font-black uppercase tracking-widest"
|
||||
>暂无数据内容</span
|
||||
>
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, index) in data"
|
||||
:key="getRowKey(row, index)"
|
||||
class="group hover:bg-zinc-800/30 transition-colors cursor-default"
|
||||
:class="{ 'bg-blue-600/5': selectedRows.includes(getRowKey(row, index)) }"
|
||||
@click="handleRowClick(row, index)"
|
||||
>
|
||||
<td v-if="selectable" class="p-4" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedRows.includes(getRowKey(row, index))"
|
||||
class="w-4 h-4 rounded border-zinc-700 bg-zinc-950 text-blue-600 focus:ring-0 focus:ring-offset-0 transition-all cursor-pointer"
|
||||
@change="toggleSelectRow(getRowKey(row, index))"
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:class="[column.class, 'p-4 text-xs font-bold text-zinc-300']"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:index="index"
|
||||
:value="getNestedValue(row, column.key)"
|
||||
>
|
||||
{{ formatCellValue(getNestedValue(row, column.key), column) }}
|
||||
</slot>
|
||||
</td>
|
||||
<td v-if="hasActions" class="p-4 text-right" @click.stop>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<slot name="actions" :row="row" :index="index">
|
||||
<button
|
||||
class="p-2 text-zinc-500 hover:text-blue-400 hover:bg-blue-400/10 rounded-xl transition-all"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-zinc-500 hover:text-red-400 hover:bg-red-400/10 rounded-xl transition-all"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="md:hidden divide-y divide-zinc-800">
|
||||
<div v-if="!loading && data.length === 0" class="p-20 text-center">
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center gap-3 text-zinc-700">
|
||||
<Database :size="40" stroke-width="1" />
|
||||
<span class="text-[10px] font-black uppercase tracking-widest">暂无数据内容</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, index) in data"
|
||||
:key="getRowKey(row, index)"
|
||||
class="p-4 space-y-4 hover:bg-zinc-800/30 transition-colors"
|
||||
:class="{ 'bg-blue-600/5': selectedRows.includes(getRowKey(row, index)) }"
|
||||
@click="handleRowClick(row, index)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="selectable" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedRows.includes(getRowKey(row, index))"
|
||||
class="w-4 h-4 rounded border-zinc-700 bg-zinc-950 text-blue-600 focus:ring-0 focus:ring-offset-0 transition-all cursor-pointer"
|
||||
@change="toggleSelectRow(getRowKey(row, index))"
|
||||
>
|
||||
</div>
|
||||
<slot name="mobile-primary" :row="row" :index="index">
|
||||
<span class="text-sm font-black text-zinc-100">{{
|
||||
formatCellValue(getNestedValue(row, columns[0]?.key), columns[0])
|
||||
}}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="hasActions" class="flex items-center gap-1" @click.stop>
|
||||
<slot name="actions" :row="row" :index="index">
|
||||
<button
|
||||
class="p-2 text-zinc-500 hover:text-blue-400 hover:bg-blue-400/10 rounded-xl transition-all"
|
||||
>
|
||||
<Edit2 :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-zinc-500 hover:text-red-400 hover:bg-red-400/10 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-y-3 gap-x-4">
|
||||
<div v-for="column in columns.slice(1)" :key="column.key" class="space-y-1">
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest block">{{
|
||||
column.title
|
||||
}}</span>
|
||||
<div class="text-xs font-bold text-zinc-400">
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:index="index"
|
||||
:value="getNestedValue(row, column.key)"
|
||||
>
|
||||
{{ formatCellValue(getNestedValue(row, column.key), column) }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
X,
|
||||
RefreshCw,
|
||||
Database,
|
||||
Edit2,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
MoreHorizontal
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
columns: { type: Array, required: true },
|
||||
data: { type: Array, required: true },
|
||||
loading: { type: Boolean, default: false },
|
||||
loadingText: { type: String, default: '正在加载数据...' },
|
||||
selectable: { type: Boolean, default: false },
|
||||
selectedRows: { type: Array, default: () => [] },
|
||||
rowKey: { type: [String, Function], default: 'id' },
|
||||
refreshable: { type: Boolean, default: false },
|
||||
showToolbar: { type: Boolean, default: true },
|
||||
hasActions: { type: Boolean, default: true },
|
||||
rowClickable: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:selectedRows', 'row-click', 'refresh', 'clear-selection'])
|
||||
|
||||
const totalColumns = computed(() => {
|
||||
let count = props.columns.length
|
||||
if (props.selectable) count++
|
||||
if (props.hasActions) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
if (props.data.length === 0) return false
|
||||
return props.data.every((row) => props.selectedRows.includes(getRowKey(row)))
|
||||
})
|
||||
|
||||
const getRowKey = (row, index) => {
|
||||
if (typeof props.rowKey === 'function') return props.rowKey(row, index)
|
||||
return row[props.rowKey] || index
|
||||
}
|
||||
|
||||
const getNestedValue = (obj, path) => {
|
||||
if (!path) return undefined
|
||||
return path.split('.').reduce((acc, part) => acc && acc[part], obj)
|
||||
}
|
||||
|
||||
const formatCellValue = (value, column) => {
|
||||
if (column.formatter) return column.formatter(value)
|
||||
if (value === null || value === undefined) return '-'
|
||||
return value
|
||||
}
|
||||
|
||||
const toggleSelectAll = (event) => {
|
||||
const checked = event.target.checked
|
||||
const newSelectedRows = checked
|
||||
? [...new Set([...props.selectedRows, ...props.data.map((row) => getRowKey(row))])]
|
||||
: props.selectedRows.filter((id) => !props.data.map((row) => getRowKey(row)).includes(id))
|
||||
emit('update:selectedRows', newSelectedRows)
|
||||
}
|
||||
|
||||
const toggleSelectRow = (key) => {
|
||||
const newSelectedRows = props.selectedRows.includes(key)
|
||||
? props.selectedRows.filter((id) => id !== key)
|
||||
: [...props.selectedRows, key]
|
||||
emit('update:selectedRows', newSelectedRows)
|
||||
}
|
||||
|
||||
const handleRowClick = (row, index) => {
|
||||
if (props.rowClickable) {
|
||||
emit('row-click', { row, index })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
/* 表格容器 */
|
||||
.table-container {
|
||||
position: relative;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-lg);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* 表格头部 */
|
||||
.table-header {
|
||||
display: flex;
|
||||
background: var(--table-header-bg);
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkbox-cell {
|
||||
width: 50px;
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
width: 120px;
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 表格主体 */
|
||||
.table-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: var(--table-row-hover);
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-row.selected {
|
||||
background-color: var(--table-selected);
|
||||
}
|
||||
|
||||
.table-row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: var(--primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: var(--error);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: var(--error-hover);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-3xl) var(--spacing-xl);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 移动端卡片布局 */
|
||||
.mobile-cards {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
background: var(--table-row-bg);
|
||||
border: 1px solid var(--table-border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-md);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.data-card:hover {
|
||||
border-color: var(--border-secondary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.data-card:active {
|
||||
transform: scale(0.98);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.data-card.selected {
|
||||
background-color: var(--table-selected);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.data-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.card-selection {
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.card-primary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.primary-value {
|
||||
font-weight: 600;
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.card-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) 0;
|
||||
border-bottom: 1px solid var(--border-tertiary);
|
||||
}
|
||||
|
||||
.card-field:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.table-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 隐藏桌面表格,显示移动端卡片 */
|
||||
.desktop-table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-cards {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕设备进一步优化 */
|
||||
@media (max-width: 480px) {
|
||||
.data-card {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.primary-value {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.field-label,
|
||||
.field-value {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<slot v-if="!hasError" />
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center min-h-[400px] p-8 text-center bg-zinc-900/30 border border-dashed border-zinc-800 rounded-3xl animate-in fade-in zoom-in duration-300"
|
||||
>
|
||||
<div class="relative mb-6">
|
||||
<div class="absolute inset-0 blur-2xl bg-red-500/10 rounded-full" />
|
||||
<div
|
||||
class="relative flex items-center justify-center w-16 h-16 bg-zinc-950 border border-red-500/30 rounded-2xl text-red-500 shadow-xl shadow-black/40"
|
||||
>
|
||||
<AlertCircle :size="32" stroke-width="1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight mb-2">{{ errorTitle }}</h3>
|
||||
<p class="text-[10px] font-black text-zinc-500 uppercase tracking-widest max-w-xs mb-8">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-4">
|
||||
<button
|
||||
:disabled="retrying"
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-zinc-950 border border-zinc-800 hover:border-blue-500/50 text-zinc-400 hover:text-zinc-100 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all shadow-lg shadow-black/20 disabled:opacity-50"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<RefreshCw :size="14" :class="{ 'animate-spin': retrying }" />
|
||||
<span>{{ retrying ? '正在重试' : '重新尝试' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showDetails"
|
||||
class="px-6 py-2.5 bg-zinc-900/50 border border-zinc-800 hover:border-zinc-700 text-zinc-500 hover:text-zinc-300 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
|
||||
@click="toggleDetails"
|
||||
>
|
||||
{{ showErrorDetails ? '隐藏详细信息' : '查看详细信息' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 错误详情 -->
|
||||
<transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div v-if="showErrorDetails" class="mt-8 w-full max-w-2xl text-left">
|
||||
<div class="p-4 bg-zinc-950 border border-zinc-800 rounded-2xl">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>系统调试信息</span
|
||||
>
|
||||
</div>
|
||||
<pre
|
||||
class="text-[10px] font-mono text-zinc-500 leading-relaxed overflow-x-auto p-4 bg-black/30 rounded-xl whitespace-pre-wrap break-all"
|
||||
>{{ errorDetails }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { AlertCircle, RefreshCw } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
error?: Error | string | null
|
||||
errorTitle?: string
|
||||
errorMessage?: string
|
||||
showDetails?: boolean
|
||||
onRetry?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
error: null,
|
||||
errorTitle: '加载失败',
|
||||
errorMessage: '数据加载时发生错误,请稍后重试',
|
||||
showDetails: false,
|
||||
onRetry: undefined
|
||||
})
|
||||
|
||||
const hasError = computed(() => !!props.error)
|
||||
const retrying = ref(false)
|
||||
const showErrorDetails = ref(false)
|
||||
|
||||
const errorDetails = computed(() => {
|
||||
if (!props.error) return ''
|
||||
if (typeof props.error === 'string') return props.error
|
||||
return props.error.stack || props.error.message || String(props.error)
|
||||
})
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (!props.onRetry || retrying.value) return
|
||||
|
||||
retrying.value = true
|
||||
try {
|
||||
await props.onRetry()
|
||||
} catch (error) {
|
||||
console.error('重试失败:', error)
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDetails = () => {
|
||||
showErrorDetails.value = !showErrorDetails.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-boundary {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: #ef4444;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 16px;
|
||||
color: #94a3b8;
|
||||
margin: 0 0 24px 0;
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.retry-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.retry-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.details-btn {
|
||||
padding: 10px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.details-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-details h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.error-details pre {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-state {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.retry-btn,
|
||||
.details-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'fixed inset-0 z-[9999] bg-zinc-950/80 backdrop-blur-md': fullScreen,
|
||||
'min-h-[200px] py-12': !fullScreen
|
||||
}"
|
||||
class="flex flex-col items-center justify-center animate-in fade-in duration-300"
|
||||
>
|
||||
<div class="flex flex-col items-center text-center max-w-md w-full px-6">
|
||||
<!-- 加载动画 -->
|
||||
<div class="mb-8">
|
||||
<!-- 默认圆形加载器 -->
|
||||
<div v-if="spinnerType === 'circle'" class="relative">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
|
||||
/>
|
||||
<div class="absolute inset-0 blur-lg bg-blue-500/20 rounded-full animate-pulse" />
|
||||
</div>
|
||||
|
||||
<!-- 点状加载器 -->
|
||||
<div v-else-if="spinnerType === 'dots'" class="flex gap-2">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="w-3 h-3 bg-blue-500 rounded-full animate-bounce"
|
||||
:style="{ animationDelay: `${(i - 1) * 0.2}s` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 脉冲加载器 -->
|
||||
<div v-else-if="spinnerType === 'pulse'" class="relative w-12 h-12">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="absolute inset-0 border-2 border-blue-500 rounded-full animate-ping"
|
||||
:style="{ animationDelay: `${(i - 1) * 0.4}s`, animationDuration: '2s' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 条状加载器 -->
|
||||
<div v-else-if="spinnerType === 'bars'" class="flex items-end gap-1.5 h-8">
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
class="w-1.5 bg-blue-500 rounded-full animate-bounce"
|
||||
:style="{
|
||||
animationDelay: `${(i - 1) * 0.1}s`,
|
||||
height: `${40 + Math.random() * 60}%`
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载文本 -->
|
||||
<div class="w-full space-y-2">
|
||||
<h3 v-if="title" class="text-xl font-black text-zinc-100 tracking-tight">{{ title }}</h3>
|
||||
<p class="text-[10px] font-black text-zinc-500 uppercase tracking-widest">{{ message }}</p>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div v-if="showProgress" class="mt-8 space-y-2">
|
||||
<div class="h-1.5 w-full bg-zinc-900 rounded-full overflow-hidden border border-zinc-800">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all duration-300 shadow-[0_0_10px_rgba(59,130,246,0.5)]"
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-1">
|
||||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>加载进度</span
|
||||
>
|
||||
<span class="text-[10px] font-black text-blue-500 tracking-widest"
|
||||
>{{ progress }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载步骤 -->
|
||||
<div v-if="steps && steps.length > 0" class="mt-8 space-y-3 text-left">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="flex items-center gap-3 transition-all duration-300"
|
||||
:class="{
|
||||
'opacity-100': index <= currentStep,
|
||||
'opacity-30': index > currentStep
|
||||
}"
|
||||
>
|
||||
<div class="relative flex items-center justify-center w-6 h-6">
|
||||
<div
|
||||
v-if="index < currentStep"
|
||||
class="flex items-center justify-center w-6 h-6 bg-blue-500/10 border border-blue-500/20 rounded-lg"
|
||||
>
|
||||
<Check :size="12" class="text-blue-500" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="index === currentStep"
|
||||
class="w-6 h-6 border-2 border-blue-500/20 border-t-blue-500 rounded-lg animate-spin"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-6 h-6 bg-zinc-900 border border-zinc-800 rounded-lg"
|
||||
>
|
||||
<span class="text-[10px] font-black text-zinc-600">{{ index + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-black uppercase tracking-widest transition-colors"
|
||||
:class="index === currentStep ? 'text-zinc-200' : 'text-zinc-600'"
|
||||
>
|
||||
{{ step }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 取消按钮 -->
|
||||
<button
|
||||
v-if="showCancel && onCancel"
|
||||
class="mt-10 px-6 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 hover:text-zinc-200 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
|
||||
@click="onCancel"
|
||||
>
|
||||
取消加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Check } from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
message?: string
|
||||
spinnerType?: 'circle' | 'dots' | 'pulse' | 'bars'
|
||||
fullScreen?: boolean
|
||||
showProgress?: boolean
|
||||
progress?: number
|
||||
steps?: string[]
|
||||
currentStep?: number
|
||||
showCancel?: boolean
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
message: '正在加载...',
|
||||
spinnerType: 'circle',
|
||||
fullScreen: false,
|
||||
showProgress: false,
|
||||
progress: 0,
|
||||
steps: () => [],
|
||||
currentStep: 0,
|
||||
showCancel: false,
|
||||
onCancel: undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 保持必要的动画定义,如果 Tailwind 无法完全覆盖的话 */
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.33);
|
||||
opacity: 0;
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="flex flex-col sm:flex-row items-center justify-between gap-4 px-2 pt-4">
|
||||
<!-- 分页信息 -->
|
||||
<div class="text-[10px] font-black text-zinc-700 uppercase tracking-[0.2em] order-2 sm:order-1">
|
||||
第 {{ currentPage }} 页 · 共 {{ totalPages }} 页
|
||||
<span v-if="totalItems !== null"> (共 {{ totalItems }} {{ itemName }})</span>
|
||||
</div>
|
||||
|
||||
<!-- 分页控制 -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 order-1 sm:order-2">
|
||||
<!-- 首页 -->
|
||||
<button
|
||||
:disabled="currentPage === 1"
|
||||
class="w-10 h-10 rounded-lg border border-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
|
||||
title="首页"
|
||||
@click="goToPage(1)"
|
||||
>
|
||||
<ChevronsLeft :size="18" />
|
||||
</button>
|
||||
|
||||
<!-- 上一页 -->
|
||||
<button
|
||||
:disabled="currentPage === 1"
|
||||
class="w-10 h-10 rounded-lg border border-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
|
||||
title="上一页"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<ChevronLeft :size="18" />
|
||||
</button>
|
||||
|
||||
<!-- 当前页码/按钮组 (移动端隐藏,只显示当前页) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-for="page in displayedPages"
|
||||
:key="page"
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg text-xs font-black transition-all shadow-lg',
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white shadow-blue-900/20'
|
||||
: 'border border-zinc-800 text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 shadow-none'
|
||||
]"
|
||||
@click="goToPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 下一页 -->
|
||||
<button
|
||||
:disabled="currentPage === totalPages"
|
||||
class="w-10 h-10 rounded-lg border border-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
|
||||
title="下一页"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<ChevronRight :size="18" />
|
||||
</button>
|
||||
|
||||
<!-- 尾页 -->
|
||||
<button
|
||||
:disabled="currentPage === totalPages"
|
||||
class="w-10 h-10 rounded-lg border border-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
|
||||
title="尾页"
|
||||
@click="goToPage(totalPages)"
|
||||
>
|
||||
<ChevronsRight :size="18" />
|
||||
</button>
|
||||
|
||||
<!-- 跳转输入 -->
|
||||
<div class="flex items-center ml-2 pl-2 border-l border-zinc-800">
|
||||
<div class="relative group">
|
||||
<input
|
||||
v-model="jumpPageInput"
|
||||
type="text"
|
||||
class="w-12 h-10 bg-zinc-950 border border-zinc-800 rounded-lg text-center text-xs font-black text-zinc-300 focus:outline-none focus:border-blue-500/50 transition-all"
|
||||
placeholder="页"
|
||||
@keyup.enter="handleJump"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="ml-2 px-3 h-10 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all"
|
||||
@click="handleJump"
|
||||
>
|
||||
跳转
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
totalItems: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
itemName: {
|
||||
type: String,
|
||||
default: '项'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:currentPage', 'change'])
|
||||
|
||||
const jumpPageInput = ref('')
|
||||
|
||||
// 计算要显示的页码
|
||||
const displayedPages = computed(() => {
|
||||
const pages = []
|
||||
const maxVisible = 3 // 手机端展示较少,桌面端也可以通过这个控制
|
||||
|
||||
let start = Math.max(1, props.currentPage - Math.floor(maxVisible / 2))
|
||||
let end = Math.min(props.totalPages, start + maxVisible - 1)
|
||||
|
||||
if (end - start + 1 < maxVisible) {
|
||||
start = Math.max(1, end - maxVisible + 1)
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
|
||||
emit('update:currentPage', page)
|
||||
emit('change', page)
|
||||
}
|
||||
}
|
||||
|
||||
const handleJump = () => {
|
||||
const page = parseInt(jumpPageInput.value)
|
||||
if (!isNaN(page) && page >= 1 && page <= props.totalPages) {
|
||||
goToPage(page)
|
||||
jumpPageInput.value = ''
|
||||
} else {
|
||||
// 简单的输入验证
|
||||
jumpPageInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 监听当前页变化,清空跳转输入
|
||||
watch(() => props.currentPage, () => {
|
||||
jumpPageInput.value = ''
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative inline-block">
|
||||
<div class="cursor-pointer" @click="toggle">
|
||||
<slot name="trigger" :is-open="isOpen" />
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0 translate-y-2"
|
||||
enter-to-class="transform scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="transform scale-95 opacity-0 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="contentRef"
|
||||
:style="contentStyle"
|
||||
class="fixed z-[10000] bg-[#1e1e1e]/95 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<slot name="content" :close="close" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch, onUnmounted, type StyleValue } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'bottom-end' // bottom-start, bottom-end, top-start, top-end
|
||||
},
|
||||
offset: {
|
||||
type: Number,
|
||||
default: 8
|
||||
}
|
||||
})
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
const contentStyle = ref<StyleValue>({})
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!isOpen.value || !containerRef.value || !contentRef.value) return
|
||||
|
||||
const triggerRect = containerRef.value.getBoundingClientRect()
|
||||
const contentRect = contentRef.value.getBoundingClientRect()
|
||||
const windowWidth = window.innerWidth
|
||||
const windowHeight = window.innerHeight
|
||||
|
||||
let top = 0
|
||||
let left = 0
|
||||
|
||||
// Vertical positioning base
|
||||
if (props.placement.startsWith('top')) {
|
||||
top = triggerRect.top - contentRect.height - props.offset
|
||||
} else {
|
||||
top = triggerRect.bottom + props.offset
|
||||
}
|
||||
|
||||
// Horizontal positioning base
|
||||
if (props.placement.endsWith('end')) {
|
||||
left = triggerRect.right - contentRect.width
|
||||
} else {
|
||||
left = triggerRect.left
|
||||
}
|
||||
|
||||
// Boundary checks
|
||||
// Right edge
|
||||
if (left + contentRect.width > windowWidth - 10) {
|
||||
left = windowWidth - contentRect.width - 10
|
||||
}
|
||||
// Left edge
|
||||
if (left < 10) {
|
||||
left = 10
|
||||
}
|
||||
|
||||
// Flip vertical if needed
|
||||
if (props.placement.startsWith('bottom') && top + contentRect.height > windowHeight - 10) {
|
||||
// Try top
|
||||
const topSpace = triggerRect.top
|
||||
if (topSpace > contentRect.height + props.offset) {
|
||||
top = triggerRect.top - contentRect.height - props.offset
|
||||
}
|
||||
} else if (props.placement.startsWith('top') && top < 10) {
|
||||
// Try bottom
|
||||
const bottomSpace = windowHeight - triggerRect.bottom
|
||||
if (bottomSpace > contentRect.height + props.offset) {
|
||||
top = triggerRect.bottom + props.offset
|
||||
}
|
||||
}
|
||||
|
||||
contentStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const toggle = async () => {
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
} else {
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (
|
||||
containerRef.value &&
|
||||
!containerRef.value.contains(target) &&
|
||||
contentRef.value &&
|
||||
!contentRef.value.contains(target)
|
||||
) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const handleScrollOrResize = () => {
|
||||
if (isOpen.value) {
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
watch(isOpen, (val) => {
|
||||
if (val) {
|
||||
window.addEventListener('scroll', handleScrollOrResize, true)
|
||||
window.addEventListener('resize', handleScrollOrResize)
|
||||
// Use setTimeout to avoid immediate trigger if the click that opened it bubbles up
|
||||
setTimeout(() => document.addEventListener('mousedown', handleClickOutside), 0)
|
||||
} else {
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true)
|
||||
window.removeEventListener('resize', handleScrollOrResize)
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true)
|
||||
window.removeEventListener('resize', handleScrollOrResize)
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<div class="flex flex-col lg:flex-row items-center gap-4 w-full">
|
||||
<!-- 搜索输入框 -->
|
||||
<div class="relative flex-1 w-full">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<Search :size="16" class="text-zinc-500" />
|
||||
</div>
|
||||
<input
|
||||
:value="searchQuery"
|
||||
type="text"
|
||||
:placeholder="searchPlaceholder"
|
||||
class="block w-full pl-11 pr-11 py-2.5 bg-zinc-950 border border-zinc-800 rounded-2xl text-xs font-bold text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-blue-500/50 focus:bg-blue-600/5 transition-all"
|
||||
@input="$emit('update:searchQuery', $event.target.value)"
|
||||
>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="absolute inset-y-0 right-0 pr-4 flex items-center text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
@click="$emit('update:searchQuery', '')"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 过滤器容器 -->
|
||||
<div v-if="filters.length > 0" class="flex flex-wrap items-center gap-3 w-full lg:w-auto">
|
||||
<div v-for="filter in filters" :key="filter.key" class="flex-1 lg:flex-none min-w-[140px]">
|
||||
<!-- 选择框过滤器 - 使用 CustomSelect -->
|
||||
<CustomSelect
|
||||
v-if="filter.type === 'select'"
|
||||
:label="filter.label"
|
||||
:value="getSelectValueLabel(filter)"
|
||||
:options="filter.options.map((o) => o.label)"
|
||||
class="w-full"
|
||||
@change="(val) => updateSelectFilter(filter, val)"
|
||||
/>
|
||||
|
||||
<!-- 日期范围过滤器 -->
|
||||
<div
|
||||
v-else-if="filter.type === 'dateRange'"
|
||||
class="flex items-center gap-2 p-1 bg-zinc-950 border border-zinc-800 rounded-2xl"
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
:value="filterValues[filter.key]?.start || ''"
|
||||
class="bg-transparent border-none text-[10px] font-bold text-zinc-300 focus:ring-0 px-2 py-1 w-28"
|
||||
@input="updateDateRange(filter.key, 'start', $event.target.value)"
|
||||
>
|
||||
<span class="text-zinc-700 text-[10px] font-black uppercase">至</span>
|
||||
<input
|
||||
type="date"
|
||||
:value="filterValues[filter.key]?.end || ''"
|
||||
class="bg-transparent border-none text-[10px] font-bold text-zinc-300 focus:ring-0 px-2 py-1 w-28"
|
||||
@input="updateDateRange(filter.key, 'end', $event.target.value)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 多选过滤器 -->
|
||||
<div v-else-if="filter.type === 'multiSelect'" ref="dropdownRef" class="relative">
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-zinc-950 border border-zinc-800 rounded-2xl transition-all hover:border-zinc-700"
|
||||
:class="{ 'border-blue-500/50 bg-blue-600/5': openDropdown === filter.key }"
|
||||
@click="toggleDropdown(filter.key)"
|
||||
>
|
||||
<div class="flex flex-col items-start gap-0.5 overflow-hidden">
|
||||
<span
|
||||
v-if="filter.label"
|
||||
class="text-[9px] font-black text-zinc-600 uppercase tracking-widest leading-none"
|
||||
>{{ filter.label }}</span
|
||||
>
|
||||
<span class="text-[11px] font-bold text-zinc-300 truncate w-full text-left">{{
|
||||
getMultiSelectLabel(filter)
|
||||
}}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
:size="14"
|
||||
class="text-zinc-500 shrink-0 transition-transform"
|
||||
:class="{ 'rotate-180': openDropdown === filter.key }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="openDropdown === filter.key"
|
||||
class="absolute z-50 mt-2 w-full min-w-[200px] bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden py-1"
|
||||
>
|
||||
<div class="max-h-60 overflow-y-auto custom-scrollbar">
|
||||
<label
|
||||
v-for="option in filter.options"
|
||||
:key="option.value"
|
||||
class="flex items-center gap-3 px-4 py-2.5 hover:bg-zinc-800 cursor-pointer group transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="(filterValues[filter.key] || []).includes(option.value)"
|
||||
class="w-3.5 h-3.5 rounded border-zinc-700 bg-zinc-950 text-blue-600 focus:ring-0 focus:ring-offset-0 transition-all"
|
||||
@change="toggleMultiSelectOption(filter.key, option.value)"
|
||||
>
|
||||
<span class="text-xs font-bold text-zinc-400 group-hover:text-zinc-200">{{
|
||||
option.label
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="showActions" class="flex items-center gap-2 w-full lg:w-auto">
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
class="flex-1 lg:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-zinc-900 border border-zinc-800 hover:border-red-500/30 hover:bg-red-500/5 text-zinc-400 hover:text-red-400 text-[10px] font-black uppercase tracking-widest rounded-2xl transition-all"
|
||||
@click="clearAllFilters"
|
||||
>
|
||||
<RotateCcw :size="14" />
|
||||
<span>清除筛选</span>
|
||||
</button>
|
||||
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { Search, X, ChevronDown, RotateCcw } from 'lucide-vue-next'
|
||||
import CustomSelect from './CustomSelect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
searchQuery: { type: String, default: '' },
|
||||
searchPlaceholder: { type: String, default: '搜索...' },
|
||||
filters: { type: Array, default: () => [] },
|
||||
filterValues: { type: Object, default: () => ({}) },
|
||||
showActions: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:searchQuery', 'update:filterValues', 'filter-change'])
|
||||
|
||||
const openDropdown = ref(null)
|
||||
const dropdownRef = ref(null)
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return (
|
||||
props.searchQuery ||
|
||||
Object.values(props.filterValues).some((value) => {
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return Object.values(value).some((v) => v)
|
||||
}
|
||||
return value !== '' && value !== null && value !== undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const updateFilter = (key, value) => {
|
||||
const newValues = { ...props.filterValues, [key]: value }
|
||||
emit('update:filterValues', newValues)
|
||||
emit('filter-change', { key, value, allValues: newValues })
|
||||
}
|
||||
|
||||
const updateSelectFilter = (filter, label) => {
|
||||
const option = filter.options.find((o) => o.label === label)
|
||||
if (option) {
|
||||
updateFilter(filter.key, option.value)
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectValueLabel = (filter) => {
|
||||
const value = props.filterValues[filter.key]
|
||||
const option = filter.options.find((o) => o.value === value)
|
||||
return option ? option.label : filter.placeholder || '请选择'
|
||||
}
|
||||
|
||||
const updateDateRange = (key, type, value) => {
|
||||
const currentRange = props.filterValues[key] || {}
|
||||
const newRange = { ...currentRange, [type]: value }
|
||||
updateFilter(key, newRange)
|
||||
}
|
||||
|
||||
const toggleMultiSelectOption = (filterKey, optionValue) => {
|
||||
const currentValues = props.filterValues[filterKey] || []
|
||||
const newValues = currentValues.includes(optionValue)
|
||||
? currentValues.filter((v) => v !== optionValue)
|
||||
: [...currentValues, optionValue]
|
||||
updateFilter(filterKey, newValues)
|
||||
}
|
||||
|
||||
const toggleDropdown = (key) => {
|
||||
openDropdown.value = openDropdown.value === key ? null : key
|
||||
}
|
||||
|
||||
const getMultiSelectLabel = (filter) => {
|
||||
const selectedValues = props.filterValues[filter.key] || []
|
||||
if (selectedValues.length === 0) return filter.placeholder || '请选择'
|
||||
if (selectedValues.length === 1) {
|
||||
const option = filter.options.find((opt) => opt.value === selectedValues[0])
|
||||
return option ? option.label : selectedValues[0]
|
||||
}
|
||||
return `已选 ${selectedValues.length} 项`
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
emit('update:searchQuery', '')
|
||||
const clearedValues = {}
|
||||
props.filters.forEach((f) => {
|
||||
if (f.type === 'multiSelect') clearedValues[f.key] = []
|
||||
else if (f.type === 'dateRange') clearedValues[f.key] = { start: '', end: '' }
|
||||
else clearedValues[f.key] = ''
|
||||
})
|
||||
emit('update:filterValues', clearedValues)
|
||||
emit('filter-change', { key: 'all', value: null, allValues: clearedValues })
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
|
||||
openDropdown.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-xl);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
/* 搜索部分 */
|
||||
.search-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: var(--spacing-lg);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-quaternary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg) var(--spacing-md) 48px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--input-shadow-focus);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--input-placeholder);
|
||||
}
|
||||
|
||||
.clear-search-btn {
|
||||
position: absolute;
|
||||
right: var(--spacing-md);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-quaternary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.clear-search-btn:hover {
|
||||
background: var(--bg-quaternary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.clear-search-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 过滤器部分 */
|
||||
.filters-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-select,
|
||||
.date-input {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-select:focus,
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-border-focus);
|
||||
}
|
||||
|
||||
/* 日期范围过滤器 */
|
||||
.date-range-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.date-separator {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 多选过滤器 */
|
||||
.multi-select-filter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.multi-select-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.multi-select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
min-width: 150px;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.multi-select-trigger:hover {
|
||||
border-color: var(--input-border-focus);
|
||||
}
|
||||
|
||||
.multi-select-trigger svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.multi-select-dropdown.open .multi-select-trigger svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.multi-select-options {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-dropdown);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.multi-select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.multi-select-option:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.multi-select-option .checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
/* 操作部分 */
|
||||
.actions-section {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.search-filter {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.date-range-filter {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative overflow-hidden p-6 bg-zinc-900 border border-zinc-800 rounded-xl group hover:border-blue-500/30 transition-all duration-300 shadow-xl shadow-black/20"
|
||||
:class="{ 'opacity-60 grayscale cursor-wait': isLoading }"
|
||||
>
|
||||
<!-- 背景装饰 -->
|
||||
<div
|
||||
class="absolute -right-4 -top-4 w-24 h-24 bg-blue-600/5 blur-3xl rounded-full group-hover:bg-blue-600/10 transition-colors"
|
||||
/>
|
||||
|
||||
<div class="flex items-start justify-between relative z-10">
|
||||
<div class="space-y-4 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="p-2.5 rounded-lg bg-zinc-950 border border-zinc-800 group-hover:border-blue-500/30 group-hover:bg-blue-600/5 transition-all duration-300"
|
||||
:class="iconClass"
|
||||
>
|
||||
<component
|
||||
:is="iconComponent"
|
||||
:size="18"
|
||||
class="text-zinc-400 group-hover:text-blue-400 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest group-hover:text-zinc-500 transition-colors"
|
||||
>{{ label }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<h3 class="text-3xl font-black text-zinc-100 tracking-tight">
|
||||
{{ formattedValue }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="change !== undefined"
|
||||
:class="changeClass"
|
||||
class="flex items-center gap-0.5 text-[10px] font-black uppercase px-1.5 py-0.5 rounded-md border"
|
||||
>
|
||||
<TrendingUp v-if="change > 0" :size="10" />
|
||||
<TrendingDown v-else-if="change < 0" :size="10" />
|
||||
<Minus v-else :size="10" />
|
||||
<span>{{ Math.abs(change) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="text-[10px] font-bold text-zinc-600 group-hover:text-zinc-500 transition-colors"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 迷你趋势图 -->
|
||||
<div v-if="trendData && trendData.length > 0" class="w-24 h-12 self-end mb-1">
|
||||
<svg class="w-full h-full drop-shadow-[0_0_8px_rgba(59,130,246,0.2)]" viewBox="0 0 100 20">
|
||||
<polyline
|
||||
:points="trendPoints"
|
||||
:stroke="trendColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
class="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载动画 -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-zinc-900/40 backdrop-blur-[1px]"
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Users,
|
||||
Music,
|
||||
Calendar,
|
||||
Heart,
|
||||
Bell,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
Database,
|
||||
ShieldCheck,
|
||||
Mail,
|
||||
Zap,
|
||||
Clock,
|
||||
BarChart3,
|
||||
MessageSquare
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
value: { type: [String, Number], required: true },
|
||||
icon: { type: String, required: true },
|
||||
iconClass: { type: String, default: '' },
|
||||
change: { type: Number, default: undefined },
|
||||
changeLabel: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' },
|
||||
trendData: { type: Array, default: () => [] },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
shouldAnimate: { type: Boolean, default: true },
|
||||
format: { type: String, default: 'number' } // 'number', 'currency', 'percentage'
|
||||
})
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
const icons = {
|
||||
users: Users,
|
||||
songs: Music,
|
||||
schedule: Calendar,
|
||||
votes: Heart,
|
||||
notifications: Bell,
|
||||
database: Database,
|
||||
security: ShieldCheck,
|
||||
email: Mail,
|
||||
performance: Zap,
|
||||
time: Clock,
|
||||
chart: BarChart3,
|
||||
messages: MessageSquare
|
||||
}
|
||||
return icons[props.icon] || icons.users
|
||||
})
|
||||
|
||||
const changeClass = computed(() => {
|
||||
if (props.change === undefined) return ''
|
||||
if (props.change > 0) return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
if (props.change < 0) return 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
return 'bg-zinc-800 text-zinc-500 border-zinc-700'
|
||||
})
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
if (typeof props.value === 'string') return props.value
|
||||
const num = Number(props.value)
|
||||
if (isNaN(num)) return props.value
|
||||
|
||||
switch (props.format) {
|
||||
case 'currency':
|
||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(num)
|
||||
case 'percentage':
|
||||
return `${num}%`
|
||||
case 'number':
|
||||
default:
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
|
||||
return num.toLocaleString()
|
||||
}
|
||||
})
|
||||
|
||||
const minTrend = computed(() => Math.min(...(props.trendData || [0])))
|
||||
const maxTrend = computed(() => Math.max(...(props.trendData || [1])))
|
||||
|
||||
const trendPoints = computed(() => {
|
||||
if (!props.trendData || props.trendData.length < 2) return ''
|
||||
const range = maxTrend.value - minTrend.value || 1
|
||||
return props.trendData
|
||||
.map((point, index) => {
|
||||
const x = (index / (props.trendData.length - 1)) * 100
|
||||
const y = 18 - ((point - minTrend.value) / range) * 16
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const trendColor = computed(() => {
|
||||
if (!props.trendData || props.trendData.length < 2) {
|
||||
if (props.change > 0) return '#10b981' // emerald-500
|
||||
if (props.change < 0) return '#ef4444' // red-500
|
||||
return '#3b82f6' // blue-500
|
||||
}
|
||||
|
||||
const first = props.trendData[0]
|
||||
const last = props.trendData[props.trendData.length - 1]
|
||||
|
||||
if (last > first) return '#10b981' // emerald-500
|
||||
if (last < first) return '#ef4444' // red-500
|
||||
return '#3b82f6' // blue-500
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[2000] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
@click="handleOverlayClick"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 内容 -->
|
||||
<div class="flex flex-col items-center p-8 text-center">
|
||||
<!-- 图标 -->
|
||||
<div
|
||||
class="w-20 h-20 rounded-[2rem] flex items-center justify-center mb-6 transition-colors border"
|
||||
:class="iconClasses"
|
||||
>
|
||||
<Icon :name="iconName" :size="40" />
|
||||
</div>
|
||||
|
||||
<!-- 文字内容 -->
|
||||
<div class="space-y-2 mb-8">
|
||||
<h4 class="text-xl font-black text-zinc-100 tracking-tight">{{ title }}</h4>
|
||||
<p class="text-sm text-zinc-500 leading-relaxed font-medium whitespace-pre-line break-all">
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
<!-- 可选的输入框 -->
|
||||
<div v-if="showInput" class="pt-4 w-full">
|
||||
<input
|
||||
v-model="inputValue"
|
||||
:type="inputType"
|
||||
:placeholder="inputPlaceholder"
|
||||
class="w-full bg-zinc-800/50 border border-zinc-700/50 rounded-xl px-4 py-3 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all text-sm"
|
||||
@keyup.enter="handleConfirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-3 w-full">
|
||||
<button
|
||||
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="loading"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-[2] px-6 py-4 text-white text-xs font-black rounded-2xl shadow-lg transition-all active:scale-95 uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="confirmBtnClasses"
|
||||
:disabled="loading || (showInput && !inputValue)"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<Icon v-if="loading" name="loader" :size="16" class="animate-spin" />
|
||||
{{ loading ? '处理中...' : confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '确认操作'
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'warning',
|
||||
validator: (value) => ['warning', 'danger', 'info', 'success'].includes(value)
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: '确认'
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: '取消'
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
closeOnOverlay: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showInput: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
inputType: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
inputPlaceholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel', 'close', 'update:show'])
|
||||
|
||||
const inputValue = ref('')
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
inputValue.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (props.showInput) {
|
||||
emit('confirm', inputValue.value)
|
||||
} else {
|
||||
emit('confirm')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
emit('close')
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
if (props.closeOnOverlay && !props.loading) {
|
||||
handleCancel()
|
||||
}
|
||||
}
|
||||
|
||||
const iconName = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'danger':
|
||||
return 'alert-circle'
|
||||
case 'success':
|
||||
return 'success'
|
||||
case 'info':
|
||||
return 'info'
|
||||
case 'warning':
|
||||
default:
|
||||
return 'alert-triangle'
|
||||
}
|
||||
})
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'danger':
|
||||
return 'bg-red-500/10 text-red-500 border-red-500/20 shadow-red-900/5'
|
||||
case 'success':
|
||||
return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20 shadow-emerald-900/5'
|
||||
case 'info':
|
||||
return 'bg-blue-500/10 text-blue-500 border-blue-500/20 shadow-blue-900/5'
|
||||
case 'warning':
|
||||
default:
|
||||
return 'bg-amber-500/10 text-amber-500 border-amber-500/20 shadow-amber-900/5'
|
||||
}
|
||||
})
|
||||
|
||||
const confirmBtnClasses = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'danger':
|
||||
return 'bg-red-600 hover:bg-red-500 shadow-red-900/20'
|
||||
case 'success':
|
||||
return 'bg-emerald-600 hover:bg-emerald-500 shadow-emerald-900/20'
|
||||
case 'info':
|
||||
return 'bg-blue-600 hover:bg-blue-500 shadow-blue-900/20'
|
||||
case 'warning':
|
||||
default:
|
||||
return 'bg-amber-600 hover:bg-amber-500 shadow-amber-900/20'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<svg
|
||||
:class="['icon', `icon-${name}`, className]"
|
||||
:height="size"
|
||||
:style="{ color }"
|
||||
:width="size"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<!-- 消息图标 (圆形气泡) -->
|
||||
<g v-if="name === 'message-circle'">
|
||||
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
|
||||
</g>
|
||||
|
||||
<!-- 播放图标 -->
|
||||
<g v-else-if="name === 'play'" fill="currentColor" stroke="none" stroke-width="0">
|
||||
<path
|
||||
d="M6.77 2.35Q5.95 1.9 5.08 2.06Q4.2 2.23 3.61 2.9Q3.02 3.58 3.02 4.51L3.02 19.46Q3.02 20.4 3.61 21.08Q4.2 21.77 5.08 21.94Q5.95 22.1 6.77 21.65L19.73 14.16Q20.54 13.7 20.83 12.85Q21.12 12 20.83 11.15Q20.54 10.3 19.73 9.84L6.77 2.35Z"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 播放/暂停 -->
|
||||
<g v-else-if="name === 'pause'">
|
||||
<rect fill="currentColor" height="16" width="4" x="6" y="4" />
|
||||
<rect fill="currentColor" height="16" width="4" x="14" y="4" />
|
||||
</g>
|
||||
|
||||
<!-- 播放圈图标 (单曲播放等使用) -->
|
||||
<g v-else-if="name === 'play-circle'">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</g>
|
||||
|
||||
<!-- 停止图标 -->
|
||||
<rect v-else-if="name === 'stop'" fill="currentColor" height="14" width="14" x="5" y="5" />
|
||||
|
||||
<!-- 音量图标 -->
|
||||
<g v-else-if="name === 'volume'">
|
||||
<polygon points="11,5 6,9 2,9 2,15 6,15 11,19" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
</g>
|
||||
|
||||
<!-- 静音图标 -->
|
||||
<g v-else-if="name === 'volume-mute'">
|
||||
<polygon points="11,5 6,9 2,9 2,15 6,15 11,19" />
|
||||
<line x1="23" x2="17" y1="9" y2="15" />
|
||||
<line x1="17" x2="23" y1="9" y2="15" />
|
||||
</g>
|
||||
|
||||
<!-- 左箭头 -->
|
||||
<g v-else-if="name === 'chevron-left'">
|
||||
<polyline points="15,18 9,12 15,6" />
|
||||
</g>
|
||||
|
||||
<!-- 右箭头 -->
|
||||
<g v-else-if="name === 'chevron-right'">
|
||||
<polyline points="9,18 15,12 9,6" />
|
||||
</g>
|
||||
|
||||
<!-- 上箭头 -->
|
||||
<g v-else-if="name === 'chevron-up'">
|
||||
<polyline points="18,15 12,9 6,15" />
|
||||
</g>
|
||||
|
||||
<!-- 下箭头 -->
|
||||
<g v-else-if="name === 'chevron-down'">
|
||||
<polyline points="6,9 12,15 18,9" />
|
||||
</g>
|
||||
|
||||
<!-- 箭头图标 -->
|
||||
<g v-else-if="name === 'arrow-left' || name === 'back'">
|
||||
<line x1="19" x2="5" y1="12" y2="12" />
|
||||
<polyline points="12 19 5 12 12 5" />
|
||||
</g>
|
||||
<g v-else-if="name === 'arrow-right'">
|
||||
<line x1="5" x2="19" y1="12" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</g>
|
||||
|
||||
<!-- 心形/点赞图标 -->
|
||||
<path
|
||||
v-else-if="name === 'heart'"
|
||||
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
|
||||
/>
|
||||
|
||||
<!-- 音乐图标 -->
|
||||
<g v-else-if="name === 'music'">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</g>
|
||||
|
||||
<!-- 视频图标 -->
|
||||
<g v-else-if="name === 'video'">
|
||||
<path d="m22 8-6 4 6 4V8Z" />
|
||||
<rect width="14" height="12" x="2" y="6" rx="2" ry="2" />
|
||||
</g>
|
||||
|
||||
<!-- 目标/选中图标 -->
|
||||
<g v-else-if="name === 'target'">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</g>
|
||||
|
||||
<!-- 拇指向上 -->
|
||||
<g v-else-if="name === 'thumbs-up'">
|
||||
<path
|
||||
d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 扬声器/广播图标 -->
|
||||
<g v-else-if="name === 'speaker'">
|
||||
<rect height="20" rx="2" ry="2" width="16" x="4" y="2" />
|
||||
<circle cx="12" cy="14" r="4" />
|
||||
<circle cx="12" cy="6" r="2" />
|
||||
</g>
|
||||
|
||||
<!-- 通知图标 -->
|
||||
<g v-else-if="name === 'bell'">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</g>
|
||||
|
||||
<!-- 日历图标 -->
|
||||
<g v-else-if="name === 'calendar'">
|
||||
<rect height="18" rx="2" ry="2" width="18" x="3" y="4" />
|
||||
<line x1="16" x2="16" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="2" y2="6" />
|
||||
<line x1="3" x2="21" y1="10" y2="10" />
|
||||
</g>
|
||||
|
||||
<!-- 时钟图标 -->
|
||||
<g v-else-if="name === 'clock'">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12,6 12,12 16,14" />
|
||||
</g>
|
||||
|
||||
<!-- 用户图标 -->
|
||||
<g v-else-if="name === 'user'">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</g>
|
||||
|
||||
<!-- 用户组/多用户图标 -->
|
||||
<g v-else-if="name === 'users' || name === 'group'">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</g>
|
||||
|
||||
<!-- 设置图标 -->
|
||||
<g v-else-if="name === 'settings'" fill="currentColor" stroke="none" stroke-width="0">
|
||||
<path
|
||||
d="M12.02 9Q12.84 9 13.52 9.41Q14.21 9.82 14.6 10.51Q15 11.21 15 12.02Q15 12.84 14.6 13.52Q14.21 14.21 13.52 14.6Q12.84 15 12 15Q11.18 15 10.49 14.59Q9.79 14.18 9.4 13.49Q9 12.79 9 11.98Q9 11.16 9.41 10.48Q9.82 9.79 10.51 9.4Q11.21 9 12.02 9ZM12.05 7.51Q10.82 7.51 9.78 8.11Q8.74 8.71 8.12 9.73Q7.51 10.75 7.51 11.98Q7.51 13.2 8.11 14.24Q8.71 15.29 9.74 15.9Q10.78 16.51 12 16.51Q13.22 16.51 14.26 15.91Q15.29 15.31 15.9 14.28Q16.51 13.25 16.51 12.02Q16.51 10.8 15.91 9.77Q15.31 8.74 14.29 8.12Q13.27 7.51 12.05 7.51ZM13.56 22.66Q14.02 22.66 14.4 22.4Q14.78 22.15 14.98 21.74L15.79 19.85L15.98 19.75Q16.42 19.49 16.85 19.22L18.91 19.49Q19.39 19.54 19.8 19.33Q20.21 19.13 20.45 18.72L22.03 15.98Q22.27 15.58 22.24 15.12Q22.2 14.66 21.94 14.28L20.69 12.6L20.69 11.42L21.94 9.74Q22.22 9.36 22.25 8.9Q22.27 8.45 22.03 8.04L20.45 5.28Q20.23 4.9 19.82 4.68Q19.42 4.46 18.94 4.51L16.85 4.78Q16.42 4.46 15.86 4.2L15.02 2.23Q14.83 1.82 14.45 1.56Q14.06 1.3 13.61 1.3L10.44 1.3Q9.98 1.3 9.59 1.56Q9.19 1.82 9 2.23L8.16 4.2L7.92 4.34Q7.73 4.44 7.55 4.54Q7.37 4.63 7.2 4.75L5.09 4.51Q4.63 4.44 4.21 4.64Q3.79 4.85 3.55 5.26L1.97 8.02Q1.73 8.4 1.76 8.87Q1.8 9.34 2.06 9.72L3.34 11.42L3.34 12.53L2.06 14.23Q1.78 14.62 1.75 15.07Q1.73 15.53 1.97 15.94L3.53 18.67Q3.77 19.08 4.18 19.28Q4.58 19.49 5.06 19.44L7.13 19.2Q7.8 19.63 8.14 19.8L8.98 21.72Q9.14 22.15 9.53 22.4Q9.91 22.66 10.39 22.66L13.56 22.66ZM7.08 17.66L4.8 17.9L3.22 15.1L4.58 13.27Q4.8 12.94 4.8 12.6Q4.78 12.31 4.78 11.95Q4.78 11.59 4.8 11.3Q4.8 10.92 4.58 10.61L3.22 8.74L4.85 5.95L7.15 6.24Q7.37 6.26 7.55 6.22Q7.73 6.17 7.87 6.07Q8.3 5.76 8.93 5.47Q9.29 5.28 9.43 4.94L10.39 2.78L13.61 2.81L14.52 4.94Q14.64 5.26 15.02 5.47Q15.55 5.71 16.1 6.1Q16.46 6.34 16.82 6.26L19.13 6.02L20.71 8.83L19.37 10.63Q19.1 10.92 19.15 11.33Q19.18 11.64 19.18 12.01Q19.18 12.38 19.15 12.67Q19.13 13.06 19.37 13.37L20.71 15.19L19.08 17.98L16.85 17.71Q16.66 17.69 16.46 17.72Q16.27 17.76 16.13 17.88Q15.6 18.26 15 18.53Q14.83 18.62 14.69 18.76Q14.54 18.89 14.47 19.06L13.56 21.14L10.34 21.12L9.46 19.03Q9.29 18.67 8.95 18.5Q8.47 18.29 7.85 17.88Q7.58 17.69 7.25 17.69Z"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 关闭/叉叉图标 -->
|
||||
<g v-else-if="name === 'x' || name === 'close'">
|
||||
<line x1="18" x2="6" y1="6" y2="18" />
|
||||
<line x1="6" x2="18" y1="6" y2="18" />
|
||||
</g>
|
||||
|
||||
<!-- 信息图标 -->
|
||||
<g v-else-if="name === 'info'">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<circle cx="12" cy="8" r="1" fill="currentColor" />
|
||||
</g>
|
||||
|
||||
<!-- 成功/勾选图标 -->
|
||||
<g v-else-if="name === 'check' || name === 'success'">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</g>
|
||||
|
||||
<!-- 错误/叉叉图标 -->
|
||||
<g v-else-if="name === 'error' || name === 'x-circle' || name === 'alert-circle'">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.1"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" stroke="currentColor" stroke-width="2" />
|
||||
</g>
|
||||
|
||||
<!-- 警告图标 -->
|
||||
<g v-else-if="name === 'warning' || name === 'alert-triangle'">
|
||||
<path
|
||||
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.1"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line x1="12" y1="9" x2="12" y2="13" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="12" cy="17" r="1" fill="currentColor" stroke="none" />
|
||||
</g>
|
||||
|
||||
<!-- 上一首图标 -->
|
||||
<g v-else-if="name === 'skip-back' || name === 'previous'" fill="currentColor" stroke="none" stroke-width="0">
|
||||
<path
|
||||
d="M9.07 10.82Q8.64 11.09 8.47 11.54Q8.3 12 8.47 12.46Q8.64 12.91 9.07 13.18L17.71 18.17Q18.17 18.41 18.64 18.31Q19.1 18.22 19.43 17.84Q19.75 17.47 19.75 16.97L19.75 7.01Q19.75 6.5 19.43 6.14Q19.1 5.78 18.62 5.69Q18.14 5.59 17.71 5.83L9.07 10.82ZM5.26 17.71Q5.66 17.71 5.95 17.42Q6.24 17.14 6.24 16.7L6.24 7.18Q6.24 6.77 5.95 6.47Q5.66 6.17 5.26 6.17Q4.85 6.17 4.55 6.47Q4.25 6.77 4.25 7.18L4.25 16.7Q4.25 17.14 4.55 17.42Q4.85 17.71 5.26 17.71Z"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 下一首图标 -->
|
||||
<g v-else-if="name === 'skip-forward' || name === 'next'" fill="currentColor" stroke="none" stroke-width="0">
|
||||
<path
|
||||
d="M6.29 5.83Q5.86 5.59 5.38 5.69Q4.9 5.78 4.57 6.14Q4.25 6.5 4.25 7.01L4.25 16.97Q4.25 17.47 4.57 17.84Q4.9 18.22 5.36 18.31Q5.83 18.41 6.29 18.17L14.9 13.18Q15.34 12.91 15.5 12.46Q15.67 12 15.5 11.54Q15.34 11.09 14.9 10.82L6.29 5.83ZM18.74 17.71Q19.15 17.71 19.45 17.42Q19.75 17.14 19.75 16.7L19.75 7.18Q19.75 6.77 19.45 6.47Q19.15 6.17 18.74 6.17Q18.34 6.17 18.05 6.47Q17.76 6.77 17.76 7.18L17.76 16.7Q17.76 17.14 18.05 17.42Q18.34 17.71 18.74 17.71Z"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 快退图标 -->
|
||||
<g v-else-if="name === 'rewind'">
|
||||
<polygon fill="currentColor" points="11,19 2,12 11,5" />
|
||||
<polygon fill="currentColor" points="22,19 13,12 22,5" />
|
||||
</g>
|
||||
|
||||
<!-- 快进图标 -->
|
||||
<g v-else-if="name === 'fast-forward'">
|
||||
<polygon fill="currentColor" points="13,19 22,12 13,5" />
|
||||
<polygon fill="currentColor" points="2,19 11,12 2,5" />
|
||||
</g>
|
||||
|
||||
<!-- 歌词图标 -->
|
||||
<g v-else-if="name === 'lyrics'">
|
||||
<!-- 音符基础形状 -->
|
||||
<path d="M9 18V5l12-2v13" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<circle cx="6" cy="18" fill="currentColor" r="3" />
|
||||
<circle cx="18" cy="16" fill="currentColor" r="3" />
|
||||
<!-- 歌词文本线条 -->
|
||||
<line
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.5"
|
||||
x1="2"
|
||||
x2="7"
|
||||
y1="8"
|
||||
y2="8"
|
||||
/>
|
||||
<line
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.5"
|
||||
x1="2"
|
||||
x2="9"
|
||||
y1="12"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.5"
|
||||
x1="2"
|
||||
x2="6"
|
||||
y1="16"
|
||||
y2="16"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 刷新图标 -->
|
||||
<g v-else-if="name === 'refresh' || name === 'rotate-cw'">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<polyline points="1 20 1 14 7 14" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</g>
|
||||
|
||||
<!-- 垃圾桶/删除图标 -->
|
||||
<g v-else-if="name === 'trash' || name === 'delete'">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</g>
|
||||
|
||||
<!-- 加载中图标 -->
|
||||
<g v-else-if="name === 'loader' || name === 'spinner'">
|
||||
<line x1="12" x2="12" y1="2" y2="6" />
|
||||
<line x1="12" x2="12" y1="18" y2="22" />
|
||||
<line x1="4.93" x2="7.76" y1="4.93" y2="7.76" />
|
||||
<line x1="16.24" x2="19.07" y1="16.24" y2="19.07" />
|
||||
<line x1="2" x2="6" y1="12" y2="12" />
|
||||
<line x1="18" x2="22" y1="12" y2="12" />
|
||||
<line x1="4.93" x2="7.76" y1="19.07" y2="16.24" />
|
||||
<line x1="16.24" x2="19.07" y1="7.76" y2="4.93" />
|
||||
</g>
|
||||
|
||||
<!-- 加号/添加图标 -->
|
||||
<g v-else-if="name === 'plus' || name === 'add'">
|
||||
<line x1="12" x2="12" y1="5" y2="19" />
|
||||
<line x1="5" x2="19" y1="12" y2="12" />
|
||||
</g>
|
||||
|
||||
<!-- 历史记录图标 -->
|
||||
<g v-else-if="name === 'history'">
|
||||
<path d="M12 2a10 10 0 1 0 10 10 10 10 0 0 0-10-10z" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</g>
|
||||
|
||||
<!-- 歌单/列表图标 -->
|
||||
<g v-else-if="name === 'playlist' || name === 'list' || name === 'order' || name === 'list-ordered'" fill="currentColor" stroke="none" stroke-width="0">
|
||||
<path
|
||||
d="M11.76 5.06Q12.07 5.06 12.3 4.85Q12.53 4.63 12.53 4.32Q12.53 4.01 12.3 3.79Q12.07 3.58 11.76 3.58L3.29 3.58Q2.98 3.58 2.75 3.79Q2.52 4.01 2.52 4.32Q2.52 4.63 2.75 4.85Q2.98 5.06 3.29 5.06L11.76 5.06ZM12.53 10.32Q12.53 10.01 12.3 9.79Q12.07 9.58 11.76 9.58L3.29 9.58Q2.98 9.58 2.75 9.79Q2.52 10.01 2.52 10.32Q2.52 10.63 2.75 10.85Q2.98 11.06 3.29 11.06L11.76 11.06Q12.07 11.06 12.3 10.85Q12.53 10.63 12.53 10.32ZM3.29 15.58Q2.98 15.58 2.75 15.79Q2.52 16.01 2.52 16.32Q2.52 16.63 2.75 16.85Q2.98 17.06 3.29 17.06L7.75 17.06Q8.06 17.06 8.28 16.85Q8.5 16.63 8.5 16.32Q8.5 16.01 8.28 15.79Q8.06 15.58 7.75 15.58L3.29 15.58Z"
|
||||
/>
|
||||
<path
|
||||
d="M18.36 3.89Q17.86 3.94 17.53 4.31Q17.21 4.68 17.21 5.21L17.23 6.91L17.23 13.13Q17.23 13.44 17.11 13.67Q16.99 13.9 16.75 14.09Q16.42 14.35 15.41 14.74L15.34 14.76Q14.14 15.24 13.38 15.74Q12.62 16.25 12.26 17.16Q12.05 17.76 12.13 18.36Q12.22 18.96 12.6 19.44Q13.1 20.06 13.94 20.3Q14.78 20.54 15.53 20.45Q17.35 20.23 18.04 19.01Q18.72 17.78 18.72 16.37L18.72 6.94Q18.77 6.53 18.92 6.38Q19.08 6.24 19.46 6.12Q20.09 5.88 20.38 5.69Q21.05 5.28 21.3 4.73Q21.55 4.18 21.41 3.5L18.36 3.89ZM15.36 18.98Q14.83 19.03 14.41 18.91Q13.99 18.79 13.78 18.53Q13.66 18.34 13.62 18.13Q13.58 17.93 13.68 17.69Q13.87 17.18 14.41 16.86Q14.95 16.54 15.89 16.15L15.96 16.13L16.1 16.06Q16.42 15.94 16.72 15.8Q17.02 15.67 17.21 15.55L17.21 16.63Q17.21 17.4 16.85 18.13Q16.49 18.86 15.36 18.98Z"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 登出/退出图标 -->
|
||||
<g v-else-if="name === 'logout' || name === 'log-out'">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" x2="9" y1="12" y2="12" />
|
||||
</g>
|
||||
|
||||
<!-- 下载图标 -->
|
||||
<g v-else-if="name === 'download'">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" x2="12" y1="15" y2="3" />
|
||||
</g>
|
||||
|
||||
<!-- 重播/循环图标 -->
|
||||
<g v-else-if="name === 'repeat'">
|
||||
<polyline points="17 1 21 5 17 9" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<polyline points="7 23 3 19 7 15" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</g>
|
||||
|
||||
<!-- 单曲循环图标 -->
|
||||
<g v-else-if="name === 'repeat-one'" fill="currentColor" stroke="none" stroke-width="0">
|
||||
<path
|
||||
d="M12.65 14.95Q12.94 14.95 13.09 14.78Q13.25 14.62 13.25 14.3L13.25 9.67Q13.25 9.12 12.89 9.05Q12.53 8.98 11.95 9.24L10.97 9.82Q10.61 10.01 10.54 10.2Q10.46 10.39 10.54 10.63Q10.58 10.8 10.81 10.86Q11.04 10.92 11.3 10.78L12.07 10.34L12.07 14.33Q12.07 14.64 12.22 14.8Q12.36 14.95 12.65 14.95Z"
|
||||
/>
|
||||
<path
|
||||
d="M16.66 7.27L19.63 7.27Q19.78 7.27 19.88 7.16Q19.99 7.06 19.99 6.91Q19.99 6.77 19.87 6.65L16.99 3.84Q16.9 3.72 16.75 3.72Q16.61 3.72 16.5 3.83Q16.39 3.94 16.39 4.08L16.39 5.76L7.32 5.76Q5.62 5.76 4.18 6.6Q2.74 7.44 1.9 8.88Q1.06 10.32 1.06 12Q1.06 13.63 1.85 15.05Q2.02 15.36 2.33 15.42Q2.64 15.48 2.88 15.34Q3.14 15.17 3.23 14.87Q3.31 14.57 3.17 14.3Q2.57 13.27 2.57 12Q2.57 10.7 3.2 9.62Q3.84 8.54 4.93 7.91Q6.02 7.27 7.32 7.27ZM22.15 8.95Q22.01 8.69 21.7 8.6Q21.38 8.52 21.12 8.66Q20.86 8.81 20.77 9.11Q20.69 9.41 20.83 9.67Q21.43 10.73 21.43 11.98Q21.43 13.27 20.8 14.36Q20.16 15.46 19.07 16.09Q17.98 16.73 16.68 16.73L7.34 16.73L4.37 16.73Q4.22 16.73 4.13 16.84Q4.03 16.94 4.03 17.09Q4.03 17.23 4.13 17.33L7.01 20.16Q7.1 20.26 7.25 20.26Q7.39 20.26 7.5 20.15Q7.61 20.04 7.61 19.9L7.61 18.22L16.68 18.22Q18.38 18.22 19.82 17.38Q21.26 16.54 22.1 15.1Q22.94 13.66 22.94 11.98Q22.94 10.32 22.15 8.95Z"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 搜索图标 -->
|
||||
<g v-else-if="name === 'search'">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" x2="16.65" y1="21" y2="16.65" />
|
||||
</g>
|
||||
|
||||
<!-- 上传图标 -->
|
||||
<g v-else-if="name === 'upload'">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" x2="12" y1="3" y2="15" />
|
||||
</g>
|
||||
|
||||
<!-- 云上传图标 -->
|
||||
<g v-else-if="name === 'cloud-upload'">
|
||||
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3" />
|
||||
<polyline points="16 16 12 12 8 16" />
|
||||
<line x1="12" y1="12" x2="12" y2="21" />
|
||||
</g>
|
||||
|
||||
<!-- 默认图标(如果没有匹配的图标) -->
|
||||
<circle v-else cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: [String, Number],
|
||||
default: 24
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'currentColor'
|
||||
},
|
||||
className: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
transition: all 0.2s ease;
|
||||
/* 确保SVG在所有设备上渲染一致 */
|
||||
shape-rendering: geometricPrecision;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div ref="containerEl" class="marquee-container">
|
||||
<div ref="contentEl" :class="{ scrolling }" class="marquee-content">
|
||||
<span class="text-item">{{ text }}</span>
|
||||
<span v-if="scrolling" class="text-item">{{ text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
speed: {
|
||||
type: Number,
|
||||
default: 40 // pixels per second
|
||||
},
|
||||
activated: {
|
||||
type: Boolean,
|
||||
default: true // 默认为true保持向后兼容性
|
||||
}
|
||||
})
|
||||
|
||||
const containerEl = ref(null)
|
||||
const contentEl = ref(null)
|
||||
const scrolling = ref(false)
|
||||
const isMobile = ref(false)
|
||||
let mql = null
|
||||
|
||||
const checkOverflow = async () => {
|
||||
if (!containerEl.value || !contentEl.value) return
|
||||
|
||||
// Reset scrolling to measure a single item's width.
|
||||
scrolling.value = false
|
||||
await nextTick()
|
||||
|
||||
if (!isMobile.value || !props.activated) {
|
||||
return // No scrolling on desktop or when not activated.
|
||||
}
|
||||
|
||||
const containerWidth = containerEl.value.offsetWidth
|
||||
const contentWidth = contentEl.value.scrollWidth
|
||||
|
||||
if (contentWidth > containerWidth) {
|
||||
scrolling.value = true
|
||||
await nextTick() // Wait for the second span to render.
|
||||
|
||||
// The animation scrolls by 50% of the total width.
|
||||
// This distance is equivalent to the width of the first span (including its padding).
|
||||
const scrollDistance = contentEl.value.firstElementChild.offsetWidth
|
||||
const animationDuration = scrollDistance / props.speed
|
||||
contentEl.value.style.setProperty('--duration', `${animationDuration}s`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleScreenChange = () => {
|
||||
if (mql) {
|
||||
isMobile.value = mql.matches
|
||||
}
|
||||
checkOverflow()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mql = window.matchMedia('(max-width: 768px)')
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
handleScreenChange() // Initial check once visible.
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.01 }
|
||||
)
|
||||
|
||||
if (containerEl.value) {
|
||||
observer.observe(containerEl.value)
|
||||
}
|
||||
|
||||
mql.addEventListener('change', handleScreenChange)
|
||||
window.addEventListener('resize', handleScreenChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mql) {
|
||||
mql.removeEventListener('change', handleScreenChange)
|
||||
}
|
||||
window.removeEventListener('resize', handleScreenChange)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.text,
|
||||
() => {
|
||||
checkOverflow()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.activated,
|
||||
() => {
|
||||
checkOverflow()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.marquee-container {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marquee-content {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.marquee-content.scrolling {
|
||||
animation: marquee var(--duration) linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.text-item {
|
||||
display: inline-block;
|
||||
padding-right: 50px; /* Space between repeated text */
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<transition name="notification">
|
||||
<div
|
||||
v-if="show"
|
||||
:class="{
|
||||
success: type === 'success',
|
||||
error: type === 'error',
|
||||
info: type === 'info'
|
||||
}"
|
||||
class="notification"
|
||||
>
|
||||
<div class="notification-icon">
|
||||
<Icon v-if="type === 'success'" :size="16" name="success" />
|
||||
<Icon v-else-if="type === 'error'" :size="16" name="error" />
|
||||
<Icon v-else :size="16" name="info" />
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button class="notification-close" @click="$emit('close')">
|
||||
<Icon :size="16" name="close" />
|
||||
</button>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div v-if="autoClose" class="notification-progress">
|
||||
<div :style="{ animationDuration: `${duration}ms` }" class="notification-progress-bar" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'info', // 'success', 'error', 'info'
|
||||
validator: (value) => ['success', 'error', 'info'].includes(value)
|
||||
},
|
||||
autoClose: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 3000 // 默认3秒后自动关闭
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 99999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
background: #21242d;
|
||||
color: #ffffff;
|
||||
max-width: 400px;
|
||||
min-width: 300px;
|
||||
font-family: 'MiSans', sans-serif;
|
||||
overflow: hidden; /* 确保进度条不会溢出 */
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.notification.info {
|
||||
border-left: 4px solid #0b5afe;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
margin-right: 16px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.success .notification-icon {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.error .notification-icon {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.info .notification-icon {
|
||||
background: rgba(11, 90, 254, 0.2);
|
||||
color: #0b5afe;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 12px;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.notification-close:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.notification-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.notification-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transform-origin: left center;
|
||||
animation: progress-shrink linear forwards;
|
||||
}
|
||||
|
||||
.success .notification-progress-bar {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.error .notification-progress-bar {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.info .notification-progress-bar {
|
||||
background-color: #0b5afe;
|
||||
}
|
||||
|
||||
@keyframes progress-shrink {
|
||||
0% {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
100% {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.notification-enter-active {
|
||||
animation: notification-in 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.notification-leave-active {
|
||||
animation: notification-out 0.3s cubic-bezier(0.55, 0.085, 0.68, 0.53);
|
||||
}
|
||||
|
||||
@keyframes notification-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(40px) scale(0.9);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-5px) scale(1.02);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<div class="notification-container">
|
||||
<TransitionGroup class="notification-list" name="notification-list" tag="div">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:class="{
|
||||
success: notification.type === 'success',
|
||||
error: notification.type === 'error',
|
||||
info: notification.type === 'info'
|
||||
}"
|
||||
class="notification-item"
|
||||
>
|
||||
<div class="notification-icon">
|
||||
<Icon v-if="notification.type === 'success'" :size="16" name="success" />
|
||||
<Icon v-else-if="notification.type === 'error'" :size="16" name="error" />
|
||||
<Icon v-else :size="16" name="info" />
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
{{ notification.message }}
|
||||
</div>
|
||||
<button class="notification-close" @click="removeToast(notification.id)">
|
||||
<Icon :size="16" name="close" />
|
||||
</button>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="notification-progress">
|
||||
<div
|
||||
:style="{
|
||||
animationDuration: `${notification.duration}ms`
|
||||
}"
|
||||
class="notification-progress-bar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
// 使用 useToast 的共享状态
|
||||
const { toasts, removeToast } = useToast()
|
||||
const notifications = toasts
|
||||
|
||||
// 全局挂载通知函数 (兼容旧代码)
|
||||
onMounted(() => {
|
||||
// 兼容旧的全局调用方式
|
||||
window.$showNotification = (message, type = 'info', autoClose = true, duration = 3000) => {
|
||||
const { showToast } = useToast()
|
||||
showToast(message, type, duration)
|
||||
}
|
||||
|
||||
window.$clearNotifications = () => {
|
||||
notifications.value = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 99999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
background: #21242d;
|
||||
color: #ffffff;
|
||||
max-width: 400px;
|
||||
min-width: 300px;
|
||||
font-family: 'MiSans', sans-serif;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.notification-item.success {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.notification-item.error {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.notification-item.info {
|
||||
border-left: 4px solid #0b5afe;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
margin-right: 16px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.success .notification-icon {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.error .notification-icon {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.info .notification-icon {
|
||||
background: rgba(11, 90, 254, 0.2);
|
||||
color: #0b5afe;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 12px;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-close:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.notification-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.notification-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transform-origin: left center;
|
||||
animation: progress-shrink linear forwards;
|
||||
}
|
||||
|
||||
.success .notification-progress-bar {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.error .notification-progress-bar {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.info .notification-progress-bar {
|
||||
background-color: #0b5afe;
|
||||
}
|
||||
|
||||
@keyframes progress-shrink {
|
||||
0% {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
100% {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 列表动画 */
|
||||
.notification-list-enter-active {
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.notification-list-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.55, 0.085, 0.68, 0.53);
|
||||
}
|
||||
|
||||
.notification-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
|
||||
.notification-list-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
.notification-list-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
.notification-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
|
||||
/* 移动动画 - 当通知被移除时,其他通知向上移动 */
|
||||
.notification-list-move {
|
||||
transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.notification-container {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
max-width: none;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.notification-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 暗色主题优化 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.notification-item {
|
||||
background: rgba(33, 36, 45, 0.95);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<Transition
|
||||
:mode="transitionMode"
|
||||
:name="transitionName"
|
||||
@enter="onEnter"
|
||||
@leave="onLeave"
|
||||
@before-enter="onBeforeEnter"
|
||||
@after-enter="onAfterEnter"
|
||||
@before-leave="onBeforeLeave"
|
||||
@after-leave="onAfterLeave"
|
||||
>
|
||||
<slot />
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 过渡状态
|
||||
const isTransitioning = ref(false)
|
||||
const previousRoute = ref(null)
|
||||
|
||||
// 计算过渡名称
|
||||
const transitionName = computed(() => {
|
||||
const currentPath = route.path
|
||||
const prevPath = previousRoute.value?.path
|
||||
|
||||
// 首页到后台的过渡
|
||||
if (prevPath === '/' && currentPath === '/dashboard') {
|
||||
return 'slide-left'
|
||||
}
|
||||
|
||||
// 后台到首页的过渡
|
||||
if (prevPath === '/dashboard' && currentPath === '/') {
|
||||
return 'slide-right'
|
||||
}
|
||||
|
||||
// 登录相关页面使用淡入淡出
|
||||
if (currentPath.includes('/login') || currentPath.includes('/register')) {
|
||||
return 'fade'
|
||||
}
|
||||
|
||||
// 其他页面使用默认过渡
|
||||
return 'page'
|
||||
})
|
||||
|
||||
const transitionMode = computed(() => {
|
||||
// 滑动过渡不需要mode,其他使用out-in
|
||||
if (transitionName.value.includes('slide')) {
|
||||
return undefined
|
||||
}
|
||||
return 'out-in'
|
||||
})
|
||||
|
||||
// 过渡事件处理
|
||||
const onBeforeEnter = (el) => {
|
||||
isTransitioning.value = true
|
||||
el.style.position = transitionName.value.includes('slide') ? 'absolute' : 'relative'
|
||||
|
||||
// 添加路由数据属性用于CSS选择器
|
||||
el.setAttribute('data-route', route.name || 'unknown')
|
||||
}
|
||||
|
||||
const onEnter = (el, done) => {
|
||||
// 强制重排以确保动画生效
|
||||
void el.offsetHeight
|
||||
done()
|
||||
}
|
||||
|
||||
const onAfterEnter = (el) => {
|
||||
isTransitioning.value = false
|
||||
el.style.position = 'relative'
|
||||
|
||||
// 清理数据属性
|
||||
el.removeAttribute('data-route')
|
||||
}
|
||||
|
||||
const onBeforeLeave = (el) => {
|
||||
isTransitioning.value = true
|
||||
el.style.position = transitionName.value.includes('slide') ? 'absolute' : 'relative'
|
||||
|
||||
// 添加路由数据属性
|
||||
el.setAttribute('data-route', route.name || 'unknown')
|
||||
}
|
||||
|
||||
const onLeave = (el, done) => {
|
||||
done()
|
||||
}
|
||||
|
||||
const onAfterLeave = (el) => {
|
||||
isTransitioning.value = false
|
||||
el.style.position = 'relative'
|
||||
|
||||
// 清理数据属性
|
||||
el.removeAttribute('data-route')
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
router.beforeEach((to, from) => {
|
||||
previousRoute.value = from
|
||||
})
|
||||
|
||||
// 暴露过渡状态
|
||||
defineExpose({
|
||||
isTransitioning
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 过渡容器样式 */
|
||||
.transition-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 确保滑动过渡时的容器样式 */
|
||||
:deep(.slide-left-enter-active),
|
||||
:deep(.slide-left-leave-active),
|
||||
:deep(.slide-right-enter-active),
|
||||
:deep(.slide-right-leave-active) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="progress-container">
|
||||
<div v-if="showPercentage || message" class="progress-info">
|
||||
<span v-if="message" class="progress-message">{{ message }}</span>
|
||||
<span v-if="showPercentage" class="progress-percentage">{{ percentage }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
:class="{ indeterminate: indeterminate }"
|
||||
:style="{ width: `${percentage}%` }"
|
||||
class="progress-fill"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="subMessage" class="progress-sub-message">{{ subMessage }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
percentage: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
validator: (value) => value >= 0 && value <= 100
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
subMessage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showPercentage: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
indeterminate: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress-container {
|
||||
margin: 15px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: #3490dc;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-fill.indeterminate {
|
||||
width: 30% !important;
|
||||
animation: progress-indeterminate 1.5s ease-in-out infinite;
|
||||
background-image: linear-gradient(to right, #3490dc, #38c172, #3490dc);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.progress-sub-message {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
@keyframes progress-indeterminate {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(300%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user