fix(translation): 翻译不会生效时不再进入翻译态(胶囊误报 + 空转 LLM) (#908)
* fix(translation): 翻译不会生效时不再进入翻译态 两个症状同源:`translation_modifier_seen` 的语义只是「按过 Shift」, 而「是否真的翻译」的判定散落在读取侧,胶囊那一侧漏了目标语言检查。 - 没在翻译页选目标语言,录音中按 Shift → 光标处显示「正在翻译」, 但 end_session 走的是普通润色,什么也没翻。 - 目标语言等于用户唯一的工作语言(例:工作语言只勾了简体中文、目标也选 简体中文)→ 源语言必定就是目标语言,仍会照常发起一次 LLM 翻译往返, 纯浪费时延和 token。 新增 `types::translation_effective` 作为唯一判定入口,并把判定收到写入侧 (`mark_translation_modifier_seen`):该函数每次按键才跑一次,读一次 prefs 无所谓;而读取侧之一是音频回调线程上的 emit_capsule,按帧执行, 不能碰偏好锁(capsule_focus.rs 既有注释已就此立过规矩)。收紧后 flag 的 语义变成「本次会话真的要翻译」,胶囊提示与 polish 分派读同一个真值。 工作语言有多个时不拦:中/英双语用户把目标设成英文是正常用法(说中文出 英文),源语言无法预先判定。简体/繁体是语言列表里两个独立条目,简→繁 仍照常翻译。安卓浮层的 start_dictation_with_translation 一并走同一个 gate。 「按了但不翻」的情况会记一条 INFO 说明原因,否则用户只看到胶囊没提示, 无从判断是没生效还是没按到。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(translation): 目标语言与唯一工作语言相同时在翻译页给出提示 后端已经不会在这种组合下发起翻译(上一个 commit),但用户在设置页看不出 任何异常:状态灯仍写「已启用」,按 Shift 却什么也不会发生,只能靠翻日志 才知道为什么。 - 翻译目标语言卡片下方出现一条警示:说明这个组合不会生效,并给出两条 出路(换目标语言,或多勾一个工作语言)。 - 「已启用 / 未启用」状态灯改为同时看目标语言和这个冗余判定,不再谎报。 判定逻辑抽成 `lib/translationTarget.ts`,与后端 `translation_effective` 同一套规则(多工作语言不拦、简→繁不误判),两侧各带单测钉住契约。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(translation): translation_modifier_seen 更名 translation_active + 修正安卓 overlay 日志 * refactor(translation): 更名 arm_translation_if_effective 并简化冗余判定表达式 审查跟进(PR #908):mark_translation_active 的实际行为是「只在翻译会 生效时置位并返回是否置位」,名字却像无条件 setter,改为 arm_translation_if_effective 更诚实;translation_effective 里的 matches!([only] if ...) 改写为显式 len==1 + trim 相等(语义完全等价)。 行为零变化:translation_effective 6 个单测全过(backend-tests), cargo check 全绿。 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
13 files changed
+246
-28
No files matched your search
@@ -624,11 +624,14 @@ struct Inner {
|
||||
/// 预览确认模式暂存的结果和原选区目标;仅在用户确认时才允许插入。
|
||||
#[cfg(not(mobile))]
|
||||
selection_polish_preview: Mutex<Option<selection_polish::PendingSelectionPolishPreview>>,
|
||||
/// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在
|
||||
/// Listening / Starting 阶段看到 Shift down 边沿时 set true。
|
||||
/// end_session 在调 polish/translate 前读这个 flag + translation_target_language
|
||||
/// 决定走哪条管线。详见 issue #4。
|
||||
translation_modifier_seen: AtomicBool,
|
||||
/// 「本次会话真的要翻译」。每次 begin_session 重置为 false;hotkey 监听器在
|
||||
/// Listening / Starting 阶段看到 Shift down 边沿(或安卓浮层请求)时,经
|
||||
/// `arm_translation_if_effective` 判定翻译确实会生效(设了目标语言、且不等于唯一工作语言)
|
||||
/// 后才 set true。
|
||||
///
|
||||
/// 判定收在写入侧:读取侧之一是音频回调线程上的 emit_capsule,不能碰偏好锁。
|
||||
/// 胶囊提示与 end_session 的 polish 分派因此读到同一个真值。详见 issue #4。
|
||||
translation_active: AtomicBool,
|
||||
/// 划词语音问答(issue #118):与 dictation hotkey 平行的全局快捷键
|
||||
/// 监听器(global-hotkey crate)。`None` 表示功能关闭或还没成功安装。
|
||||
qa_hotkey: Mutex<Option<QaHotkeyMonitor>>,
|
||||
@@ -851,7 +854,7 @@ impl Coordinator {
|
||||
selection_polish_hotkey: Mutex::new(None),
|
||||
#[cfg(not(mobile))]
|
||||
selection_polish_preview: Mutex::new(None),
|
||||
translation_modifier_seen: AtomicBool::new(false),
|
||||
translation_active: AtomicBool::new(false),
|
||||
qa_hotkey: Mutex::new(None),
|
||||
coding_agent_modifier_hotkey: Mutex::new(None),
|
||||
coding_agent_combo_hotkey: Mutex::new(None),
|
||||
@@ -969,7 +972,7 @@ impl Coordinator {
|
||||
selection_polish_hotkey: Mutex::new(None),
|
||||
#[cfg(not(mobile))]
|
||||
selection_polish_preview: Mutex::new(None),
|
||||
translation_modifier_seen: AtomicBool::new(false),
|
||||
translation_active: AtomicBool::new(false),
|
||||
qa_hotkey: Mutex::new(None),
|
||||
coding_agent_modifier_hotkey: Mutex::new(None),
|
||||
coding_agent_combo_hotkey: Mutex::new(None),
|
||||
@@ -1763,10 +1766,10 @@ impl Coordinator {
|
||||
|
||||
pub async fn start_dictation_with_translation(&self) -> Result<(), String> {
|
||||
begin_session(&self.inner).await?;
|
||||
self.inner
|
||||
.translation_modifier_seen
|
||||
.store(true, Ordering::SeqCst);
|
||||
log::info!("[coord] android overlay translation dictation started");
|
||||
// 与桌面 Shift 走同一个 gate:目标语言没设 / 与唯一工作语言相同时不置位,
|
||||
// 避免安卓浮层也出现「提示在翻译、实际没翻」。
|
||||
let translation_armed = arm_translation_if_effective(&self.inner);
|
||||
log::info!("[coord] android overlay dictation started (translation={translation_armed})");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1780,7 +1783,7 @@ impl Coordinator {
|
||||
|
||||
pub async fn stop_dictation_with_translation(&self, translation: bool) -> Result<(), String> {
|
||||
if translation {
|
||||
mark_translation_modifier_seen(&self.inner);
|
||||
arm_translation_if_effective(&self.inner);
|
||||
}
|
||||
self.stop_dictation().await
|
||||
}
|
||||
|
||||
@@ -517,7 +517,7 @@ fn emit_capsule_with_context_locked(
|
||||
return event_epoch;
|
||||
};
|
||||
// 选区润色不属于语音翻译 / Less Computer,会话之间残留的标志不能带进其提示。
|
||||
let translation = !selection_polish && inner.translation_modifier_seen.load(Ordering::SeqCst);
|
||||
let translation = !selection_polish && inner.translation_active.load(Ordering::SeqCst);
|
||||
let operating = !selection_polish && inner.state.lock().voice_agent;
|
||||
// 预备态只对 Recording 有意义:麦克风还没吐第一帧 PCM 时(capsule_warming=true)把
|
||||
// warming 打成 true,前端渲染「待命」光效;level_handler 首触发后翻 false → 光条点亮。
|
||||
|
||||
@@ -1613,10 +1613,8 @@ pub(super) async fn begin_session_as(
|
||||
store_prepared_windows_ime_session(&mut slots, current_session_id, prepared);
|
||||
}
|
||||
}
|
||||
// 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。
|
||||
inner
|
||||
.translation_modifier_seen
|
||||
.store(false, Ordering::SeqCst);
|
||||
// 翻译生效标志重置;修饰键按下或安卓浮层请求时经 arm_translation_if_effective 置位。
|
||||
inner.translation_active.store(false, Ordering::SeqCst);
|
||||
|
||||
#[cfg(any(debug_assertions, test))]
|
||||
if hotkey_injection_dry_run_enabled() {
|
||||
@@ -3603,8 +3601,11 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
);
|
||||
let raw_uses_llm = mode == PolishMode::Raw && super::raw_style_pack_uses_llm(&pack);
|
||||
let translation_target = prefs.translation_target_language.trim().to_string();
|
||||
let translation_active =
|
||||
inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty();
|
||||
let translation_active = crate::types::translation_effective(
|
||||
inner.translation_active.load(Ordering::SeqCst),
|
||||
&translation_target,
|
||||
&working_languages,
|
||||
);
|
||||
log::info!(
|
||||
"[style-pack] runtime dispatch scope=asr session_id={} active_pack={} kind={:?} mode={:?} raw_chars={} prompt_chars={} raw_uses_llm={} translation_active={} hotwords={} working_languages={:?}",
|
||||
current_session_id,
|
||||
|
||||
@@ -994,7 +994,7 @@ pub(super) fn translation_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiv
|
||||
continue;
|
||||
}
|
||||
if matches!(evt, ComboHotkeyEvent::Pressed { .. }) {
|
||||
mark_translation_modifier_seen(&inner);
|
||||
arm_translation_if_effective(&inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1286,14 +1286,38 @@ pub(super) fn modifier_shortcut_triggers(
|
||||
(qa_trigger, selection_polish_trigger, translation_trigger)
|
||||
}
|
||||
|
||||
pub(super) fn mark_translation_modifier_seen(inner: &Arc<Inner>) {
|
||||
/// 在这里、而不是在读取侧判定「翻译是否真的会发生」:本函数在桥接线程(翻译热键事件 /
|
||||
/// 主热键循环)和安卓 overlay 命令路径上调用,均非音频回调线程,读一次 prefs 无妨;
|
||||
/// 而 `translation_active` 的读取侧之一是 emit_capsule —— 它在音频回调线程按帧执行,
|
||||
/// 不能碰偏好锁(见 capsule_focus.rs 注释)。
|
||||
///
|
||||
/// 收紧后这个 flag 的语义从「按过 Shift」变成「本次会话真的要翻译」,胶囊提示与 polish
|
||||
/// 分派读同一个值,不会再出现「胶囊说正在翻译、后端其实没翻」的漂移(用户未设目标语言
|
||||
/// 时按 Shift 就会撞上)。返回 true 表示本次会话翻译已置位。
|
||||
pub(super) fn arm_translation_if_effective(inner: &Arc<Inner>) -> bool {
|
||||
let phase = inner.state.lock().phase;
|
||||
if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) {
|
||||
inner
|
||||
.translation_modifier_seen
|
||||
.store(true, Ordering::SeqCst);
|
||||
log::info!("[coord] translation modifier seen during {phase:?}");
|
||||
if !matches!(phase, SessionPhase::Starting | SessionPhase::Listening) {
|
||||
return false;
|
||||
}
|
||||
let prefs = inner.prefs.get();
|
||||
if !crate::types::translation_effective(
|
||||
true,
|
||||
&prefs.translation_target_language,
|
||||
&prefs.working_languages,
|
||||
) {
|
||||
// 明确记录「按了但不翻」的原因,否则用户只能看到胶囊不提示、无从判断是没生效
|
||||
// 还是没按到。
|
||||
log::info!(
|
||||
"[coord] translation requested during {phase:?} but translation is a no-op \
|
||||
(target={:?} working={:?}); staying in plain polish",
|
||||
prefs.translation_target_language,
|
||||
prefs.working_languages
|
||||
);
|
||||
return false;
|
||||
}
|
||||
inner.translation_active.store(true, Ordering::SeqCst);
|
||||
log::info!("[coord] translation active during {phase:?}");
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
|
||||
@@ -1332,7 +1356,7 @@ pub(super) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEve
|
||||
|| crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey)
|
||||
.is_some()
|
||||
{
|
||||
mark_translation_modifier_seen(&inner_cloned);
|
||||
arm_translation_if_effective(&inner_cloned);
|
||||
}
|
||||
}
|
||||
HotkeyEvent::QaShortcutPressed => {
|
||||
|
||||
@@ -469,6 +469,33 @@ impl Default for StylePack {
|
||||
}
|
||||
}
|
||||
|
||||
/// 本次会话是否真的会走翻译管线。**唯一判定入口**——写入侧(arm_translation_if_effective)
|
||||
/// 与 end_session 的 polish 分派都经它判定,否则两边会漂移(此前胶囊只看
|
||||
/// `modifier_seen`,用户没设目标语言按下 Shift 也会看到「正在翻译」,而后端根本没翻)。
|
||||
/// 胶囊本身只读经它置位的原子标志,不在音频回调线程触碰偏好锁。
|
||||
///
|
||||
/// 三个条件:
|
||||
/// 1. 会话期间按下过翻译修饰键;
|
||||
/// 2. 设了翻译目标语言(空串 = 功能未启用);
|
||||
/// 3. 目标语言不等于用户「唯一的」工作语言——此时源语言必定就是目标语言,翻译是可证
|
||||
/// 的空操作,白花一次 LLM 往返。工作语言有多个时不拦:中/英双语用户把目标设成英文
|
||||
/// 是正常用法(说中文出英文)。简体/繁体是列表里的两个独立条目,按字面比较即可,
|
||||
/// 简→繁仍会照常翻译。
|
||||
pub fn translation_effective(
|
||||
modifier_seen: bool,
|
||||
translation_target_language: &str,
|
||||
working_languages: &[String],
|
||||
) -> bool {
|
||||
if !modifier_seen {
|
||||
return false;
|
||||
}
|
||||
let target = translation_target_language.trim();
|
||||
if target.is_empty() {
|
||||
return false;
|
||||
}
|
||||
!(working_languages.len() == 1 && working_languages[0].trim() == target)
|
||||
}
|
||||
|
||||
pub const BUILTIN_STYLE_PACK_RAW_ID: &str = "builtin.raw";
|
||||
pub const BUILTIN_STYLE_PACK_LIGHT_ID: &str = "builtin.light";
|
||||
pub const BUILTIN_STYLE_PACK_STRUCTURED_ID: &str = "builtin.structured";
|
||||
@@ -2992,6 +3019,74 @@ pub struct QaChatMessage {
|
||||
pub selection_text: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod translation_effective_tests {
|
||||
use super::translation_effective;
|
||||
|
||||
fn langs(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_the_modifier() {
|
||||
assert!(!translation_effective(
|
||||
false,
|
||||
"English",
|
||||
&langs(&["简体中文"])
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_target_language_is_not_translation() {
|
||||
// 用户没在翻译页选目标语言就按 Shift:此前胶囊照样显示「正在翻译」,
|
||||
// 而后端走的是普通润色。
|
||||
assert!(!translation_effective(true, "", &langs(&["简体中文"])));
|
||||
assert!(!translation_effective(true, " ", &langs(&["简体中文"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_equal_to_the_only_working_language_is_a_no_op() {
|
||||
// 工作语言只有中文、目标也是中文 —— 源语言必定就是目标语言,翻译是空操作。
|
||||
assert!(!translation_effective(
|
||||
true,
|
||||
"简体中文",
|
||||
&langs(&["简体中文"])
|
||||
));
|
||||
// 前后空白不该让它逃过判定。
|
||||
assert!(!translation_effective(
|
||||
true,
|
||||
" 简体中文 ",
|
||||
&langs(&["简体中文"])
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplified_to_traditional_still_translates() {
|
||||
// 简体/繁体是语言列表里两个独立条目,简→繁是真实转换,不能按「同一种中文」拦掉。
|
||||
assert!(translation_effective(
|
||||
true,
|
||||
"繁体中文",
|
||||
&langs(&["简体中文"])
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_working_languages_are_never_blocked() {
|
||||
// 中/英双语用户把目标设成英文是正常用法(说中文出英文),源语言无法预先判定,
|
||||
// 不能因为目标语言出现在工作语言里就拦。
|
||||
assert!(translation_effective(
|
||||
true,
|
||||
"English",
|
||||
&langs(&["简体中文", "English"])
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_working_languages_still_translates() {
|
||||
assert!(translation_effective(true, "English", &[]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -585,6 +585,7 @@ export const en: typeof zhCN = {
|
||||
title: 'Translation target language',
|
||||
desc: 'Press Shift during recording to trigger translation. "Disabled" makes Shift a no-op.',
|
||||
disabled: 'Disabled (Shift does nothing)',
|
||||
sameAsWorking: 'The target matches your only working language, so translation cannot take effect — Shift will just run a normal polish. Pick a different target, or add another working language above.',
|
||||
},
|
||||
save: {
|
||||
workingFailed: 'Failed to save working languages. Please try again.',
|
||||
|
||||
@@ -587,6 +587,7 @@ export const ja: typeof zhCN = {
|
||||
title: '翻訳ターゲット言語',
|
||||
desc: '録音中に Shift で翻訳を起動。「無効」で Shift 無効化。',
|
||||
disabled: '無効(Shift で翻訳を発動しない)',
|
||||
sameAsWorking: 'ターゲット言語が唯一の作業言語と同じため、翻訳は発動しません(Shift を押しても通常の整文になります)。別のターゲットを選ぶか、上で作業言語を追加してください。',
|
||||
},
|
||||
save: {
|
||||
workingFailed: '作業言語の保存に失敗しました。もう一度お試しください。',
|
||||
|
||||
@@ -587,6 +587,7 @@ export const ko: typeof zhCN = {
|
||||
title: '번역 대상 언어',
|
||||
desc: '녹음 중 Shift 로 번역 실행. "비활성화" 시 Shift 무효.',
|
||||
disabled: '비활성화 (Shift 로 번역 발동 안 함)',
|
||||
sameAsWorking: '대상 언어가 유일한 작업 언어와 같아 번역이 실행되지 않습니다. Shift 를 눌러도 일반 정리로 처리됩니다. 다른 대상 언어를 고르거나 위에서 작업 언어를 추가하세요.',
|
||||
},
|
||||
save: {
|
||||
workingFailed: '작업 언어 저장에 실패했습니다. 다시 시도하세요.',
|
||||
|
||||
@@ -583,6 +583,7 @@ export const zhCN = {
|
||||
title: '翻译目标语言',
|
||||
desc: '录音时按 Shift 触发翻译。选「不启用」则 Shift 无效。',
|
||||
disabled: '不启用(Shift 按下不触发翻译)',
|
||||
sameAsWorking: '目标语言与你唯一的工作语言相同,翻译不会生效:按 Shift 仍按普通润色处理。换一个目标语言,或在上方多勾选一个工作语言。',
|
||||
},
|
||||
save: {
|
||||
workingFailed: '工作语言保存失败,请重试。',
|
||||
|
||||
@@ -585,6 +585,7 @@ export const zhTW: typeof zhCN = {
|
||||
title: '翻譯目標語言',
|
||||
desc: '錄音時按 Shift 觸發翻譯。選「不啟用」則 Shift 無效。',
|
||||
disabled: '不啓用(Shift 按下不觸發翻譯)',
|
||||
sameAsWorking: '目標語言與你唯一的工作語言相同,翻譯不會生效:按 Shift 仍按普通潤色處理。換一個目標語言,或在上方多勾選一個工作語言。',
|
||||
},
|
||||
save: {
|
||||
workingFailed: '工作語言保存失敗,請重試。',
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { isTranslationEnabled, isTranslationTargetRedundant } from './translationTarget';
|
||||
|
||||
function assert(condition: boolean, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
// 未选目标语言 = 功能未启用。
|
||||
assert(isTranslationEnabled('') === false, 'empty target should read as disabled');
|
||||
assert(isTranslationEnabled(' ') === false, 'blank target should read as disabled');
|
||||
assert(isTranslationEnabled('English') === true, 'a chosen target should read as enabled');
|
||||
|
||||
// 目标 = 唯一工作语言:翻译是空操作,页面必须提示。
|
||||
assert(
|
||||
isTranslationTargetRedundant('简体中文', ['简体中文']) === true,
|
||||
'target equal to the only working language should be flagged redundant',
|
||||
);
|
||||
assert(
|
||||
isTranslationTargetRedundant(' 简体中文 ', ['简体中文']) === true,
|
||||
'surrounding whitespace should not hide a redundant target',
|
||||
);
|
||||
|
||||
// 简→繁是真实转换,不能误判成空操作。
|
||||
assert(
|
||||
isTranslationTargetRedundant('繁体中文', ['简体中文']) === false,
|
||||
'simplified to traditional is a real conversion',
|
||||
);
|
||||
|
||||
// 多工作语言不拦:说中文出英文是正常用法。
|
||||
assert(
|
||||
isTranslationTargetRedundant('English', ['简体中文', 'English']) === false,
|
||||
'multiple working languages should never be flagged',
|
||||
);
|
||||
|
||||
// 没选目标语言时走「未启用」提示,不该同时报「冗余」。
|
||||
assert(
|
||||
isTranslationTargetRedundant('', ['简体中文']) === false,
|
||||
'an unset target is disabled, not redundant',
|
||||
);
|
||||
assert(
|
||||
isTranslationTargetRedundant('English', []) === false,
|
||||
'no working languages means nothing to compare against',
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
// 翻译目标语言的可用性判定,与后端 `types.rs::translation_effective` 保持同一套规则。
|
||||
// 后端在按下翻译修饰键时用它决定是否进入翻译管线;这里只负责在翻译页提前把「设了但
|
||||
// 不会生效」的组合告诉用户,避免出现「按了 Shift 却什么也没翻」的沉默失败。
|
||||
|
||||
/** 未选择目标语言 = 翻译功能未启用(Shift 无效)。 */
|
||||
export function isTranslationEnabled(translationTargetLanguage: string): boolean {
|
||||
return translationTargetLanguage.trim() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标语言与用户「唯一的」工作语言相同 —— 源语言必定就是目标语言,翻译是可证的空操作。
|
||||
*
|
||||
* 工作语言有多个时返回 false:中/英双语用户把目标设成英文是正常用法(说中文出英文),
|
||||
* 源语言无法预先判定,不能拦。简体/繁体是语言列表里两个独立条目,按字面比较即可,
|
||||
* 简→繁不会被误判成空操作。
|
||||
*/
|
||||
export function isTranslationTargetRedundant(
|
||||
translationTargetLanguage: string,
|
||||
workingLanguages: readonly string[],
|
||||
): boolean {
|
||||
const target = translationTargetLanguage.trim();
|
||||
if (target === '') return false;
|
||||
return workingLanguages.length === 1 && workingLanguages[0].trim() === target;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Card, PageHeader } from './_atoms';
|
||||
import { SavedToast } from '../components/SavedToast';
|
||||
import { SelectLite } from '../components/ui/SelectLite';
|
||||
import { SUPPORTED_LANGUAGES } from '../lib/types';
|
||||
import { isTranslationEnabled, isTranslationTargetRedundant } from '../lib/translationTarget';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { formatComboLabel } from '../lib/hotkey';
|
||||
import type { UserPreferences } from '../lib/types';
|
||||
@@ -119,7 +120,13 @@ export function Translation() {
|
||||
|
||||
const triggerLabel = formatComboLabel(prefs.dictationHotkey);
|
||||
const translationHotkeyLabel = formatComboLabel(prefs.translationHotkey);
|
||||
const enabled = prefs.translationTargetLanguage.trim() !== '';
|
||||
// 「已启用」= 选了目标语言 **且** 该目标真的会触发翻译。目标等于唯一工作语言时后端
|
||||
// 走普通润色,状态灯不能还亮着说已启用(否则用户按 Shift 什么都没发生,无从排查)。
|
||||
const redundantTarget = isTranslationTargetRedundant(
|
||||
prefs.translationTargetLanguage,
|
||||
prefs.workingLanguages,
|
||||
);
|
||||
const enabled = isTranslationEnabled(prefs.translationTargetLanguage) && !redundantTarget;
|
||||
|
||||
const targetOptions = useMemo(() => ([
|
||||
{ value: '', label: t('translation.target.disabled') },
|
||||
@@ -230,6 +237,23 @@ export function Translation() {
|
||||
ariaLabel={t('translation.target.title')}
|
||||
style={{ width: '100%', maxWidth: 360, fontSize: 13 }}
|
||||
/>
|
||||
{redundantTarget && (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: '0.5px solid rgba(217,119,6,0.24)',
|
||||
background: 'rgba(217,119,6,0.08)',
|
||||
color: 'var(--ol-warn, #b45309)',
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{t('translation.target.sameAsWorking')}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 3. 使用方法 */}
|
||||
|
||||
Reference in new issue
Block a user