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 <!-- 右侧歌曲列表 -->
3932
4033<script setup lang="ts">
4134import type { SongType } from " @/types/main" ;
35+ import type { TreeOption } from " naive-ui" ;
4236import { useLocalStore , useSettingStore } from " @/stores" ;
4337import 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
4642const 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// 删除歌曲时,同步更新本地歌曲列表
100244const 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+ // 初始化时展开第一层
106253watch (
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