mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Add mobile layout (mini player, bottom tab bar, responsive navbar), new Library view, redesigned bot dropdown with action buttons, and centralize colors/typography/shadows into CSS variables in variables.scss. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
647 lines
15 KiB
Vue
647 lines
15 KiB
Vue
<template>
|
||
<nav class="navbar frosted-glass">
|
||
<RouterLink to="/" class="logo">TSMusicBot</RouterLink>
|
||
|
||
<div class="nav-links">
|
||
<RouterLink to="/" class="nav-link" active-class="active">发现</RouterLink>
|
||
<RouterLink to="/search" class="nav-link" active-class="active">搜索</RouterLink>
|
||
<RouterLink to="/library" class="nav-link" active-class="active">音乐库</RouterLink>
|
||
<RouterLink to="/history" class="nav-link" active-class="active">播放历史</RouterLink>
|
||
</div>
|
||
|
||
<div class="nav-right">
|
||
<!-- Bot selector (always shown when at least one bot exists) -->
|
||
<div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
|
||
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
||
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
|
||
<span v-if="activeBot?.playing && !activeBot?.paused" class="bot-state-mini playing">▶</span>
|
||
<span v-else-if="activeBot?.paused" class="bot-state-mini paused">⏸</span>
|
||
<Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" />
|
||
</button>
|
||
<div v-if="dropdownOpen" class="bot-dropdown">
|
||
<div class="bot-dropdown-header">机器人</div>
|
||
<div
|
||
v-for="bot in store.bots"
|
||
:key="bot.id"
|
||
class="bot-card"
|
||
:class="{ active: bot.id === store.activeBotId }"
|
||
>
|
||
<div class="bot-card-head" @click="bot.connected ? selectBot(bot.id) : undefined">
|
||
<span class="bot-dot" :class="{ online: bot.connected }" />
|
||
<span class="bot-card-name">{{ bot.name }}</span>
|
||
<span v-if="bot.id === store.activeBotId" class="bot-current-badge">当前</span>
|
||
<span v-if="bot.playing && !bot.paused" class="bot-playing-badge">播放中</span>
|
||
<span v-else-if="bot.paused" class="bot-paused-badge">已暂停</span>
|
||
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
|
||
<span v-else class="bot-offline-badge">离线</span>
|
||
</div>
|
||
<div class="bot-card-controls">
|
||
<button
|
||
v-if="bot.connected"
|
||
class="bot-ctrl-btn danger"
|
||
:disabled="togglingBots[bot.id]"
|
||
@click.stop="togglePower(bot)"
|
||
>
|
||
<Icon icon="mdi:link-off" /> 断开
|
||
</button>
|
||
<button
|
||
v-else
|
||
class="bot-ctrl-btn primary"
|
||
:disabled="togglingBots[bot.id]"
|
||
@click.stop="togglePower(bot)"
|
||
>
|
||
<Icon icon="mdi:link-variant" /> 连接
|
||
</button>
|
||
<button
|
||
v-if="bot.playing || bot.paused"
|
||
class="bot-ctrl-btn"
|
||
:disabled="!bot.connected"
|
||
@click.stop="store.pause()"
|
||
>
|
||
<Icon icon="mdi:stop" /> 停止
|
||
</button>
|
||
<button
|
||
v-else
|
||
class="bot-ctrl-btn"
|
||
:disabled="!bot.connected"
|
||
@click.stop="store.resume()"
|
||
>
|
||
<Icon icon="mdi:play" /> 播放
|
||
</button>
|
||
<button
|
||
class="bot-ctrl-btn"
|
||
:disabled="!bot.connected || (!bot.playing && !bot.paused)"
|
||
@click.stop="store.next()"
|
||
title="下一首"
|
||
>
|
||
<Icon icon="mdi:skip-next" />
|
||
</button>
|
||
<button class="bot-ctrl-btn" @click.stop="copyBotLink(bot.id)" title="复制链接">
|
||
<Icon icon="mdi:link-variant" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<RouterLink to="/settings" class="settings-btn">
|
||
<Icon icon="mdi:cog" />
|
||
</RouterLink>
|
||
</div>
|
||
</nav>
|
||
|
||
<div v-if="linkDialog.open" class="link-dialog-backdrop" @click="closeLinkDialog">
|
||
<div class="link-dialog" @click.stop>
|
||
<div class="link-dialog-title">{{ linkDialog.name }} 的专属链接</div>
|
||
<div class="link-dialog-hint">选中文本并按 Ctrl/Cmd+C 复制,或点击下方按钮</div>
|
||
<input
|
||
ref="linkInputRef"
|
||
class="link-dialog-input"
|
||
:value="linkDialog.url"
|
||
readonly
|
||
@focus="($event.target as HTMLInputElement).select()"
|
||
/>
|
||
<div class="link-dialog-actions">
|
||
<button class="link-dialog-btn primary" @click="copyLinkFromDialog">
|
||
{{ linkDialog.copied ? '已复制' : '复制链接' }}
|
||
</button>
|
||
<button class="link-dialog-btn" @click="closeLinkDialog">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
|
||
import { Icon } from '@iconify/vue';
|
||
import { usePlayerStore } from '../stores/player.js';
|
||
|
||
const store = usePlayerStore();
|
||
const activeBot = computed(() => store.activeBot);
|
||
const dropdownOpen = ref(false);
|
||
const selectorRef = ref<HTMLElement | null>(null);
|
||
const togglingBots = ref<Record<string, boolean>>({});
|
||
const linkInputRef = ref<HTMLInputElement | null>(null);
|
||
const publicBaseUrl = ref<string | null>(null);
|
||
|
||
const linkDialog = reactive({
|
||
open: false,
|
||
url: '',
|
||
name: '',
|
||
copied: false,
|
||
});
|
||
|
||
function selectBot(id: string) {
|
||
store.setActiveBotId(id);
|
||
dropdownOpen.value = false;
|
||
}
|
||
|
||
function resolveBaseUrl(): string {
|
||
const base = publicBaseUrl.value;
|
||
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
|
||
return window.location.origin;
|
||
}
|
||
|
||
async function tryClipboardWrite(text: string): Promise<boolean> {
|
||
try {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(text);
|
||
return true;
|
||
}
|
||
} catch {
|
||
// fall through
|
||
}
|
||
try {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
ta.style.pointerEvents = 'none';
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
ta.select();
|
||
const ok = document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
return ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function copyBotLink(id: string) {
|
||
const bot = store.bots.find((b) => b.id === id);
|
||
const url = `${resolveBaseUrl()}/bot/${id}`;
|
||
linkDialog.url = url;
|
||
linkDialog.name = bot?.name ?? '机器人';
|
||
linkDialog.copied = false;
|
||
linkDialog.open = true;
|
||
dropdownOpen.value = false;
|
||
// Try to copy silently; user can still manually select if it fails.
|
||
const ok = await tryClipboardWrite(url);
|
||
if (ok) linkDialog.copied = true;
|
||
await nextTick();
|
||
linkInputRef.value?.focus();
|
||
linkInputRef.value?.select();
|
||
}
|
||
|
||
async function copyLinkFromDialog() {
|
||
const ok = await tryClipboardWrite(linkDialog.url);
|
||
if (ok) {
|
||
linkDialog.copied = true;
|
||
} else {
|
||
linkInputRef.value?.focus();
|
||
linkInputRef.value?.select();
|
||
}
|
||
}
|
||
|
||
function closeLinkDialog() {
|
||
linkDialog.open = false;
|
||
}
|
||
|
||
async function loadPublicBaseUrl() {
|
||
try {
|
||
const res = await fetch('/api/config/public-url');
|
||
if (!res.ok) return;
|
||
const data = (await res.json()) as { publicUrl?: string | null };
|
||
if (data.publicUrl) publicBaseUrl.value = data.publicUrl;
|
||
} catch {
|
||
// ignore — fall back to window.location.origin
|
||
}
|
||
}
|
||
|
||
async function togglePower(bot: { id: string; connected: boolean; name: string }) {
|
||
if (togglingBots.value[bot.id]) return;
|
||
togglingBots.value[bot.id] = true;
|
||
try {
|
||
if (bot.connected) {
|
||
await store.stopBotInstance(bot.id);
|
||
} else {
|
||
await store.startBotInstance(bot.id);
|
||
}
|
||
} catch (err) {
|
||
console.error(`Failed to toggle bot ${bot.name}`, err);
|
||
} finally {
|
||
togglingBots.value[bot.id] = false;
|
||
}
|
||
}
|
||
|
||
function onClickOutside(e: MouseEvent) {
|
||
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
|
||
dropdownOpen.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('click', onClickOutside);
|
||
loadPublicBaseUrl();
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('click', onClickOutside);
|
||
});
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.navbar {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: var(--navbar-height);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 10vw;
|
||
z-index: 100;
|
||
border-bottom: 1px solid var(--border-color);
|
||
|
||
@media (max-width: 1336px) {
|
||
padding: 0 5vw;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
padding: 0 16px;
|
||
height: 52px;
|
||
}
|
||
}
|
||
|
||
.logo {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: var(--color-primary);
|
||
margin-right: 40px;
|
||
|
||
@media (max-width: 768px) {
|
||
font-size: 17px;
|
||
margin-right: 0;
|
||
}
|
||
}
|
||
|
||
.nav-links {
|
||
display: flex;
|
||
gap: 24px;
|
||
|
||
@media (max-width: 768px) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.nav-link {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
opacity: 0.6;
|
||
transition: opacity var(--transition-fast);
|
||
|
||
&:hover { opacity: 0.8; }
|
||
&.active { opacity: 1; color: var(--color-primary); }
|
||
}
|
||
|
||
.nav-right {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.bot-status {
|
||
padding: 4px 12px;
|
||
background: var(--hover-bg);
|
||
border-radius: var(--radius-sm);
|
||
font-size: 12px;
|
||
opacity: 0.6;
|
||
|
||
&.online {
|
||
background: var(--color-primary-15);
|
||
color: var(--color-primary);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* Bot selector dropdown */
|
||
.bot-selector {
|
||
position: relative;
|
||
}
|
||
|
||
.bot-selector-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 20px;
|
||
background: var(--hover-bg);
|
||
border-radius: var(--radius-md);
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
min-height: 44px;
|
||
border: 1px solid var(--border-color);
|
||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||
cursor: pointer;
|
||
|
||
&:hover {
|
||
background: var(--bg-card);
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
min-height: 32px;
|
||
gap: 6px;
|
||
border-radius: var(--radius-full);
|
||
}
|
||
}
|
||
|
||
.bot-state-mini {
|
||
font-size: 14px;
|
||
&.playing { color: var(--color-online); }
|
||
&.paused { color: var(--color-paused); }
|
||
|
||
@media (max-width: 768px) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.bot-chevron {
|
||
font-size: 20px;
|
||
opacity: 0.5;
|
||
transition: transform 0.2s ease;
|
||
|
||
&.rotated {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.bot-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
background: var(--text-tertiary);
|
||
flex-shrink: 0;
|
||
|
||
&.online {
|
||
background: var(--color-online);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
}
|
||
|
||
.bot-selector-name {
|
||
max-width: 160px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
|
||
@media (max-width: 768px) {
|
||
max-width: 80px;
|
||
}
|
||
}
|
||
|
||
.bot-dropdown {
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
right: 0;
|
||
min-width: 320px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-md);
|
||
padding: 6px;
|
||
box-shadow: var(--shadow-dropdown);
|
||
z-index: 200;
|
||
|
||
@media (max-width: 768px) {
|
||
position: fixed;
|
||
top: 52px;
|
||
left: 8px;
|
||
right: 8px;
|
||
min-width: auto;
|
||
}
|
||
}
|
||
|
||
.bot-dropdown-header {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-tertiary);
|
||
padding: 6px 10px 4px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.bot-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 10px;
|
||
border-radius: var(--radius-sm);
|
||
margin-bottom: 4px;
|
||
border: 1px solid transparent;
|
||
transition: background var(--transition-fast);
|
||
|
||
&.active {
|
||
background: var(--color-primary-12);
|
||
border-color: rgba(99, 102, 241, 0.25);
|
||
}
|
||
}
|
||
|
||
.bot-card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.bot-card-name {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.bot-current-badge {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: var(--color-primary);
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
background: var(--color-primary-15);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.bot-playing-badge,
|
||
.bot-paused-badge,
|
||
.bot-idle-badge,
|
||
.bot-offline-badge {
|
||
font-size: 11px;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-weight: 500;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.bot-playing-badge {
|
||
background: var(--color-online-15);
|
||
color: var(--color-online);
|
||
}
|
||
|
||
.bot-paused-badge {
|
||
background: var(--color-paused-15);
|
||
color: var(--color-paused);
|
||
}
|
||
|
||
.bot-idle-badge {
|
||
background: var(--hover-bg);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.bot-offline-badge {
|
||
background: var(--hover-bg);
|
||
color: var(--text-tertiary);
|
||
}
|
||
|
||
.bot-card-controls {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.bot-ctrl-btn {
|
||
flex: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
padding: 6px 8px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--hover-bg);
|
||
border: 1px solid var(--border-color);
|
||
color: var(--text-primary);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
transition: all var(--transition-fast);
|
||
|
||
&:hover:not(:disabled) {
|
||
background: var(--bg-card);
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
&:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
&.primary {
|
||
background: var(--color-primary);
|
||
color: #fff;
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
&.danger {
|
||
color: #ef4444;
|
||
}
|
||
}
|
||
|
||
.settings-btn {
|
||
font-size: 22px;
|
||
opacity: 0.6;
|
||
transition: opacity var(--transition-fast);
|
||
&:hover { opacity: 1; }
|
||
|
||
@media (max-width: 768px) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.link-dialog-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: var(--bg-modal-scrim);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.link-dialog {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-md);
|
||
padding: 20px;
|
||
min-width: 360px;
|
||
max-width: 90vw;
|
||
box-shadow: var(--shadow-modal);
|
||
}
|
||
|
||
.link-dialog-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.link-dialog-hint {
|
||
font-size: 12px;
|
||
color: var(--text-tertiary);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.link-dialog-input {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
font-size: 13px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
background: var(--hover-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-sm);
|
||
color: inherit;
|
||
user-select: all;
|
||
-webkit-user-select: all;
|
||
|
||
&:focus {
|
||
outline: none;
|
||
border-color: var(--color-primary);
|
||
}
|
||
}
|
||
|
||
.link-dialog-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.link-dialog-btn {
|
||
padding: 8px 16px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--border-color);
|
||
background: var(--hover-bg);
|
||
color: inherit;
|
||
cursor: pointer;
|
||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||
|
||
&:hover {
|
||
background: var(--bg-card);
|
||
}
|
||
|
||
&.primary {
|
||
background: var(--color-primary);
|
||
border-color: var(--color-primary);
|
||
color: #fff;
|
||
|
||
&:hover {
|
||
filter: brightness(1.08);
|
||
}
|
||
}
|
||
}
|
||
</style>
|