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

+12 -1
View File
@@ -179,6 +179,10 @@ onMounted(() => {
.section {
margin-bottom: 36px;
@media (max-width: 768px) {
margin-bottom: 28px;
}
}
.section-title {
@@ -188,6 +192,11 @@ onMounted(() => {
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 768px) {
font-size: 18px;
margin-bottom: 12px;
}
}
.section-count {
@@ -223,7 +232,7 @@ onMounted(() => {
justify-content: center;
width: 24px;
height: 24px;
background: #00a1d6;
background: var(--brand-bilibili);
color: white;
border-radius: 4px;
font-size: 14px;
@@ -317,6 +326,7 @@ onMounted(() => {
@media (max-width: 1200px) { grid-template-columns: repeat(4, 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 {
@@ -348,6 +358,7 @@ onMounted(() => {
@media (max-width: 1200px) { grid-template-columns: repeat(4, 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 {
+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);
color: var(--text-tertiary);
&.online {
background: rgba(51, 94, 234, 0.15);
background: var(--color-primary-15);
color: var(--color-primary);
}
&.playing {
background: rgba(76, 175, 80, 0.15);
color: #4caf50;
background: var(--color-online-15);
color: var(--color-online);
}
&.paused {
background: rgba(255, 152, 0, 0.15);
color: #ff9800;
background: var(--color-paused-15);
color: var(--color-paused);
}
}
@@ -853,7 +853,7 @@ onUnmounted(() => {
color: var(--color-primary);
&.bilibili-icon {
color: #00a1d6;
color: var(--brand-bilibili);
}
}
@@ -865,7 +865,7 @@ onUnmounted(() => {
.account-status {
font-size: 12px;
color: var(--text-tertiary);
&.logged { color: #4caf50; }
&.logged { color: var(--color-online); }
}
.login-methods {
@@ -888,7 +888,7 @@ onUnmounted(() => {
&:hover { border-color: var(--color-primary); color: var(--color-primary); }
&.active {
background: rgba(51, 94, 234, 0.1);
background: var(--color-primary-10);
border-color: var(--color-primary);
color: var(--color-primary);
}
@@ -1012,7 +1012,7 @@ onUnmounted(() => {
&:hover { border-color: var(--border-color); }
&.active {
border-color: var(--color-primary);
background: rgba(51, 94, 234, 0.1);
background: var(--color-primary-10);
}
}