yummyTranslator

Yuan.Sn

最近阅读英文文献,发现原有翻译流 (沙拉划词+Quiker) 由于Chrome安全策略原因失效。加之本人有学习英语的兴趣及其阅读英文文档的需求,萌生了开发一款 Translator Hub 的想法。因此 Yummy Translator 诞生。此LOG记录整个项目的开发历程。

建项、架构确立

确定了以 React + Tauri的架构开发, 前端采用 TS 后端用 Rust

模块 核心组件 技术选型/设计模式 主要职责与优势
应用框架 桌面端应用 Tauri (Rust + Web UI) 高性能、低资源占用,提供原生级的体验。
表现层 用户界面 (UI) Web技术 (React) 利用现代前端生态,实现高效、美观、灵活的界面开发。
核心逻辑层 Rust 后端 Tauri Command & Event System Rust保证了后端的稳定、安全和高并发处理能力。
服务层 翻译服务 适配器模式 (Adapter Pattern) 高扩展性、低耦合,未来增加新的翻译源无需修改核心代码。
服务层 输入修正服务 纯本地离线库 瞬时响应、完全离线、保护隐私,提升核心输入体验。
数据层 本地化存储 SQLite 数据库 功能强大、数据安全,为未来复杂的单词本功能铺平道路。

以 MVP的策略开发,先构建第一版v1 实现最基础的翻译功能

  • 程序主界面
  • 翻译结果界面
  • 翻译API接入
  • 本地化存储词典
  • 输入修正(修正 多余复制进去的标点、以及拼错情况)

V 0.1.0(Prompt工程)

对于 React + Tauri 的前端开发, 只有些许 H5 DOM的开发经验。为了提升整个开发效率,采用Vibe Coding形式开发。由于成本限制 本项目主要采用 Github Copilot 模型开发 claude-sonnet-4-5-20250929

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/*
* Project Brief for GitHub Copilot
*
* Project Name: Yummy Translator
*
* ## 1. Project Vision
* A high-performance, multi-engine desktop translation application built with Tauri. The primary goal is to provide English learners and professionals with accurate and context-rich translations by aggregating results from multiple sources. The application must be fast, lightweight, and respect user privacy.
*
* ## 2. Core Features (Version 1.0)
* - Main UI: A clean interface for text input and displaying multiple translation results side-by-side.
* - Multi-API Aggregation: Fetch and display translation results simultaneously from different providers (e.g., Google Translate, DeepL, etc.).
* - Input Correction:
* - Automatically sanitize pasted text (e.g., remove extra line breaks).
* - Provide fast, local spell-checking for English input.
* - Local Storage:
* - Store translation history.
* - A basic vocabulary book to save words and phrases.
*
* ## 3. V1 Technical Architecture & Decisions
* We have made the following architectural decisions. Please generate code that aligns with this stack and these patterns.
*
* - **Framework:** Tauri (Rust backend, web-based frontend).
* - **Backend Language:** Rust.
* - **Frontend Framework:** Modern web framework (e.g., Vue.js or React) using TypeScript.
* - **Database:** SQLite for all local data storage.
* - It will manage tables for `settings`, `translation_history`, and `vocabulary_book`.
*
* - **Design Pattern for Translation APIs:** We will use the **Adapter Pattern**.
* - A core `Translator` trait will define the common interface.
* - Each translation service (Google, DeepL, etc.) will be implemented as a separate struct that implements this `Translator` trait.
* - Example of the core trait in Rust:
* ```rust
* pub trait Translator {
* fn name(&self) -> &str;
* async fn translate(&self, text: &str, source_lang: &str, target_lang: &str) -> Result<String, anyhow::Error>;
* }
* ```
*
* - **Input Correction Service:** This must be a **purely local and offline** feature for speed and privacy.
* - Use Rust's native string manipulation and regex for punctuation sanitization.
* - Use a local, dictionary-based Rust crate for spell-checking (e.g., a hunspell binding or similar). **Do not use online APIs for this feature.**
*
* - **State Management:** The Rust backend will be the single source of truth. The frontend will communicate with the backend via Tauri commands for data fetching and mutations.
*
* I will now start building this "Yummy Translator" project with your assistance based on the above specifications.
*/

输入关键词后,进过多次 prompt修正 形成第一版 DEMO页

2025-09-15 22-00-57_3
2025-09-15 22-00-57_3

9.15(架构逻辑)

项目的基本结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
YUMMYTRANSLATOR
├── ......
├── src/
├── assets/
├── components/ #组件库
├── SearchBox/
├── SearchBox.tsx #主搜索框
└── SuggestionDropdown.tsx #下拉建议
└── SearchResult/
└── ResultPanel.tsx #结果页
├── App.css
├── App.tsx #状态管理、组件调用
├── main.tsx #程序入口
└── vite-env.d.ts
├── ......

采用了单向数据流 (One-Way Data Flow)状态提升 (Lifting State Up) 的架构方案,App 组件通过 props 将状态数据传递给子组件(SearchBoxSuggestionDropdown.tsx etc...),子组件再将UI样式回传回 App进行调用渲染。

9.16(组件及其参数)

剖析一下程序的组件及其参数

App.tsx

先把主要控制处理组件 App 列出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119

import React, { useState, useRef } from "react";
import { Box } from "@mui/material";
import SearchBox from "./components/SearchBox/SearchBox";
import SuggestionDropdown from "./components/SearchBox/SuggestionDropdown";
import ResultPanel from "./components/SearchResult/ResultPanel";

const SUGGESTIONS = [
"apple", "abandon", "absolutely", "ash", "accommodation", "banana", "cat", "dog", "elephant", "fish", "grape", "house", "ice", "juice",
"kite", "lemon", "mountain", "night", "orange", "pencil", "queen", "river", "sun", "tree", "umbrella", "violin", "water", "xylophone", "yogurt", "zebra",
"book", "car", "desk", "egg", "flower", "garden", "hat", "island", "jacket", "key", "lamp", "moon", "nest", "ocean", "pizza", "quiet", "road", "star", "train", "unicorn", "vase", "window", "yard", "zipper"
];

function App() {
/**
* @variable {string} searchValue - 当前搜索框输入内容
* @variable {boolean} showDropdown - 是否显示下拉建议
* @variable {boolean} isActivated - 是否激活结果区
* @variable {Array<{ name: string; result: string }>} results - 翻译结果列表
* @variable {React.RefObject<HTMLInputElement>} inputRef - 搜索框的引用
*/

const [searchValue, setSearchValue] = useState("");
const [showDropdown, setShowDropdown] = useState(false);
const [isActivated, setIsActivated] = useState(false);
const [results, setResults] = useState<Array<{ name: string; result: string }>>([]);
const inputRef = useRef<HTMLInputElement>(null);

// 输入内容 过滤建议
const filteredSuggestions = SUGGESTIONS.filter(
(word) => searchValue && word.startsWith(searchValue.toLowerCase())
);

// 模拟API返回结果(仅演示用)
function getApiResults(word: string): Array<{ name: string; result: string }> {
return [
{
name: "Google 翻译",
result: `${word} 的 Google 翻译结果占位。`
},
{
name: "DeepL 翻译",
result: `${word} 的 DeepL 翻译结果占位。`
},
{
name: "有道翻译",
result: `${word} 的有道翻译结果占位。`
}
];
}


// 处理下拉选项点击或回车选择
function handleSelect(word: string) {
setSearchValue(word);
setShowDropdown(false);
setIsActivated(true);
setResults(getApiResults(word));
}

// 处理输入框内容变化
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setSearchValue(value);
setShowDropdown(!!value && filteredSuggestions.length > 0);
}

// 处理回车键事件
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" && searchValue) {
setShowDropdown(false);
setIsActivated(true);
setResults(getApiResults(searchValue));
}
}

return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
background: "none",
}}
>
{/* 搜索框和下拉建议 */}
<Box
sx={{
position: isActivated ? "fixed" : "absolute",
top: isActivated ? 32 : "50%",
left: "50%",
transform: isActivated ? "translate(-50%, 0)" : "translate(-50%, -50%)",
width: 400,
maxWidth: "90vw",
zIndex: 10,
transition: "top 0.5s, transform 0.5s",
}}
>
<SearchBox
value={searchValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
inputRef={inputRef}
/>
<SuggestionDropdown
suggestions={showDropdown ? filteredSuggestions : []}
onSelect={handleSelect}
/>
</Box>

{/* 结果区 */}
<ResultPanel results={results} />
</Box>
);
}

export default App;

组件 (Components)

  • Box: (来自 @mui/material) 用于布局和样式化的容器组件

  • SearchBox: 自定义的搜索框组件

  • SuggestionDropdown:自定义的搜索建议下拉框组件

  • ResultPanel: 自定义的结果展示面板组件。

变量 / 状态 (Variables / State)

  • SUGGESTIONS(arr): 一个建议单词测试数组,数量优先 后期会修改

  • filteredSuggestions(arr):根据 searchValue 从 SUGGESTIONS 中过滤出的建议列表。

  • searchValue (String): 存储当前搜索框中的输入内容

  • showDropdown(Boolen):是否显示下拉建议

  • isActivated(Boolen): 是否处于结果展示模式

  • results(arr): 存储模拟 API 返回的翻译结果 ,后期修改接入API

  • inputRef(useRef): 获取 SearchBox 内部 input 元素的直接引用

函数 (Functions)

  • getApiResults(word): 模拟 API 请求,根据输入的单词返回一个包含多个翻译结果的数组。

  • handleSelect(word): 处理用户在下拉建议中选择一项的逻辑。

  • handleInputChange(e): 处理输入框内容变化的逻辑。

  • handleKeyDown(e): 处理在输入框中按下键盘(特别是回车键)的逻辑。

SearchBox.tsx

然后是搜索组件 SerchBox

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import React from "react";
import { Box, TextField } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";

interface SearchBoxProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
inputRef: React.RefObject<HTMLInputElement | null>;
}

// 搜索框组件
const SearchBox: React.FC<SearchBoxProps> = ({ value, onChange, onKeyDown, inputRef }) => (
<Box
sx={{
display: "flex",
alignItems: "center",
boxShadow: 2,
px: 2,
py: 1.5,
borderRadius: "32px",
background: "#fff",
border: "1px solid #e0e0e0",
}}
>
<SearchIcon color="primary" sx={{ mr: 2 }} />
<TextField
inputRef={inputRef}
fullWidth
placeholder="请输入查询单词..."
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
autoFocus
variant="standard"
InputProps={{
disableUnderline: true,
sx: {
fontSize: "1.25rem",
background: "none",
},
}}
/>
</Box>
);

export default SearchBox;

组件 (Components)

  • SearchBox: 组件本身,渲染带样式的输入框

  • Box: (@mui/material) 作为 SearchBox 的容器

  • TextField: (@mui/material) 核心的文本输入框组件

  • SearchIcon: (@mui/icons-material/Search) 搜索图标组件

参数 (Parameters)

  • SearchBox 组件通过 Props 接收参数:

    • value: (string) 需要在输入框中显示的值。

    • onChange: (function) 当输入框内容改变时需要触发的回调函数。

    • onKeyDown: (function) 当在输入框中按下按键时需要触发的回调函数。

    • inputRef: (object) 从父组件传递过来的 ref 对象。

SuggestionDropdown.tsx

再是搜索建议组件 SuggestionDropdown

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import React from "react";
import { Box, List, ListItem, ListItemButton } from "@mui/material";

interface SuggestionDropdownProps {
suggestions: string[];
onSelect: (word: string) => void;
}

// 下拉建议组件
const SuggestionDropdown: React.FC<SuggestionDropdownProps> = ({ suggestions, onSelect }) => {
if (suggestions.length === 0) return null;
return (
<Box
sx={{
mt: 1,
boxShadow: 3,
borderRadius: "22px",
background: "#fff",
border: "1px solid #e0e0e0",
maxHeight: 300,
overflowY: "auto",
}}
>
<List sx={{ p: 0 }}>
{suggestions.map((word) => (
<ListItem key={word} disablePadding sx={{ borderRadius: "24px" }}>
<ListItemButton
onClick={() => onSelect(word)}
sx={{
borderRadius: "24px",
px: 3,
py: 1.5,
fontSize: "1.1rem",
color: "#333",
'&:hover': {
background: "#f5f5f5",
},
}}
>
{word}
</ListItemButton>
</ListItem>
))}
</List>
</Box>
);
};

export default SuggestionDropdown;

组件 (Components)

  • Box, List, ListItem, ListItemButton: (@mui/material) 构建列表结构的 UI 组件

参数 (Parameters)

  • SuggestionDropdown 组件通过 Props 接收参数:

    • suggestions: (string[]) 需要在列表中显示的建议单词数组

    • onSelect: (function) 当用户点击某一个建议项时需要触发的回调函数

  • 在 .map() 循环内部:

    • word: 代表 suggestions 数组中当前正在被渲染的单词

ResultPanel.tsx

最后是结果页 ResultPanel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import React from "react";
import { Box, Paper, Typography, TextField } from "@mui/material";

interface ResultPanelProps {
results: Array<{ name: string; result: string }>;
}

// 结果区组件
const ResultPanel: React.FC<ResultPanelProps> = ({ results }) => {
if (results.length === 0) return null;
return (
<Box sx={{ width: 400, maxWidth: "90vw", mt: 16 }}>
{results.map((api) => (
<Paper key={api.name} elevation={2} sx={{ p: 3, mb: 3, borderRadius: "18px" }}>
<Typography variant="h6" color="primary" sx={{ mb: 1 }}>
{api.name}
</Typography>
<TextField
multiline
fullWidth
value={api.result}
InputProps={{
readOnly: true,
sx: { fontSize: "1.1rem", background: "none" },
}}
variant="outlined"
/>
</Paper>
))}
</Box>
);
};

export default ResultPanel;

组件 (Components)

  • Box, Paper, Typography, TextField: (@mui/material) 用于构建结果卡片样式的 UI 组件

参数 (Parameters)

  • ResultPanel 组件通过 Props 接收参数:
    • results: (Array) 包含多个翻译结果对象的数组。
  • .map() 循环内部:
    • api: 代表 results 数组中当前正在被渲染的那个结果对象 (e.g., {name: "Google 翻译", result: "..." })。

v 0.1.1 (FTS5下拉建议匹配)

为了提高搜索效率,构建了下拉正则匹配建议框。采用本地数据库的方式存储单词,单词内容来自COCA前20000个常用词汇。

SQLite数据库构建

初始数据文件为 .txt 格式,需要将其转化为sql数据库文件,后端编写了构建的 Rust程序 build.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
//! # 构建脚本 build.rs
//!
//! 本脚本在编译时自动执行,负责:
//! 1. 从 COCA_20000.txt 文件生成 SQLite 数据库
//! 2. 创建 FTS5 全文搜索索引
//! 3. 配置 Tauri 构建环境
//!
//! ## 数据库结构
//! - `words_fts`: 主数据表,存储单词
//! - `words_search`: FTS5 虚拟表,提供全文搜索功能
//! - 自动触发器:保持主表和搜索表同步

use std::path::Path;

/// 构建脚本主函数
///
/// 执行顺序:
/// 1. 构建数据库(必须在 Tauri 构建之前完成)
/// 2. 执行 Tauri 构建配置
fn main() {
println!("cargo:warning=Build script started...");

// 添加 COCA 文件作为依赖,当文件改变时触发重新构建
println!("cargo:rerun-if-changed=../COCA_20000.txt");

// 数据库构建逻辑 - 必须在 tauri_build::build() 之前运行
// 因为 Tauri 会检查资源文件的存在性
match build_database() {
Ok(_) => println!("cargo:warning=Database build script executed successfully"),
Err(e) => {
println!("cargo:warning=Failed to build database: {}", e);
// 不中断构建,只输出警告
}
}

// Tauri 构建必须在数据库创建之后运行
tauri_build::build();
}

/// 同步包装函数:构建数据库
///
/// 在同步上下文中创建 Tokio 运行时来执行异步数据库操作。
///
/// # 返回
/// * `Ok(())` - 数据库构建成功
/// * `Err(Box<dyn std::error::Error>)` - 构建失败的错误信息
fn build_database() -> Result<(), Box<dyn std::error::Error>> {
// 创建 Tokio 运行时以在同步函数中执行异步代码
let rt = tokio::runtime::Runtime::new()?;

rt.block_on(async {
build_database_async().await
})
}

/// 异步函数:执行数据库构建
///
/// 从 COCA_20000.txt 文件读取单词列表,创建 SQLite 数据库并建立 FTS5 全文搜索索引。
///
/// # 流程
/// 1. 检查是否需要重建(比较文件修改时间)
/// 2. 创建数据库文件和连接
/// 3. 创建表结构和 FTS5 虚拟表
/// 4. 导入单词数据
/// 5. 验证数据完整性
///
/// # 返回
/// * `Ok(())` - 数据库构建成功
/// * `Err(Box<dyn std::error::Error>)` - 构建失败的错误信息
async fn build_database_async() -> Result<(), Box<dyn std::error::Error>> {
use sqlx::{SqlitePool, Row};

// 获取 Cargo manifest 目录(即 src-tauri 目录)
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
println!("cargo:warning=CARGO_MANIFEST_DIR: {}", manifest_dir);

// 数据库文件路径:src-tauri/words.db
let db_path = Path::new(&manifest_dir).join("words.db");
// COCA 源文件路径:项目根目录/COCA_20000.txt
let coca_source_path = Path::new(&manifest_dir)
.parent()
.ok_or("Cannot get parent directory")?
.join("COCA_20000.txt");

println!("cargo:warning=Database path: {:?}", db_path);
println!("cargo:warning=COCA file path: {:?}", coca_source_path);

// 检查是否需要重建数据库
let needs_rebuild = if db_path.exists() {
if !coca_source_path.exists() {
false // 源文件不存在,不重建
} else {
// 比较文件修改时间,源文件更新则需要重建
let db_metadata = std::fs::metadata(&db_path)?;
let source_metadata = std::fs::metadata(&coca_source_path)?;
source_metadata.modified()? > db_metadata.modified()?

}
} else {
true // 数据库不存在,需要构建
};

if !needs_rebuild {
println!("cargo:warning=Database is up to date, skipping build");
return Ok(());
}

println!("cargo:warning=Starting database build...");

// 如果数据库已存在,先删除以确保数据是最新的
if db_path.exists() {
std::fs::remove_file(&db_path)?;
}

// 创建数据库连接
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
use std::str::FromStr;

// 构建 SQLite URL(Windows 路径需要转换反斜杠)
let db_url = format!("sqlite://{}", db_path.to_string_lossy().replace("\\", "/"));
println!("cargo:warning=Connecting to database: {}", db_url);

// 配置连接选项:自动创建文件,使用 WAL 日志模式
let options = SqliteConnectOptions::from_str(&db_url)?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal);

let pool = SqlitePool::connect_with(options).await?;

// 创建表结构
// 1. words_fts: 主表,存储单词数据
// 2. words_search: FTS5 虚拟表,提供全文搜索功能
// 3. 触发器:自动同步主表和搜索表的数据
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS words_fts (
word TEXT PRIMARY KEY
);

CREATE VIRTUAL TABLE IF NOT EXISTS words_search USING fts5(
word,
content='words_fts',
content_rowid='rowid'
);

CREATE TRIGGER IF NOT EXISTS words_ai AFTER INSERT ON words_fts BEGIN
INSERT INTO words_search(rowid, word) VALUES (new.rowid, new.word);
END;

CREATE TRIGGER IF NOT EXISTS words_ad AFTER DELETE ON words_fts BEGIN
INSERT INTO words_search(words_search, rowid, word) VALUES('delete', old.rowid, old.word);
END;

CREATE TRIGGER IF NOT EXISTS words_au AFTER UPDATE ON words_fts BEGIN
INSERT INTO words_search(words_search, rowid, word) VALUES('delete', old.rowid, old.word);
INSERT INTO words_search(rowid, word) VALUES (new.rowid, new.word);
END;
"#
)
.execute(&pool)
.await?;

println!("cargo:warning=Table structure created successfully");

// 检查源文件是否存在
if !coca_source_path.exists() {
println!("cargo:warning=Source file {:?} doesn't exist, skipping data import", coca_source_path);
pool.close().await;
return Ok(());
}

// 读取并解析单词文件
let content = std::fs::read_to_string(&coca_source_path)?;
let words: Vec<&str> = content.lines()
.map(|line| line.trim()) // 去除首尾空白
.filter(|word| !word.is_empty()) // 过滤空行
.collect();

println!("cargo:warning=Preparing to import {} words...", words.len());

// 使用事务批量插入数据(提高性能)
let mut tx = pool.begin().await?;

for word in words {
// 使用 INSERT OR IGNORE 避免重复插入
sqlx::query("INSERT OR IGNORE INTO words_fts (word) VALUES (?)")
.bind(word)
.execute(&mut *tx)
.await?;
}

// 提交事务
tx.commit().await?;

println!("cargo:warning=Database build completed!");

// 验证导入的单词数
let row = sqlx::query("SELECT COUNT(*) as count FROM words_fts")
.fetch_one(&pool)
.await?;
let count: i64 = row.get("count");
println!("cargo:warning=Database contains {} words", count);

pool.close().await;

// 将数据库复制到 target 目录(用于开发和测试)
copy_database_to_target(&manifest_dir, &db_path)?;

Ok(())
}

/// 将数据库复制到 target 目录
///
/// 在开发模式下,Tauri 从 target/debug 目录运行,需要确保数据库在该目录可用。
/// 在发布模式下,也需要将数据库复制到 target/release 目录。
///
/// **重要说明**:
/// - 在打包发布时,Tauri 会从 src-tauri/words.db 读取(通过 tauri.conf.json 的 resources 配置)
/// - 在开发运行时,需要复制到 target/debug/words.db
/// - 在构建发布版本时,需要复制到 target/release/words.db
///
/// # 参数
/// * `manifest_dir` - Cargo manifest 目录路径
/// * `db_path` - 源数据库文件路径
///
/// # 返回
/// * `Ok(())` - 复制成功
/// * `Err(Box<dyn std::error::Error>)` - 复制失败的错误信息
fn copy_database_to_target(
manifest_dir: &str,
db_path: &Path
) -> Result<(), Box<dyn std::error::Error>> {
let target_dir = Path::new(manifest_dir).join("target");

// 确保 target 目录存在
if !target_dir.exists() {
println!("cargo:warning=Target directory doesn't exist yet, will be created during build");
return Ok(());
}

// 复制到 debug 目录(开发模式)
let debug_dir = target_dir.join("debug");
if debug_dir.exists() {
let debug_db = debug_dir.join("words.db");
std::fs::copy(db_path, &debug_db)?;
println!("cargo:warning=Database copied to debug: {:?}", debug_db);
} else {
println!("cargo:warning=Debug directory not found, will be copied when debug build runs");
}

// 复制到 release 目录(发布模式)
let release_dir = target_dir.join("release");
if release_dir.exists() {
let release_db = release_dir.join("words.db");
std::fs::copy(db_path, &release_db)?;
println!("cargo:warning=Database copied to release: {:?}", release_db);
} else {
println!("cargo:warning=Release directory not found, will be copied when release build runs");
}

// 打包时的说明
println!("cargo:warning=For production builds, Tauri will bundle the database from: {:?}", db_path);
println!("cargo:warning=Make sure 'resources' in tauri.conf.json includes 'words.db'");

Ok(())
}

简而言之,就是Rust 利用 sqlx组件,通过tokio 异步构建出 FTS5的数据库结构 再将值插入进去 生成word.db的数据库文件, 并将其复制到运行目录下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
┌─────────────────────────────────────────────────────────────────────┐
│ YummyTranslator 数据库架构 │
│ (SQLite + FTS5) │
└─────────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────┐
│ words_fts (主表) │
├──────────────────────────────────────┤
│ • word TEXT [PRIMARY KEY] │ ← 存储所有单词
│ • rowid INTEGER (隐式) │ ← SQLite 自动生成的行ID
└──────────────────────────────────────┘

│ (通过触发器同步)

┌──────────────────────────────────────┐
│ words_search (FTS5虚拟表) │
├──────────────────────────────────────┤
│ • word TEXT │ ← 全文搜索索引
│ │
│ 配置: │
│ - content='words_fts' │ ← 内容来自 words_fts
│ - content_rowid='rowid' │ ← 关联 rowid
└──────────────────────────────────────┘

├─────────────────────────────────────┐
│ │
↓ ↓
┌─────────────────────────┐ ┌──────────────────────────┐
│ words_search_data │ │ words_search_idx │
├─────────────────────────┤ ├──────────────────────────┤
│ • id INTEGER [PK] │ │ • segid (组合主键) │
│ • block BLOB │ │ • term (组合主键) │
└─────────────────────────┘ │ • pgno │
(FTS5内部数据块) └──────────────────────────┘
(FTS5倒排索引)


┌─────────────────────────┐ ┌──────────────────────────┐
│ words_search_docsize │ │ words_search_config │
├─────────────────────────┤ ├──────────────────────────┤
│ • id INTEGER [PK] │ │ • k [PRIMARY KEY] │
│ • sz BLOB │ │ • v │
└─────────────────────────┘ └──────────────────────────┘
(文档大小统计) (FTS5配置信息)


┌─────────────────────────────────────────────────────────────────────┐
│ 触发器 (Triggers) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ words_ai (AFTER INSERT) │
│ 当向 words_fts 插入数据时,自动同步到 words_search │
│ │
│ words_ad (AFTER DELETE) │
│ 当从 words_fts 删除数据时,自动从 words_search 删除 │
│ │
│ words_au (AFTER UPDATE) │
│ 当更新 words_fts 数据时,先删除旧数据再插入新数据 │
│ │
└─────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────┐
│ 数据流向 (Data Flow) │
└─────────────────────────────────────────────────────────────────────┘

应用层查询

[搜索请求: "app*"]

SELECT word FROM words_search
WHERE words_search MATCH 'app*'
ORDER BY rank LIMIT 10

FTS5引擎 → 倒排索引查找

[返回结果: "apple", "application", "append", ...]

数据库调用

通过 database.rs 实现数据库的连接 及其 搜索逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
//! # 数据库模块
//!
//! 本模块负责管理 SQLite 数据库连接和操作。
//! 使用 FTS5 全文搜索引擎提供高效的单词搜索功能。
//!
//! ## 主要功能
//! - 数据库连接管理
//! - 单词全文搜索
//! - 单词计数统计

use sqlx::{SqlitePool, Row};
use anyhow::Result;
use tauri::Manager;

/// 数据库结构体
///
/// 封装了 SQLite 连接池,提供数据库操作接口。
/// 使用 Clone trait 以便在多个地方共享数据库实例。
#[derive(Clone)]
pub struct Database {
/// SQLite 连接池
pub pool: SqlitePool,
}

impl Database {
/// 创建新的数据库实例
///
/// 根据应用环境自动定位数据库文件,支持多种连接方式。
///
/// # 参数
/// * `app_handle` - Tauri 应用句柄(可选),用于获取资源目录路径
///
/// # 返回
/// * `Ok(Database)` - 成功创建的数据库实例
/// * `Err(anyhow::Error)` - 连接失败的错误信息
///
/// # 注意
/// - 在生产环境中,数据库文件应该在应用资源目录中
/// - 在开发环境中,使用当前目录下的数据库文件
pub async fn new(app_handle: Option<&tauri::AppHandle>) -> Result<Self> {
// 确定数据库文件路径
let db_file_path = if let Some(app_handle) = app_handle {
// 生产环境:从应用资源目录获取数据库文件
match app_handle.path().resource_dir() {
Ok(resource_path) => {
let db_path = resource_path.join("words.db");
println!("Using database from installation directory: {}", db_path.to_string_lossy());
db_path
},
Err(e) => {
println!("Cannot get resource directory: {}, fallback to current directory", e);
// 备选方案:如果无法获取资源目录,使用当前目录
std::path::PathBuf::from("words.db")
}
}
} else {
// 开发模式:使用当前目录下的数据库文件
println!("No app handle provided, using current directory");
std::path::PathBuf::from("words.db")
};

// 验证数据库文件是否存在
if !db_file_path.exists() {
return Err(anyhow::anyhow!("Database file not found at: {}. Please ensure the application is properly installed.", db_file_path.to_string_lossy()));
}

// 处理 Windows 长路径前缀
// Windows 上的长路径会包含 \\?\ 前缀,需要移除以确保 SQLite 能正确解析
let db_path_str = db_file_path.to_string_lossy();
let clean_path = if cfg!(windows) && db_path_str.starts_with("\\\\?\\") {
&db_path_str[4..] // 移除 \\?\ 前缀
} else {
db_path_str.as_ref()
};

// 构建 SQLite 连接字符串
let db_path = if cfg!(windows) {
// Windows: 将反斜杠转换为正斜杠
format!("sqlite:{}", clean_path.replace('\\', "/"))
} else {
// Unix-like 系统: 使用标准路径格式
format!("sqlite:{}", clean_path)
};

println!("Final database file: {}", db_path_str);
println!("Final SQLite connection string: {}", db_path);

// 尝试多种连接字符串格式以确保兼容性
// 不同的环境和 SQLite 版本可能需要不同的 URI 格式
let connection_attempts = vec![
format!("sqlite:{}", clean_path.replace('\\', "/")),
format!("sqlite:///{}", clean_path.replace('\\', "/")),
format!("sqlite://{}", clean_path.replace('\\', "/")),
];

let mut pool = None;
let mut last_error = None;

// 依次尝试每种连接格式
for conn_str in connection_attempts.iter() {
match SqlitePool::connect(conn_str).await {
Ok(p) => {
pool = Some(p);
println!("Connected to local file database: {}", conn_str);
break;
}
Err(e) => {
last_error = Some(e);
}
}
}

// 如果所有连接尝试都失败,返回错误
let pool = pool.ok_or_else(|| {
anyhow::anyhow!("Cannot connect to database: {:?}", last_error)
})?;

let db = Database { pool };

// 验证数据库完整性:检查是否包含数据
let word_count = db.get_word_count().await?;
if word_count == 0 {
return Err(anyhow::anyhow!("Database file is empty or corrupted"));
}

println!("Database initialization completed with {} words", word_count);
Ok(db)
}


/// 搜索匹配的单词
///
/// 使用 SQLite FTS5 全文搜索引擎进行前缀匹配搜索。
///
/// # 参数
/// * `query` - 搜索关键字
/// * `limit` - 返回结果的最大数量
///
/// # 返回
/// * `Ok(Vec<String>)` - 匹配的单词列表,按相关性排序
/// * `Err(anyhow::Error)` - 搜索失败的错误信息
///
/// # 示例
/// ```ignore
/// let words = db.search_words("app", 10).await?;
/// // 可能返回: ["apple", "application", "append", ...]
/// ```
pub async fn search_words(&self, query: &str, limit: i32) -> Result<Vec<String>> {
println!("Searching for query: '{}' with limit: {}", query, limit);

// 构建 FTS5 搜索查询(前缀匹配)
let search_query = format!("{}*", query);
println!("Full-text search query: '{}'", search_query);

// 执行全文搜索查询,按相关性排序
let rows = sqlx::query("SELECT word FROM words_search WHERE words_search MATCH ? ORDER BY rank LIMIT ?")
.bind(&search_query)
.bind(limit)
.fetch_all(&self.pool)
.await?;

// 提取单词列表
let words: Vec<String> = rows
.iter()
.map(|row| row.get::<String, _>("word"))
.collect();

println!("Found {} matching words: {:?}", words.len(), words);
Ok(words)
}

/// 获取数据库中的单词总数
///
/// # 返回
/// * `Ok(i64)` - 数据库中存储的单词总数
/// * `Err(anyhow::Error)` - 查询失败的错误信息
pub async fn get_word_count(&self) -> Result<i64> {
let row = sqlx::query("SELECT COUNT(*) as count FROM words_fts")
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("count"))
}

}

使用 lib.rs交互于前端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
//! # YummyTranslator 库模块
//!
//! 本模块是 YummyTranslator 应用的核心库,提供与前端交互的 Tauri 命令。
//! 主要功能包括:
//! - 单词搜索
//! - 数据库管理
//! - 状态管理

mod database;

use database::Database;
use std::sync::Mutex;
use tauri::{Manager, State};

/// Tauri 命令:问候函数
///
/// # 参数
/// * `name` - 用户名称
///
/// # 返回
/// 返回包含问候语的字符串
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}

/// Tauri 命令:搜索单词
///
/// 根据用户输入的查询字符串,在数据库中进行全文搜索,返回匹配的单词列表。
/// 使用 FTS5 全文搜索引擎进行高效的前缀匹配。
///
/// # 参数
/// * `query` - 查询字符串
/// * `limit` - 返回结果数量限制(可选,默认为 10)
/// * `db` - 数据库状态引用
///
/// # 返回
/// * `Ok(Vec<String>)` - 匹配的单词列表
/// * `Err(String)` - 错误信息
#[tauri::command]
async fn search_words(
query: String,
limit: Option<i32>,
db: State<'_, Mutex<Option<Database>>>,
) -> Result<Vec<String>, String> {
// 从互斥锁中获取数据库实例的克隆
let database = {
let db_guard = db.lock().unwrap();
db_guard.as_ref().cloned()
};

if let Some(database) = database {
let limit = limit.unwrap_or(10);
match database.search_words(&query, limit).await {
Ok(words) => Ok(words),
Err(e) => Err(format!("Search error: {}", e)),
}
} else {
Err("Database not initialized".to_string())
}
}

/// Tauri 命令:获取单词总数
///
/// 查询数据库中存储的单词总数,用于统计和展示。
///
/// # 参数
/// * `db` - 数据库状态引用
///
/// # 返回
/// * `Ok(i64)` - 单词总数
/// * `Err(String)` - 错误信息
#[tauri::command]
async fn get_word_count(db: State<'_, Mutex<Option<Database>>>) -> Result<i64, String> {
let database = {
let db_guard = db.lock().unwrap();
db_guard.as_ref().cloned()
};

if let Some(database) = database {
match database.get_word_count().await {
Ok(count) => {
println!("Word count requested: {}", count);
Ok(count)
},
Err(e) => {
println!("Count error: {}", e);
Err(format!("Count error: {}", e))
},
}
} else {
println!("Database not initialized when getting word count");
Err("Database not initialized".to_string())
}
}


/// Tauri 命令:测试数据库状态
///
/// 执行数据库健康检查,包括:
/// 1. 获取单词总数
/// 2. 执行测试搜索(搜索 "the")
/// 3. 返回详细的状态信息
///
/// # 参数
/// * `db` - 数据库状态引用
///
/// # 返回
/// * `Ok(String)` - 包含测试结果的状态信息
/// * `Err(String)` - 错误信息
#[tauri::command]
async fn test_database_status(db: State<'_, Mutex<Option<Database>>>) -> Result<String, String> {
let database = {
let db_guard = db.lock().unwrap();
db_guard.as_ref().cloned()
};

if let Some(database) = database {
// 首先获取单词总数
match database.get_word_count().await {
Ok(count) => {
// 执行测试搜索
let test_query = "the";
match database.search_words(test_query, 5).await {
Ok(words) => {
let status = format!("Database OK - {} words total, test search for '{}' found {} results: {:?}",
count, test_query, words.len(), words);
println!("{}", status);
Ok(status)
},
Err(e) => {
let error = format!("Search test failed: {}", e);
println!("{}", error);
Err(error)
}
}
},
Err(e) => {
let error = format!("Count test failed: {}", e);
println!("{}", error);
Err(error)
}
}
} else {
let error = "Database not initialized".to_string();
println!("{}", error);
Err(error)
}
}

/// 应用程序主入口函数
///
/// 初始化 Tauri 应用,配置数据库连接,注册所有命令处理器。
///
/// # 功能
/// 1. 异步初始化数据库连接
/// 2. 注册 Tauri 命令处理器
/// 3. 配置应用状态管理
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let app_handle = app.handle().clone();

// 在异步运行时中初始化数据库
tauri::async_runtime::spawn(async move {
match Database::new(Some(&app_handle)).await {
Ok(db) => {
// 将数据库实例存储到应用状态中
let db_state = app_handle.state::<Mutex<Option<Database>>>();
let mut db_guard = db_state.lock().unwrap();
*db_guard = Some(db);
println!("Database initialized successfully");
}
Err(e) => {
eprintln!("Failed to initialize database: {}", e);
}
}
});

Ok(())
})
// 管理数据库状态(使用互斥锁保护)
.manage(Mutex::new(None::<Database>))
// 注册所有 Tauri 命令处理器
.invoke_handler(tauri::generate_handler![
greet,
search_words,
get_word_count,
test_database_status
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

形成带有下拉建议搜索的 v0.1.1版本

2025-10-12 16-18-09
2025-10-12 16-18-09
Comments