diff --git a/components.d.ts b/components.d.ts
index 381335e0..0f343c01 100644
--- a/components.d.ts
+++ b/components.d.ts
@@ -87,7 +87,6 @@ declare module 'vue' {
IconLucideCloudUpload: typeof import('~icons/lucide/cloud-upload')['default']
IconLucideCopy: typeof import('~icons/lucide/copy')['default']
IconLucideDatabase: typeof import('~icons/lucide/database')['default']
- IconLucideDisc: typeof import('~icons/lucide/disc')['default']
IconLucideDisc3: typeof import('~icons/lucide/disc3')['default']
IconLucideDownload: typeof import('~icons/lucide/download')['default']
IconLucideEllipsis: typeof import('~icons/lucide/ellipsis')['default']
@@ -229,6 +228,7 @@ declare module 'vue' {
SFormItem: typeof import('./src/components/ui/SFormItem.vue')['default']
SIconSwap: typeof import('./src/components/ui/SIconSwap.vue')['default']
SideBar: typeof import('./src/components/layout/SideBar.vue')['default']
+ SidebarCustomizeConfig: typeof import('./src/components/settings/custom/SidebarCustomizeConfig.vue')['default']
SideBarLogo: typeof import('./src/components/layout/SideBarLogo.vue')['default']
SImg: typeof import('./src/components/ui/SImg.vue')['default']
SInput: typeof import('./src/components/ui/SInput.vue')['default']
diff --git a/src/assets/icons/type-off.svg b/src/assets/icons/type-off.svg
new file mode 100644
index 00000000..8cc6a935
--- /dev/null
+++ b/src/assets/icons/type-off.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/components/layout/SideBar.vue b/src/components/layout/SideBar.vue
index 6c5c68ee..5e15afad 100644
--- a/src/components/layout/SideBar.vue
+++ b/src/components/layout/SideBar.vue
@@ -1,28 +1,23 @@
+
+
+
+ {{ t("common.configure") }}
+
+
+
+
+ {{ t("settings.sidebarCustomize.nav") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.label }}
+
+
+ {{ t("settings.sidebarCustomize.featureDisabled") }}
+
+
+
+
+
+
+
+
+
+
+ {{ t("settings.sidebarCustomize.emptyGroup") }}
+
+
+
+
+
+
+ {{ t("settings.sidebarCustomize.addGroup") }}
+
+
+ {{ t("settings.sidebarCustomize.keepEmptyDivider") }}
+
+
+
+ {{ t("settings.sidebarCustomize.nameWithDivider") }}
+
+
+
+ {{ t("settings.sidebarCustomize.groups") }}
+
+
+
+
+
+
+
+
+ {{ t("collection.my", { type: t("collection.playlist") }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ mySource === "local"
+ ? t("collection.localPlaylist")
+ : t("collection.onlinePlaylist")
+ }}
+
+
+
+
+
+
+
+
+
+ {{ row.title }}
+
+
+
+
+
+
+
+
+
+ {{ t("settings.sidebarCustomize.noPlaylists") }}
+
+
+
+
+
+
+
+
+
+
+ {{ t("collection.subscribed", { type: t("collection.playlist") }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.title }}
+
+
+
+
+
+
+
+
+
+ {{ t("settings.sidebarCustomize.noPlaylists") }}
+
+
+
+
+
+
+
+ {{ t("common.reset") }}
+ {{ t("common.cancel") }}
+ {{ t("common.confirm") }}
+
+
+
diff --git a/src/components/ui/SMenu.vue b/src/components/ui/SMenu.vue
index 1a46b2c0..7fc41099 100644
--- a/src/components/ui/SMenu.vue
+++ b/src/components/ui/SMenu.vue
@@ -110,6 +110,7 @@ const handleSelect = (item: SMenuItem) => {
>
(stored: T[], all: readonly T[]): T[] => {
return [...known, ...missing];
};
+/**
+ * 对账侧边栏导航分组:仅保留已知导航项并去重(空组保留),
+ * 新增的导航项补到末组,存档无效时回退默认分组
+ * @param stored - 存档分组
+ * @returns 对账后的分组
+ */
+const reconcileNavGroups = (stored: unknown): SidebarNavGroup[] => {
+ const all = DEFAULT_SIDEBAR_NAV_GROUPS.flatMap((group) => group.keys);
+ const seen = new Set();
+ const groups: SidebarNavGroup[] = [];
+ for (const raw of Array.isArray(stored) ? stored : []) {
+ const record = raw as Partial | null;
+ if (!Array.isArray(record?.keys)) continue;
+ const keys: string[] = [];
+ for (const key of record.keys) {
+ if (!all.includes(key) || seen.has(key)) continue;
+ seen.add(key);
+ keys.push(key);
+ }
+ groups.push({
+ name: typeof record.name === "string" ? record.name : "",
+ showName: record.showName === true,
+ keys,
+ });
+ }
+ if (groups.length === 0)
+ return DEFAULT_SIDEBAR_NAV_GROUPS.map((group) => ({ ...group, keys: [...group.keys] }));
+ const missing = all.filter((key) => !seen.has(key));
+ if (missing.length > 0) groups[groups.length - 1].keys.push(...missing);
+ return groups;
+};
+
+/**
+ * 对账侧边栏隐藏键:仅保留有效的导航项、歌单分组与歌单路由键,首页不可隐藏
+ * @param stored - 存档隐藏键
+ * @returns 对账后的隐藏键
+ */
+const reconcileHiddenKeys = (stored: string[]): string[] => {
+ const valid = new Set([
+ ...DEFAULT_SIDEBAR_NAV_GROUPS.flatMap((group) => group.keys),
+ SIDEBAR_GROUP_MY_PLAYLISTS,
+ SIDEBAR_GROUP_SUBSCRIBED,
+ ]);
+ return stored.filter((key) => key !== "/" && (valid.has(key) || key.startsWith("/collection/")));
+};
+
+/**
+ * 对账侧边栏歌单顺序:剔除非法存档,保证各字段均为字符串数组
+ * @param stored - 存档顺序
+ * @returns 对账后的顺序
+ */
+const reconcilePlaylistOrder = (stored: unknown): SidebarPlaylistOrder => {
+ const record = (stored ?? {}) as Partial>;
+ const orderOf = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter((key) => typeof key === "string") : [];
+ return {
+ myLocal: orderOf(record.myLocal),
+ myOnline: orderOf(record.myOnline),
+ subscribed: orderOf(record.subscribed),
+ };
+};
+
export const useSettingsStore = defineStore(
"settings",
() => {
@@ -41,6 +108,14 @@ export const useSettingsStore = defineStore(
routeTransition: "fade",
sidebarCollapsed: false,
sidebarPlaylistCover: false,
+ sidebarNavGroups: DEFAULT_SIDEBAR_NAV_GROUPS.map((group) => ({
+ ...group,
+ keys: [...group.keys],
+ })),
+ sidebarHiddenKeys: [],
+ sidebarKeepEmptyDivider: false,
+ sidebarNameWithDivider: false,
+ sidebarPlaylistOrder: { myLocal: [], myOnline: [], subscribed: [] },
showQualitySwitch: false,
closeAction: "hide",
rememberCloseChoice: false,
@@ -265,12 +340,18 @@ export const useSettingsStore = defineStore(
storage: localStorage,
omit: ["system"],
afterHydrate: ({ store }) => {
- const { lyric } = store as unknown as { lyric: LyricSettings };
+ const { lyric, appearance } = store as unknown as {
+ lyric: LyricSettings;
+ appearance: AppearanceSettings;
+ };
if (typeof lyric.detectBackgroundLyrics !== "boolean") {
lyric.detectBackgroundLyrics = true;
}
lyric.lyricSourceOrder = reconcileOrder(lyric.lyricSourceOrder, ALL_PLATFORMS);
lyric.lyricFormatOrder = reconcileOrder(lyric.lyricFormatOrder, DEFAULT_LYRIC_FORMAT_ORDER);
+ appearance.sidebarNavGroups = reconcileNavGroups(appearance.sidebarNavGroups ?? []);
+ appearance.sidebarHiddenKeys = reconcileHiddenKeys(appearance.sidebarHiddenKeys ?? []);
+ appearance.sidebarPlaylistOrder = reconcilePlaylistOrder(appearance.sidebarPlaylistOrder);
},
},
},
diff --git a/src/types/settings.ts b/src/types/settings.ts
index 6a207a85..9ab364b3 100644
--- a/src/types/settings.ts
+++ b/src/types/settings.ts
@@ -68,6 +68,46 @@ export const DEFAULT_LYRIC_SOURCE_ORDER: LyricSourceOrder = [...ALL_PLATFORMS];
/** 默认格式优先级 */
export const DEFAULT_LYRIC_FORMAT_ORDER: LyricFormatOrder = [...DEFAULT_LYRIC_FORMAT_ORDER_SHARED];
+/** 侧边栏「我的歌单」分组 key(仅显隐,不参与排序) */
+export const SIDEBAR_GROUP_MY_PLAYLISTS = "group-my-playlists";
+
+/** 侧边栏「收藏的歌单」分组 key(仅显隐,不参与排序) */
+export const SIDEBAR_GROUP_SUBSCRIBED = "group-subscribed";
+
+/** 侧边栏导航分组(匿名分组,可命名;组间以分隔线或分组名区分) */
+export interface SidebarNavGroup {
+ /** 分组名,空字符串为未命名 */
+ name: string;
+ /** 是否在侧栏显示分组名 */
+ showName: boolean;
+ /** 组内导航项 key(路由路径) */
+ keys: string[];
+}
+
+/** 侧边栏歌单显示顺序(key 为歌单路由路径;空数组为自然顺序) */
+export interface SidebarPlaylistOrder {
+ /** 我的歌单 - 本地 */
+ myLocal: string[];
+ /** 我的歌单 - 在线 */
+ myOnline: string[];
+ /** 收藏的歌单 */
+ subscribed: string[];
+}
+
+/** 侧边栏导航项默认分组 */
+export const DEFAULT_SIDEBAR_NAV_GROUPS: SidebarNavGroup[] = [
+ {
+ name: "",
+ showName: false,
+ keys: ["/", "/library", "/artists/local", "/albums/local", "/folders"],
+ },
+ {
+ name: "",
+ showName: false,
+ keys: ["/liked", "/favorites", "/cloud", "/download", "/streaming", "/history"],
+ },
+];
+
/** 歌词设置 */
export interface LyricSettings {
/** 歌词来源偏好 */
@@ -200,6 +240,16 @@ export interface AppearanceSettings {
sidebarCollapsed: boolean;
/** 侧边栏歌单项显示封面 */
sidebarPlaylistCover: boolean;
+ /** 侧边栏导航分组(匿名分组,可命名;组间以分隔线或分组名区分) */
+ sidebarNavGroups: SidebarNavGroup[];
+ /** 侧边栏隐藏的导航项与歌单分组 */
+ sidebarHiddenKeys: string[];
+ /** 无可见项的分组是否保留分隔线(留白) */
+ sidebarKeepEmptyDivider: boolean;
+ /** 显示分组名时是否叠加分隔线 */
+ sidebarNameWithDivider: boolean;
+ /** 侧边栏歌单显示顺序 */
+ sidebarPlaylistOrder: SidebarPlaylistOrder;
/** 播放栏显示快捷音质切换 */
showQualitySwitch: boolean;
/** 点击关闭按钮的行为 */