fix(android): 修复小屏大字号下的设置与风格页面排版 (#960) (#983)

* fix(android): 修复移动端设置与润色页排版及底栏遮挡 (#960)

设置弹窗 z-index 低于底栏导致底部控件无法点击;润色风格/市场页与 Android 权限区补齐 mobile 布局。

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(ui): 通用设置新增易读布局,大字号小屏防溢出换行

新增 stackedRowLayout 偏好与 useLayoutStack;设置外观区可手动开启同行控件换行,覆盖老人机等极限场景。关联 #960。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 安全查询悬浮窗权限,修复 HyperOS canDrawOverlays 闪退

WebView IPC 线程不再直接 JNI 调用 Settings.canDrawOverlays,改走 Kotlin 主线程桥接并捕获异常,避免 Redmi/HyperOS 在权限轮询时崩溃。

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(ui): 保守排版开关与易读布局 min-width 增强

新增 conservativeLayout 偏好与 ol-conservative-scope 单列满宽规则;易读布局补充 min-width 换行;设置与主要页面支持双开关独立并存。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): can_draw_overlays JNI 生命周期以通过 Android CI 编译

为 env 与 context 绑定同一 lifetime,满足 call_static_bool_with_context_class 的借用约束。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(android): 恢复 AccessibilityService 对 Settings.Secure 的 import

isEnabled 仍使用 Settings.Secure 查询无障碍状态,与 canDrawOverlays 移除的 import 无关。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): 将易读/保守排版开关独立为「布局」区块并置顶

原开关藏在「外观」卡片深处不易发现;现于设置→通用页录音输入下方展示独立「布局」卡片。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): 统一易读与保守排版布局规则

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
浩彬(^-^)andCursor authored and GitHub committed 2026-08-23 00:22:21 +08:00
1 parent a19830d312
commit ec33bd5dd2
40 files changed
+1394 -148

No files matched your search

@@ -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';
@@ -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 { useLayoutStack, useConservativeLayout } from '../../../src/lib/useMobileLayout';
import {
getAndroidAccessibilityStatus,
getAndroidOverlayStatus,
@@ -73,6 +74,9 @@ interface AndroidPermissionsPanelProps {
export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPanelProps) {
const { t } = useTranslation();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const layoutStack = baseLayoutStack || conservative;
const [androidOverlay, setAndroidOverlay] = useState<AndroidOverlayStatus | null>(null);
const [androidAccessibility, setAndroidAccessibility] = useState<AndroidAccessibilityStatus | null>(null);
const [androidShizuku, setAndroidShizuku] = useState<AndroidShizukuStatus | null>(null);
@@ -261,13 +265,31 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
const showAccessibility = mode === 'all' || mode === 'accessibility';
const showOverlayConfig = mode === 'all' || mode === 'overlayConfig';
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 };
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 && (
<SettingRow label={t('settings.permissions.androidOverlayLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
<div className="ol-flex-row" style={actionRowStyle}>
{androidOverlay?.message && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: 220, textAlign: 'right' }}>
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidthSm, textAlign: hintAlign }}>
{androidOverlay.message}
</span>
)}
@@ -282,10 +304,10 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
)}
{showAccessibility && (
<SettingRow label={t('settings.permissions.androidAccessibilityLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%', minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%', minWidth: 0 }}>
<div className="ol-flex-row" style={actionRowStyle}>
{resolveAccessibilityMessage(t, androidAccessibility?.messageKey) && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: 220, textAlign: 'right' }}>
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidthSm, textAlign: hintAlign }}>
{resolveAccessibilityMessage(t, androidAccessibility?.messageKey)}
</span>
)}
@@ -296,7 +318,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
</Btn>
)}
</div>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: 300, textAlign: 'right' }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidth, textAlign: hintAlign }}>
{t('settings.permissions.androidAccessibilityImpact')}
</span>
</div>
@@ -304,10 +326,10 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
)}
{showAccessibility && (
<SettingRow label={t('settings.permissions.androidShizukuLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%', minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%', minWidth: 0 }}>
<div className="ol-flex-row" style={actionRowStyle}>
{resolveShizukuMessage(t, shizukuDisplayMessageKey) && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: 220, textAlign: 'right' }}>
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidthSm, textAlign: hintAlign }}>
{resolveShizukuMessage(t, shizukuDisplayMessageKey)}
</span>
)}
@@ -366,7 +388,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
)}
</div>
{androidShizuku?.state === 'authorized' && (
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: 300, textAlign: 'right' }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidth, textAlign: hintAlign }}>
{androidShizuku.accessibility.operational
? t('settings.permissions.androidShizukuAccessibilityOperational')
: t('settings.permissions.androidShizukuAccessibilityRegistered', {
@@ -379,7 +401,7 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
})}
</span>
)}
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: 300, textAlign: 'right' }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: hintMaxWidth, textAlign: hintAlign }}>
{t('settings.permissions.androidShizukuHint')}
</span>
</div>
@@ -388,87 +410,87 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
{showOverlayConfig && (
<>
<SettingRow label={t('settings.permissions.androidInsertStrategyLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
<select
value={androidPrefs?.androidInsertStrategy ?? 'accessibility'}
onChange={(event) => { void updateAndroidPref('androidInsertStrategy', event.target.value as AndroidInsertStrategy); }}
style={{ minWidth: 180, maxWidth: '100%' }}
style={selectStyle}
>
<option value="accessibility">{t('settings.permissions.androidInsertStrategy.accessibility')}</option>
<option value="clipboard">{t('settings.permissions.androidInsertStrategy.clipboard')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
{t(`settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'accessibility'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayTriggerLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
<select
value={androidPrefs?.androidOverlayTrigger ?? 'background'}
onChange={(event) => { void updateAndroidPref('androidOverlayTrigger', event.target.value as AndroidOverlayTrigger); }}
style={{ minWidth: 180, maxWidth: '100%' }}
style={selectStyle}
>
<option value="background">{t('settings.permissions.androidOverlayTrigger.background')}</option>
<option value="keyboard" disabled>{t('settings.permissions.androidOverlayTrigger.keyboard')}</option>
<option value="always">{t('settings.permissions.androidOverlayTrigger.always')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
{t(`settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`)}
</span>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
{t('settings.permissions.androidOverlayTriggerDisabled.keyboard')}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayActivationModeLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
<select
value={androidPrefs?.androidOverlayActivationMode ?? 'tap'}
onChange={(event) => { void updateAndroidPref('androidOverlayActivationMode', event.target.value as AndroidOverlayActivationMode); }}
style={{ minWidth: 180, maxWidth: '100%' }}
style={selectStyle}
>
<option value="tap">{t('settings.permissions.androidOverlayActivationMode.tap')}</option>
<option value="long_press">{t('settings.permissions.androidOverlayActivationMode.long_press')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
{t(`settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayLeftSwipeActionLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
<select
value={androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}
onChange={(event) => { void updateAndroidPref('androidOverlayLeftSwipeAction', event.target.value as AndroidOverlayLeftSwipeAction); }}
style={{ minWidth: 180, maxWidth: '100%' }}
style={selectStyle}
>
<option value="translation">{t('settings.permissions.androidOverlayLeftSwipeAction.translation')}</option>
<option value="style_pack">{t('settings.permissions.androidOverlayLeftSwipeAction.style_pack')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
{t(`settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayCancelSwipeDirectionLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
<select
value={androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}
onChange={(event) => { void updateAndroidPref('androidOverlayCancelSwipeDirection', event.target.value as AndroidOverlayCancelSwipeDirection); }}
style={{ minWidth: 180, maxWidth: '100%' }}
style={selectStyle}
>
<option value="up">{t('settings.permissions.androidOverlayCancelSwipeDirection.up')}</option>
<option value="down">{t('settings.permissions.androidOverlayCancelSwipeDirection.down')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
{t(`settings.permissions.androidOverlayCancelSwipeDirectionHint.${androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlaySizeLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 180, maxWidth: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: colAlign, width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', maxWidth: '100%' }}>
<input
type="range"
min={48}
@@ -487,13 +509,13 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
onBlur={(event) => {
flushAndroidOverlaySizeSave(Number(event.currentTarget.value));
}}
style={{ width: 132 }}
style={{ flex: 1, minWidth: 0 }}
/>
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', minWidth: 42, textAlign: 'right' }}>
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', minWidth: 42, textAlign: hintAlign }}>
{sizeDraft ?? androidPrefs?.androidOverlaySizeDp ?? 72} dp
</span>
</div>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
{t('settings.permissions.androidOverlaySizeHint')}
</span>
</div>
@@ -114,11 +114,7 @@ class OpenLessAccessibilityService : AccessibilityService() {
}
private fun canDrawOverlays(): Boolean {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
Settings.canDrawOverlays(this)
} else {
true
}
return OpenLessPermissionBridge.canDrawOverlaysSafely(this)
}
private fun performPasteToFocusedFieldInternal(pasteText: String? = null): AccessibilityPasteResult {
@@ -6,7 +6,6 @@ import android.app.Application
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
/**
@@ -66,11 +65,7 @@ class OpenLessApplication : Application() {
}
private fun canDrawOverlays(): Boolean {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
Settings.canDrawOverlays(this)
} else {
true
}
return OpenLessPermissionBridge.canDrawOverlaysSafely(this)
}
private fun sendOverlayAction(action: String) {
@@ -13,7 +13,6 @@ import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.IBinder
import android.provider.Settings
import android.util.Log
import android.view.Gravity
import android.view.MotionEvent
@@ -266,11 +265,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
}
private fun canDrawOverlays(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Settings.canDrawOverlays(this)
} else {
true
}
return OpenLessPermissionBridge.canDrawOverlaysSafely(this)
}
private fun refreshExistingOverlayLayout(
@@ -1,12 +1,18 @@
package com.openless.app
import android.Manifest
import android.app.AppOpsManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import androidx.annotation.Keep
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@Keep
@@ -46,4 +52,70 @@ object OpenLessPermissionBridge {
Log.i(TAG, "RECORD_AUDIO permission completed granted=$granted")
requestInFlight.set(false)
}
/**
* Safe overlay permission query for Rust JNI / WebView IPC threads.
* HyperOS and some OEM skins may throw from Settings.canDrawOverlays off the main thread.
*/
@Keep
@JvmStatic
fun canDrawOverlaysSafely(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return true
}
val appContext = OpenLessAppContext.context ?: context.applicationContext
return if (Looper.myLooper() == Looper.getMainLooper()) {
queryCanDrawOverlays(appContext)
} else {
val result = AtomicBoolean(false)
val latch = CountDownLatch(1)
Handler(Looper.getMainLooper()).post {
try {
result.set(queryCanDrawOverlays(appContext))
} finally {
latch.countDown()
}
}
try {
latch.await(2, TimeUnit.SECONDS)
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
Log.w(TAG, "canDrawOverlaysSafely interrupted", error)
}
result.get()
}
}
private fun queryCanDrawOverlays(context: Context): Boolean {
return try {
Settings.canDrawOverlays(context)
} catch (error: Throwable) {
Log.w(TAG, "Settings.canDrawOverlays failed, trying AppOpsManager", error)
queryCanDrawOverlaysViaAppOps(context)
}
}
private fun queryCanDrawOverlaysViaAppOps(context: Context): Boolean {
return try {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appOps.unsafeCheckOpNoThrow(
AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW,
android.os.Process.myUid(),
context.packageName,
)
} else {
@Suppress("DEPRECATION")
appOps.checkOpNoThrow(
AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW,
android.os.Process.myUid(),
context.packageName,
)
}
mode == AppOpsManager.MODE_ALLOWED
} catch (error: Throwable) {
Log.w(TAG, "AppOpsManager overlay check failed", error)
false
}
}
}
@@ -15,7 +15,9 @@ class OverlayPermissionActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
!OpenLessPermissionBridge.canDrawOverlaysSafely(this)
) {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName"),
@@ -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();
}
@@ -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');
@@ -446,18 +446,21 @@ pub mod android {
Ok(())
}
pub fn can_draw_overlays(env: &mut JNIEnv, context: &JObject) -> Result<bool, String> {
pub fn can_draw_overlays<'local>(
env: &mut JNIEnv<'local>,
context: &JObject<'local>,
) -> Result<bool, String> {
if android_sdk_int(env)? < 23 {
return Ok(true);
}
env.call_static_method(
"android/provider/Settings",
"canDrawOverlays",
call_static_bool_with_context_class(
env,
context,
"com.openless.app.OpenLessPermissionBridge",
"canDrawOverlaysSafely",
"(Landroid/content/Context;)Z",
&[JValue::Object(context)],
)
.and_then(|value| value.z())
.map_err(|error| format!("Settings.canDrawOverlays: {error}"))
}
pub fn check_self_permission(
+16
View File
@@ -1169,6 +1169,12 @@ pub struct UserPreferences {
/// 活动计数照常记录(persistence/activity.rs),再打开时全年数据仍在。
#[serde(default = "default_true")]
pub show_overview_activity_heatmap: bool,
/// 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出与文字被压扁。默认 false。
#[serde(default)]
pub stacked_row_layout: bool,
/// 保守排版:除首页、顶栏、底栏与胶囊窗外,内容区强制单列满宽。默认 false。
#[serde(default)]
pub conservative_layout: bool,
/// 主窗口启动 + 后台每 60 分钟自动检查更新。默认 true。
/// Android 开启后自动检查并下载,校验后打开系统安装器;桌面仅自动检查 + 用户确认安装。
/// 关闭后仅 Settings 手动「检查更新」按钮可用。
@@ -1441,6 +1447,10 @@ struct UserPreferencesWire {
cursor_context_enabled: bool,
#[serde(default = "default_true")]
show_overview_activity_heatmap: bool,
#[serde(default)]
stacked_row_layout: bool,
#[serde(default)]
conservative_layout: bool,
#[serde(default = "default_true")]
auto_update_check: bool,
#[serde(default)]
@@ -1591,6 +1601,8 @@ 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,
conservative_layout: prefs.conservative_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 +1759,8 @@ 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,
conservative_layout: wire.conservative_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 +2577,8 @@ impl Default for UserPreferences {
streaming_insert_save_clipboard: true,
cursor_context_enabled: false,
show_overview_activity_heatmap: true,
stacked_row_layout: false,
conservative_layout: false,
auto_update_check: true,
history_max_entries: None,
record_audio_for_debug: false,
@@ -37,7 +37,10 @@ import { type SettingsSectionId } from './SettingsModal';
import { MobileMoreSheet } from './MobileMoreSheet';
import { MobileStyleSheet } from './MobileStyleSheet';
import { subItemLabelKey } from '../lib/navLabels';
import { useMobileLayout } from '../lib/useMobileLayout';
import { applyStackedLayoutFromPrefs } from '../lib/stackedLayout';
import { applyConservativeLayout } from '../lib/conservativeLayout';
import { useMobileLayout, useConservativeLayout } from '../lib/useMobileLayout';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { useAppState, type AppTab } from '../state/useAppState';
const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk'];
@@ -92,6 +95,8 @@ 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 conservative = useConservativeLayout();
const { prefs } = useHotkeySettings();
const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings);
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
const [providerPromptOpen, setProviderPromptOpen] = useState(false);
@@ -118,6 +123,11 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
applyFontScale(readFontScale());
}, []);
useEffect(() => {
applyStackedLayoutFromPrefs(prefs?.stackedRowLayout);
applyConservativeLayout(prefs?.conservativeLayout === true);
}, [prefs?.stackedRowLayout, prefs?.conservativeLayout]);
const Page = PAGE_CMP[displayTab as Exclude<AppTab, 'localAsr'>] ?? Overview;
// 分组展开态:默认展开「当前所在页所属的分组」。用户点分组标题手动切换。
@@ -453,7 +463,12 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
{displayTab === 'overview' ? (
<Overview onOpenHistory={() => setCurrentTab('history')} />
) : (
<Page />
<div
className={conservative ? 'ol-conservative-scope' : undefined}
style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}
>
<Page />
</div>
)}
</div>
</main>
@@ -462,6 +477,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
{mobile && (
<>
{!settingsOpen && !styleOpen && !moreOpen && (
<MobileBottomNav
currentTab={currentTab}
moreOpen={moreOpen}
@@ -483,6 +499,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
setMoreOpen(true);
}}
/>
)}
<MobileStyleSheet
open={styleOpen}
currentTab={currentTab}
@@ -31,7 +31,7 @@ export function MobileMoreSheet({
<div
onClick={onClose}
style={{
position: 'absolute',
position: 'fixed',
inset: 0,
zIndex: 60,
background: 'rgba(15,17,22,0.32)',
@@ -79,7 +79,8 @@ export function MobileMoreSheet({
style={rowBtnStyle}
>
<Icon name={item.icon} size={16} />
<span style={{ flex: 1 }}>{t(`nav.${item.id}`)}</span>
<span style={rowLabelStyle}>{t(`nav.${item.id}`)}</span>
<Icon name="chevRight" size={13} />
</button>
);
})}
@@ -93,7 +94,8 @@ export function MobileMoreSheet({
style={rowBtnStyle}
>
<Icon name="settings" size={16} />
<span style={{ flex: 1 }}>{t('shell.footer.settings')}</span>
<span style={rowLabelStyle}>{t('shell.footer.settings')}</span>
<Icon name="chevRight" size={13} />
</button>
</div>
</div>
@@ -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',
};
@@ -29,7 +29,7 @@ export function MobileStyleSheet({
<div
onClick={onClose}
style={{
position: 'absolute',
position: 'fixed',
inset: 0,
zIndex: 60,
background: 'rgba(15,17,22,0.32)',
@@ -77,7 +77,8 @@ export function MobileStyleSheet({
style={rowBtnStyle}
>
<Icon name={item.icon} size={16} />
<span style={{ flex: 1 }}>{t(subItemLabelKey(item.id))}</span>
<span style={rowLabelStyle}>{t(subItemLabelKey(item.id))}</span>
<Icon name="chevRight" size={13} />
</button>
);
})}
@@ -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',
};
@@ -14,7 +14,7 @@ import { Icon } from './Icon';
import { SavedToast } from './SavedToast';
import { useSavedToastListener } from '../lib/savedEvent';
import { openExternal } from '../lib/ipc';
import { useMobileLayout } from '../lib/useMobileLayout';
import { useMobileLayout, useConservativeLayout } from '../lib/useMobileLayout';
import type { OS } from './WindowChrome';
import { AboutTab, GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs';
import { chipSelectedStyle } from '../pages/settings/shared';
@@ -59,6 +59,7 @@ const LINK_ITEMS: ModalNavItem[] = [
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
const { t } = useTranslation();
const mobile = useMobileLayout();
const conservative = useConservativeLayout();
const [section, setSection] = useState<SettingsSectionId>(initialSettingsSection ?? 'general');
const savedToast = useSavedToastListener();
@@ -88,7 +89,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
alignItems: mobile ? 'stretch' : 'center',
justifyContent: mobile ? 'stretch' : 'center',
padding: mobile ? 0 : 28,
zIndex: 50,
zIndex: mobile ? 70 : 50,
animation: mobile ? undefined : 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
}}>
@@ -246,7 +247,10 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
)}
<div
className="ol-thinscroll ol-scroll-fade"
className={[
'ol-thinscroll ol-scroll-fade',
conservative ? 'ol-conservative-scope' : undefined,
].filter(Boolean).join(' ')}
style={{
flex: 1,
minHeight: 0,
+25 -3
View File
@@ -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 (
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 16, padding: '12px 0', borderTop: '0.5px solid var(--ol-line-soft)' }}>
<div>
<div style={{
display: 'grid',
gridTemplateColumns: stackLayout ? 'minmax(0, 1fr)' : '180px minmax(0, 1fr)',
gap: stackLayout ? 8 : 16,
padding: '12px 0',
borderTop: '0.5px solid var(--ol-line-soft)',
alignItems: 'center',
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--ol-ink)' }}>{label}</div>
{desc && <div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 4, lineHeight: 1.5 }}>{desc}</div>}
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>{children}</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
minWidth: 0,
width: stackLayout ? '100%' : 'auto',
maxWidth: '100%',
flexWrap: stackLayout ? 'wrap' : 'nowrap',
gap: stackLayout ? 6 : undefined,
}}>
{children}
</div>
</div>
);
}
+7
View File
@@ -1331,10 +1331,17 @@ 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',
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.',
conservativeLayoutLabel: 'Conservative layout',
conservativeLayoutDesc: 'Outside the home page, top bar, and bottom bar, settings and feature pages use a single full-width column to minimize horizontal overflow.',
system: 'Follow system',
light: 'Light',
dark: 'Dark',
+7
View File
@@ -1299,10 +1299,17 @@ export const ja: typeof zhCN = {
ko: '한국어 (Beta)',
restartHint: '一部のネイティブメニュー(トレイ等)は再起動後に反映されます。',
},
layout: {
title: 'レイアウト',
},
theme: {
title: '外観',
label: 'テーマ',
activityHeatmapLabel: '概要ページに年間アクティビティを表示',
stackedRowLayoutLabel: '読みやすいレイアウト(はみ出し防止)',
stackedRowLayoutDesc: '小さい画面や大きな文字サイズでは、1行に収まらないボタンや設定が次の行に折り返され、横方向のはみ出しや文字の潰れを防ぎます。',
conservativeLayoutLabel: '保守レイアウト',
conservativeLayoutDesc: 'ホーム、上部バー、下部バー以外の設定・機能ページを単列・全幅にし、横方向のはみ出しを最大限防ぎます。',
system: 'システムに従う',
light: 'ライト',
dark: 'ダーク',
+7
View File
@@ -1299,10 +1299,17 @@ export const ko: typeof zhCN = {
ko: '한국어 (Beta)',
restartHint: '일부 네이티브 메뉴(트레이 등)는 앱 재시작 후 반영될 수 있습니다.',
},
layout: {
title: '레이아웃',
},
theme: {
title: '모양',
label: '테마',
activityHeatmapLabel: '개요 페이지에 연간 활동 표시',
stackedRowLayoutLabel: '읽기 쉬운 레이아웃(넘침 방지 줄바꿈)',
stackedRowLayoutDesc: '작은 화면이나 큰 글꼴에서 한 줄에 맞지 않는 버튼과 옵션은 다음 줄로 넘어가 가로 넘침과 글자 눌림을 방지합니다.',
conservativeLayoutLabel: '보수적 레이아웃',
conservativeLayoutDesc: '홈, 상단 바, 하단 바 외 설정·기능 페이지를 단일 열·전체 너비로 표시하여 가로 넘침을 최대한 방지합니다.',
system: '시스템 따르기',
light: '라이트',
dark: '다크',
+7
View File
@@ -1329,10 +1329,17 @@ export const zhCN = {
ko: '한국어 (Beta)',
restartHint: '部分原生菜单(系统托盘等)可能需要重启 App 才会切换。',
},
layout: {
title: '布局',
},
theme: {
title: '外观',
label: '主题',
activityHeatmapLabel: '概览页显示年度活动热力图',
stackedRowLayoutLabel: '易读布局(防溢出换行)',
stackedRowLayoutDesc: '小屏或大字时,同一行放不下的按钮和选项会自动换到下一行,避免横向挤出屏幕或文字被压扁。',
conservativeLayoutLabel: '保守排版',
conservativeLayoutDesc: '除首页、顶栏与底栏外,设置与功能页改为单列满宽,最大程度避免横向溢出。',
system: '跟随系统',
light: '浅色',
dark: '深色',
+7
View File
@@ -1297,10 +1297,17 @@ export const zhTW: typeof zhCN = {
ko: '한국어 (Beta)',
restartHint: '部分原生菜單(系統托盤等)可能需要重啓 App 纔會切換。',
},
layout: {
title: '布局',
},
theme: {
title: '外觀',
label: '主題',
activityHeatmapLabel: '概覽頁顯示年度活動熱力圖',
stackedRowLayoutLabel: '易讀布局(防溢出換行)',
stackedRowLayoutDesc: '小螢幕或大字時,同一行放不下的按鈕和選項會自動換到下一行,避免橫向擠出螢幕或文字被壓扁。',
conservativeLayoutLabel: '保守排版',
conservativeLayoutDesc: '除首頁、頂欄與底欄外,設定與功能頁改為單列滿寬,最大程度避免橫向溢出。',
system: '跟隨系統',
light: '淺色',
dark: '深色',
@@ -0,0 +1,14 @@
// 保守排版:除首页、顶栏、底栏与胶囊窗外,内容区强制单列满宽。
// 通过 html[data-ol-conservative-layout] + global.css 工具类生效。
export function applyConservativeLayout(enabled: boolean): void {
if (enabled) {
document.documentElement.dataset.olConservativeLayout = 'true';
} else {
delete document.documentElement.dataset.olConservativeLayout;
}
}
export function isConservativeLayoutActive(): boolean {
return document.documentElement.dataset.olConservativeLayout === 'true';
}
@@ -110,6 +110,8 @@ export let mockSettings: UserPreferences = {
streamingInsertSaveClipboard: true,
cursorContextEnabled: false,
showOverviewActivityHeatmap: true,
stackedRowLayout: false,
conservativeLayout: false,
autoUpdateCheck: true,
historyMaxEntries: null,
recordAudioForDebug: false,
@@ -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<string, string> = {};
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');
+20
View File
@@ -0,0 +1,20 @@
// 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出。
// 通过 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(
stackedRowLayout?: boolean,
): void {
applyStackedLayout(stackedRowLayout === true);
}
export function isStackedLayoutActive(): boolean {
return document.documentElement.dataset.olStackedLayout === 'true';
}
@@ -26,6 +26,8 @@ const previousPrefs: UserPreferences = {
selectionPolishOutputMode: 'directReplace',
cursorContextEnabled: false,
showOverviewActivityHeatmap: true,
stackedRowLayout: false,
conservativeLayout: false,
defaultMode: 'light',
enabledModes: ['raw', 'light', 'structured'],
activeStylePackId: '',
+4
View File
@@ -490,6 +490,10 @@ export interface UserPreferences {
cursorContextEnabled: boolean;
/** 概览页是否显示「年度活动」热力图卡。默认 true;关闭只隐藏卡片,活动计数照常记录。 */
showOverviewActivityHeatmap: boolean;
/** 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出。默认 false。 */
stackedRowLayout: boolean;
/** 保守排版:除首页、顶栏、底栏与胶囊窗外,内容区强制单列满宽。默认 false。 */
conservativeLayout: boolean;
/** 主窗口启动 + 后台每 60 分钟自动检查更新。默认 true。
* Android:开启后自动检查并下载,校验后打开系统安装器。
* 桌面:开启后自动检查,发现更新弹窗由用户确认安装。
+21 -1
View File
@@ -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,22 @@ 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 readable = useReadableLayout();
return mobile || readable;
}
/** 保守排版:仅读用户偏好,不与 mobile 联动。 */
export function useConservativeLayout(): boolean {
const { prefs } = useHotkeySettings();
return prefs?.conservativeLayout === true;
}
+13 -6
View File
@@ -48,12 +48,17 @@ import {
type MarketplaceInstallError,
} from '../lib/marketplaceInstall';
import { pickStylePackZipTargetPath, stylePackZipFileName } from '../lib/stylePackZip';
import { useMobileLayout, useLayoutStack, useConservativeLayout } 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 baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const stackLayout = conservative || baseLayoutStack;
const { prefs, updatePrefs } = useHotkeySettings();
// 启动时尝试读缓存:上次默认视图(popular + 空 query)的列表,秒呈现。后台 refresh 校准。
@@ -536,10 +541,12 @@ export function Marketplace() {
{/* 顶部搜索 + 排序 */}
<div
className="ol-flex-row"
style={{
display: 'flex',
gap: 10,
alignItems: 'center',
alignItems: stackLayout ? 'stretch' : 'center',
flexDirection: stackLayout ? 'column' : 'row',
padding: '4px 0 14px',
}}
>
@@ -571,7 +578,7 @@ export function Marketplace() {
}}
/>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<div className="ol-flex-row ol-flex-split" style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sortPills.map(p => (
<button
key={p.id}
@@ -642,7 +649,7 @@ export function Marketplace() {
</div>
</Card>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
<div className="ol-grid-auto-cards" style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
<AnimatePresence mode="sync">
{visibleItems.map(p => {
const isDownloading = downloadingPackId === p.id;
@@ -744,7 +751,7 @@ export function Marketplace() {
{/* 详情弹窗 */}
{selectedId && (
<Modal onClose={() => { setSelectedId(null); setInstallError(null); }}>
<Modal zIndex={mobile || stackLayout ? 70 : 50} onClose={() => { setSelectedId(null); setInstallError(null); }}>
{detailLoading || !detail ? (
<div
style={{
@@ -872,7 +879,7 @@ export function Marketplace() {
{/* 上传选包器 —— zIndex 60 让它叠在「我的发布」(zIndex 50) 之上 */}
{showUpload && (
<Modal
zIndex={60}
zIndex={mobile || stackLayout ? 70 : 60}
onClose={() => {
setShowUpload(false);
setUploadOriginPackId(null);
@@ -962,7 +969,7 @@ export function Marketplace() {
{/* 我的发布 · 弹框形态(叠在风格市场页面之上)*/}
{showMyPacks && (
<Modal onClose={() => setShowMyPacks(false)}>
<Modal zIndex={mobile || stackLayout ? 70 : 50} onClose={() => setShowMyPacks(false)}>
{/* 顶部一行:搜索 (左) + 用户名/登录 (中) + 关闭 × (右) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
{/* 搜索框 (最左) */}
+47 -21
View File
@@ -21,6 +21,7 @@ import { Btn, Card, PageHeader, Pill } from './_atoms';
import { Icon } from '../components/Icon';
import { SavedToast, type SaveToastState } from '../components/SavedToast';
import { pickStylePackZipTargetPath, stylePackZipFileName } from '../lib/stylePackZip';
import { useMobileLayout, useLayoutStack, useConservativeLayout } from '../lib/useMobileLayout';
type BusyAction =
| 'loading'
@@ -130,6 +131,10 @@ function modeTone(mode: PolishMode): 'default' | 'blue' | 'ok' | 'outline' | 'da
export function Style() {
const { t } = useTranslation();
const mobile = useMobileLayout();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const stackLayout = conservative || baseLayoutStack;
const { prefs: marketplacePrefs, updatePrefs: updateMarketplacePrefs } = useHotkeySettings();
const marketplaceDisplayLogin = (marketplacePrefs?.marketplaceDevLogin ?? '').trim();
const [marketplaceSignedIn, setMarketplaceSignedIn] = useState(false);
@@ -597,7 +602,15 @@ export function Style() {
title={t('style.pack.title')}
desc={t('style.pack.desc')}
right={(
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: 40 }}>
<div className="ol-flex-row ol-flex-push" style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
justifyContent: stackLayout ? 'flex-start' : 'flex-end',
marginTop: stackLayout ? 0 : 40,
width: stackLayout ? '100%' : undefined,
}}>
{/* 风格市场入口已移到侧栏「风格」展开组(用户拍板);此处不再放按钮。 */}
<Btn variant="ghost" icon="refresh" onClick={() => void loadPacks(selectedId)} disabled={busy === 'loading'}>
{t('common.refresh')}
@@ -614,8 +627,15 @@ export function Style() {
<SavedToast saveState={saveState} message={saveMessage} />
<Card padding={0} style={{ overflow: 'hidden', flex: '1 1 0', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ padding: stackLayout ? 14 : 18, borderBottom: '0.5px solid var(--ol-line)', flexShrink: 0 }}>
<div className="ol-flex-row" style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
flexDirection: stackLayout ? 'column' : 'row',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', minWidth: 0 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ol-ink)' }}>
@@ -655,7 +675,7 @@ export function Style() {
</button>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div className="ol-flex-row ol-flex-split" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0, width: stackLayout ? '100%' : undefined }}>
<div style={{ display: 'inline-flex', padding: 3, borderRadius: 8, background: 'var(--ol-surface-2)', border: '0.5px solid var(--ol-line)' }}>
{([
['dictation', t('style.pack.dictationTab')],
@@ -670,7 +690,7 @@ export function Style() {
</div>
</div>
<div className="ol-thinscroll" style={{ padding: 18, overflow: 'auto', flex: '1 1 0', minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
<div className="ol-grid-auto-cards" style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
<AnimatePresence mode="sync">
{bodyPacks.map(pack => {
const isBuiltin = pack.kind === 'builtin';
@@ -680,15 +700,12 @@ export function Style() {
return (
<motion.div
key={pack.id}
layout
// 仅当包列表增减时才重算 layout;切换 active(pack.active 变化)
// 不再触发整列卡片的 layout 重测,消除切换风格包时的卡顿。
layoutDependency={bodyPacks.length}
{...(!stackLayout ? { layout: true, layoutDependency: bodyPacks.length } : {})}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{
layout: { type: 'spring', damping: 25, stiffness: 220 },
...(stackLayout ? {} : { layout: { type: 'spring', damping: 25, stiffness: 220 } }),
opacity: { duration: 0.2 },
scale: { duration: 0.2 }
}}
@@ -823,11 +840,11 @@ export function Style() {
})}
<motion.button
key="add-new-pack-btn"
layout
{...(!stackLayout ? { layout: true } : {})}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
layout: { type: 'spring', damping: 25, stiffness: 220 },
...(stackLayout ? {} : { layout: { type: 'spring', damping: 25, stiffness: 220 } }),
opacity: { duration: 0.2 },
scale: { duration: 0.2 }
}}
@@ -876,6 +893,7 @@ export function Style() {
<>
<motion.div
aria-hidden="true"
className="ol-modal-backdrop"
onClick={closeEditor}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -885,9 +903,11 @@ export function Style() {
position: 'fixed',
inset: 0,
background: 'var(--ol-overlay-bg)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
zIndex: 40,
...(mobile ? {} : {
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
}),
zIndex: mobile ? 70 : 40,
}}
/>
<motion.div
@@ -898,7 +918,12 @@ export function Style() {
animate={{ x: 0, opacity: 1 }}
exit={{ x: '100%', opacity: 0 }}
transition={{ type: 'spring', damping: 26, stiffness: 280 }}
style={{
style={mobile ? {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 71,
} : {
position: 'fixed',
top: 16,
right: 16,
@@ -914,7 +939,8 @@ export function Style() {
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
overflow: 'hidden',
boxShadow: 'var(--ol-shadow-xl)',
boxShadow: mobile ? 'none' : 'var(--ol-shadow-xl)',
borderRadius: mobile ? 0 : undefined,
}}
>
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
@@ -997,7 +1023,7 @@ export function Style() {
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink)' }}>{t('style.pack.fieldName')}</span>
<input
@@ -1043,7 +1069,7 @@ export function Style() {
/>
</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink)' }}>{t('style.pack.fieldModel')}</span>
<input
@@ -1148,7 +1174,7 @@ export function Style() {
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ol-ink)' }}>{t('style.pack.metaTitle')}</div>
<Pill tone="default" size="sm">{draft.id}</Pill>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10 }}>
<MetaItem label={t('style.pack.metaSource')} value={draft.kind === 'builtin' ? t('style.pack.builtin') : t('style.pack.imported')} />
<MetaItem label={t('style.pack.metaBaseMode')} value={t(`style.modes.${draft.baseMode}.name`)} />
<MetaItem label={t('style.pack.metaUpdatedAt')} value={draft.updatedAt || '—'} />
@@ -1207,7 +1233,7 @@ export function Style() {
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: stackLayout ? '1fr' : 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
<div
style={{
borderRadius: 14,
+18 -6
View File
@@ -4,7 +4,7 @@
import { useState, type CSSProperties, type ReactNode } from 'react';
import { Icon } from '../components/Icon';
import { useMobileLayout } from '../lib/useMobileLayout';
import { useMobileLayout, useReadableLayout, useConservativeLayout } from '../lib/useMobileLayout';
interface PageHeaderProps {
kicker?: string;
@@ -16,14 +16,18 @@ interface PageHeaderProps {
export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeaderProps) {
const mobile = useMobileLayout();
const readable = useReadableLayout();
const conservative = useConservativeLayout();
const preferenceStack = readable || conservative;
const stackLayout = mobile || preferenceStack;
return (
<div style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: mobile ? 12 : 24,
marginBottom: mobile ? 16 : 24,
flexWrap: mobile ? 'wrap' : 'nowrap',
gap: stackLayout ? 12 : mobile ? 12 : 24,
marginBottom: stackLayout ? 16 : mobile ? 16 : 24,
flexWrap: stackLayout ? 'wrap' : 'nowrap',
}}>
<div style={{ minWidth: 0 }}>
{kicker && (
@@ -33,9 +37,17 @@ export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeade
<h1 style={{ margin: 0, fontSize: mobile ? 22 : 26, fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ol-ink)' }}>{title}</h1>
{titleRight}
</div>
{desc && <p style={{ margin: '8px 0 0', fontSize: 13, color: 'var(--ol-ink-3)', maxWidth: 640, lineHeight: 1.55 }}>{desc}</p>}
{desc && <p style={{ margin: '8px 0 0', fontSize: 13, color: 'var(--ol-ink-3)', maxWidth: preferenceStack ? undefined : 640, lineHeight: 1.55 }}>{desc}</p>}
</div>
{right}
{right && (
preferenceStack ? (
<div className="ol-flex-row ol-flex-split" style={{ width: '100%' }}>
{right}
</div>
) : (
right
)
)}
</div>
);
}
@@ -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<PlatformCapabilities | null>(null);
const qqCopiedRef = useRef<number | null>(null);
@@ -42,21 +47,43 @@ export function AboutSection() {
<>
{/* ─── 版本信息 + 检查更新(正式版)─────────────────────────────── */}
<Card>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div
className="ol-inline-composite"
style={{
display: 'flex',
alignItems: 'center',
gap: compactLayout ? 4 : 14,
minWidth: 0,
flexWrap: 'nowrap',
}}
>
<img
src="AppIcon.png"
alt=""
style={{ width: 56, height: 56, borderRadius: 13, boxShadow: '0 4px 10px rgba(0,0,0,.10), 0 0 0 0.5px rgba(0,0,0,.06)' }}
style={{
width: appIconSize,
height: appIconSize,
flexShrink: 0,
borderRadius: 13,
boxShadow: '0 4px 10px rgba(0,0,0,.10), 0 0 0 0.5px rgba(0,0,0,.06)',
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 600 }}>OpenLess</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 2 }}>
<div style={{ fontSize: 17, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>OpenLess</div>
<div style={{
fontSize: 12,
color: 'var(--ol-ink-3)',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{t('modal.about.tagline')} · {APP_VERSION_LABEL}
</div>
</div>
{/* 图标右上方:查正式版的检查更新按钮。Beta 渠道在「高级」页。 */}
{platformCaps?.supportsAutoUpdate === true && (
<CheckUpdateButton channel="stable" />
<CheckUpdateButton channel="stable" compact={compactLayout} />
)}
</div>
</Card>
@@ -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)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0, flex: 1, width: preferenceStack ? '100%' : undefined }}>
<span
onPointerDown={e => onDragHandleDown(e, channel.id)}
onClick={e => e.stopPropagation()}
@@ -500,6 +505,18 @@ export function ChannelList({
{channel.lastTest && <span>{relativeTime(channel.lastTest.at, t)}</span>}
</div>
</div>
</div>
<div
className={conservative ? 'ol-conservative-stack' : undefined}
style={{
display: 'flex',
flexDirection: conservative ? 'column' : 'row',
alignItems: conservative ? 'flex-start' : 'center',
gap: 8,
flexWrap: readable ? 'wrap' : 'nowrap',
width: preferenceStack ? '100%' : undefined,
}}
>
<VerifyButton
channel={channel}
testing={Boolean(testingIds[channel.id])}
@@ -512,11 +529,13 @@ export function ChannelList({
onClick={() => 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 } : {}) }}
>
<Icon name="chevRight" size={13} />
{conservative && <span style={{ fontSize: 12 }}>{t('settings.channels.edit')}</span>}
</button>
</div>
</div>
);
})}
</div>
@@ -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 }) {
<button
onClick={() => void updater.checkForUpdates(channel)}
disabled={checking || busy}
aria-label={compact ? label : undefined}
title={
failed
? (updater.errorMessage ?? t('settings.about.updateError'))
: upToDate
? t('settings.about.upToDate')
: undefined
: compact
? label
: undefined
}
// 固定宽度 + 内容居中:图标(check ↔ refresh)与 label("检查更新" ↔ "检查中…")
// 切换时按钮尺寸不再跳动;图标固定容器保证旋转时绕中心转、不位移。
style={{ ...btnGhostStyle, color, opacity: checking || busy ? 0.7 : 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, minWidth: 160 }}
// 桌面保留稳定文字宽度;紧凑布局使用带可访问名称的图标按钮。
style={{
...btnGhostStyle,
color,
opacity: checking || busy ? 0.7 : 1,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
boxSizing: 'border-box',
width: compact ? 32 : undefined,
minWidth: compact ? 32 : 160,
maxWidth: compact ? 32 : '100%',
minHeight: compact ? 32 : undefined,
padding: compact ? 5 : btnGhostStyle.padding,
flexShrink: 0,
}}
>
<span style={{ display: 'inline-flex', width: 14, height: 14, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Icon
@@ -62,6 +79,7 @@ export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
<span
key={label}
style={{
display: compact ? 'none' : undefined,
whiteSpace: 'nowrap',
// 状态文案("检查更新" ↔ "检查中…" ↔ 结果提示)切换时淡入微滑移,
// 与 SelectLite 选中值切换动画同款(ol-select-value-in,global.css)。
@@ -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 (
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>
{t('settings.layout.title')}
</div>
<SettingRow
label={t('settings.theme.stackedRowLayoutLabel')}
desc={t('settings.theme.stackedRowLayoutDesc')}
>
<Toggle
on={prefs.stackedRowLayout === true}
onToggle={next =>
void updatePrefs(current => ({ ...current, stackedRowLayout: next }))
}
/>
</SettingRow>
<SettingRow
label={t('settings.theme.conservativeLayoutLabel')}
desc={t('settings.theme.conservativeLayoutDesc')}
>
<Toggle
on={prefs.conservativeLayout === true}
onToggle={next =>
void updatePrefs(current => ({ ...current, conservativeLayout: next }))
}
/>
</SettingRow>
</Card>
);
}
@@ -1,7 +1,7 @@
// 权限/连通性面板:麦克风 / 辅助功能 / 全局热键 / Windows IME / 网络。
// 内含三个状态 Pill + 适配器名称翻译辅助函数。
import { useEffect, useState } from 'react';
import { useEffect, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { AndroidPermissionsPanel } from '@android/components/AndroidPermissionsPanel';
import { Icon } from '../../components/Icon';
@@ -19,6 +19,7 @@ import {
import type { NetworkCheckResult } from '../../lib/ipc';
import { emitSaved } from '../../lib/savedEvent';
import { getPlatformCapabilities } from '../../lib/platform';
import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout';
import { checkAndroidMicrophoneAccess, requestAndroidMicrophoneAccess } from '@android/lib/androidMicrophonePermission';
import type {
HotkeyStatus,
@@ -41,6 +42,17 @@ export function PermissionsSection() {
const [network, setNetwork] = useState<NetworkCheckResult | null>(null);
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
const { capability } = useHotkeySettings();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const stackLayout = baseLayoutStack || conservative;
const permissionActionsStyle: CSSProperties = {
display: 'flex',
flexDirection: conservative ? 'column' : 'row',
alignItems: conservative ? 'flex-start' : 'center',
justifyContent: stackLayout ? 'flex-start' : 'flex-end',
width: '100%', flexWrap: conservative ? 'nowrap' : 'wrap', minWidth: 0, gap: 8,
};
useEffect(() => {
void getPlatformCapabilities().then(setPlatformCaps);
@@ -152,7 +164,7 @@ export function PermissionsSection() {
)}
</div>
<SettingRow label={t('settings.permissions.micLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
<div style={permissionActionsStyle}>
<PermissionPill status={microphone} />
{microphone === 'noDevice' ? (
<Btn variant="ghost" size="sm" onClick={refreshPermissions}>
@@ -167,7 +179,7 @@ export function PermissionsSection() {
</SettingRow>
{capability?.requiresAccessibilityPermission && platformCaps?.platform !== 'android' && (
<SettingRow label={t('settings.permissions.accLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
<div style={permissionActionsStyle}>
<PermissionPill status={accessibility} />
{accessibility !== 'granted' && accessibility !== 'notApplicable' && accessibility !== 'loading' && (
<Btn variant="ghost" size="sm" onClick={reRequestAccessibility}>
@@ -189,7 +201,7 @@ export function PermissionsSection() {
)}
{platformCaps?.supportsDesktopHotkey === true && (
<SettingRow label={t('settings.permissions.hotkeyLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap' }}>
<div style={permissionActionsStyle}>
{hotkey?.message && (
<span style={{
fontSize: 11.5, color: 'var(--ol-ink-4)',
@@ -208,7 +220,7 @@ export function PermissionsSection() {
)}
{windowsIme?.state !== 'notWindows' && platformCaps?.platform !== 'android' && (
<SettingRow label={t('settings.permissions.windowsImeLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap' }}>
<div style={permissionActionsStyle}>
{windowsIme && (
<span style={{
fontSize: 11.5, color: 'var(--ol-ink-4)',
@@ -223,7 +235,7 @@ export function PermissionsSection() {
</SettingRow>
)}
<SettingRow label={t('settings.permissions.networkLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
<div style={permissionActionsStyle}>
{network && network.latencyMs != null && (
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
{network.latencyMs}ms
@@ -14,7 +14,7 @@ import {
validateProviderCredentials,
} from '../../lib/ipc';
import { emitSaved } from '../../lib/savedEvent';
import { useMobileLayout } 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,18 +55,20 @@ function isLocalAsrPreset(id: string): boolean {
function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) {
const { t } = useTranslation();
const mobile = useMobileLayout();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const layoutStack = conservative || baseLayoutStack;
return (
<div
title={t('settings.providers.thinkingModeHint')}
style={{
display: 'flex',
alignItems: 'center',
flex: mobile ? '1 1 100%' : undefined,
flexWrap: mobile ? 'wrap' : 'nowrap',
flex: layoutStack ? '1 1 100%' : undefined,
flexWrap: layoutStack ? 'wrap' : 'nowrap',
gap: 6,
paddingLeft: 2,
whiteSpace: mobile ? 'normal' : 'nowrap',
whiteSpace: layoutStack ? 'normal' : 'nowrap',
}}
>
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
@@ -304,7 +306,9 @@ export function ChannelCredentialFields({
}) {
const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
const mobile = useMobileLayout();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const layoutStack = conservative || baseLayoutStack;
const [llmModelRevision, setLlmModelRevision] = useState(0);
const [asrModelRevision, setAsrModelRevision] = useState(0);
const unifiedBailian = providerType === 'bailian';
@@ -421,7 +425,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 }}
/>
</SettingRow>
{/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key)
@@ -739,7 +743,9 @@ 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 baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const layoutStack = conservative || baseLayoutStack;
const [models, setModels] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [status, setStatus] = useState<ProviderToolStatus>('idle');
@@ -827,7 +833,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
return (
<SettingRow label={t('settings.providers.toolsLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: mobile ? '100%' : 420 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: layoutStack ? '100%' : 420 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', width: '100%' }}>
<button onClick={validate} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.validate')}</button>
{showFetchModels && (
@@ -841,7 +847,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 }}
/>
)}
</div>
@@ -904,7 +910,9 @@ interface CredentialFieldProps {
function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options }: CredentialFieldProps) {
const { t } = useTranslation();
const mobile = useMobileLayout();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const layoutStack = conservative || baseLayoutStack;
const [value, setValue] = useState('');
const [revealed, setRevealed] = useState(false);
const [loaded, setLoaded] = useState(false);
@@ -1042,8 +1050,8 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
return (
<SettingRow label={label}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, width: '100%', maxWidth: mobile ? '100%' : 420 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', flexWrap: mobile ? 'wrap' : 'nowrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, width: '100%', maxWidth: layoutStack ? '100%' : 420 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', flexWrap: layoutStack ? 'wrap' : 'nowrap' }}>
{options && !customModelMode ? (
<SelectLite
value={value}
@@ -1068,7 +1076,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
placeholder={loaded ? placeholder : t('common.loading')}
disabled={disabled}
ariaLabel={label}
style={{ flex: mobile ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
style={{ flex: layoutStack ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
/>
) : (
<input
@@ -1078,7 +1086,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
onChange={handleChange}
onBlur={onBlur}
disabled={disabled}
style={{ ...inputStyle, flex: mobile ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
style={{ ...inputStyle, flex: layoutStack ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
/>
)}
{options && customModelMode && (
@@ -1169,7 +1177,9 @@ const iconBtnStyle: CSSProperties = {
*/
export function OmniChannelSection() {
const { t } = useTranslation();
const mobile = useMobileLayout();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const layoutStack = conservative || baseLayoutStack;
const { prefs, updatePrefs } = useHotkeySettings();
const [omniProvider, setOmniProvider] = useState<OmniPresetId>('custom');
const [committedOmniProvider, setCommittedOmniProvider] = useState<OmniPresetId>('custom');
@@ -1246,7 +1256,7 @@ export function OmniChannelSection() {
label={t('settings.providers.pipelineModeLabel')}
desc={t('settings.providers.pipelineModeHint')}
>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: mobile ? 'wrap' : 'nowrap' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: layoutStack ? 'wrap' : 'nowrap' }}>
<div style={segmentedTrackStyle}>
{(['traditional', 'multimodal'] as const).map(mode => (
<button
@@ -1287,7 +1297,7 @@ export function OmniChannelSection() {
label: t(`settings.providers.presets.${p.nameKey}`),
}))}
ariaLabel={t('settings.providers.providerLabel')}
style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 200 }}
style={{ ...inputStyle, width: '100%', maxWidth: layoutStack ? '100%' : 200 }}
/>
</SettingRow>
<CredentialField
+15 -7
View File
@@ -3,7 +3,7 @@
import type { CSSProperties, ReactNode } from "react"
import { Tooltip } from "../../components/Tooltip"
import { useMobileLayout } from "../../lib/useMobileLayout"
import { useMobileLayout, useReadableLayout, useConservativeLayout } from "../../lib/useMobileLayout"
// 带说明的文字统一加虚线下划线 + help 光标,暗示「悬停可看解释」。
const hintableTextStyle: CSSProperties = {
@@ -68,6 +68,9 @@ export function SettingRow({
controlWidth,
}: SettingRowProps) {
const mobile = useMobileLayout()
const readable = useReadableLayout()
const conservative = useConservativeLayout()
const stackLayout = mobile || readable || conservative
const labelStyle: CSSProperties = {
fontSize: 13,
fontWeight: 500,
@@ -78,9 +81,9 @@ export function SettingRow({
<div
style={{
display: "grid",
gridTemplateColumns: mobile ? "minmax(0, 1fr)" : "minmax(0, 180px) minmax(0, 1fr)",
gap: mobile ? 8 : 16,
padding: mobile ? "12px 0" : "14px 0",
gridTemplateColumns: stackLayout ? "minmax(0, 1fr)" : "minmax(0, 180px) minmax(0, 1fr)",
gap: stackLayout ? 8 : 16,
padding: stackLayout ? "12px 0" : "14px 0",
borderTop: "0.5px solid var(--ol-line-soft)",
alignItems: "center",
}}
@@ -96,14 +99,16 @@ export function SettingRow({
)}
</div>
<div
className="ol-flex-row"
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
minWidth: 0,
width: mobile ? "100%" : controlWidth ?? "auto",
width: stackLayout ? "100%" : controlWidth ?? "auto",
maxWidth: "100%",
flexWrap: mobile ? "wrap" : "nowrap",
gap: mobile ? 6 : undefined,
flexWrap: stackLayout ? "wrap" : "nowrap",
gap: stackLayout ? 6 : undefined,
}}
>
{children}
@@ -124,7 +129,10 @@ export function Toggle({
onClick={() => onToggle?.(!on)}
style={{
position: "relative",
flex: "0 0 36px",
width: 36,
minWidth: 36,
maxWidth: 36,
height: 20,
borderRadius: 999,
border: 0,
@@ -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 (
<>
<RecordingInputSection />
<LayoutSection />
{showRemoteInput && <RemoteInputSection />}
<SelectionPolishSection />
{showDesktopShortcuts && <ShortcutsSection />}
@@ -21,6 +21,8 @@ import type {
} from "../lib/types"
import i18n, { outputPrefsForLocale, type SupportedLocale } from "../i18n"
import { applyThemeFromPreference } from "../lib/themeMode"
import { applyStackedLayoutFromPrefs } from "../lib/stackedLayout"
import { applyConservativeLayout } from "../lib/conservativeLayout"
import { emitSaved } from "../lib/savedEvent"
interface HotkeySettingsContextValue {
@@ -65,6 +67,8 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
persistedPrefsRef.current = prefsResult.value
setPrefs(prefsResult.value)
applyThemeFromPreference(prefsResult.value.themeMode ?? "system")
applyStackedLayoutFromPrefs(prefsResult.value.stackedRowLayout)
applyConservativeLayout(prefsResult.value.conservativeLayout === true)
} else {
console.error(
"[hotkey-settings] failed to load preferences",
@@ -126,6 +130,8 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
persistedPrefsRef.current = nextPrefs
setPrefs(nextPrefs)
applyThemeFromPreference(nextPrefs.themeMode ?? "system")
applyStackedLayoutFromPrefs(nextPrefs.stackedRowLayout)
applyConservativeLayout(nextPrefs.conservativeLayout === true)
},
)
if (cancelled) {
@@ -200,6 +206,8 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
if (resolved === current) return
setPrefs(resolved)
latestPrefsRef.current = resolved
applyStackedLayoutFromPrefs(resolved.stackedRowLayout)
applyConservativeLayout(resolved.conservativeLayout === true)
try {
await queueSetSettings(resolved)
persistedPrefsRef.current = resolved
+72
View File
@@ -204,6 +204,78 @@ html[data-ol-platform="android"] .ol-frost::before {
content: none;
}
/* Android WebView:modal 遮罩禁用 backdrop-filter,避免触摸迟滞与渲染异常。 */
html[data-ol-platform="android"] .ol-modal-backdrop {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* ── 易读布局(data-ol-stacked-layout)──────────────────────────────────────────
小屏或大字号时强制同行控件换行,避免横向溢出与 flex 压扁文字。 */
.ol-flex-row {
display: flex;
min-width: 0;
flex-wrap: nowrap;
align-items: center;
}
html[data-ol-stacked-layout="true"] .ol-flex-row {
flex-wrap: wrap;
align-items: flex-start;
}
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 {
flex: 0 0 auto;
max-width: 100%;
}
html[data-ol-stacked-layout="true"] .ol-grid-auto-cards {
grid-template-columns: 1fr !important;
}
html[data-ol-stacked-layout="true"] .ol-console-main {
overflow-x: hidden;
}
/* ── 保守排版(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-conservative-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
min-width: 0;
}
html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-conservative-stack > * {
max-width: 100%;
min-width: 0;
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 .ol-conservative-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);
}