mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
feat: 皮肤系统重构、seek暂停修复、本地音乐优化、外观一体化
- 重构皮肤系统:提取 skins.ts 管理预设皮肤,CSS 变量由 JS 动态设置 - 提取公共 color.ts 工具函数(hexToRgba/toHex),消除重复定义 - 修复 seek 时暂停状态丢失的 bug(后端 audio_paused 状态保留) - 本地音乐页面:循环排序切换、三点菜单、打开所在文件夹 - 本地音乐文件夹管理:支持启用/禁用切换,兼容旧数据迁移 - 新增 show_item_in_folder 命令(Windows/macOS/Linux 跨平台) - 外观一体化:有壁纸时 TitleBar/Sidebar 透明,PlayerBar 统一透明度+backdrop-blur - 进度条外层直角、内层填充圆角 - 滚动条默认透明,悬停时显示 - 移除 PageHeader 粘性栏 - 内存优化:keep-alive TTL 5min、pageCache TTL 30min/上限30条、colorCache 上限200 - recentLocal 防抖写入、播放器 tick interval 500ms
This commit is contained in:
@ -62,23 +62,18 @@
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div v-else-if="songs.length" class="space-y-1">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
<VirtualSongList
|
||||
v-else-if="songs.length"
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -90,7 +85,7 @@ import { usePlayerStore } from '../stores/player';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { formatDate } from '../utils/format';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
|
||||
@ -114,23 +114,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="songs.length" class="space-y-1">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
<VirtualSongList
|
||||
v-else-if="songs.length"
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 专辑列表 -->
|
||||
@ -171,7 +166,7 @@ import { usePlayerStore } from '../stores/player';
|
||||
import { formatPlayCount, formatDate } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
import IconMusic from '~icons/lucide/music';
|
||||
|
||||
@ -23,30 +23,25 @@
|
||||
<p class="text-content-2 text-sm">加载失败</p>
|
||||
<button @click="loadData(true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="isCurrentSong(song.id)"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="isCurrentSong(song.id) ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
<VirtualSongList
|
||||
v-else
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { MusicApi } from '../api';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
|
||||
@ -59,10 +54,6 @@ const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref(false);
|
||||
|
||||
function isCurrentSong(songId: number): boolean {
|
||||
return player.currentSong?.id === songId;
|
||||
}
|
||||
|
||||
async function loadData(force = false) {
|
||||
if (!force) {
|
||||
const cached = pageCacheGet('dailySongs');
|
||||
|
||||
@ -28,30 +28,25 @@
|
||||
<button @click="loadData(true)" class="px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">重试</button>
|
||||
</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
|
||||
<div v-else class="space-y-1">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
<VirtualSongList
|
||||
v-else
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onActivated, watch } from 'vue';
|
||||
import { MusicApi } from '../api';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useUserStore } from '../stores/user';
|
||||
|
||||
@ -4,19 +4,12 @@
|
||||
<h1 class="text-2xl font-bold">本地音乐</h1>
|
||||
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} 首</span>
|
||||
<template #actions>
|
||||
<button
|
||||
@click="refresh"
|
||||
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
@click="showFolderModal = true"
|
||||
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
|
||||
title="文件夹管理"
|
||||
>
|
||||
<IconEllipsis class="w-5 h-5 fill-current" />
|
||||
<button @click="cycleSort" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition-all flex items-center justify-center gap-1 whitespace-nowrap">
|
||||
<IconArrowUpDown class="w-3 h-3" />
|
||||
{{ sortLabel }}
|
||||
</button>
|
||||
<button @click="refresh" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition">刷新</button>
|
||||
<button @click="showFolderModal = true" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition">扫描目录</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@ -29,27 +22,42 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="settings.localMusicPaths.length === 0" class="text-content-3 py-4">
|
||||
<div v-else-if="settings.localMusicFolders.length === 0" class="text-content-3 py-4">
|
||||
请先添加要扫描的文件夹
|
||||
</div>
|
||||
<div v-else-if="settings.enabledMusicPaths.length === 0" class="text-content-3 py-4">
|
||||
请至少启用一个扫描文件夹
|
||||
</div>
|
||||
<div v-else-if="songs.length === 0" class="text-content-3">
|
||||
当前文件夹下没有音乐文件,支持 mp3、flac、wav、ogg、aac、m4a 格式
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in normalizedSongs"
|
||||
v-for="(song, index) in sortedSongs"
|
||||
:key="song.id + '-' + index"
|
||||
:song="song"
|
||||
:song="sortedNormalized[index]"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(normalizedSongs, index)"
|
||||
@click="player.playFromList(sortedNormalized, index)"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(songs[index].fileSize) }}</span>
|
||||
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(song.fileSize) }}</span>
|
||||
<div class="relative flex-shrink-0" :ref="(el: any) => menuRefs[song.id] = el">
|
||||
<button @click.stop="toggleMenu(song.id)" class="text-content-3 hover:text-content transition p-1 rounded-md hover:bg-subtle">
|
||||
<IconEllipsis class="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
<div v-if="openMenuId === song.id"
|
||||
class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-xl shadow-xl z-50 py-1 min-w-[140px]">
|
||||
<button @click.stop="openFolder(song.path)" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-content-2 hover:bg-subtle hover:text-content transition whitespace-nowrap">
|
||||
<IconFolderOpen class="w-3.5 h-3.5" />
|
||||
打开所在文件夹
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SongListItem>
|
||||
</div>
|
||||
@ -59,35 +67,28 @@
|
||||
<div v-if="showFolderModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showFolderModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[420px] p-6 select-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">扫描文件夹</h2>
|
||||
<h2 class="text-lg font-semibold">扫描目录</h2>
|
||||
<button @click="showFolderModal = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="settings.localMusicPaths.length === 0" class="text-sm text-content-3 py-4 text-center">
|
||||
<div v-if="settings.localMusicFolders.length === 0" class="text-sm text-content-3 py-4 text-center">
|
||||
未添加任何文件夹
|
||||
</div>
|
||||
<div v-else class="space-y-1.5 max-h-60 overflow-y-auto mb-4">
|
||||
<div
|
||||
v-for="p in settings.localMusicPaths"
|
||||
:key="p"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-subtle rounded-lg group"
|
||||
>
|
||||
<div v-for="folder in settings.localMusicFolders" :key="folder.path" class="flex items-center gap-2 px-3 py-2 bg-subtle rounded-lg group">
|
||||
<button @click="settings.toggleLocalMusicFolder(folder.path)" class="flex-shrink-0" :title="folder.enabled ? '点击禁用' : '点击启用'">
|
||||
<IconCheckSquare v-if="folder.enabled" class="w-4 h-4 text-accent-text" />
|
||||
<IconSquare v-else class="w-4 h-4 text-content-4" />
|
||||
</button>
|
||||
<IconFolder class="w-4 h-4 text-content-3 flex-shrink-0" />
|
||||
<span class="text-sm text-content-2 truncate flex-1" :title="p">{{ p }}</span>
|
||||
<button
|
||||
@click="settings.removeLocalMusicPath(p)"
|
||||
class="text-content-4 hover:text-danger transition opacity-0 group-hover:opacity-100"
|
||||
title="移除"
|
||||
>
|
||||
<span class="text-sm truncate flex-1" :class="folder.enabled ? 'text-content-2' : 'text-content-4 line-through'" :title="folder.path">{{ folder.path }}</span>
|
||||
<button @click="settings.removeLocalMusicPath(folder.path)" class="text-content-4 hover:text-danger transition opacity-0 group-hover:opacity-100" title="移除">
|
||||
<IconX class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="addFolder"
|
||||
class="w-full py-2.5 rounded-lg bg-accent/15 text-accent-text hover:bg-accent/25 text-sm font-medium transition"
|
||||
>
|
||||
<button @click="addFolder" class="w-full py-2.5 rounded-lg bg-accent/15 text-accent-text hover:bg-accent/25 text-sm font-medium transition">
|
||||
添加文件夹
|
||||
</button>
|
||||
</div>
|
||||
@ -97,18 +98,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onActivated, watch } from 'vue';
|
||||
import { DownloadApi } from '../api';
|
||||
import { ref, computed, onMounted, onActivated, watch, onBeforeUnmount } from 'vue';
|
||||
import { AppApi, DownloadApi } from '../api';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { pageCacheSet, pageCacheIsStale } from '../composables/usePageCache';
|
||||
import { formatFileSize, localSongToSong, fetchMissingCovers, type LocalSong } from '../composables/useLocalMusic';
|
||||
import { showToast } from '../composables/useToast';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
import IconFolder from '~icons/lucide/folder';
|
||||
import IconFolderOpen from '~icons/lucide/folder-open';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconArrowUpDown from '~icons/lucide/arrow-up-down';
|
||||
import IconCheckSquare from '~icons/lucide/check-square';
|
||||
import IconSquare from '~icons/lucide/square';
|
||||
import IconEllipsis from '~icons/lucide/ellipsis';
|
||||
|
||||
defineOptions({ name: 'LocalMusicView' });
|
||||
|
||||
@ -119,7 +125,59 @@ const songs = ref<LocalSong[]>([]);
|
||||
const loading = ref(true);
|
||||
const showFolderModal = ref(false);
|
||||
|
||||
const normalizedSongs = computed(() => songs.value.map(localSongToSong));
|
||||
// 排序:点击循环切换
|
||||
type SortKey = 'default' | 'name' | 'size';
|
||||
const SORT_CYCLE: SortKey[] = ['default', 'name', 'size'];
|
||||
const SORT_LABELS: Record<SortKey, string> = { default: '默认', name: '名称', size: '大小' };
|
||||
const sortBy = ref<SortKey>('default');
|
||||
|
||||
const sortLabel = computed(() => SORT_LABELS[sortBy.value]);
|
||||
|
||||
function cycleSort() {
|
||||
const idx = SORT_CYCLE.indexOf(sortBy.value);
|
||||
sortBy.value = SORT_CYCLE[(idx + 1) % SORT_CYCLE.length];
|
||||
}
|
||||
|
||||
const sortedSongs = computed(() => {
|
||||
const list = [...songs.value];
|
||||
if (sortBy.value === 'name') {
|
||||
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
|
||||
} else if (sortBy.value === 'size') {
|
||||
list.sort((a, b) => b.fileSize - a.fileSize);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const sortedNormalized = computed(() => sortedSongs.value.map(localSongToSong));
|
||||
|
||||
// 三点菜单
|
||||
const openMenuId = ref<number | null>(null);
|
||||
const menuRefs: Record<number, HTMLElement | null> = {};
|
||||
|
||||
function toggleMenu(id: number) {
|
||||
openMenuId.value = openMenuId.value === id ? null : id;
|
||||
}
|
||||
|
||||
async function openFolder(path: string) {
|
||||
openMenuId.value = null;
|
||||
try {
|
||||
await AppApi.showItemInFolder(path);
|
||||
} catch (e: any) {
|
||||
showToast(e.toString(), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (openMenuId.value !== null) {
|
||||
const el = menuRefs[openMenuId.value];
|
||||
if (el && !el.contains(e.target as Node)) {
|
||||
openMenuId.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onClickOutside));
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside));
|
||||
|
||||
async function addFolder() {
|
||||
const selected = await open({
|
||||
@ -133,14 +191,15 @@ async function addFolder() {
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (settings.localMusicPaths.length === 0) {
|
||||
const paths = settings.enabledMusicPaths;
|
||||
if (paths.length === 0) {
|
||||
songs.value = [];
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await DownloadApi.scanLocalFolders(settings.localMusicPaths);
|
||||
const list = await DownloadApi.scanLocalFolders(paths);
|
||||
songs.value = list;
|
||||
pageCacheSet('localMusic', list);
|
||||
fetchMissingCovers(songs.value);
|
||||
@ -157,7 +216,7 @@ onActivated(() => {
|
||||
if (pageCacheIsStale('localMusic')) refresh();
|
||||
});
|
||||
|
||||
watch(() => settings.localMusicPaths, () => { refresh(); }, { deep: true });
|
||||
watch(() => settings.enabledMusicPaths, () => { refresh(); }, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -100,23 +100,18 @@
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div v-else-if="songs.length" class="space-y-1">
|
||||
<SongListItem
|
||||
v-for="(song, index) in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(songs, index)"
|
||||
/>
|
||||
</div>
|
||||
<VirtualSongList
|
||||
v-else-if="songs.length"
|
||||
:songs="songs"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(songs, i)"
|
||||
/>
|
||||
|
||||
<div v-else-if="!songsLoading && !loadError" class="text-content-2">暂无歌曲</div>
|
||||
|
||||
@ -136,7 +131,7 @@ import { showToast } from '../composables/useToast';
|
||||
import { formatPlayCount } from '../utils/format';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet } from '../composables/usePageCache';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import CommentSection from '../components/CommentSection.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import IconPlay from '~icons/lucide/play';
|
||||
|
||||
@ -4,29 +4,24 @@
|
||||
<h1 class="text-2xl font-bold">最近播放</h1>
|
||||
</PageHeader>
|
||||
<div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录,去听首歌吧</div>
|
||||
<div v-else class="space-y-2">
|
||||
<SongListItem
|
||||
v-for="(song, index) in player.recentLocal"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="index"
|
||||
:is-current="player.currentSong?.id === song.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
|
||||
@click="player.playFromList(player.recentLocal, index)"
|
||||
/>
|
||||
</div>
|
||||
<VirtualSongList
|
||||
v-else
|
||||
:songs="player.recentLocal"
|
||||
:current-song-id="player.currentSong?.id"
|
||||
show-index
|
||||
show-like
|
||||
show-download
|
||||
show-menu
|
||||
show-duration
|
||||
show-playing-overlay
|
||||
@song-click="(_s, i) => player.playFromList(player.recentLocal, i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import VirtualSongList from '../components/VirtualSongList.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
|
||||
const player = usePlayerStore();
|
||||
|
||||
@ -26,37 +26,84 @@
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-3">外观模式</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
v-for="(label, key) in appearanceLabels"
|
||||
:key="key"
|
||||
@click="settings.setAppearance(key)"
|
||||
class="flex items-center gap-2 px-4 py-2.5 rounded-xl transition-all border-2"
|
||||
:class="settings.appearance === key ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
<IconSun v-if="key === 'light'" class="w-4 h-4" :class="settings.appearance === key ? 'text-accent-text' : 'text-content-3'" />
|
||||
<IconMoon v-else class="w-4 h-4" :class="settings.appearance === key ? 'text-accent-text' : 'text-content-3'" />
|
||||
<span class="text-sm" :class="settings.appearance === key ? 'text-content font-medium' : 'text-content-3'">{{ label }}</span>
|
||||
</button>
|
||||
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">皮肤</h2>
|
||||
<div class="space-y-4">
|
||||
<!-- 明暗切换 -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="settings.setSkin(toSkinId(currentThemeColor, 'dark'))"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-xl transition-all border-2"
|
||||
:class="!settings.isPreset || currentAppearance !== 'dark' ? 'border-transparent bg-subtle hover:bg-muted' : 'border-accent/40 bg-accent/10'"
|
||||
>
|
||||
<IconMoon class="w-4 h-4" :class="currentAppearance === 'dark' && settings.isPreset ? 'text-accent-text' : 'text-content-3'" />
|
||||
<span class="text-sm" :class="currentAppearance === 'dark' && settings.isPreset ? 'text-content font-medium' : 'text-content-3'">深色</span>
|
||||
</button>
|
||||
<button
|
||||
@click="settings.setSkin(toSkinId(currentThemeColor, 'light'))"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-xl transition-all border-2"
|
||||
:class="!settings.isPreset || currentAppearance !== 'light' ? 'border-transparent bg-subtle hover:bg-muted' : 'border-accent/40 bg-accent/10'"
|
||||
>
|
||||
<IconSun class="w-4 h-4" :class="currentAppearance === 'light' && settings.isPreset ? 'text-accent-text' : 'text-content-3'" />
|
||||
<span class="text-sm" :class="currentAppearance === 'light' && settings.isPreset ? 'text-content font-medium' : 'text-content-3'">浅色</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主题色选择 -->
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
<div
|
||||
v-for="tc in themeColorOptions"
|
||||
:key="tc.id"
|
||||
@click="settings.setSkin(toSkinId(tc.id, currentAppearance))"
|
||||
class="flex flex-col items-center gap-1.5 p-2 rounded-xl transition-all border-2 cursor-pointer"
|
||||
:class="currentThemeColor === tc.id && settings.isPreset ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: tc.color }"></div>
|
||||
<span class="text-[11px]" :class="currentThemeColor === tc.id && settings.isPreset ? 'text-content font-medium' : 'text-content-3'">{{ tc.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-3">主题色</p>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<button
|
||||
v-for="(color, key) in themeColors"
|
||||
:key="key"
|
||||
@click="settings.setTheme(key)"
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-xl transition-all border-2"
|
||||
:class="settings.theme === key ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
|
||||
<!-- 自定义皮肤 -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-content-3">自定义</p>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<div
|
||||
v-for="s in settings.customSkins"
|
||||
:key="s.id"
|
||||
@click="settings.setSkin(s.id)"
|
||||
class="flex flex-col items-center gap-1.5 p-2 rounded-xl transition-all border-2 cursor-pointer relative group"
|
||||
:class="settings.skin === s.id ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
|
||||
<span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
|
||||
</button>
|
||||
<div class="w-8 h-8 rounded-full shadow-md relative overflow-hidden" :style="{ backgroundColor: s.preview }">
|
||||
<div v-if="s.wallpaper && skinWallpaperDataUrls[s.wallpaper]" class="absolute inset-0 bg-cover bg-center opacity-40" :style="{ backgroundImage: `url(${skinWallpaperDataUrls[s.wallpaper]})` }"></div>
|
||||
</div>
|
||||
<span class="text-[11px] truncate w-full text-center" :class="settings.skin === s.id ? 'text-content font-medium' : 'text-content-3'">{{ s.name }}</span>
|
||||
<!-- 编辑按钮 -->
|
||||
<button
|
||||
@click.stop="openSkinEditor(s.id)"
|
||||
class="absolute -top-1 -right-1 w-4 h-4 flex items-center justify-center rounded-full bg-accent/60 text-white opacity-0 group-hover:opacity-100 transition"
|
||||
title="编辑"
|
||||
>
|
||||
<svg class="w-2 h-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<!-- 删除按钮 -->
|
||||
<button
|
||||
@click.stop="handleDeleteCustomSkin(s.id)"
|
||||
class="absolute -top-1 -left-1 w-4 h-4 flex items-center justify-center rounded-full bg-danger/80 text-white opacity-0 group-hover:opacity-100 transition"
|
||||
title="删除"
|
||||
>
|
||||
<IconX style="font-size: 8px" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- 创建自定义皮肤(永远排最后) -->
|
||||
<div
|
||||
@click="openSkinEditor()"
|
||||
class="flex flex-col items-center justify-center gap-1.5 p-2 rounded-xl transition-all border-2 border-dashed border-line cursor-pointer hover:border-accent/40 hover:bg-accent/5"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-subtle">
|
||||
<IconPalette class="w-4 h-4 text-content-3" />
|
||||
</div>
|
||||
<span class="text-[11px] text-content-3">自定义</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -201,7 +248,7 @@
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="fade">
|
||||
<div v-if="showResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showResetConfirm = false">
|
||||
<div v-if="showResetConfirm" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showResetConfirm = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||
<h2 class="text-lg font-semibold text-content mb-1">确认重置</h2>
|
||||
<p class="text-sm text-content-2 mb-5">所有设置将恢复为默认值,此操作不可撤销。</p>
|
||||
@ -220,7 +267,7 @@
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showChangelogModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showChangelogModal = false">
|
||||
<div v-if="showChangelogModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showChangelogModal = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[480px] max-h-[80vh] flex flex-col select-auto">
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
@ -249,15 +296,366 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 皮肤编辑器弹窗(Teleport 到 body 避免 z-index 问题) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showSkinEditor" class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showSkinEditor = false">
|
||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[960px] max-h-[90vh] flex flex-col select-auto">
|
||||
<!-- 顶栏 -->
|
||||
<div class="flex items-center justify-between p-5 border-b border-line-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">{{ editingSkinId ? '编辑皮肤' : '创建自定义皮肤' }}</h2>
|
||||
<input v-model="editorName" class="px-3 py-1.5 bg-subtle border border-line rounded-lg text-sm text-content focus:border-accent focus:outline-none transition w-40" placeholder="皮肤名称" />
|
||||
</div>
|
||||
<button @click="showSkinEditor = false" class="text-content-3 hover:text-content transition">
|
||||
<IconX class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- 左侧:实时预览 -->
|
||||
<div class="w-[420px] flex-shrink-0 p-5 border-r border-line-2 flex flex-col gap-4">
|
||||
<p class="text-xs text-content-3 font-medium">实时预览</p>
|
||||
<!-- 横向桌面比例预览 -->
|
||||
<div class="rounded-xl overflow-hidden border border-line relative" style="aspect-ratio: 16/10;" :style="{ backgroundColor: getEditorColor('bg') }">
|
||||
<!-- 壁纸层 -->
|
||||
<div v-if="editorWallpaper && editorWallpaperDataUrl" class="absolute inset-0 bg-cover bg-center" :style="{ backgroundImage: `url(${editorWallpaperDataUrl})`, filter: `blur(${editorWallpaperBlur}px)`, opacity: editorWallpaperOpacity }"></div>
|
||||
<!-- 无壁纸时的提示 -->
|
||||
<div v-if="!editorWallpaper" class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-[10px] opacity-20" :style="{ color: getEditorColor('content3') }">纯色背景</span>
|
||||
</div>
|
||||
<!-- 模拟内容 -->
|
||||
<div class="relative z-[1] flex flex-col h-full">
|
||||
<!-- 模拟 TitleBar -->
|
||||
<div class="h-5 flex items-center justify-end px-2 flex-shrink-0" :style="{ backgroundColor: `${getEditorColor('surface')}cc` }">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-red-500"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-yellow-500 ml-1"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-green-500 ml-1"></div>
|
||||
</div>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<!-- 模拟 Sidebar (w-56 比例) -->
|
||||
<div class="w-[30%] flex-shrink-0 flex flex-col p-1.5 gap-0.5" :style="{ backgroundColor: `${getEditorColor('surface')}cc`, borderRight: `1px solid ${getEditorColor('line')}` }">
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded" :style="{ backgroundColor: getEditorColor('muted') }">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
<div class="h-1 rounded-full w-6" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-5" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-4" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
<div class="mt-1 pt-1" :style="{ borderTop: `1px solid ${getEditorColor('line2')}` }">
|
||||
<div class="h-0.5 rounded-full w-4 mb-0.5" :style="{ backgroundColor: getEditorColor('content4') }"></div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-7" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 rounded">
|
||||
<div class="w-1.5 h-1.5 rounded-sm" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<div class="h-1 rounded-full w-6" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部设置+头像 -->
|
||||
<div class="mt-auto flex items-center gap-1 px-1.5 py-1">
|
||||
<div class="w-3 h-3 rounded-full" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="h-1 rounded-full w-5" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模拟主内容 -->
|
||||
<div class="flex-1 p-2 flex flex-col gap-1.5 overflow-hidden" :style="editorWallpaper ? { backgroundColor: `${getEditorColor('bg')}cc` } : {}">
|
||||
<div class="h-2 rounded-full w-14" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<div class="h-1 rounded-full w-20" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
<!-- 模拟歌曲行 -->
|
||||
<div class="mt-1 flex items-center gap-1 px-1 py-0.5 rounded" :style="{ backgroundColor: `${getEditorColor('surface')}99` }">
|
||||
<div class="w-2 text-right flex-shrink-0"><div class="h-0.5 rounded-full w-1.5 ml-auto" :style="{ backgroundColor: getEditorColor('content4') }"></div></div>
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-12" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<div class="h-0.5 rounded-full w-8" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 rounded">
|
||||
<div class="w-2 text-right flex-shrink-0"><div class="h-0.5 rounded-full w-1.5 ml-auto" :style="{ backgroundColor: getEditorColor('content4') }"></div></div>
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-10" :style="{ backgroundColor: getEditorColor('content2') }"></div>
|
||||
<div class="h-0.5 rounded-full w-14" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 选中行(均衡器动画) -->
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 rounded" :style="{ backgroundColor: getEditorColor('accentDim') }">
|
||||
<div class="w-2 flex items-center justify-end flex-shrink-0 gap-[1px]">
|
||||
<div class="w-[1px] rounded-full" :style="{ backgroundColor: getEditorColor('accentText'), height: '3px' }"></div>
|
||||
<div class="w-[1px] rounded-full" :style="{ backgroundColor: getEditorColor('accentText'), height: '5px' }"></div>
|
||||
<div class="w-[1px] rounded-full" :style="{ backgroundColor: getEditorColor('accentText'), height: '2px' }"></div>
|
||||
</div>
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-11" :style="{ backgroundColor: getEditorColor('accentText') }"></div>
|
||||
<div class="h-0.5 rounded-full w-8" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模拟按钮 -->
|
||||
<div class="flex gap-1 mt-0.5">
|
||||
<div class="px-2 py-0.5 rounded text-[6px] font-medium text-white" :style="{ backgroundColor: getEditorColor('accent') }">播放全部</div>
|
||||
<div class="px-2 py-0.5 rounded text-[6px]" :style="{ backgroundColor: getEditorColor('muted'), color: getEditorColor('content2') }">收藏</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模拟 PlayerBar -->
|
||||
<div class="flex-shrink-0 flex flex-col" :style="{ backgroundColor: `${getEditorColor('surface')}f2` }">
|
||||
<!-- 进度条 -->
|
||||
<div class="h-0.5 w-full" :style="{ backgroundColor: getEditorColor('muted') }">
|
||||
<div class="h-full w-1/3" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
</div>
|
||||
<div class="flex items-center px-2 h-6 gap-1.5">
|
||||
<!-- 封面+歌名 -->
|
||||
<div class="flex items-center gap-1 w-[30%] min-w-0">
|
||||
<div class="w-4 h-4 rounded flex-shrink-0" :style="{ backgroundColor: getEditorColor('subtle') }"></div>
|
||||
<div class="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div class="h-0.5 rounded-full w-10" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<div class="h-0.5 rounded-full w-6" :style="{ backgroundColor: getEditorColor('content3') }"></div>
|
||||
</div>
|
||||
<svg class="w-1.5 h-1.5 flex-shrink-0" :style="{ color: getEditorColor('content3') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
|
||||
</div>
|
||||
<!-- 播放控制 -->
|
||||
<div class="flex-1 flex items-center justify-center gap-2">
|
||||
<svg class="w-2 h-2" :style="{ color: getEditorColor('content2') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="19 20 9 12 19 4" fill="currentColor"/><line x1="5" y1="4" x2="5" y2="20"/></svg>
|
||||
<div class="w-4 h-4 rounded-full flex items-center justify-center" :style="{ backgroundColor: getEditorColor('muted'), border: `1px solid ${getEditorColor('emphasis')}` }">
|
||||
<svg class="w-2 h-2" :style="{ color: getEditorColor('content') }" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21"/></svg>
|
||||
</div>
|
||||
<svg class="w-2 h-2" :style="{ color: getEditorColor('content2') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 4 15 12 5 20" fill="currentColor"/><line x1="19" y1="4" x2="19" y2="20"/></svg>
|
||||
</div>
|
||||
<!-- 右侧 -->
|
||||
<div class="w-[30%] flex items-center justify-end gap-1">
|
||||
<svg class="w-1.5 h-1.5" :style="{ color: getEditorColor('content3') }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="currentColor"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
|
||||
<div class="w-4 h-0.5 rounded-full" :style="{ backgroundColor: getEditorColor('muted') }">
|
||||
<div class="h-full w-2/3 rounded-full" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 基础风格快选 -->
|
||||
<div>
|
||||
<p class="text-xs text-content-3 mb-2">基于预设风格</p>
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
<div
|
||||
v-for="s in presetSkins"
|
||||
:key="s.id"
|
||||
@click="editorBaseSkin = s.id; onBaseSkinChange()"
|
||||
class="w-6 h-6 rounded-full cursor-pointer border-2 transition-all"
|
||||
:class="editorBaseSkin === s.id ? 'border-white scale-125' : 'border-transparent hover:scale-110'"
|
||||
:style="{ backgroundColor: s.preview }"
|
||||
:title="s.name"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:控制面板 -->
|
||||
<div class="flex-1 overflow-y-auto p-5 space-y-5">
|
||||
<!-- 背景与壁纸 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
<p class="text-sm font-medium">背景与壁纸</p>
|
||||
</div>
|
||||
<div class="pl-3 space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('bg'))" @input="setEditorColor('bg', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">背景色</p>
|
||||
<p class="text-[11px] text-content-3">整个页面的底色,壁纸会覆盖在上面</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="pickEditorWallpaper" class="flex items-center gap-2 px-3 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition">
|
||||
<IconImage class="w-4 h-4" />
|
||||
{{ editorWallpaper ? '更换图片' : '选择壁纸图片' }}
|
||||
</button>
|
||||
<button v-if="editorWallpaper" @click="editorWallpaper = ''" class="px-3 py-2 bg-subtle hover:bg-danger/10 rounded-lg text-sm text-content-2 hover:text-danger transition">移除</button>
|
||||
</div>
|
||||
<template v-if="editorWallpaper">
|
||||
<div>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-xs text-content-3">模糊</span>
|
||||
<span class="text-xs text-content-4">{{ editorWallpaperBlur }}px</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="30" step="1" v-model.number="editorWallpaperBlur" class="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-xs text-content-3">透明度</span>
|
||||
<span class="text-xs text-content-4">{{ Math.round(editorWallpaperOpacity * 100) }}%</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" step="5" :value="Math.round(editorWallpaperOpacity * 100)" @input="editorWallpaperOpacity = Number(($event.target as HTMLInputElement).value) / 100" class="w-full h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-accent" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主题色 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('accent') }"></div>
|
||||
<p class="text-sm font-medium">主题色</p>
|
||||
</div>
|
||||
<p class="text-[11px] text-content-3 pl-3">按钮、链接、高亮、播放图标等使用这个颜色</p>
|
||||
<div class="pl-3 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('accent'))" @input="setEditorColor('accent', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">主题色</p>
|
||||
<p class="text-[11px] text-content-3">按钮、进度条、选中状态</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('accentDim'))" @input="setEditorColor('accentDim', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">主题色淡</p>
|
||||
<p class="text-[11px] text-content-3">选中项的背景、淡色高亮</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文字颜色 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('content') }"></div>
|
||||
<p class="text-sm font-medium">文字</p>
|
||||
</div>
|
||||
<div class="pl-3 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('content'))" @input="setEditorColor('content', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">主要文字</p>
|
||||
<p class="text-[11px] text-content-3">标题、歌曲名等最重要的文字</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('content2'))" @input="setEditorColor('content2', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">次要文字</p>
|
||||
<p class="text-[11px] text-content-3">歌手名、专辑名</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('content3'))" @input="setEditorColor('content3', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">辅助文字</p>
|
||||
<p class="text-[11px] text-content-3">描述、时间、播放量等</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表面与卡片 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded-full" :style="{ backgroundColor: getEditorColor('surface') }"></div>
|
||||
<p class="text-sm font-medium">表面与卡片</p>
|
||||
</div>
|
||||
<p class="text-[11px] text-content-3 pl-3">侧栏、底栏、弹窗、歌曲卡片的背景色</p>
|
||||
<div class="pl-3 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch"><input type="color" :value="toHex(getEditorColor('surface'))" @input="setEditorColor('surface', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm">卡片背景</p>
|
||||
<p class="text-[11px] text-content-3">弹窗、侧栏、底栏的主色</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-sm"><input type="color" :value="toHex(getEditorColor('line'))" @input="setEditorColor('line', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<div>
|
||||
<p class="text-sm text-content-2">分割线</p>
|
||||
<p class="text-[11px] text-content-3">卡片边框、区域之间的分隔</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 更多细节(折叠) -->
|
||||
<div>
|
||||
<button @click="showAdvancedEditor = !showAdvancedEditor" class="flex items-center gap-1.5 text-xs text-content-3 hover:text-content-2 transition">
|
||||
<svg class="w-3 h-3 transition-transform" :class="showAdvancedEditor ? 'rotate-90' : ''" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
更多细节调整
|
||||
</button>
|
||||
<div v-if="showAdvancedEditor" class="mt-3 space-y-4 pl-1">
|
||||
<div>
|
||||
<p class="text-[11px] text-content-3 mb-1.5">悬停与交互</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('subtle'))" @input="setEditorColor('subtle', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">微弱背景</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('muted'))" @input="setEditorColor('muted', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">悬停背景</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('emphasis'))" @input="setEditorColor('emphasis', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">强调背景</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] text-content-3 mb-1.5">主题色变体</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('accentHover'))" @input="setEditorColor('accentHover', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">按钮悬停</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('accentText'))" @input="setEditorColor('accentText', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">主题文字</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] text-content-3 mb-1.5">功能色</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('danger'))" @input="setEditorColor('danger', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">危险/错误</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="color-swatch color-swatch-xs"><input type="color" :value="toHex(getEditorColor('warning'))" @input="setEditorColor('warning', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<span class="text-xs text-content-2">警告</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底栏 -->
|
||||
<div class="p-4 border-t border-line flex gap-3">
|
||||
<button @click="showSkinEditor = false" class="flex-1 py-2.5 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">取消</button>
|
||||
<button @click="handleSaveSkin" :disabled="!editorName.trim()" class="flex-1 py-2.5 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition disabled:opacity-50">{{ editingSkinId ? '保存修改' : '创建皮肤' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, appearanceLabels, type CloseAction } from '../stores/settings';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, reactive, watch } from 'vue';
|
||||
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, type CloseAction } from '../stores/settings';
|
||||
import { presetSkins, getPresetSkin, type SkinColors } from '../skins';
|
||||
import { toHex } from '../utils/color';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { useUpdater } from '../composables/useUpdater';
|
||||
import { DeviceApi, DownloadApi } from '../api';
|
||||
import { DeviceApi, DownloadApi, AppApi } from '../api';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
@ -266,13 +664,224 @@ import PageHeader from '../components/PageHeader.vue';
|
||||
import IconX from '~icons/lucide/x';
|
||||
import IconFileText from '~icons/lucide/file-text';
|
||||
import IconLoader2 from '~icons/lucide/loader-2';
|
||||
import IconPalette from '~icons/lucide/palette';
|
||||
import IconSun from '~icons/lucide/sun';
|
||||
import IconMoon from '~icons/lucide/moon';
|
||||
import IconImage from '~icons/lucide/image';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const { showToast } = useToast();
|
||||
const updater = useUpdater();
|
||||
|
||||
// 主题色选项(7色,不分深浅)
|
||||
const themeColorOptions = [
|
||||
{ id: 'blue', name: '蓝', color: '#3b82f6' },
|
||||
{ id: 'green', name: '翠', color: '#22c55e' },
|
||||
{ id: 'rose', name: '红', color: '#f43f5e' },
|
||||
{ id: 'violet', name: '紫', color: '#8b5cf6' },
|
||||
{ id: 'orange', name: '橙', color: '#f97316' },
|
||||
{ id: 'cyan', name: '青', color: '#06b6d4' },
|
||||
{ id: 'pink', name: '粉', color: '#ec4899' },
|
||||
];
|
||||
|
||||
// 从当前 skin id 解析出 appearance 和 themeColor
|
||||
const currentAppearance = computed(() => {
|
||||
if (settings.skin.startsWith('light')) return 'light';
|
||||
return 'dark';
|
||||
});
|
||||
|
||||
const currentThemeColor = computed(() => {
|
||||
const id = settings.skin;
|
||||
if (id.startsWith('dark-')) return id.slice(5);
|
||||
if (id.startsWith('light-')) return id.slice(6);
|
||||
return 'blue'; // 自定义皮肤默认蓝
|
||||
});
|
||||
|
||||
function toSkinId(color: string, appearance: 'dark' | 'light'): string {
|
||||
return `${appearance}-${color}`;
|
||||
}
|
||||
|
||||
// 壁纸路径转可访问 URL(通过 Rust 命令读取本地图片转 base64 data URL)
|
||||
const wallpaperCache = new Map<string, string>();
|
||||
const MAX_WALLPAPER_CACHE = 10;
|
||||
async function wallpaperSrc(path: string): Promise<string> {
|
||||
if (!path) return '';
|
||||
if (wallpaperCache.has(path)) return wallpaperCache.get(path)!;
|
||||
try {
|
||||
const dataUrl = await AppApi.readImageAsDataUrl(path);
|
||||
if (wallpaperCache.size >= MAX_WALLPAPER_CACHE) {
|
||||
const firstKey = wallpaperCache.keys().next().value;
|
||||
if (firstKey !== undefined) wallpaperCache.delete(firstKey);
|
||||
}
|
||||
wallpaperCache.set(path, dataUrl);
|
||||
return dataUrl;
|
||||
} catch (e) {
|
||||
console.error('加载壁纸预览失败:', e);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
// 用于模板中同步绑定壁纸预览的响应式数据
|
||||
const editorWallpaperDataUrl = ref('');
|
||||
const skinWallpaperDataUrls = ref<Record<string, string>>({});
|
||||
|
||||
async function loadEditorWallpaper() {
|
||||
if (!editorWallpaper.value) {
|
||||
editorWallpaperDataUrl.value = '';
|
||||
return;
|
||||
}
|
||||
editorWallpaperDataUrl.value = await wallpaperSrc(editorWallpaper.value);
|
||||
}
|
||||
|
||||
async function loadSkinWallpaperPreviews() {
|
||||
for (const s of settings.customSkins) {
|
||||
if (s.wallpaper && !skinWallpaperDataUrls.value[s.wallpaper]) {
|
||||
const url = await wallpaperSrc(s.wallpaper);
|
||||
if (url) skinWallpaperDataUrls.value[s.wallpaper] = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 皮肤编辑器
|
||||
const showSkinEditor = ref(false);
|
||||
const showAdvancedEditor = ref(false);
|
||||
const editorName = ref('');
|
||||
const editorBaseSkin = ref('dark-blue');
|
||||
const editorColors = reactive<Partial<SkinColors>>({});
|
||||
const editorWallpaper = ref('');
|
||||
const editorWallpaperBlur = ref(10);
|
||||
const editorWallpaperOpacity = ref(0.3);
|
||||
/** 正在编辑的已有皮肤 id,为空则表示创建新皮肤 */
|
||||
const editingSkinId = ref<string | null>(null);
|
||||
|
||||
function openSkinEditor(skinId?: string) {
|
||||
if (skinId) {
|
||||
// 编辑已有自定义皮肤
|
||||
const existing = settings.customSkins.find(s => s.id === skinId);
|
||||
if (!existing) return;
|
||||
editingSkinId.value = skinId;
|
||||
editorName.value = existing.name;
|
||||
editorBaseSkin.value = 'dark-blue';
|
||||
// 将已有颜色完整填入 editorColors
|
||||
Object.keys(editorColors).forEach(k => delete editorColors[k as keyof SkinColors]);
|
||||
for (const [key, value] of Object.entries(existing.colors)) {
|
||||
(editorColors as any)[key] = value;
|
||||
}
|
||||
editorWallpaper.value = existing.wallpaper || '';
|
||||
editorWallpaperBlur.value = existing.wallpaperBlur ?? 10;
|
||||
editorWallpaperOpacity.value = existing.wallpaperOpacity ?? 0.3;
|
||||
} else {
|
||||
// 创建新皮肤:基于当前皮肤或默认
|
||||
editingSkinId.value = null;
|
||||
editorName.value = '';
|
||||
const baseSkinId = settings.isPreset ? settings.skin : 'dark-blue';
|
||||
editorBaseSkin.value = baseSkinId;
|
||||
// 将基础皮肤颜色完整填入 editorColors
|
||||
Object.keys(editorColors).forEach(k => delete editorColors[k as keyof SkinColors]);
|
||||
const base = getPresetSkin(baseSkinId);
|
||||
if (base) {
|
||||
for (const [key, value] of Object.entries(base.colors)) {
|
||||
(editorColors as any)[key] = value;
|
||||
}
|
||||
}
|
||||
editorWallpaper.value = '';
|
||||
editorWallpaperBlur.value = 10;
|
||||
editorWallpaperOpacity.value = 0.3;
|
||||
}
|
||||
showSkinEditor.value = true;
|
||||
loadEditorWallpaper();
|
||||
loadSkinWallpaperPreviews();
|
||||
}
|
||||
|
||||
function onBaseSkinChange() {
|
||||
// 切换基础风格时,将该风格的完整颜色填入 editorColors
|
||||
Object.keys(editorColors).forEach(k => delete editorColors[k as keyof SkinColors]);
|
||||
const base = getPresetSkin(editorBaseSkin.value);
|
||||
if (base) {
|
||||
for (const [key, value] of Object.entries(base.colors)) {
|
||||
(editorColors as any)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getEditorColor(key: keyof SkinColors): string {
|
||||
return editorColors[key] || '#000000';
|
||||
}
|
||||
|
||||
function setEditorColor(key: keyof SkinColors, value: string) {
|
||||
editorColors[key] = value;
|
||||
}
|
||||
|
||||
function handleSaveSkin() {
|
||||
if (!editorName.value.trim()) return;
|
||||
// 确保颜色完整:缺失字段从基础皮肤补齐
|
||||
const base = getPresetSkin(editorBaseSkin.value);
|
||||
const baseColors = base ? base.colors : getPresetSkin('dark-blue')!.colors;
|
||||
const colors = { ...baseColors } as SkinColors;
|
||||
for (const key of Object.keys(editorColors) as (keyof SkinColors)[]) {
|
||||
if (editorColors[key]) {
|
||||
colors[key] = editorColors[key]!;
|
||||
}
|
||||
}
|
||||
|
||||
if (editingSkinId.value) {
|
||||
settings.updateCustomSkin(editingSkinId.value, {
|
||||
name: editorName.value.trim(),
|
||||
preview: colors.accent,
|
||||
colors,
|
||||
wallpaper: editorWallpaper.value,
|
||||
wallpaperBlur: editorWallpaperBlur.value,
|
||||
wallpaperOpacity: editorWallpaperOpacity.value,
|
||||
});
|
||||
showSkinEditor.value = false;
|
||||
showToast('皮肤已更新', 'success');
|
||||
} else {
|
||||
const id = `custom-${Date.now()}`;
|
||||
settings.addCustomSkin({
|
||||
id,
|
||||
name: editorName.value.trim(),
|
||||
preview: colors.accent,
|
||||
colors,
|
||||
wallpaper: editorWallpaper.value,
|
||||
wallpaperBlur: editorWallpaperBlur.value,
|
||||
wallpaperOpacity: editorWallpaperOpacity.value,
|
||||
});
|
||||
showSkinEditor.value = false;
|
||||
showToast('自定义皮肤已创建', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteCustomSkin(id: string) {
|
||||
settings.removeCustomSkin(id);
|
||||
showToast('已删除自定义皮肤', 'success');
|
||||
}
|
||||
|
||||
async function pickEditorWallpaper() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
title: '选择壁纸图片',
|
||||
filters: [{
|
||||
name: '图片',
|
||||
extensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif'],
|
||||
}],
|
||||
});
|
||||
if (selected) {
|
||||
editorWallpaper.value = selected;
|
||||
wallpaperCache.delete(selected);
|
||||
loadEditorWallpaper();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 监听编辑器壁纸变化
|
||||
watch(editorWallpaper, () => {
|
||||
loadEditorWallpaper();
|
||||
});
|
||||
|
||||
// 监听自定义皮肤列表变化,加载壁纸预览
|
||||
watch(() => settings.customSkins, () => {
|
||||
loadSkinWallpaperPreviews();
|
||||
}, { deep: true });
|
||||
|
||||
const devices = ref<string[]>([]);
|
||||
const deviceOptions = computed(() => {
|
||||
const options: Record<string, string> = { '': '跟随系统默认' };
|
||||
@ -450,3 +1059,50 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onRecordingKeydown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 颜色选择器:让 input[type=color] 填满外层 label,消除内部小方块 */
|
||||
.color-swatch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-line);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.color-swatch-sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.color-swatch-xs {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.color-swatch input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: calc(100% + 8px);
|
||||
height: calc(100% + 8px);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
.color-swatch input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
.color-swatch input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.color-swatch input[type="color"]::-moz-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user