From 19d5f4e9d63c3a95652cf097d0daa1c8499a5bbc Mon Sep 17 00:00:00 2001 From: imsyy Date: Thu, 7 May 2026 17:08:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=9B=BE=E7=89=87=E8=83=8C=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components.d.ts | 2 + electron/main/ipc/index.ts | 2 + electron/main/ipc/theme.ts | 71 +++++++++++++++++ electron/preload/index.d.ts | 4 + electron/preload/index.ts | 7 ++ src/App.vue | 1 + src/components/AppBackground.vue | 33 ++++++++ .../settings/custom/BackgroundImagePicker.vue | 72 +++++++++++++++++ src/components/ui/SCombobox.vue | 2 +- src/components/ui/SMenu.vue | 2 +- src/components/ui/SSelect.vue | 2 +- src/i18n/locales/en-US.json | 34 +++++++- src/i18n/locales/zh-CN.json | 36 +++++++-- src/layouts/MainLayout.vue | 34 ++++---- src/layouts/components/NavHeader.vue | 7 +- src/settings/schema.ts | 58 ++++++++++++++ src/stores/theme.ts | 78 +++++++++++++++++-- src/styles/global.css | 53 ++++++++++++- src/types/theme.ts | 15 ++++ src/utils/color.ts | 16 +++- uno.config.ts | 6 ++ 21 files changed, 497 insertions(+), 38 deletions(-) create mode 100644 electron/main/ipc/theme.ts create mode 100644 src/components/AppBackground.vue create mode 100644 src/components/settings/custom/BackgroundImagePicker.vue diff --git a/components.d.ts b/components.d.ts index 54b43a8..0c5cfd7 100644 --- a/components.d.ts +++ b/components.d.ts @@ -13,7 +13,9 @@ declare module 'vue' { export interface GlobalComponents { AbLoopDialog: typeof import('./src/components/modals/AbLoopDialog.vue')['default'] AmllDbServerConfig: typeof import('./src/components/settings/custom/AmllDbServerConfig.vue')['default'] + AppBackground: typeof import('./src/components/AppBackground.vue')['default'] AutoCloseDialog: typeof import('./src/components/modals/AutoCloseDialog.vue')['default'] + BackgroundImagePicker: typeof import('./src/components/settings/custom/BackgroundImagePicker.vue')['default'] BottomSpectrum: typeof import('./src/components/player/FullPlayer/BottomSpectrum.vue')['default'] ComboboxAnchor: typeof import('reka-ui')['ComboboxAnchor'] ComboboxContent: typeof import('reka-ui')['ComboboxContent'] diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index 3c805f9..7887400 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -8,6 +8,7 @@ import { registerPluginIpc } from "./plugin"; import { registerApisIpc } from "./apis"; import { registerLyricsIpc } from "./lyrics"; import { registerHotkeyIpc } from "./hotkey"; +import { registerThemeIpc } from "./theme"; /** 注册所有 IPC 处理 */ export const registerIpcHandlers = (): void => { @@ -21,4 +22,5 @@ export const registerIpcHandlers = (): void => { registerApisIpc(); registerLyricsIpc(); registerHotkeyIpc(); + registerThemeIpc(); }; diff --git a/electron/main/ipc/theme.ts b/electron/main/ipc/theme.ts new file mode 100644 index 0000000..b3549ae --- /dev/null +++ b/electron/main/ipc/theme.ts @@ -0,0 +1,71 @@ +import { dialog, ipcMain } from "electron"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { appCacheDir } from "@main/utils/config"; +import { toCacheUrl } from "@main/utils/protocol"; +import { systemLog } from "@main/utils/logger"; + +/** 背景图片缓存目录 */ +const bgDir = path.join(appCacheDir, "backgrounds"); + +/** 允许的图片扩展名 */ +const allowedExt = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]); + +/** 图片体积上限:30MB */ +const MAX_BG_SIZE = 30 * 1024 * 1024; + +/** + * 注册主题相关的 IPC + */ +export const registerThemeIpc = (): void => { + /** + * 弹出文件选择框,选择背景图后复制到 app-cache/backgrounds/. + * 返回 cache:// URL;用户取消 / 体积超限 / 失败均返回 null + */ + ipcMain.handle("theme:pickBackgroundImage", async (): Promise => { + try { + const result = await dialog.showOpenDialog({ + title: "选择背景图片", + properties: ["openFile"], + filters: [{ name: "Images", extensions: ["jpg", "jpeg", "png", "webp", "bmp", "gif"] }], + }); + if (result.canceled) return null; + const src = result.filePaths[0]; + if (!src) return null; + const ext = path.extname(src).toLowerCase(); + if (!allowedExt.has(ext)) return null; + // 体积校验 + const stat = await fs.stat(src); + if (stat.size > MAX_BG_SIZE) { + systemLog.warn( + `[theme] background image too large: ${stat.size} bytes (max ${MAX_BG_SIZE})`, + ); + return null; + } + const data = await fs.readFile(src); + // 内容 hash 作为文件名 + const hash = createHash("sha1").update(data).digest("hex").slice(0, 16); + // 清空整个目录再写入新图 + await fs.rm(bgDir, { recursive: true, force: true }); + await fs.mkdir(bgDir, { recursive: true }); + const dest = path.join(bgDir, `${hash}${ext}`); + await fs.writeFile(dest, data); + return toCacheUrl(dest) ?? null; + } catch (err) { + systemLog.error("[theme] pickBackgroundImage failed", err); + return null; + } + }); + + /** + * 清空所有缓存的背景图 + */ + ipcMain.handle("theme:clearBackgroundImages", async (): Promise => { + try { + await fs.rm(bgDir, { recursive: true, force: true }); + } catch (err) { + systemLog.error("[theme] clearBackgroundImages failed", err); + } + }); +}; diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index ab5f087..bc7d4c8 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -40,6 +40,10 @@ declare global { plugins: PluginsApi; apis: ApisApi; lyrics: LyricsApi; + theme: { + pickBackgroundImage: () => Promise; + clearBackgroundImages: () => Promise; + }; hotkey: HotkeyApi; }; } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 4b4d61e..47a9dab 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -295,6 +295,13 @@ const api = { onPositionSync: (callback: (data: unknown) => void) => subscribe("nowPlaying:position-sync", callback), }, + theme: { + // 弹出文件选择框 + pickBackgroundImage: (): Promise => + ipcRenderer.invoke("theme:pickBackgroundImage"), + // 清空已缓存的背景图 + clearBackgroundImages: (): Promise => ipcRenderer.invoke("theme:clearBackgroundImages"), + }, hotkey: { getAll: () => ipcRenderer.invoke("hotkey:getAll"), set: (id: HotkeyActionId, binding: HotkeyBinding) => diff --git a/src/App.vue b/src/App.vue index d584dc7..e8dfa16 100644 --- a/src/App.vue +++ b/src/App.vue @@ -10,5 +10,6 @@ watchEffect(() => { diff --git a/src/components/AppBackground.vue b/src/components/AppBackground.vue new file mode 100644 index 0000000..fac10fa --- /dev/null +++ b/src/components/AppBackground.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/settings/custom/BackgroundImagePicker.vue b/src/components/settings/custom/BackgroundImagePicker.vue new file mode 100644 index 0000000..997dc76 --- /dev/null +++ b/src/components/settings/custom/BackgroundImagePicker.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/components/ui/SCombobox.vue b/src/components/ui/SCombobox.vue index b4826eb..2cc4c08 100644 --- a/src/components/ui/SCombobox.vue +++ b/src/components/ui/SCombobox.vue @@ -135,7 +135,7 @@ const compareByValue = (a: unknown, b: unknown): boolean => { diff --git a/src/components/ui/SMenu.vue b/src/components/ui/SMenu.vue index b75ca7a..ab39a0a 100644 --- a/src/components/ui/SMenu.vue +++ b/src/components/ui/SMenu.vue @@ -78,7 +78,7 @@ const handleSelect = (item: SMenuItem) => { sizeClass.item, modelValue === item.key ? 'bg-primary/10 text-primary' - : 'text-on-surface-variant hover:bg-on-surface/5', + : 'text-on-surface/80 hover:bg-on-surface/5', item.disabled ? 'opacity-40 pointer-events-none' : '', ]" @click="handleSelect(item)" diff --git a/src/components/ui/SSelect.vue b/src/components/ui/SSelect.vue index 81a17d7..d7ad682 100644 --- a/src/components/ui/SSelect.vue +++ b/src/components/ui/SSelect.vue @@ -42,7 +42,7 @@ const handleChange = (val: string) => { @update:model-value="handleChange" > {{ selectedLabel }} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 3142ae8..83fc6b3 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -191,6 +191,7 @@ "dynamicIsland": "Dynamic Island Lyric", "taskbarLyric": "Taskbar Lyric", "theme": "Theme", + "appearanceStyle": "Appearance Style", "layout": "Layout", "font": "Fonts", "media": "Media Controls", @@ -619,10 +620,10 @@ "themeSource": { "label": "Theme Color Source", "description": "How the theme color is determined", - "default": "Default", - "custom": "Custom", - "cover": "From Cover", - "solid": "Solid" + "default": "Default Color", + "custom": "Custom Color", + "cover": "From Cover / Background", + "solid": "No Theme Color" }, "customColor": { "label": "Custom Theme Color", @@ -632,6 +633,31 @@ "label": "Global Tint", "description": "Apply theme color tint to the entire interface" }, + "appearanceStyle": { + "label": "Appearance Style", + "description": "How the application background is rendered", + "solid": "Solid color", + "image": "Custom image" + }, + "backgroundImage": { + "label": "Background Image", + "description": "Pick a local image as the app background", + "select": "Choose image", + "replace": "Replace", + "clear": "Clear" + }, + "backgroundBlur": { + "label": "Background Blur", + "description": "Gaussian blur applied to the background image" + }, + "backgroundDim": { + "label": "Dim Overlay", + "description": "Black overlay opacity for foreground readability" + }, + "backgroundScale": { + "label": "Scale", + "description": "Background image scale factor" + }, "layoutMode": { "label": "Layout Mode", "description": "Application layout style", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a49d153..4e7ea0c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -190,7 +190,8 @@ "desktopLyric": "桌面歌词", "dynamicIsland": "灵动岛歌词", "taskbarLyric": "任务栏歌词", - "theme": "主题与风格", + "theme": "主题与颜色", + "appearanceStyle": "外观风格", "layout": "布局", "font": "字体", "media": "媒体控制", @@ -619,10 +620,10 @@ "themeSource": { "label": "主题色来源", "description": "主题色的获取方式", - "default": "默认", - "custom": "自定义", - "cover": "跟随封面", - "solid": "纯色" + "default": "默认主题色", + "custom": "自定义主色", + "cover": "跟随封面 / 背景", + "solid": "无主题色" }, "customColor": { "label": "自定义主题色", @@ -632,6 +633,31 @@ "label": "全局着色", "description": "将主题色应用到全局界面" }, + "appearanceStyle": { + "label": "外观风格", + "description": "应用主背景的呈现方式", + "solid": "纯色背景", + "image": "自定义图片" + }, + "backgroundImage": { + "label": "背景图片", + "description": "选择本地图片作为应用背景", + "select": "选择图片", + "replace": "更换图片", + "clear": "清除" + }, + "backgroundBlur": { + "label": "背景模糊", + "description": "对背景图片应用高斯模糊" + }, + "backgroundDim": { + "label": "遮罩浓度", + "description": "叠加的黑色遮罩透明度,越高前景越易读" + }, + "backgroundScale": { + "label": "缩放大小", + "description": "背景图的缩放倍数" + }, "layoutMode": { "label": "布局模式", "description": "应用的整体布局方式", diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 9705c6c..4b29a6e 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -55,7 +55,7 @@ const playerBarInnerClass = computed(() => { const base = "pointer-events-auto"; switch (appearance.layoutMode) { case "floating": - return `${base} mx-auto max-w-4xl bg-surface-panel/80 backdrop-blur-2xl backdrop-saturate-150 rounded-full shadow-xl border border-solid border-primary/10`; + return `${base} mx-auto max-w-4xl glass-panel rounded-full shadow-xl border border-solid border-primary/10`; default: return `${base} h-20 bg-surface-panel border-t border-t-solid border-t-primary/10`; } @@ -65,7 +65,7 @@ const playerBarInnerClass = computed(() => {