Files
teamspeak-music-bot/web/src/components/SongCard.vue
T
saopig1andClaude Sonnet 4.6 d5d6abeb1e feat: implement server password login, YouTube source, and improved bot selector UI
- **TS server password** (#7/#9): add serverPassword field across database,
  manager, bot API, and Settings UI — allows joining password-protected servers
- **YouTube audio source** (#1/#10): new YouTubeProvider using yt-dlp binary;
  adds -y flag in chat commands, /api/music supports platform=youtube,
  YouTube badge in SongCard, yt-dlp-wrap npm dependency
- **Bot selector UI** (#14): selector always visible (not just when >1 bot),
  bigger button with border and play-state indicator; per-bot URL routing at
  /bot/:id with BotRedirect view; copy-link button in dropdown

Closes #1, #7, #9, #10, #14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:58:07 +08:00

163 lines
3.4 KiB
Vue

<template>
<div class="song-card" :class="{ active }" @dblclick="$emit('play')">
<div class="song-index">{{ index }}</div>
<CoverArt :url="song.coverUrl" :size="36" :radius="6" />
<div class="song-info">
<div class="song-name-row">
<span class="song-name">{{ song.name }}</span>
<span
class="platform-badge"
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : '网易云' }}</span>
</div>
<div class="song-artist">{{ song.artist }}</div>
</div>
<div class="song-album">{{ song.album }}</div>
<div class="song-duration">{{ formatDuration(song.duration) }}</div>
<div class="song-actions">
<button class="action-btn" @click.stop="$emit('play')" title="播放">
<Icon icon="mdi:play" />
</button>
<button class="action-btn" @click.stop="$emit('add')" title="添加到队列">
<Icon icon="mdi:playlist-plus" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import CoverArt from './CoverArt.vue';
defineProps<{
song: { id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string };
index: number;
active?: boolean;
}>();
defineEmits<{
play: [];
add: [];
}>();
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
<style lang="scss" scoped>
.song-card {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: var(--radius-md);
transition: background var(--transition-fast);
cursor: pointer;
&:hover {
background: var(--hover-bg);
.song-actions { opacity: 1; }
}
&.active {
background: rgba(51, 94, 234, 0.1);
}
}
.song-index {
width: 24px;
text-align: center;
font-size: 13px;
color: var(--text-tertiary);
}
.song-info {
flex: 1;
min-width: 0;
}
.song-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.song-name {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.platform-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 1px 5px;
border-radius: 3px;
line-height: 1.4;
}
.badge-netease {
background: rgba(232, 17, 35, 0.15);
color: #e81123;
}
.badge-qq {
background: rgba(18, 183, 106, 0.15);
color: #12b76a;
}
.badge-bilibili {
background: rgba(0, 161, 214, 0.15);
color: #00a1d6;
}
.badge-youtube {
background: rgba(255, 0, 0, 0.12);
color: #ff0000;
}
.song-artist {
font-size: 12px;
color: var(--text-secondary);
}
.song-album {
width: 160px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-duration {
width: 48px;
font-size: 12px;
color: var(--text-tertiary);
text-align: right;
}
.song-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity var(--transition-fast);
}
.action-btn {
font-size: 18px;
padding: 4px;
border-radius: var(--radius-sm);
opacity: 0.7;
transition: opacity var(--transition-fast);
&:hover { opacity: 1; }
}
</style>