Merge pull request #338 from H-Chris233/fix/issue-315-selectionask-save-feedback
修复划词追问保存失败无反馈
This commit is contained in:
7 files changed
+141
-15
No files matched your search
@@ -8,10 +8,12 @@ export function ShortcutRecorder({
|
||||
value,
|
||||
onSave,
|
||||
alignRecordButton = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: ShortcutBinding;
|
||||
onSave: (binding: ShortcutBinding) => Promise<void>;
|
||||
alignRecordButton?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [recording, setRecording] = useState(false);
|
||||
@@ -39,6 +41,12 @@ export function ShortcutRecorder({
|
||||
};
|
||||
}, [recording]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled || !recording) return;
|
||||
setRecording(false);
|
||||
clearPendingModifier();
|
||||
}, [disabled, recording]);
|
||||
|
||||
const finish = async (binding: ShortcutBinding) => {
|
||||
try {
|
||||
await validateShortcutBinding(binding);
|
||||
@@ -52,7 +60,7 @@ export function ShortcutRecorder({
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!recording) return;
|
||||
if (!recording || disabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Escape') {
|
||||
@@ -80,7 +88,7 @@ export function ShortcutRecorder({
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!recording || !isModifierKey(e.key)) return;
|
||||
if (!recording || disabled || !isModifierKey(e.key)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const primary = modifierPrimaryFromCode(e.code, e.key);
|
||||
@@ -113,7 +121,8 @@ export function ShortcutRecorder({
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: 500,
|
||||
cursor: recording ? 'default' : 'pointer',
|
||||
cursor: recording || disabled ? 'default' : 'pointer',
|
||||
opacity: disabled ? 0.68 : 1,
|
||||
marginLeft: alignRecordButton ? 'auto' : undefined,
|
||||
};
|
||||
|
||||
@@ -125,11 +134,12 @@ export function ShortcutRecorder({
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setRecording(true);
|
||||
setError(null);
|
||||
clearPendingModifier();
|
||||
}}
|
||||
disabled={recording}
|
||||
disabled={recording || disabled}
|
||||
style={recordButtonStyle}
|
||||
>
|
||||
{recording ? t('settings.recording.comboRecordHint') : t('settings.recording.comboRecordBtn')}
|
||||
|
||||
@@ -240,6 +240,11 @@ export const en: typeof zhCN = {
|
||||
optionDisabled: 'Disabled',
|
||||
chordWarning: '',
|
||||
},
|
||||
save: {
|
||||
hotkeyRegisterFailed: 'Failed to register the Selection Ask shortcut. The preference was not saved.',
|
||||
hotkeySaveFailed: 'Failed to save the Selection Ask shortcut. Please try again.',
|
||||
historySaveFailed: 'Failed to save the Q&A history setting. Please try again.',
|
||||
},
|
||||
history: {
|
||||
title: 'Save history',
|
||||
desc: 'When on, every selection + voice question + AI answer is persisted locally (never uploaded). Off by default — closing the panel forgets the exchange entirely, more private.',
|
||||
|
||||
@@ -242,6 +242,11 @@ export const ja: typeof zhCN = {
|
||||
optionDisabled: '無効',
|
||||
chordWarning: '',
|
||||
},
|
||||
save: {
|
||||
hotkeyRegisterFailed: '選択追問ショートカットの登録に失敗しました。設定は保存されていません。',
|
||||
hotkeySaveFailed: '選択追問ショートカットの保存に失敗しました。もう一度お試しください。',
|
||||
historySaveFailed: 'Q&A 履歴設定の保存に失敗しました。もう一度お試しください。',
|
||||
},
|
||||
history: {
|
||||
title: '履歴を保存',
|
||||
desc: 'ON にすると、各追問の「選択テキスト + あなたの音声質問 + AI 回答」をローカルに保存(クラウドへは送信しない)。デフォルト OFF では、フロートウィンドウを閉じると問答が消去されプライバシー優先。',
|
||||
|
||||
@@ -242,6 +242,11 @@ export const ko: typeof zhCN = {
|
||||
optionDisabled: '비활성화',
|
||||
chordWarning: '',
|
||||
},
|
||||
save: {
|
||||
hotkeyRegisterFailed: '선택 질문 단축키 등록에 실패했습니다. 설정은 저장되지 않았습니다.',
|
||||
hotkeySaveFailed: '선택 질문 단축키 저장에 실패했습니다. 다시 시도하세요.',
|
||||
historySaveFailed: 'Q&A 기록 설정 저장에 실패했습니다. 다시 시도하세요.',
|
||||
},
|
||||
history: {
|
||||
title: '기록 저장',
|
||||
desc: '체크 시 매 후속 질문의 "선택 텍스트 + 음성 질문 + AI 답변"을 로컬 보관(클라우드 비전송). 기본 OFF 일 때는 창을 닫으면 문답이 즉시 사라져 프라이버시 우선.',
|
||||
|
||||
@@ -238,6 +238,11 @@ export const zhCN = {
|
||||
optionDisabled: '不启用',
|
||||
chordWarning: '',
|
||||
},
|
||||
save: {
|
||||
hotkeyRegisterFailed: '划词追问快捷键注册失败,未继续保存。',
|
||||
hotkeySaveFailed: '划词追问快捷键保存失败,请重试。',
|
||||
historySaveFailed: 'Q&A 历史保存设置保存失败,请重试。',
|
||||
},
|
||||
history: {
|
||||
title: '保存历史',
|
||||
desc: '勾上则把每次追问的「选中文本 + 你的语音问题 + AI 答案」写入本地存档(不上云)。默认关,关闭时浮窗一关问答即遗忘,更注重隐私。',
|
||||
|
||||
@@ -240,6 +240,11 @@ export const zhTW: typeof zhCN = {
|
||||
optionDisabled: '不啓用',
|
||||
chordWarning: '',
|
||||
},
|
||||
save: {
|
||||
hotkeyRegisterFailed: '劃詞追問快捷鍵註冊失敗,未繼續保存。',
|
||||
hotkeySaveFailed: '劃詞追問快捷鍵保存失敗,請重試。',
|
||||
historySaveFailed: 'Q&A 歷史保存設置保存失敗,請重試。',
|
||||
},
|
||||
history: {
|
||||
title: '保存歷史',
|
||||
desc: '勾上則把每次追問的「選中文本 + 你的語音問題 + AI 答案」寫入本地存檔(不上雲)。默認關,關閉時浮窗一關問答即遺忘,更注重隱私。',
|
||||
|
||||
@@ -5,19 +5,83 @@
|
||||
// 这一页把原本散在 Settings → 录音 里的两条配置(hotkey 预设 / 保存 Q&A 历史)
|
||||
// 集中起来 + 加完整使用指南,跟"翻译"页平级。
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, PageHeader } from './_atoms';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { setQaHotkey } from '../lib/ipc';
|
||||
import { defaultQaShortcut, formatComboLabel } from '../lib/hotkey';
|
||||
import { ShortcutRecorder } from '../components/ShortcutRecorder';
|
||||
import type { QaHotkeyBinding, UserPreferences } from '../lib/types';
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'failed';
|
||||
|
||||
export function SelectionAsk() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, updatePrefs: savePrefs } = useHotkeySettings();
|
||||
const { prefs, refresh, updatePrefs: savePrefs } = useHotkeySettings();
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const statusTimer = useRef<number | null>(null);
|
||||
const defaultQaHotkey = defaultQaShortcut();
|
||||
const defaultHotkeyLabel = formatComboLabel(defaultQaHotkey);
|
||||
const recordHotkeyLabel = prefs ? formatComboLabel(prefs.dictationHotkey) : '快捷键';
|
||||
|
||||
useEffect(() => () => {
|
||||
if (statusTimer.current !== null) window.clearTimeout(statusTimer.current);
|
||||
}, []);
|
||||
|
||||
const showSaveStatus = (state: SaveState, message: string, temporary = false) => {
|
||||
if (statusTimer.current !== null) {
|
||||
window.clearTimeout(statusTimer.current);
|
||||
statusTimer.current = null;
|
||||
}
|
||||
setSaveState(state);
|
||||
setSaveMessage(message);
|
||||
if (temporary) {
|
||||
statusTimer.current = window.setTimeout(() => {
|
||||
setSaveState('idle');
|
||||
setSaveMessage('');
|
||||
statusTimer.current = null;
|
||||
}, 1600);
|
||||
}
|
||||
};
|
||||
|
||||
const persistPrefs = async (
|
||||
resolveNext: (current: UserPreferences) => UserPreferences,
|
||||
failureMessage: string,
|
||||
) => {
|
||||
try {
|
||||
await savePrefs(resolveNext);
|
||||
showSaveStatus('saved', t('common.saved'), true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[selection-ask] failed to save preferences', error);
|
||||
showSaveStatus('failed', failureMessage);
|
||||
await refresh().catch(refreshError => {
|
||||
console.warn('[selection-ask] failed to refresh preferences after save error', refreshError);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveQaHotkey = async (nextHotkey: QaHotkeyBinding | null) => {
|
||||
showSaveStatus('saving', t('common.saving'));
|
||||
try {
|
||||
await setQaHotkey(nextHotkey);
|
||||
} catch (error) {
|
||||
console.error('[selection-ask] failed to register QA hotkey', error);
|
||||
showSaveStatus('failed', t('selectionAsk.save.hotkeyRegisterFailed'));
|
||||
await refresh().catch(refreshError => {
|
||||
console.warn('[selection-ask] failed to refresh preferences after hotkey error', refreshError);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await persistPrefs(
|
||||
current => ({ ...current, qaHotkey: nextHotkey }),
|
||||
t('selectionAsk.save.hotkeySaveFailed'),
|
||||
);
|
||||
};
|
||||
|
||||
if (!prefs) {
|
||||
return (
|
||||
<>
|
||||
@@ -36,11 +100,17 @@ export function SelectionAsk() {
|
||||
);
|
||||
}
|
||||
|
||||
const onSaveHistoryChange = (qaSaveHistory: boolean) =>
|
||||
savePrefs({ ...prefs, qaSaveHistory });
|
||||
const onSaveHistoryChange = (qaSaveHistory: boolean) => {
|
||||
showSaveStatus('saving', t('common.saving'));
|
||||
void persistPrefs(
|
||||
current => ({ ...current, qaSaveHistory }),
|
||||
t('selectionAsk.save.historySaveFailed'),
|
||||
);
|
||||
};
|
||||
|
||||
const enabled = prefs.qaHotkey !== null;
|
||||
const currentLabel = prefs.qaHotkey ? formatComboLabel(prefs.qaHotkey) : defaultHotkeyLabel;
|
||||
const saving = saveState === 'saving';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -54,16 +124,34 @@ export function SelectionAsk() {
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{saveState !== 'idle' && (
|
||||
<div
|
||||
role={saveState === 'failed' ? 'alert' : 'status'}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: saveState === 'failed'
|
||||
? '0.5px solid rgba(239,68,68,0.22)'
|
||||
: '0.5px solid rgba(37,99,235,0.16)',
|
||||
background: saveState === 'failed' ? 'rgba(239,68,68,0.07)' : 'rgba(37,99,235,0.06)',
|
||||
color: saveState === 'failed' ? 'var(--ol-red, #ef4444)' : 'var(--ol-blue)',
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{saveMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. 触发快捷键 */}
|
||||
<Card>
|
||||
<CardHeaderToggle
|
||||
title={t('selectionAsk.hotkey.title')}
|
||||
checked={enabled}
|
||||
onToggle={async () => {
|
||||
disabled={saving}
|
||||
onToggle={() => {
|
||||
const nextHotkey = enabled ? null : defaultQaHotkey;
|
||||
await setQaHotkey(nextHotkey);
|
||||
await savePrefs({ ...prefs, qaHotkey: nextHotkey });
|
||||
void saveQaHotkey(nextHotkey);
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: prefs.qaHotkey ? 12 : 0, lineHeight: 1.55 }}>
|
||||
@@ -72,10 +160,8 @@ export function SelectionAsk() {
|
||||
{prefs.qaHotkey && (
|
||||
<ShortcutRecorder
|
||||
value={prefs.qaHotkey}
|
||||
onSave={async binding => {
|
||||
await setQaHotkey(binding);
|
||||
await savePrefs({ ...prefs, qaHotkey: binding });
|
||||
}}
|
||||
onSave={saveQaHotkey}
|
||||
disabled={saving}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
@@ -85,6 +171,7 @@ export function SelectionAsk() {
|
||||
<CardHeaderToggle
|
||||
title={t('selectionAsk.history.title')}
|
||||
checked={prefs.qaSaveHistory}
|
||||
disabled={saving}
|
||||
onToggle={() => onSaveHistoryChange(!prefs.qaSaveHistory)}
|
||||
/>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.55 }}>
|
||||
@@ -142,10 +229,12 @@ export function SelectionAsk() {
|
||||
function CardHeaderToggle({
|
||||
title,
|
||||
checked,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -154,6 +243,7 @@ function CardHeaderToggle({
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-pressed={checked}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 36,
|
||||
@@ -161,7 +251,8 @@ function CardHeaderToggle({
|
||||
borderRadius: 999,
|
||||
border: 0,
|
||||
background: checked ? 'var(--ol-blue)' : 'rgba(0,0,0,0.15)',
|
||||
cursor: 'default',
|
||||
cursor: disabled ? 'not-allowed' : 'default',
|
||||
opacity: disabled ? 0.68 : 1,
|
||||
transition: 'background 0.16s var(--ol-motion-quick)',
|
||||
padding: 0,
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user