feat: 新增界面整体缩放与字体超大档,修复设置页溢出

- 界面缩放:Electron webContents.setZoomFactor,档位 90%/100%/110%/125%/150%
  - 主进程新增 set-zoom-factor IPC,启动时从 window-state.json 恢复 zoomFactor
  - 窗口尺寸保存与退出前均持久化当前 zoom
  - displaySettings 新增 UiScaleOption + initUiScale,App.vue 启动恢复
  - SettingsModal 新增「界面缩放」卡片,实时经 IPC 应用并持久化
- 字体大小新增「超大」档(html font-size 20px),共 5 档
- 修复设置模态框在字体/界面放大后内容超出视口:面板限制
  max-h-[calc(100vh-2rem)],内容区 flex-1 overflow-y-auto 滚动
This commit is contained in:
xiyidaiwa
2026-08-17 23:40:23 +08:00
parent 7b658e9dd1
commit e25a3c9d8f
5 changed files with 191 additions and 7 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
spark-store (5.3.0.3-test) UNRELEASED; urgency=medium spark-store (5.3.0.6-test) UNRELEASED; urgency=medium
* Initial release. * Initial release.
+34 -1
View File
@@ -170,6 +170,18 @@ ipcMain.handle("save-window-bounds", (): boolean => {
return true; return true;
}); });
// 渲染端(设置页)切换界面整体缩放系数,经此实时应用到主窗口。
// factor 限定在 0.5~3 之间,避免极端值导致界面不可用。
ipcMain.handle("set-zoom-factor", (_event, factor: unknown): boolean => {
if (typeof factor !== "number" || !Number.isFinite(factor)) return false;
const clamped = Math.min(3, Math.max(0.5, factor));
if (win && !win.isDestroyed()) {
win.webContents.setZoomFactor(clamped);
return true;
}
return false;
});
ipcMain.handle("get-app-version", (): string => getAppVersion()); ipcMain.handle("get-app-version", (): string => getAppVersion());
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo()); ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
@@ -307,6 +319,8 @@ interface WindowState {
x?: number; x?: number;
y?: number; y?: number;
maximized?: boolean; maximized?: boolean;
/** 界面整体缩放系数(Electron webContents.setZoomFactor),默认 1 */
zoomFactor?: number;
} }
function getWindowStatePath(): string { function getWindowStatePath(): string {
@@ -375,7 +389,14 @@ function flushSaveBounds(): void {
} }
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
const { width, height, x, y } = win.getBounds(); const { width, height, x, y } = win.getBounds();
saveWindowState({ width, height, x, y, maximized: win.isMaximized() }); saveWindowState({
width,
height,
x,
y,
maximized: win.isMaximized(),
zoomFactor: win.webContents.getZoomFactor(),
});
} }
} }
function scheduleSaveBounds(winInstance: BrowserWindow): void { function scheduleSaveBounds(winInstance: BrowserWindow): void {
@@ -389,6 +410,7 @@ function scheduleSaveBounds(winInstance: BrowserWindow): void {
x, x,
y, y,
maximized: winInstance.isMaximized(), maximized: winInstance.isMaximized(),
zoomFactor: winInstance.webContents.getZoomFactor(),
}); });
}, 400); }, 400);
} }
@@ -438,6 +460,17 @@ async function createWindow() {
}); });
win = mainWindow; win = mainWindow;
// 启动即应用持久化的界面缩放系数(默认 1 = 100%)。
// 必须在 loadURL/loadFile 前设置,使首帧即按目标缩放渲染,避免闪烁。
const savedZoom =
typeof saved.zoomFactor === "number" &&
saved.zoomFactor >= 0.5 &&
saved.zoomFactor <= 3
? saved.zoomFactor
: 1;
mainWindow.webContents.setZoomFactor(savedZoom);
logger.info({ zoomFactor: savedZoom }, "已应用界面缩放系数");
// 设计意图(非缺陷,勿改为自动恢复 maximized): // 设计意图(非缺陷,勿改为自动恢复 maximized):
// 启动时不自动恢复最大化状态。原因——最大化窗口在多数屏幕上 bounds 会超过 // 启动时不自动恢复最大化状态。原因——最大化窗口在多数屏幕上 bounds 会超过
// OVERSIZED_WINDOW_THRESHOLD(1600x900),若强行恢复最大化会导致「启动即全屏、 // OVERSIZED_WINDOW_THRESHOLD(1600x900),若强行恢复最大化会导致「启动即全屏、
+3 -1
View File
@@ -344,7 +344,7 @@ import ReviewUserProfileModal from "./components/ReviewUserProfileModal.vue";
import WindowTitleBar from "./components/WindowTitleBar.vue"; import WindowTitleBar from "./components/WindowTitleBar.vue";
import SubmitterWindow from "./components/SubmitterWindow.vue"; import SubmitterWindow from "./components/SubmitterWindow.vue";
import { initTagPriorityStrategy } from "./global/tagPriority"; import { initTagPriorityStrategy } from "./global/tagPriority";
import { initFontSize } from "./global/displaySettings"; import { initFontSize, initUiScale } from "./global/displaySettings";
import { import {
FLARUM_BASE_URL, FLARUM_BASE_URL,
FLARUM_REGISTER_URL, FLARUM_REGISTER_URL,
@@ -1055,6 +1055,8 @@ onMounted(async () => {
initTagPriorityStrategy(); initTagPriorityStrategy();
// 恢复并应用持久化的字体大小档位(仅字号),避免渲染瞬间默认字号闪烁。 // 恢复并应用持久化的字体大小档位(仅字号),避免渲染瞬间默认字号闪烁。
initFontSize(); initFontSize();
// 恢复并应用持久化的界面整体缩放(Electron setZoomFactor),与字号正交。
void initUiScale();
initTheme(); initTheme();
updateCenterStore.bind(); updateCenterStore.bind();
+75 -3
View File
@@ -13,7 +13,7 @@
@click.self="closeModal" @click.self="closeModal"
> >
<div <div
class="relative w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900" class="relative flex max-h-[calc(100vh-2rem)] w-full max-w-md flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
> >
<!-- 标题栏 --> <!-- 标题栏 -->
<div <div
@@ -35,8 +35,8 @@
</button> </button>
</div> </div>
<!-- 设置内容 --> <!-- 设置内容可滚动标题/底部固定 -->
<div class="p-6 space-y-4"> <div class="flex-1 overflow-y-auto p-6 space-y-4">
<!-- 更新检测开关 --> <!-- 更新检测开关 -->
<div <div
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50" class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50"
@@ -213,6 +213,51 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 界面缩放整体缩放图标 / 间距 / 布局等比变化 -->
<div
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-4 dark:border-slate-800/60 dark:bg-slate-800/50"
>
<div class="flex items-start gap-3">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-teal-100 text-teal-600 dark:bg-teal-900/30 dark:text-teal-400"
>
<i class="fas fa-search-plus"></i>
</div>
<div class="min-w-0 flex-1">
<p
class="text-sm font-medium text-slate-800 dark:text-slate-200"
>
界面缩放
</p>
<p class="text-xs text-slate-500 dark:text-slate-400">
整体放大或缩小界面图标间距布局等比变化
</p>
<div
class="mt-3 inline-flex w-full overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700"
role="radiogroup"
aria-label="界面缩放"
>
<button
v-for="opt in uiScaleOptions"
:key="opt.value"
type="button"
role="radio"
:aria-checked="uiScale === opt.value"
class="flex-1 px-2 py-1.5 text-xs font-medium transition-colors"
:class="
uiScale === opt.value
? 'bg-brand text-white'
: 'bg-white text-slate-500 hover:bg-slate-100 dark:bg-slate-700 dark:text-slate-400 dark:hover:bg-slate-600'
"
@click="selectUiScale(opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
</div> </div>
<!-- 底部提示 --> <!-- 底部提示 -->
@@ -240,6 +285,11 @@ import {
setFontSize, setFontSize,
FONT_SIZE_OPTIONS, FONT_SIZE_OPTIONS,
type FontSizeOption, type FontSizeOption,
getUiScale,
setUiScale,
uiScaleToFactor,
UI_SCALE_OPTIONS,
type UiScaleOption,
} from "../global/displaySettings"; } from "../global/displaySettings";
const props = defineProps<{ const props = defineProps<{
@@ -265,6 +315,9 @@ const strategyOptions: Array<{ value: TagPriorityStrategy; label: string }> = [
// 字体大小档位选项(小 / 标准 / 大 / 特大) // 字体大小档位选项(小 / 标准 / 大 / 特大)
const fontSizeOptions = FONT_SIZE_OPTIONS; const fontSizeOptions = FONT_SIZE_OPTIONS;
// 界面缩放档位选项(90% / 100% / 110% / 125% / 150%
const uiScaleOptions = UI_SCALE_OPTIONS;
const tagPriorityStrategy = ref<TagPriorityStrategy>("auto"); const tagPriorityStrategy = ref<TagPriorityStrategy>("auto");
// 加载标签优先显示策略(从持久化读取) // 加载标签优先显示策略(从持久化读取)
@@ -291,6 +344,24 @@ const selectFontSize = (value: FontSizeOption) => {
setFontSize(value); setFontSize(value);
}; };
// 界面整体缩放档位(Electron setZoomFactor,连图标/间距/布局等比变化)
const uiScale = ref<UiScaleOption>("100");
const loadUiScale = () => {
uiScale.value = getUiScale();
};
// 选择并保存界面缩放档位(立即经 IPC 应用到主窗口)
const selectUiScale = async (value: UiScaleOption) => {
uiScale.value = value;
setUiScale(value);
try {
await window.ipcRenderer.invoke("set-zoom-factor", uiScaleToFactor(value));
} catch (error) {
console.error("应用界面缩放失败:", error);
}
};
const settings = ref<Settings>({ const settings = ref<Settings>({
enableUpdateCheck: true, enableUpdateCheck: true,
enableCreateDesktop: true, enableCreateDesktop: true,
@@ -352,6 +423,7 @@ watch(
loadSettings(); loadSettings();
loadTagPriority(); loadTagPriority();
loadFontSize(); loadFontSize();
loadUiScale();
} }
}, },
); );
+78 -1
View File
@@ -7,7 +7,7 @@
* *
* 持久化:localStorage key = "spark-store-font-size",存档位枚举字符串。 * 持久化:localStorage key = "spark-store-font-size",存档位枚举字符串。
*/ */
export type FontSizeOption = "small" | "medium" | "large" | "xlarge"; export type FontSizeOption = "small" | "medium" | "large" | "xlarge" | "xxlarge";
interface FontSizeMeta { interface FontSizeMeta {
/** 应用到 html 的 font-sizepx */ /** 应用到 html 的 font-sizepx */
@@ -23,6 +23,7 @@ export const FONT_SIZE_OPTIONS: Array<{
{ value: "medium", label: "标准" }, { value: "medium", label: "标准" },
{ value: "large", label: "大" }, { value: "large", label: "大" },
{ value: "xlarge", label: "特大" }, { value: "xlarge", label: "特大" },
{ value: "xxlarge", label: "超大" },
]; ];
// 各档位对应的根字号。medium 保持现状 16px,向上放大、向下略缩。 // 各档位对应的根字号。medium 保持现状 16px,向上放大、向下略缩。
@@ -31,6 +32,7 @@ const FONT_SIZE_MAP: Record<FontSizeOption, FontSizeMeta> = {
medium: { px: 16, label: "标准" }, medium: { px: 16, label: "标准" },
large: { px: 17, label: "大" }, large: { px: 17, label: "大" },
xlarge: { px: 18, label: "特大" }, xlarge: { px: 18, label: "特大" },
xxlarge: { px: 20, label: "超大" },
}; };
const FONT_SIZE_STORAGE_KEY = "spark-store-font-size"; const FONT_SIZE_STORAGE_KEY = "spark-store-font-size";
@@ -73,3 +75,78 @@ export const setFontSize = (option: FontSizeOption): void => {
export const initFontSize = (): void => { export const initFontSize = (): void => {
applyFontSize(getFontSize()); applyFontSize(getFontSize());
}; };
/* ------------------------------------------------------------------ *
* 界面整体缩放(Electron webContents.setZoomFactor
*
* 与「字体大小」正交:字号只改 html font-sizerem 字号类),
* 而界面缩放会连图标、间距、布局一起等比放大/缩小(逻辑分辨率变化)。
* 实际缩放由主进程 setZoomFactor 执行;渲染端仅负责持久化档位偏好,
* 并在启动时把档位换算的系数通过 IPC 告知主进程。
* ------------------------------------------------------------------ */
export type UiScaleOption = "90" | "100" | "110" | "125" | "150";
// 档位 → 缩放系数(与 Electron setZoomFactor 一致,1 = 100%
const UI_SCALE_MAP: Record<UiScaleOption, number> = {
"90": 0.9,
"100": 1,
"110": 1.1,
"125": 1.25,
"150": 1.5,
};
export const UI_SCALE_OPTIONS: Array<{
value: UiScaleOption;
label: string;
}> = [
{ value: "90", label: "90%" },
{ value: "100", label: "100%" },
{ value: "110", label: "110%" },
{ value: "125", label: "125%" },
{ value: "150", label: "150%" },
];
const UI_SCALE_STORAGE_KEY = "spark-store-ui-scale";
const DEFAULT_UI_SCALE: UiScaleOption = "100";
const isValidUiScale = (v: unknown): v is UiScaleOption =>
typeof v === "string" && v in UI_SCALE_MAP;
/** 读取持久化的界面缩放档位(无/非法时回退默认「100%」) */
export const getUiScale = (): UiScaleOption => {
try {
const raw = localStorage.getItem(UI_SCALE_STORAGE_KEY);
if (isValidUiScale(raw)) return raw;
} catch {
// localStorage 不可用时忽略,使用默认
}
return DEFAULT_UI_SCALE;
};
/** 将档位换算为 Electron 缩放系数 */
export const uiScaleToFactor = (option: UiScaleOption): number =>
UI_SCALE_MAP[option] ?? UI_SCALE_MAP[DEFAULT_UI_SCALE];
/** 选择并持久化界面缩放档位(不在此直接调用主进程,由调用方经 IPC 应用) */
export const setUiScale = (option: UiScaleOption): void => {
try {
localStorage.setItem(UI_SCALE_STORAGE_KEY, option);
} catch {
// 持久化失败时仍已在会话内选定,忽略写入错误
}
};
/**
* 应用启动时初始化:从持久化恢复档位,并经 IPC 告知主进程应用缩放。
* 需在 App.vue onMounted 调用(initFontSize 之后或同级均可),
* 主进程 createWindow 阶段已自有兜底(默认 1),此处确保渲染端偏好生效。
*/
export const initUiScale = async (): Promise<void> => {
const factor = uiScaleToFactor(getUiScale());
try {
await window.ipcRenderer.invoke("set-zoom-factor", factor);
} catch {
// 主进程 IPC 不可用时忽略(主进程启动兜底已设为 1)
}
};