refactor(ui): 统一 SongItem 圆角、抽象 HistoryItem、新增 EmptyState、修复主题色

- SongItem 5 变体容器/图片圆角统一为 rounded-xl(12px):
  BaseSongItem(rounded-3xl→xl) / Standard(img rounded-2xl→xl) /
  Compact(rounded-lg→xl) / List(rounded-lg→xl) / Mini(rounded-2xl→xl)
- 抽象 HistoryItem.vue:AlbumItem 和 PlaylistItem 提取共享 UI 组件,
  消除 ~80 行重复样式代码,同时迁移至内联 Tailwind class
- 新增 EmptyState.vue:统一空状态组件(icon + text,暗色模式完整适配)
- 动画时长:SearchItem 图片 hover duration-700→duration-500
- MobilePlayBar:进度条颜色 Spotify #1ed760→项目主色 #22c55e
This commit is contained in:
alger
2026-03-15 14:14:52 +08:00
parent 57a441312f
commit 3e6f981379
11 changed files with 126 additions and 188 deletions

View File

@@ -1,44 +1,34 @@
<template>
<div class="album-item" @click="handleClick">
<n-image
:src="getImgUrl(item.picUrl || '', '100y100')"
class="album-item-img"
lazy
preview-disabled
/>
<div class="album-item-info">
<div class="album-item-name">
<n-ellipsis :line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="album-item-desc">
{{ getDescription() }}
</div>
</div>
<div v-if="showCount && item.count" class="album-item-count">
{{ item.count }}
</div>
<div v-if="showDelete" class="album-item-delete" @click.stop="handleDelete">
<i class="iconfont icon-close"></i>
</div>
</div>
<history-item
:image-url="getImgUrl(item.picUrl || '', '100y100')"
:name="item.name"
:description="getDescription()"
:count="item.count"
:show-count="showCount"
:show-delete="showDelete"
@click="emit('click', item)"
@delete="emit('delete', item)"
/>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import HistoryItem from '@/components/common/HistoryItem.vue';
import type { AlbumHistoryItem } from '@/store/modules/playHistory';
import { getImgUrl } from '@/utils';
interface Props {
item: AlbumHistoryItem;
showCount?: boolean;
showDelete?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showCount: false,
showDelete: false
});
const props = withDefaults(
defineProps<{
item: AlbumHistoryItem;
showCount?: boolean;
showDelete?: boolean;
}>(),
{
showCount: false,
showDelete: false
}
);
const emit = defineEmits<{
click: [item: AlbumHistoryItem];
@@ -49,64 +39,8 @@ const { t } = useI18n();
const getDescription = () => {
const parts: string[] = [];
if (props.item.artist?.name) {
parts.push(props.item.artist.name);
}
if (props.item.size !== undefined) {
parts.push(t('common.songCount', { count: props.item.size }));
}
if (props.item.artist?.name) parts.push(props.item.artist.name);
if (props.item.size !== undefined) parts.push(t('common.songCount', { count: props.item.size }));
return parts.join(' · ') || t('history.noDescription');
};
const handleClick = () => {
emit('click', props.item);
};
const handleDelete = () => {
emit('delete', props.item);
};
</script>
<style scoped lang="scss">
.album-item {
@apply flex items-center px-2 py-2 rounded-xl cursor-pointer;
@apply transition-all duration-200;
@apply bg-light-100 dark:bg-dark-100;
@apply hover:bg-light-200 dark:hover:bg-dark-200;
@apply mb-2;
&-img {
@apply flex items-center justify-center rounded-xl;
@apply w-[60px] h-[60px] flex-shrink-0;
@apply bg-light-300 dark:bg-dark-300;
}
&-info {
@apply ml-3 flex-1 min-w-0;
}
&-name {
@apply text-gray-900 dark:text-white text-base mb-1;
}
&-desc {
@apply text-sm text-gray-500 dark:text-gray-400;
}
&-count {
@apply px-4 text-lg text-center min-w-[60px];
@apply text-gray-600 dark:text-gray-400;
}
&-delete {
@apply cursor-pointer rounded-full border-2 w-8 h-8 flex justify-center items-center;
@apply border-gray-400 dark:border-gray-600;
@apply text-gray-600 dark:text-gray-400;
@apply hover:border-red-500 hover:text-red-500;
@apply transition-all;
}
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div
class="flex flex-col items-center justify-center py-20 text-neutral-400 dark:text-neutral-500"
>
<i :class="[icon, 'text-5xl mb-4 opacity-40']" />
<p class="text-sm">{{ text }}</p>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
icon?: string;
text?: string;
}>(),
{
icon: 'ri-music-2-line',
text: ''
}
);
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div
class="flex items-center gap-3 px-2 py-2 mb-2 rounded-xl cursor-pointer transition-colors duration-200 bg-light-100 dark:bg-dark-100 hover:bg-light-200 dark:hover:bg-dark-200"
@click="$emit('click')"
>
<n-image
:src="imageUrl"
class="w-[60px] h-[60px] flex-shrink-0 rounded-xl bg-light-300 dark:bg-dark-300"
lazy
preview-disabled
/>
<div class="flex-1 min-w-0">
<div class="text-base text-gray-900 dark:text-white mb-1">
<n-ellipsis :line-clamp="1">{{ name }}</n-ellipsis>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400 truncate">{{ description }}</div>
</div>
<div
v-if="showCount && count"
class="px-4 text-lg text-center min-w-[60px] text-gray-600 dark:text-gray-400 flex-shrink-0"
>
{{ count }}
</div>
<div
v-if="showDelete"
class="cursor-pointer rounded-full border-2 w-8 h-8 flex flex-shrink-0 justify-center items-center border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-red-500 hover:text-red-500 transition-colors duration-200"
@click.stop="$emit('delete')"
>
<i class="iconfont icon-close" />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
imageUrl: string;
name: string;
description: string;
count?: number;
showCount?: boolean;
showDelete?: boolean;
}>();
defineEmits<{
click: [];
delete: [];
}>();
</script>

View File

@@ -1,44 +1,34 @@
<template>
<div class="playlist-item" @click="handleClick">
<n-image
:src="getImgUrl(item.coverImgUrl || item.picUrl || '', '100y100')"
class="playlist-item-img"
lazy
preview-disabled
/>
<div class="playlist-item-info">
<div class="playlist-item-name">
<n-ellipsis :line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="playlist-item-desc">
{{ getDescription() }}
</div>
</div>
<div v-if="showCount && item.count" class="playlist-item-count">
{{ item.count }}
</div>
<div v-if="showDelete" class="playlist-item-delete" @click.stop="handleDelete">
<i class="iconfont icon-close"></i>
</div>
</div>
<history-item
:image-url="getImgUrl(item.coverImgUrl || item.picUrl || '', '100y100')"
:name="item.name"
:description="getDescription()"
:count="item.count"
:show-count="showCount"
:show-delete="showDelete"
@click="emit('click', item)"
@delete="emit('delete', item)"
/>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import HistoryItem from '@/components/common/HistoryItem.vue';
import type { PlaylistHistoryItem } from '@/store/modules/playHistory';
import { getImgUrl } from '@/utils';
interface Props {
item: PlaylistHistoryItem;
showCount?: boolean;
showDelete?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showCount: false,
showDelete: false
});
const props = withDefaults(
defineProps<{
item: PlaylistHistoryItem;
showCount?: boolean;
showDelete?: boolean;
}>(),
{
showCount: false,
showDelete: false
}
);
const emit = defineEmits<{
click: [item: PlaylistHistoryItem];
@@ -49,64 +39,9 @@ const { t } = useI18n();
const getDescription = () => {
const parts: string[] = [];
if (props.item.trackCount !== undefined) {
if (props.item.trackCount !== undefined)
parts.push(t('user.playlist.trackCount', { count: props.item.trackCount }));
}
if (props.item.creator?.nickname) {
parts.push(props.item.creator.nickname);
}
if (props.item.creator?.nickname) parts.push(props.item.creator.nickname);
return parts.join(' · ') || t('history.noDescription');
};
const handleClick = () => {
emit('click', props.item);
};
const handleDelete = () => {
emit('delete', props.item);
};
</script>
<style scoped lang="scss">
.playlist-item {
@apply flex items-center px-2 py-2 rounded-xl cursor-pointer;
@apply transition-all duration-200;
@apply bg-light-100 dark:bg-dark-100;
@apply hover:bg-light-200 dark:hover:bg-dark-200;
@apply mb-2;
&-img {
@apply flex items-center justify-center rounded-xl;
@apply w-[60px] h-[60px] flex-shrink-0;
@apply bg-light-300 dark:bg-dark-300;
}
&-info {
@apply ml-3 flex-1 min-w-0;
}
&-name {
@apply text-gray-900 dark:text-white text-base mb-1;
}
&-desc {
@apply text-sm text-gray-500 dark:text-gray-400;
}
&-count {
@apply px-4 text-lg text-center min-w-[60px];
@apply text-gray-600 dark:text-gray-400;
}
&-delete {
@apply cursor-pointer rounded-full border-2 w-8 h-8 flex justify-center items-center;
@apply border-gray-400 dark:border-gray-600;
@apply text-gray-600 dark:text-gray-400;
@apply hover:border-red-500 hover:text-red-500;
@apply transition-all;
}
}
</style>

View File

@@ -10,7 +10,7 @@
:class="[item.type === 'mv' ? 'aspect-video' : 'aspect-square']"
>
<n-image
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
:src="getImgUrl(item.picUrl, item.type === 'mv' ? '400y225' : '400y400')"
lazy
preview-disabled

View File

@@ -110,7 +110,7 @@ defineExpose({
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@apply rounded-3xl p-3 flex items-center transition bg-transparent dark:text-white text-gray-900;
@apply rounded-xl p-3 flex items-center transition bg-transparent dark:text-white text-gray-900;
}
.text-ellipsis {

View File

@@ -178,7 +178,7 @@ const formatDuration = (ms: number): string => {
<style lang="scss" scoped>
.compact-song-item {
@apply rounded-lg p-2 h-12 mb-1 border-b dark:border-gray-800 border-gray-100;
@apply rounded-xl p-2 h-12 mb-1 border-b dark:border-gray-800 border-gray-100;
&:hover {
@apply bg-gray-50 dark:bg-gray-700;

View File

@@ -143,14 +143,14 @@ const onPlayMusic = () => {
<style lang="scss" scoped>
.list-song-item {
@apply p-2 rounded-lg mb-2 border dark:border-gray-800 border-gray-200;
@apply p-2 rounded-xl mb-2 border dark:border-gray-800 border-gray-200;
&:hover {
@apply bg-gray-50 dark:bg-gray-800;
}
.song-item-img {
@apply w-10 h-10 rounded-lg mr-3;
@apply w-10 h-10 rounded-xl mr-3;
}
.song-item-content {

View File

@@ -140,7 +140,7 @@ const onPlayMusic = () => {
<style lang="scss" scoped>
.mini-song-item {
@apply p-2 rounded-2xl;
@apply p-2 rounded-xl;
&:hover {
@apply bg-light-100 dark:bg-dark-100;

View File

@@ -159,7 +159,7 @@ const onPlayNext = () => {
}
.song-item-img {
@apply w-12 h-12 rounded-2xl mr-4;
@apply w-12 h-12 rounded-xl mr-4;
}
.song-item-content {

View File

@@ -195,9 +195,9 @@ watch(
--n-rail-height: 3px;
--n-rail-color: rgba(255, 255, 255, 0.15);
--n-rail-color-dark: rgba(255, 255, 255, 0.15);
--n-fill-color: #1ed760; /* Spotify绿色可调整为其他绿色 */
--n-fill-color: #22c55e;
--n-handle-size: 0px; /* 隐藏滑块 */
--n-handle-color: #1ed760;
--n-handle-color: #22c55e;
&:hover {
--n-handle-size: 10px; /* 鼠标悬停时显示滑块 */