feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+293
View File
@@ -0,0 +1,293 @@
<template>
<section
class="app-loading-screen"
role="status"
aria-live="polite"
:aria-label="locale.ariaLabel"
>
<div class="top-progress-bar" :style="{ width: `${safeProgress}%` }" />
<WarpCanvas
:settings="warpSettings"
:is-accelerating="isAccelerating"
:current-progress="safeProgress / 100"
/>
<main class="loading-main">
<div class="progress-ring-wrap">
<div class="progress-ring-static" />
<svg class="progress-ring" viewBox="0 0 128 128">
<circle
cx="64" cy="64" r="54"
class="ring-track"
stroke-width="3"
fill="transparent"
/>
<circle
cx="64" cy="64" r="54"
class="ring-fill"
:class="{ accelerating: isAccelerating }"
stroke-width="4"
stroke-linecap="round"
fill="transparent"
:stroke-dasharray="circumference"
:stroke-dashoffset="ringOffset"
/>
</svg>
<div class="ring-label">
<span class="ring-percent" :key="safeProgress">{{ safeProgress }}%</span>
</div>
</div>
<div class="text-container">
<Transition name="fade-slide" mode="out-in">
<div class="text-content" :key="displayMessage">
<h2 class="loading-message">{{ displayMessage }}</h2>
</div>
</Transition>
<span class="phase-badge">{{ phaseLabel }}</span>
</div>
</main>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import WarpCanvas from './WarpCanvas.vue'
import { useLocale } from '~/utils/locale'
const { auth } = useLocale()
const locale = computed(() => auth.value?.appLoadingScreen || {})
const DEFAULT_PROGRESS = 8
const props = defineProps({
message: { type: String },
progress: { type: Number }
})
const displayMessage = computed(() => props.message || locale.value.message)
const safeProgress = computed(() => {
const normalized = Math.round(Number(props.progress ?? DEFAULT_PROGRESS) || 0)
return Math.min(100, Math.max(0, normalized))
})
const circumference = 2 * Math.PI * 54
const ringOffset = computed(() => circumference - (circumference * safeProgress.value) / 100)
const phaseLabel = computed(() => {
const p = safeProgress.value
if (p >= 100) return 'COMPLETE PHASE'
if (p >= 88) return 'FINALIZING PHASE'
if (p >= 82) return 'FALLBACK PHASE'
if (p >= 68) return 'CONTENT PHASE'
if (p >= 58) return 'SLOW NETWORK PHASE'
if (p >= 46) return 'AUTH PHASE'
if (p >= 28) return 'CONFIG PHASE'
return 'START PHASE'
})
const isAccelerating = ref(false)
watch(() => props.progress, () => {
isAccelerating.value = true
setTimeout(() => { isAccelerating.value = false }, 600)
})
const warpSettings = computed(() => ({
pattern: 'hyperdrive',
speedMultiplier: safeProgress.value >= 100 ? 0.3 : 1.3,
particleCount: 220,
glowEffect: true,
interactive: true
}))
</script>
<style scoped>
.app-loading-screen {
position: fixed;
inset: 0;
z-index: var(--z-app-loader, 9000);
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 0;
overflow: hidden;
color: var(--text-primary);
/* 兜底色:极端情况下 data-theme 未设置导致主题变量缺失时,仍保证加载页不透明,避免主页内容透出 */
background: var(--bg-primary, #000000);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
user-select: none;
}
.loading-main {
width: 100%;
max-width: 576px;
padding: 0 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
z-index: 10;
flex: 1;
}
.progress-ring-wrap {
position: relative;
width: 128px;
height: 128px;
margin-bottom: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.progress-ring-static {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid var(--overlay-5);
}
.progress-ring {
width: 112px;
height: 112px;
transform: rotate(-90deg);
}
.ring-track {
stroke: var(--overlay-5);
}
.ring-fill {
stroke: var(--app-loading-brand-primary);
filter: drop-shadow(0 0 8px var(--app-loading-primary-glow));
transition: stroke-dashoffset 360ms cubic-bezier(0.22, 1, 0.36, 1), filter 400ms ease;
}
.ring-fill.accelerating {
filter: drop-shadow(0 0 20px var(--app-loading-primary-glow-accelerating)) brightness(1.3);
}
.ring-label {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.ring-percent {
font-size: 26px;
font-weight: 700;
letter-spacing: normal;
color: var(--text-primary);
}
.text-container {
position: relative;
height: 80px;
width: 100%;
max-width: 448px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
}
.text-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.loading-message {
margin: 0;
font-size: 18px;
font-weight: 500;
letter-spacing: -0.025em;
color: var(--overlay-95);
text-wrap: balance;
}
@media (min-width: 640px) {
.loading-message {
font-size: 20px;
}
}
.phase-badge {
margin-top: 8px;
font-size: 10px;
font-family: ui-monospace, monospace;
letter-spacing: 0.2em;
color: var(--app-loading-text-color);
text-transform: uppercase;
}
.top-progress-bar {
position: absolute;
top: 0;
left: 0;
height: 3px;
z-index: 20;
background: linear-gradient(90deg, var(--app-loading-brand-primary), var(--app-loading-brand-secondary));
box-shadow: 0 0 12px var(--app-loading-primary-glow), 0 0 40px var(--app-loading-secondary-glow);
transition: width 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(8px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-8px);
}
@media (max-width: 640px) {
.progress-ring-wrap {
width: 112px;
height: 112px;
margin-bottom: 24px;
}
.progress-ring {
width: 96px;
height: 96px;
}
.ring-percent {
font-size: 24px;
}
.loading-message {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: none;
}
}
</style>
+268
View File
@@ -0,0 +1,268 @@
<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">
<AppSpinner :size="24" class="mb-3" />
<p>{{ locale.loading }}</p>
</div>
<div v-else-if="error" class="placeholder error">
<p>{{ error }}</p>
</div>
<div v-else class="placeholder">
<p>{{ locale.empty }}</p>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import { useLocale } from '~/utils/locale'
const { ui } = useLocale()
const locale = computed(() => ui.value?.lyrics || {})
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: 'var(--text-primary)' },
inactiveLineColor: { type: String, default: 'var(--overlay-60)' },
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: var(--overlay-5);
}
.lyric-line.active {
background: var(--overlay-8);
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px var(--mask-10);
}
.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: var(--overlay-70);
margin-top: 4px;
}
.lyric-line.active .translation-lyric {
color: var(--overlay-90);
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--overlay-60);
font-size: 16px;
}
.placeholder.error {
color: var(--color-error-light);
}
/* 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, var(--mask-80), transparent);
}
.custom-lyric-player::after {
bottom: 0;
background: linear-gradient(to top, var(--mask-80), transparent);
}
/* 深色主题适配 */
@media (prefers-color-scheme: dark) {
.placeholder {
color: var(--overlay-70);
}
}
/* 移动端适配 */
@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,86 @@
<template>
<audio
ref="audioPlayer"
:key="audioKey"
:src="audioSrc"
:crossorigin="crossOriginVal"
:referrerpolicy="referrerPolicyVal"
playsinline
preload="auto"
webkit-playsinline
@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)"
></audio>
</template>
<script setup>
import { ref, watch, nextTick, computed } 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 crossOriginVal = computed(() => {
return props.song?.musicPlatform === 'migu' ? null : 'anonymous'
})
const referrerPolicyVal = computed(() => {
return props.song?.musicPlatform === 'migu' ? null : 'no-referrer'
})
const audioSrc = computed(() => {
return props.song?.musicUrl || undefined
})
const audioKey = computed(() => {
// 当平台改变时,使用不同的 key 强制重新创建元素
return props.song?.musicPlatform || 'default'
})
// 错误消息常量,避免使用魔法字符串
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,317 @@
<template>
<div class="player-controls">
<div class="progress-container">
<div class="control-with-progress">
<!-- 上一首按钮 -->
<button
:disabled="!hasPrevious"
:title="hasPrevious ? locale.previous : locale.noPrevious"
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')"
>
<AppSpinner v-if="isLoadingTrack" :size="18" color="white" />
<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 ? locale.next : locale.noNext"
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 { computed, ref } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import { useLocale } from '~/utils/locale'
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 { ui } = useLocale()
const locale = computed(() => ui.value?.playerControls || {})
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: var(--overlay-10);
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: var(--overlay-20);
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: var(--overlay-15);
}
.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: var(--overlay-20);
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, var(--color-accent-light) 0%, var(--color-cyan) 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 var(--mask-20);
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: var(--overlay-80);
text-shadow: 0 1px 2px var(--mask-30);
width: 100%;
}
.current-time,
.duration {
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,190 @@
<template>
<div class="player-info">
<div class="cover-container" :title="locale.openLyrics" @click="openLyrics">
<template v-if="song.cover && !coverError">
<img
:src="convertToHttps(song.cover)"
:alt="locale.coverAlt"
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">{{ locale.lyrics }}</span>
</div>
</div>
<div class="player-text">
<h4>{{ song.title }}</h4>
<p>{{ song.artist }}</p>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { convertToHttps } from '~/utils/url'
import { useLocale } from '~/utils/locale'
const props = defineProps({
song: {
type: Object,
required: true
}
})
const emit = defineEmits(['openLyrics'])
const { ui } = useLocale()
const locale = computed(() => ui.value?.playerInfo || {})
const coverError = ref(false)
const handleImageError = () => {
coverError.value = true
}
const getFirstChar = (title) => {
if (!title) return locale.value.textCoverFallback
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;
--player-lyrics-overlay: rgba(0, 0, 0, 0.35);
}
.cover-container {
width: 48px;
height: 48px;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-collab-hover) 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: var(--player-lyrics-overlay);
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 var(--mask-50);
}
.player-cover {
width: 100%;
height: 100%;
object-fit: cover;
/* 添加辉光效果 */
box-shadow:
0 0 10px var(--overlay-30),
0 0 20px var(--overlay-20),
0 0 30px var(--overlay-10);
transition: box-shadow 0.3s ease;
}
.player-cover:hover {
box-shadow:
0 0 15px var(--overlay-40),
0 0 30px var(--overlay-30),
0 0 45px var(--overlay-20);
}
.text-cover {
width: 100%;
height: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-collab-hover) 100%);
color: white;
font-size: 18px;
font-weight: bold;
text-shadow: 0 1px 2px var(--mask-30);
}
.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 var(--mask-30);
}
.player-text p {
margin: 2px 0 0 0;
font-size: 12px;
color: var(--overlay-80);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 2px var(--mask-30);
}
</style>
@@ -0,0 +1,340 @@
<template>
<div
class="volume-control-container"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@wheel.prevent="handleWheel"
>
<!-- 弹出的音量条 (仅 PC 端 hover 或移动端点击时显示) -->
<Transition name="volume-slide">
<div v-show="showSlider" class="volume-slider-popup">
<div
ref="sliderRef"
class="volume-slider"
@mousedown="startDrag"
@touchstart.prevent="startTouchDrag"
@click="handleSliderClick"
>
<div class="volume-slider-bg">
<div
class="volume-slider-fill"
:style="{ height: `${volumePercentage}%` }"
/>
<div
class="volume-slider-thumb"
:style="{ bottom: `${volumePercentage}%` }"
:class="{ dragging: isDragging }"
/>
</div>
</div>
</div>
</Transition>
<!-- 底部按钮区 -->
<div
class="volume-btn"
:class="{ active: showSlider }"
@click="toggleMuteOrShowSlider"
>
<!-- 显示百分比或图标 -->
<Transition name="fade-icon" mode="out-in">
<span v-if="showSlider" class="volume-text sf-pro">
{{ Math.round(volumePercentage) }}%
</span>
<Icon
v-else
:name="volumeIcon"
:size="20"
:class="{ 'muted-icon': isMuted }"
/>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import Icon from '../Icon.vue'
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
const control = useAudioPlayerControl()
const isMobile = ref(false)
const showSlider = ref(false)
const sliderRef = ref<HTMLElement | null>(null)
const isDragging = ref(false)
const hideTimer = ref<number | null>(null)
const volumePercentage = computed(() => {
return (control.volume.value || 0) * 100
})
const isMuted = computed(() => {
return control.isMuted.value || control.volume.value === 0
})
const volumeIcon = computed(() => {
if (isMuted.value || control.volume.value === 0) return 'volume-mute'
return 'volume'
})
const checkMobile = () => {
if (import.meta.client) {
isMobile.value = window.innerWidth <= 768
}
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
document.removeEventListener('click', handleClickOutside)
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
}
})
// PC端 Hover 效果
const handleMouseEnter = () => {
if (isMobile.value || isDragging.value) return
if (hideTimer.value !== null) clearTimeout(hideTimer.value)
showSlider.value = true
}
const handleMouseLeave = () => {
if (isMobile.value || isDragging.value) return
hideTimer.value = window.setTimeout(() => {
showSlider.value = false
}, 300)
}
// 移动端点击处理
const toggleMuteOrShowSlider = (e: Event) => {
if (isMobile.value) {
e.stopPropagation()
showSlider.value = !showSlider.value
} else {
// PC端点击直接静音/取消静音
control.toggleMute()
}
}
// 滚轮控制音量
const handleWheel = (e: WheelEvent) => {
if (!showSlider.value && !isMobile.value) return
// 向上滚增加音量,向下滚减小音量
const delta = e.deltaY > 0 ? -0.05 : 0.05
control.setVolume(control.volume.value + delta)
}
// 拖拽控制
const updateVolumeFromEvent = (clientY: number) => {
if (!sliderRef.value) return
const rect = sliderRef.value.getBoundingClientRect()
// 计算相对于底部的百分比
const y = Math.max(0, Math.min(rect.height, rect.bottom - clientY))
const percentage = y / rect.height
control.setVolume(percentage)
}
const handleSliderClick = (e: MouseEvent) => {
updateVolumeFromEvent(e.clientY)
}
const startDrag = (e: MouseEvent) => {
isDragging.value = true
updateVolumeFromEvent(e.clientY)
const onMouseMove = (e: MouseEvent) => {
updateVolumeFromEvent(e.clientY)
}
const onMouseUp = () => {
isDragging.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
// 如果鼠标已经离开区域,隐藏 slider
if (!isMobile.value) {
hideTimer.value = window.setTimeout(() => {
showSlider.value = false
}, 500)
}
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
const startTouchDrag = (e: TouchEvent) => {
isDragging.value = true
updateVolumeFromEvent(e.touches[0].clientY)
const onTouchMove = (e: TouchEvent) => {
updateVolumeFromEvent(e.touches[0].clientY)
}
const onTouchEnd = () => {
isDragging.value = false
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd)
}
document.addEventListener('touchmove', onTouchMove, { passive: false })
document.addEventListener('touchend', onTouchEnd)
}
const handleClickOutside = (e: Event) => {
if (isMobile.value && showSlider.value) {
const target = e.target as HTMLElement
if (!target.closest('.volume-control-container')) {
showSlider.value = false
}
}
}
</script>
<style scoped>
.volume-control-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 39px;
height: 39px;
z-index: 100;
}
/* 底部按钮 */
.volume-btn {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary-60);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
border-radius: 50%;
background: transparent;
user-select: none;
}
.volume-btn:hover {
color: var(--text-primary);
transform: scale(1.1);
}
.volume-btn.active {
color: var(--text-primary);
background: var(--overlay-10);
border-radius: 20px;
}
.muted-icon {
color: var(--volume-control-muted-color);
}
.volume-text {
font-size: 12px;
font-weight: 600;
letter-spacing: -0.5px;
}
/* 图标/文本切换动画 */
.fade-icon-enter-active,
.fade-icon-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fade-icon-enter-from,
.fade-icon-leave-to {
opacity: 0;
transform: scale(0.8);
}
/* 弹出层 */
.volume-slider-popup {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 120px;
background: linear-gradient(135deg, var(--overlay-15), var(--overlay-5)),
var(--volume-control-panel-bg);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
border: 1px solid var(--overlay-10);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 30px var(--mask-30), inset 0 1px 0 var(--overlay-20);
padding: 12px 0;
}
/* 弹出动画 */
.volume-slide-enter-active {
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: bottom center;
}
.volume-slide-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: bottom center;
}
.volume-slide-enter-from,
.volume-slide-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(10px) scale(0.8);
}
/* 滑动条容器 */
.volume-slider {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
cursor: pointer;
touch-action: none;
}
.volume-slider-bg {
position: relative;
width: 6px;
height: 100%;
background: var(--overlay-20);
border-radius: 3px;
}
.volume-slider-fill {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background: linear-gradient(to top, var(--color-accent-light), var(--color-cyan));
border-radius: 3px;
transition: height 0.1s linear;
}
.volume-slider-thumb {
position: absolute;
left: 50%;
transform: translate(-50%, 50%);
width: 14px;
height: 14px;
background: var(--text-primary);
border-radius: 50%;
box-shadow: 0 2px 6px var(--mask-30);
transition: transform 0.2s ease, box-shadow 0.2s ease;
pointer-events: none;
}
.volume-slider-thumb.dragging,
.volume-slider:hover .volume-slider-thumb {
transform: translate(-50%, 50%) scale(1.2);
box-shadow: 0 4px 10px var(--mask-40);
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<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-bg-primary-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-primary-hover hover:bg-primary text-text-primary text-sm font-bold transition-all flex items-center gap-2"
:title="locale.openInNewTab"
@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>
{{ locale.openInBilibili }}
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-xl bg-bg-tertiary-80 hover:bg-bg-quaternary text-text-tertiary hover:text-text-primary transition-all"
:title="locale.close"
@click="close"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- iframe 容器 -->
<div class="flex-1 rounded-2xl overflow-hidden bg-bg-primary 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-text-tertiary">
<div class="text-center">
<Icon name="alert-circle" :size="48" class="mx-auto mb-4 opacity-20" />
<p class="text-sm font-bold">{{ locale.loadFailed }}</p>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import { useLocale } from '~/utils/locale'
const props = defineProps({
show: Boolean,
bvid: String,
page: {
type: Number,
default: 1
}
})
const emit = defineEmits(['close'])
const { ui } = useLocale()
const locale = computed(() => ui.value?.bilibiliIframe || {})
// 构建 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>
+76
View File
@@ -0,0 +1,76 @@
<template>
<span class="app-spinner-wrap" :class="{ 'app-spinner-wrap--with-label': label }">
<span
class="app-spinner"
:class="{ 'app-spinner--accent': !color }"
:style="spinnerStyle"
role="status"
:aria-label="label || '加载中'"
/>
<span v-if="label" class="app-spinner-label">{{ label }}</span>
</span>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
// 转圈直径(px)
size: { type: Number, default: 32 },
// 边框粗细(px),默认按尺寸自动计算
borderWidth: { type: Number, default: 0 },
// 可选文字(显示在转圈右侧,不随转圈旋转)
label: { type: String, default: '' },
// 自定义颜色(如播放器按钮旁的白色),不传则保持主题色
color: { type: String, default: '' }
})
const spinnerStyle = computed(() => {
const border = props.borderWidth || Math.max(2, Math.round(props.size / 10))
const style = {
width: `${props.size}px`,
height: `${props.size}px`,
borderWidth: `${border}px`
}
if (props.color) {
style.borderColor = `color-mix(in srgb, ${props.color} 30%, transparent)`
style.borderTopColor = props.color
}
return style
})
</script>
<style scoped>
.app-spinner-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.app-spinner {
display: inline-block;
flex-shrink: 0;
border-style: solid;
border-radius: 50%;
animation: app-spinner-rotate 0.8s linear infinite;
}
/* 默认主题色,仅未传 color 时生效 */
.app-spinner--accent {
border-color: var(--color-accent-alpha-20);
border-top-color: var(--color-accent);
}
.app-spinner-label {
font-size: 12px;
font-weight: 600;
color: var(--text-tertiary);
}
@keyframes app-spinner-rotate {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,25 @@
<template>
<div class="border border-panel-bg-dark rounded-2xl overflow-hidden">
<button
class="w-full flex items-center justify-between px-5 py-4 hover:bg-panel-bg-darkest-50 transition-colors"
@click="open = !open"
>
<span class="text-xs font-black text-text-muted uppercase tracking-widest">{{ title }}</span>
<ChevronDown :class="['w-4 h-4 text-text-tertiary transition-transform duration-200', open && 'rotate-180']" />
</button>
<div v-show="open" class="px-5 pb-5">
<slot />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ChevronDown } from '@lucide/vue'
defineProps({
title: { type: String, required: true }
})
const open = ref(false)
</script>
+273
View File
@@ -0,0 +1,273 @@
<template>
<div ref="containerRef" class="relative" :class="className">
<div
class="flex items-center gap-2 px-3 py-2 bg-bg-primary border rounded-lg transition-all select-none"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isOpen
? 'border-primary-50 bg-primary-hover-5 shadow-lg'
: 'border-border-secondary hover:border-border-tertiary'
]"
@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-primary' : 'text-text-disabled'"
>
{{ label }}
</span>
<span class="text-[11px] font-bold text-text-secondary truncate">{{ displayLabel }}</span>
</div>
<div class="transition-transform duration-200" :class="{ 'rotate-180': isOpen }">
<ChevronDown :size="12" :class="isOpen ? 'text-primary' : 'text-text-secondary'" />
</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-bg-primary border border-border-secondary rounded-lg shadow-2xl backdrop-blur-xl"
>
<div class="max-h-[200px] overflow-y-auto custom-scrollbar">
<button
v-for="option in normalizedOptions"
:key="option.value"
type="button"
class="w-full flex items-center justify-between px-3 py-2 rounded-md text-[11px] font-bold transition-all"
:class="[
isSelected(option)
? 'bg-primary-hover-10 text-primary'
: 'text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary-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'
import { useLocale } from '~/utils/locale'
const props = defineProps({
label: String,
modelValue: [String, Number, Object, Array],
value: [String, Number, Object, Array],
options: {
type: Array,
required: true
},
className: String,
labelKey: {
type: String,
default: 'label'
},
valueKey: {
type: String,
default: 'value'
},
placeholder: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
multiple: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'update:value', 'change'])
const isOpen = ref(false)
const containerRef = ref(null)
const dropdownRef = ref(null)
const dropdownStyle = ref({})
const { common } = useLocale()
const locale = computed(() => common.value || {})
const resolvedPlaceholder = computed(() => props.placeholder || locale.value?.selectPlaceholder || '请选择')
// 统一获取当前值
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(() => {
if (props.multiple) {
const selectedValues = Array.isArray(currentValue.value) ? currentValue.value : []
const labels = normalizedOptions.value
.filter((option) => selectedValues.includes(option.value))
.map((option) => option.label)
return labels.length > 0 ? labels.join('、') : props.placeholder
}
const selected = normalizedOptions.value.find((opt) => opt.value === currentValue.value)
return selected
? selected.label
: currentValue.value && typeof currentValue.value !== 'object'
? currentValue.value
: resolvedPlaceholder.value
})
const isSelected = (option) => {
if (props.multiple) {
return Array.isArray(currentValue.value) && currentValue.value.includes(option.value)
}
return option.value === currentValue.value
}
const updatePosition = () => {
if (!isOpen.value || !containerRef.value || !dropdownRef.value) return
const rect = containerRef.value.getBoundingClientRect()
const dropdownHeight = dropdownRef.value.offsetHeight || 200 // 预估高度
const windowHeight = window.innerHeight
const spaceBelow = windowHeight - rect.bottom
const spaceAbove = rect.top
let top = rect.bottom + 4
let transformOrigin = 'origin-top'
// 如果底部空间不足,并且顶部空间更充足或者能完全放下弹层,则向上展开
if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
top = rect.top - dropdownHeight - 4
transformOrigin = 'origin-bottom'
}
dropdownStyle.value = {
top: `${top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
minWidth: '120px',
transformOrigin
}
}
const toggleDropdown = async () => {
if (props.disabled) return
if (isOpen.value) {
isOpen.value = false
} else {
isOpen.value = true
// 等待下拉框渲染以便获取其实际高度
await nextTick()
// 此时元素已存在于 DOM 中,但可能尚未完全重绘,因此先触发一次计算
// 并通过 requestAnimationFrame 再次触发,确保计算出正确的高度
updatePosition()
requestAnimationFrame(updatePosition)
}
}
const selectOption = (option) => {
if (props.multiple) {
const selectedValues = Array.isArray(currentValue.value) ? [...currentValue.value] : []
const existingIndex = selectedValues.indexOf(option.value)
if (existingIndex >= 0) {
selectedValues.splice(existingIndex, 1)
} else {
selectedValues.push(option.value)
}
emit('update:modelValue', selectedValues)
emit('update:value', selectedValues)
emit('change', selectedValues)
return
}
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: var(--panel-bg-alt);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--panel-bg-hover);
}
</style>
+661
View File
@@ -0,0 +1,661 @@
<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-primary-hover-10 border border-primary-20 rounded-lg"
>
<span class="text-[10px] font-black text-primary uppercase tracking-widest"
>{{ formatLocale(locale.selectedItems, selectedRows.length) }}</span
>
<button
class="p-0.5 text-primary hover:text-primary 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-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-tertiary hover:text-text-primary 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>{{ locale.refreshData }}</span>
</button>
</slot>
</div>
</div>
<!-- 表格主容器 -->
<div
class="relative bg-bg-secondary-50 border border-border-secondary rounded-xl overflow-hidden shadow-2xl shadow-[0_25px_50px_var(--shadow-color)]"
>
<!-- 加载状态 -->
<div
v-if="loading"
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-bg-primary-60 backdrop-blur-[2px] animate-in fade-in duration-300"
>
<AppSpinner :size="40" class="mb-4" />
<span class="text-[10px] font-black text-text-tertiary uppercase tracking-widest">{{
resolvedLoadingText
}}</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-border-secondary bg-bg-secondary-50">
<th v-if="selectable" class="p-4 w-10">
<input
type="checkbox"
:checked="isAllSelected"
class="w-4 h-4 rounded border-border-tertiary bg-bg-primary text-primary-hover 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-text-disabled uppercase tracking-widest'
]"
:style="{ width: column.width }"
>
{{ column.title }}
</th>
<th
v-if="hasActions"
class="p-4 text-[10px] font-black text-text-disabled uppercase tracking-widest text-right"
>
{{ locale.actions }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-panel-bg-dark-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-text-secondary">
<Database :size="40" stroke-width="1" />
<span class="text-[10px] font-black uppercase tracking-widest"
>{{ locale.noData }}</span
>
</div>
</slot>
</td>
</tr>
<tr
v-for="(row, index) in data"
:key="getRowKey(row, index)"
class="group hover:bg-bg-tertiary-30 transition-colors cursor-default"
:class="{ 'bg-primary-hover-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-border-tertiary bg-bg-primary text-primary-hover 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-text-secondary']"
>
<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-text-tertiary hover:text-primary hover:bg-primary-10 rounded-xl transition-all"
:title="locale.edit"
>
<Edit2 :size="14" />
</button>
<button
class="p-2 text-text-tertiary hover:text-error hover:bg-error-10 rounded-xl transition-all"
:title="locale.delete"
>
<Trash2 :size="14" />
</button>
</slot>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动端卡片列表 -->
<div class="md:hidden divide-y divide-panel-bg-dark">
<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-text-secondary">
<Database :size="40" stroke-width="1" />
<span class="text-[10px] font-black uppercase tracking-widest">{{ locale.noData }}</span>
</div>
</slot>
</div>
<div
v-for="(row, index) in data"
:key="getRowKey(row, index)"
class="p-4 space-y-4 hover:bg-bg-tertiary-30 transition-colors"
:class="{ 'bg-primary-hover-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-border-tertiary bg-bg-primary text-primary-hover 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-text-primary">{{
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-text-tertiary hover:text-primary hover:bg-primary-10 rounded-xl transition-all"
>
<Edit2 :size="14" />
</button>
<button
class="p-2 text-text-tertiary hover:text-error hover:bg-error-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-text-disabled uppercase tracking-widest block">{{
column.title
}}</span>
<div class="text-xs font-bold text-text-tertiary">
<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'
import { useLocale } from '~/utils/locale'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
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 { common } = useLocale()
const locale = computed(() => common.value || {})
const resolvedLoadingText = computed(() => props.loadingText || locale.value.loadingData)
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: var(--mask-50);
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>
+291
View File
@@ -0,0 +1,291 @@
<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-bg-secondary-30 border border-dashed border-border-secondary rounded-3xl animate-in fade-in zoom-in duration-300"
>
<div class="relative mb-6">
<div class="absolute inset-0 blur-2xl bg-error-10 rounded-full" />
<div
class="relative flex items-center justify-center w-16 h-16 bg-bg-primary border border-error-30 rounded-2xl text-error shadow-xl shadow-[0_20px_25px_var(--shadow-color-deep)]"
>
<AlertCircle :size="32" stroke-width="1.5" />
</div>
</div>
<h3 class="text-xl font-black text-text-primary tracking-tight mb-2">{{ displayTitle }}</h3>
<p class="text-[10px] font-black text-text-tertiary uppercase tracking-widest max-w-xs mb-8">
{{ displayMessage }}
</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-bg-primary border border-border-secondary hover:border-primary-50 text-text-tertiary hover:text-text-primary text-[10px] font-black uppercase tracking-widest rounded-xl transition-all shadow-lg shadow-[0_10px_15px_var(--shadow-color)] disabled:opacity-50"
@click="handleRetry"
>
<RefreshCw :size="14" :class="{ 'animate-spin': retrying }" />
<span>{{ retrying ? locale.retrying : locale.retry }}</span>
</button>
<button
v-if="showDetails"
class="px-6 py-2.5 bg-bg-secondary-50 border border-border-secondary hover:border-border-tertiary text-text-tertiary hover:text-text-secondary text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
@click="toggleDetails"
>
{{ showErrorDetails ? locale.hideDetails : locale.showDetails }}
</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-bg-primary border border-border-secondary rounded-2xl">
<div class="flex items-center gap-2 mb-3">
<div class="w-2 h-2 rounded-full bg-error animate-pulse" />
<span class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
>{{ locale.debugInfo }}</span
>
</div>
<pre
class="text-[10px] font-mono text-text-tertiary leading-relaxed overflow-x-auto p-4 bg-bg-primary-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'
import { computed, ref } from 'vue'
import { useLocale } from '~/utils/locale'
const { ui } = useLocale()
const locale = computed(() => ui.value?.errorBoundary || {})
interface Props {
error?: Error | string | null
errorTitle?: string
errorMessage?: string
showDetails?: boolean
onRetry?: () => void | Promise<void>
}
const props = withDefaults(defineProps<Props>(), {
error: null,
errorTitle: undefined,
errorMessage: undefined,
showDetails: false,
onRetry: undefined
})
const hasError = computed(() => !!props.error)
const displayTitle = computed(() => props.errorTitle || locale.value.defaultTitle)
const displayMessage = computed(() => props.errorMessage || locale.value.defaultMessage)
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(locale.value.retryFailed, 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: var(--color-error);
margin-bottom: 16px;
}
.error-icon svg {
width: 100%;
height: 100%;
}
.error-title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary-lighter);
margin: 0 0 8px 0;
}
.error-message {
font-size: 16px;
color: var(--text-muted);
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, var(--color-indigo-hover), var(--color-collab-hover));
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, var(--color-indigo), var(--color-collab));
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: var(--overlay-10);
border: 1px solid var(--overlay-20);
border-radius: 8px;
color: var(--text-muted);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.details-btn:hover {
background: var(--overlay-15);
border-color: var(--overlay-30);
color: var(--text-primary-lighter);
}
.error-details {
margin-top: 24px;
padding: 16px;
background: var(--surface-card-bg-medium);
border: 1px solid var(--overlay-10);
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: var(--text-primary-lighter);
}
.error-details pre {
margin: 0;
font-size: 12px;
color: var(--text-muted);
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>
+23
View File
@@ -0,0 +1,23 @@
<template>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest">{{ label }}</label>
<input
:value="modelValue"
:type="type"
:placeholder="placeholder"
class="w-full bg-panel-bg-darkest border border-panel-bg-dark rounded-xl px-4 py-2.5 text-sm text-text-primary-light focus:outline-none focus:border-primary-30 placeholder:text-text-disabled"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script setup>
defineProps({
label: { type: String, required: true },
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
type: { type: String, default: 'text' }
})
defineEmits(['update:modelValue'])
</script>
+180
View File
@@ -0,0 +1,180 @@
<template>
<div
:class="{
'fixed inset-0 z-[9999] bg-bg-primary-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'">
<AppSpinner :size="48" />
</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-primary 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-primary 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-primary 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-text-primary tracking-tight">{{ title }}</h3>
<p class="text-[10px] font-black text-text-tertiary uppercase tracking-widest">{{ displayMessage }}</p>
<!-- 进度条 -->
<div v-if="showProgress" class="mt-8 space-y-2">
<div class="h-1.5 w-full bg-bg-secondary rounded-full overflow-hidden border border-border-secondary">
<div
class="h-full bg-primary transition-all duration-300 shadow-[0_0_10px_var(--primary-50)]"
:style="{ width: `${progress}%` }"
/>
</div>
<div class="flex justify-between items-center px-1">
<span class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
>{{ locale.progress }}</span
>
<span class="text-[10px] font-black text-primary 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-primary-10 border border-primary-20 rounded-lg"
>
<Check :size="12" class="text-primary" />
</div>
<div
v-else-if="index === currentStep"
class="w-6 h-6 flex items-center justify-center"
>
<AppSpinner :size="24" />
</div>
<div
v-else
class="flex items-center justify-center w-6 h-6 bg-bg-secondary border border-border-secondary rounded-lg"
>
<span class="text-[10px] font-black text-text-disabled">{{ index + 1 }}</span>
</div>
</div>
<span
class="text-[10px] font-black uppercase tracking-widest transition-colors"
:class="index === currentStep ? 'text-text-primary' : 'text-text-disabled'"
>
{{ step }}
</span>
</div>
</div>
</div>
<!-- 取消按钮 -->
<button
v-if="showCancel && onCancel"
class="mt-10 px-6 py-2 bg-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-tertiary hover:text-text-primary text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
@click="onCancel"
>
{{ locale.cancel }}
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { Check } from '@lucide/vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import { computed } from 'vue'
import { useLocale } from '~/utils/locale'
const { ui } = useLocale()
const locale = computed(() => ui.value?.loadingState || {})
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
}
const props = withDefaults(defineProps<Props>(), {
title: '',
message: undefined,
spinnerType: 'circle',
fullScreen: false,
showProgress: false,
progress: 0,
steps: () => [],
currentStep: 0,
showCancel: false,
onCancel: undefined
})
const displayMessage = computed(() => props.message || locale.value.defaultMessage)
</script>
<style scoped>
/* 保持必要的动画定义,如果 Tailwind 无法完全覆盖的话 */
@keyframes pulse-ring {
0% {
transform: scale(0.33);
opacity: 0;
}
80%,
100% {
opacity: 0;
}
50% {
opacity: 0.5;
}
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<template>
<div :class="['border rounded-2xl transition-all', enabled ? 'border-panel-bg-hover bg-panel-bg-darkest-30' : 'border-panel-bg-dark-50 bg-panel-bg-darkest-20']">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4">
<div class="flex items-center gap-3 min-w-0">
<div class="text-text-tertiary shrink-0">
<slot name="icon" />
</div>
<div class="min-w-0">
<p :class="['text-sm font-bold truncate', masterOff ? 'text-text-disabled' : 'text-text-secondary']">{{ name }}</p>
<p class="text-[11px] text-text-disabled truncate">{{ desc }}</p>
</div>
</div>
<button
type="button"
role="switch"
:aria-checked="enabled"
:disabled="masterOff"
:class="[enabled ? 'bg-primary' : 'bg-panel-bg-compact', masterOff ? 'opacity-40 cursor-not-allowed' : '']"
class="relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ml-3"
@click="toggle"
>
<span :class="enabled ? 'translate-x-4' : 'translate-x-0.5'" class="inline-block h-4 w-4 rounded-full bg-panel-bg-raised shadow-sm transition-transform duration-200 mt-0.5" />
</button>
</div>
<!-- 展开的配置表单 -->
<div v-if="enabled" class="px-5 pb-5 border-t border-panel-bg-dark-50 pt-4">
<slot />
</div>
</div>
</template>
<script setup>
const props = defineProps({
enabled: { type: Boolean, default: false },
masterOff: { type: Boolean, default: false },
name: { type: String, required: true },
desc: { type: String, default: '' }
})
const emit = defineEmits(['toggle'])
const toggle = () => {
if (!props.masterOff) {
emit('toggle', !props.enabled)
}
}
</script>
+167
View File
@@ -0,0 +1,167 @@
<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-text-secondary uppercase tracking-[0.2em] order-2 sm:order-1">
{{ formatLocale(locale.info, currentPage, totalPages) }}
<span v-if="totalItems !== null"> ({{ formatLocale(locale.totalItems, totalItems, displayItemName) }})</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-border-secondary flex items-center justify-center text-text-secondary hover:text-primary hover:border-primary-30 transition-all disabled:opacity-20 disabled:hover:text-text-secondary disabled:hover:border-border-secondary"
:title="locale.first"
@click="goToPage(1)"
>
<ChevronsLeft :size="18" />
</button>
<!-- 上一页 -->
<button
:disabled="currentPage === 1"
class="w-10 h-10 rounded-lg border border-border-secondary flex items-center justify-center text-text-secondary hover:text-primary hover:border-primary-30 transition-all disabled:opacity-20 disabled:hover:text-text-secondary disabled:hover:border-border-secondary"
:title="locale.prev"
@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-primary-hover text-text-primary shadow-[var(--primary-glow)]'
: 'border border-border-secondary text-text-secondary hover:text-primary hover:border-primary-30 shadow-none'
]"
@click="goToPage(page)"
>
{{ page }}
</button>
</div>
<!-- 下一页 -->
<button
:disabled="currentPage === totalPages"
class="w-10 h-10 rounded-lg border border-border-secondary flex items-center justify-center text-text-secondary hover:text-primary hover:border-primary-30 transition-all disabled:opacity-20 disabled:hover:text-text-secondary disabled:hover:border-border-secondary"
:title="locale.next"
@click="goToPage(currentPage + 1)"
>
<ChevronRight :size="18" />
</button>
<!-- 尾页 -->
<button
:disabled="currentPage === totalPages"
class="w-10 h-10 rounded-lg border border-border-secondary flex items-center justify-center text-text-secondary hover:text-primary hover:border-primary-30 transition-all disabled:opacity-20 disabled:hover:text-text-secondary disabled:hover:border-border-secondary"
:title="locale.last"
@click="goToPage(totalPages)"
>
<ChevronsRight :size="18" />
</button>
<!-- 跳转输入 -->
<div class="flex items-center ml-2 pl-2 border-l border-border-secondary">
<div class="relative group">
<input
v-model="jumpPageInput"
type="text"
class="w-12 h-10 bg-bg-primary border border-border-secondary rounded-lg text-center text-xs font-black text-text-secondary focus:outline-none focus:border-primary-50 transition-all"
:placeholder="locale.pagePlaceholder"
@keyup.enter="handleJump"
>
</div>
<button
class="ml-2 px-3 h-10 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary hover:text-text-primary text-[10px] font-black uppercase tracking-widest rounded-lg transition-all"
@click="handleJump"
>
{{ locale.jump }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight
} from '@lucide/vue'
import { useLocale } from '~/utils/locale'
const { ui } = useLocale()
const locale = computed(() => ui.value?.pagination || {})
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 displayItemName = computed(() => props.itemName || locale.value.defaultItemName)
// 计算要显示的页码
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,37 @@
<template>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest">{{ label }}</label>
<div class="relative">
<input
:value="modelValue"
:type="show ? 'text' : 'password'"
:placeholder="placeholder"
class="w-full bg-panel-bg-darkest border border-panel-bg-dark rounded-xl px-4 py-2.5 pr-10 text-sm text-text-primary-light focus:outline-none focus:border-primary-30 placeholder:text-text-disabled"
@input="$emit('update:modelValue', $event.target.value)"
>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-text-disabled hover:text-text-muted transition-colors"
@click="show = !show"
>
<EyeOff v-if="show" class="w-4 h-4" />
<Eye v-else class="w-4 h-4" />
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Eye, EyeOff } from '@lucide/vue'
defineProps({
label: { type: String, required: true },
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' }
})
defineEmits(['update:modelValue'])
const show = ref(false)
</script>
+155
View File
@@ -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-bg-secondary-95 backdrop-blur-xl border border-primary-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>
+473
View File
@@ -0,0 +1,473 @@
<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-text-tertiary" />
</div>
<input
:value="searchQuery"
type="text"
:placeholder="resolvedSearchPlaceholder"
class="block w-full pl-11 pr-11 py-2.5 bg-bg-primary border border-border-secondary rounded-2xl text-xs font-bold text-text-primary placeholder:text-text-disabled focus:outline-none focus:border-primary-50 focus:bg-primary-hover-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-text-tertiary hover:text-text-secondary 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-bg-primary border border-border-secondary rounded-2xl"
>
<input
type="date"
:value="filterValues[filter.key]?.start || ''"
class="bg-transparent border-none text-[10px] font-bold text-text-secondary focus:ring-0 px-2 py-1 w-28"
@input="updateDateRange(filter.key, 'start', $event.target.value)"
>
<span class="text-text-secondary text-[10px] font-black uppercase">{{ locale.to }}</span>
<input
type="date"
:value="filterValues[filter.key]?.end || ''"
class="bg-transparent border-none text-[10px] font-bold text-text-secondary 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-bg-primary border border-border-secondary rounded-2xl transition-all hover:border-border-tertiary"
:class="{ 'border-primary-50 bg-primary-hover-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-text-disabled uppercase tracking-widest leading-none"
>{{ filter.label }}</span
>
<span class="text-[11px] font-bold text-text-secondary truncate w-full text-left">{{
getMultiSelectLabel(filter)
}}</span>
</div>
<ChevronDown
:size="14"
class="text-text-tertiary 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-bg-secondary border border-border-secondary 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-bg-tertiary cursor-pointer group transition-colors"
>
<input
type="checkbox"
:checked="(filterValues[filter.key] || []).includes(option.value)"
class="w-3.5 h-3.5 rounded border-border-tertiary bg-bg-primary text-primary-hover focus:ring-0 focus:ring-offset-0 transition-all"
@change="toggleMultiSelectOption(filter.key, option.value)"
>
<span class="text-xs font-bold text-text-tertiary group-hover:text-text-primary">{{
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-bg-secondary border border-border-secondary hover:border-error-30 hover:bg-error-5 text-text-tertiary hover:text-error text-[10px] font-black uppercase tracking-widest rounded-2xl transition-all"
@click="clearAllFilters"
>
<RotateCcw :size="14" />
<span>{{ locale.clearFilters }}</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'
import CustomSelect from './CustomSelect.vue'
import { useLocale } from '~/utils/locale'
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 { common } = useLocale()
const locale = computed(() => common.value || {})
const resolvedSearchPlaceholder = computed(() => props.searchPlaceholder || locale.value?.searchPlaceholder || '搜索...')
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 || locale.value.selectPlaceholder
}
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 || locale.value.selectPlaceholder
if (selectedValues.length === 1) {
const option = filter.options.find((opt) => opt.value === selectedValues[0])
return option ? option.label : selectedValues[0]
}
return formatLocale(locale.value.selectedCount, 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>
+190
View File
@@ -0,0 +1,190 @@
<template>
<div
class="relative overflow-hidden p-6 bg-bg-secondary border border-border-secondary rounded-xl group hover:border-primary-30 transition-all duration-300 shadow-xl shadow-[0_20px_25px_var(--shadow-color)]"
:class="{ 'opacity-60 grayscale cursor-wait': isLoading }"
>
<!-- 背景装饰 -->
<div
class="absolute -right-4 -top-4 w-24 h-24 bg-primary-hover-5 blur-3xl rounded-full group-hover:bg-primary-hover-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-bg-primary border border-border-secondary group-hover:border-primary-30 group-hover:bg-primary-hover-5 transition-all duration-300"
:class="iconClass"
>
<component
:is="iconComponent"
:size="18"
class="text-text-tertiary group-hover:text-primary transition-colors"
/>
</div>
<span
class="text-[10px] font-black text-text-disabled uppercase tracking-widest group-hover:text-text-tertiary transition-colors"
>{{ label }}</span
>
</div>
<div class="space-y-1">
<div class="flex items-baseline gap-2">
<h3 class="text-3xl font-black text-text-primary 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-text-disabled group-hover:text-text-tertiary 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_var(--primary-20)]" 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-bg-secondary-40 backdrop-blur-[1px]"
>
<AppSpinner :size="24" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import {
Users,
Music,
Calendar,
Heart,
Bell,
TrendingUp,
TrendingDown,
Minus,
Database,
ShieldCheck,
Mail,
Zap,
Clock,
BarChart3,
MessageSquare
} from '@lucide/vue'
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-success-10 text-success border-success-20'
if (props.change < 0) return 'bg-error-10 text-error border-error-20'
return 'bg-bg-tertiary text-text-tertiary border-border-tertiary'
})
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 'var(--color-success)' // emerald-500
if (props.change < 0) return 'var(--color-error)' // red-500
return 'var(--color-accent-light)' // blue-500
}
const first = props.trendData[0]
const last = props.trendData[props.trendData.length - 1]
if (last > first) return 'var(--color-success)' // emerald-500
if (last < first) return 'var(--color-error)' // red-500
return 'var(--color-accent-light)' // blue-500
})
</script>
<style scoped></style>
+206
View File
@@ -0,0 +1,206 @@
<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-bg-primary-80 backdrop-blur-sm flex items-center justify-center p-4"
@click="handleOverlayClick"
>
<div
class="w-full max-w-md bg-bg-secondary border border-border-secondary 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-text-primary tracking-tight">{{ resolvedTitle }}</h4>
<p class="text-sm text-text-tertiary 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-bg-tertiary-50 border border-border-tertiary-50 rounded-xl px-4 py-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary 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-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-2xl transition-all uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading"
@click="handleCancel"
>
{{ resolvedCancelText }}
</button>
<button
class="flex-[2] px-6 py-4 text-text-primary 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 ? locale.processing : resolvedConfirmText }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { useLocale } from '~/utils/locale'
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('')
const { common } = useLocale()
const locale = computed(() => common.value || {})
const resolvedTitle = computed(() => props.title || locale.value?.confirmOperation || '确认操作')
const resolvedConfirmText = computed(() => props.confirmText || locale.value?.confirm || '确认')
const resolvedCancelText = computed(() => props.cancelText || locale.value?.cancel || '取消')
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-error-10 text-error border-error-20 shadow-[var(--error-glow-5)]'
case 'success':
return 'bg-success-10 text-success border-success-20 shadow-[var(--success-glow-5)]'
case 'info':
return 'bg-primary-10 text-primary border-primary-20 shadow-[var(--primary-glow-5)]'
case 'warning':
default:
return 'bg-warning-10 text-warning border-warning-20 shadow-[var(--warning-glow-5)]'
}
})
const confirmBtnClasses = computed(() => {
switch (props.type) {
case 'danger':
return 'bg-error hover:bg-error shadow-[var(--error-glow-20)]'
case 'success':
return 'bg-success hover:bg-success shadow-[var(--success-glow-20)]'
case 'info':
return 'bg-primary-hover hover:bg-primary shadow-[var(--primary-glow)]'
case 'warning':
default:
return 'bg-warning hover:bg-warning shadow-[var(--warning-glow-20)]'
}
})
</script>
<style scoped></style>
+619
View File
@@ -0,0 +1,619 @@
<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 === 'oauth-qq'"
fill="currentColor"
stroke="none"
stroke-width="0"
transform="scale(0.0234375)"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64z m210.5 612.4c-11.5 1.4-44.9-52.7-44.9-52.7 0 31.3-16.2 72.2-51.1 101.8 16.9 5.2 54.9 19.2 45.9 34.4-7.3 12.3-125.6 7.9-159.8 4-34.2 3.8-152.5 8.3-159.8-4-9.1-15.2 28.9-29.2 45.8-34.4-35-29.5-51.1-70.4-51.1-101.8 0 0-33.4 54.1-44.9 52.7-5.4-0.7-12.4-29.6 9.4-99.7 10.3-33 22-60.5 40.2-105.8-3.1-116.9 45.3-215 160.4-215 113.9 0 163.3 96.1 160.4 215 18.1 45.2 29.9 72.8 40.2 105.8 21.7 70.1 14.6 99.1 9.3 99.7z"
/>
</g>
<g
v-else-if="name === 'oauth-wechat'"
fill="currentColor"
stroke="none"
stroke-width="0"
transform="scale(0.0234375)"
>
<path
d="M512 0c282.768 0 512 229.232 512 512 0 282.768-229.232 512-512 512C229.232 1024 0 794.768 0 512 0 229.232 229.232 0 512 0z m140.272 400.128c-116.912 0-211.696 87.696-211.696 195.824 0 108.128 94.8 195.84 211.68 195.84 22.16 0 44.16-3.264 65.344-9.648l77.36 32.864c8.8 3.632 21.296-3.072 21.296-14.112l0.32-81.504C846.256 685.728 864 642.816 864 596c0.016-108.176-94.72-195.872-211.728-195.872zM413.36 208C273.392 208 160 312.96 160 442.368c0 56 21.296 107.36 56.672 147.664l0.4 97.536c0 13.296 15.04 21.376 25.472 17.008l92.576-39.296c24.688 7.44 54.432 11.52 81.76 11.52 1.184 0-13.6-37.376-13.6-81.2 0-131.2 114.976-235.648 239.152-235.648h-0.016c0.704 0 8.224 0.544 8.88 0.544C610.752 271.664 526.624 208 413.36 208z m167.136 279.936c15.792 0 28.608 13.184 28.608 29.392s-12.832 29.408-28.576 29.408l-0.032-0.032c-15.792 0-28.592-13.168-28.592-29.376 0-16.208 12.8-29.392 28.592-29.392z m145.376 0c15.76 0 28.608 13.184 28.608 29.392s-12.736 29.408-28.576 29.408l-0.032-0.032c-15.696 0-28.592-13.168-28.592-29.376 0-16.208 12.848-29.392 28.592-29.392z m-400.64-174.88c18.848 0 34.208 15.776 34.208 35.168 0 19.376-15.296 35.2-34.176 35.2l-0.016-0.016c-18.928 0-34.24-15.76-34.24-35.2 0-19.408 15.408-35.168 34.24-35.168z m174.048-0.016c18.912 0 34.176 15.792 34.176 35.184 0 19.376-15.312 35.184-34.176 35.184-18.88 0-34.192-15.76-34.192-35.2 0-18.56 14.032-33.776 31.76-35.072l2.432-0.096z"
/>
</g>
<g
v-else-if="name === 'oauth-alipay'"
fill="currentColor"
stroke="none"
stroke-width="0"
transform="scale(0.0234375)"
>
<path
d="M230.404452 576.536487C218.3168 586.264487 205.360974 600.466922 201.598887 618.520487 196.478887 643.18553 200.530365 674.061357 224.327235 698.281183 253.155061 727.64327 296.964452 735.679443 315.886191 737.059617 367.286539 740.777183 422.070539 715.288487 463.364452 686.215791 479.548104 674.795965 507.262887 651.86727 533.753322 616.494748 474.383583 585.84153 400.277148 551.938226 321.050713 555.255096 280.580452 556.946922 251.5968 565.33927 230.404452 576.536487ZM983.262609 712.080696C1009.463652 650.573913 1024 582.967652 1024 512 1024 229.687652 794.334609 0 512 0 229.665391 0 0 229.687652 0 512 0 794.334609 229.665391 1024 512 1024 682.384696 1024 833.491478 940.27687 926.630957 811.876174 838.633739 768.133565 693.604174 696.141913 604.271304 652.577391 561.641739 701.17287 498.621217 749.879652 427.430957 771.072 382.70887 784.361739 342.394435 789.437217 300.232348 780.822261 258.493217 772.274087 227.750957 752.729043 209.830957 733.139478 200.704 723.144348 190.21913 710.433391 182.628174 695.318261 183.340522 697.232696 183.830261 698.368 183.830261 698.368 183.830261 698.368 179.46713 690.843826 176.128 678.867478 174.413913 673.079652 173.256348 667.046957 172.78887 660.72487 172.432696 656.584348 172.343652 652.221217 172.588522 647.68 172.210087 639.977739 172.521739 631.897043 174.258087 623.616 178.44313 603.38087 187.080348 579.806609 209.430261 557.924174 258.493217 509.885217 324.207304 507.302957 358.244174 507.503304 408.665043 507.792696 496.283826 529.853217 570.056348 555.942957 590.491826 512.422957 603.603478 465.875478 612.062609 434.843826L305.307826 434.843826 305.307826 401.67513 462.825739 401.67513 462.825739 335.337739 272.13913 335.337739 272.13913 302.169043 462.825739 302.169043 462.825739 235.853913C462.825739 226.749217 464.62887 219.269565 479.410087 219.269565L554.028522 219.269565 554.028522 302.169043 761.321739 302.169043 761.321739 335.337739 554.028522 335.337739 554.028522 401.67513 719.849739 401.67513C719.849739 401.67513 703.198609 494.502957 651.130435 585.99513 766.686609 627.266783 929.257739 690.843826 983.262609 712.080696Z"
/>
</g>
<!-- 新浪微博、百度品牌路径来源:Simple Icons(CC0) -->
<g v-else-if="name === 'oauth-sina-weibo'" fill="currentColor" stroke="none">
<path
d="M10.098 20.323c-3.977.391-7.414-1.406-7.672-4.02-.259-2.609 2.759-5.047 6.74-5.441 3.979-.394 7.413 1.404 7.671 4.018.259 2.6-2.759 5.049-6.737 5.439zM9.05 17.219c-.384.616-1.208.884-1.829.602-.612-.279-.793-.991-.406-1.593.379-.595 1.176-.861 1.793-.601.622.263.82.972.442 1.592m1.27-1.627c-.141.237-.449.353-.689.253-.236-.09-.313-.361-.177-.586.138-.227.436-.346.672-.24.239.09.315.36.18.601zm.176-2.719c-1.893-.493-4.033.45-4.857 2.118-.836 1.704-.026 3.591 1.886 4.21 1.983.64 4.318-.341 5.132-2.179.8-1.793-.201-3.642-2.161-4.149m7.563-1.224c-.346-.105-.57-.18-.405-.615.375-.977.42-1.804 0-2.404-.781-1.112-2.915-1.053-5.364-.03 0 0-.766.331-.571-.271.376-1.217.315-2.224-.27-2.809-1.338-1.337-4.869.045-7.888 3.08C1.309 10.87 0 13.273 0 15.348c0 3.981 5.099 6.395 10.086 6.395 6.536 0 10.888-3.801 10.888-6.82 0-1.822-1.547-2.854-2.915-3.284zm1.908-5.092a3.1 3.1 0 0 0-2.96-.962.786.786 0 0 0-.616.932.79.79 0 0 0 .932.602 1.51 1.51 0 0 1 1.442.465c.376.421.466.977.316 1.473a.786.786 0 0 0 .51.992.813.813 0 0 0 .992-.512 3.11 3.11 0 0 0-.646-3.035zm2.418-2.195c-1.576-1.757-3.905-2.419-6.054-1.968a.91.91 0 0 0-.706 1.081.91.91 0 0 0 1.082.707 4.5 4.5 0 0 1 4.296 1.383 4.53 4.53 0 0 1 .947 4.416.91.91 0 0 0 .586 1.157c.479.165.991-.104 1.157-.586a6.39 6.39 0 0 0-1.338-6.235z"
/>
</g>
<g v-else-if="name === 'oauth-baidu'" fill="currentColor" stroke="none">
<path
d="M9.154 0C7.71 0 6.54 1.658 6.54 3.707c0 2.051 1.171 3.71 2.615 3.71 1.446 0 2.614-1.659 2.614-3.71C11.768 1.658 10.6 0 9.154 0m7.025.594C14.86.58 13.347 2.589 13.2 3.927c-.187 1.745.25 3.487 2.179 3.735 1.933.25 3.175-1.806 3.422-3.364.252-1.555-.995-3.364-2.362-3.674a1.2 1.2 0 0 0-.261-.03zM3.582 5.535a3 3 0 0 0-.156.008c-2.118.19-2.428 3.24-2.428 3.24-.287 1.41.686 4.425 3.297 3.864 2.617-.561 2.262-3.68 2.183-4.362-.125-1.018-1.292-2.773-2.896-2.75m16.534 1.753c-2.308 0-2.617 2.119-2.617 3.616 0 1.43.121 3.425 2.988 3.362s2.553-3.238 2.553-3.988c0-.745-.62-2.99-2.924-2.99m-8.264 2.478c-1.424.014-2.708.925-3.323 1.947-1.118 1.868-2.863 3.05-3.112 3.363-.25.309-3.61 2.116-2.864 5.42.746 3.301 3.365 3.237 3.365 3.237s1.93.19 4.171-.31c2.24-.495 4.17.123 4.17.123s5.233 1.748 6.665-1.616c1.43-3.364-.808-5.109-.808-5.109s-2.99-2.306-4.736-4.798c-1.072-1.665-2.348-2.268-3.528-2.257m-2.234 3.84 1.542.024v8.197H7.758c-1.47-.291-2.055-1.292-2.13-1.462-.072-.173-.488-.976-.268-2.343.635-2.049 2.447-2.196 2.447-2.196h1.81zm3.964 2.39v3.881c.096.413.612.488.612.488h1.614v-4.343h1.689v5.782h-3.915c-1.517-.39-1.59-1.465-1.59-1.465v-4.317zm-5.458 1.147c-.66.197-.978.708-1.05.928-.076.22-.247.78-.1 1.269.294 1.095 1.248 1.144 1.248 1.144h1.37v-3.34z"
/>
</g>
<g
v-else-if="name === 'oauth-douyin'"
fill="currentColor"
stroke="none"
stroke-width="0"
transform="scale(0.0234375)"
>
<path
d="M512.28125 62.28125C263.9375 62.28125 62.73125 263.65625 62.73125 512.1125 62.73125 760.625 263.9375 962 512.28125 962c248.23125 0 449.49375-201.375 449.49375-449.8875C961.775 263.65625 760.56875 62.28125 512.28125 62.28125z m212.34375 406.51875a198.1125 198.1125 0 0 1-115.3125-36.39375c0 51.8625 0 103.6125 0.45 155.475 0 6.91875 0.3375 13.8375-0.225 20.7a167.23125 167.23125 0 0 1-65.8125 120.7125c-23.0625 16.875-50.85 26.38125-79.59375 27.1125a174.825 174.825 0 0 1-43.03125-3.4875 150.525 150.525 0 0 1-56.8125-25.14375 154.51875 154.51875 0 0 1-64.96875-130.5 154.9125 154.9125 0 0 1 73.51875-127.06875 152.83125 152.83125 0 0 1 101.5875-21.375c0.05625 6.58125-0.16875 13.1625 0.1125 19.6875 0.50625 8.2125 0.05625 16.425 0.225 24.6375v41.90625A59.175 59.175 0 0 0 441.6875 534.05c-12.9375 3.0375-25.36875 8.55-35.60625 17.15625a61.26591797 61.26591797 0 0 0-15.13125 18.95625 72.16875 72.16875 0 0 0-7.2 40.21875c1.575 13.3875 7.14375 25.9875 16.03125 36.225 5.9625 7.0875 13.3875 12.7125 21.15 17.4375 21.375 12.09375 49.21875 10.8 69.8625-2.41875 19.96875-12.375 32.90625-35.26875 32.9625-58.8375V389.9375c0.3375-40.66875 0.05625-81.3375 0.225-121.95h83.925c-0.225 7.14375 0.675 14.2875 1.85625 21.31875h0.45c2.25 11.8125 6.6375 23.11875 12.0375 33.8625 8.55 16.5375 21.09375 31.33125 37.125 41.0625 0.95625 0.61875 1.6875 1.51875 2.475 2.30625 11.75625 7.3125 24.75 12.825 38.25 15.75 7.93125 1.74375 16.0875 2.3625 24.1875 2.5875 0 6.1875 0 12.375 0.1125 18.5625 0.3375 21.76875 0.1125 43.5375 0.225 65.3625z"
/>
</g>
<!-- 华为、小米品牌路径来源:Simple Icons(CC0) -->
<g v-else-if="name === 'oauth-huawei'" fill="currentColor" stroke="none">
<path
d="M3.67 6.14S1.82 7.91 1.72 9.78v.35c.08 1.51 1.22 2.4 1.22 2.4 1.83 1.79 6.26 4.04 7.3 4.55 0 0 .06.03.1-.01l.02-.04v-.04C7.52 10.8 3.67 6.14 3.67 6.14M9.65 18.6c-.02-.08-.1-.08-.1-.08l-7.38.26c.8 1.43 2.15 2.53 3.56 2.2.96-.25 3.16-1.78 3.88-2.3.06-.05.04-.09.04-.09zm.08-.78C6.49 15.63.21 12.28.21 12.28c-.15.46-.2.9-.21 1.3v.07c0 1.07.4 1.82.4 1.82.8 1.69 2.34 2.2 2.34 2.2.7.3 1.4.31 1.4.31.12.02 4.4 0 5.54 0 .05 0 .08-.05.08-.05v-.06c0-.03-.03-.05-.03-.05M9.06 3.19a3.42 3.42 0 0 0-2.57 3.15v.41c.03.6.16 1.05.16 1.05.66 2.9 3.86 7.65 4.55 8.65.05.05.1.03.1.03a.1.1 0 0 0 .06-.1c1.06-10.6-1.11-13.42-1.11-13.42-.32.02-1.19.23-1.19.23m8.299 2.27s-.49-1.8-2.44-2.28c0 0-.57-.14-1.17-.22 0 0-2.18 2.81-1.12 13.43.01.07.06.08.06.08.07.03.1-.03.1-.03.72-1.03 3.9-5.76 4.55-8.64 0 0 .36-1.4.02-2.34m-2.92 13.07s-.07 0-.09.05c0 0-.01.07.03.1.7.51 2.85 2 3.88 2.3 0 0 .16.05.43.06h.14c.69-.02 1.9-.37 3-2.26l-7.4-.25zm7.83-8.41c.14-2.06-1.94-3.97-1.94-3.98 0 0-3.85 4.66-6.67 10.8 0 0-.03.08.02.13l.04.01h.06c1.06-.53 5.46-2.77 7.28-4.54 0 0 1.15-.93 1.21-2.42m1.52 2.14s-6.28 3.37-9.52 5.55c0 0-.05.04-.03.11 0 0 .03.06.07.06 1.16 0 5.56 0 5.67-.02 0 0 .57-.02 1.27-.29 0 0 1.56-.5 2.37-2.27 0 0 .73-1.45.17-3.14"
/>
</g>
<g v-else-if="name === 'oauth-xiaomi'" fill="currentColor" stroke="none">
<path
d="M12 0C8.016 0 4.756.255 2.493 2.516.23 4.776 0 8.033 0 12.012s.23 7.235 2.494 9.497C4.757 23.77 8.017 24 12 24s7.243-.23 9.506-2.491S24 15.99 24 12.012c0-3.984-.233-7.243-2.502-9.504C19.234.252 15.978 0 12 0M4.906 7.405h5.624c1.47 0 3.007.068 3.764.827.746.746.827 2.233.83 3.676v4.54a.15.15 0 0 1-.152.147h-1.947a.15.15 0 0 1-.152-.148V11.83c-.002-.806-.048-1.634-.464-2.051-.358-.36-1.026-.441-1.72-.458H7.158a.15.15 0 0 0-.151.147v6.98a.15.15 0 0 1-.152.148H4.906a.15.15 0 0 1-.15-.148V7.554a.15.15 0 0 1 .15-.149m12.131 0h1.949a.15.15 0 0 1 .15.15v8.892a.15.15 0 0 1-.15.148h-1.949a.15.15 0 0 1-.151-.148V7.554a.15.15 0 0 1 .151-.149M8.92 10.948h2.046c.083 0 .15.066.15.147v5.352a.15.15 0 0 1-.15.148H8.92a.15.15 0 0 1-.152-.148v-5.352a.15.15 0 0 1 .152-.147"
/>
</g>
<!-- Gitee 品牌路径来源:Simple Icons(CC0) -->
<g v-else-if="name === 'oauth-gitee'" fill="currentColor" stroke="none">
<path
d="M11.984 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm6.09 5.333c.328 0 .593.266.592.593v1.482a.594.594 0 0 1-.593.592H9.777c-.982 0-1.778.796-1.778 1.778v5.63c0 .327.266.592.593.592h5.63c.982 0 1.778-.796 1.778-1.778v-.296a.593.593 0 0 0-.592-.593h-4.15a.59.59 0 0 1-.592-.592v-1.482a.593.593 0 0 1 .593-.592h6.815c.327 0 .593.265.593.592v3.408a4 4 0 0 1-4 4H5.926a.593.593 0 0 1-.593-.593V9.778a4.444 4.444 0 0 1 4.445-4.444h8.296Z"
/>
</g>
<!-- Gitea 品牌路径来源:gitea.com/assets/img/logo.svg -->
<g v-else-if="name === 'oauth-gitea'" stroke="none" stroke-width="0" transform="scale(0.0375)">
<path
d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12"
fill="var(--gitea-cutout)"
/>
<path
d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1"
fill="currentColor"
/>
<path
d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z"
fill="currentColor"
/>
</g>
<!-- 哔哩哔哩、快手品牌路径来源:Simple Icons(CC0) -->
<g v-else-if="name === 'oauth-bilibili'" fill="currentColor" stroke="none">
<path
d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"
/>
</g>
<g v-else-if="name === 'oauth-kuaishou'" fill="currentColor" stroke="none">
<path
d="M18.315 12.264c2.33 0 4.218 1.88 4.218 4.2V19.8c0 2.32-1.888 4.2-4.218 4.2h-6.202a4.218 4.218 0 0 1-4.023-2.938l-3.676 1.833a2.04 2.04 0 0 1-2.731-.903 2.015 2.015 0 0 1-.216-.907v-5.94a2.03 2.03 0 0 1 2.035-2.024 2.044 2.044 0 0 1 .919.218l3.673 1.85a4.218 4.218 0 0 1 4.02-2.925zm-.062 2.162h-6.078c-1.153 0-2.09.921-2.108 2.065v3.247c0 1.148.925 2.081 2.073 2.1h6.113c1.153 0 2.09-.922 2.109-2.065v-3.247a2.104 2.104 0 0 0-2.074-2.1zM4.18 15.72a.554.554 0 0 0-.555.542v3.734a.556.556 0 0 0 .798.496l.01-.004 3.463-1.756V17.51l-3.467-1.73a.557.557 0 0 0-.249-.06zM9.28 0a5.667 5.667 0 0 1 4.98 2.965 4.921 4.921 0 0 1 3.36-1.317c2.714 0 4.913 2.177 4.913 4.863 0 2.686-2.2 4.863-4.912 4.863a4.921 4.921 0 0 1-3.996-2.034 5.651 5.651 0 0 1-4.345 2.034c-3.131 0-5.67-2.546-5.67-5.687C3.61 2.546 6.149 0 9.28 0Zm8.34 3.926c-1.441 0-2.61 1.157-2.61 2.585s1.169 2.585 2.61 2.585c1.443 0 2.612-1.157 2.612-2.585s-1.169-2.585-2.611-2.585zM9.28 2.287a3.395 3.395 0 0 0-3.39 3.4c0 1.877 1.518 3.4 3.39 3.4a3.395 3.395 0 0 0 3.39-3.4c0-1.878-1.518-3.4-3.39-3.4z"
/>
</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 === 'disc'">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" 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 === 'maximize-2'">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</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 === 'thumbs-up-filled'" fill="currentColor" stroke="none">
<path
d="M7 10v12H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h3Zm2 12V10.65l3.12-7.02A2.75 2.75 0 0 1 17 6v3h2.59a3 3 0 0 1 2.96 3.49l-1.17 7A3 3 0 0 1 18.42 22H9Z"
/>
</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-ring'">
<path d="M10.268 21a2 2 0 0 0 3.464 0" />
<path d="M22 8c0-2.3-.8-4.3-2-6" />
<path
d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"
/>
<path d="M4 2C2.8 3.7 2 5.7 2 8" />
</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 === 'eye'">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
</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 === 'edit' || name === 'pencil'">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</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 === 'mic' || name === 'microphone'">
<rect width="8" height="12" x="8" y="3" rx="4" ry="4" />
<path d="M19 11a7 7 0 0 1-14 0" />
<line x1="12" x2="12" y1="18" y2="22" />
<line x1="8" x2="16" y1="22" y2="22" />
</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 === 'external-link'">
<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" />
</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>
<!-- 主题切换图标 -->
<g v-else-if="name === 'gift'" fill="currentColor" stroke="none" stroke-width="0" transform="scale(0.0234375)">
<path
d="M772.8 96v64l163.2 161.6-91.2 91.2c-12.8-11.2-27.2-16-43.2-16-36.8 0-65.6 28.8-65.6 65.6V800c0 35.2-28.8 64-64 64H352c-35.2 0-64-28.8-64-64V462.4c0-36.8-28.8-65.6-65.6-65.6-16 0-32 6.4-43.2 16l-91.2-91.2L249.6 160h40l1.6 1.6C336 228.8 420.8 272 512 272c91.2 0 176-41.6 220.8-110.4 0-1.6 1.6-1.6 1.6-1.6h38.4V96M291.2 96H256c-22.4 0-38.4 6.4-49.6 19.2L43.2 276.8c-25.6 25.6-25.6 65.6 0 89.6l94.4 94.4c11.2 11.2 27.2 17.6 41.6 17.6s30.4-6.4 41.6-17.6h1.6c1.6 0 1.6 0 1.6 1.6V800c0 70.4 57.6 128 128 128h320c70.4 0 128-57.6 128-128V462.4c0-1.6 0-1.6 1.6-1.6h1.6c11.2 11.2 27.2 17.6 41.6 17.6 16 0 30.4-6.4 41.6-17.6l94.4-94.4c25.6-25.6 25.6-65.6 0-89.6L819.2 115.2c-12.8-12.8-28.8-19.2-46.4-19.2h-40c-22.4 0-41.6 11.2-54.4 30.4-33.6 49.6-96 81.6-168 81.6s-134.4-33.6-168-81.6C332.8 107.2 312 96 291.2 96z"
/>
</g>
<!-- 语言/翻译图标 (文 / A) -->
<g
v-else-if="name === 'globe' || name === 'language' || name === 'translate'"
fill="currentColor"
stroke="none"
stroke-width="0"
>
<path
d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"
/>
</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>
@@ -0,0 +1,261 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="notification"
class="fixed inset-0 z-[10000] flex items-center justify-center overflow-y-auto bg-panel-bg-darkest-80 p-4 backdrop-blur-sm"
@keydown="handleKeydown"
>
<section
ref="dialogRef"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="contentId"
tabindex="-1"
class="my-auto flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-panel-bg-dark bg-panel-bg-deepest shadow-2xl"
>
<header class="relative border-b border-panel-bg-dark-50 px-5 py-5 sm:px-8 sm:py-6">
<div
class="absolute right-5 top-5 flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-bold sm:right-8 sm:top-6"
:class="
notification.read
? 'border-success-30 bg-success-10 text-success-light'
: 'border-warning-20 bg-warning-20 text-warning-light'
"
>
<span
class="h-2 w-2 rounded-full"
:class="notification.read ? 'bg-success-light' : 'bg-warning-light'"
/>
{{ notification.read ? locale.read : locale.unread }}
</div>
<div class="flex items-start gap-4 pr-20 sm:pr-24">
<div
class="mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-warning-20 bg-warning-20 text-warning-light"
aria-hidden="true"
>
<Icon name="bell-ring" :size="22" />
</div>
<div class="min-w-0 flex-1">
<span class="text-xs font-bold text-warning-light">{{ locale.label }}</span>
<h2
:id="titleId"
class="mt-1 break-words text-xl font-black text-text-primary-lighter sm:text-2xl"
>
{{ notification.title || locale.label }}
</h2>
<time
v-if="formattedCreatedAt"
:datetime="notification.createdAt"
class="mt-2 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-text-tertiary"
>
<span class="inline-flex items-center gap-1 font-semibold text-text-muted">
<Icon name="clock" :size="13" class="shrink-0" aria-hidden="true" />
{{ relativeCreatedAt }}
</span>
<span aria-hidden="true">&middot;</span>
<span>{{ formattedCreatedAt }}</span>
</time>
<p class="mt-2 flex items-center gap-1.5 text-xs text-text-muted">
<Icon name="user" :size="13" class="shrink-0" aria-hidden="true" />
<span>{{ locale.sender }}:{{ senderDisplayName }}</span>
</p>
</div>
</div>
</header>
<div
:id="contentId"
class="important-notification-content markdown-body min-h-0 flex-1 overflow-y-auto px-5 py-6 text-sm text-text-secondary sm:p-8 sm:text-base"
v-html="renderedMessage"
/>
<footer
class="flex flex-col items-end gap-3 border-t border-panel-bg-dark-50 bg-panel-bg-deepest-50 px-5 py-5 sm:flex-row sm:items-center sm:justify-end sm:px-8 sm:py-6"
>
<p
v-if="error"
class="w-full min-w-0 flex-1 break-words text-xs font-medium text-error"
role="alert"
>
{{ error }}
</p>
<button
ref="closeButtonRef"
type="button"
:disabled="closing"
class="inline-flex min-h-10 shrink-0 items-center justify-center gap-2 rounded-lg bg-primary px-8 py-2.5 text-xs font-black text-white transition-colors hover:bg-primary focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-panel-bg-deepest disabled:cursor-wait disabled:opacity-60"
@click="markAsReadAndClose"
>
<Icon v-if="closing" name="loader" :size="17" class="animate-spin" />
<Icon v-else name="check" :size="17" />
{{ closing ? locale.closing : locale.close }}
</button>
</footer>
</section>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import { useImportantNotification } from '~/composables/useImportantNotification'
import { useLocale } from '~/utils/locale'
import { renderMarkdown } from '~/utils/markdown'
const { notification, closing, error, markAsReadAndClose } = useImportantNotification()
const { importantNotification, currentLocale } = useLocale()
const locale = computed(() => importantNotification.value)
const dialogRef = ref(null)
const closeButtonRef = ref(null)
const nowTimestamp = ref(Date.now())
const titleId = 'important-notification-title'
const contentId = 'important-notification-content'
let previousFocusedElement = null
let previousBodyOverflow = ''
let relativeTimeTimer = null
const renderedMessage = computed(() => renderMarkdown(notification.value?.message || ''))
const senderDisplayName = computed(
() =>
notification.value?.sender?.name?.trim() ||
notification.value?.sender?.username?.trim() ||
locale.value.systemSender
)
const formattedCreatedAt = computed(() => {
if (!notification.value?.createdAt) return ''
const date = new Date(notification.value.createdAt)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat(currentLocale.value, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date)
})
const relativeCreatedAt = computed(() => {
if (!notification.value?.createdAt) return ''
const date = new Date(notification.value.createdAt)
if (Number.isNaN(date.getTime())) return ''
const elapsedSeconds = Math.max(0, Math.floor((nowTimestamp.value - date.getTime()) / 1000))
if (elapsedSeconds < 60) return locale.value.justNow
const elapsedMinutes = Math.floor(elapsedSeconds / 60)
if (elapsedMinutes < 60) return locale.value.minutesAgo(elapsedMinutes)
const elapsedHours = Math.floor(elapsedMinutes / 60)
if (elapsedHours < 24) return locale.value.hoursAgo(elapsedHours)
return locale.value.daysAgo(Math.floor(elapsedHours / 24))
})
const stopRelativeTimeClock = () => {
if (relativeTimeTimer) {
clearInterval(relativeTimeTimer)
relativeTimeTimer = null
}
}
const startRelativeTimeClock = () => {
if (!import.meta.client) return
stopRelativeTimeClock()
nowTimestamp.value = Date.now()
relativeTimeTimer = window.setInterval(() => {
nowTimestamp.value = Date.now()
}, 30_000)
}
const restorePageState = () => {
if (!import.meta.client) return
document.body.style.overflow = previousBodyOverflow
if (previousFocusedElement instanceof HTMLElement) {
previousFocusedElement.focus()
}
previousFocusedElement = null
}
watch(
() => notification.value?.id,
async (notificationId, previousId) => {
if (notificationId) {
startRelativeTimeClock()
if (!previousId) {
previousFocusedElement = document.activeElement
previousBodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
}
await nextTick()
closeButtonRef.value?.focus()
} else if (previousId) {
stopRelativeTimeClock()
restorePageState()
}
},
{ immediate: true, flush: 'post' }
)
const handleKeydown = (event) => {
if (event.key === 'Escape') {
event.preventDefault()
markAsReadAndClose()
return
}
if (event.key !== 'Tab' || !dialogRef.value) return
const focusableElements = dialogRef.value.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
if (!focusableElements.length) {
event.preventDefault()
dialogRef.value.focus()
return
}
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
}
}
onBeforeUnmount(() => {
stopRelativeTimeClock()
restorePageState()
})
</script>
<style scoped>
.important-notification-content :deep(:first-child) {
margin-top: 0;
}
.important-notification-content :deep(:last-child) {
margin-bottom: 0;
}
</style>
File diff suppressed because it is too large Load Diff
+140
View File
@@ -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>
+255
View File
@@ -0,0 +1,255 @@
<template>
<transition name="notification">
<div
v-if="show"
:class="['notification-item', type]"
class="notification"
role="alert"
>
<div class="notification-icon-badge">
<Icon v-if="type === 'success'" :size="15" name="success" />
<Icon v-else-if="type === 'error'" :size="15" name="error" />
<Icon v-else-if="type === 'warning'" :size="15" name="warning" />
<Icon v-else :size="15" name="info" />
</div>
<div class="notification-content">
{{ message }}
</div>
<button
class="notification-close"
type="button"
aria-label="关闭通知"
@click="$emit('close')"
>
<Icon :size="13" name="close" />
</button>
<div v-if="autoClose && duration > 0" 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',
validator: (value) => ['success', 'error', 'info', 'warning'].includes(value)
},
autoClose: {
type: Boolean,
default: true
},
duration: {
type: Number,
default: 3000
}
})
defineEmits(['close'])
</script>
<style scoped>
.notification {
position: fixed;
top: max(16px, env(safe-area-inset-top, 16px));
left: 50%;
transform: translateX(-50%);
z-index: 99999;
display: flex;
align-items: center;
max-width: min(480px, calc(100vw - 32px));
min-width: 0;
width: fit-content;
padding: 10px 14px 10px 12px;
border-radius: 14px;
background: var(--panel-bg);
color: var(--text-primary);
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13.5px;
line-height: 1.45;
box-shadow:
0 4px 6px -1px var(--mask-10, rgba(0, 0, 0, 0.05)),
0 10px 25px -3px var(--mask-30, rgba(0, 0, 0, 0.12)),
0 0 0 1px var(--border-color);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
overflow: hidden;
box-sizing: border-box;
will-change: transform, opacity;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.notification:hover {
box-shadow:
0 6px 10px -1px var(--mask-20, rgba(0, 0, 0, 0.08)),
0 16px 32px -4px var(--mask-40, rgba(0, 0, 0, 0.16)),
0 0 0 1px var(--border-color);
}
.notification-icon-badge {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
margin-right: 10px;
flex-shrink: 0;
}
.success .notification-icon-badge {
background: var(--success-light);
color: var(--color-success);
box-shadow: 0 0 10px var(--success-glow);
}
.error .notification-icon-badge {
background: var(--error-light);
color: var(--color-error);
box-shadow: 0 0 10px var(--error-glow);
}
.warning .notification-icon-badge {
background: var(--warning-light);
color: var(--color-warning);
box-shadow: 0 0 10px var(--warning-glow);
}
.info .notification-icon-badge {
background: var(--primary-light);
color: var(--primary);
box-shadow: 0 0 10px var(--primary-glow);
}
.notification-content {
flex: 1;
font-size: 13.5px;
font-weight: 500;
color: var(--text-primary);
word-break: break-word;
padding-right: 6px;
}
.notification-close {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
color: var(--text-tertiary);
background: transparent;
border: none;
cursor: pointer;
padding: 0;
margin-left: 6px;
flex-shrink: 0;
transition: all 0.18s ease;
}
.notification-close:hover {
color: var(--text-primary);
background: var(--overlay-10);
}
.notification-progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--overlay-5);
}
.notification-progress-bar {
height: 100%;
width: 100%;
transform-origin: left center;
animation: notification-progress-shrink linear forwards;
}
.success .notification-progress-bar {
background: var(--color-success);
}
.error .notification-progress-bar {
background: var(--color-error);
}
.warning .notification-progress-bar {
background: var(--color-warning);
}
.info .notification-progress-bar {
background: var(--primary);
}
@keyframes notification-progress-shrink {
0% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
.notification-enter-active {
transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.notification-leave-active {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.notification-enter-from {
opacity: 0;
transform: translate(-50%, -20px) scale(0.94);
}
.notification-enter-to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
.notification-leave-from {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
.notification-leave-to {
opacity: 0;
transform: translate(-50%, -12px) scale(0.96);
}
@media (max-width: 640px) {
.notification {
top: max(14px, env(safe-area-inset-top, 14px));
max-width: calc(100vw - 32px);
min-width: 0;
width: fit-content;
padding: 9px 13px 9px 10px;
border-radius: 12px;
font-size: 13px;
}
.notification-icon-badge {
width: 24px;
height: 24px;
border-radius: 6px;
margin-right: 8px;
}
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<template>
<div class="notification-container" aria-live="polite">
<TransitionGroup class="notification-list" name="notification-list" tag="div">
<div
v-for="notification in notifications"
:key="notification.id"
:class="['notification-item', notification.type]"
role="alert"
>
<div class="notification-icon-badge">
<Icon v-if="notification.type === 'success'" :size="15" name="success" />
<Icon v-else-if="notification.type === 'error'" :size="15" name="error" />
<Icon v-else-if="notification.type === 'warning'" :size="15" name="warning" />
<Icon v-else :size="15" name="info" />
</div>
<div class="notification-content">
{{ notification.message }}
</div>
<button
class="notification-close"
type="button"
aria-label="关闭通知"
@click="removeToast(notification.id)"
>
<Icon :size="13" name="close" />
</button>
<div v-if="notification.duration > 0" class="notification-progress">
<div
:style="{
animationDuration: `${notification.duration}ms`
}"
class="notification-progress-bar"
/>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import Icon from './Icon.vue'
import { useToast } from '~/composables/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: max(16px, env(safe-area-inset-top, 16px));
left: 0;
right: 0;
z-index: 99999;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 16px;
box-sizing: border-box;
}
.notification-list {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 100%;
}
.notification-item {
pointer-events: auto;
display: flex;
align-items: center;
position: relative;
max-width: min(480px, calc(100vw - 32px));
min-width: 0;
width: fit-content;
padding: 10px 14px 10px 12px;
border-radius: 14px;
background: var(--panel-bg);
color: var(--text-primary);
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13.5px;
line-height: 1.45;
box-shadow:
0 4px 6px -1px var(--mask-10, rgba(0, 0, 0, 0.05)),
0 10px 25px -3px var(--mask-30, rgba(0, 0, 0, 0.12)),
0 0 0 1px var(--border-color);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
overflow: hidden;
box-sizing: border-box;
will-change: transform, opacity;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.notification-item:hover {
box-shadow:
0 6px 10px -1px var(--mask-20, rgba(0, 0, 0, 0.08)),
0 16px 32px -4px var(--mask-40, rgba(0, 0, 0, 0.16)),
0 0 0 1px var(--border-color);
}
.notification-icon-badge {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
margin-right: 10px;
flex-shrink: 0;
}
.success .notification-icon-badge {
background: var(--success-light);
color: var(--color-success);
box-shadow: 0 0 10px var(--success-glow);
}
.error .notification-icon-badge {
background: var(--error-light);
color: var(--color-error);
box-shadow: 0 0 10px var(--error-glow);
}
.warning .notification-icon-badge {
background: var(--warning-light);
color: var(--color-warning);
box-shadow: 0 0 10px var(--warning-glow);
}
.info .notification-icon-badge {
background: var(--primary-light);
color: var(--primary);
box-shadow: 0 0 10px var(--primary-glow);
}
.notification-content {
flex: 1;
font-size: 13.5px;
font-weight: 500;
color: var(--text-primary);
word-break: break-word;
padding-right: 6px;
}
.notification-close {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
color: var(--text-tertiary);
background: transparent;
border: none;
cursor: pointer;
padding: 0;
margin-left: 6px;
flex-shrink: 0;
transition: all 0.18s ease;
}
.notification-close:hover {
color: var(--text-primary);
background: var(--overlay-10);
}
.notification-progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--overlay-5);
}
.notification-progress-bar {
height: 100%;
width: 100%;
transform-origin: left center;
animation: notification-progress-shrink linear forwards;
}
.success .notification-progress-bar {
background: var(--color-success);
}
.error .notification-progress-bar {
background: var(--color-error);
}
.warning .notification-progress-bar {
background: var(--color-warning);
}
.info .notification-progress-bar {
background: var(--primary);
}
@keyframes notification-progress-shrink {
0% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
.notification-list-enter-active {
transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.notification-list-leave-active {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.notification-list-enter-from {
opacity: 0;
transform: translateY(-20px) scale(0.94);
}
.notification-list-enter-to {
opacity: 1;
transform: translateY(0) scale(1);
}
.notification-list-leave-from {
opacity: 1;
transform: translateY(0) scale(1);
}
.notification-list-leave-to {
opacity: 0;
transform: translateY(-12px) scale(0.96);
}
.notification-list-move {
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
@media (max-width: 640px) {
.notification-container {
top: max(14px, env(safe-area-inset-top, 14px));
padding: 0 16px;
}
.notification-list {
gap: 8px;
}
.notification-item {
max-width: calc(100vw - 32px);
min-width: 0;
width: fit-content;
padding: 9px 13px 9px 10px;
border-radius: 12px;
font-size: 13px;
}
.notification-icon-badge {
width: 24px;
height: 24px;
border-radius: 6px;
margin-right: 8px;
}
}
</style>
+133
View File
@@ -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>
+92
View File
@@ -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: var(--panel-bg-hover);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--color-accent);
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, var(--color-accent), var(--color-success), var(--color-accent));
background-size: 200% 100%;
}
.progress-sub-message {
margin-top: 5px;
font-size: 12px;
color: var(--text-muted);
}
@keyframes progress-indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(300%);
}
}
</style>
File diff suppressed because it is too large Load Diff
+307
View File
@@ -0,0 +1,307 @@
<template>
<div class="absolute inset-0 z-0 bg-bg-primary overflow-hidden">
<canvas
ref="canvasRef"
class="w-full h-full block touch-none pointer-events-auto"
/>
<div
class="absolute inset-0 pointer-events-none"
:style="{ background: `radial-gradient(circle_at_center,transparent_45%,${glowColors.overlayEnd} 100%)` }"
/>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useTheme } from '~/composables/useTheme'
const props = defineProps({
settings: { type: Object, required: true },
isAccelerating: { type: Boolean, required: true },
currentProgress: { type: Number, required: true }
})
const { isDark } = useTheme()
const canvasRef = ref(null)
const settingsRef = ref(props.settings)
const isAcceleratingRef = ref(props.isAccelerating)
watch(() => props.settings, (val) => { settingsRef.value = val })
watch(() => props.isAccelerating, (val) => { isAcceleratingRef.value = val })
watch(isDark, () => {
loadBrandColors()
// 主题切换后重绘光晕颜色
glowColors.load()
// 主题切换后重新生成星星颜色
stars.length = 0
for (let i = 0; i < 250; i++) {
stars.push(initStar({ z: Math.random() * 1000 }))
}
})
const pointer = { x: 0, y: 0, targetX: 0, targetY: 0 }
// Canvas 动画所需的状态,提升为模块级以便主题切换时重绘
const stars = []
// Canvas 2D API 不识别 CSS 变量,此处从 CSS 变量动态读取实际色值
const BRAND_COLORS = {}
function loadBrandColors(root) {
root = root || document.documentElement
const cs = getComputedStyle(root)
const keys = [
'warp-canvas-indigo', 'warp-canvas-primary', 'warp-canvas-primary-light', 'brand-blue-light',
'brand-green', 'brand-green-light', 'brand-red', 'brand-pink',
'brand-teal', 'brand-orange', 'brand-yellow-light',
'text-primary', 'text-primary-lighter', 'brand-cyan',
]
for (const key of keys) {
const val = cs.getPropertyValue(`--${key}`).trim()
if (val) BRAND_COLORS[key] = toHexValue(val)
}
}
if (typeof document !== 'undefined') {
loadBrandColors()
}
/** 将任意 CSS 颜色值统一转为 hex (#RRGGBB) */
function toHexValue(color) {
if (color.startsWith('#')) {
// #RGB 展开为 #RRGGBB
if (color.length === 4) {
return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`
}
return color
}
// rgb(r, g, b) / rgba(r, g, b, a)
const m = color.match(/rgba?\(\s*([\d.]+)\s*[,%]\s*([\d.]+)\s*[,%]\s*([\d.]+)/)
if (m) {
const r = parseInt(m[1], 10)
const g = parseInt(m[2], 10)
const b = parseInt(m[3], 10)
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}
// 兜底:原样返回
return color
}
/** 将 hex 颜色转为 rgba 数组 [r, g, b, a] */
function hexToRgba(hex) {
let h = hex.replace('#', '')
if (h.length === 3) h = `${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`
const r = parseInt(h.substring(0, 2), 16)
const g = parseInt(h.substring(2, 4), 16)
const b = parseInt(h.substring(4, 6), 16)
return [r, g, b, 1]
}
/** 将 hex 颜色 + alpha 转为 rgba() 字符串 */
function hexWithAlpha(hex, alpha) {
const [r, g, b] = hexToRgba(hex)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
// 光晕颜色 — 统一从 CSS 变量 --app-loading-brand-primary 读取
const glowColors = {
color: '', // hex
overlayEnd: '', // 解析后的 rgba 字符串
load() {
const cs = getComputedStyle(document.documentElement)
const raw = cs.getPropertyValue('--app-loading-brand-primary').trim()
// 回退值与 SSR 默认主题(经典深色)的主色对齐
this.color = raw ? toHexValue(raw) : '#6366f1'
// 解析 --canvas-glow-overlay-end 的实际 rgba 值
const rawOverlay = cs.getPropertyValue('--canvas-glow-overlay-end').trim()
this.overlayEnd = rawOverlay || 'rgba(245, 245, 247, 0.7)'
}
}
if (typeof document !== 'undefined') {
glowColors.load()
}
function getRandomColor(base) {
const cold = ['warp-canvas-indigo', 'warp-canvas-primary', 'brand-blue-light', 'text-primary', 'warp-canvas-primary-light']
if (base === 'emerald') {
const greens = ['brand-green', 'brand-green-light', 'brand-cyan']
return toHex(greens[Math.floor(Math.random() * greens.length)])
}
if (base === 'cyberpunk') {
const pinks = ['brand-red', 'brand-pink', 'warp-canvas-primary', 'brand-teal']
return toHex(pinks[Math.floor(Math.random() * pinks.length)])
}
if (base === 'sunset') {
const warm = ['brand-orange', 'brand-red', 'text-primary-lighter', 'brand-yellow-light', 'brand-pink']
return toHex(warm[Math.floor(Math.random() * warm.length)])
}
return toHex(cold[Math.floor(Math.random() * cold.length)])
}
function toHex(color) {
// 已经是 hex 直接返回
if (color.startsWith('#')) return color
// 从映射表取(已统一为 hex)
return BRAND_COLORS[color] ?? glowColors.color
}
function initStar(partial) {
partial = partial || {}
return {
x: partial.x ?? (Math.random() - 0.5) * 1000,
y: partial.y ?? (Math.random() - 0.5) * 1000,
z: partial.z ?? Math.random() * 1000 + 10,
prevZ: partial.z ?? 1000,
color: getRandomColor('indigo'),
size: Math.random() * 1.5 + 1.2,
angle: Math.random() * Math.PI * 2,
}
}
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let animationId
let width = 0
let height = 0
const handleResize = () => {
const parent = canvas.parentElement
if (parent) {
width = parent.clientWidth
height = parent.clientHeight
canvas.width = width
canvas.height = height
}
}
const resizeObserver = new ResizeObserver(() => handleResize())
if (canvas.parentElement) {
resizeObserver.observe(canvas.parentElement)
}
handleResize()
for (let i = 0; i < 250; i++) {
stars.push(initStar({ z: Math.random() * 1000 }))
}
let speedFactor = 1.0
let thicknessFactor = 1.2
let glowFactor = 0.12
let radialGlowOpacity = 0.2
const handleMouseMove = (e) => {
const halfW = window.innerWidth / 2
const halfH = window.innerHeight / 2
pointer.targetX = (e.clientX - halfW) / halfW
pointer.targetY = (e.clientY - halfH) / halfH
}
const handleTouchMove = (e) => {
if (e.touches.length > 0) {
const touch = e.touches[0]
const halfW = window.innerWidth / 2
const halfH = window.innerHeight / 2
pointer.targetX = (touch.clientX - halfW) / halfW
pointer.targetY = (touch.clientY - halfH) / halfH
}
}
const handleMouseLeave = () => {
pointer.targetX = 0
pointer.targetY = 0
}
if (props.settings.interactive) {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('touchmove', handleTouchMove, { passive: true })
window.addEventListener('mouseleave', handleMouseLeave)
}
const tick = () => {
const currentSettings = settingsRef.value
const accelerating = isAcceleratingRef.value
const targetSpeedFactor = accelerating ? 3.2 : 1.0
const targetThicknessFactor = accelerating ? 2.2 : 1.2
const targetGlowFactor = accelerating ? 0.26 : 0.12
const targetRadialGlowOpacity = accelerating ? 0.42 : 0.2
speedFactor += (targetSpeedFactor - speedFactor) * 0.07
thicknessFactor += (targetThicknessFactor - thicknessFactor) * 0.07
glowFactor += (targetGlowFactor - glowFactor) * 0.07
radialGlowOpacity += (targetRadialGlowOpacity - radialGlowOpacity) * 0.07
pointer.x += (pointer.targetX - pointer.x) * 0.08
pointer.y += (pointer.targetY - pointer.y) * 0.08
ctx.save()
// 从 CSS 变量读取背景叠加色
const bgOverlay = getComputedStyle(document.documentElement)
.getPropertyValue('--canvas-bg-overlay').trim()
ctx.fillStyle = bgOverlay || 'rgba(7, 7, 9, 0.18)'
ctx.fillRect(0, 0, width, height)
const centerX = width / 2 + pointer.x * (width * 0.12)
const centerY = height / 2 + pointer.y * (height * 0.12)
const currentSpeed = 7 * currentSettings.speedMultiplier * speedFactor
stars.forEach((star) => {
star.prevZ = star.z
star.z -= currentSpeed
if (star.z <= 0) {
Object.assign(star, initStar({ z: 1000 }))
star.prevZ = star.z
}
const scaleFactor = 300
const screenX = (star.x / star.z) * scaleFactor + centerX
const screenY = (star.y / star.z) * scaleFactor + centerY
const prevScreenX = (star.x / star.prevZ) * scaleFactor + centerX
const prevScreenY = (star.y / star.prevZ) * scaleFactor + centerY
if (screenX < -200 || screenX > width + 200 || screenY < -200 || screenY > height + 200) return
const depthAlpha = Math.min(1, (1000 - star.z) / 800) * 0.85
ctx.strokeStyle = star.color
ctx.beginPath()
ctx.lineWidth = star.size * thicknessFactor * (1 - star.z / 1000)
ctx.globalAlpha = depthAlpha
ctx.moveTo(prevScreenX, prevScreenY)
ctx.lineTo(screenX, screenY)
ctx.stroke()
})
if (currentSettings.glowEffect) {
ctx.globalAlpha = glowFactor
const grad = ctx.createRadialGradient(centerX, centerY, 5, centerX, centerY, Math.max(width, height) * 0.75)
grad.addColorStop(0, hexWithAlpha(glowColors.color, radialGlowOpacity))
grad.addColorStop(0.5, hexWithAlpha(glowColors.color, 0.04))
grad.addColorStop(1, 'transparent')
ctx.fillStyle = grad
ctx.fillRect(0, 0, width, height)
}
ctx.restore()
animationId = requestAnimationFrame(tick)
}
animationId = requestAnimationFrame(tick)
onUnmounted(() => {
cancelAnimationFrame(animationId)
resizeObserver.disconnect()
if (props.settings.interactive) {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('mouseleave', handleMouseLeave)
}
})
})
</script>