feat(web): mobile-responsive redesign with Library view and design tokens

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>
This commit is contained in:
saopig1andClaude Opus 4.7 committed 2026-04-21 01:12:58 +08:00
1 parent 1bd6ab9975
commit 1552fa1a39
11 files changed
+684 -139

No files matched your search

+4 -1
View File
@@ -13,7 +13,10 @@
"mcp__Claude_Preview__*", "mcp__Claude_Preview__*",
"mcp__Claude_in_Chrome__*", "mcp__Claude_in_Chrome__*",
"mcp__scheduled-tasks__*", "mcp__scheduled-tasks__*",
"Bash(npx vitest:*)" "Bash(npx vitest:*)",
"Bash(cp \"C:\\\\Users\\\\saopig1\\\\.claude\\\\projects\\\\C--Users-saopig1-Music-teamspeak-music-bot\\\\b5a64d6f-051e-4b87-966c-ece97d2b879b\\\\tool-results\\\\webfetch-1776569008470-exv7qe.bin\" /tmp/design.gz)",
"Bash(gunzip -f /tmp/design.gz)",
"Read(//tmp/**)"
], ],
"deny": [ "deny": [
"Bash(git push * main)", "Bash(git push * main)",
+175 -2
View File
@@ -5,32 +5,87 @@
<RouterView /> <RouterView />
</main> </main>
<Player /> <Player />
<!-- Mobile mini player -->
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')">
<div class="m-player-progress">
<div class="m-player-progress-fill" :style="{ width: mobileProgressPct + '%' }" />
</div>
<CoverArt :url="currentSong.coverUrl" :size="40" :radius="8" />
<div class="m-player-info">
<div class="m-player-name">{{ currentSong.name }}</div>
<div class="m-player-artist">{{ currentSong.artist }}</div>
</div>
<button class="m-player-btn" @click.stop="playerStore.isPlaying ? playerStore.pause() : playerStore.resume()">
<Icon :icon="playerStore.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button>
<button class="m-player-btn" @click.stop="playerStore.next()">
<Icon icon="mdi:skip-next" />
</button>
</div>
<!-- Mobile bottom tab bar -->
<nav class="m-tabbar">
<RouterLink to="/" class="m-tab" :class="{ active: route.path === '/' }">
<Icon icon="mdi:home" class="tab-icon" />
<span class="tab-label">发现</span>
</RouterLink>
<RouterLink to="/search" class="m-tab" :class="{ active: route.path === '/search' }">
<Icon icon="mdi:magnify" class="tab-icon" />
<span class="tab-label">搜索</span>
</RouterLink>
<RouterLink to="/library" class="m-tab" :class="{ active: route.path === '/library' }">
<Icon icon="mdi:music-box-multiple" class="tab-icon" />
<span class="tab-label">音乐库</span>
</RouterLink>
<RouterLink to="/settings" class="m-tab" :class="{ active: route.path.startsWith('/settings') }">
<Icon icon="mdi:cog" class="tab-icon" />
<span class="tab-label">设置</span>
</RouterLink>
</nav>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from './stores/player.js'; import { usePlayerStore } from './stores/player.js';
import { useWebSocket } from './composables/useWebSocket.js'; import { useWebSocket } from './composables/useWebSocket.js';
import Navbar from './components/Navbar.vue'; import Navbar from './components/Navbar.vue';
import Player from './components/Player.vue'; import Player from './components/Player.vue';
import CoverArt from './components/CoverArt.vue';
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
const theme = computed(() => playerStore.theme); const theme = computed(() => playerStore.theme);
const route = useRoute();
const router = useRouter();
const { connect } = useWebSocket(); const { connect } = useWebSocket();
const currentSong = computed(() => playerStore.currentSong);
const mobileProgressPct = ref(0);
let syncTimer: ReturnType<typeof setInterval> | null = null; let syncTimer: ReturnType<typeof setInterval> | null = null;
let mobileRaf: number | null = null;
function updateMobileProgress() {
const duration = currentSong.value?.duration ?? 0;
mobileProgressPct.value = duration > 0
? Math.min((playerStore.elapsed / duration) * 100, 100)
: 0;
mobileRaf = requestAnimationFrame(updateMobileProgress);
}
onMounted(() => { onMounted(() => {
playerStore.loadTheme(); playerStore.loadTheme();
connect(); connect();
playerStore.fetchBots(); playerStore.fetchBots();
// Periodically sync elapsed time from server (ground truth)
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000); syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
mobileRaf = requestAnimationFrame(updateMobileProgress);
}); });
onUnmounted(() => { onUnmounted(() => {
if (syncTimer) clearInterval(syncTimer); if (syncTimer) clearInterval(syncTimer);
if (mobileRaf !== null) cancelAnimationFrame(mobileRaf);
}); });
</script> </script>
@@ -47,5 +102,123 @@ onUnmounted(() => {
@media (max-width: 1336px) { @media (max-width: 1336px) {
padding: 80px 5vw 80px; padding: 80px 5vw 80px;
} }
@media (max-width: 768px) {
padding: 72px 16px 200px;
}
}
// Mobile mini player
.m-player {
position: fixed;
left: 8px;
right: 8px;
bottom: 68px;
height: 58px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 10px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
z-index: 95;
cursor: pointer;
@media (min-width: 769px) {
display: none;
}
}
.m-player-progress {
position: absolute;
top: 0;
left: 10px;
right: 10px;
height: 2px;
}
.m-player-progress-fill {
height: 2px;
background: var(--color-primary);
border-radius: 1px;
}
.m-player-info {
flex: 1;
min-width: 0;
}
.m-player-name {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-player-artist {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-player-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
opacity: 0.85;
flex-shrink: 0;
}
// Mobile bottom tab bar
.m-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 60px;
display: flex;
align-items: center;
justify-content: space-around;
padding-bottom: env(safe-area-inset-bottom, 0);
background: var(--bg-navbar);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-top: 1px solid var(--border-color);
z-index: 100;
@media (min-width: 769px) {
display: none;
}
}
.m-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 14px;
color: var(--text-tertiary);
text-decoration: none;
font-family: inherit;
&.active {
color: var(--color-primary);
}
.tab-icon {
font-size: 22px;
}
.tab-label {
font-size: 10px;
font-weight: 500;
}
} }
</style> </style>
+195 -107
View File
@@ -5,6 +5,7 @@
<div class="nav-links"> <div class="nav-links">
<RouterLink to="/" class="nav-link" active-class="active">发现</RouterLink> <RouterLink to="/" class="nav-link" active-class="active">发现</RouterLink>
<RouterLink to="/search" 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> <RouterLink to="/history" class="nav-link" active-class="active">播放历史</RouterLink>
</div> </div>
@@ -19,38 +20,68 @@
<Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" /> <Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" />
</button> </button>
<div v-if="dropdownOpen" class="bot-dropdown"> <div v-if="dropdownOpen" class="bot-dropdown">
<div class="bot-dropdown-header">机器人</div>
<div <div
v-for="bot in store.bots" v-for="bot in store.bots"
:key="bot.id" :key="bot.id"
class="bot-dropdown-row" class="bot-card"
:class="{ active: bot.id === store.activeBotId }"
> >
<button <div class="bot-card-head" @click="bot.connected ? selectBot(bot.id) : undefined">
class="bot-dropdown-item"
:class="{ active: bot.id === store.activeBotId }"
@click="selectBot(bot.id)"
>
<span class="bot-dot" :class="{ online: bot.connected }" /> <span class="bot-dot" :class="{ online: bot.connected }" />
<span class="bot-dropdown-name">{{ bot.name }}</span> <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-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.paused" class="bot-paused-badge">已暂停</span>
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span> <span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
<span v-else class="bot-offline-badge">离线</span> <span v-else class="bot-offline-badge">离线</span>
</button> </div>
<button <div class="bot-card-controls">
class="bot-power-btn" <button
:class="{ online: bot.connected }" v-if="bot.connected"
:title="bot.connected ? `停止 ${bot.name}` : `启动 ${bot.name}`" class="bot-ctrl-btn danger"
:disabled="togglingBots[bot.id]" :disabled="togglingBots[bot.id]"
@click.stop="togglePower(bot)" @click.stop="togglePower(bot)"
> >
<Icon :icon="bot.connected ? 'mdi:power' : 'mdi:power-off'" /> <Icon icon="mdi:link-off" /> 断开
</button> </button>
<button class="bot-link-btn" :title="`复制 ${bot.name} 的专属链接`" @click.stop="copyBotLink(bot.id)"> <button
<Icon icon="mdi:link-variant" /> v-else
</button> 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 class="bot-dropdown-divider" />
<div class="bot-dropdown-hint">点击切换 · 🔗 复制专属链接</div>
</div> </div>
</div> </div>
@@ -227,6 +258,11 @@ onUnmounted(() => {
@media (max-width: 1336px) { @media (max-width: 1336px) {
padding: 0 5vw; padding: 0 5vw;
} }
@media (max-width: 768px) {
padding: 0 16px;
height: 52px;
}
} }
.logo { .logo {
@@ -234,11 +270,20 @@ onUnmounted(() => {
font-weight: 700; font-weight: 700;
color: var(--color-primary); color: var(--color-primary);
margin-right: 40px; margin-right: 40px;
@media (max-width: 768px) {
font-size: 17px;
margin-right: 0;
}
} }
.nav-links { .nav-links {
display: flex; display: flex;
gap: 24px; gap: 24px;
@media (max-width: 768px) {
display: none;
}
} }
.nav-link { .nav-link {
@@ -266,7 +311,7 @@ onUnmounted(() => {
opacity: 0.6; opacity: 0.6;
&.online { &.online {
background: rgba(51, 94, 234, 0.15); background: var(--color-primary-15);
color: var(--color-primary); color: var(--color-primary);
opacity: 1; opacity: 1;
} }
@@ -295,12 +340,25 @@ onUnmounted(() => {
background: var(--bg-card); background: var(--bg-card);
border-color: var(--color-primary); 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 { .bot-state-mini {
font-size: 14px; font-size: 14px;
&.playing { color: #22c55e; } &.playing { color: var(--color-online); }
&.paused { color: #eab308; } &.paused { color: var(--color-paused); }
@media (max-width: 768px) {
display: none;
}
} }
.bot-chevron { .bot-chevron {
@@ -311,6 +369,10 @@ onUnmounted(() => {
&.rotated { &.rotated {
transform: rotate(180deg); transform: rotate(180deg);
} }
@media (max-width: 768px) {
display: none;
}
} }
.bot-dot { .bot-dot {
@@ -321,7 +383,12 @@ onUnmounted(() => {
flex-shrink: 0; flex-shrink: 0;
&.online { &.online {
background: #22c55e; background: var(--color-online);
}
@media (max-width: 768px) {
width: 8px;
height: 8px;
} }
} }
@@ -330,96 +397,83 @@ onUnmounted(() => {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@media (max-width: 768px) {
max-width: 80px;
}
} }
.bot-dropdown { .bot-dropdown {
position: absolute; position: absolute;
top: calc(100% + 6px); top: calc(100% + 6px);
right: 0; right: 0;
min-width: 200px; min-width: 320px;
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 4px; padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-dropdown);
z-index: 200; z-index: 200;
@media (max-width: 768px) {
position: fixed;
top: 52px;
left: 8px;
right: 8px;
min-width: auto;
}
} }
.bot-dropdown-item { .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; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex: 1;
min-width: 0;
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
cursor: pointer; cursor: pointer;
transition: background var(--transition-fast);
&:hover {
background: var(--hover-bg);
}
&.active {
background: rgba(51, 94, 234, 0.12);
color: var(--color-primary);
}
} }
.bot-dropdown-row { .bot-card-name {
display: flex;
align-items: center;
gap: 2px;
}
.bot-dropdown-name {
flex: 1; flex: 1;
font-size: 13px;
font-weight: 600;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.bot-link-btn { .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; flex-shrink: 0;
padding: 6px 8px;
border-radius: var(--radius-sm);
font-size: 15px;
opacity: 0.4;
transition: opacity var(--transition-fast), background var(--transition-fast);
cursor: pointer;
&:hover {
opacity: 1;
background: var(--hover-bg);
}
}
.bot-power-btn {
flex-shrink: 0;
padding: 6px 8px;
border-radius: var(--radius-sm);
font-size: 16px;
opacity: 0.5;
color: var(--text-tertiary);
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
cursor: pointer;
&:hover:not(:disabled) {
opacity: 1;
background: var(--hover-bg);
}
&:disabled {
opacity: 0.25;
cursor: wait;
}
&.online {
color: #22c55e;
opacity: 0.9;
}
} }
.bot-playing-badge, .bot-playing-badge,
@@ -427,25 +481,25 @@ onUnmounted(() => {
.bot-idle-badge, .bot-idle-badge,
.bot-offline-badge { .bot-offline-badge {
font-size: 11px; font-size: 11px;
padding: 1px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-weight: 500; font-weight: 500;
flex-shrink: 0; flex-shrink: 0;
} }
.bot-playing-badge { .bot-playing-badge {
background: rgba(34, 197, 94, 0.15); background: var(--color-online-15);
color: #22c55e; color: var(--color-online);
} }
.bot-paused-badge { .bot-paused-badge {
background: rgba(234, 179, 8, 0.15); background: var(--color-paused-15);
color: #eab308; color: var(--color-paused);
} }
.bot-idle-badge { .bot-idle-badge {
background: rgba(51, 94, 234, 0.12); background: var(--hover-bg);
color: var(--color-primary); color: var(--text-secondary);
} }
.bot-offline-badge { .bot-offline-badge {
@@ -453,17 +507,47 @@ onUnmounted(() => {
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.bot-dropdown-divider { .bot-card-controls {
height: 1px; display: flex;
background: var(--border-color); gap: 6px;
margin: 4px 0;
} }
.bot-dropdown-hint { .bot-ctrl-btn {
padding: 4px 12px 6px; 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-size: 11px;
color: var(--text-tertiary); font-weight: 500;
text-align: center; 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 { .settings-btn {
@@ -471,12 +555,16 @@ onUnmounted(() => {
opacity: 0.6; opacity: 0.6;
transition: opacity var(--transition-fast); transition: opacity var(--transition-fast);
&:hover { opacity: 1; } &:hover { opacity: 1; }
@media (max-width: 768px) {
display: none;
}
} }
.link-dialog-backdrop { .link-dialog-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.55); background: var(--bg-modal-scrim);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -490,7 +578,7 @@ onUnmounted(() => {
padding: 20px; padding: 20px;
min-width: 360px; min-width: 360px;
max-width: 90vw; max-width: 90vw;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); box-shadow: var(--shadow-modal);
} }
.link-dialog-title { .link-dialog-title {
+8 -4
View File
@@ -206,6 +206,10 @@ function cycleMode() {
left: 0; left: 0;
right: 0; right: 0;
z-index: 100; z-index: 100;
@media (max-width: 768px) {
display: none;
}
} }
.player-bar { .player-bar {
@@ -326,12 +330,12 @@ function cycleMode() {
.bot-badge { .bot-badge {
display: inline-block; display: inline-block;
font-size: 10px; font-size: var(--fs-micro);
font-weight: 600; font-weight: var(--fw-semi);
padding: 0 5px; padding: 0 5px;
background: rgba(51, 94, 234, 0.15); background: var(--color-primary-15);
color: var(--color-primary); color: var(--color-primary);
border-radius: 3px; border-radius: var(--radius-xs);
line-height: 16px; line-height: 16px;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
+1 -1
View File
@@ -146,7 +146,7 @@ async function removeSong(index: number) {
} }
&.active { &.active {
background: rgba(51, 94, 234, 0.1); background: var(--color-primary-10);
} }
} }
+12 -12
View File
@@ -63,7 +63,7 @@ function formatDuration(seconds: number): string {
} }
&.active { &.active {
background: rgba(51, 94, 234, 0.1); background: var(--color-primary-10);
} }
} }
@@ -96,31 +96,31 @@ function formatDuration(seconds: number): string {
.platform-badge { .platform-badge {
flex-shrink: 0; flex-shrink: 0;
font-size: 10px; font-size: var(--fs-micro);
font-weight: 600; font-weight: var(--fw-semi);
padding: 1px 5px; padding: 1px 5px;
border-radius: 3px; border-radius: var(--radius-xs);
line-height: 1.4; line-height: 1.4;
} }
.badge-netease { .badge-netease {
background: rgba(232, 17, 35, 0.15); background: var(--brand-netease-15);
color: #e81123; color: var(--brand-netease);
} }
.badge-qq { .badge-qq {
background: rgba(18, 183, 106, 0.15); background: var(--brand-qq-15);
color: #12b76a; color: var(--brand-qq);
} }
.badge-bilibili { .badge-bilibili {
background: rgba(0, 161, 214, 0.15); background: var(--brand-bilibili-15);
color: #00a1d6; color: var(--brand-bilibili);
} }
.badge-youtube { .badge-youtube {
background: rgba(255, 0, 0, 0.12); background: var(--brand-youtube-12);
color: #ff0000; color: var(--brand-youtube);
} }
.song-artist { .song-artist {
+5
View File
@@ -13,6 +13,11 @@ const router = createRouter({
name: 'search', name: 'search',
component: () => import('../views/Search.vue'), component: () => import('../views/Search.vue'),
}, },
{
path: '/library',
name: 'library',
component: () => import('../views/Library.vue'),
},
{ {
path: '/playlist/:id', path: '/playlist/:id',
name: 'playlist', name: 'playlist',
+65 -2
View File
@@ -1,33 +1,95 @@
// YesPlayMusic-inspired design tokens // TSMusicBot Design System — canonical tokens
:root { :root {
// Fonts // Typography
--font-primary: 'Barlow', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; --font-primary: 'Barlow', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, 'Cascadia Code', monospace;
--fs-hero: 22px;
--fs-h1: 20px;
--fs-h2: 17px;
--fs-h3: 16px;
--fs-body: 14px;
--fs-sm: 13px;
--fs-xs: 12px;
--fs-2xs: 11px;
--fs-micro: 10px;
--fw-regular: 400;
--fw-medium: 500;
--fw-semi: 600;
--fw-bold: 700;
--fw-black: 800;
--lh-tight: 1.2;
--lh-normal: 1.4;
--lh-body: 1.5;
// Spacing // Spacing
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 36px;
--space-10: 80px;
// Layout
--navbar-height: 56px; --navbar-height: 56px;
--player-height: 56px; --player-height: 56px;
--page-gutter: 10vw;
// Radius // Radius
--radius-xs: 3px;
--radius-sm: 6px; --radius-sm: 6px;
--radius-md: 10px; --radius-md: 10px;
--radius-lg: 14px; --radius-lg: 14px;
--radius-xl: 20px; --radius-xl: 20px;
--radius-full: 9999px;
// Transitions // Transitions
--transition-fast: 0.2s ease; --transition-fast: 0.2s ease;
--transition-normal: 0.3s ease; --transition-normal: 0.3s ease;
--ease-cinematic: cubic-bezier(0.25, 0.1, 0.25, 1);
// Shadows
--shadow-dropdown: 0 8px 24px rgba(0, 0, 0, 0.2);
--shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.35);
// Accent // Accent
--color-primary: #335eea; --color-primary: #335eea;
--color-primary-bg: #eaeffd; --color-primary-bg: #eaeffd;
--color-primary-15: rgba(51, 94, 234, 0.15);
--color-primary-10: rgba(51, 94, 234, 0.10);
--color-primary-12: rgba(51, 94, 234, 0.12);
--gradient-fm: linear-gradient(135deg, #335eea, #6366f1);
// Semantic (theme-invariant)
--color-online: #22c55e;
--color-online-15: rgba(34, 197, 94, 0.15);
--color-paused: #eab308;
--color-paused-15: rgba(234, 179, 8, 0.15);
// Platform brand
--brand-netease: #e81123;
--brand-netease-15: rgba(232, 17, 35, 0.15);
--brand-qq: #12b76a;
--brand-qq-15: rgba(18, 183, 106, 0.15);
--brand-bilibili: #00a1d6;
--brand-bilibili-15: rgba(0, 161, 214, 0.15);
--brand-youtube: #ff0000;
--brand-youtube-12: rgba(255, 0, 0, 0.12);
} }
// Dark theme (default)
:root,
[data-theme='dark'] { [data-theme='dark'] {
--bg-primary: #222222; --bg-primary: #222222;
--bg-secondary: #323232; --bg-secondary: #323232;
--bg-card: rgba(255, 255, 255, 0.04); --bg-card: rgba(255, 255, 255, 0.04);
--bg-navbar: rgba(34, 34, 34, 0.86); --bg-navbar: rgba(34, 34, 34, 0.86);
--bg-modal-scrim: rgba(0, 0, 0, 0.55);
--text-primary: #ffffff; --text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.58); --text-secondary: rgba(255, 255, 255, 0.58);
--text-tertiary: rgba(255, 255, 255, 0.38); --text-tertiary: rgba(255, 255, 255, 0.38);
@@ -40,6 +102,7 @@
--bg-secondary: #f5f5f7; --bg-secondary: #f5f5f7;
--bg-card: rgba(0, 0, 0, 0.02); --bg-card: rgba(0, 0, 0, 0.02);
--bg-navbar: rgba(255, 255, 255, 0.86); --bg-navbar: rgba(255, 255, 255, 0.86);
--bg-modal-scrim: rgba(0, 0, 0, 0.35);
--text-primary: #000000; --text-primary: #000000;
--text-secondary: rgba(0, 0, 0, 0.58); --text-secondary: rgba(0, 0, 0, 0.58);
--text-tertiary: rgba(0, 0, 0, 0.38); --text-tertiary: rgba(0, 0, 0, 0.38);
+12 -1
View File
@@ -179,6 +179,10 @@ onMounted(() => {
.section { .section {
margin-bottom: 36px; margin-bottom: 36px;
@media (max-width: 768px) {
margin-bottom: 28px;
}
} }
.section-title { .section-title {
@@ -188,6 +192,11 @@ onMounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@media (max-width: 768px) {
font-size: 18px;
margin-bottom: 12px;
}
} }
.section-count { .section-count {
@@ -223,7 +232,7 @@ onMounted(() => {
justify-content: center; justify-content: center;
width: 24px; width: 24px;
height: 24px; height: 24px;
background: #00a1d6; background: var(--brand-bilibili);
color: white; color: white;
border-radius: 4px; border-radius: 4px;
font-size: 14px; font-size: 14px;
@@ -317,6 +326,7 @@ onMounted(() => {
@media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); } @media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); } @media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 768px) { grid-template-columns: repeat(3, 1fr); gap: 14px; }
} }
.daily-card { .daily-card {
@@ -348,6 +358,7 @@ onMounted(() => {
@media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); } @media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); } @media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 768px) { grid-template-columns: repeat(3, 1fr); gap: 14px; }
} }
.playlist-card { .playlist-card {
+198
View File
@@ -0,0 +1,198 @@
<template>
<div class="library-page">
<h1 class="page-title">音乐库</h1>
<!-- 我的歌单 -->
<section class="section" v-if="store.userPlaylists.length > 0">
<h2 class="section-title">
我的歌单
<span class="section-count">{{ store.userPlaylists.length }}</span>
</h2>
<div class="playlist-grid">
<RouterLink
v-for="pl in store.userPlaylists"
:key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ pl.name }}</div>
<div class="playlist-count">{{ pl.songCount }} 首</div>
</RouterLink>
</div>
</section>
<!-- 最近播放 -->
<section class="section">
<h2 class="section-title">最近播放</h2>
<div v-if="historyLoading" class="loading">加载中...</div>
<div v-else-if="history.length === 0" class="empty">暂无播放记录</div>
<div v-else class="song-list">
<SongCard
v-for="(song, i) in history.slice(0, 10)"
:key="`hist-${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>
</section>
<!-- 我的收藏 -->
<section class="section" v-if="liked.length > 0">
<h2 class="section-title">我的收藏</h2>
<div class="song-list">
<SongCard
v-for="(song, i) in liked.slice(0, 10)"
:key="`fav-${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>
</section>
<div v-if="!historyLoading && store.userPlaylists.length === 0 && history.length === 0 && liked.length === 0" class="empty-state">
<Icon icon="mdi:music-box-outline" class="empty-icon" />
<div>登录网易云或QQ音乐后,这里将显示你的歌单和播放记录</div>
</div>
</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 CoverArt from '../components/CoverArt.vue';
import SongCard from '../components/SongCard.vue';
const store = usePlayerStore();
type SongItem = { id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string };
const history = ref<SongItem[]>([]);
const liked = ref<SongItem[]>([]);
const historyLoading = ref(true);
onMounted(async () => {
if (!store.activeBotId) {
await store.fetchBots();
}
store.fetchHomeData();
if (store.activeBotId) {
try {
const res = await axios.get(`/api/player/${store.activeBotId}/history`);
history.value = res.data.history ?? [];
} catch {
// API may not be ready
}
try {
const res = await axios.get('/api/music/user/liked');
liked.value = res.data.songs ?? [];
} catch {
// Liked songs API may not exist yet
}
}
historyLoading.value = false;
});
</script>
<style lang="scss" scoped>
.page-title {
font-size: var(--fs-hero);
font-weight: var(--fw-bold);
margin-bottom: 24px;
}
.section {
margin-bottom: 36px;
}
.section-title {
font-size: var(--fs-hero);
font-weight: var(--fw-bold);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.section-count {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--text-tertiary);
}
.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;
display: block;
text-decoration: none;
color: inherit;
}
.playlist-name {
margin-top: 8px;
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.playlist-count {
font-size: var(--fs-xs);
color: var(--text-tertiary);
margin-top: 2px;
}
.song-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.empty {
text-align: center;
padding: 40px;
color: var(--text-tertiary);
font-size: var(--fs-body);
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--text-tertiary);
font-size: var(--fs-body);
}
.empty-icon {
font-size: 48px;
opacity: 0.3;
margin-bottom: 16px;
}
</style>
+9 -9
View File
@@ -820,16 +820,16 @@ onUnmounted(() => {
background: var(--border-color); background: var(--border-color);
color: var(--text-tertiary); color: var(--text-tertiary);
&.online { &.online {
background: rgba(51, 94, 234, 0.15); background: var(--color-primary-15);
color: var(--color-primary); color: var(--color-primary);
} }
&.playing { &.playing {
background: rgba(76, 175, 80, 0.15); background: var(--color-online-15);
color: #4caf50; color: var(--color-online);
} }
&.paused { &.paused {
background: rgba(255, 152, 0, 0.15); background: var(--color-paused-15);
color: #ff9800; color: var(--color-paused);
} }
} }
@@ -853,7 +853,7 @@ onUnmounted(() => {
color: var(--color-primary); color: var(--color-primary);
&.bilibili-icon { &.bilibili-icon {
color: #00a1d6; color: var(--brand-bilibili);
} }
} }
@@ -865,7 +865,7 @@ onUnmounted(() => {
.account-status { .account-status {
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
&.logged { color: #4caf50; } &.logged { color: var(--color-online); }
} }
.login-methods { .login-methods {
@@ -888,7 +888,7 @@ onUnmounted(() => {
&:hover { border-color: var(--color-primary); color: var(--color-primary); } &:hover { border-color: var(--color-primary); color: var(--color-primary); }
&.active { &.active {
background: rgba(51, 94, 234, 0.1); background: var(--color-primary-10);
border-color: var(--color-primary); border-color: var(--color-primary);
color: var(--color-primary); color: var(--color-primary);
} }
@@ -1012,7 +1012,7 @@ onUnmounted(() => {
&:hover { border-color: var(--border-color); } &:hover { border-color: var(--border-color); }
&.active { &.active {
border-color: var(--color-primary); border-color: var(--color-primary);
background: rgba(51, 94, 234, 0.1); background: var(--color-primary-10);
} }
} }