mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: add Vue.js WebUI — YesPlayMusic-inspired SPA with all pages and components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2a966e914c
commit
694e3c953b
25 files changed
+5388
-1
No files matched your search
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<h1 class="page-title">播放历史</h1>
|
||||
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
|
||||
<div v-else-if="history.length === 0" class="empty">
|
||||
暂无播放记录
|
||||
</div>
|
||||
|
||||
<div v-else class="history-list">
|
||||
<SongCard
|
||||
v-for="(song, i) in history"
|
||||
:key="`${song.id}-${i}`"
|
||||
:song="song"
|
||||
:index="i + 1"
|
||||
:active="store.currentSong?.id === song.id"
|
||||
@play="store.play(song.name, song.platform)"
|
||||
@add="store.addToQueue(song.name, song.platform)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import SongCard from '../components/SongCard.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
|
||||
const history = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.activeBotId) {
|
||||
await store.fetchBots();
|
||||
}
|
||||
|
||||
if (store.activeBotId) {
|
||||
try {
|
||||
const res = await axios.get(`/api/player/${store.activeBotId}/history`);
|
||||
history.value = res.data.history;
|
||||
} catch {
|
||||
// Ignore if API not ready
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<!-- Bot Instance Selector -->
|
||||
<div class="bot-selector" v-if="store.bots.length > 1">
|
||||
<button
|
||||
v-for="bot in store.bots"
|
||||
:key="bot.id"
|
||||
class="bot-tab"
|
||||
:class="{ active: bot.id === store.activeBotId }"
|
||||
@click="store.setActiveBotId(bot.id)"
|
||||
>
|
||||
{{ bot.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="search-bar" @click="$router.push('/search')">
|
||||
<Icon icon="mdi:magnify" class="search-icon" />
|
||||
<span class="search-placeholder">搜索歌曲、歌单、专辑...</span>
|
||||
</div>
|
||||
|
||||
<!-- Now Playing -->
|
||||
<section v-if="store.currentSong" class="section">
|
||||
<h2 class="section-title">正在播放</h2>
|
||||
<div class="now-playing">
|
||||
<CoverArt :url="store.currentSong.coverUrl" :size="80" :radius="10" :show-shadow="true" />
|
||||
<div class="now-playing-info">
|
||||
<div class="now-playing-name">{{ store.currentSong.name }}</div>
|
||||
<div class="now-playing-artist">{{ store.currentSong.artist }} · {{ store.currentSong.album }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recommended Playlists -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">推荐歌单</h2>
|
||||
<div class="playlist-grid">
|
||||
<div
|
||||
v-for="playlist in playlists"
|
||||
:key="playlist.id"
|
||||
class="playlist-card hover-scale"
|
||||
@click="$router.push(`/playlist/${playlist.id}?platform=${playlist.platform}`)"
|
||||
>
|
||||
<CoverArt :url="playlist.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="playlist-name">{{ playlist.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import { useWebSocket } from '../composables/useWebSocket.js';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const { connect } = useWebSocket();
|
||||
|
||||
interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
const playlists = ref<Playlist[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
store.loadTheme();
|
||||
connect();
|
||||
await store.fetchBots();
|
||||
|
||||
try {
|
||||
const res = await axios.get('/api/music/recommend/playlists');
|
||||
playlists.value = res.data.playlists;
|
||||
} catch {
|
||||
// Ignore if API not ready
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bot-selector {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.bot-tab {
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: var(--hover-bg);
|
||||
opacity: 0.6;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 32px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
|
||||
&:hover { background: var(--hover-bg); }
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 20px;
|
||||
opacity: 0.4;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
opacity: 0.3;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.now-playing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.now-playing-name {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.now-playing-artist {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.playlist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
|
||||
@media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.playlist-name {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="lyrics-page" :style="bgStyle">
|
||||
<div class="lyrics-overlay" />
|
||||
|
||||
<div v-if="currentSong" class="lyrics-content">
|
||||
<div class="lyrics-left">
|
||||
<CoverArt :url="currentSong.coverUrl" :size="280" :radius="14" :show-shadow="true" />
|
||||
<div class="song-meta">
|
||||
<div class="song-name">{{ currentSong.name }}</div>
|
||||
<div class="song-artist">{{ currentSong.artist }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lyrics-right">
|
||||
<div v-if="loading" class="lyrics-loading">加载歌词中...</div>
|
||||
<div v-else-if="lines.length === 0" class="lyrics-empty">暂无歌词</div>
|
||||
<div v-else class="lyrics-scroll" ref="scrollContainer">
|
||||
<div
|
||||
v-for="(line, i) in lines"
|
||||
:key="i"
|
||||
class="lyrics-line"
|
||||
:class="{ active: i === activeLine }"
|
||||
>
|
||||
{{ line.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-song">
|
||||
当前没有正在播放的歌曲
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const currentSong = computed(() => store.currentSong);
|
||||
|
||||
interface LyricLine {
|
||||
time: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const lines = ref<LyricLine[]>([]);
|
||||
const activeLine = ref(0);
|
||||
const loading = ref(false);
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
const dominantColor = ref('');
|
||||
|
||||
const bgStyle = computed(() => {
|
||||
if (currentSong.value?.coverUrl) {
|
||||
return {
|
||||
backgroundImage: `url(${currentSong.value.coverUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
async function fetchLyrics() {
|
||||
if (!currentSong.value) return;
|
||||
loading.value = true;
|
||||
lines.value = [];
|
||||
|
||||
try {
|
||||
const res = await axios.get(`/api/music/lyrics/${currentSong.value.id}`, {
|
||||
params: { platform: currentSong.value.platform },
|
||||
});
|
||||
lines.value = res.data.lyrics || [];
|
||||
} catch {
|
||||
lines.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(currentSong, () => {
|
||||
fetchLyrics();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (currentSong.value) fetchLyrics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lyrics-page {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lyrics-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(60px);
|
||||
-webkit-backdrop-filter: blur(60px);
|
||||
}
|
||||
|
||||
.lyrics-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 60px;
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.lyrics-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.song-meta {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.song-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.lyrics-right {
|
||||
flex: 1;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lyrics-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.lyrics-line {
|
||||
font-size: 18px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics-loading,
|
||||
.lyrics-empty {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.no-song {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="playlist-page">
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
|
||||
<template v-else-if="playlist">
|
||||
<!-- Hero Header -->
|
||||
<div class="playlist-hero">
|
||||
<CoverArt :url="playlist.coverUrl" :size="200" :radius="14" :show-shadow="true" />
|
||||
<div class="playlist-meta">
|
||||
<h1 class="playlist-title">{{ playlist.name }}</h1>
|
||||
<p class="playlist-desc">{{ playlist.description }}</p>
|
||||
<div class="playlist-stats">
|
||||
{{ playlist.songCount }} 首歌曲
|
||||
</div>
|
||||
<button class="play-all-btn" @click="playAll">
|
||||
<Icon icon="mdi:play" />
|
||||
播放全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Song List -->
|
||||
<div class="song-list">
|
||||
<SongCard
|
||||
v-for="(song, i) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="i + 1"
|
||||
:active="store.currentSong?.id === song.id"
|
||||
@play="store.play(song.name, song.platform)"
|
||||
@add="store.addToQueue(song.name, song.platform)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import SongCard from '../components/SongCard.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const route = useRoute();
|
||||
|
||||
interface PlaylistDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
}
|
||||
|
||||
const playlist = ref<PlaylistDetail | null>(null);
|
||||
const songs = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
async function playAll() {
|
||||
if (songs.value.length > 0) {
|
||||
const first = songs.value[0];
|
||||
await store.play(first.name, first.platform);
|
||||
for (let i = 1; i < songs.value.length; i++) {
|
||||
await store.addToQueue(songs.value[i].name, songs.value[i].platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string;
|
||||
const platform = (route.query.platform as string) || 'netease';
|
||||
|
||||
try {
|
||||
const res = await axios.get(`/api/music/playlist/${id}`, {
|
||||
params: { platform },
|
||||
});
|
||||
playlist.value = res.data.playlist;
|
||||
songs.value = res.data.songs;
|
||||
} catch {
|
||||
// Ignore if API not ready
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.playlist-hero {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.playlist-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-stats {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.play-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 28px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
transition: transform var(--transition-fast);
|
||||
|
||||
&:hover { transform: scale(1.04); }
|
||||
&:active { transform: scale(0.96); }
|
||||
}
|
||||
|
||||
.song-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="search-page">
|
||||
<div class="search-header">
|
||||
<div class="search-input-wrap">
|
||||
<Icon icon="mdi:magnify" class="search-icon" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
class="search-input"
|
||||
placeholder="搜索歌曲、歌手、专辑..."
|
||||
@keyup.enter="doSearch"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="platform-toggle">
|
||||
<button
|
||||
class="platform-btn"
|
||||
:class="{ active: platform === 'netease' }"
|
||||
@click="platform = 'netease'; doSearch()"
|
||||
>
|
||||
网易云
|
||||
</button>
|
||||
<button
|
||||
class="platform-btn"
|
||||
:class="{ active: platform === 'qq' }"
|
||||
@click="platform = 'qq'; doSearch()"
|
||||
>
|
||||
QQ音乐
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">搜索中...</div>
|
||||
|
||||
<div v-else-if="results.length > 0" class="results">
|
||||
<SongCard
|
||||
v-for="(song, i) in results"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="i + 1"
|
||||
:active="store.currentSong?.id === song.id"
|
||||
@play="store.play(song.name, song.platform)"
|
||||
@add="store.addToQueue(song.name, song.platform)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="searched" class="empty">
|
||||
未找到相关结果
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import SongCard from '../components/SongCard.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const route = useRoute();
|
||||
|
||||
const query = ref((route.query.q as string) || '');
|
||||
const platform = ref<'netease' | 'qq'>('netease');
|
||||
const results = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
|
||||
const loading = ref(false);
|
||||
const searched = ref(false);
|
||||
|
||||
async function doSearch() {
|
||||
if (!query.value.trim()) return;
|
||||
loading.value = true;
|
||||
searched.value = true;
|
||||
try {
|
||||
const res = await axios.get('/api/music/search', {
|
||||
params: { q: query.value, platform: platform.value },
|
||||
});
|
||||
results.value = res.data.songs;
|
||||
} catch {
|
||||
results.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (query.value) doSearch();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 22px;
|
||||
opacity: 0.4;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.platform-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.platform-btn {
|
||||
padding: 6px 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: var(--hover-bg);
|
||||
opacity: 0.6;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h1 class="page-title">设置</h1>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<section class="settings-section">
|
||||
<h2 class="section-title">外观</h2>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<Icon icon="mdi:theme-light-dark" class="setting-icon" />
|
||||
主题模式
|
||||
</div>
|
||||
<button class="theme-toggle" @click="store.toggleTheme()">
|
||||
<Icon :icon="store.theme === 'dark' ? 'mdi:weather-night' : 'mdi:weather-sunny'" />
|
||||
{{ store.theme === 'dark' ? '深色' : '浅色' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bot Management -->
|
||||
<section class="settings-section">
|
||||
<h2 class="section-title">机器人管理</h2>
|
||||
<div class="bot-list">
|
||||
<div v-for="bot in store.bots" :key="bot.id" class="bot-item">
|
||||
<div class="bot-info">
|
||||
<div class="bot-name">{{ bot.name }}</div>
|
||||
<div class="bot-status" :class="{ online: bot.connected }">
|
||||
{{ bot.connected ? '在线' : '离线' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bot-actions">
|
||||
<button class="btn-sm" @click="toggleBot(bot.id, bot.connected)">
|
||||
{{ bot.connected ? '停止' : '启动' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="create-bot">
|
||||
<h3 class="subsection-title">创建新实例</h3>
|
||||
<div class="form-row">
|
||||
<input v-model="newBotName" class="input" placeholder="机器人名称" />
|
||||
<input v-model="newBotServer" class="input" placeholder="TS服务器地址" />
|
||||
<button class="btn-primary" @click="createBot">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Music Account -->
|
||||
<section class="settings-section">
|
||||
<h2 class="section-title">音乐账号</h2>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<Icon icon="mdi:music-note" class="setting-icon" />
|
||||
网易云登录Cookie
|
||||
</div>
|
||||
<div class="cookie-input-wrap">
|
||||
<textarea
|
||||
v-model="neteaseCookie"
|
||||
class="textarea"
|
||||
placeholder="粘贴网易云音乐Cookie..."
|
||||
rows="3"
|
||||
/>
|
||||
<button class="btn-primary" @click="saveCookie('netease')">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<Icon icon="mdi:music-note" class="setting-icon" />
|
||||
QQ音乐登录Cookie
|
||||
</div>
|
||||
<div class="cookie-input-wrap">
|
||||
<textarea
|
||||
v-model="qqCookie"
|
||||
class="textarea"
|
||||
placeholder="粘贴QQ音乐Cookie..."
|
||||
rows="3"
|
||||
/>
|
||||
<button class="btn-primary" @click="saveCookie('qq')">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Command Prefix -->
|
||||
<section class="settings-section">
|
||||
<h2 class="section-title">命令设置</h2>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<Icon icon="mdi:console" class="setting-icon" />
|
||||
命令前缀
|
||||
</div>
|
||||
<div class="prefix-input-wrap">
|
||||
<input v-model="commandPrefix" class="input input-sm" placeholder="!" />
|
||||
<button class="btn-primary" @click="savePrefix">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
|
||||
const store = usePlayerStore();
|
||||
|
||||
const newBotName = ref('');
|
||||
const newBotServer = ref('');
|
||||
const neteaseCookie = ref('');
|
||||
const qqCookie = ref('');
|
||||
const commandPrefix = ref('!');
|
||||
|
||||
async function createBot() {
|
||||
if (!newBotName.value || !newBotServer.value) return;
|
||||
try {
|
||||
await axios.post('/api/bot', {
|
||||
name: newBotName.value,
|
||||
server: newBotServer.value,
|
||||
});
|
||||
newBotName.value = '';
|
||||
newBotServer.value = '';
|
||||
await store.fetchBots();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBot(botId: string, connected: boolean) {
|
||||
try {
|
||||
if (connected) {
|
||||
await axios.post(`/api/bot/${botId}/stop`);
|
||||
} else {
|
||||
await axios.post(`/api/bot/${botId}/start`);
|
||||
}
|
||||
await store.fetchBots();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCookie(platform: string) {
|
||||
const cookie = platform === 'netease' ? neteaseCookie.value : qqCookie.value;
|
||||
try {
|
||||
await axios.post('/api/settings/cookie', { platform, cookie });
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function savePrefix() {
|
||||
try {
|
||||
await axios.post('/api/settings/prefix', { prefix: commandPrefix.value });
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 36px;
|
||||
padding: 24px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.setting-icon {
|
||||
font-size: 18px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
background: var(--hover-bg);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover { background: var(--color-primary); color: white; }
|
||||
}
|
||||
|
||||
.bot-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bot-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--hover-bg);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.bot-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bot-status {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--border-color);
|
||||
color: var(--text-tertiary);
|
||||
|
||||
&.online {
|
||||
background: rgba(51, 94, 234, 0.15);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
|
||||
&:focus { border-color: var(--color-primary); }
|
||||
}
|
||||
|
||||
.input-sm {
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
|
||||
&:focus { border-color: var(--color-primary); }
|
||||
}
|
||||
|
||||
.cookie-input-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prefix-input-wrap {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: transform var(--transition-fast);
|
||||
|
||||
&:hover { transform: scale(1.02); }
|
||||
&:active { transform: scale(0.98); }
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 14px;
|
||||
background: var(--hover-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover { background: var(--color-primary); color: white; }
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user