Merge branch 'main' into feature/key-binding
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
13 files changed
+364
-30
No files matched your search
@@ -13,7 +13,8 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul
|
||||
use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
|
||||
use crate::types::{
|
||||
ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability,
|
||||
HotkeyStatus, PolishMode, ShortcutBinding, UserPreferences, WindowsImeStatus,
|
||||
HotkeyStatus, PolishMode, ShortcutBinding, UserPreferences, VocabPresetStore,
|
||||
WindowsImeStatus,
|
||||
};
|
||||
|
||||
type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
|
||||
@@ -401,6 +402,16 @@ pub fn set_vocab_enabled(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vocab_presets() -> Result<VocabPresetStore, String> {
|
||||
crate::persistence::list_vocab_presets().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_vocab_presets(store: VocabPresetStore) -> Result<(), String> {
|
||||
crate::persistence::save_vocab_presets(&store).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ─────────────────────────── dictation lifecycle ───────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -206,6 +206,8 @@ pub fn run() {
|
||||
commands::add_vocab,
|
||||
commands::remove_vocab,
|
||||
commands::set_vocab_enabled,
|
||||
commands::list_vocab_presets,
|
||||
commands::save_vocab_presets,
|
||||
commands::start_dictation,
|
||||
commands::stop_dictation,
|
||||
commands::cancel_dictation,
|
||||
@@ -666,12 +668,10 @@ pub(crate) fn show_qa_window<R: tauri::Runtime>(app: &AppHandle<R>, content_kind
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if !show_qa_window_no_activate(&window) {
|
||||
log::warn!("[qa] show_no_activate failed; falling back to window.show()");
|
||||
if let Err(e) = window.show() {
|
||||
log::warn!("[qa] show fallback failed: {e}");
|
||||
}
|
||||
if !show_qa_window_no_activate(&window) {
|
||||
log::warn!("[qa] show_no_activate failed; falling back to window.show()");
|
||||
if let Err(e) = window.show() {
|
||||
log::warn!("[qa] show fallback failed: {e}");
|
||||
}
|
||||
}
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
@@ -724,10 +724,7 @@ pub(crate) fn hide_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) {
|
||||
fn show_qa_window_no_activate<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> bool {
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE,
|
||||
SWP_SHOWWINDOW, SW_SHOWNOACTIVATE,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWNOACTIVATE};
|
||||
|
||||
let Ok(handle) = window.window_handle() else {
|
||||
return false;
|
||||
@@ -741,17 +738,6 @@ fn show_qa_window_no_activate<R: tauri::Runtime>(window: &tauri::WebviewWindow<R
|
||||
}
|
||||
|
||||
let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
|
||||
let _ = unsafe {
|
||||
SetWindowPos(
|
||||
hwnd,
|
||||
HWND_TOPMOST,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW | SWP_NOACTIVATE,
|
||||
)
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::{DictationSession, DictionaryEntry, UserPreferences};
|
||||
use crate::types::{DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore};
|
||||
|
||||
const HISTORY_CAP: usize = 200;
|
||||
const HISTORY_FILE: &str = "history.json";
|
||||
@@ -30,6 +30,7 @@ const PREFERENCES_FILE: &str = "preferences.json";
|
||||
/// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名,
|
||||
/// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。
|
||||
const VOCAB_FILE: &str = "dictionary.json";
|
||||
const VOCAB_PRESETS_FILE: &str = "vocab-presets.json";
|
||||
|
||||
/// Swift 老 `CredentialsVault` 的 JSON 备用路径。
|
||||
/// 升级到 Tauri 版后,先尝试 Keychain;Keychain 没有时回落读这个文件,
|
||||
@@ -593,6 +594,20 @@ fn count_occurrences(haystack: &str, needle: &str) -> u64 {
|
||||
count
|
||||
}
|
||||
|
||||
pub fn list_vocab_presets() -> Result<VocabPresetStore> {
|
||||
let dir = data_dir()?;
|
||||
ensure_dir(&dir)?;
|
||||
read_or_default::<VocabPresetStore>(&dir.join(VOCAB_PRESETS_FILE))
|
||||
}
|
||||
|
||||
pub fn save_vocab_presets(store: &VocabPresetStore) -> Result<()> {
|
||||
let dir = data_dir()?;
|
||||
ensure_dir(&dir)?;
|
||||
let path = dir.join(VOCAB_PRESETS_FILE);
|
||||
let json = serde_json::to_vec_pretty(store).context("encode vocab presets failed")?;
|
||||
atomic_write(&path, &json)
|
||||
}
|
||||
|
||||
// ───────────────────────── CredentialsVault ─────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -719,3 +734,37 @@ impl CredentialsVault {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{list_vocab_presets, save_vocab_presets};
|
||||
use crate::types::{VocabPreset, VocabPresetStore};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn vocab_presets_roundtrip_json_file() {
|
||||
let tmp: PathBuf = std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&tmp).expect("create temp dir");
|
||||
// Linux path helper uses XDG_DATA_HOME first.
|
||||
unsafe {
|
||||
std::env::set_var("XDG_DATA_HOME", &tmp);
|
||||
}
|
||||
let store = VocabPresetStore {
|
||||
custom: vec![VocabPreset {
|
||||
id: "test".into(),
|
||||
name: "测试".into(),
|
||||
phrases: vec!["PR".into(), "CI".into()],
|
||||
}],
|
||||
overrides: vec![],
|
||||
disabled_builtin_preset_ids: vec!["chef".into()],
|
||||
};
|
||||
save_vocab_presets(&store).expect("save presets");
|
||||
let loaded = list_vocab_presets().expect("list presets");
|
||||
assert_eq!(loaded.custom.len(), 1);
|
||||
assert_eq!(loaded.custom[0].id, "test");
|
||||
assert_eq!(loaded.custom[0].phrases, vec!["PR".to_string(), "CI".to_string()]);
|
||||
assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]);
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,22 @@ pub struct DictionaryEntry {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VocabPreset {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub phrases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct VocabPresetStore {
|
||||
pub custom: Vec<VocabPreset>,
|
||||
pub overrides: Vec<VocabPreset>,
|
||||
pub disabled_builtin_preset_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -166,6 +166,17 @@ export const en: typeof zhCN = {
|
||||
tipDisabled: 'Click to disable this entry',
|
||||
tipEnabled: 'Click to enable this entry',
|
||||
removeAria: 'Remove',
|
||||
presets: {
|
||||
title: 'Scenario presets',
|
||||
tip: 'Multi-select to apply in batch; supports edit/create and keeps local preset structure ready for future import/export.',
|
||||
create: 'New preset',
|
||||
apply: 'Apply selected',
|
||||
save: 'Save preset',
|
||||
edit: 'Edit {{name}}',
|
||||
newPreset: 'New preset',
|
||||
namePlaceholder: 'Preset name',
|
||||
wordsPlaceholder: 'Terms (comma or newline separated)',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
kicker: 'STYLE',
|
||||
|
||||
@@ -164,6 +164,17 @@ export const zhCN = {
|
||||
tipDisabled: '点击禁用此词条',
|
||||
tipEnabled: '点击启用此词条',
|
||||
removeAria: '删除',
|
||||
presets: {
|
||||
title: '场景预设',
|
||||
tip: '可多选后批量启用;支持编辑和新建,已为后续导入导出预留本地结构。',
|
||||
create: '新建预设',
|
||||
apply: '启用所选',
|
||||
save: '保存预设',
|
||||
edit: '编辑 {{name}}',
|
||||
newPreset: '新预设',
|
||||
namePlaceholder: '预设名称',
|
||||
wordsPlaceholder: '词条(用逗号或换行分隔)',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
kicker: 'STYLE',
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
ShortcutBinding,
|
||||
UserPreferences,
|
||||
WindowsImeStatus,
|
||||
VocabPreset,
|
||||
VocabPresetStore,
|
||||
} from './types';
|
||||
import { OL_DATA } from './mockData';
|
||||
|
||||
@@ -207,6 +209,18 @@ export function setVocabEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined);
|
||||
}
|
||||
|
||||
export function listVocabPresets(): Promise<VocabPresetStore> {
|
||||
return invokeOrMock('list_vocab_presets', undefined, () => ({
|
||||
custom: [],
|
||||
overrides: [],
|
||||
disabledBuiltinPresetIds: [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function saveVocabPresets(store: VocabPresetStore): Promise<void> {
|
||||
return invokeOrMock('save_vocab_presets', { store }, () => undefined);
|
||||
}
|
||||
|
||||
// ── Dictation lifecycle ────────────────────────────────────────────────
|
||||
export function startDictation(): Promise<void> {
|
||||
return invokeOrMock('start_dictation', undefined, () => undefined);
|
||||
|
||||
@@ -29,6 +29,18 @@ export interface DictionaryEntry {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VocabPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
phrases: string[];
|
||||
}
|
||||
|
||||
export interface VocabPresetStore {
|
||||
custom: VocabPreset[];
|
||||
overrides: VocabPreset[];
|
||||
disabledBuiltinPresetIds: string[];
|
||||
}
|
||||
|
||||
export type HotkeyTrigger =
|
||||
| 'rightOption'
|
||||
| 'leftOption'
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"id": "programmer",
|
||||
"name": "程序员",
|
||||
"phrases": ["PR", "CI", "tag", "release", "issue", "Rust", "TypeScript"]
|
||||
},
|
||||
{
|
||||
"id": "chef",
|
||||
"name": "厨师",
|
||||
"phrases": ["出品", "备料", "火候", "刀工", "摆盘", "sous vide"]
|
||||
},
|
||||
{
|
||||
"id": "civil-servant",
|
||||
"name": "公务员",
|
||||
"phrases": ["公文", "批示", "督办", "政务", "会签", "材料"]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
import defaultPresetsJson from './vocab-presets.json';
|
||||
import { listVocabPresets, saveVocabPresets } from './ipc';
|
||||
import type { VocabPreset, VocabPresetStore } from './types';
|
||||
|
||||
export const DEFAULT_VOCAB_PRESETS: VocabPreset[] = defaultPresetsJson as VocabPreset[];
|
||||
|
||||
export async function loadVocabPresets(): Promise<VocabPreset[]> {
|
||||
const store = await listVocabPresets();
|
||||
const builtin = new Map(DEFAULT_VOCAB_PRESETS.map(p => [p.id, p] as const));
|
||||
for (const id of store.disabledBuiltinPresetIds || []) {
|
||||
builtin.delete(id);
|
||||
}
|
||||
for (const preset of store.overrides || []) {
|
||||
if (!preset || !preset.id) continue;
|
||||
if (builtin.has(preset.id)) builtin.set(preset.id, preset);
|
||||
}
|
||||
const custom = (store.custom || []).filter(p => p && p.id);
|
||||
return [...builtin.values(), ...custom];
|
||||
}
|
||||
|
||||
export async function persistVocabPresets(presets: VocabPreset[]) {
|
||||
const builtinMap = new Map(DEFAULT_VOCAB_PRESETS.map(p => [p.id, p] as const));
|
||||
const store: VocabPresetStore = {
|
||||
custom: [],
|
||||
overrides: [],
|
||||
disabledBuiltinPresetIds: [],
|
||||
};
|
||||
const seenBuiltin = new Set<string>();
|
||||
for (const preset of presets) {
|
||||
const base = builtinMap.get(preset.id);
|
||||
if (!base) {
|
||||
store.custom.push(preset);
|
||||
continue;
|
||||
}
|
||||
seenBuiltin.add(preset.id);
|
||||
if (JSON.stringify(base) !== JSON.stringify(preset)) {
|
||||
store.overrides.push(preset);
|
||||
}
|
||||
}
|
||||
for (const id of builtinMap.keys()) {
|
||||
if (!seenBuiltin.has(id)) {
|
||||
store.disabledBuiltinPresetIds.push(id);
|
||||
}
|
||||
}
|
||||
await saveVocabPresets(store);
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { disable as disableAutostart, enable as enableAutostart, isEnabled as isAutostartEnabled } from '@tauri-apps/plugin-autostart';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { ShortcutRecorder } from '../components/ShortcutRecorder';
|
||||
import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate';
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
checkMicrophonePermission,
|
||||
getHotkeyStatus,
|
||||
getWindowsImeStatus,
|
||||
isTauri,
|
||||
openExternal,
|
||||
openSystemSettings,
|
||||
listProviderModels,
|
||||
@@ -57,6 +57,21 @@ export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permi
|
||||
|
||||
const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language', 'about'];
|
||||
|
||||
async function autostartIsEnabled(): Promise<boolean> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
return invoke<boolean>('plugin:autostart|is_enabled');
|
||||
}
|
||||
|
||||
async function autostartEnable(): Promise<void> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('plugin:autostart|enable');
|
||||
}
|
||||
|
||||
async function autostartDisable(): Promise<void> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('plugin:autostart|disable');
|
||||
}
|
||||
|
||||
export function Settings({ embedded = false, initialSection = 'recording' }: SettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [section, setSection] = useState<SettingsSectionId>(initialSection);
|
||||
@@ -266,15 +281,19 @@ function AutostartRow() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
isAutostartEnabled()
|
||||
.then(v => {
|
||||
autostartIsEnabled()
|
||||
.then((v: boolean) => {
|
||||
if (!cancelled) {
|
||||
setEnabled(v);
|
||||
setLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err: unknown) => {
|
||||
console.error('[autostart] isEnabled failed', err);
|
||||
if (!cancelled) setLoaded(true);
|
||||
});
|
||||
@@ -287,8 +306,9 @@ function AutostartRow() {
|
||||
setEnabled(next);
|
||||
setError(null);
|
||||
try {
|
||||
if (next) await enableAutostart();
|
||||
else await disableAutostart();
|
||||
if (!isTauri) return;
|
||||
if (next) await autostartEnable();
|
||||
else await autostartDisable();
|
||||
} catch (err) {
|
||||
console.error('[autostart] toggle failed', err);
|
||||
setEnabled(!next);
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { addVocab, isTauri, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc';
|
||||
import type { DictionaryEntry } from '../lib/types';
|
||||
import type { DictionaryEntry, VocabPreset } from '../lib/types';
|
||||
import { DEFAULT_VOCAB_PRESETS, loadVocabPresets, persistVocabPresets } from '../lib/vocabPresets';
|
||||
import { Btn, Card, PageHeader } from './_atoms';
|
||||
|
||||
const NEW_PRESET_DRAFT_ID = '__new__';
|
||||
|
||||
export function Vocab() {
|
||||
const { t } = useTranslation();
|
||||
const [entries, setEntries] = useState<DictionaryEntry[]>([]);
|
||||
@@ -14,6 +17,11 @@ export function Vocab() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [presets, setPresets] = useState<VocabPreset[]>(DEFAULT_VOCAB_PRESETS);
|
||||
const [selectedPresetIds, setSelectedPresetIds] = useState<string[]>([]);
|
||||
const [editingPresetId, setEditingPresetId] = useState<string | null>(null);
|
||||
const [presetNameDraft, setPresetNameDraft] = useState('');
|
||||
const [presetPhrasesDraft, setPresetPhrasesDraft] = useState('');
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
@@ -30,6 +38,9 @@ export function Vocab() {
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
void loadVocabPresets()
|
||||
.then(setPresets)
|
||||
.catch(err => setError(err instanceof Error ? err.message : String(err)));
|
||||
// 订阅后端 vocab:updated:每段口述结束、record_hits 触发后由 coordinator 推送。
|
||||
// Vocab 页面打开期间能即时看到命中数累加,无需切到其他 tab 再切回。
|
||||
if (!isTauri) return;
|
||||
@@ -82,6 +93,89 @@ export function Vocab() {
|
||||
}
|
||||
};
|
||||
|
||||
const togglePreset = (id: string) => {
|
||||
setSelectedPresetIds(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const startEditPreset = (preset: VocabPreset) => {
|
||||
setEditingPresetId(preset.id);
|
||||
setPresetNameDraft(preset.name);
|
||||
setPresetPhrasesDraft(preset.phrases.join(', '));
|
||||
};
|
||||
|
||||
const savePreset = async () => {
|
||||
if (!editingPresetId) return;
|
||||
const name = presetNameDraft.trim();
|
||||
if (!name) return;
|
||||
const phrases = Array.from(
|
||||
new Set(
|
||||
presetPhrasesDraft
|
||||
.split(/[,\n]/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const next =
|
||||
editingPresetId === NEW_PRESET_DRAFT_ID
|
||||
? [...presets, { id: `user-${Date.now()}`, name, phrases }]
|
||||
: presets.map(p => (p.id === editingPresetId ? { ...p, name, phrases } : p));
|
||||
try {
|
||||
await persistVocabPresets(next);
|
||||
setPresets(next);
|
||||
setEditingPresetId(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const createPreset = () => {
|
||||
setEditingPresetId(NEW_PRESET_DRAFT_ID);
|
||||
setPresetNameDraft(t('vocab.presets.newPreset'));
|
||||
setPresetPhrasesDraft('');
|
||||
};
|
||||
|
||||
const applySelectedPresets = async () => {
|
||||
const selected = presets.filter(p => selectedPresetIds.includes(p.id));
|
||||
if (selected.length === 0) return;
|
||||
const byPhrase = new Map<string, DictionaryEntry[]>();
|
||||
const addedPhrases = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const key = entry.phrase.trim().toLowerCase();
|
||||
if (!byPhrase.has(key)) byPhrase.set(key, []);
|
||||
byPhrase.get(key)?.push(entry);
|
||||
}
|
||||
let failures = 0;
|
||||
for (const p of selected) {
|
||||
for (const phrase of p.phrases) {
|
||||
const key = phrase.trim().toLowerCase();
|
||||
if (addedPhrases.has(key)) continue;
|
||||
const existing = byPhrase.get(key) || [];
|
||||
if (existing.length === 0) {
|
||||
try {
|
||||
await addVocab(phrase);
|
||||
addedPhrases.add(key);
|
||||
} catch {
|
||||
failures += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const item of existing) {
|
||||
if (!item.enabled) {
|
||||
try {
|
||||
await setVocabEnabled(item.id, true);
|
||||
} catch {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
if (failures > 0) {
|
||||
setError(`部分词条添加失败(${failures})`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -95,6 +189,48 @@ export function Vocab() {
|
||||
}
|
||||
/>
|
||||
<Card padding={0}>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 12 }}>{t('vocab.presets.title')}</strong>
|
||||
{presets.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => togglePreset(p.id)}
|
||||
style={{
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
borderRadius: 999,
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
background: selectedPresetIds.includes(p.id) ? 'var(--ol-blue-soft)' : 'var(--ol-surface-2)',
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
<Btn size="sm" variant="ghost" onClick={createPreset}>{t('vocab.presets.create')}</Btn>
|
||||
<Btn size="sm" variant="primary" onClick={applySelectedPresets}>{t('vocab.presets.apply')}</Btn>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 10 }}>{t('vocab.presets.tip')}</div>
|
||||
{editingPresetId && (
|
||||
<div style={{ marginTop: 10, display: 'grid', gap: 8 }}>
|
||||
<input value={presetNameDraft} onChange={e => setPresetNameDraft(e.target.value)} placeholder={t('vocab.presets.namePlaceholder')} />
|
||||
<textarea value={presetPhrasesDraft} onChange={e => setPresetPhrasesDraft(e.target.value)} placeholder={t('vocab.presets.wordsPlaceholder')} rows={3} />
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Btn size="sm" variant="primary" onClick={() => void savePreset()}>{t('vocab.presets.save')}</Btn>
|
||||
<Btn size="sm" variant="ghost" onClick={() => setEditingPresetId(null)}>{t('common.cancel')}</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!editingPresetId && presets.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{presets.map(p => (
|
||||
<Btn key={`${p.id}-edit`} size="sm" variant="ghost" onClick={() => startEditPreset(p)}>
|
||||
{t('vocab.presets.edit', { name: p.name })}
|
||||
</Btn>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
declare module '@tauri-apps/plugin-autostart' {
|
||||
export function enable(): Promise<void>;
|
||||
export function disable(): Promise<void>;
|
||||
export function isEnabled(): Promise<boolean>;
|
||||
}
|
||||
Reference in new issue
Block a user