Files
dudu/desktop/src/settings/SettingsApp.jsx
T
wangjia 0ca1caa460
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 12s
feat(design+desktop): 设置屏改版——卡片分组布局(12A 实测反馈,原型先行)
原布局「灰字组标题 + 通栏细线」平铺无体块感、行距不均、控件对齐参差。
改版(原型 SettingsTray 与实现 SettingsApp 同步):

- 分组卡片化:组标题在卡外(12px text-3 加字距),每组一张 radius-lg
  卡片(复用 Card 组件),卡内行以内嵌分隔线相隔,页面 bg-app 底上
  卡片体块清晰
- 行规格统一:SettingRow 复用,右侧控件统一右缘对齐
- 时长余额改大数字排版(text-xl bold + 单位小字基线对齐)
- 载入动效:四组 40ms 级差上浮淡入(对齐浮层动效语言,快入慢出)
- 顶条标题绝对居中(mac 原生习惯),与红绿灯永不重叠;原型 MacWindow
  同步改居中
- 原型补齐「帮助」组与实现对位

双主题截图验收通过;三道闸全绿;vite 构建通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:09:12 +08:00

170 lines
8.5 KiB
React

// dudu 设置窗口 — 四分组(输入 / 通用 / 账号与时长 / 帮助),规格见 doc/frontend-design.html 4.2
import { useEffect, useState } from 'react';
import { Button } from '@dudu/design/components/core/Button';
import { Badge } from '@dudu/design/components/core/Badge';
import { Card } from '@dudu/design/components/core/Card';
import { HotkeyCombo } from '@dudu/design/components/core/Kbd';
import { ProgressBar } from '@dudu/design/components/core/ProgressBar';
import { Switch } from '@dudu/design/components/forms/Switch';
import { SettingRow } from '@dudu/design/components/forms/SettingRow';
import { Select } from '@dudu/design/components/forms/Select';
import { invoke } from '../shared/tauri';
import { setThemePref } from '../shared/theme';
const THEME_OPTIONS = [['system', '跟随系统'], ['light', '浅色'], ['dark', '深色']];
// 分组卡片(对齐原型 SettingsTray 2026-07-11 改版):组标题在卡外、
// radius-lg 卡片体块、载入分组轻微上浮淡入(40ms 级差)。
const groupCss = `
@keyframes dd-set-in { from { opacity: 0; transform: translateY(4px); } }
.dd-set-group { animation: dd-set-in var(--dur-base) var(--ease-out) both; }
`;
function Group({ title, order = 0, children }) {
return (
<div className="dd-set-group" style={{ animationDelay: `${order * 40}ms` }}>
<div style={{
fontSize: 'var(--text-xs)', color: 'var(--text-3)', fontWeight: 'var(--weight-medium)',
letterSpacing: 'var(--tracking-wide)', margin: '0 0 6px 4px',
}}>{title}</div>
<Card padding="0 16px" style={{ borderRadius: 'var(--radius-lg)' }}>{children}</Card>
</div>
);
}
export function SettingsApp() {
const [settings, setSettings] = useState({
hotkey: 'CmdOrCtrl+Shift+Space', mic: '', sound: false, autostart: true, theme: 'system',
});
const [mics, setMics] = useState([]);
const [me, setMe] = useState(null); // null = 未登录
useEffect(() => {
invoke('get_settings').then((s) => { if (s) { setSettings(s); setThemePref(s.theme); } });
invoke('list_mic_devices').then((d) => d && setMics(d));
invoke('fetch_me').then((m) => m && setMe(m));
}, []);
// 仅这些字段属于「用户可改的设置」,回写时只动这几项,
// 其余字段(token / device_id / onboarding_done / server_url)保持后端最新值。
const USER_FIELDS = ['hotkey', 'mic', 'sound', 'autostart', 'theme'];
const update = (patch) => {
const next = { ...settings, ...patch };
setSettings(next);
if (patch.theme) setThemePref(patch.theme);
// 保存前重新读最新 settings 再合并,避免用陈旧(可能空)token 覆盖登录流程写入的 token,
// 导致先开设置窗再扫码登录、回设置页改开关时静默登出(丢失更新)。
invoke('get_settings').then((latest) => {
const base = latest || settings;
const merged = { ...base };
for (const k of USER_FIELDS) merged[k] = next[k];
invoke('set_settings', { settings: merged });
});
};
const minutes = me ? Math.floor(me.balance_seconds / 60) : 0;
const trialUsedMin = me ? Math.floor(me.trial_used_today / 60) : 0;
return (
// 透明窗口自绘 MacWindow 框(对齐原型):radius-md + border-1,占满窗口,
// 阴影用 macOS 原生(贴合内容圆角,窗口即卡片实际尺寸)。
// 原生 NSWindow 底色在深色内容外露白边,故窗口 transparent、框由页面画。
<div style={{
fontFamily: 'var(--font-sans)', position: 'fixed', inset: 0,
background: 'var(--bg-app)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-1)',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
<style>{groupCss}</style>
{/* 自绘顶条:系统红绿灯悬浮于左上,标题绝对居中(mac 原生习惯,永不重叠);整条可拖拽 */}
<div
data-tauri-drag-region
style={{
position: 'relative', height: 36, flex: 'none', display: 'flex', alignItems: 'center',
background: 'var(--surface-2)', borderBottom: '1px solid var(--border-1)',
userSelect: 'none', WebkitUserSelect: 'none', cursor: 'default',
}}
>
<span style={{
position: 'absolute', left: '50%', transform: 'translateX(-50%)', pointerEvents: 'none',
fontSize: 'var(--text-xs)', fontWeight: 'var(--weight-medium)', color: 'var(--text-2)',
}}>dudu 设置</span>
</div>
<div style={{ overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 16, padding: '10px 16px 16px' }}>
<Group title="输入" order={0}>
<SettingRow label="说话快捷键" description="按住说话,松开上屏">
<HotkeyCombo keys={['⌘', '⇧', 'Space']} />
<Button variant="ghost" size="sm">修改</Button>
</SettingRow>
<SettingRow label="麦克风">
<Select
value={settings.mic}
onChange={(mic) => update({ mic })}
options={[{ value: '', label: '系统默认' }, ...mics.map((m) => ({ value: m, label: m }))]}
/>
</SettingRow>
<SettingRow label="提示音" description="开始 / 完成时播放轻提示音">
<Switch checked={settings.sound} onChange={(v) => update({ sound: v })} />
</SettingRow>
</Group>
<Group title="通用" order={1}>
<SettingRow label="开机自启">
<Switch checked={settings.autostart} onChange={(v) => update({ autostart: v })} />
</SettingRow>
<SettingRow label="外观">
<span style={{ display: 'inline-flex', background: 'var(--surface-2)', borderRadius: 'var(--radius-sm)', padding: 2, gap: 2 }}>
{THEME_OPTIONS.map(([val, lab]) => (
<button key={val} type="button" onClick={() => update({ theme: val })} style={{
height: 24, padding: '0 10px', borderRadius: 4, border: 'none', cursor: 'pointer',
fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)',
background: settings.theme === val ? 'var(--surface-card)' : 'transparent',
color: settings.theme === val ? 'var(--text-1)' : 'var(--text-2)',
boxShadow: settings.theme === val ? 'var(--shadow-card)' : 'none',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>{lab}</button>
))}
</span>
</SettingRow>
</Group>
<Group title="账号与时长" order={2}>
<SettingRow label="登录状态" description={me ? '微信已登录' : '登录后开始使用'}>
{me ? (
<>
<span style={{ fontSize: 'var(--text-sm)', color: 'var(--text-1)' }}>{me.nickname_masked}</span>
<Badge tone={me.account_state === 'quota' ? 'orange' : 'green'}>
{me.account_state === 'ok' ? '余额充足' : me.account_state === 'trial' ? '试用中' : '余额不足'}
</Badge>
</>
) : (
<Button variant="primary" size="sm" onClick={() => invoke('open_login')}>微信登录</Button>
)}
</SettingRow>
<SettingRow label="时长余额" description="时长不过期 · 每天另有 3 分钟免费试用">
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 'var(--text-xl)', fontWeight: 'var(--weight-bold)', color: 'var(--text-1)' }}>{minutes}</span>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-2)' }}>分钟</span>
</span>
<Button variant="secondary" size="sm" onClick={() => invoke('open_login')}>购买时长</Button>
</SettingRow>
<div style={{ padding: '12px 0 14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginBottom: 7 }}>
<span>今日免费试用</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{trialUsedMin} / 3 分钟</span>
</div>
<ProgressBar value={trialUsedMin} max={3} />
</div>
</Group>
<Group title="帮助" order={3}>
<SettingRow label="反馈问题" description="遇到问题或建议,告诉我们">
<Button variant="ghost" size="sm" onClick={() => invoke('open_feedback')}>去反馈</Button>
</SettingRow>
</Group>
</div>
</div>
);
}