feat(ui): add mobile portrait shell with bottom nav and responsive pages

Replace fixed sidebar on narrow/Android with top bar, bottom tabs, and more sheet; adapt Overview, History, Vocab, and Settings for portrait; add android-ui-verify.ps1.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HKLHaoBinandCursor committed 2026-06-13 23:13:39 +08:00
1 parent 24049d9472
commit 5e2663b26b
15 files changed
+634 -55

No files matched your search

@@ -0,0 +1,86 @@
# android-ui-verify.ps1 — Trigger CI Android debug APK build (optional), install via adb,
# capture logcat, and save UI screenshots. Artifacts stay local; do not commit outputs.
param(
[string]$Repo = "HKLHaoBin/openless",
[string]$Branch = "feat/mobile-portrait-ui",
[string]$Workflow = "Android APK (debug)",
[string]$ArtifactName = "openless-android-debug-arm64-v8a",
[switch]$SkipBuild,
[int]$StartupWaitSeconds = 8,
[string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\\..\\..")).Path
)
$ErrorActionPreference = "Stop"
Set-Location $ProjectRoot
function Require-Command($name) {
if (-not (Get-Command $name -ErrorAction SilentlyContinue)) {
throw "Required command not found: $name"
}
}
Require-Command adb
if (-not $SkipBuild) {
Require-Command gh
}
$devices = adb devices | Select-String "device$"
if (-not $devices) {
throw "No adb device in 'device' state. Connect phone and enable USB debugging."
}
$ciDir = Join-Path $ProjectRoot "ci-artifacts"
$shotDir = Join-Path $ProjectRoot "android-screenshots"
New-Item -ItemType Directory -Force -Path $ciDir, $shotDir | Out-Null
if (-not $SkipBuild) {
Write-Host "Triggering workflow '$Workflow' on $Repo ref $Branch ..."
gh workflow run $Workflow --repo $Repo --ref $Branch
Start-Sleep -Seconds 5
$runLine = gh run list --repo $Repo --workflow $Workflow --limit 1 --json databaseId,status --jq '.[0]'
$run = $runLine | ConvertFrom-Json
if (-not $run.databaseId) {
throw "Could not resolve latest workflow run id."
}
$runId = $run.databaseId
Write-Host "Watching run $runId ..."
gh run watch $runId --repo $Repo --exit-status
if (Test-Path $ciDir) {
Get-ChildItem $ciDir -File | Remove-Item -Force
}
gh run download $runId --repo $Repo --name $ArtifactName --dir $ciDir
}
$apk = Get-ChildItem -Path $ciDir -Filter "OpenLess-android-debug-*.apk" -File | Select-Object -First 1
if (-not $apk) {
throw "No APK found in $ciDir. Run without -SkipBuild or place an APK there."
}
Write-Host "Installing $($apk.FullName) ..."
adb logcat -c
adb install -r $apk.FullName
Write-Host "Starting MainActivity ..."
adb shell am start -n com.openless.app/.MainActivity
Start-Sleep -Seconds $StartupWaitSeconds
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$crashOnly = Join-Path $ProjectRoot "android-crash-only.log"
$fullLog = Join-Path $ProjectRoot "android-crash.log"
adb logcat -b crash -d -v time | Set-Content -Path $crashOnly -Encoding utf8
adb logcat -d -v time | Set-Content -Path $fullLog -Encoding utf8
adb shell dumpsys package com.openless.app | Set-Content -Path (Join-Path $ProjectRoot "openless-package.txt") -Encoding utf8
function Capture-Screen([string]$label) {
$out = Join-Path $shotDir "openless-screenshot-$label-$stamp.png"
$bytes = adb exec-out screencap -p
if ($bytes) {
[System.IO.File]::WriteAllBytes($out, $bytes)
Write-Host "Screenshot: $out"
}
}
Capture-Screen "overview"
Write-Host "Capture additional screens manually or extend script with adb input tap coordinates."
Write-Host "Logs: $fullLog"
Write-Host "Filter: Select-String -Path '$fullLog' -Pattern 'FATAL EXCEPTION','panic','UnsatisfiedLinkError'"
+237 -18
View File
@@ -4,7 +4,7 @@
//
// Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell.
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { WindowChrome, detectOS, type OS } from './WindowChrome';
@@ -32,8 +32,12 @@ import {
shouldShowProviderSetupPrompt,
} from '../lib/providerSetup';
import { type SettingsSectionId } from './SettingsModal';
import { MobileMoreSheet } from './MobileMoreSheet';
import { useMobileLayout } from '../lib/useMobileLayout';
import { useAppState, type AppTab } from '../state/useAppState';
const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk'];
interface NavItem {
id: AppTab;
name: string;
@@ -67,10 +71,12 @@ 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 { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings);
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
const [providerPromptOpen, setProviderPromptOpen] = useState(false);
const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
// tab 切换的 cross-fade:旧页 blur+fade out(180ms),结束后挂载新页(走 ol-page-slide enter)。
// displayTab 是实际渲染的 tab,currentTab 是用户点中的目标 tab。
@@ -155,6 +161,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
const openSettings = (section?: SettingsSectionId) => {
setSettingsInitialSection(section);
setSettingsOpen(true);
setMoreOpen(false);
};
// ⌘, 打开设置页面
@@ -180,8 +187,22 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
openSettings('general');
};
const mobileTitle = settingsOpen
? t('shell.footer.settings')
: (NAV.find(n => n.id === currentTab)?.name ?? t('nav.overview'));
const moreTabActive = MORE_TAB_IDS.includes(currentTab);
const useOpaqueMain = mobile || os === 'linux';
return (
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: os === 'mac' ? 28 : 0 }}>
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: mobile ? 0 : os === 'mac' ? 28 : 0 }}>
{mobile && (
<MobileTopBar
title={mobileTitle}
onOpenSettings={() => openSettings()}
settingsActive={settingsOpen}
/>
)}
{/* Main shell — flush with the frosted backplate (no separate float). */}
<div
@@ -194,7 +215,8 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
zIndex: 1,
}}>
{/* Sidebar — 透明地坐在外层磨砂底板上,让 LOGO/导航/快捷键/BETA/footer 共用同一片磨砂玻璃 */}
{/* Sidebar — desktop / wide only */}
{!mobile && (
<aside
style={{
width: 188,
@@ -319,21 +341,21 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
</button>
</div>
</aside>
)}
{/* Main content — Linux 禁用透明窗口后使用不透明面;其他平台保留玻璃层。
悬浮台到右边 / 下边的间距相等(都 8px),左侧贴 sidebar(0)。 */}
<div style={{ flex: 1, minWidth: 0, padding: '4px 8px 8px 0', display: 'flex' }}>
{/* Main content — Linux 禁用透明窗口后使用不透明面;mobile 全宽无玻璃层。 */}
<div style={{ flex: 1, minWidth: 0, padding: mobile ? 0 : '4px 8px 8px 0', display: 'flex' }}>
<main
className="ol-console-main"
style={{
flex: 1, minWidth: 0,
overflow: 'hidden',
background: os === 'linux' ? 'var(--ol-surface)' : 'rgba(255, 255, 255, 0.62)',
backdropFilter: os === 'linux' ? 'none' : 'blur(18px) saturate(170%)',
WebkitBackdropFilter: os === 'linux' ? 'none' : 'blur(18px) saturate(170%)',
borderRadius: 'var(--ol-window-console-radius)',
border: '0.5px solid rgba(0,0,0,0.06)',
boxShadow: '0 1px 0 rgba(255,255,255,0.8) inset, 0 8px 24px -12px rgba(15,17,22,0.10), 0 2px 6px -2px rgba(15,17,22,0.06)',
background: useOpaqueMain ? 'var(--ol-surface)' : 'rgba(255, 255, 255, 0.62)',
backdropFilter: useOpaqueMain ? 'none' : 'blur(18px) saturate(170%)',
WebkitBackdropFilter: useOpaqueMain ? 'none' : 'blur(18px) saturate(170%)',
borderRadius: mobile ? 0 : 'var(--ol-window-console-radius)',
border: mobile ? 'none' : '0.5px solid rgba(0,0,0,0.06)',
boxShadow: mobile ? 'none' : '0 1px 0 rgba(255,255,255,0.8) inset, 0 8px 24px -12px rgba(15,17,22,0.10), 0 2px 6px -2px rgba(15,17,22,0.06)',
display: 'flex',
flexDirection: 'column',
}}
@@ -358,15 +380,20 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
style={{
flex: 1, minHeight: 0,
overflow: 'auto',
padding: '24px 28px 32px',
padding: mobile
? '16px 16px calc(16px + env(safe-area-inset-bottom, 0px) + 56px)'
: '24px 28px 32px',
// position:relative 让页面里的"已保存"toast 用 absolute top:16 right:16
// 锚到这块控制台卡的右上角,而不是横在页头变成长横幅。
position: 'relative',
// 苹果"spring out"风格的曲线:开始快、收尾顺滑,符合人体直觉
animation: tabPhase === 'exiting'
? 'ol-page-fadeout 0.18s var(--ol-motion-soft) forwards'
: 'ol-page-slide 0.34s var(--ol-motion-spring) both',
willChange: 'opacity, transform, filter',
animation: mobile
? (tabPhase === 'exiting'
? 'ol-page-fadeout-mobile 0.18s var(--ol-motion-soft) forwards'
: 'ol-page-fade-mobile 0.22s var(--ol-motion-soft) both')
: (tabPhase === 'exiting'
? 'ol-page-fadeout 0.18s var(--ol-motion-soft) forwards'
: 'ol-page-slide 0.34s var(--ol-motion-spring) both'),
willChange: mobile ? 'opacity' : 'opacity, transform, filter',
display: 'flex',
flexDirection: 'column',
}}
@@ -381,6 +408,29 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
</div>
</div>
{mobile && (
<>
<MobileBottomNav
currentTab={currentTab}
moreOpen={moreOpen}
moreTabActive={moreTabActive}
settingsOpen={settingsOpen}
onSelectTab={id => {
setMoreOpen(false);
setCurrentTab(id);
}}
onOpenMore={() => setMoreOpen(true)}
/>
<MobileMoreSheet
open={moreOpen}
currentTab={currentTab}
onClose={() => setMoreOpen(false)}
onSelectTab={setCurrentTab}
onOpenSettings={() => openSettings()}
/>
</>
)}
{/* Settings modal — rendered inside this window */}
{settingsOpen &&
<SettingsModal
@@ -432,6 +482,22 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
from { opacity: 1; filter: blur(0); }
to { opacity: 0; filter: blur(8px); }
}
@keyframes ol-page-fade-mobile {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes ol-page-fadeout-mobile {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes ol-mobile-sheet-backdrop {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes ol-mobile-sheet-up {
from { opacity: 0; transform: translate3d(0, 12px, 0); }
to { opacity: 1; transform: translate3d(0, 0, 0); }
}
@keyframes ol-prompt-fade {
from { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); }
to { opacity: 1; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
@@ -445,6 +511,159 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
);
}
const MOBILE_BOTTOM_TABS: Array<{ id: AppTab; icon: string }> = [
{ id: 'overview', icon: 'overview' },
{ id: 'history', icon: 'history' },
{ id: 'style', icon: 'style' },
];
function MobileTopBar({
title,
onOpenSettings,
settingsActive,
}: {
title: string;
onOpenSettings: () => void;
settingsActive: boolean;
}) {
const { t } = useTranslation();
return (
<header
style={{
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: 'calc(10px + env(safe-area-inset-top, 0px)) 14px 10px',
borderBottom: '0.5px solid var(--ol-line-soft)',
background: 'var(--ol-surface)',
zIndex: 2,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<img
src="AppIcon.png"
alt=""
style={{ width: 22, height: 22, borderRadius: 5, flexShrink: 0 }}
/>
<span
style={{
fontSize: 16,
fontWeight: 600,
letterSpacing: '-0.02em',
color: 'var(--ol-ink)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{title}
</span>
</div>
<button
type="button"
onClick={onOpenSettings}
aria-label={t('shell.footer.settings')}
className={settingsActive ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={{
width: 36,
height: 36,
flexShrink: 0,
border: 0,
borderRadius: 10,
background: settingsActive ? 'var(--ol-surface-2)' : 'transparent',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'default',
}}
>
<Icon name="settings" size={18} />
</button>
</header>
);
}
function MobileBottomNav({
currentTab,
moreOpen,
moreTabActive,
settingsOpen,
onSelectTab,
onOpenMore,
}: {
currentTab: AppTab;
moreOpen: boolean;
moreTabActive: boolean;
settingsOpen: boolean;
onSelectTab: (tab: AppTab) => void;
onOpenMore: () => void;
}) {
const { t } = useTranslation();
const moreActive = moreOpen || moreTabActive;
return (
<nav
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
zIndex: 55,
display: 'flex',
alignItems: 'stretch',
justifyContent: 'space-around',
gap: 4,
padding: '6px 8px calc(6px + env(safe-area-inset-bottom, 0px))',
borderTop: '0.5px solid var(--ol-line-soft)',
background: 'var(--ol-surface)',
}}
>
{MOBILE_BOTTOM_TABS.map(tab => {
const active = !settingsOpen && currentTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => onSelectTab(tab.id)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={mobileNavBtnStyle}
>
<Icon name={tab.icon} size={18} />
<span style={{ fontSize: 10.5, fontWeight: active ? 600 : 500 }}>{t(`nav.${tab.id}`)}</span>
</button>
);
})}
<button
type="button"
onClick={onOpenMore}
className={moreActive ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={mobileNavBtnStyle}
>
<Icon name="more" size={18} />
<span style={{ fontSize: 10.5, fontWeight: moreActive ? 600 : 500 }}>{t('nav.more')}</span>
</button>
</nav>
);
}
const mobileNavBtnStyle: CSSProperties = {
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
padding: '6px 4px',
border: 0,
borderRadius: 10,
background: 'transparent',
fontFamily: 'inherit',
cursor: 'default',
};
function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; onOpenSettings: () => void }) {
const { t } = useTranslation();
return (
+1
View File
@@ -51,6 +51,7 @@ export const ICONS: Record<string, string> = {
shield: 'M12 22s8-3 8-9V5l-8-3-8 3v8c0 6 8 9 8 9z', // Shield (privacy)
external: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3', // External link
close: 'M18 6L6 18M6 6l12 12', // Close / X
more: 'M5 12h.01M12 12h.01M19 12h.01', // More (horizontal dots)
play: 'M5 3l14 9-14 9V3z', // Play
download: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3', // Download
};
@@ -0,0 +1,129 @@
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import type { AppTab } from '../state/useAppState';
const MORE_TABS: Array<{ id: AppTab; icon: string }> = [
{ id: 'vocab', icon: 'vocab' },
{ id: 'translation', icon: 'translate' },
{ id: 'selectionAsk', icon: 'selectionAsk' },
];
interface MobileMoreSheetProps {
open: boolean;
currentTab: AppTab;
onClose: () => void;
onSelectTab: (tab: AppTab) => void;
onOpenSettings: () => void;
}
export function MobileMoreSheet({
open,
currentTab,
onClose,
onSelectTab,
onOpenSettings,
}: MobileMoreSheetProps) {
const { t } = useTranslation();
if (!open) return null;
return (
<div
onClick={onClose}
style={{
position: 'absolute',
inset: 0,
zIndex: 60,
background: 'rgba(15,17,22,0.32)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
animation: 'ol-mobile-sheet-backdrop 0.2s var(--ol-motion-soft)',
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
background: 'var(--ol-surface)',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
border: '0.5px solid var(--ol-line)',
padding: '12px 12px calc(12px + env(safe-area-inset-bottom, 0px))',
boxShadow: '0 -8px 32px -8px rgba(15,17,22,0.18)',
animation: 'ol-mobile-sheet-up 0.26s var(--ol-motion-spring)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 8px 12px' }}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)' }}>{t('nav.more')}</span>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
style={iconBtnStyle}
>
<Icon name="close" size={16} />
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{MORE_TABS.map(item => {
const active = currentTab === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => {
onSelectTab(item.id);
onClose();
}}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={rowBtnStyle}
>
<Icon name={item.icon} size={16} />
<span style={{ flex: 1 }}>{t(`nav.${item.id}`)}</span>
</button>
);
})}
<button
type="button"
onClick={() => {
onOpenSettings();
onClose();
}}
className="ol-nav-btn"
style={rowBtnStyle}
>
<Icon name="settings" size={16} />
<span style={{ flex: 1 }}>{t('shell.footer.settings')}</span>
</button>
</div>
</div>
</div>
);
}
const iconBtnStyle: CSSProperties = {
width: 32,
height: 32,
border: 0,
borderRadius: 999,
background: 'transparent',
color: 'var(--ol-ink-3)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'default',
};
const rowBtnStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 14px',
borderRadius: 10,
border: 0,
background: 'transparent',
fontFamily: 'inherit',
fontSize: 14,
cursor: 'default',
textAlign: 'left',
};
+128 -24
View File
@@ -7,12 +7,13 @@
// 设计原则:每个可见控件都必须可用。没有后端支撑的占位(账号 / 主题切换 等)
// 不在此弹窗出现。
import { useLayoutEffect, useRef, useState } from 'react';
import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { SavedToast } from './SavedToast';
import { useSavedToastListener } from '../lib/savedEvent';
import { openExternal } from '../lib/ipc';
import { useMobileLayout } from '../lib/useMobileLayout';
import type { OS } from './WindowChrome';
import { GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs';
import { AboutSection } from '../pages/settings/AboutSection';
@@ -56,47 +57,97 @@ const LINK_ITEMS: ModalNavItem[] = [
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
const { t } = useTranslation();
const mobile = useMobileLayout();
const [section, setSection] = useState<SettingsSectionId>(initialSettingsSection ?? 'general');
const savedToast = useSavedToastListener();
// 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill。
// 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill(desktop)。
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
useLayoutEffect(() => {
if (mobile) {
setPillRect(null);
return;
}
const idx = TAB_ITEMS.findIndex(it => it.id === section);
const el = tabRefs.current[idx];
if (!el) return;
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
}, [section]);
}, [section, mobile]);
return (
<div
onClick={onClose}
onClick={mobile ? undefined : onClose}
style={{
position: 'absolute', inset: 0,
background: 'rgba(15,17,22,0.32)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 28,
position: mobile ? 'fixed' : 'absolute',
inset: 0,
background: mobile ? 'var(--ol-surface)' : 'rgba(15,17,22,0.32)',
backdropFilter: mobile ? 'none' : 'blur(8px) saturate(140%)',
WebkitBackdropFilter: mobile ? 'none' : 'blur(8px) saturate(140%)',
display: 'flex',
alignItems: mobile ? 'stretch' : 'center',
justifyContent: mobile ? 'stretch' : 'center',
padding: mobile ? 0 : 28,
zIndex: 50,
animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
animation: mobile ? undefined : 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
}}>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 880, height: '100%', maxHeight: 600,
width: '100%',
maxWidth: mobile ? undefined : 880,
height: '100%',
maxHeight: mobile ? undefined : 600,
background: 'var(--ol-surface)',
borderRadius: 14,
border: '0.5px solid rgba(0,0,0,.08)',
boxShadow: '0 30px 80px -20px rgba(15,17,22,.35), 0 0 0 0.5px rgba(0,0,0,.06)',
display: 'flex', overflow: 'hidden',
animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)',
borderRadius: mobile ? 0 : 14,
border: mobile ? 'none' : '0.5px solid rgba(0,0,0,.08)',
boxShadow: mobile ? 'none' : '0 30px 80px -20px rgba(15,17,22,.35), 0 0 0 0.5px rgba(0,0,0,.06)',
display: 'flex',
flexDirection: mobile ? 'column' : 'row',
overflow: 'hidden',
animation: mobile ? undefined : 'ol-modal-card-in 0.24s var(--ol-motion-spring)',
position: 'relative',
}}>
{/* ─── 单层侧栏 ────────────────────────────────────────────── */}
{mobile ? (
<div style={{
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 'calc(10px + env(safe-area-inset-top, 0px)) 12px 10px',
borderBottom: '0.5px solid var(--ol-line-soft)',
}}>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
style={mobileHeaderBtnStyle}
>
<Icon name="close" size={16} />
</button>
<div
className="ol-thinscroll"
style={{ flex: 1, minWidth: 0, display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 2 }}
>
{TAB_ITEMS.map(it => {
const active = section === it.id;
return (
<button
key={it.id}
type="button"
onClick={() => setSection(it.id as SettingsSectionId)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={mobileTabChipStyle(active)}
>
{t(`modal.sections.${it.id}`)}
</button>
);
})}
</div>
</div>
) : (
<aside
style={{
width: 200, flexShrink: 0,
@@ -157,18 +208,17 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
))}
</div>
</aside>
)}
{/* ─── 内容区 ──────────────────────────────────────────────
父容器 overflow:hidden + 列向 flex;关闭按钮、section 标题固定在头部,
只有最里层的 scroll wrapper 真正滚动。 */}
{/* ─── 内容区 ────────────────────────────────────────────── */}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
{/* "已保存" toast:right:54 避开 28×28 关闭按钮 + 12px gap。 */}
<SavedToast
saveState={savedToast.state}
message={savedToast.message}
slideFrom="top"
offsetStyle={{ position: 'absolute', top: 16, right: 54 }}
offsetStyle={{ position: 'absolute', top: mobile ? 12 : 16, right: mobile ? 14 : 54 }}
/>
{!mobile && (
<button
onClick={onClose}
style={{
@@ -184,14 +234,22 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
title={t('common.close')}>
<Icon name="close" size={14} />
</button>
)}
{!mobile && (
<h2 style={{ margin: 0, padding: '22px 28px 8px', fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em', flexShrink: 0 }}>
{t(`modal.sections.${section}`)}
</h2>
)}
<div
className="ol-thinscroll"
style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '10px 28px 28px' }}>
style={{
flex: 1,
minHeight: 0,
overflow: 'auto',
padding: mobile ? '12px 16px calc(16px + env(safe-area-inset-bottom, 0px))' : '10px 28px 28px',
}}>
{/* key=section 让切 tab 时整块重挂载,ol-tab-fade 轻微淡入。 */}
<div
key={section}
@@ -202,6 +260,23 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
{section === 'advanced' && <AdvancedTab />}
{section === 'about' && <AboutSection />}
</div>
{mobile && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 20, paddingTop: 16, borderTop: '0.5px solid var(--ol-line-soft)' }}>
{LINK_ITEMS.map(it => (
<button
key={it.id}
type="button"
onClick={() => { if (it.href) void openExternal(it.href); }}
className="ol-nav-btn"
style={navBtnStyle}
>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
<Icon name="external" size={11} />
</button>
))}
</div>
)}
</div>
</div>
</div>
@@ -209,6 +284,35 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
);
}
const mobileHeaderBtnStyle: CSSProperties = {
width: 36,
height: 36,
flexShrink: 0,
border: 0,
borderRadius: 10,
background: 'transparent',
color: 'var(--ol-ink-3)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'default',
};
function mobileTabChipStyle(active: boolean): CSSProperties {
return {
flexShrink: 0,
padding: '6px 12px',
borderRadius: 999,
border: active ? '0.5px solid var(--ol-ink)' : '0.5px solid var(--ol-line-strong)',
background: active ? 'var(--ol-ink)' : 'transparent',
color: active ? '#fff' : 'var(--ol-ink-3)',
fontFamily: 'inherit',
fontSize: 12,
fontWeight: active ? 600 : 500,
cursor: 'default',
};
}
const navBtnStyle = {
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
@@ -46,7 +46,7 @@ export function WindowChrome({
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
linear-gradient(180deg, rgba(245,245,247,0.92) 0%, rgba(232,232,236,0.92) 100%)
`;
const useSolidSurface = os === 'linux';
const useSolidSurface = os === 'linux' || os === 'android';
return (
<div
+2
View File
@@ -83,6 +83,7 @@ export const en: typeof zhCN = {
translation: 'Translation',
selectionAsk: 'Ask',
localAsr: 'Models',
more: 'More',
},
marketplace: {
kicker: 'MARKETPLACE',
@@ -332,6 +333,7 @@ export const en: typeof zhCN = {
copiedFallback: 'Copied (use {{shortcut}})',
insertFailed: 'Insert failed',
confirmClear: 'Delete all {{count}} history entries? This cannot be undone.',
backToList: 'Back to list',
},
vocab: {
kicker: 'VOCABULARY',
+2
View File
@@ -85,6 +85,7 @@ export const ja: typeof zhCN = {
translation: '翻訳',
selectionAsk: '選択追問',
localAsr: 'モデル設定',
more: 'その他',
},
marketplace: {
kicker: 'MARKETPLACE',
@@ -334,6 +335,7 @@ export const ja: typeof zhCN = {
copiedFallback: 'コピー済み(要 {{shortcut}})',
insertFailed: '入力失敗',
confirmClear: '全 {{count}} 件の記録を削除しますか?この操作は取り消せません。',
backToList: '一覧に戻る',
},
vocab: {
kicker: 'VOCABULARY',
+2
View File
@@ -85,6 +85,7 @@ export const ko: typeof zhCN = {
translation: '번역',
selectionAsk: '선택 질문',
localAsr: '모델 설정',
more: '더보기',
},
marketplace: {
kicker: 'MARKETPLACE',
@@ -334,6 +335,7 @@ export const ko: typeof zhCN = {
copiedFallback: '복사됨({{shortcut}} 필요)',
insertFailed: '입력 실패',
confirmClear: '전체 {{count}}건의 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
backToList: '목록으로',
},
vocab: {
kicker: 'VOCABULARY',
+2
View File
@@ -81,6 +81,7 @@ export const zhCN = {
translation: '翻译',
selectionAsk: '划词追问',
localAsr: '模型设置',
more: '更多',
},
marketplace: {
kicker: 'MARKETPLACE',
@@ -330,6 +331,7 @@ export const zhCN = {
copiedFallback: '已复制(需 {{shortcut}})',
insertFailed: '插入失败',
confirmClear: '确定清空全部 {{count}} 条记录?此操作不可恢复。',
backToList: '返回列表',
},
vocab: {
kicker: 'VOCABULARY',
+2
View File
@@ -83,6 +83,7 @@ export const zhTW: typeof zhCN = {
translation: '翻譯',
selectionAsk: '劃詞追問',
localAsr: '模型設置',
more: '更多',
},
marketplace: {
kicker: 'MARKETPLACE',
@@ -332,6 +333,7 @@ export const zhTW: typeof zhCN = {
copiedFallback: '已複製(需 {{shortcut}})',
insertFailed: '插入失敗',
confirmClear: '確定清空全部 {{count}} 條記錄?此操作不可恢復。',
backToList: '返回列表',
},
vocab: {
kicker: 'VOCABULARY',
+21 -4
View File
@@ -7,6 +7,7 @@ import { Icon } from '../components/Icon';
import { detectOS } from '../components/WindowChrome';
import { formatComboLabel } from '../lib/hotkey';
import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording } from '../lib/ipc';
import { useMobileLayout } from '../lib/useMobileLayout';
import type { DictationSession, PolishMode } from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { Btn, Card, PageHeader, Pill } from './_atoms';
@@ -59,6 +60,8 @@ export function History() {
});
}, []);
const { prefs } = useHotkeySettings();
const mobile = useMobileLayout();
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
@@ -176,7 +179,8 @@ export function History() {
</div>
}
/>
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: 14, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '300px 1fr', gap: 14, flex: 1, minHeight: 0 }}>
{( !mobile || !mobileDetailOpen) && (
<Card padding={0} style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '12px 14px', borderBottom: '0.5px solid var(--ol-line)' }}>
<div style={{
@@ -226,7 +230,10 @@ export function History() {
{!loadError && filtered.map(s => (
<button
key={s.id}
onClick={() => setSelectedId(s.id)}
onClick={() => {
setSelectedId(s.id);
if (mobile) setMobileDetailOpen(true);
}}
style={{
width: '100%', padding: '10px 12px', textAlign: 'left',
display: 'flex', flexDirection: 'column', gap: 4,
@@ -253,11 +260,20 @@ export function History() {
))}
</div>
</Card>
)}
{(!mobile || mobileDetailOpen) && (
<Card padding={20} className="ol-thinscroll" style={{ overflow: 'auto' }}>
{item ? (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
{mobile && (
<div style={{ marginBottom: 12 }}>
<Btn icon="chevLeft" variant="ghost" size="sm" onClick={() => setMobileDetailOpen(false)}>
{t('history.backToList')}
</Btn>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, fontFamily: 'var(--ol-font-mono)', color: 'var(--ol-ink-3)' }}>{formatTime(item.createdAt)}</span>
<Pill size="sm" tone="default">{MODE_LABEL[item.mode]}</Pill>
@@ -278,7 +294,7 @@ export function History() {
key={item.id}
/>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1fr 1fr', gap: 12 }}>
<div style={{ padding: 14, border: '0.5px solid var(--ol-line)', borderRadius: 10, background: 'var(--ol-surface-2)' }}>
<Pill size="sm" tone="outline" style={{ marginBottom: 10 }}>{t('history.rawLabel')}</Pill>
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.7, color: 'var(--ol-ink-2)', whiteSpace: 'pre-wrap' }}>
@@ -315,6 +331,7 @@ export function History() {
</div>
)}
</Card>
)}
</div>
</div>
);
+5 -3
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { formatComboLabel } from '../lib/hotkey';
import { getCredentials, listHistory } from '../lib/ipc';
import { useMobileLayout } from '../lib/useMobileLayout';
import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { Btn, Card, PageHeader, Pill } from './_atoms';
@@ -53,6 +54,7 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
export function Overview({ onOpenHistory }: OverviewProps) {
const { t } = useTranslation();
const mobile = useMobileLayout();
const modeLabel = useModeLabels();
const [history, setHistory] = useState<DictationSession[]>([]);
const [historyError, setHistoryError] = useState(false);
@@ -172,7 +174,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
<>
<PageHeader title={t('overview.title')} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 18 }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1fr 1fr', gap: 12, marginBottom: 18 }}>
<ProviderCard
kind={t('overview.asrKind')}
name={asrProviderName}
@@ -187,7 +189,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 18 }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? 'repeat(2, 1fr)' : 'repeat(4, 1fr)', gap: 12, marginBottom: 18 }}>
<Metric icon="hash" label={t('overview.metricChars')} value={historyError ? '—' : metrics.charsToday.toLocaleString()} trend={historyError ? t('overview.historyLoadError') : t('overview.metricSegments', { count: metrics.segmentsToday })} />
<Metric icon="mic" label={t('overview.metricDuration')} value={historyError ? '—' : formatDuration(metrics.totalDurationMs, t)} trend={historyError ? t('overview.historyLoadError') : ''} />
<Metric icon="clock" label={t('overview.metricAvg')} value={historyError ? '—' : formatDuration(metrics.avgLatencyMs, t)} trend={historyError ? t('overview.historyLoadError') : metrics.segmentsToday > 0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} />
@@ -197,7 +199,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
{/* 底部一行 = flex:1 撑满剩余高度(父 wrapper 是 display:flex/column)。
只有「最近识别」内部允许滚动;其他卡片按内容自然高度,不破裂底部圆角。
issue #243 follow-up:去掉外层 overflow 后底部圆角被裁的视觉问题。 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: 12, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1fr 1.4fr', gap: 12, flex: mobile ? undefined : 1, minHeight: mobile ? undefined : 0 }}>
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>{t('overview.weekTitle')}</span>
+5 -3
View File
@@ -16,6 +16,7 @@ import {
} from '../lib/ipc';
import type { CorrectionRule, DictionaryEntry, VocabPreset } from '../lib/types';
import { DEFAULT_VOCAB_PRESETS, loadVocabPresets, persistVocabPresets } from '../lib/vocabPresets';
import { useMobileLayout } from '../lib/useMobileLayout';
import { Btn, Card, Collapsible, PageHeader } from './_atoms';
const NEW_PRESET_DRAFT_ID = '__new__';
@@ -35,6 +36,7 @@ function isSupportedCorrectionRule(pattern: string, replacement: string) {
export function Vocab() {
const { t } = useTranslation();
const mobile = useMobileLayout();
const [entries, setEntries] = useState<DictionaryEntry[]>([]);
const [loading, setLoading] = useState(true);
const inputRef = useRef<HTMLInputElement>(null);
@@ -317,21 +319,21 @@ export function Vocab() {
desc={t('vocab.corrections.tip')}
>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto minmax(0, 1fr) auto', gap: 8, alignItems: 'center' }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : 'minmax(0, 1fr) auto minmax(0, 1fr) auto', gap: 8, alignItems: mobile ? 'stretch' : 'center' }}>
<input
value={rulePatternDraft}
onChange={e => setRulePatternDraft(e.target.value)}
placeholder={t('vocab.corrections.patternPlaceholder')}
style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }}
/>
<span style={{ color: 'var(--ol-ink-4)', fontSize: 12 }}>→</span>
{!mobile && <span style={{ color: 'var(--ol-ink-4)', fontSize: 12 }}>→</span>}
<input
value={ruleReplacementDraft}
onChange={e => setRuleReplacementDraft(e.target.value)}
placeholder={t('vocab.corrections.replacementPlaceholder')}
style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }}
/>
<Btn size="sm" variant="primary" onClick={() => void onAddCorrectionRule()}>{t('common.add')}</Btn>
<Btn size="sm" variant="primary" onClick={() => void onAddCorrectionRule()} style={mobile ? { justifySelf: 'start' } : undefined}>{t('common.add')}</Btn>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, minHeight: correctionRules.length ? undefined : 20 }}>
{correctionRules.length === 0 && (
+11 -2
View File
@@ -4,6 +4,7 @@
import { useState, type CSSProperties, type ReactNode } from 'react';
import { Icon } from '../components/Icon';
import { useMobileLayout } from '../lib/useMobileLayout';
interface PageHeaderProps {
kicker?: string;
@@ -14,14 +15,22 @@ interface PageHeaderProps {
}
export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeaderProps) {
const mobile = useMobileLayout();
return (
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 24, marginBottom: 24 }}>
<div style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: mobile ? 12 : 24,
marginBottom: mobile ? 16 : 24,
flexWrap: mobile ? 'wrap' : 'nowrap',
}}>
<div style={{ minWidth: 0 }}>
{kicker && (
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--ol-ink-4)', marginBottom: 8 }}>{kicker}</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ol-ink)' }}>{title}</h1>
<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>}