整理重构前端
This commit is contained in:
186
web/src/App.vue
186
web/src/App.vue
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
import { Menu } from 'lucide-vue-next'
|
import { Menu, Music, Settings, Info } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
import AudioPlayer from './components/AudioPlayer.vue'
|
import AudioPlayer from './components/AudioPlayer.vue'
|
||||||
|
|
||||||
const sidebarOpen = ref(false)
|
const sidebarOpen = ref(false)
|
||||||
@@ -10,25 +10,47 @@ const sidebarOpen = ref(false)
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<!-- 移动端遮罩层 -->
|
<!-- 移动端遮罩层 -->
|
||||||
<div v-if="sidebarOpen" class="overlay" @click="sidebarOpen = false"></div>
|
<Transition name="overlay">
|
||||||
|
<div v-if="sidebarOpen" class="overlay" @click="sidebarOpen = false"></div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- 侧边栏 -->
|
<!-- 侧边栏 -->
|
||||||
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
||||||
<nav>
|
<div class="sidebar-header">
|
||||||
<RouterLink to="/music" @click="sidebarOpen = false">所有歌曲</RouterLink>
|
<Music class="sidebar-logo" :size="24" />
|
||||||
<RouterLink to="/settings" @click="sidebarOpen = false">设置</RouterLink>
|
<span class="sidebar-title">butterfliu</span>
|
||||||
<RouterLink to="/about" @click="sidebarOpen = false">About</RouterLink>
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<RouterLink to="/music" @click="sidebarOpen = false" class="nav-link">
|
||||||
|
<Music :size="18" />
|
||||||
|
<span>所有歌曲</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/settings" @click="sidebarOpen = false" class="nav-link">
|
||||||
|
<Settings :size="18" />
|
||||||
|
<span>设置</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/about" @click="sidebarOpen = false" class="nav-link">
|
||||||
|
<Info :size="18" />
|
||||||
|
<span>关于</span>
|
||||||
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<span>v0.1.0</span>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
<!-- 主内容区 -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<!-- 移动端汉堡菜单按钮 -->
|
<!-- 移动端汉堡菜单按钮 -->
|
||||||
<button class="menu-toggle" @click="sidebarOpen = !sidebarOpen">
|
<button class="menu-toggle" @click="sidebarOpen = !sidebarOpen" aria-label="打开菜单">
|
||||||
<Menu />
|
<Menu :size="24" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<RouterView />
|
<Transition name="fade" mode="out-in">
|
||||||
|
<RouterView />
|
||||||
|
</Transition>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,66 +63,129 @@ const sidebarOpen = ref(false)
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 侧边栏 === */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 200px;
|
width: 220px;
|
||||||
background-color: #f5f5f5;
|
background: linear-gradient(180deg, var(--stone-1) 0%, var(--stone-0) 100%);
|
||||||
padding: 20px;
|
padding: var(--size-5);
|
||||||
border-right: 1px solid #e0e0e0;
|
border-right: 1px solid var(--border);
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar nav {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar a {
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-2);
|
||||||
|
padding-bottom: var(--size-5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: var(--size-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
color: var(--brand);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-size: var(--font-size-2);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-1);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-3);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #333;
|
color: var(--text-secondary);
|
||||||
padding: 10px;
|
padding: var(--size-2) var(--size-3);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-2);
|
||||||
transition: background-color 0.2s;
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--font-size-1);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar a:hover {
|
.nav-link:hover {
|
||||||
background-color: #e0e0e0;
|
background-color: var(--surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar a.router-link-active {
|
.nav-link.router-link-active {
|
||||||
background-color: #42b883;
|
background-color: var(--brand-light);
|
||||||
color: white;
|
color: var(--brand);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-link.router-link-active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 60%;
|
||||||
|
background-color: var(--brand);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding-top: var(--size-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer span {
|
||||||
|
font-size: var(--font-size-0);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 主内容区 === */
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 20px;
|
padding: var(--size-6);
|
||||||
|
padding-bottom: calc(100px + var(--size-6));
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 汉堡菜单按钮 */
|
/* === 汉堡菜单按钮 === */
|
||||||
.menu-toggle {
|
.menu-toggle {
|
||||||
display: none;
|
display: none;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 10px;
|
padding: var(--size-2);
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--size-4);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
transition: background-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 遮罩层 */
|
.menu-toggle:hover {
|
||||||
|
background-color: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 遮罩层 === */
|
||||||
.overlay {
|
.overlay {
|
||||||
display: none;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 998;
|
z-index: 998;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移动端响应式 */
|
/* === 移动端响应式 === */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -109,22 +194,31 @@ const sidebarOpen = ref(false)
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
|
box-shadow: var(--shadow-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.open {
|
.sidebar.open {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-toggle {
|
.menu-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
padding: 10px;
|
padding: var(--size-3);
|
||||||
|
padding-bottom: calc(100px + var(--size-3));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 遮罩动画 === */
|
||||||
|
.overlay-enter-active,
|
||||||
|
.overlay-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-enter-from,
|
||||||
|
.overlay-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,3 +18,90 @@
|
|||||||
@import 'open-props/sizes';
|
@import 'open-props/sizes';
|
||||||
@import 'open-props/gradients';
|
@import 'open-props/gradients';
|
||||||
/* see PropPacks for the full list */
|
/* see PropPacks for the full list */
|
||||||
|
|
||||||
|
/* === 全局重置 === */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条美化 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--stone-4);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--stone-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮重置 — 覆盖 Open Props 默认样式 */
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框基础样式 */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="search"],
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--font-size-1);
|
||||||
|
padding: var(--size-2) var(--size-3);
|
||||||
|
border: 1px solid var(--stone-3);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background-color: var(--stone-0);
|
||||||
|
color: var(--stone-10);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="password"]:focus,
|
||||||
|
input[type="email"]:focus,
|
||||||
|
input[type="number"]:focus,
|
||||||
|
input[type="search"]:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 3px var(--brand-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]::placeholder,
|
||||||
|
input[type="password"]::placeholder,
|
||||||
|
input[type="email"]::placeholder,
|
||||||
|
input[type="number"]::placeholder,
|
||||||
|
input[type="search"]::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--stone-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面切换过渡 */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,20 +4,90 @@
|
|||||||
:root {
|
:root {
|
||||||
--brand: var(--orange-6);
|
--brand: var(--orange-6);
|
||||||
--brand-hover: var(--orange-7);
|
--brand-hover: var(--orange-7);
|
||||||
--brand-light: var(--orange-3);
|
--brand-light: var(--orange-2);
|
||||||
--text-light: var(--gray-5);
|
--text-light: var(--gray-5);
|
||||||
|
--body-bg: var(--stone-0);
|
||||||
|
--surface: var(--stone-1);
|
||||||
|
--surface-hover: var(--stone-2);
|
||||||
|
--border: var(--stone-3);
|
||||||
|
--text-primary: var(--stone-11);
|
||||||
|
--text-secondary: var(--stone-7);
|
||||||
|
--text-muted: var(--stone-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全局背景 */
|
||||||
|
body {
|
||||||
|
background-color: var(--body-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
h4 {
|
h4 {
|
||||||
color: var(--gray-8);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-transitional);
|
font-family: var(--font-transitional);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
p,
|
p,
|
||||||
li {
|
li {
|
||||||
color: var(--gray-7);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-humanist);
|
font-family: var(--font-humanist);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统一按钮样式 */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--size-2);
|
||||||
|
padding: var(--size-2) var(--size-4);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
font-size: var(--font-size-1);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--brand-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
border-color: var(--stone-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background-color: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<dialog ref="dialogRef" :open="modelValue" @cancel="handleCancel" @close="handleClose">
|
<Transition name="dialog-fade">
|
||||||
<form method="dialog">
|
<div v-if="modelValue" class="dialog-overlay" @click.self="close">
|
||||||
<p>添加音乐库</p>
|
<dialog ref="dialogRef" class="dialog" :open="modelValue" @cancel="handleCancel" @close="handleClose">
|
||||||
<div class="dialog-body">
|
<form method="dialog">
|
||||||
<input v-model="name" type="text" placeholder="名称" />
|
<div class="dialog-header">
|
||||||
<input v-model="path" type="text" placeholder="路径" />
|
<h3>添加音乐库</h3>
|
||||||
<div class="dialog-buttons">
|
</div>
|
||||||
<button :disabled="!validated" @click="confirm" value="confirm">确认</button>
|
<div class="dialog-body">
|
||||||
<button @click="close" value="cancel">取消</button>
|
<div class="form-group">
|
||||||
</div>
|
<label for="lib-name">名称</label>
|
||||||
</div>
|
<input id="lib-name" v-model="name" type="text" placeholder="例如:我的音乐" autofocus />
|
||||||
</form>
|
</div>
|
||||||
</dialog>
|
<div class="form-group">
|
||||||
|
<label for="lib-path">路径</label>
|
||||||
|
<input id="lib-path" v-model="path" type="text" placeholder="例如:/music" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="close">取消</button>
|
||||||
|
<button type="button" class="btn btn-primary" :disabled="!validated" @click="confirm">确认添加</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -29,19 +41,17 @@ const emit = defineEmits(['update:modelValue', 'confirm'])
|
|||||||
|
|
||||||
const dialogRef = ref(null)
|
const dialogRef = ref(null)
|
||||||
|
|
||||||
// 监听外部值变化,同步到 dialog 元素
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
dialogRef.value?.showModal()
|
// 重置表单
|
||||||
} else {
|
name.value = ''
|
||||||
dialogRef.value?.close()
|
path.value = ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 关闭时更新 v-model
|
|
||||||
const close = () => emit('update:modelValue', false)
|
const close = () => emit('update:modelValue', false)
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
emit('confirm', name.value, path.value)
|
emit('confirm', name.value, path.value)
|
||||||
@@ -50,23 +60,116 @@ const confirm = () => {
|
|||||||
|
|
||||||
const validated = computed(() => name.value && path.value)
|
const validated = computed(() => name.value && path.value)
|
||||||
|
|
||||||
// ESC 键触发 cancel 事件
|
|
||||||
const handleCancel = () => close()
|
const handleCancel = () => close()
|
||||||
// 调用 close() 时触发
|
|
||||||
const handleClose = () => emit('update:modelValue', false)
|
const handleClose = () => emit('update:modelValue', false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dialog-body {
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--size-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--size-3);
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
padding: var(--size-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-3);
|
||||||
|
background-color: var(--stone-0);
|
||||||
|
box-shadow: var(--shadow-5);
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog::backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
padding: var(--size-5) var(--size-5) var(--size-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header h3 {
|
||||||
|
font-size: var(--font-size-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: var(--size-2) var(--size-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: var(--font-size-0);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
padding: var(--size-4) var(--size-5) var(--size-5);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--size-2);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 对话框动画 */
|
||||||
|
.dialog-fade-enter-active,
|
||||||
|
.dialog-fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-fade-enter-from,
|
||||||
|
.dialog-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-fade-enter-active .dialog {
|
||||||
|
animation: dialog-slide-in 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-fade-leave-active .dialog {
|
||||||
|
animation: dialog-slide-out 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-slide-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-slide-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(10px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,14 +1,115 @@
|
|||||||
<template>
|
<template>
|
||||||
<div @click="visible = !visible">
|
<div ref="rootRef" class="dropdown" :class="{ open: visible }">
|
||||||
<slot name="trigger"></slot>
|
<div class="dropdown-trigger" @click="visible = !visible">
|
||||||
</div>
|
<slot name="trigger"></slot>
|
||||||
<div v-if="visible">
|
</div>
|
||||||
<slot></slot>
|
<Transition name="dropdown">
|
||||||
|
<div v-if="visible" class="dropdown-menu">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
|
const rootRef = ref(null)
|
||||||
|
|
||||||
|
function handleClickOutside(e) {
|
||||||
|
if (rootRef.value && !rootRef.value.contains(e.target)) {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background-color: var(--stone-0);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
box-shadow: var(--shadow-3);
|
||||||
|
padding: var(--size-1) 0;
|
||||||
|
min-width: 150px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu button {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--size-2) var(--size-4);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-0);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu button:hover {
|
||||||
|
background-color: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu button[type="reset"] {
|
||||||
|
color: var(--red-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu button[type="reset"]:hover {
|
||||||
|
background-color: var(--red-1, #fef2f2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 过渡动画 */
|
||||||
|
.dropdown-enter-active,
|
||||||
|
.dropdown-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-enter-from,
|
||||||
|
.dropdown-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(-4px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
msg: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="greetings">
|
|
||||||
<h1 class="green">{{ msg }}</h1>
|
|
||||||
<h3>
|
|
||||||
You’ve successfully created a project with
|
|
||||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
|
||||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
h1 {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 2.6rem;
|
|
||||||
position: relative;
|
|
||||||
top: -10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.greetings h1,
|
|
||||||
.greetings h3 {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.greetings h1,
|
|
||||||
.greetings h3 {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="library-card">
|
<div class="library-card">
|
||||||
<h4>{{ props.name }}</h4>
|
<div class="card-icon">
|
||||||
|
<FolderOpen :size="32" />
|
||||||
|
</div>
|
||||||
|
<h4 class="card-name">{{ props.name }}</h4>
|
||||||
<DropDown>
|
<DropDown>
|
||||||
<template v-slot:trigger>
|
<template v-slot:trigger>
|
||||||
<button><EllipsisVertical /></button>
|
<button class="card-menu-btn" aria-label="更多操作"><EllipsisVertical :size="16" /></button>
|
||||||
</template>
|
</template>
|
||||||
<button @click="viewSongs">查看歌曲</button>
|
<button @click="viewSongs">查看歌曲</button>
|
||||||
<button @click="scanCard">扫描</button>
|
<button @click="scanCard">扫描</button>
|
||||||
@@ -13,9 +16,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { EllipsisVertical } from 'lucide-vue-next'
|
import { EllipsisVertical, FolderOpen } from 'lucide-vue-next'
|
||||||
import PopupMenu from './PopupMenu.vue'
|
|
||||||
import DropDown from './DropDown.vue'
|
import DropDown from './DropDown.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
name: String,
|
name: String,
|
||||||
id: Number,
|
id: Number,
|
||||||
@@ -35,16 +38,73 @@ const viewSongs = () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.library-card {
|
.library-card {
|
||||||
max-width: var(--size-fluid-8);
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: var(--stone-3);
|
gap: var(--size-2);
|
||||||
|
background: linear-gradient(135deg, var(--stone-1) 0%, var(--stone-2) 100%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
aspect-ratio: var(--ratio-square);
|
aspect-ratio: var(--ratio-square);
|
||||||
border-radius: var(--radius-2);
|
border-radius: var(--radius-3);
|
||||||
box-shadow: var(--shadow-1);
|
|
||||||
padding: var(--size-6);
|
padding: var(--size-6);
|
||||||
margin: var(--size-3);
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-3);
|
||||||
|
border-color: var(--stone-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: var(--radius-3);
|
||||||
|
background-color: var(--brand-light);
|
||||||
|
color: var(--brand);
|
||||||
|
margin-bottom: var(--size-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: var(--font-size-1);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.3;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 菜单按钮 */
|
||||||
|
.card-menu-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--size-2);
|
||||||
|
right: var(--size-2);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--size-1);
|
||||||
|
border-radius: var(--radius-1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-card:hover .card-menu-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-menu-btn:hover {
|
||||||
|
background-color: var(--stone-3);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
<template>
|
|
||||||
<slot name="trigger" @click.prevent="console.log(222)"></slot>
|
|
||||||
<!-- 遮罩:点击外部关闭 -->
|
|
||||||
<div v-if="visible" class="pm-mask" @click="close" @contextmenu.prevent></div>
|
|
||||||
|
|
||||||
<!-- 菜单容器 -->
|
|
||||||
<Transition name="pm-fade">
|
|
||||||
<div
|
|
||||||
v-if="visible"
|
|
||||||
class="pm-wrapper"
|
|
||||||
:style="{ top: y + 'px', left: x + 'px' }"
|
|
||||||
@contextmenu.prevent
|
|
||||||
></div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
items: { type: Array, default: () => [] }, // 菜单项
|
|
||||||
show: Boolean, // 显示开关
|
|
||||||
point: { type: Object, default: () => ({ x: 0, y: 0 }) }, // 坐标
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:show', 'select'])
|
|
||||||
|
|
||||||
const visible = ref(false)
|
|
||||||
const x = ref(0)
|
|
||||||
const y = ref(0)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.show,
|
|
||||||
async (val) => {
|
|
||||||
if (!val) return (visible.value = false)
|
|
||||||
// 先拿到坐标再渲染,防止超出屏幕
|
|
||||||
x.value = props.point.x
|
|
||||||
y.value = props.point.y
|
|
||||||
visible.value = true
|
|
||||||
await nextTick()
|
|
||||||
fixPosition()
|
|
||||||
// 全局监听
|
|
||||||
document.addEventListener('keydown', onKeyDown)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function fixPosition() {
|
|
||||||
const el = document.querySelector('.pm-wrapper')
|
|
||||||
if (!el) return
|
|
||||||
const { innerWidth, innerHeight } = window
|
|
||||||
const { offsetWidth: w, offsetHeight: h } = el
|
|
||||||
if (x.value + w > innerWidth) x.value = innerWidth - w - 4
|
|
||||||
if (y.value + h > innerHeight) y.value = innerHeight - h - 4
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
visible.value = false
|
|
||||||
emit('update:show', false)
|
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyDown(e) {
|
|
||||||
if (e.key === 'Escape') close()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClick(item) {
|
|
||||||
if (item.disabled) return
|
|
||||||
emit('select', item)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.pm-mask {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 998;
|
|
||||||
}
|
|
||||||
.pm-wrapper {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 999;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 4px 0;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.pm-fade-enter-active,
|
|
||||||
.pm-fade-leave-active {
|
|
||||||
transition:
|
|
||||||
opacity 0.15s,
|
|
||||||
transform 0.15s;
|
|
||||||
transform-origin: left top;
|
|
||||||
}
|
|
||||||
.pm-fade-enter-from,
|
|
||||||
.pm-fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import WelcomeItem from './WelcomeItem.vue'
|
|
||||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
|
||||||
import ToolingIcon from './icons/IconTooling.vue'
|
|
||||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
|
||||||
import CommunityIcon from './icons/IconCommunity.vue'
|
|
||||||
import SupportIcon from './icons/IconSupport.vue'
|
|
||||||
|
|
||||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<DocumentationIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Documentation</template>
|
|
||||||
|
|
||||||
Vue’s
|
|
||||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
|
||||||
provides you with all information you need to get started.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<ToolingIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Tooling</template>
|
|
||||||
|
|
||||||
This project is served and bundled with
|
|
||||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
|
||||||
recommended IDE setup is
|
|
||||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
|
||||||
+
|
|
||||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
|
|
||||||
>Vue - Official</a
|
|
||||||
>. If you need to test your components and web pages, check out
|
|
||||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
|
||||||
and
|
|
||||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
|
||||||
/
|
|
||||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
More instructions are available in
|
|
||||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
|
||||||
>.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<EcosystemIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Ecosystem</template>
|
|
||||||
|
|
||||||
Get official tools and libraries for your project:
|
|
||||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
|
||||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
|
||||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
|
||||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
|
||||||
you need more resources, we suggest paying
|
|
||||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
|
||||||
a visit.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<CommunityIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Community</template>
|
|
||||||
|
|
||||||
Got stuck? Ask your question on
|
|
||||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
|
||||||
(our official Discord server), or
|
|
||||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
|
||||||
>StackOverflow</a
|
|
||||||
>. You should also follow the official
|
|
||||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
|
||||||
Bluesky account or the
|
|
||||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
|
||||||
X account for latest news in the Vue world.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<SupportIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Support Vue</template>
|
|
||||||
|
|
||||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
|
||||||
us by
|
|
||||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
|
||||||
</WelcomeItem>
|
|
||||||
</template>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="item">
|
|
||||||
<i>
|
|
||||||
<slot name="icon"></slot>
|
|
||||||
</i>
|
|
||||||
<div class="details">
|
|
||||||
<h3>
|
|
||||||
<slot name="heading"></slot>
|
|
||||||
</h3>
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.item {
|
|
||||||
margin-top: 2rem;
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
place-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
color: var(--color-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.item {
|
|
||||||
margin-top: 0;
|
|
||||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
top: calc(50% - 25px);
|
|
||||||
left: -26px;
|
|
||||||
position: absolute;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-background);
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:before {
|
|
||||||
content: ' ';
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
bottom: calc(50% + 25px);
|
|
||||||
height: calc(50% - 25px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:after {
|
|
||||||
content: ' ';
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: calc(50% + 25px);
|
|
||||||
height: calc(50% - 25px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:first-of-type:before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:last-of-type:after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import HelloWorld from '../HelloWorld.vue'
|
|
||||||
|
|
||||||
describe('HelloWorld', () => {
|
|
||||||
it('renders properly', () => {
|
|
||||||
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
|
|
||||||
expect(wrapper.text()).toContain('Hello Vitest')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
|
||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
aria-hidden="true"
|
|
||||||
role="img"
|
|
||||||
class="iconify iconify--mdi"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
|
||||||
fill="currentColor"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import HomeView from '../views/HomeView.vue'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'home',
|
redirect: '/music',
|
||||||
component: HomeView,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/music',
|
path: '/music',
|
||||||
@@ -27,9 +25,6 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/about',
|
path: '/about',
|
||||||
name: 'about',
|
name: 'about',
|
||||||
// route level code-splitting
|
|
||||||
// this generates a separate chunk (About.[hash].js) for this route
|
|
||||||
// which is lazy-loaded when the route is visited.
|
|
||||||
component: () => import('../views/AboutView.vue'),
|
component: () => import('../views/AboutView.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,63 +3,102 @@ import { defineStore } from 'pinia'
|
|||||||
|
|
||||||
export const useButterfliuStore = defineStore('butterfliu', () => {
|
export const useButterfliuStore = defineStore('butterfliu', () => {
|
||||||
const libraries = ref([])
|
const libraries = ref([])
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
async function fetchLibraries() {
|
async function fetchLibraries() {
|
||||||
libraries.value = await (await fetch('/api/libraries')).json()
|
try {
|
||||||
|
error.value = null
|
||||||
|
const resp = await fetch('/api/libraries')
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
libraries.value = await resp.json()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
console.error('Failed to fetch libraries:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanLibrary(id) {
|
async function scanLibrary(id) {
|
||||||
const resp = await fetch('/api/libraries/' + id + '/scan', {
|
try {
|
||||||
method: 'POST',
|
error.value = null
|
||||||
})
|
const resp = await fetch(`/api/libraries/${id}/scan`, {
|
||||||
const result = await resp.json()
|
method: 'POST',
|
||||||
|
})
|
||||||
console.log(result)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
const result = await resp.json()
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
console.error('Failed to scan library:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function addLibrary(name, path) {
|
|
||||||
const resp = await fetch('/api/libraries', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
name,
|
|
||||||
path,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const result = await resp.json()
|
|
||||||
|
|
||||||
console.log(result)
|
async function addLibrary(name, path) {
|
||||||
|
try {
|
||||||
|
error.value = null
|
||||||
|
const resp = await fetch('/api/libraries', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, path }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
const result = await resp.json()
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
console.error('Failed to add library:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteLibrary(id) {
|
async function deleteLibrary(id) {
|
||||||
const resp = await fetch('/api/libraries/' + id, {
|
try {
|
||||||
method: 'DELETE',
|
error.value = null
|
||||||
})
|
const resp = await fetch(`/api/libraries/${id}`, {
|
||||||
const result = await resp.json()
|
method: 'DELETE',
|
||||||
|
})
|
||||||
console.log(result)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
const result = await resp.json()
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
console.error('Failed to delete library:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLibrarySongs(id) {
|
async function fetchLibrarySongs(id) {
|
||||||
const resp = await fetch('/api/libraries/' + id + '/songs')
|
const resp = await fetch(`/api/libraries/${id}/songs`)
|
||||||
const songs = await resp.json()
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
return songs
|
return resp.json()
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchArtists() {
|
|
||||||
const resp = await fetch('/api/artists')
|
|
||||||
const artists = await resp.json()
|
|
||||||
return artists
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAlbums() {
|
|
||||||
const resp = await fetch('/api/albums')
|
|
||||||
const albums = await resp.json()
|
|
||||||
return albums
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAllSongs() {
|
async function fetchAllSongs() {
|
||||||
const resp = await fetch('/api/songs')
|
const resp = await fetch('/api/songs')
|
||||||
const songs = await resp.json()
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
return songs
|
return resp.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { libraries, fetchLibraries, scanLibrary, addLibrary, deleteLibrary, fetchLibrarySongs, fetchArtists, fetchAlbums, fetchAllSongs }
|
// TODO: 用于未来艺术家/专辑页面
|
||||||
|
async function fetchArtists() {
|
||||||
|
const resp = await fetch('/api/artists')
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlbums() {
|
||||||
|
const resp = await fetch('/api/albums')
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
libraries,
|
||||||
|
error,
|
||||||
|
fetchLibraries,
|
||||||
|
scanLibrary,
|
||||||
|
addLibrary,
|
||||||
|
deleteLibrary,
|
||||||
|
fetchLibrarySongs,
|
||||||
|
fetchAllSongs,
|
||||||
|
fetchArtists,
|
||||||
|
fetchAlbums,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { ref, computed } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
export const useCounterStore = defineStore('counter', () => {
|
|
||||||
const count = ref(0)
|
|
||||||
const doubleCount = computed(() => count.value * 2)
|
|
||||||
function increment() {
|
|
||||||
count.value++
|
|
||||||
}
|
|
||||||
|
|
||||||
return { count, doubleCount, increment }
|
|
||||||
})
|
|
||||||
@@ -1,7 +1,250 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="about">
|
<div class="about">
|
||||||
<h1>This is an about page</h1>
|
<div class="about-header">
|
||||||
|
<div class="header-icon">
|
||||||
|
<Music :size="32" />
|
||||||
|
</div>
|
||||||
|
<div class="header-text">
|
||||||
|
<h1>关于 butterfliu</h1>
|
||||||
|
<p class="version">v0.1.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="about-section">
|
||||||
|
<h2>简介</h2>
|
||||||
|
<p>
|
||||||
|
butterfliu 是一个轻量级的本地音乐播放器 Web 应用,
|
||||||
|
支持扫描本地音乐库、浏览歌曲信息、在线播放音频文件。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="about-section">
|
||||||
|
<h2>功能特性</h2>
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon"><FolderSearch :size="20" /></div>
|
||||||
|
<div class="feature-text">
|
||||||
|
<h3>自动扫描</h3>
|
||||||
|
<p>扫描本地目录,自动解析音频元数据</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon"><AudioWaveform :size="20" /></div>
|
||||||
|
<div class="feature-text">
|
||||||
|
<h3>多格式支持</h3>
|
||||||
|
<p>支持 MP3、FLAC、M4A、WAV、OGG</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon"><ListMusic :size="20" /></div>
|
||||||
|
<div class="feature-text">
|
||||||
|
<h3>音乐库浏览</h3>
|
||||||
|
<p>按音乐库浏览和管理歌曲</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon"><CirclePlay :size="20" /></div>
|
||||||
|
<div class="feature-text">
|
||||||
|
<h3>完整播放器</h3>
|
||||||
|
<p>进度跳转、音量调节、上下曲切换</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon"><RotateCcw :size="20" /></div>
|
||||||
|
<div class="feature-text">
|
||||||
|
<h3>状态恢复</h3>
|
||||||
|
<p>自动恢复上次播放状态</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="about-section">
|
||||||
|
<h2>技术栈</h2>
|
||||||
|
<div class="tech-grid">
|
||||||
|
<div class="tech-item">
|
||||||
|
<h3>后端</h3>
|
||||||
|
<p>Go 1.23 · chi 路由 · SQLite · dhowden/tag</p>
|
||||||
|
</div>
|
||||||
|
<div class="tech-item">
|
||||||
|
<h3>前端</h3>
|
||||||
|
<p>Vue 3 · Vite · Pinia · Vue Router · Open Props</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style></style>
|
<script setup>
|
||||||
|
import { Music, FolderSearch, AudioWaveform, ListMusic, CirclePlay, RotateCcw } from 'lucide-vue-next'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.about {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-8);
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 头部 === */
|
||||||
|
.about-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-4);
|
||||||
|
padding-bottom: var(--size-6);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: var(--radius-3);
|
||||||
|
background: linear-gradient(135deg, var(--brand-light) 0%, var(--brand) 100%);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text h1 {
|
||||||
|
font-size: var(--font-size-fluid-2);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-1);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 章节 === */
|
||||||
|
.about-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section h2 {
|
||||||
|
font-size: var(--font-size-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
position: relative;
|
||||||
|
padding-left: var(--size-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section h2::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 70%;
|
||||||
|
background-color: var(--brand);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section p {
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 功能特性网格 === */
|
||||||
|
.features-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--size-3);
|
||||||
|
padding: var(--size-3) var(--size-4);
|
||||||
|
background-color: var(--surface);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item:hover {
|
||||||
|
border-color: var(--brand-light);
|
||||||
|
background-color: var(--brand-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
background-color: var(--brand-light);
|
||||||
|
color: var(--brand);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item:hover .feature-icon {
|
||||||
|
background-color: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-text h3 {
|
||||||
|
font-size: var(--font-size-1);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-text p {
|
||||||
|
font-size: var(--font-size-0);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 技术栈 === */
|
||||||
|
.tech-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--size-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-item {
|
||||||
|
background-color: var(--surface);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
padding: var(--size-4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-item:hover {
|
||||||
|
border-color: var(--stone-4);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-item h3 {
|
||||||
|
margin-bottom: var(--size-1);
|
||||||
|
font-size: var(--font-size-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-item p {
|
||||||
|
font-size: var(--font-size-0);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tech-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--size-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import TheWelcome from '../components/TheWelcome.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main>
|
|
||||||
<TheWelcome />
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
@@ -1,15 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="hero">
|
<div class="music-page">
|
||||||
<h1>{{ library?.name || '所有歌曲' }}</h1>
|
<div class="page-header">
|
||||||
<p>{{ songs.length }} 首歌曲</p>
|
<div class="header-text">
|
||||||
</div>
|
<h1>{{ library?.name || '所有歌曲' }}</h1>
|
||||||
<div class="song-list">
|
<p class="song-count">
|
||||||
<div v-for="s in songs" :key="s.id" class="song-item" @click="playSong(s)">
|
<Music :size="14" />
|
||||||
<span class="song-id">{{ s.id }}</span>
|
{{ songs.length }} 首歌曲
|
||||||
<div class="song-textbox">
|
</p>
|
||||||
<span class="song-title">{{ s.title }}</span>
|
</div>
|
||||||
<span class="song-artist">{{ s.artist }}</span>
|
</div>
|
||||||
<span class="song-album">{{ s.album }}</span>
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="songs.length === 0" class="empty-state">
|
||||||
|
<Music :size="48" class="empty-icon" />
|
||||||
|
<h3>暂无歌曲</h3>
|
||||||
|
<p>前往设置页添加音乐库并扫描</p>
|
||||||
|
<button class="btn btn-primary" @click="goToSettings">
|
||||||
|
<Settings :size="16" />
|
||||||
|
去添加音乐库
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 歌曲列表 -->
|
||||||
|
<div v-else class="song-list">
|
||||||
|
<div
|
||||||
|
v-for="(s, index) in songs"
|
||||||
|
:key="s.id"
|
||||||
|
class="song-item"
|
||||||
|
:class="{ playing: player.song?.id === s.id }"
|
||||||
|
@click="playSong(s)"
|
||||||
|
>
|
||||||
|
<div class="song-index">
|
||||||
|
<span v-if="player.song?.id !== s.id" class="index-number">{{ index + 1 }}</span>
|
||||||
|
<span v-else class="playing-indicator">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="song-textbox">
|
||||||
|
<span class="song-title">{{ s.title }}</span>
|
||||||
|
<span class="song-artist">{{ s.artist }}</span>
|
||||||
|
<span class="song-album">{{ s.album }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,11 +48,13 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Music, Settings } from 'lucide-vue-next'
|
||||||
import { useButterfliuStore } from '@/stores/butterfliu'
|
import { useButterfliuStore } from '@/stores/butterfliu'
|
||||||
import { usePlayerStore } from '@/stores/player'
|
import { usePlayerStore } from '@/stores/player'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const butterfliu = useButterfliuStore()
|
const butterfliu = useButterfliuStore()
|
||||||
const player = usePlayerStore()
|
const player = usePlayerStore()
|
||||||
const songs = ref([])
|
const songs = ref([])
|
||||||
@@ -45,6 +78,10 @@ function playSong(song) {
|
|||||||
player.playSong(song, songs.value)
|
player.playSong(song, songs.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToSettings() {
|
||||||
|
router.push('/settings')
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => route.params.id, () => {
|
watch(() => route.params.id, () => {
|
||||||
loadSongs()
|
loadSongs()
|
||||||
})
|
})
|
||||||
@@ -56,48 +93,190 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.song-list {
|
.music-page {
|
||||||
display: flex;
|
max-width: 900px;
|
||||||
flex-direction: column;
|
margin: 0 auto;
|
||||||
gap: var(--size-1);
|
|
||||||
}
|
}
|
||||||
.song-item {
|
|
||||||
display: flex;
|
/* === 页面头部 === */
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--size-6);
|
||||||
|
padding-bottom: var(--size-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text h1 {
|
||||||
|
font-size: var(--font-size-fluid-2);
|
||||||
|
margin-bottom: var(--size-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-count {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--size-3);
|
gap: var(--size-2);
|
||||||
padding: var(--size-1);
|
color: var(--text-muted);
|
||||||
&:hover {
|
|
||||||
background-color: var(--stone-1);
|
|
||||||
box-shadow: var(--shadow-1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-id {
|
|
||||||
text-align: center;
|
|
||||||
inline-size: var(--size-8);
|
|
||||||
line-height: var(--size-8);
|
|
||||||
aspect-ratio: var(--ratio-square);
|
|
||||||
font-family: var(--font-classical-humanist);
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-textbox {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-title {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: var(--font-size-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-artist {
|
|
||||||
color: var(--stone-6);
|
|
||||||
font-size: var(--font-size-1);
|
font-size: var(--font-size-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-album {
|
/* === 空状态 === */
|
||||||
color: var(--stone-5);
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--size-10) var(--size-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
color: var(--stone-4);
|
||||||
|
margin-bottom: var(--size-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: var(--font-size-2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--size-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: var(--size-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 歌曲列表 === */
|
||||||
|
.song-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-4);
|
||||||
|
padding: var(--size-3) var(--size-3);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item.playing {
|
||||||
|
background-color: var(--brand-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item.playing::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 15%;
|
||||||
|
bottom: 15%;
|
||||||
|
width: 3px;
|
||||||
|
background-color: var(--brand);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 序号 */
|
||||||
|
.song-index {
|
||||||
|
width: var(--size-8);
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-number {
|
||||||
font-size: var(--font-size-0);
|
font-size: var(--font-size-0);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-classical-humanist);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item:hover .index-number {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 播放中动画 */
|
||||||
|
.playing-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-indicator span {
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
background-color: var(--brand);
|
||||||
|
border-radius: 1px;
|
||||||
|
animation: sound-bars 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-indicator span:nth-child(1) {
|
||||||
|
height: 60%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-indicator span:nth-child(2) {
|
||||||
|
height: 100%;
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-indicator span:nth-child(3) {
|
||||||
|
height: 40%;
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sound-bars {
|
||||||
|
0%, 100% { transform: scaleY(0.3); }
|
||||||
|
50% { transform: scaleY(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 歌曲文本 */
|
||||||
|
.song-textbox {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item.playing .song-title {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-artist {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-0);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-album {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-0);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: var(--font-size-3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="setting">
|
<div class="settings-page">
|
||||||
<h1>设置</h1>
|
<div class="page-header">
|
||||||
<p>管理您的音乐库和系统配置</p>
|
<h1>设置</h1>
|
||||||
<p class="controll-buttons">
|
<p>管理您的音乐库</p>
|
||||||
<button type="submit" @click="show = true">添加</button>
|
</div>
|
||||||
<button @click="butterfliu.fetchLibraries()">刷新</button>
|
|
||||||
</p>
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary" @click="show = true">
|
||||||
|
<Plus :size="16" />
|
||||||
|
添加音乐库
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="butterfliu.fetchLibraries()">
|
||||||
|
<RefreshCw :size="16" />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AddLibraryDialog v-model="show" @confirm="toAddLibrary" />
|
<AddLibraryDialog v-model="show" @confirm="toAddLibrary" />
|
||||||
|
|
||||||
<div>
|
<!-- 空状态 -->
|
||||||
|
<div v-if="libraries.length === 0" class="empty-state">
|
||||||
|
<FolderOpen :size="48" class="empty-icon" />
|
||||||
|
<h3>还没有音乐库</h3>
|
||||||
|
<p>点击上方「添加音乐库」按钮开始使用</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 音乐库网格 -->
|
||||||
|
<div v-else class="library-grid">
|
||||||
<LibraryCard
|
<LibraryCard
|
||||||
v-for="l in libraries"
|
v-for="l in libraries"
|
||||||
:key="l.id"
|
:key="l.id"
|
||||||
@@ -25,14 +42,14 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
import { Plus, RefreshCw, FolderOpen } from 'lucide-vue-next'
|
||||||
import AddLibraryDialog from '@/components/AddLibraryDialog.vue'
|
import AddLibraryDialog from '@/components/AddLibraryDialog.vue'
|
||||||
import LibraryCard from '@/components/LibraryCard.vue'
|
import LibraryCard from '@/components/LibraryCard.vue'
|
||||||
import { useButterfliuStore } from '@/stores/butterfliu'
|
import { useButterfliuStore } from '@/stores/butterfliu'
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const butterfliu = useButterfliuStore()
|
const butterfliu = useButterfliuStore()
|
||||||
butterfliu.fetchLibraries()
|
|
||||||
const libraries = computed(() => butterfliu.libraries)
|
const libraries = computed(() => butterfliu.libraries)
|
||||||
const show = ref(false)
|
const show = ref(false)
|
||||||
|
|
||||||
@@ -51,17 +68,82 @@ function toDeleteLibrary(id) {
|
|||||||
function toViewSongs(id) {
|
function toViewSongs(id) {
|
||||||
router.push({ name: 'music-library', params: { id } })
|
router.push({ name: 'music-library', params: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
butterfliu.fetchLibraries()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.setting {
|
.settings-page {
|
||||||
display: flex;
|
max-width: 900px;
|
||||||
flex-direction: column;
|
margin: 0 auto;
|
||||||
gap: var(--size-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.controll-buttons {
|
/* === 页面头部 === */
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--size-6);
|
||||||
|
padding-bottom: var(--size-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: var(--font-size-fluid-2);
|
||||||
|
margin-bottom: var(--size-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 操作按钮 === */
|
||||||
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--size-2);
|
gap: var(--size-2);
|
||||||
|
margin-bottom: var(--size-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 空状态 === */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--size-10) var(--size-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
color: var(--stone-4);
|
||||||
|
margin-bottom: var(--size-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: var(--font-size-2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--size-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 音乐库网格 === */
|
||||||
|
.library-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: var(--size-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.library-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: var(--size-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user