Files
openless/openless-all/app/src/components/SettingsModal.tsx
T
吕柏青 1bb580700d revert(ui): 回退 aura 皮肤(#605) 到之前 UI + 设置瘦身/词汇表边距/胶囊弹性弹出
用户决定放弃 aura 皮肤改版(套框悬浮/吞玻璃/选框丑),前端整体回退到 aura 之前
的设计,并在其上做几处打磨:
- 设置页瘦身:去掉所有区块/每行描述小字 + 移除字体大小个性化
- 词汇表悬浮台到右/下边距相等(8px)
- 胶囊弹出改弹性曲线(scale .78→1 back-out overshoot)
2026-06-08 17:30:38 +08:00

223 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SettingsModal.tsx — 居中弹窗,左侧单层侧栏。
//
// 重构(2026-05):原本是「外层弹窗侧栏 + 设置页内层侧栏」双层嵌套,用户点
// 「设置」还要再面对第二个侧栏。现在拍平成单层 —— 通用 / 服务 / 隐私 / 高级 /
// 个性化 / 关于 六个 tab + 帮助外链组。每个 tab 的内容见 pages/settings/。
//
// 设计原则:每个可见控件都必须可用。没有后端支撑的占位(账号 / 主题切换 等)
// 不在此弹窗出现。
import { useLayoutEffect, useRef, useState } 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 type { OS } from './WindowChrome';
import { GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs';
import { AboutSection } from '../pages/settings/AboutSection';
// 稳定 tab ID(与 i18n key `modal.sections.*` 一致)。
export type SettingsSectionId =
| 'general'
| 'services'
| 'privacy'
| 'advanced'
| 'about';
interface SettingsModalProps {
os: OS;
onClose: () => void;
initialSettingsSection?: SettingsSectionId;
}
interface ModalNavItem {
id: string;
icon: string;
external?: boolean;
href?: string;
}
const HELP_URL = 'https://github.com/appergb/openless#readme';
const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases';
// 第一组:可选中的 tab;第二组:外部链接(永远不 active)。
const TAB_ITEMS: ModalNavItem[] = [
{ id: 'general', icon: 'settings' },
{ id: 'services', icon: 'cloud' },
{ id: 'privacy', icon: 'shield' },
{ id: 'advanced', icon: 'bolt' },
{ id: 'about', icon: 'info' },
];
const LINK_ITEMS: ModalNavItem[] = [
{ id: 'helpCenter', icon: 'help', external: true, href: HELP_URL },
{ id: 'releaseNotes', icon: 'doc', external: true, href: RELEASE_NOTES_URL },
];
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
const { t } = useTranslation();
const [section, setSection] = useState<SettingsSectionId>(initialSettingsSection ?? 'general');
const savedToast = useSavedToastListener();
// 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill。
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
useLayoutEffect(() => {
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]);
return (
<div
onClick={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,
zIndex: 50,
animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
}}>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 880, height: '100%', maxHeight: 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)',
position: 'relative',
}}>
{/* ─── 单层侧栏 ────────────────────────────────────────────── */}
<aside
style={{
width: 200, flexShrink: 0,
background: 'rgba(247,247,250,0.7)',
borderRight: '0.5px solid var(--ol-line-soft)',
padding: '18px 12px',
display: 'flex', flexDirection: 'column', gap: 14,
}}>
{/* tab 组 */}
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
{pillRect && (
<div
aria-hidden
style={{
position: 'absolute',
left: 0,
right: 0,
top: pillRect.top,
height: pillRect.height,
background: '#fff',
borderRadius: 8,
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
)}
{TAB_ITEMS.map((it, idx) => {
const active = section === it.id;
return (
<button
key={it.id}
ref={el => { tabRefs.current[idx] = el; }}
onClick={() => setSection(it.id as SettingsSectionId)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={navBtnStyle}>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
</button>
);
})}
</div>
{/* 外链组 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, paddingTop: 8, borderTop: '0.5px solid var(--ol-line-soft)' }}>
{LINK_ITEMS.map(it => (
<button
key={it.id}
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>
</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 }}
/>
<button
onClick={onClose}
style={{
position: 'absolute', top: 14, right: 14, zIndex: 2,
width: 28, height: 28, border: 0, borderRadius: 999,
background: 'transparent', color: 'var(--ol-ink-3)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick)',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.05)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
title={t('common.close')}>
<Icon name="close" size={14} />
</button>
<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' }}>
{/* key=section 让切 tab 时整块重挂载,ol-tab-fade 轻微淡入。 */}
<div
key={section}
style={{ display: 'flex', flexDirection: 'column', gap: 12, animation: 'ol-tab-fade 0.2s var(--ol-motion-soft)' }}>
{section === 'general' && <GeneralTab />}
{section === 'services' && <ServicesTab />}
{section === 'privacy' && <PrivacyTab />}
{section === 'advanced' && <AdvancedTab />}
{section === 'about' && <AboutSection />}
</div>
</div>
</div>
</div>
</div>
);
}
const navBtnStyle = {
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: 'transparent',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default', textAlign: 'left' as const,
position: 'relative' as const,
zIndex: 1,
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
};