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:
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'"
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>}
|
||||
|
||||
Reference in new issue
Block a user