Skip to content

Commit 68134ce

Browse files
committed
✨ feat: 优化文件夹选择界面
1 parent a34c64b commit 68134ce

4 files changed

Lines changed: 201 additions & 96 deletions

File tree

components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ declare module 'vue' {
124124
NTag: typeof import('naive-ui')['NTag']
125125
NText: typeof import('naive-ui')['NText']
126126
NThing: typeof import('naive-ui')['NThing']
127+
NTree: typeof import('naive-ui')['NTree']
127128
NVirtualList: typeof import('naive-ui')['NVirtualList']
128129
OtherSetting: typeof import('./src/components/Setting/OtherSetting.vue')['default']
129130
PersonalFM: typeof import('./src/components/Player/PersonalFM.vue')['default']

src/components/Player/PlayerControl.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
<SvgIcon name="AddList" />
2222
</div>
2323
<!-- 下载 -->
24-
<div class="menu-icon" @click.stop="openDownloadSong(musicStore.playSong)">
24+
<div
25+
class="menu-icon"
26+
v-if="!musicStore.playSong.path"
27+
@click.stop="openDownloadSong(musicStore.playSong)"
28+
>
2529
<SvgIcon name="Download" />
2630
</div>
2731
<!-- 显示评论 -->

src/components/Setting/PlaySetting.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@
111111
/>
112112
</n-card>
113113
</div>
114-
<div v-if="isElectron" class="set-list">
114+
<div v-if="isElectron&& statusStore.isDeveloperMode" class="set-list">
115115
<n-h3 prefix="bar">
116116
音乐解锁
117117
<n-tag type="warning" size="small" round>Beta</n-tag>
@@ -299,7 +299,7 @@
299299

300300
<script setup lang="ts">
301301
import type { SelectOption } from "naive-ui";
302-
import { useSettingStore } from "@/stores";
302+
import { useSettingStore, useStatusStore } from "@/stores";
303303
import { isLogin } from "@/utils/auth";
304304
import { renderOption } from "@/utils/helper";
305305
import { isElectron } from "@/utils/env";
@@ -308,8 +308,8 @@ import { usePlayerController } from "@/core/player/PlayerController";
308308
import { openSongUnlockManager } from "@/utils/modal";
309309
310310
const player = usePlayerController();
311+
const statusStore = useStatusStore();
311312
const settingStore = useSettingStore();
312-
313313
// 输出设备数据
314314
const outputDevices = ref<SelectOption[]>([]);
315315

src/views/Local/folders.vue

Lines changed: 192 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,18 @@
22
<div class="local-folders">
33
<!-- 左侧文件夹列表 -->
44
<n-scrollbar class="folder-list">
5-
<n-card
6-
v-for="(songs, folderPath, index) in folderData"
7-
:key="index"
8-
:id="folderPath"
9-
:class="['folder-item', { choose: chooseFolder === folderPath }]"
10-
@click="chooseFolder = folderPath"
11-
>
12-
<n-text class="name">
13-
<SvgIcon name="Folder" :depth="2" />
14-
{{ getFolderName(folderPath) || "未知文件夹" }}
15-
</n-text>
16-
<n-text class="path" depth="3">
17-
{{ folderPath }}
18-
</n-text>
19-
<n-text class="num" depth="3">
20-
<SvgIcon name="Music" :depth="3" />
21-
{{ songs.length }} 首
22-
</n-text>
23-
</n-card>
5+
<n-tree
6+
block-line
7+
expand-on-click
8+
virtual-scroll
9+
:data="treeData"
10+
:selected-keys="[chooseFolder]"
11+
:expanded-keys="expandedKeys"
12+
:render-prefix="renderPrefix"
13+
:render-label="renderLabel"
14+
@update:selected-keys="handleTreeSelect"
15+
@update:expanded-keys="(keys: string[]) => (expandedKeys = keys)"
16+
/>
2417
</n-scrollbar>
2518

2619
<!-- 右侧歌曲列表 -->
@@ -39,9 +32,12 @@
3932

4033
<script setup lang="ts">
4134
import type { SongType } from "@/types/main";
35+
import type { TreeOption } from "naive-ui";
4236
import { useLocalStore, useSettingStore } from "@/stores";
4337
import SongList from "@/components/List/SongList.vue";
44-
import { some } from "lodash-es";
38+
import SvgIcon from "@/components/Global/SvgIcon.vue";
39+
import { some, uniqBy } from "lodash-es";
40+
import { h } from "vue";
4541
4642
const props = defineProps<{
4743
data: SongType[];
@@ -78,38 +74,190 @@ const folderData = computed<Record<string, SongType[]>>(() => {
7874
sortedMap[key] = map[key];
7975
});
8076
81-
// 默认选中第一个文件夹
82-
if (!chooseFolder.value && sortedKeys.length > 0) {
83-
chooseFolder.value = sortedKeys[0];
77+
return sortedMap;
78+
});
79+
80+
// 树状结构数据
81+
const treeData = computed<TreeOption[]>(() => {
82+
const allPaths = Object.keys(folderData.value);
83+
if (allPaths.length === 0) return [];
84+
85+
// 构建原始树结构
86+
interface RawNode {
87+
label: string;
88+
key: string;
89+
children: RawNode[];
90+
isDirectFolder: boolean; // 是否直接包含歌曲
91+
songCount: number; // 直接包含的歌曲数量
8492
}
8593
86-
return sortedMap;
94+
const rootNodes: RawNode[] = [];
95+
const nodeMap = new Map<string, RawNode>();
96+
97+
const sortedPaths = allPaths.sort();
98+
99+
sortedPaths.forEach((fullPath) => {
100+
const isWindows = fullPath.includes("\\");
101+
const sep = isWindows ? "\\" : "/";
102+
const segments = fullPath.split(/[/\\]/).filter(Boolean);
103+
104+
let currentPath = "";
105+
if (fullPath.startsWith(sep)) currentPath = sep;
106+
107+
segments.forEach((segment, index) => {
108+
const prevPath = currentPath;
109+
if (index === 0 && !fullPath.startsWith(sep)) {
110+
currentPath = segment;
111+
} else {
112+
currentPath = currentPath.endsWith(sep)
113+
? currentPath + segment
114+
: currentPath + sep + segment;
115+
}
116+
117+
if (!nodeMap.has(currentPath)) {
118+
const isLast = index === segments.length - 1;
119+
const node: RawNode = {
120+
label: segment,
121+
key: currentPath,
122+
children: [],
123+
isDirectFolder: isLast,
124+
songCount: isLast ? folderData.value[currentPath]?.length || 0 : 0,
125+
};
126+
nodeMap.set(currentPath, node);
127+
128+
if (index === 0) {
129+
rootNodes.push(node);
130+
} else {
131+
const parentNode = nodeMap.get(prevPath);
132+
if (parentNode) {
133+
parentNode.children.push(node);
134+
} else {
135+
rootNodes.push(node);
136+
}
137+
}
138+
} else if (index === segments.length - 1) {
139+
const node = nodeMap.get(currentPath)!;
140+
node.isDirectFolder = true;
141+
node.songCount = folderData.value[currentPath]?.length || 0;
142+
}
143+
});
144+
});
145+
146+
// 计算节点及其所有子节点的总歌曲数
147+
const nodeTotalCounts = new Map<string, number>();
148+
const calcTotalCount = (node: RawNode): number => {
149+
let total = node.songCount;
150+
node.children.forEach((child) => {
151+
total += calcTotalCount(child);
152+
});
153+
nodeTotalCounts.set(node.key, total);
154+
return total;
155+
};
156+
rootNodes.forEach((root) => calcTotalCount(root));
157+
158+
// 合并只有一个子节点的节点,并转换为 TreeOption
159+
const mergeAndConvert = (nodes: RawNode[]): TreeOption[] => {
160+
return nodes.map((node) => {
161+
let currentLabel = node.label;
162+
let currentKey = node.key;
163+
let currentChildren = node.children;
164+
let currentDirectFolder = node.isDirectFolder;
165+
166+
const isWindows = currentKey.includes("\\");
167+
const sep = isWindows ? "\\" : "/";
168+
169+
// 如果只有一个子节点,且当前节点本身没有歌曲,则合并
170+
while (currentChildren.length === 1 && !currentDirectFolder) {
171+
const child = currentChildren[0];
172+
currentLabel += sep + child.label;
173+
currentKey = child.key;
174+
currentChildren = child.children;
175+
currentDirectFolder = child.isDirectFolder;
176+
}
177+
178+
const totalSongs = nodeTotalCounts.get(node.key) || 0;
179+
180+
return {
181+
label: `${currentLabel} (${totalSongs})`,
182+
key: currentKey,
183+
children: currentChildren.length > 0 ? mergeAndConvert(currentChildren) : undefined,
184+
};
185+
});
186+
};
187+
188+
const finalTree = mergeAndConvert(rootNodes);
189+
190+
// 默认选中第一个节点
191+
if (!chooseFolder.value && finalTree.length > 0) {
192+
chooseFolder.value = finalTree[0].key as string;
193+
}
194+
195+
return finalTree;
87196
});
88197
89-
// 当前选中文件夹的歌曲
90-
const folderSongs = computed<SongType[]>(() => folderData.value?.[chooseFolder.value] || []);
198+
// 当前选中文件夹的歌曲(包含子目录)
199+
const folderSongs = computed<SongType[]>(() => {
200+
if (!chooseFolder.value) return [];
91201
92-
// 从完整路径中提取最后一级目录作为显示名
93-
const getFolderName = (folderPath: string): string => {
94-
if (!folderPath) return "";
95-
const parts = folderPath.split(/[/\\]/).filter(Boolean);
96-
return parts[parts.length - 1] || folderPath;
202+
const songs: SongType[] = [];
203+
const path = chooseFolder.value;
204+
205+
// 查找当前目录及所有子目录下的歌曲
206+
Object.keys(folderData.value).forEach((k) => {
207+
const isWindows = k.includes("\\");
208+
const sep = isWindows ? "\\" : "/";
209+
if (k === path || k.startsWith(path + sep)) {
210+
songs.push(...folderData.value[k]);
211+
}
212+
});
213+
214+
return uniqBy(songs, "id");
215+
});
216+
217+
// 树节点选中回调
218+
const handleTreeSelect = (keys: string[]) => {
219+
if (keys.length > 0) {
220+
chooseFolder.value = keys[0];
221+
}
222+
};
223+
224+
// 自定义渲染前缀(图标)
225+
const renderPrefix = () => {
226+
return h(SvgIcon, { name: "Folder", depth: 2 });
227+
};
228+
229+
// 自定义渲染标签(显示数量)
230+
const renderLabel = ({ option }: { option: TreeOption }) => {
231+
const label = option.label as string;
232+
const match = label.match(/(.*) \((\d+)\)$/);
233+
if (match) {
234+
return h("div", { class: "tree-label" }, [
235+
h("span", { class: "name" }, match[1]),
236+
h("span", { class: "count" }, ` (${match[2]})`),
237+
]);
238+
}
239+
return label;
97240
};
98241
242+
// 从完整路径中提取最后一级目录作为显示名
99243
// 删除歌曲时,同步更新本地歌曲列表
100244
const handleRemoveSong = (ids: number[]) => {
101245
const updatedSongs = localStore.localSongs.filter((song) => !ids.includes(song.id));
102246
localStore.updateLocalSong(updatedSongs);
103247
};
104248
105-
// 切换选中文件夹时,让左侧列表自动滚动居中
249+
// 展开的节点
250+
const expandedKeys = ref<string[]>([]);
251+
252+
// 初始化时展开第一层
106253
watch(
107-
() => chooseFolder.value,
254+
() => treeData.value,
108255
(val) => {
109-
if (!val) return;
110-
const folderDom = document.getElementById(val);
111-
if (folderDom) folderDom.scrollIntoView({ behavior: "smooth", block: "center" });
256+
if (val.length > 0 && expandedKeys.value.length === 0) {
257+
expandedKeys.value = val.map((node) => node.key as string);
258+
}
112259
},
260+
{ immediate: true },
113261
);
114262
</script>
115263

@@ -119,64 +267,16 @@ watch(
119267
height: calc((var(--layout-height) - 80) * 1px);
120268
121269
:deep(.folder-list) {
122-
width: 260px;
123-
.n-scrollbar-content {
124-
padding: 0 5px 0 0 !important;
125-
}
126-
}
127-
128-
.folder-item {
129-
margin-bottom: 8px;
130-
border-radius: 8px;
131-
border: 2px solid rgba(var(--primary), 0.12);
132-
cursor: pointer;
133-
134-
:deep(.n-card__content) {
135-
display: flex;
136-
flex-direction: column;
137-
padding: 10px 14px;
138-
}
139-
140-
&:last-child {
141-
margin-bottom: 24px;
142-
}
143-
144-
.name {
145-
display: flex;
146-
align-items: center;
147-
font-weight: bold;
148-
font-size: 15px;
149-
150-
.n-icon {
151-
margin-right: 6px;
270+
width: 280px;
271+
height: 100%;
272+
background-color: var(--surface-container-hex);
273+
border-radius: 12px;
274+
padding: 10px;
275+
.n-tree {
276+
.n-tree-node-wrapper {
277+
--n-node-border-radius: 6px;
152278
}
153279
}
154-
155-
.path {
156-
font-size: 12px;
157-
margin-top: 2px;
158-
word-break: break-all;
159-
}
160-
161-
.num {
162-
margin-top: 4px;
163-
display: flex;
164-
align-items: center;
165-
166-
.n-icon {
167-
margin-right: 2px;
168-
margin-top: -2px;
169-
}
170-
}
171-
172-
&:hover {
173-
border-color: rgba(var(--primary), 0.58);
174-
}
175-
176-
&.choose {
177-
border-color: rgba(var(--primary), 0.58);
178-
background-color: rgba(var(--primary), 0.28);
179-
}
180280
}
181281
182282
.song-list {

0 commit comments

Comments
 (0)