整理重构前端
Some checks failed
Go CI / test-and-build (push) Successful in 11s
Web CI / lint-test-build (push) Failing after 15s

This commit is contained in:
2026-04-06 14:38:06 +08:00
parent ec9f085bab
commit d5e0c4a0db
23 changed files with 1258 additions and 611 deletions

View File

@@ -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">
<!-- 移动端遮罩层 --> <!-- 移动端遮罩层 -->
<Transition name="overlay">
<div v-if="sidebarOpen" class="overlay" @click="sidebarOpen = false"></div> <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>
<Transition name="fade" mode="out-in">
<RouterView /> <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>

View File

@@ -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;
}

View File

@@ -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);
} }

View File

@@ -1,17 +1,29 @@
<template> <template>
<dialog ref="dialogRef" :open="modelValue" @cancel="handleCancel" @close="handleClose"> <Transition name="dialog-fade">
<div v-if="modelValue" class="dialog-overlay" @click.self="close">
<dialog ref="dialogRef" class="dialog" :open="modelValue" @cancel="handleCancel" @close="handleClose">
<form method="dialog"> <form method="dialog">
<p>添加音乐库</p> <div class="dialog-header">
<div class="dialog-body"> <h3>添加音乐库</h3>
<input v-model="name" type="text" placeholder="名称" />
<input v-model="path" type="text" placeholder="路径" />
<div class="dialog-buttons">
<button :disabled="!validated" @click="confirm" value="confirm">确认</button>
<button @click="close" value="cancel">取消</button>
</div> </div>
<div class="dialog-body">
<div class="form-group">
<label for="lib-name">名称</label>
<input id="lib-name" v-model="name" type="text" placeholder="例如:我的音乐" autofocus />
</div>
<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> </div>
</form> </form>
</dialog> </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>

View File

@@ -1,14 +1,115 @@
<template> <template>
<div @click="visible = !visible"> <div ref="rootRef" class="dropdown" :class="{ open: visible }">
<div class="dropdown-trigger" @click="visible = !visible">
<slot name="trigger"></slot> <slot name="trigger"></slot>
</div> </div>
<div v-if="visible"> <Transition name="dropdown">
<div v-if="visible" class="dropdown-menu">
<slot></slot> <slot></slot>
</div> </div>
</Transition>
</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>

View File

@@ -1,44 +0,0 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
Vues
<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>

View File

@@ -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>

View File

@@ -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')
})
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'),
}, },
], ],

View File

@@ -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 {
error.value = null
const resp = await fetch(`/api/libraries/${id}/scan`, {
method: 'POST', method: 'POST',
}) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const result = await resp.json() const result = await resp.json()
return result
console.log(result) } catch (e) {
error.value = e.message
console.error('Failed to scan library:', e)
} }
}
async function addLibrary(name, path) { async function addLibrary(name, path) {
try {
error.value = null
const resp = await fetch('/api/libraries', { const resp = await fetch('/api/libraries', {
method: 'POST', method: 'POST',
body: JSON.stringify({ headers: { 'Content-Type': 'application/json' },
name, body: JSON.stringify({ name, path }),
path,
}),
}) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const result = await resp.json() const result = await resp.json()
return result
console.log(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 {
error.value = null
const resp = await fetch(`/api/libraries/${id}`, {
method: 'DELETE', method: 'DELETE',
}) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const result = await resp.json() const result = await resp.json()
return result
console.log(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,
}
}) })

View File

@@ -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 }
})

View File

@@ -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>支持 MP3FLACM4AWAVOGG</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>

View File

@@ -1,9 +0,0 @@
<script setup>
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>

View File

@@ -1,11 +1,41 @@
<template> <template>
<div class="hero"> <div class="music-page">
<div class="page-header">
<div class="header-text">
<h1>{{ library?.name || '所有歌曲' }}</h1> <h1>{{ library?.name || '所有歌曲' }}</h1>
<p>{{ songs.length }} 首歌曲</p> <p class="song-count">
<Music :size="14" />
{{ songs.length }} 首歌曲
</p>
</div>
</div>
<!-- 空状态 -->
<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>
<div class="song-list">
<div v-for="s in songs" :key="s.id" class="song-item" @click="playSong(s)">
<span class="song-id">{{ s.id }}</span>
<div class="song-textbox"> <div class="song-textbox">
<span class="song-title">{{ s.title }}</span> <span class="song-title">{{ s.title }}</span>
<span class="song-artist">{{ s.artist }}</span> <span class="song-artist">{{ s.artist }}</span>
@@ -13,15 +43,18 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<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>

View File

@@ -1,15 +1,32 @@
<template> <template>
<div class="setting"> <div class="settings-page">
<div class="page-header">
<h1>设置</h1> <h1>设置</h1>
<p>管理您的音乐库和系统配置</p> <p>管理您的音乐库</p>
<p class="controll-buttons"> </div>
<button type="submit" @click="show = true">添加</button>
<button @click="butterfliu.fetchLibraries()">刷新</button> <div class="actions">
</p> <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>