From 6b2e40b69296dd75662330e0cd5d0f984245e890 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sat, 22 Aug 2026 15:26:33 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(android):=20=E4=BF=AE=E5=A4=8D=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E8=AE=BE=E7=BD=AE=E4=B8=8E=E6=B6=A6=E8=89=B2?= =?UTF-8?q?=E9=A1=B5=E6=8E=92=E7=89=88=E5=8F=8A=E5=BA=95=E6=A0=8F=E9=81=AE?= =?UTF-8?q?=E6=8C=A1=20(#960)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置弹窗 z-index 低于底栏导致底部控件无法点击;润色风格/市场页与 Android 权限区补齐 mobile 布局。 Co-authored-by: Cursor --- .../components/AndroidPermissionsPanel.tsx | 75 +++++++++++-------- .../app/src/components/FloatingShell.tsx | 2 + .../app/src/components/SettingsModal.tsx | 2 +- openless-all/app/src/pages/Marketplace.tsx | 15 ++-- openless-all/app/src/pages/Style.tsx | 65 ++++++++++------ openless-all/app/src/styles/global.css | 6 ++ 6 files changed, 105 insertions(+), 60 deletions(-) diff --git a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx index 5c242a860..22cee5ab1 100644 --- a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx +++ b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx @@ -6,6 +6,7 @@ import { getSettings, setSettings } from '../../../src/lib/ipc/settings'; import type { UserPreferences } from '../../../src/lib/types'; import { Btn, Pill } from '../../../src/pages/_atoms'; import { SettingRow } from '../../../src/pages/settings/shared'; +import { useMobileLayout } from '../../../src/lib/useMobileLayout'; import { getAndroidAccessibilityStatus, getAndroidOverlayStatus, @@ -73,6 +74,7 @@ interface AndroidPermissionsPanelProps { export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPanelProps) { const { t } = useTranslation(); + const mobile = useMobileLayout(); const [androidOverlay, setAndroidOverlay] = useState(null); const [androidAccessibility, setAndroidAccessibility] = useState(null); const [androidShizuku, setAndroidShizuku] = useState(null); @@ -261,13 +263,22 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane const showAccessibility = mode === 'all' || mode === 'accessibility'; const showOverlayConfig = mode === 'all' || mode === 'overlayConfig'; + const rowJustify = mobile ? 'flex-start' : 'flex-end'; + const colAlign = mobile ? 'stretch' : 'flex-end'; + const hintAlign = mobile ? 'left' : 'right'; + const hintMaxWidth = mobile ? undefined : 300; + const hintMaxWidthSm = mobile ? undefined : 220; + const selectStyle = mobile + ? { width: '100%', maxWidth: '100%' as const } + : { minWidth: 180, maxWidth: '100%' as const }; + return ( <> {showOverlayPermission && ( -
+
{androidOverlay?.message && ( - + {androidOverlay.message} )} @@ -282,10 +293,10 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane )} {showAccessibility && ( -
-
+
+
{resolveAccessibilityMessage(t, androidAccessibility?.messageKey) && ( - + {resolveAccessibilityMessage(t, androidAccessibility?.messageKey)} )} @@ -296,7 +307,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane )}
- + {t('settings.permissions.androidAccessibilityImpact')}
@@ -304,10 +315,10 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane )} {showAccessibility && ( -
-
+
+
{resolveShizukuMessage(t, shizukuDisplayMessageKey) && ( - + {resolveShizukuMessage(t, shizukuDisplayMessageKey)} )} @@ -366,7 +377,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane )}
{androidShizuku?.state === 'authorized' && ( - + {androidShizuku.accessibility.operational ? t('settings.permissions.androidShizukuAccessibilityOperational') : t('settings.permissions.androidShizukuAccessibilityRegistered', { @@ -379,7 +390,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane })} )} - + {t('settings.permissions.androidShizukuHint')}
@@ -388,87 +399,87 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane {showOverlayConfig && ( <> -
+
- + {t(`settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'accessibility'}`)}
-
+
- + {t(`settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`)} - + {t('settings.permissions.androidOverlayTriggerDisabled.keyboard')}
-
+
- + {t(`settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`)}
-
+
- + {t(`settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`)}
-
+
- + {t(`settings.permissions.androidOverlayCancelSwipeDirectionHint.${androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}`)}
-
-
+
+
{ flushAndroidOverlaySizeSave(Number(event.currentTarget.value)); }} - style={{ width: 132 }} + style={{ flex: 1, minWidth: 0 }} /> - + {sizeDraft ?? androidPrefs?.androidOverlaySizeDp ?? 72} dp
- + {t('settings.permissions.androidOverlaySizeHint')}
diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 2770145e4..85fd4d9a5 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -462,6 +462,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia {mobile && ( <> + {!settingsOpen && !styleOpen && !moreOpen && ( + )} diff --git a/openless-all/app/src/pages/Marketplace.tsx b/openless-all/app/src/pages/Marketplace.tsx index 40e85f11a..1762d631a 100644 --- a/openless-all/app/src/pages/Marketplace.tsx +++ b/openless-all/app/src/pages/Marketplace.tsx @@ -48,12 +48,14 @@ import { type MarketplaceInstallError, } from '../lib/marketplaceInstall'; import { pickStylePackZipTargetPath, stylePackZipFileName } from '../lib/stylePackZip'; +import { useMobileLayout } from '../lib/useMobileLayout'; import { Btn, Card, PageHeader, Pill } from './_atoms'; type SortMode = 'popular' | 'new' | 'liked'; export function Marketplace() { const { t } = useTranslation(); + const mobile = useMobileLayout(); const { prefs, updatePrefs } = useHotkeySettings(); // 启动时尝试读缓存:上次默认视图(popular + 空 query)的列表,秒呈现。后台 refresh 校准。 @@ -539,7 +541,8 @@ export function Marketplace() { style={{ display: 'flex', gap: 10, - alignItems: 'center', + alignItems: mobile ? 'stretch' : 'center', + flexDirection: mobile ? 'column' : 'row', padding: '4px 0 14px', }} > @@ -571,7 +574,7 @@ export function Marketplace() { }} />
-
+
{sortPills.map(p => ( )}
-
+
{([ ['dictation', t('style.pack.dictationTab')], @@ -670,7 +687,7 @@ export function Style() {
-
+
{bodyPacks.map(pack => { const isBuiltin = pack.kind === 'builtin'; @@ -680,15 +697,12 @@ export function Style() { return (
-
+
-
+
{draft.id}
-
+
@@ -1207,7 +1230,7 @@ export function Style() {
-
+
Date: Sat, 22 Aug 2026 16:19:19 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(ui):=20=E9=80=9A=E7=94=A8=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E6=96=B0=E5=A2=9E=E6=98=93=E8=AF=BB=E5=B8=83=E5=B1=80?= =?UTF-8?q?=EF=BC=8C=E5=A4=A7=E5=AD=97=E5=8F=B7=E5=B0=8F=E5=B1=8F=E9=98=B2?= =?UTF-8?q?=E6=BA=A2=E5=87=BA=E6=8D=A2=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 stackedRowLayout 偏好与 useLayoutStack;设置外观区可手动开启同行控件换行,覆盖老人机等极限场景。关联 #960。 Co-authored-by: Cursor --- .../components/AndroidPermissionsPanel.tsx | 22 +++++------ openless-all/app/src-tauri/src/types.rs | 8 ++++ .../app/src/components/FloatingShell.tsx | 7 ++++ openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/stackedLayout.ts | 21 +++++++++++ openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 2 + openless-all/app/src/lib/useMobileLayout.ts | 10 ++++- openless-all/app/src/pages/Marketplace.tsx | 18 +++++---- openless-all/app/src/pages/Style.tsx | 37 ++++++++++--------- openless-all/app/src/pages/_atoms.tsx | 20 +++++++--- .../src/pages/settings/ProvidersSection.tsx | 36 +++++++++--------- .../app/src/pages/settings/ThemeSection.tsx | 13 +++++++ .../app/src/pages/settings/shared.tsx | 17 +++++---- .../app/src/state/HotkeySettingsContext.tsx | 14 +++++++ openless-all/app/src/styles/global.css | 33 +++++++++++++++++ 21 files changed, 200 insertions(+), 70 deletions(-) create mode 100644 openless-all/app/src/lib/stackedLayout.ts diff --git a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx index 22cee5ab1..1687fba2a 100644 --- a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx +++ b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx @@ -6,7 +6,7 @@ import { getSettings, setSettings } from '../../../src/lib/ipc/settings'; import type { UserPreferences } from '../../../src/lib/types'; import { Btn, Pill } from '../../../src/pages/_atoms'; import { SettingRow } from '../../../src/pages/settings/shared'; -import { useMobileLayout } from '../../../src/lib/useMobileLayout'; +import { useLayoutStack } from '../../../src/lib/useMobileLayout'; import { getAndroidAccessibilityStatus, getAndroidOverlayStatus, @@ -74,7 +74,7 @@ interface AndroidPermissionsPanelProps { export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPanelProps) { const { t } = useTranslation(); - const mobile = useMobileLayout(); + const layoutStack = useLayoutStack(); const [androidOverlay, setAndroidOverlay] = useState(null); const [androidAccessibility, setAndroidAccessibility] = useState(null); const [androidShizuku, setAndroidShizuku] = useState(null); @@ -263,12 +263,12 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane const showAccessibility = mode === 'all' || mode === 'accessibility'; const showOverlayConfig = mode === 'all' || mode === 'overlayConfig'; - const rowJustify = mobile ? 'flex-start' : 'flex-end'; - const colAlign = mobile ? 'stretch' : 'flex-end'; - const hintAlign = mobile ? 'left' : 'right'; - const hintMaxWidth = mobile ? undefined : 300; - const hintMaxWidthSm = mobile ? undefined : 220; - const selectStyle = mobile + const rowJustify = layoutStack ? 'flex-start' : 'flex-end'; + const colAlign = layoutStack ? 'stretch' : 'flex-end'; + const hintAlign = layoutStack ? 'left' : 'right'; + const hintMaxWidth = layoutStack ? undefined : 300; + const hintMaxWidthSm = layoutStack ? undefined : 220; + const selectStyle = layoutStack ? { width: '100%', maxWidth: '100%' as const } : { minWidth: 180, maxWidth: '100%' as const }; @@ -276,7 +276,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane <> {showOverlayPermission && ( -
+
{androidOverlay?.message && ( {androidOverlay.message} @@ -294,7 +294,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane {showAccessibility && (
-
+
{resolveAccessibilityMessage(t, androidAccessibility?.messageKey) && ( {resolveAccessibilityMessage(t, androidAccessibility?.messageKey)} @@ -316,7 +316,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane {showAccessibility && (
-
+
{resolveShizukuMessage(t, shizukuDisplayMessageKey) && ( {resolveShizukuMessage(t, shizukuDisplayMessageKey)} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 65afce0c0..23a0ee233 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1169,6 +1169,9 @@ pub struct UserPreferences { /// 活动计数照常记录(persistence/activity.rs),再打开时全年数据仍在。 #[serde(default = "default_true")] pub show_overview_activity_heatmap: bool, + /// 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出与文字被压扁。默认 false。 + #[serde(default)] + pub stacked_row_layout: bool, /// 主窗口启动 + 后台每 60 分钟自动检查更新。默认 true。 /// Android 开启后自动检查并下载,校验后打开系统安装器;桌面仅自动检查 + 用户确认安装。 /// 关闭后仅 Settings 手动「检查更新」按钮可用。 @@ -1441,6 +1444,8 @@ struct UserPreferencesWire { cursor_context_enabled: bool, #[serde(default = "default_true")] show_overview_activity_heatmap: bool, + #[serde(default)] + stacked_row_layout: bool, #[serde(default = "default_true")] auto_update_check: bool, #[serde(default)] @@ -1591,6 +1596,7 @@ impl Default for UserPreferencesWire { streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard, cursor_context_enabled: prefs.cursor_context_enabled, show_overview_activity_heatmap: prefs.show_overview_activity_heatmap, + stacked_row_layout: prefs.stacked_row_layout, auto_update_check: prefs.auto_update_check, history_max_entries: prefs.history_max_entries, record_audio_for_debug: prefs.record_audio_for_debug, @@ -1747,6 +1753,7 @@ impl<'de> Deserialize<'de> for UserPreferences { streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard, cursor_context_enabled: wire.cursor_context_enabled, show_overview_activity_heatmap: wire.show_overview_activity_heatmap, + stacked_row_layout: wire.stacked_row_layout, auto_update_check: wire.auto_update_check, history_max_entries: wire.history_max_entries, record_audio_for_debug: wire.record_audio_for_debug, @@ -2563,6 +2570,7 @@ impl Default for UserPreferences { streaming_insert_save_clipboard: true, cursor_context_enabled: false, show_overview_activity_heatmap: true, + stacked_row_layout: false, auto_update_check: true, history_max_entries: None, record_audio_for_debug: false, diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 85fd4d9a5..2454d63fd 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -38,6 +38,8 @@ import { MobileMoreSheet } from './MobileMoreSheet'; import { MobileStyleSheet } from './MobileStyleSheet'; import { subItemLabelKey } from '../lib/navLabels'; import { useMobileLayout } from '../lib/useMobileLayout'; +import { applyStackedLayoutFromPrefs } from '../lib/stackedLayout'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { useAppState, type AppTab } from '../state/useAppState'; const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk']; @@ -92,6 +94,7 @@ export function FloatingShell({ os: osProp, initialTab = 'overview', initialSett function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initialTab: AppTab; initialSettings: boolean }) { const { t } = useTranslation(); const mobile = useMobileLayout(); + const { prefs } = useHotkeySettings(); const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings); const [settingsInitialSection, setSettingsInitialSection] = useState(); const [providerPromptOpen, setProviderPromptOpen] = useState(false); @@ -118,6 +121,10 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia applyFontScale(readFontScale()); }, []); + useEffect(() => { + applyStackedLayoutFromPrefs(mobile, prefs?.stackedRowLayout); + }, [mobile, prefs?.stackedRowLayout]); + const Page = PAGE_CMP[displayTab as Exclude] ?? Overview; // 分组展开态:默认展开「当前所在页所属的分组」。用户点分组标题手动切换。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index e534c7f8e..46d623648 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1335,6 +1335,8 @@ export const en: typeof zhCN = { title: 'Appearance', label: 'Theme', activityHeatmapLabel: 'Show annual activity heatmap on Overview', + stackedRowLayoutLabel: 'Readable layout (wrap rows)', + stackedRowLayoutDesc: 'On small screens or with large text, buttons and controls that no longer fit on one line move to the next line instead of overflowing or squashing text.', system: 'Follow system', light: 'Light', dark: 'Dark', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 60c24ebc4..05bd00020 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1303,6 +1303,8 @@ export const ja: typeof zhCN = { title: '外観', label: 'テーマ', activityHeatmapLabel: '概要ページに年間アクティビティを表示', + stackedRowLayoutLabel: '読みやすいレイアウト(はみ出し防止)', + stackedRowLayoutDesc: '小さい画面や大きな文字サイズでは、1行に収まらないボタンや設定が次の行に折り返され、横方向のはみ出しや文字の潰れを防ぎます。', system: 'システムに従う', light: 'ライト', dark: 'ダーク', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ab00aa16b..722be38d1 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1303,6 +1303,8 @@ export const ko: typeof zhCN = { title: '모양', label: '테마', activityHeatmapLabel: '개요 페이지에 연간 활동 표시', + stackedRowLayoutLabel: '읽기 쉬운 레이아웃(넘침 방지 줄바꿈)', + stackedRowLayoutDesc: '작은 화면이나 큰 글꼴에서 한 줄에 맞지 않는 버튼과 옵션은 다음 줄로 넘어가 가로 넘침과 글자 눌림을 방지합니다.', system: '시스템 따르기', light: '라이트', dark: '다크', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c94bb2236..05a184525 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1333,6 +1333,8 @@ export const zhCN = { title: '外观', label: '主题', activityHeatmapLabel: '概览页显示年度活动热力图', + stackedRowLayoutLabel: '易读布局(防溢出换行)', + stackedRowLayoutDesc: '小屏或大字时,同一行放不下的按钮和选项会自动换到下一行,避免横向挤出屏幕或文字被压扁。', system: '跟随系统', light: '浅色', dark: '深色', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index c039235ce..482ecfe1a 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1301,6 +1301,8 @@ export const zhTW: typeof zhCN = { title: '外觀', label: '主題', activityHeatmapLabel: '概覽頁顯示年度活動熱力圖', + stackedRowLayoutLabel: '易讀布局(防溢出換行)', + stackedRowLayoutDesc: '小螢幕或大字時,同一行放不下的按鈕和選項會自動換到下一行,避免橫向擠出螢幕或文字被壓扁。', system: '跟隨系統', light: '淺色', dark: '深色', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 462e6ec77..245ad95a8 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -110,6 +110,7 @@ export let mockSettings: UserPreferences = { streamingInsertSaveClipboard: true, cursorContextEnabled: false, showOverviewActivityHeatmap: true, + stackedRowLayout: false, autoUpdateCheck: true, historyMaxEntries: null, recordAudioForDebug: false, diff --git a/openless-all/app/src/lib/stackedLayout.ts b/openless-all/app/src/lib/stackedLayout.ts new file mode 100644 index 000000000..891e72652 --- /dev/null +++ b/openless-all/app/src/lib/stackedLayout.ts @@ -0,0 +1,21 @@ +// 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出。 +// 通过 html[data-ol-stacked-layout] + global.css 工具类生效。 + +export function applyStackedLayout(enabled: boolean): void { + if (enabled) { + document.documentElement.dataset.olStackedLayout = 'true'; + } else { + delete document.documentElement.dataset.olStackedLayout; + } +} + +export function applyStackedLayoutFromPrefs( + mobile: boolean, + stackedRowLayout?: boolean, +): void { + applyStackedLayout(mobile || stackedRowLayout === true); +} + +export function isStackedLayoutActive(): boolean { + return document.documentElement.dataset.olStackedLayout === 'true'; +} diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 6dcc18177..e168d491c 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -26,6 +26,7 @@ const previousPrefs: UserPreferences = { selectionPolishOutputMode: 'directReplace', cursorContextEnabled: false, showOverviewActivityHeatmap: true, + stackedRowLayout: false, defaultMode: 'light', enabledModes: ['raw', 'light', 'structured'], activeStylePackId: '', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 711937db1..d616be83e 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -490,6 +490,8 @@ export interface UserPreferences { cursorContextEnabled: boolean; /** 概览页是否显示「年度活动」热力图卡。默认 true;关闭只隐藏卡片,活动计数照常记录。 */ showOverviewActivityHeatmap: boolean; + /** 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出。默认 false。 */ + stackedRowLayout: boolean; /** 主窗口启动 + 后台每 60 分钟自动检查更新。默认 true。 * Android:开启后自动检查并下载,校验后打开系统安装器。 * 桌面:开启后自动检查,发现更新弹窗由用户确认安装。 diff --git a/openless-all/app/src/lib/useMobileLayout.ts b/openless-all/app/src/lib/useMobileLayout.ts index 77a00995c..930c44231 100644 --- a/openless-all/app/src/lib/useMobileLayout.ts +++ b/openless-all/app/src/lib/useMobileLayout.ts @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import { detectOS } from '../components/WindowChrome'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; -function shouldUseMobileLayout(breakpoint: number): boolean { +export function shouldUseMobileLayout(breakpoint = 720): boolean { if (typeof window === 'undefined') return false; const osQuery = new URLSearchParams(window.location.search).get('os'); return osQuery === 'android' || detectOS() === 'android' || window.innerWidth < breakpoint; @@ -23,3 +24,10 @@ export function useMobileLayout(breakpoint = 720): boolean { return mobile; } + +/** 排版堆叠:移动端壳层 或 用户开启「易读布局」。用于 grid/flex 换行,不含底栏等壳层逻辑。 */ +export function useLayoutStack(breakpoint = 720): boolean { + const mobile = useMobileLayout(breakpoint); + const { prefs } = useHotkeySettings(); + return mobile || prefs?.stackedRowLayout === true; +} diff --git a/openless-all/app/src/pages/Marketplace.tsx b/openless-all/app/src/pages/Marketplace.tsx index 1762d631a..f0d89259a 100644 --- a/openless-all/app/src/pages/Marketplace.tsx +++ b/openless-all/app/src/pages/Marketplace.tsx @@ -48,7 +48,7 @@ import { type MarketplaceInstallError, } from '../lib/marketplaceInstall'; import { pickStylePackZipTargetPath, stylePackZipFileName } from '../lib/stylePackZip'; -import { useMobileLayout } from '../lib/useMobileLayout'; +import { useMobileLayout, useLayoutStack } from '../lib/useMobileLayout'; import { Btn, Card, PageHeader, Pill } from './_atoms'; type SortMode = 'popular' | 'new' | 'liked'; @@ -56,6 +56,7 @@ type SortMode = 'popular' | 'new' | 'liked'; export function Marketplace() { const { t } = useTranslation(); const mobile = useMobileLayout(); + const layoutStack = useLayoutStack(); const { prefs, updatePrefs } = useHotkeySettings(); // 启动时尝试读缓存:上次默认视图(popular + 空 query)的列表,秒呈现。后台 refresh 校准。 @@ -538,11 +539,12 @@ export function Marketplace() { {/* 顶部搜索 + 排序 */}
@@ -574,7 +576,7 @@ export function Marketplace() { }} />
-
+
{sortPills.map(p => ( )}
-
+
{([ ['dictation', t('style.pack.dictationTab')], @@ -687,7 +688,7 @@ export function Style() {
-
+
{bodyPacks.map(pack => { const isBuiltin = pack.kind === 'builtin'; @@ -697,12 +698,12 @@ export function Style() { return (
-
+
-
+
{draft.id}
-
+
@@ -1230,7 +1231,7 @@ export function Style() {
-
+
{kicker && ( @@ -33,9 +34,16 @@ export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeade

{title}

{titleRight}
- {desc &&

{desc}

} + {desc &&

{desc}

}
- {right} + {right && ( +
+ {right} +
+ )}
); } diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 05de07c7b..f61b5ff9f 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -14,7 +14,7 @@ import { validateProviderCredentials, } from '../../lib/ipc'; import { emitSaved } from '../../lib/savedEvent'; -import { useMobileLayout } from '../../lib/useMobileLayout'; +import { useLayoutStack } from '../../lib/useMobileLayout'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { SelectLite, type SelectOption } from '../../components/ui/SelectLite'; import { Card } from '../_atoms'; @@ -55,18 +55,18 @@ function isLocalAsrPreset(id: string): boolean { function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) { const { t } = useTranslation(); - const mobile = useMobileLayout(); + const layoutStack = useLayoutStack(); return (
@@ -304,7 +304,7 @@ export function ChannelCredentialFields({ }) { const { t } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); - const mobile = useMobileLayout(); + const layoutStack = useLayoutStack(); const [llmModelRevision, setLlmModelRevision] = useState(0); const [asrModelRevision, setAsrModelRevision] = useState(0); const unifiedBailian = providerType === 'bailian'; @@ -421,7 +421,7 @@ export function ChannelCredentialFields({ { value: 'api_key', label: t('settings.providers.volcengineAuthModeApiKey') }, ]} ariaLabel={t('settings.providers.volcengineAuthModeLabel')} - style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 260 }} + style={{ ...inputStyle, width: '100%', maxWidth: layoutStack ? '100%' : 260 }} /> {/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key) @@ -739,7 +739,7 @@ type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true }: { kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) { const { t } = useTranslation(); - const mobile = useMobileLayout(); + const layoutStack = useLayoutStack(); const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(''); const [status, setStatus] = useState('idle'); @@ -827,7 +827,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested return ( -
+
{showFetchModels && ( @@ -841,7 +841,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested options={models.map(model => ({ value: model, label: model }))} placeholder={t('settings.providers.selectModel')} ariaLabel={t('settings.providers.selectModel')} - style={{ flex: mobile ? '1 1 100%' : '1 1 180px', maxWidth: mobile ? '100%' : 220, minWidth: 0 }} + style={{ flex: layoutStack ? '1 1 100%' : '1 1 180px', maxWidth: layoutStack ? '100%' : 220, minWidth: 0 }} /> )}
@@ -904,7 +904,7 @@ interface CredentialFieldProps { function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options }: CredentialFieldProps) { const { t } = useTranslation(); - const mobile = useMobileLayout(); + const layoutStack = useLayoutStack(); const [value, setValue] = useState(''); const [revealed, setRevealed] = useState(false); const [loaded, setLoaded] = useState(false); @@ -1042,8 +1042,8 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de return ( -
-
+
+
{options && !customModelMode ? ( ) : ( )} {options && customModelMode && ( @@ -1169,7 +1169,7 @@ const iconBtnStyle: CSSProperties = { */ export function OmniChannelSection() { const { t } = useTranslation(); - const mobile = useMobileLayout(); + const layoutStack = useLayoutStack(); const { prefs, updatePrefs } = useHotkeySettings(); const [omniProvider, setOmniProvider] = useState('custom'); const [committedOmniProvider, setCommittedOmniProvider] = useState('custom'); @@ -1246,7 +1246,7 @@ export function OmniChannelSection() { label={t('settings.providers.pipelineModeLabel')} desc={t('settings.providers.pipelineModeHint')} > -
+
{(['traditional', 'multimodal'] as const).map(mode => ( )}
-
+
{([ ['dictation', t('style.pack.dictationTab')], @@ -688,7 +690,7 @@ export function Style() {
-
+
{bodyPacks.map(pack => { const isBuiltin = pack.kind === 'builtin'; @@ -698,12 +700,12 @@ export function Style() { return (
-
+
-
+
{draft.id}
-
+
@@ -1231,7 +1233,7 @@ export function Style() {
-
+
@@ -34,12 +36,12 @@ export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeade

{title}

{titleRight}
- {desc &&

{desc}

} + {desc &&

{desc}

}
{right && (
{right}
diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index f61b5ff9f..a315b7662 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -14,7 +14,7 @@ import { validateProviderCredentials, } from '../../lib/ipc'; import { emitSaved } from '../../lib/savedEvent'; -import { useLayoutStack } from '../../lib/useMobileLayout'; +import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { SelectLite, type SelectOption } from '../../components/ui/SelectLite'; import { Card } from '../_atoms'; @@ -55,7 +55,9 @@ function isLocalAsrPreset(id: string): boolean { function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) { const { t } = useTranslation(); - const layoutStack = useLayoutStack(); + const baseLayoutStack = useLayoutStack(); + const conservative = useConservativeLayout(); + const layoutStack = conservative || baseLayoutStack; return (
void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) { const { t } = useTranslation(); - const layoutStack = useLayoutStack(); + const baseLayoutStack = useLayoutStack(); + const conservative = useConservativeLayout(); + const layoutStack = conservative || baseLayoutStack; const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(''); const [status, setStatus] = useState('idle'); @@ -904,7 +910,9 @@ interface CredentialFieldProps { function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options }: CredentialFieldProps) { const { t } = useTranslation(); - const layoutStack = useLayoutStack(); + const baseLayoutStack = useLayoutStack(); + const conservative = useConservativeLayout(); + const layoutStack = conservative || baseLayoutStack; const [value, setValue] = useState(''); const [revealed, setRevealed] = useState(false); const [loaded, setLoaded] = useState(false); @@ -1169,7 +1177,9 @@ const iconBtnStyle: CSSProperties = { */ export function OmniChannelSection() { const { t } = useTranslation(); - const layoutStack = useLayoutStack(); + const baseLayoutStack = useLayoutStack(); + const conservative = useConservativeLayout(); + const layoutStack = conservative || baseLayoutStack; const { prefs, updatePrefs } = useHotkeySettings(); const [omniProvider, setOmniProvider] = useState('custom'); const [committedOmniProvider, setCommittedOmniProvider] = useState('custom'); diff --git a/openless-all/app/src/pages/settings/ThemeSection.tsx b/openless-all/app/src/pages/settings/ThemeSection.tsx index afd443be9..73a782e78 100644 --- a/openless-all/app/src/pages/settings/ThemeSection.tsx +++ b/openless-all/app/src/pages/settings/ThemeSection.tsx @@ -66,6 +66,19 @@ export function ThemeSection() { /> )} + {prefs && ( + + + void updatePrefs(current => ({ ...current, conservativeLayout: next })) + } + /> + + )} {/* 概览页年度活动热力图开关:关闭只隐藏卡片,活动计数照常记录。 */} {prefs && ( diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index 8265e3047..874d12b32 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -3,7 +3,7 @@ import type { CSSProperties, ReactNode } from "react" import { Tooltip } from "../../components/Tooltip" -import { useLayoutStack } from "../../lib/useMobileLayout" +import { useLayoutStack, useConservativeLayout } from "../../lib/useMobileLayout" // 带说明的文字统一加虚线下划线 + help 光标,暗示「悬停可看解释」。 const hintableTextStyle: CSSProperties = { @@ -68,6 +68,8 @@ export function SettingRow({ controlWidth, }: SettingRowProps) { const layoutStack = useLayoutStack() + const conservative = useConservativeLayout() + const stackLayout = conservative || layoutStack const labelStyle: CSSProperties = { fontSize: 13, fontWeight: 500, @@ -78,9 +80,9 @@ export function SettingRow({
{children} diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx index 94f215f5f..47b48e207 100644 --- a/openless-all/app/src/state/HotkeySettingsContext.tsx +++ b/openless-all/app/src/state/HotkeySettingsContext.tsx @@ -22,6 +22,7 @@ import type { import i18n, { outputPrefsForLocale, type SupportedLocale } from "../i18n" import { applyThemeFromPreference } from "../lib/themeMode" import { applyStackedLayoutFromPrefs } from "../lib/stackedLayout" +import { applyConservativeLayout } from "../lib/conservativeLayout" import { shouldUseMobileLayout } from "../lib/useMobileLayout" import { emitSaved } from "../lib/savedEvent" @@ -71,6 +72,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { shouldUseMobileLayout(), prefsResult.value.stackedRowLayout, ) + applyConservativeLayout(prefsResult.value.conservativeLayout === true) } else { console.error( "[hotkey-settings] failed to load preferences", @@ -136,6 +138,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { shouldUseMobileLayout(), nextPrefs.stackedRowLayout, ) + applyConservativeLayout(nextPrefs.conservativeLayout === true) }, ) if (cancelled) { @@ -214,6 +217,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { shouldUseMobileLayout(), resolved.stackedRowLayout, ) + applyConservativeLayout(resolved.conservativeLayout === true) try { await queueSetSettings(resolved) persistedPrefsRef.current = resolved diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 5e26c8b93..3be5477d3 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -243,6 +243,43 @@ html[data-ol-stacked-layout="true"] .ol-console-main { overflow-x: hidden; } +html[data-ol-stacked-layout="true"]:not([data-ol-conservative-layout="true"]) .ol-flex-row > * { + flex: 1 1 auto; + min-width: min(100%, 10rem); + max-width: 100%; +} + +/* ── 保守排版(data-ol-conservative-layout)────────────────────────────────────── + 除首页、顶栏、底栏与胶囊窗外,内容区强制单列满宽。 */ +html[data-ol-conservative-layout="true"] .ol-conservative-scope { + overflow-x: hidden; +} + +html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-flex-row { + flex-direction: column; + align-items: stretch; + width: 100%; +} + +html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-flex-row > * { + width: 100%; + max-width: 100%; + min-width: 0; + flex: 1 1 auto; +} + +html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-grid-auto-cards, +html[data-ol-conservative-layout="true"] .ol-conservative-scope [class*="ol-grid"] { + grid-template-columns: 1fr !important; +} + +html[data-ol-conservative-layout="true"] .ol-conservative-scope select, +html[data-ol-conservative-layout="true"] .ol-conservative-scope input:not([type="checkbox"]):not([type="radio"]), +html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-select-lite-trigger { + width: 100%; + max-width: 100%; +} + .ol-app-shell-bg { background: var(--ol-app-shell-bg); } From 3d7ec8ec6599e1191c2d855fe4c25953cc5eb280 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sat, 22 Aug 2026 17:54:58 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(android):=20can=5Fdraw=5Foverlays=20JNI?= =?UTF-8?q?=20=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E4=BB=A5=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20Android=20CI=20=E7=BC=96=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 env 与 context 绑定同一 lifetime,满足 call_static_bool_with_context_class 的借用约束。 Co-authored-by: Cursor --- openless-all/app/src-tauri/src/android/jni.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index beaae9a40..474e06858 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -446,7 +446,10 @@ pub mod android { Ok(()) } - pub fn can_draw_overlays(env: &mut JNIEnv, context: &JObject) -> Result { + pub fn can_draw_overlays<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + ) -> Result { if android_sdk_int(env)? < 23 { return Ok(true); } From 9fa9684ebeddb1809a0be8a4ad5f92322022bfb1 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sat, 22 Aug 2026 18:24:26 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(android):=20=E6=81=A2=E5=A4=8D=20Access?= =?UTF-8?q?ibilityService=20=E5=AF=B9=20Settings.Secure=20=E7=9A=84=20impo?= =?UTF-8?q?rt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isEnabled 仍使用 Settings.Secure 查询无障碍状态,与 canDrawOverlays 移除的 import 无关。 Co-authored-by: Cursor --- openless-all/app/android/kotlin/OpenLessAccessibilityService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt index 9b1ba1fdc..474a61fe3 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt @@ -9,6 +9,7 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.ResultReceiver +import android.provider.Settings import android.util.Log import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo From 17c050e3adcaa147d849cf55ca5f73a99e9e3c0d Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sat, 22 Aug 2026 19:02:14 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(ui):=20=E5=B0=86=E6=98=93=E8=AF=BB/?= =?UTF-8?q?=E4=BF=9D=E5=AE=88=E6=8E=92=E7=89=88=E5=BC=80=E5=85=B3=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E4=B8=BA=E3=80=8C=E5=B8=83=E5=B1=80=E3=80=8D=E5=8C=BA?= =?UTF-8?q?=E5=9D=97=E5=B9=B6=E7=BD=AE=E9=A1=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原开关藏在「外观」卡片深处不易发现;现于设置→通用页录音输入下方展示独立「布局」卡片。 Co-authored-by: Cursor --- openless-all/app/src/i18n/en.ts | 3 ++ openless-all/app/src/i18n/ja.ts | 3 ++ openless-all/app/src/i18n/ko.ts | 3 ++ openless-all/app/src/i18n/zh-CN.ts | 3 ++ openless-all/app/src/i18n/zh-TW.ts | 3 ++ .../app/src/pages/settings/LayoutSection.tsx | 41 +++++++++++++++++++ .../app/src/pages/settings/ThemeSection.tsx | 26 ------------ openless-all/app/src/pages/settings/tabs.tsx | 2 + 8 files changed, 58 insertions(+), 26 deletions(-) create mode 100644 openless-all/app/src/pages/settings/LayoutSection.tsx diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 59a05c525..8dc0cb4d1 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1331,6 +1331,9 @@ export const en: typeof zhCN = { ko: '한국어 (Beta)', restartHint: 'Some native menus (system tray, etc.) may require an app restart to fully switch.', }, + layout: { + title: 'Layout', + }, theme: { title: 'Appearance', label: 'Theme', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b408aa570..dc634d5d4 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1299,6 +1299,9 @@ export const ja: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '一部のネイティブメニュー(トレイ等)は再起動後に反映されます。', }, + layout: { + title: 'レイアウト', + }, theme: { title: '外観', label: 'テーマ', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 2029a3a1d..e95540ac6 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1299,6 +1299,9 @@ export const ko: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '일부 네이티브 메뉴(트레이 등)는 앱 재시작 후 반영될 수 있습니다.', }, + layout: { + title: '레이아웃', + }, theme: { title: '모양', label: '테마', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 6bc5c93bd..47e9b73e6 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1329,6 +1329,9 @@ export const zhCN = { ko: '한국어 (Beta)', restartHint: '部分原生菜单(系统托盘等)可能需要重启 App 才会切换。', }, + layout: { + title: '布局', + }, theme: { title: '外观', label: '主题', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index ebaa4c0e6..9e752e5ce 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1297,6 +1297,9 @@ export const zhTW: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '部分原生菜單(系統托盤等)可能需要重啓 App 纔會切換。', }, + layout: { + title: '布局', + }, theme: { title: '外觀', label: '主題', diff --git a/openless-all/app/src/pages/settings/LayoutSection.tsx b/openless-all/app/src/pages/settings/LayoutSection.tsx new file mode 100644 index 000000000..5f16a40af --- /dev/null +++ b/openless-all/app/src/pages/settings/LayoutSection.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Card } from '../_atoms'; +import { SettingRow, Toggle } from './shared'; + +export function LayoutSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + + if (!prefs) return null; + + return ( + +
+ {t('settings.layout.title')} +
+ + + void updatePrefs(current => ({ ...current, stackedRowLayout: next })) + } + /> + + + + void updatePrefs(current => ({ ...current, conservativeLayout: next })) + } + /> + +
+ ); +} diff --git a/openless-all/app/src/pages/settings/ThemeSection.tsx b/openless-all/app/src/pages/settings/ThemeSection.tsx index 73a782e78..606a852e2 100644 --- a/openless-all/app/src/pages/settings/ThemeSection.tsx +++ b/openless-all/app/src/pages/settings/ThemeSection.tsx @@ -53,32 +53,6 @@ export function ThemeSection() { style={{ maxWidth: 220, minWidth: 200 }} /> - {prefs && ( - - - void updatePrefs(current => ({ ...current, stackedRowLayout: next })) - } - /> - - )} - {prefs && ( - - - void updatePrefs(current => ({ ...current, conservativeLayout: next })) - } - /> - - )} {/* 概览页年度活动热力图开关:关闭只隐藏卡片,活动计数照常记录。 */} {prefs && ( diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 08e93fa38..654573dd2 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -9,6 +9,7 @@ import { ShortcutsSection } from './ShortcutsSection'; import { SelectionPolishSection } from './SelectionPolishSection'; import { LanguageSection } from './LanguageSection'; import { ThemeSection } from './ThemeSection'; +import { LayoutSection } from './LayoutSection'; import { ProvidersSection } from './ChannelList'; import { NetworkSection } from './NetworkSection'; import { MarketplaceSection } from './MarketplaceSection'; @@ -46,6 +47,7 @@ export function GeneralTab() { return ( <> + {showRemoteInput && } {showDesktopShortcuts && } From c1d8c0b1b723b3a4ea2d8d8b6f8f3861de665ca7 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sat, 22 Aug 2026 20:36:31 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(ui):=20=E7=BB=9F=E4=B8=80=E6=98=93?= =?UTF-8?q?=E8=AF=BB=E4=B8=8E=E4=BF=9D=E5=AE=88=E6=8E=92=E7=89=88=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/AndroidPermissionsPanel.tsx | 19 +- .../app/scripts/layout-extreme-h5.mjs | 622 ++++++++++++++++++ .../app/scripts/layout-mode-contract.test.mjs | 56 ++ .../app/src/components/FloatingShell.tsx | 4 +- .../app/src/components/MobileMoreSheet.tsx | 20 +- .../app/src/components/MobileStyleSheet.tsx | 17 +- openless-all/app/src/components/ui/Row.tsx | 28 +- .../app/src/lib/stackedLayout.test.ts | 38 ++ openless-all/app/src/lib/stackedLayout.ts | 3 +- openless-all/app/src/lib/useMobileLayout.ts | 10 +- openless-all/app/src/pages/_atoms.tsx | 24 +- .../app/src/pages/settings/AboutSection.tsx | 37 +- .../app/src/pages/settings/ChannelList.tsx | 25 +- .../src/pages/settings/CheckUpdateButton.tsx | 28 +- .../src/pages/settings/PermissionsSection.tsx | 24 +- .../app/src/pages/settings/shared.tsx | 13 +- .../app/src/state/HotkeySettingsContext.tsx | 16 +- openless-all/app/src/styles/global.css | 22 +- 18 files changed, 927 insertions(+), 79 deletions(-) create mode 100644 openless-all/app/scripts/layout-extreme-h5.mjs create mode 100644 openless-all/app/scripts/layout-mode-contract.test.mjs create mode 100644 openless-all/app/src/lib/stackedLayout.test.ts diff --git a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx index 3cf19a579..466b68731 100644 --- a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx +++ b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { Icon } from '../../../src/components/Icon'; @@ -76,7 +76,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane const { t } = useTranslation(); const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); - const layoutStack = conservative || baseLayoutStack; + const layoutStack = baseLayoutStack || conservative; const [androidOverlay, setAndroidOverlay] = useState(null); const [androidAccessibility, setAndroidAccessibility] = useState(null); const [androidShizuku, setAndroidShizuku] = useState(null); @@ -273,12 +273,21 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane const selectStyle = layoutStack ? { width: '100%', maxWidth: '100%' as const } : { minWidth: 180, maxWidth: '100%' as const }; + const actionRowStyle: CSSProperties = { + display: 'flex', + flexDirection: conservative ? 'column' : 'row', + gap: 8, + alignItems: conservative ? 'flex-start' : layoutStack ? 'flex-start' : 'center', + justifyContent: rowJustify, + width: '100%', flexWrap: conservative ? 'nowrap' : 'wrap', minWidth: 0, + }; + return ( <> {showOverlayPermission && ( -
+
{androidOverlay?.message && ( {androidOverlay.message} @@ -296,7 +305,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane {showAccessibility && (
-
+
{resolveAccessibilityMessage(t, androidAccessibility?.messageKey) && ( {resolveAccessibilityMessage(t, androidAccessibility?.messageKey)} @@ -318,7 +327,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane {showAccessibility && (
-
+
{resolveShizukuMessage(t, shizukuDisplayMessageKey) && ( {resolveShizukuMessage(t, shizukuDisplayMessageKey)} diff --git a/openless-all/app/scripts/layout-extreme-h5.mjs b/openless-all/app/scripts/layout-extreme-h5.mjs new file mode 100644 index 000000000..d4002a6aa --- /dev/null +++ b/openless-all/app/scripts/layout-extreme-h5.mjs @@ -0,0 +1,622 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const APP_URL = process.env.OPENLESS_H5_URL || 'http://127.0.0.1:1420/'; +const CHROME_PATH = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; +const OUTPUT_DIR = process.env.OPENLESS_LAYOUT_ARTIFACT_DIR || join(tmpdir(), 'openless-layout-h5'); +const VIEWPORTS = [ + { width: 360, height: 640 }, + { width: 320, height: 568 }, +]; +const ZOOMS = [1.1, 2]; + +mkdirSync(OUTPUT_DIR, { recursive: true }); + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +class CdpClient { + constructor(socket) { + this.socket = socket; + this.nextId = 1; + this.pending = new Map(); + socket.addEventListener('message', event => { + const message = JSON.parse(String(event.data)); + if (!message.id) return; + const waiter = this.pending.get(message.id); + if (!waiter) return; + this.pending.delete(message.id); + if (message.error) waiter.reject(new Error(JSON.stringify(message.error))); + else waiter.resolve(message.result); + }); + } + + static async connect(url) { + const socket = new WebSocket(url); + await new Promise((resolve, reject) => { + socket.addEventListener('open', resolve, { once: true }); + socket.addEventListener('error', reject, { once: true }); + }); + return new CdpClient(socket); + } + + send(method, params = {}) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + close() { + this.socket.close(); + } +} + +async function waitForPageTarget(port) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + try { + const response = await fetch('http://127.0.0.1:' + port + '/json/list'); + const targets = await response.json(); + const target = targets.find(item => item.type === 'page' && item.url.startsWith(APP_URL)); + if (target?.webSocketDebuggerUrl) return target; + } catch { + // Chrome is still starting. + } + await sleep(100); + } + throw new Error('Chrome DevTools target did not become ready'); +} + +async function launchChrome() { + const port = 9300 + Math.floor(Math.random() * 400); + const profile = join(tmpdir(), 'openless-layout-cdp-' + Date.now()); + const child = spawn(CHROME_PATH, [ + '--headless=new', + '--disable-gpu', + '--no-first-run', + '--no-default-browser-check', + '--remote-debugging-port=' + port, + '--user-data-dir=' + profile, + APP_URL, + ], { stdio: 'ignore' }); + const target = await waitForPageTarget(port); + return { child, client: await CdpClient.connect(target.webSocketDebuggerUrl) }; +} + +async function evaluate(client, expression) { + const response = await client.send('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + }); + if (response.exceptionDetails) { + throw new Error( + response.exceptionDetails.exception?.description || response.exceptionDetails.text || 'Runtime.evaluate failed', + ); + } + return response.result.value; +} + +async function evaluateFn(client, fn, ...args) { + const expression = '(' + fn.toString() + ')(' + args.map(value => JSON.stringify(value)).join(',') + ')'; + return evaluate(client, expression); +} + +async function waitForFn(client, fn, args = [], timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await evaluateFn(client, fn, ...args)) return; + await sleep(100); + } + const diagnostics = await evaluateFn(client, function collectDiagnostics() { + const visible = element => element.getBoundingClientRect().width > 0; + return { + buttons: Array.from(document.querySelectorAll('button')).filter(visible) + .map(button => button.innerText.trim()).filter(Boolean).slice(0, 40), + leaves: Array.from(document.querySelectorAll('div,span')).filter(element => ( + visible(element) && element.children.length === 0 && element.textContent.trim() + )).map(element => element.textContent.trim()).slice(0, 80), + layoutTexts: document.body.innerText.split('\n') + .map(text => text.trim()).filter(text => /布局|排版|易读/.test(text)), + }; + }); + throw new Error( + 'Timed out waiting for browser state: ' + fn.name + ' diagnostics=' + JSON.stringify(diagnostics), + ); +} + +function hasExactButton(text) { + return Array.from(document.querySelectorAll('button')).some(button => ( + button.getBoundingClientRect().width > 0 && button.innerText.trim() === text + )); +} + +function clickExactButton(text) { + const button = Array.from(document.querySelectorAll('button')).find(candidate => ( + candidate.getBoundingClientRect().width > 0 && candidate.innerText.trim() === text + )); + if (!button) { + return { + ok: false, + buttons: Array.from(document.querySelectorAll('button')) + .filter(candidate => candidate.getBoundingClientRect().width > 0) + .map(candidate => candidate.innerText.trim()) + .filter(Boolean), + }; + } + button.click(); + return { ok: true }; +} + +async function clickButton(client, text) { + const result = await evaluateFn(client, clickExactButton, text); + assert.equal(result.ok, true, '找不到按钮 ' + text + ',当前按钮:' + JSON.stringify(result.buttons)); + await sleep(180); +} + +function clickCompositeRow(text) { + const button = Array.from(document.querySelectorAll('button.ol-nav-btn')).find(candidate => ( + candidate.children.length === 3 && + candidate.getBoundingClientRect().width > 0 && + candidate.innerText.trim() === text + )); + if (!button) return false; + button.click(); + return true; +} + +function hasSettingLabel(label) { + return document.body.innerText.includes(label); +} + +async function openSettings(client) { + await clickButton(client, '更多'); + await waitForFn(client, hasExactButton, ['设置']); + await clickButton(client, '设置'); + await waitForFn(client, hasExactButton, ['通用']); + await clickButton(client, '通用'); + await waitForFn(client, hasSettingLabel, ['易读布局(防溢出换行)']); +} + +function closeCurrentOverlay() { + const buttons = Array.from(document.querySelectorAll('button[aria-label="关闭"]')) + .filter(button => button.getBoundingClientRect().width > 0); + if (!buttons.length) return false; + buttons[0].click(); + return true; +} + +async function closeOverlay(client) { + assert.equal(await evaluateFn(client, closeCurrentOverlay), true, '找不到可见的关闭按钮'); + await sleep(220); +} + +function clickSettingToggle(label) { + const labelNode = Array.from(document.querySelectorAll('div,span')) + .find(node => node.textContent.trim() === label); + let row = labelNode; + while (row && !row.querySelector('button')) row = row.parentElement; + const button = row?.querySelector('button'); + if (!button) return false; + button.click(); + return true; +} + +function rootPreferenceState() { + return { + readable: document.documentElement.dataset.olStackedLayout === 'true', + conservative: document.documentElement.dataset.olConservativeLayout === 'true', + }; +} + +async function setPreference(client, label, key, desired) { + const state = await evaluateFn(client, rootPreferenceState); + if (state[key] !== desired) { + assert.equal(await evaluateFn(client, clickSettingToggle, label), true, '找不到布局开关:' + label); + } + await waitForFn(client, function waitPreference(prefKey, expected) { + const root = document.documentElement.dataset; + const actual = prefKey === 'readable' + ? root.olStackedLayout === 'true' + : root.olConservativeLayout === 'true'; + return actual === expected; + }, [key, desired]); + await sleep(180); +} + +async function setPreferences(client, readable, conservative) { + await clickButton(client, '通用'); + await waitForFn(client, hasSettingLabel, ['易读布局(防溢出换行)']); + await setPreference(client, '易读布局(防溢出换行)', 'readable', readable); + await setPreference(client, '保守排版', 'conservative', conservative); +} + +function inspectGeneralLayout(zoom) { + const inspectToggle = label => { + const labelNode = Array.from(document.querySelectorAll('div,span')) + .find(node => node.textContent.trim() === label); + let row = labelNode; + while (row && !row.querySelector('button')) row = row.parentElement; + const button = row?.querySelector('button'); + const control = button?.parentElement; + if (!row || !button || !control) return null; + const buttonRect = button.getBoundingClientRect(); + const rowRect = row.getBoundingClientRect(); + return { + normalizedWidth: Number((buttonRect.width / zoom).toFixed(2)), + buttonLeft: Number(buttonRect.left.toFixed(2)), + rowLeft: Number(rowRect.left.toFixed(2)), + rowRight: Number(rowRect.right.toFixed(2)), + justifyContent: getComputedStyle(control).justifyContent, + }; + }; + + const settingsRoot = document.querySelector('.ol-settings-surface') || document.body; + const isScrollContained = element => { + for (let parent = element.parentElement; parent && parent !== settingsRoot; parent = parent.parentElement) { + const overflowX = getComputedStyle(parent).overflowX; + if (overflowX === 'auto' || overflowX === 'scroll') return true; + } + return false; + }; + const outliers = Array.from(settingsRoot.querySelectorAll('button,input,select,textarea,[role="button"],.ol-inline-composite')) + .filter(element => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && (rect.left < -1 || rect.right > innerWidth + 1); + }) + .filter(element => !isScrollContained(element)) + .slice(0, 12) + .map(element => ({ + tag: element.tagName, + text: element.textContent.trim().slice(0, 30), + rect: Array.from([ + element.getBoundingClientRect().left, + element.getBoundingClientRect().right, + innerWidth, + ]).map(value => Number(value.toFixed(1))), + })); + + return { + root: { + readable: document.documentElement.dataset.olStackedLayout === 'true', + conservative: document.documentElement.dataset.olConservativeLayout === 'true', + }, + readableToggle: inspectToggle('易读布局(防溢出换行)'), + conservativeToggle: inspectToggle('保守排版'), + outliers, + }; +} + +function inspectServiceActions() { + const edit = Array.from(document.querySelectorAll('button')).find(button => ( + button.getBoundingClientRect().width > 0 && + (button.getAttribute('aria-label') || '').startsWith('编辑') + )); + const group = edit?.parentElement; + if (!group) return null; + const style = getComputedStyle(group); + const childTops = Array.from(group.children) + .filter(child => child.getBoundingClientRect().height > 0) + .map(child => Math.round(child.getBoundingClientRect().top)); + return { + className: group.className, + flexDirection: style.flexDirection, + alignItems: style.alignItems, + childCount: childTops.length, + uniqueRows: new Set(childTops).size, + }; +} + +function inspectPermissionActions() { + const surface = document.querySelector('.ol-settings-surface'); + const labelNode = Array.from(surface?.querySelectorAll('div,span') || []) + .find(node => node.textContent.trim() === '麦克风'); + let row = labelNode; + while (row && getComputedStyle(row).display !== 'grid') row = row.parentElement; + const group = row?.children[1]?.firstElementChild; + if (!group) return null; + const style = getComputedStyle(group); + return { + flexDirection: style.flexDirection, + alignItems: style.alignItems, + justifyContent: style.justifyContent, + }; +} + +function inspectAboutComposite() { + const row = document.querySelector('.ol-inline-composite'); + if (!row) return null; + const rowRect = row.getBoundingClientRect(); + const children = Array.from(row.children).map(child => { + const rect = child.getBoundingClientRect(); + return { + left: Number(rect.left.toFixed(1)), + right: Number(rect.right.toFixed(1)), + top: Number(rect.top.toFixed(1)), + bottom: Number(rect.bottom.toFixed(1)), + }; + }); + const style = getComputedStyle(row); + return { + flexDirection: style.flexDirection, + row: [rowRect.left, rowRect.right, rowRect.top, rowRect.bottom].map(value => Number(value.toFixed(1))), + flexWrap: style.flexWrap, + childCount: children.length, + withinRow: children.every(rect => rect.left >= rowRect.left - 1 && rect.right <= rowRect.right + 1), + children, + }; +} + +function inspectCompositeSheet(expectedRows) { + const rows = Array.from(document.querySelectorAll('button.ol-nav-btn')).filter(button => ( + button.children.length === 3 && + button.getBoundingClientRect().width > 0 && + getComputedStyle(button).flexDirection === 'row' + )); + return { + count: rows.length, + expectedRows, + valid: rows.every(row => { + const rect = row.getBoundingClientRect(); + const children = Array.from(row.children).map(child => child.getBoundingClientRect()); + return rect.left >= -1 && + rect.right <= innerWidth + 1 && + children.every(child => child.top < rect.bottom && child.bottom > rect.top); + }), + rows: rows.map(row => { + const rect = row.getBoundingClientRect(); + const parentRect = row.parentElement.getBoundingClientRect(); + const style = getComputedStyle(row); + return { + text: row.innerText.trim(), + rect: [rect.left, rect.right, rect.top, rect.bottom, innerWidth].map(value => Number(value.toFixed(1))), + parentRect: [parentRect.left, parentRect.right, parentRect.width].map(value => Number(value.toFixed(1))), + computed: { boxSizing: style.boxSizing, width: style.width, padding: style.padding }, + children: Array.from(row.children).map(child => { + const childRect = child.getBoundingClientRect(); + return [childRect.left, childRect.right, childRect.top, childRect.bottom].map(value => Number(value.toFixed(1))); + }), + }; + }), + }; +} + +function inspectPageOutliers() { + const root = document.querySelector('main'); + if (!root) return [{ tag: 'MISSING_MAIN' }]; + const isScrollContained = element => { + for (let parent = element.parentElement; parent && parent !== root; parent = parent.parentElement) { + const overflowX = getComputedStyle(parent).overflowX; + if (overflowX === 'auto' || overflowX === 'scroll') return true; + } + return false; + }; + return Array.from(root.querySelectorAll('h1,p,button,input,select,textarea')) + .filter(element => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && (rect.left < -1 || rect.right > innerWidth + 1); + }) + .filter(element => !isScrollContained(element)) + .slice(0, 12) + .map(element => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName, + text: element.textContent.trim().slice(0, 30), + rect: [ + Number(rect.left.toFixed(1)), + Number(rect.right.toFixed(1)), + innerWidth, + ], + }; + }); +} +function pageGridFingerprint() { + const grid = document.querySelector('.ol-grid-auto-cards'); + if (!grid) return null; + const rect = grid.getBoundingClientRect(); + return { + columns: getComputedStyle(grid).gridTemplateColumns, + width: Number(rect.width.toFixed(1)), + }; +} + +async function openStylePage(client, label) { + await clickButton(client, '风格'); + await waitForFn(client, function hasComposite(labelText) { + return Array.from(document.querySelectorAll('button.ol-nav-btn')).some(button => ( + button.children.length === 3 && + button.getBoundingClientRect().width > 0 && + button.innerText.trim() === labelText + )); + }, [label]); + assert.equal(await evaluateFn(client, clickCompositeRow, label), true, '找不到风格抽屉入口:' + label); + await waitForFn(client, function hasGrid() { + return Boolean(document.querySelector('.ol-grid-auto-cards')); + }); + await sleep(300); +} + +async function assertCompositeSheets(client) { + await clickButton(client, '更多'); + await sleep(120); + const more = await evaluateFn(client, inspectCompositeSheet, 4); + assert.equal(more.count, 4, '更多抽屉应有四个三元素复合行'); + assert.equal(more.valid, true, '更多抽屉复合行溢出或未保持同行:' + JSON.stringify(more)); + await closeOverlay(client); + + await clickButton(client, '风格'); + await sleep(120); + const style = await evaluateFn(client, inspectCompositeSheet, 2); + assert.equal(style.count, 2, '风格抽屉应有两个三元素复合行'); + assert.equal(style.valid, true, '风格抽屉复合行溢出或未保持同行:' + JSON.stringify(style)); + await closeOverlay(client); +} + +async function captureScreenshot(client, filename) { + const result = await client.send('Page.captureScreenshot', { + format: 'png', + captureBeyondViewport: false, + }); + const path = join(OUTPUT_DIR, filename); + writeFileSync(path, Buffer.from(result.data, 'base64')); + return path; +} + +async function runCase(client, viewport, zoom) { + await client.send('Emulation.setDeviceMetricsOverride', { + width: viewport.width, + height: viewport.height, + deviceScaleFactor: 1, + mobile: false, + }); + await client.send('Page.navigate', { url: APP_URL }); + await waitForFn(client, hasExactButton, ['更多'], 15000); + await evaluateFn(client, function applyTestZoom(value) { + document.documentElement.style.zoom = String(value); + }, zoom); + await sleep(250); + + await openSettings(client); + await setPreferences(client, false, false); + const baselineGeneral = await evaluateFn(client, inspectGeneralLayout, zoom); + + await closeOverlay(client); + await openStylePage(client, '润色模式'); + const baselineStyle = await evaluateFn(client, pageGridFingerprint); + await openStylePage(client, '风格市场'); + const baselineMarketplace = await evaluateFn(client, pageGridFingerprint); + + await openSettings(client); + const combinations = [ + [false, false], + [true, false], + [false, true], + [true, true], + ]; + + const results = []; + for (const combination of combinations) { + const readable = combination[0]; + const conservative = combination[1]; + await setPreferences(client, readable, conservative); + const general = await evaluateFn(client, inspectGeneralLayout, zoom); + assert.deepEqual(general.root, { readable, conservative }); + for (const toggle of [general.readableToggle, general.conservativeToggle]) { + assert.ok(toggle, '布局开关未渲染'); + assert.ok(Math.abs(toggle.normalizedWidth - 36) <= 0.75, '开关宽度不再是 36px:' + JSON.stringify(toggle)); + assert.equal(toggle.justifyContent, 'flex-start', '小尺寸布局开关应靠左'); + assert.ok(toggle.buttonLeft >= -1 && toggle.buttonLeft <= toggle.rowRight, '布局开关超出设置行'); + } + assert.deepEqual(general.outliers, [], '设置页存在未被滚动容器承接的横向溢出'); + + if (!readable && conservative) { + await clickButton(client, '服务'); + await waitForFn(client, function hasEditAction() { + return Array.from(document.querySelectorAll('button')).some(button => ( + button.getBoundingClientRect().width > 0 && + (button.getAttribute('aria-label') || '').startsWith('编辑') + )); + }); + const service = await evaluateFn(client, inspectServiceActions); + assert.ok(service, '服务卡片动作组未渲染'); + assert.equal(service.flexDirection, 'column'); + assert.equal(service.alignItems, 'flex-start'); + assert.ok(service.childCount === 3 && service.uniqueRows === 3, '服务卡片应有验证、开关、编辑三个纵排动作'); + + await clickButton(client, '隐私'); + await waitForFn(client, hasSettingLabel, ['麦克风']); + const privacy = await evaluateFn(client, inspectPermissionActions); + assert.deepEqual(privacy, { + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'flex-start', + }); + + await clickButton(client, '关于'); + await waitForFn(client, function hasAboutComposite() { + return Boolean(document.querySelector('.ol-inline-composite')); + }); + const about = await evaluateFn(client, inspectAboutComposite); + assert.ok(about, '关于页复合行未渲染'); + assert.equal(about.flexDirection, 'row'); + assert.equal(about.flexWrap, 'nowrap'); + assert.equal(about.childCount, 3); + assert.equal(about.withinRow, true, '关于页三元素未保持在容器内:' + JSON.stringify(about)); + await closeOverlay(client); + await openStylePage(client, '润色模式'); + const styleOutliers = await evaluateFn(client, inspectPageOutliers); + assert.deepEqual(styleOutliers, [], '保守排版下风格页存在横向溢出:' + JSON.stringify(styleOutliers)); + await openStylePage(client, '风格市场'); + const marketplaceOutliers = await evaluateFn(client, inspectPageOutliers); + assert.deepEqual(marketplaceOutliers, [], '保守排版下风格市场存在横向溢出:' + JSON.stringify(marketplaceOutliers)); + await openSettings(client); + } + + results.push({ readable, conservative, outliers: general.outliers.length }); + } + + await setPreferences(client, false, false); + const restoredGeneral = await evaluateFn(client, inspectGeneralLayout, zoom); + assert.deepEqual(restoredGeneral.root, { readable: false, conservative: false }); + assert.deepEqual(restoredGeneral.readableToggle, baselineGeneral.readableToggle); + assert.deepEqual(restoredGeneral.conservativeToggle, baselineGeneral.conservativeToggle); + + await closeOverlay(client); + await openStylePage(client, '润色模式'); + const restoredStyle = await evaluateFn(client, pageGridFingerprint); + assert.deepEqual(restoredStyle, baselineStyle, '关闭两个偏好后风格页未恢复基线布局'); + await openStylePage(client, '风格市场'); + const restoredMarketplace = await evaluateFn(client, pageGridFingerprint); + assert.deepEqual(restoredMarketplace, baselineMarketplace, '关闭两个偏好后风格市场未恢复基线布局'); + + await assertCompositeSheets(client); + await openSettings(client); + await setPreferences(client, false, true); + await closeOverlay(client); + await openStylePage(client, '风格市场'); + const screenshot = await captureScreenshot( + client, + 'layout-' + viewport.width + 'x' + viewport.height + '-zoom-' + String(zoom).replace('.', '_') + '.png', + ); + + return { + viewport, + zoom, + combinations: results, + baselineStyle, + baselineMarketplace, + screenshot, + }; +} + +const { child, client } = await launchChrome(); +const reports = []; + +try { + await client.send('Page.enable'); + await client.send('Runtime.enable'); + for (const viewport of VIEWPORTS) { + for (const zoom of ZOOMS) { + const report = await runCase(client, viewport, zoom); + reports.push(report); + console.log('[layout-h5] passed ' + viewport.width + 'x' + viewport.height + ' zoom=' + zoom); + } + } + const reportPath = join(OUTPUT_DIR, 'layout-report.json'); + writeFileSync(reportPath, JSON.stringify(reports, null, 2)); + console.log('[layout-h5] report ' + reportPath); +} finally { + try { + await client.send('Browser.close'); + } catch { + child.kill(); + } + client.close(); +} diff --git a/openless-all/app/scripts/layout-mode-contract.test.mjs b/openless-all/app/scripts/layout-mode-contract.test.mjs new file mode 100644 index 000000000..9e8437f7e --- /dev/null +++ b/openless-all/app/scripts/layout-mode-contract.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const read = relativePath => readFileSync(join(appRoot, relativePath), 'utf8'); + +const css = read('src/styles/global.css'); +const stackedLayout = read('src/lib/stackedLayout.ts'); +const shared = read('src/pages/settings/shared.tsx'); +const pageAtoms = read('src/pages/_atoms.tsx'); +const row = read('src/components/ui/Row.tsx'); +const channelList = read('src/pages/settings/ChannelList.tsx'); +const permissions = read('src/pages/settings/PermissionsSection.tsx'); +const androidPermissions = read('android/frontend/components/AndroidPermissionsPanel.tsx'); +const about = read('src/pages/settings/AboutSection.tsx'); +const updateButton = read('src/pages/settings/CheckUpdateButton.tsx'); +const moreSheet = read('src/components/MobileMoreSheet.tsx'); +const styleSheet = read('src/components/MobileStyleSheet.tsx'); + +assert.doesNotMatch(css, /\.ol-flex-row\s*>\s*\*/); +assert.doesNotMatch(css, /\[class\*=["']ol-grid["']\]/); +assert.match(css, /\.ol-flex-row\.ol-flex-split/); +assert.match(css, /\.ol-conservative-stack/); + +assert.match(stackedLayout, /stackedRowLayout\?: boolean/); +assert.doesNotMatch(stackedLayout, /mobile\s*\|\|/); +assert.match(shared, /flex:\s*"0 0 36px"/); +assert.match(shared, /minWidth:\s*36/); +assert.match(shared, /maxWidth:\s*36/); +assert.match(pageAtoms, /preferenceStack\s*\?/); +assert.match(row, /stackLayout\s*\?\s*'minmax\(0, 1fr\)'/); + +assert.match(channelList, /ol-conservative-stack/); +assert.match(channelList, /settings\.channels\.edit/); +assert.match(permissions, /permissionActionsStyle/); +assert.match(permissions, /justifyContent:\s*stackLayout\s*\?\s*'flex-start'\s*:\s*'flex-end'/); +assert.match(androidPermissions, /const baseLayoutStack = useLayoutStack\(\)/); +assert.match(androidPermissions, /const rowJustify = layoutStack \? 'flex-start' : 'flex-end'/); + +assert.match(about, /ol-inline-composite/); +assert.match(about, /compact=\{compactLayout\}/); +assert.match(updateButton, /minWidth:\s*compact\s*\?\s*32\s*:\s*160/); +assert.match(updateButton, /aria-label=\{compact\s*\?\s*label\s*:\s*undefined\}/); +assert.match(updateButton, /display:\s*compact\s*\?\s*'none'/); +assert.match(moreSheet, /chevRight/); +assert.match(styleSheet, /chevRight/); +assert.match(moreSheet, /flexWrap:\s*'nowrap'/); +assert.match(styleSheet, /flexWrap:\s*'nowrap'/); +assert.match(moreSheet, /position:\s*'fixed'/); +assert.match(styleSheet, /position:\s*'fixed'/); +assert.match(moreSheet, /boxSizing:\s*'border-box'/); +assert.match(styleSheet, /boxSizing:\s*'border-box'/); + +console.log('layout mode contract tests passed'); diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 11b84619a..522a371ce 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -124,9 +124,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia }, []); useEffect(() => { - applyStackedLayoutFromPrefs(mobile, prefs?.stackedRowLayout); + applyStackedLayoutFromPrefs(prefs?.stackedRowLayout); applyConservativeLayout(prefs?.conservativeLayout === true); - }, [mobile, prefs?.stackedRowLayout, prefs?.conservativeLayout]); + }, [prefs?.stackedRowLayout, prefs?.conservativeLayout]); const Page = PAGE_CMP[displayTab as Exclude] ?? Overview; diff --git a/openless-all/app/src/components/MobileMoreSheet.tsx b/openless-all/app/src/components/MobileMoreSheet.tsx index 286ec4550..09d6395de 100644 --- a/openless-all/app/src/components/MobileMoreSheet.tsx +++ b/openless-all/app/src/components/MobileMoreSheet.tsx @@ -31,7 +31,7 @@ export function MobileMoreSheet({
- {t(`nav.${item.id}`)} + {t(`nav.${item.id}`)} + ); })} @@ -93,7 +94,8 @@ export function MobileMoreSheet({ style={rowBtnStyle} > - {t('shell.footer.settings')} + {t('shell.footer.settings')} +
@@ -116,6 +118,10 @@ const iconBtnStyle: CSSProperties = { const rowBtnStyle: CSSProperties = { display: 'flex', + width: '100%', + boxSizing: 'border-box', + minWidth: 0, + flexWrap: 'nowrap', alignItems: 'center', gap: 12, padding: '12px 14px', @@ -127,3 +133,11 @@ const rowBtnStyle: CSSProperties = { cursor: 'default', textAlign: 'left', }; + +const rowLabelStyle: CSSProperties = { + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; diff --git a/openless-all/app/src/components/MobileStyleSheet.tsx b/openless-all/app/src/components/MobileStyleSheet.tsx index d875202fb..70a0c15ed 100644 --- a/openless-all/app/src/components/MobileStyleSheet.tsx +++ b/openless-all/app/src/components/MobileStyleSheet.tsx @@ -29,7 +29,7 @@ export function MobileStyleSheet({
- {t(subItemLabelKey(item.id))} + {t(subItemLabelKey(item.id))} + ); })} @@ -102,6 +103,10 @@ const iconBtnStyle: CSSProperties = { const rowBtnStyle: CSSProperties = { display: 'flex', + width: '100%', + boxSizing: 'border-box', + minWidth: 0, + flexWrap: 'nowrap', alignItems: 'center', gap: 12, padding: '12px 14px', @@ -113,3 +118,11 @@ const rowBtnStyle: CSSProperties = { cursor: 'default', textAlign: 'left', }; + +const rowLabelStyle: CSSProperties = { + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; diff --git a/openless-all/app/src/components/ui/Row.tsx b/openless-all/app/src/components/ui/Row.tsx index a2723017f..ea76eeab8 100644 --- a/openless-all/app/src/components/ui/Row.tsx +++ b/openless-all/app/src/components/ui/Row.tsx @@ -1,6 +1,7 @@ // Row — two-column row used in the Settings modal sub-sections. import type { ReactNode } from 'react'; +import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout'; interface RowProps { label: string; @@ -9,13 +10,34 @@ interface RowProps { } export function Row({ label, desc, children }: RowProps) { + const baseLayoutStack = useLayoutStack(); + const conservative = useConservativeLayout(); + const stackLayout = baseLayoutStack || conservative; return ( -
-
+
+
{label}
{desc &&
{desc}
}
-
{children}
+
+ {children} +
); } diff --git a/openless-all/app/src/lib/stackedLayout.test.ts b/openless-all/app/src/lib/stackedLayout.test.ts new file mode 100644 index 000000000..2e66cdcad --- /dev/null +++ b/openless-all/app/src/lib/stackedLayout.test.ts @@ -0,0 +1,38 @@ +import { + applyStackedLayout, + applyStackedLayoutFromPrefs, + isStackedLayoutActive, +} from './stackedLayout'; + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); +const dataset: Record = {}; +Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { documentElement: { dataset } }, +}); + +try { + applyStackedLayoutFromPrefs(true); + assert(dataset.olStackedLayout === 'true', 'enabled preference should set the root layout attribute'); + assert(isStackedLayoutActive(), 'active query should reflect the root layout attribute'); + + applyStackedLayoutFromPrefs(false); + assert(!('olStackedLayout' in dataset), 'disabled preference should remove the root layout attribute'); + assert(!isStackedLayoutActive(), 'active query should become false after removal'); + + applyStackedLayout(true); + applyStackedLayoutFromPrefs(undefined); + assert(!('olStackedLayout' in dataset), 'missing preference should restore the base layout'); +} finally { + if (previousDocument) { + Object.defineProperty(globalThis, 'document', previousDocument); + } else { + delete (globalThis as { document?: unknown }).document; + } +} + +console.log('stacked layout tests passed'); diff --git a/openless-all/app/src/lib/stackedLayout.ts b/openless-all/app/src/lib/stackedLayout.ts index 891e72652..500d2b2d4 100644 --- a/openless-all/app/src/lib/stackedLayout.ts +++ b/openless-all/app/src/lib/stackedLayout.ts @@ -10,10 +10,9 @@ export function applyStackedLayout(enabled: boolean): void { } export function applyStackedLayoutFromPrefs( - mobile: boolean, stackedRowLayout?: boolean, ): void { - applyStackedLayout(mobile || stackedRowLayout === true); + applyStackedLayout(stackedRowLayout === true); } export function isStackedLayoutActive(): boolean { diff --git a/openless-all/app/src/lib/useMobileLayout.ts b/openless-all/app/src/lib/useMobileLayout.ts index 29fe3c80c..ad667dad4 100644 --- a/openless-all/app/src/lib/useMobileLayout.ts +++ b/openless-all/app/src/lib/useMobileLayout.ts @@ -25,11 +25,17 @@ export function useMobileLayout(breakpoint = 720): boolean { return mobile; } +/** 易读布局:只表示用户偏好,不混入移动端基础布局。 */ +export function useReadableLayout(): boolean { + const { prefs } = useHotkeySettings(); + return prefs?.stackedRowLayout === true; +} + /** 排版堆叠:移动端壳层 或 用户开启「易读布局」。用于 grid/flex 换行,不含底栏等壳层逻辑。 */ export function useLayoutStack(breakpoint = 720): boolean { const mobile = useMobileLayout(breakpoint); - const { prefs } = useHotkeySettings(); - return mobile || prefs?.stackedRowLayout === true; + const readable = useReadableLayout(); + return mobile || readable; } /** 保守排版:仅读用户偏好,不与 mobile 联动。 */ diff --git a/openless-all/app/src/pages/_atoms.tsx b/openless-all/app/src/pages/_atoms.tsx index 1acf56f01..3f173bff4 100644 --- a/openless-all/app/src/pages/_atoms.tsx +++ b/openless-all/app/src/pages/_atoms.tsx @@ -4,7 +4,7 @@ import { useState, type CSSProperties, type ReactNode } from 'react'; import { Icon } from '../components/Icon'; -import { useMobileLayout, useLayoutStack, useConservativeLayout } from '../lib/useMobileLayout'; +import { useMobileLayout, useReadableLayout, useConservativeLayout } from '../lib/useMobileLayout'; interface PageHeaderProps { kicker?: string; @@ -16,9 +16,10 @@ interface PageHeaderProps { export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeaderProps) { const mobile = useMobileLayout(); - const layoutStack = useLayoutStack(); + const readable = useReadableLayout(); const conservative = useConservativeLayout(); - const stackLayout = conservative || layoutStack; + const preferenceStack = readable || conservative; + const stackLayout = mobile || preferenceStack; return (
{kicker && ( @@ -36,15 +37,16 @@ export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeade

{title}

{titleRight}
- {desc &&

{desc}

} + {desc &&

{desc}

}
{right && ( -
- {right} -
+ preferenceStack ? ( +
+ {right} +
+ ) : ( + right + ) )}
); diff --git a/openless-all/app/src/pages/settings/AboutSection.tsx b/openless-all/app/src/pages/settings/AboutSection.tsx index 978dc6bf4..3559f38bd 100644 --- a/openless-all/app/src/pages/settings/AboutSection.tsx +++ b/openless-all/app/src/pages/settings/AboutSection.tsx @@ -11,6 +11,7 @@ import { getPlatformCapabilities, openExternal } from '../../lib/ipc'; import type { PlatformCapabilities } from '../../lib/types'; import { APP_VERSION_LABEL } from '../../lib/appVersion'; import { Card } from '../_atoms'; +import { useConservativeLayout, useLayoutStack } from '../../lib/useMobileLayout'; import { btnGhostStyle, SectionTitle } from './shared'; import { CheckUpdateButton } from './CheckUpdateButton'; @@ -19,6 +20,10 @@ const RELEASE_NOTES_URL = 'https://github.com/Open-Less/openless/releases'; export function AboutSection() { const { t } = useTranslation(); + const baseLayoutStack = useLayoutStack(); + const conservativeLayout = useConservativeLayout(); + const compactLayout = baseLayoutStack || conservativeLayout; + const appIconSize = compactLayout ? 36 : 56; const [qqCopied, setQqCopied] = useState(false); const [platformCaps, setPlatformCaps] = useState(null); const qqCopiedRef = useRef(null); @@ -42,21 +47,43 @@ export function AboutSection() { <> {/* ─── 版本信息 + 检查更新(正式版)─────────────────────────────── */} -
+
-
OpenLess
-
+
OpenLess
+
{t('modal.about.tagline')} · {APP_VERSION_LABEL}
{/* 图标右上方:查正式版的检查更新按钮。Beta 渠道在「高级」页。 */} {platformCaps?.supportsAutoUpdate === true && ( - + )}
diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index 70cab0a9e..a30cc1c5c 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -29,7 +29,7 @@ import { type Channel, } from '../../lib/ipc'; import { emitSaved } from '../../lib/savedEvent'; -import { useMobileLayout } from '../../lib/useMobileLayout'; +import { useMobileLayout, useReadableLayout, useConservativeLayout } from '../../lib/useMobileLayout'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { getPlatformCapabilities } from '../../lib/platform'; import { Card } from '../_atoms'; @@ -154,6 +154,9 @@ export function ChannelList({ }) { const { t } = useTranslation(); const mobile = useMobileLayout(); + const readable = useReadableLayout(); + const conservative = useConservativeLayout(); + const preferenceStack = readable || conservative; const os = detectOS(); // 初值 false:getPlatformCapabilities() 的权威值是架构感知的(Apple Silicon / // Intel),以 os === 'mac' 起步会让 Intel Mac 打开下拉时闪现一次 MLX 预设, @@ -452,7 +455,8 @@ export function ChannelList({ }} style={{ display: 'flex', - alignItems: 'center', + flexDirection: preferenceStack ? 'column' : 'row', + alignItems: preferenceStack ? 'stretch' : 'center', gap: 10, // 左侧补偿 2px 竖条与 0.5px 细边的宽度差,文字基线保持对齐。 padding: isActive ? '10px 12px 10px 10.5px' : '10px 12px', @@ -470,6 +474,7 @@ export function ChannelList({ transition: draggingId ? undefined : 'opacity 0.16s var(--ol-motion-quick)', }} > +
onDragHandleDown(e, channel.id)} onClick={e => e.stopPropagation()} @@ -500,6 +505,18 @@ export function ChannelList({ {channel.lastTest && {relativeTime(channel.lastTest.at, t)}}
+
+
setEditingId(channel.id)} title={t('settings.channels.edit')} aria-label={t('settings.channels.edit')} - style={iconBtn} + style={{ ...iconBtn, ...(conservative ? { width: 'auto', padding: '0 10px', gap: 6 } : {}) }} > + {conservative && {t('settings.channels.edit')}}
+
); })}
diff --git a/openless-all/app/src/pages/settings/CheckUpdateButton.tsx b/openless-all/app/src/pages/settings/CheckUpdateButton.tsx index b672f58c6..4d5587791 100644 --- a/openless-all/app/src/pages/settings/CheckUpdateButton.tsx +++ b/openless-all/app/src/pages/settings/CheckUpdateButton.tsx @@ -8,7 +8,7 @@ import { Icon } from '../../components/Icon'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../../components/AutoUpdate'; import type { UpdateChannel } from '../../lib/ipc'; -export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) { +export function CheckUpdateButton({ channel, compact = false }: { channel: UpdateChannel; compact?: boolean }) { const { t } = useTranslation(); const updater = useAutoUpdate(); const { status, checking, busy } = updater; @@ -36,16 +36,33 @@ export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
-
+
{microphone === 'noDevice' ? ( @@ -167,7 +179,7 @@ export function PermissionsSection() { {capability?.requiresAccessibilityPermission && platformCaps?.platform !== 'android' && ( -
+
{accessibility !== 'granted' && accessibility !== 'notApplicable' && accessibility !== 'loading' && ( @@ -189,7 +201,7 @@ export function PermissionsSection() { )} {platformCaps?.supportsDesktopHotkey === true && ( -
+
{hotkey?.message && ( -
+
{windowsIme && ( )} -
+
{network && network.latencyMs != null && ( {network.latencyMs}ms diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index 874d12b32..b08bd10e4 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -3,7 +3,7 @@ import type { CSSProperties, ReactNode } from "react" import { Tooltip } from "../../components/Tooltip" -import { useLayoutStack, useConservativeLayout } from "../../lib/useMobileLayout" +import { useMobileLayout, useReadableLayout, useConservativeLayout } from "../../lib/useMobileLayout" // 带说明的文字统一加虚线下划线 + help 光标,暗示「悬停可看解释」。 const hintableTextStyle: CSSProperties = { @@ -67,9 +67,10 @@ export function SettingRow({ children, controlWidth, }: SettingRowProps) { - const layoutStack = useLayoutStack() + const mobile = useMobileLayout() + const readable = useReadableLayout() const conservative = useConservativeLayout() - const stackLayout = conservative || layoutStack + const stackLayout = mobile || readable || conservative const labelStyle: CSSProperties = { fontSize: 13, fontWeight: 500, @@ -98,10 +99,11 @@ export function SettingRow({ )}
onToggle?.(!on)} style={{ position: "relative", + flex: "0 0 36px", width: 36, + minWidth: 36, + maxWidth: 36, height: 20, borderRadius: 999, border: 0, diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx index 47b48e207..62e06452f 100644 --- a/openless-all/app/src/state/HotkeySettingsContext.tsx +++ b/openless-all/app/src/state/HotkeySettingsContext.tsx @@ -23,7 +23,6 @@ import i18n, { outputPrefsForLocale, type SupportedLocale } from "../i18n" import { applyThemeFromPreference } from "../lib/themeMode" import { applyStackedLayoutFromPrefs } from "../lib/stackedLayout" import { applyConservativeLayout } from "../lib/conservativeLayout" -import { shouldUseMobileLayout } from "../lib/useMobileLayout" import { emitSaved } from "../lib/savedEvent" interface HotkeySettingsContextValue { @@ -68,10 +67,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { persistedPrefsRef.current = prefsResult.value setPrefs(prefsResult.value) applyThemeFromPreference(prefsResult.value.themeMode ?? "system") - applyStackedLayoutFromPrefs( - shouldUseMobileLayout(), - prefsResult.value.stackedRowLayout, - ) + applyStackedLayoutFromPrefs(prefsResult.value.stackedRowLayout) applyConservativeLayout(prefsResult.value.conservativeLayout === true) } else { console.error( @@ -134,10 +130,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { persistedPrefsRef.current = nextPrefs setPrefs(nextPrefs) applyThemeFromPreference(nextPrefs.themeMode ?? "system") - applyStackedLayoutFromPrefs( - shouldUseMobileLayout(), - nextPrefs.stackedRowLayout, - ) + applyStackedLayoutFromPrefs(nextPrefs.stackedRowLayout) applyConservativeLayout(nextPrefs.conservativeLayout === true) }, ) @@ -213,10 +206,7 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { if (resolved === current) return setPrefs(resolved) latestPrefsRef.current = resolved - applyStackedLayoutFromPrefs( - shouldUseMobileLayout(), - resolved.stackedRowLayout, - ) + applyStackedLayoutFromPrefs(resolved.stackedRowLayout) applyConservativeLayout(resolved.conservativeLayout === true) try { await queueSetSettings(resolved) diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 3be5477d3..2cbfe7b83 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -224,13 +224,13 @@ html[data-ol-stacked-layout="true"] .ol-flex-row { align-items: flex-start; } -html[data-ol-stacked-layout="true"] .ol-flex-row > .ol-flex-split { +html[data-ol-stacked-layout="true"] .ol-flex-row.ol-flex-split { flex: 1 1 100%; max-width: 100%; min-width: 0; } -html[data-ol-stacked-layout="true"] .ol-flex-row > .ol-flex-push { +html[data-ol-stacked-layout="true"] .ol-flex-row.ol-flex-push { flex: 0 0 auto; max-width: 100%; } @@ -243,11 +243,6 @@ html[data-ol-stacked-layout="true"] .ol-console-main { overflow-x: hidden; } -html[data-ol-stacked-layout="true"]:not([data-ol-conservative-layout="true"]) .ol-flex-row > * { - flex: 1 1 auto; - min-width: min(100%, 10rem); - max-width: 100%; -} /* ── 保守排版(data-ol-conservative-layout)────────────────────────────────────── 除首页、顶栏、底栏与胶囊窗外,内容区强制单列满宽。 */ @@ -255,21 +250,22 @@ html[data-ol-conservative-layout="true"] .ol-conservative-scope { overflow-x: hidden; } -html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-flex-row { +html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-conservative-stack { + display: flex; flex-direction: column; - align-items: stretch; + align-items: flex-start; width: 100%; + min-width: 0; } -html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-flex-row > * { - width: 100%; +html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-conservative-stack > * { max-width: 100%; min-width: 0; - flex: 1 1 auto; + flex: 0 0 auto; } html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-grid-auto-cards, -html[data-ol-conservative-layout="true"] .ol-conservative-scope [class*="ol-grid"] { +html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-conservative-grid { grid-template-columns: 1fr !important; }