【Tauri】當Rust跟Web同在一起,在一起,在一起…
前言
記得之前有用Wails = Web + Go做了一個簡單的切割ts檔的小工具,今天我們用Tauri = Web + Rust來做一個讀取CSV檔的小APP,因為筆者平常就有在用Airtable記錄相關軟體、套件的習慣,而它也可以輸出成CSV檔來做進一步的處理,雖然Airtable本身有API可以使用,但要做成手機APP,或者是Web網頁也是滿麻煩的,除了一定要能連線之外,有個網址框也不好看,所以還是傾向做成單頁的APP,當然…主要也是Rust可以產出WASM,想來試用看看,結果越走越歪了,不管了,也順便學學吧,都快要變成超全端了,反正有AI的出現,以後的一人軟體公司也會越來越多了吧?
作業環境
項目 | 版本 |
---|---|
macOS | Sequoia 15.5 |
Visual Studio Code | 1.101.1 |
nodejs | 20.19.2 |
rust | 1.87 |
tauri | 2.5.1 |
做一個長得像這樣的APP
安裝Tauri
Tauri Command
cargo install create-tauri-app
- 再來建立一個Tauri新專案來試試…
- 這裡取的專案名稱叫
csv-library
,使用的前端框架是vanilla-ts… - 然後就給它跑跑看的啦…
cargo create-tauri-app <專案名稱> --template <前端架構>
cd ~/Desktop
cargo create-tauri-app csv-library --template vanilla-ts
cd csv-library
npm install
npm run tauri dev
- 它的檔案結構是長這個樣子的…
src
是寫前端程式 - nodejs
的地方src-tauri
是寫後端程式 - rust
的地方
前後端通信
- 要怎麼要讓前後端通信呢?這就要靠
invoke()
來召喚後端的程式了… - 後端程式要先以
#[tauri::command]
這個巨集來註解它是可以被前端執行
的程式碼… - 然後再用
invoke_handler()
來註冊在前端之上,這樣就可以讓前端使用了…
greetMsgEl.textContent = await invoke("greet", {
name: greetInputEl.value,
});
抓蟲啦! 3
- 世間的萬物絕對沒有那麼簡單的,不然你我怎麼混口飯吃呢?
- 我們知道他就是個網頁程式,所以一定是靠瀏覽器的開發者工具來Debug的…
- 就是按
滑鼠右鍵
,然後按檢閱元件
就會出現大家熟知的開發者工具
畫面了… - 不過因為tauri的WebView庫是直接用系統上的,所以打包起來檔案會小很多很多的…
打包、建立
- 最後我們來打包成品吧,build! build! build!…
- 不過說實在的tauri在編譯、打包真的很慢啊,編譯式的壞處吧…
npm run tauri build
npm run tauri build --debug
npm run tauri build --release
./src-tauri/target/release/bundle/dmg/csv-library_0.1.0_aarch64.dmg
- 打包完的檔案會產生在
./src-tauri/target/release/bundle/dmg
之下… - 當然,也可以打包成
Windows
跟Linux
的格式,只是還是再要安裝相關的套件…
建立後端API
事前準備
[package]
name = "CSV讀取器"
version = "0.1.0"
description = "A Tauri App"
authors = ["William Weng"]
edition = "2021"
[lib]
name = "csv_library_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
colored = "3.0"
csv = "1.3.1"
log = "0.4.27"
env_logger = "0.10.2"
chrono = "0.4.41"
// macros.rs
#[macro_export]
macro_rules! ww_print {
($message:expr) => {
println!("\n[{file} - {line}]\n{message}",
file = file!().green().bold(),
line = format!("line.{}", line!().to_string()).yellow().bold(),
message = $message
)
};
}
/// models.rs
use serde::{Serialize, Deserialize};
use serde::de::Deserializer;
#[derive(Serialize, Deserialize, Debug)]
pub struct CsvRecord {
pub name: String,
pub notes: String,
pub url: String,
pub level: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub example: Option<String>,
#[serde(deserialize_with = "deserialize_platform")]
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub platform: Vec<String>,
#[serde(deserialize_with = "deserialize_platform")]
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub r#type: Vec<String>,
#[serde(deserialize_with = "deserialize_platform")]
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub os: Vec<String>,
}
fn deserialize_platform<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error> where D: Deserializer<'de> {
let str = String::deserialize(deserializer)?;
Ok(str.split(',').map(|str| str.trim().to_string()).collect())
}
/// utils.rs
use std::fmt::Debug;
use std::fs::{read_dir, File};
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
use std::collections::HashSet;
use csv::Reader;
use serde::de::{DeserializeOwned};
use tauri::path::BaseDirectory;
use tauri::{AppHandle, Manager};
use crate::library::models::CsvRecord;
pub fn read_csv_file(app: AppHandle, filename: String) -> Result<Vec<CsvRecord>, Error> {
let resource_path = _csv_file_path(&app, filename)?;
let records: Vec<CsvRecord> = _parse_csv_file(resource_path.to_string_lossy().to_string())?;
Ok(records)
}
pub fn read_type_set(app: AppHandle, filename: String) -> Result<HashSet<String>, Error> {
let records = match read_csv_file(app, filename) {
Ok(records) => records,
Err(error) => return Err(error)
};
let mut type_set: HashSet<String> = HashSet::new();
for record in records.iter() {
for r#type in record.r#type.clone() { type_set.insert(r#type.clone()); }
}
return Ok(type_set);
}
pub fn folder_files(path: PathBuf) -> Result<Vec<String>, Error> {
let mut file_names = Vec::new();
match read_dir(&path) {
Err(error) => Err(error),
Ok(entries) => {
for entry in entries {
if let Ok(entry) = entry {
if let Some(name) = entry.file_name().to_str() {
file_names.push(name.to_string());
}
}
}
file_names.sort_by(|name1, name2| name1.to_lowercase().cmp(&name2.to_lowercase()));
Ok(file_names)
}
}
}
fn _csv_file_path(app: &AppHandle, filename: String) -> Result<PathBuf, Error> {
if filename.is_empty() {
return Err(Error::new(
ErrorKind::InvalidInput,
"Filename cannot be empty",
));
}
let resource_path = match app.path().resolve("document", BaseDirectory::Resource) {
Ok(path) => path,
Err(error) => return Err(Error::new(ErrorKind::NotFound, error.to_string())),
};
Ok(resource_path.as_path().join(filename))
}
fn _parse_csv_file<T>(resource_path: String) -> Result<Vec<T>, Error> where T: DeserializeOwned + Debug {
if resource_path.is_empty() {
return Err(Error::new(
ErrorKind::InvalidInput,
"Resource path cannot be empty",
));
}
let mut records: Vec<T> = Vec::new();
let opened_file = File::open(&resource_path)?;
let mut reader = Reader::from_reader(opened_file);
for result in reader.deserialize() {
match result {
Ok(record) => records.push(record),
Err(error) => return Err(Error::new(ErrorKind::InvalidData, error.to_string())),
}
}
Ok(records)
}
// mod.rs
pub mod models;
pub mod utils;
pub mod macros;
- 再來就是把要讀取的CSV檔案,放在
src-tauri/document
的資料夾之下… - 然後設定
src-tauri/tauri.conf.json
檔的resources
設定值,這樣才會程式才會把src-tauri/document
下的檔案複製到target/debug/document
… - 我們寫的程式都是去讀取
target/debug/document
下的檔案,而不是直讀src-tauri/document
… - 為什麼要這麼做呢?因為打包成APP之後,APP就再也讀不到
src-tauri/document
位置下的檔案了,而是讀取APP本身
的resources
下的檔案,所以我們需要把檔案複製到target/debug/document
下,這樣APP才能在打包時,把相關的文件一起打包進去,滿像Xcode的Bundle Resources…
/// tauri.conf.json
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CSV讀取器",
...
"app": {
"resources": [
"document/*"
]
}
}
讀取CSV檔
- 這裡主要的功能有兩個,一個是
讀取 CSV 檔案資料夾檔名列表
,另一個是讀取 CSV 檔案並返回記錄
… - 首先呢,
讀取 CSV 檔案資料夾檔名列表
呢,就是把CSV檔一行一行的讀出來,然後轉成JSON字串的格式,其實就跟打API是一樣的… - 至於
讀取 CSV 檔案並返回記錄
,就是把document
下的CSV文件檔檔名通通讀出來,當成選單來用…
/// lib.rs
mod library;
use tauri::{AppHandle, Manager};
use tauri::path::BaseDirectory;
use library::utils::{read_csv_file, read_type_set, folder_files};
#[tauri::command]
fn read_csv(app: AppHandle, filename: String) -> String {
let records = match read_csv_file(app.clone(), filename) {
Ok(records) => records,
Err(error) => return serde_json::json!({ "error": error.to_string() }).to_string(),
};
serde_json::json!({ "result": records }).to_string()
}
#[tauri::command]
fn read_type(app: AppHandle, filename: String) -> String {
let types = match read_type_set(app.clone(), filename) {
Ok(types) => types,
Err(error) => return serde_json::json!({ "error": error.to_string() }).to_string(),
};
serde_json::json!({ "result": types }).to_string()
}
#[tauri::command]
fn csv_list(app: AppHandle) -> String {
let list = match app.path().resolve("document", BaseDirectory::Resource) {
Ok(path) => match folder_files(path) {
Ok(array) => array,
Err(error) => return serde_json::json!({ "error": error.to_string() }).to_string(),
},
Err(error) => return serde_json::json!({ "error": error.to_string() }).to_string(),
};
serde_json::json!({ "result": list }).to_string()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![read_csv, csv_list, read_type])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
處理前端功能
APP外觀畫面
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="/src/styles.css" />
<style>
</style>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri App</title>
<script type="module" src="/src/main.ts" defer></script>
</head>
<body>
<main id="container">
<div id="platform-selector">
<select id="platform-select">
<option value="Linux">Linux</option>
</select>
</div>
<div class="switch-container">
<input type="checkbox" id="switch" />
<label for="switch">
<span class="switch-txt"></span>
</label>
</div>
<div id="loader-container">
<div id="loader">
<div id="loader-circle"></div>
<div id="loader-text">載入中...</div>
</div>
</div>
<div id="card-collection-view"></div>
</main>
</body>
</html>
:root {
--max-width: 1440px;
--card-width: 300px;
--header-height: 60px;
--selector-width: 200px;
--switch-height: 30px;
--color-background-rgb: 255, 255, 255;
--color-primary-rgb: 44, 82, 130;
--color-primary: #2c5282;
--color-secondary: #4299e1;
--color-selector: #2c5282;
--color-selector-secondary: #4299e1;
--color-accent: #24c8db;
--color-background: #f6f6f6;
--color-text: #0f0f0f;
--color-link: #646cff;
--color-link-hover: #535bf2;
--color-button: #ffffff;
--color-notes-bg: #e9e4e5;
--color-example-bg: #1a1a1a;
--color-example-text: #fffff0;
--color-card-bg: white;
--color-switch-bg: white;
--color-switch-border: #2c5282;
--color-header-bg: #f0f4f8;
--color-hashtag-bg: #e2e8f0;
--color-note-text: #4a5568;
--color-star: #ffd700;
--color-star-bg: #2980b9;
--color-overlay: rgba(0, 0, 0, 0.1);
--font-family-base: Inter, Avenir, Helvetica, Arial, sans-serif;
--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-size-xs: 0.75em;
--font-size-sm: 0.85em;
--font-size-base: 16px;
--font-size-lg: 1.2em;
--font-size-xl: 1.5em;
--line-height-base: 1.5;
--line-height-tight: 1.25;
--line-height-relaxed: 1.75;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-bold: 600;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
--spacing-2xl: 24px;
--gap-cards: 40px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-text: 0 1px 2px rgba(0, 0, 0, 0.2);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-full: 9999px;
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.75s;
--animation-duration: 1s;
--animation-timing: linear;
--z-header: 100;
--z-loader: 1000;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family-base);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
font-weight: var(--font-weight-normal);
color: var(--color-text);
background-color: var(--color-background);
text-rendering: optimizeLegibility;
}
#container {
width: 100%;
min-height: 100vh;
margin: 0 auto;
max-width: var(--max-width);
padding-top: calc(var(--header-height) + var(--spacing-xl));
display: flex;
flex-direction: column;
justify-content: flex-start;
text-align: center;
align-items: center;
}
#platform-selector {
top: 0;
left: 0;
right: 0;
backdrop-filter: blur(8px);
height: var(--header-height);
z-index: var(--z-header);
background-color: rgba(var(--color-background-rgb), 0.95);
padding: var(--spacing-md) 0;
box-shadow: var(--shadow-md);
position: fixed;
align-items: center;
display: flex;
justify-content: center;
transition: transform var(--transition-normal);
}
#platform-selector select {
margin: 0;
padding-right: 32px;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--spacing-xl);
border: 2px solid var(--color-selector);
border-radius: var(--radius-md);
background-color: var(--color-card-bg);
color: var(--color-selector);
cursor: pointer;
min-width: var(--selector-width);
transition: all var(--transition-normal);
appearance: none;
background-size: 16px;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 8px center;
}
#platform-selector select:hover {
border-color: var(--color-selector-secondary);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
#platform-selector select:focus {
outline: none;
border-color: var(--color-selector-secondary);
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.5);
}
#card-collection-view {
width: 100%;
gap: var(--gap-cards);
padding: var(--spacing-xl);
max-width: var(--max-width);
margin-bottom: var(--spacing-2xl);
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
box-sizing: border-box;
}
.card {
width: var(--card-width);
background: var(--color-card-bg);
border-radius: var(--radius-lg);
padding: var(--spacing-lg) var(--spacing-md) var(--spacing-sm);
box-shadow: var(--shadow-md);
transition: transform var(--transition-normal), box-shadow var(--transition-normal);
text-align: left;
box-sizing: border-box;
position: relative;
}
.card:hover {
transform: translateY(-8px);
}
.card-link {
margin: 0;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-large);
background-color: var(--color-header-bg);
border-radius: var(--radius-md);
color: var(--color-primary);
font-weight: var(--font-weight-bold);
box-shadow: var(--shadow-sm);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-link a {
color: var(--color-primary);
transition: color var(--transition-fast);
text-decoration: none;
}
.card-link a:hover {
color: var(--color-secondary);
}
.card-notes {
height: 7.5em;
line-height: var(--line-height-base);
background-color: var(--color-notes-bg);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
margin: var(--spacing-lg) 0;
border: 1px solid var(--color-hashtag-bg);
color: var(--color-note-text);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--color-hashtag-bg) transparent;
}
.card-example {
height: 5.5em;
line-clamp: 3;
background-color: var(--color-example-bg);
color: var(--color-example-text);
text-shadow: var(--shadow-text);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-sm);
margin: var(--spacing-sm) 0;
font-family: var(--font-family-mono);
font-size: var(--font-size-small);
line-height: var(--line-height-base);
overflow-y: auto;
position: relative;
}
.star-rating {
z-index: 2;
top: calc(-1 * var(--spacing-xl));
left: calc(-1 * var(--spacing-xl));
color: var(--color-star);
background: var(--color-star-bg);
font-size: var(--font-size-small);
letter-spacing: 1px;
text-shadow: var(--shadow-text);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-full);
box-shadow: var(--shadow-md);
white-space: nowrap;
position: absolute;
transform: scale(1.0);
}
.hashtag-container {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
margin-top: var(--spacing-md);
padding: var(--spacing-xs);
}
.hashtag {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-md);
margin: var(--spacing-);
background-color: var(--color-hashtag-bg);
color: var(--color-note-text);
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
transition: all var(--transition-fast);
text-decoration: none;
line-height: var(--line-height-tight);
}
.hashtag:hover {
background-color: var(--color-secondary);
color: var(--color-button);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.switch-container {
position: fixed;
top: var(--spacing-md);
right: var(--spacing-xl);
z-index: var(--z-header);
}
.switch-container input[type="checkbox"] {
display: none;
}
.switch-container label {
display: block;
width: 60px;
height: var(--switch-height);
padding: 3px;
border-radius: 15px;
border: 2px solid var(--color-switch-border);
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
}
.switch-container label::before {
content: "";
display: block;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: var(--color-switch-border);
transition: all var(--transition-fast);
}
.switch-container input:checked + label {
background-color: var(--color-primary);
}
.switch-txt {
position: absolute;
top: 50%;
left: 100%;
transform: translateY(-50%);
margin-left: var(--spacing-sm);
font-size: var(--font-size-sm);
color: var(--color-text);
}
.switch-txt::before {
content: attr(turnOff);
}
.switch-container input:checked + label .switch-txt::before {
content: attr(turnOn);
}
.switch-container input:checked + label::before {
transform: translateX(var(--switch-height));
}
#loader-container {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--color-overlay);
backdrop-filter: blur(4px);
justify-content: center;
align-items: center;
z-index: var(--z-loader);
opacity: 0;
transition: opacity var(--transition-normal);
}
#loader-container.active {
display: flex;
opacity: 1;
}
#loader-container.active #loader {
transform: translateY(0);
}
#loader {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-lg);
transform: translateY(var(--spacing-xl));
transition: transform var(--transition-normal);
}
#loader-circle {
width: 48px;
height: 48px;
border: 4px solid rgba(var(--color-primary-rgb), 0.3);
border-top: 4px solid var(--color-primary);
border-radius: var(--radius-full);
animation: spin var(--animation-duration) var(--animation-timing) infinite;
box-shadow: var(--shadow-md);
}
#loader-text {
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
text-shadow: var(--shadow-text);
opacity: 0;
transform: translateY(var(--spacing-md));
animation: fadeInUp 0.5s ease forwards 0.2s;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(var(--spacing-md));
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes ripple {
0% {
transform: scale(0, 0);
opacity: 0.5;
}
20% {
transform: scale(25, 25);
opacity: 0.3;
}
100% {
transform: scale(40, 40);
opacity: 0;
}
}
.dark-mode {
--color-selector: #c6c6c6;
--color-selector-secondary: #f6f6f6;
--color-background: #2f2f2f;
--color-background-rgb: 47, 47, 47;
--color-text: #f6f6f6;
--color-button: #0f0f0f98;
--color-card-bg: #3f3f3f;
--color-switch-bg: white;
--color-switch-border: #f6f6f6;
--color-header-bg: #c8d2de;
--color-hashtag-bg: #4a5568;
--color-note-text: #f6f6f6;
--color-link-hover: #24c8db;
--color-notes-bg: #4a5568;
--color-example-text: #ffffff;
--color-overlay: rgba(0, 0, 0, 0.2);
--shadow-sm: 0 1px 2px rgba(255, 255, 255, 0.05);
--shadow-md: 0 2px 4px rgba(255, 255, 255, 0.1);
--shadow-lg: 0 2px 8px rgba(255, 255, 255, 0.15);
--shadow-text: 0 1px 2px rgba(255, 255, 255, 0.2);
}
- 這個APP的主要功能呢,就是讀取CSV檔,整整齊齊的顯示在APP上,然後可以切換暗黑模式…
- 切換暗黑模式主要的核心,就是使用CSS變數來實現。
- 就是那個以
--
開頭的CSS變數,例如--color-button
、--color-card-bg
等,超好用,一改就自動變更了。
:root {
--max-width: 1440px;
--card-width: 300px;
}
程式主功能
const jsonString = new String(await invoke(RustFunctions["readCsvContent"], { filename: filename })).valueOf();
- 完整的程式碼如下…
// main.ts
import { invoke } from "@tauri-apps/api/core";
const Colors: { background: string; text: string }[] = [
{ background: '#1abc9c', text: '#ffffff' },
{ background: '#e67e22', text: '#ffffff' },
{ background: '#e74c3c', text: '#ffffff' },
{ background: '#2980b9', text: '#ffffff' },
{ background: '#8e44ad', text: '#ffffff' },
{ background: '#f1c40f', text: '#ffffff' },
{ background: '#2ecc71', text: '#ffffff' },
{ background: '#34495e', text: '#ffffff' },
{ background: '#ff6f61', text: '#ffffff' },
{ background: '#00bcd4', text: '#ffffff' },
];
const TypeColors: { [key: string]: { background: string; text: string } } = {
"Windows": Colors[0],
"macOS": Colors[1],
"Linux": Colors[2],
"Android": Colors[3],
"iOS": Colors[4],
"Swift": Colors[0],
"SwiftUI": Colors[1],
"Objective-C": Colors[2],
"Xcode": Colors[3],
"Portfolio": Colors[0],
"Idea": Colors[1],
"Icon": Colors[2],
"Music": Colors[3],
"Font": Colors[4],
"PPT": Colors[5],
"Sound": Colors[6],
"Image": Colors[7],
"3D": Colors[8],
"UI / UX": Colors[9],
"Video": Colors[0],
"IDE": Colors[1],
"Subtitle": Colors[2],
"Youtube": Colors[3],
"Database": Colors[4],
"TV": Colors[5],
"Electron": Colors[6],
"AI": Colors[7],
"Color": Colors[8],
"Audio": Colors[9],
"JSON": Colors[0],
"PDF": Colors[2],
"BaaS": Colors[3],
"API": Colors[5],
"HTML": Colors[6],
"QR-Code": Colors[7],
"Gallery": Colors[8],
"Document": Colors[9],
"File": Colors[1],
}
const RustFunctions = {
"readCsvList": "csv_list",
"readCsvContent": "read_csv"
}
let platformSelect: HTMLInputElement | null;
let collectionView: HTMLElement | null;
let loader: HTMLElement | null;
let darkModeSwitch: HTMLInputElement | null;
async function displayCsvCard(platform?: string) {
if (!platformSelect || !collectionView) { return; }
const filename = (platform || "Linux.csv");
collectionView.innerHTML = "";
displayLoader();
try {
const jsonString = new String(await invoke(RustFunctions["readCsvContent"], { filename: filename })).valueOf();
const response = JSON.parse(jsonString) as Record<string, any>;
const array = response.result as Array<Record<string, any>>;
for (const row of array) {
const divHtml = collectionItemMaker(row);
collectionView.insertAdjacentHTML('beforeend', divHtml);
}
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error("Error reading CSV file:", error);
} finally {
dismissLoader();
collectionView.style.opacity = '1.0';
}
}
function collectionItemMaker(row: Record<string, any>) {
const stars = '★'.repeat(Number(row.level) || 0) + '☆'.repeat(5 - (Number(row.level) || 0));
const divHtml = `
<div class="card">
<div class="star-rating">${stars}</div>
<h2 class="card-link"><a href="${row.url}" target="_blank">${row.name}</a></h2>
<p class="card-notes">${row.notes}</p>
${row.example ? `<p class="card-example">${row.example}</p>` : ''}
<div class="hashtag-container">
${hashtagHtmlElementMaker(row.platform)}
${hashtagHtmlElementMaker(row.type)}
</div>
</div>
`
return divHtml;
}
function hashtagHtmlElementMaker(tags?: [string]) {
if (!tags) { return '' };
let hashtags: string[] = [];
tags.forEach((tag: string, index: number) => {
let color = TypeColors[tag] ?? Colors[index % Colors.length];
let html = `<span class="hashtag" style="background-color: ${color.background}; color: ${color.text}">${tag}</span>`;
hashtags.push(html);
});
return hashtags.join('');
}
async function initMenuSetting() {
if (!platformSelect) return;
platformSelect.innerHTML = "";
let jsonString = new String(await invoke(RustFunctions["readCsvList"])).valueOf();
let response = JSON.parse(jsonString);
let list = response.result as string[];
list.forEach((filename, index) => {
const option = document.createElement('option');
if (index === 0) { option.selected = true; }
option.value = filename;
option.textContent = filename;
platformSelect?.appendChild(option);
});
}
function displayLoader() { loader?.classList.add('active'); }
function dismissLoader() { loader?.classList.remove('active'); }
function initDarkMode() {
darkModeSwitch = document.querySelector('#switch');
if (!darkModeSwitch) return;
const isDarkMode = localStorage.getItem('darkMode') === 'true';
darkModeSwitch.checked = isDarkMode;
document.documentElement.classList.toggle('dark-mode', isDarkMode);
darkModeSwitch.addEventListener('change', () => {
document.documentElement.classList.toggle('dark-mode', darkModeSwitch?.checked);
localStorage.setItem('darkMode', String(darkModeSwitch?.checked));
});
}
async function main() {
platformSelect = document.querySelector("#platform-select");
collectionView = document.querySelector("#card-collection-view");
loader = document.querySelector('#loader-container');
await initMenuSetting();
initDarkMode();
if (!platformSelect || !collectionView) { return; }
platformSelect?.addEventListener("change", () => displayCsvCard(platformSelect?.value));
await displayCsvCard(platformSelect.value);
}
main();
選擇CSV檔
- 其實就是CSV文件的下拉式選單,主要是以
initMenuSetting()
這個非同步函數來處理的… - 使用
csv_list()
這個函數,來讀取CSV文件列表,使用HTML原生的select標籤,然後值文件名一個一個放在option之內,並設定選項的值和文字內容…
async function initMenuSetting() {
if (!platformSelect) return;
platformSelect.innerHTML = "";
let jsonString = new String(await invoke(RustFunctions["readCsvList"])).valueOf();
let response = JSON.parse(jsonString);
let list = response.result as string[];
list.forEach((filename, index) => {
const option = document.createElement('option');
if (index === 0) { option.selected = true; }
option.value = filename;
option.textContent = filename;
platformSelect?.appendChild(option);
});
}
切換卡片內容
- 這裡是以
displayCsvCard(platform?:)
這個非同步函數來處理的… - 使用
read_csv()
這個函數,來讀取CSV文件內容,然後再一個一個的放在畫面上…
async function displayCsvCard(platform?: string) {
if (!platformSelect || !collectionView) { return; }
const filename = (platform || "Linux.csv");
collectionView.innerHTML = "";
displayLoader();
try {
const jsonString = new String(await invoke(RustFunctions["readCsvContent"], { filename: filename })).valueOf();
const response = JSON.parse(jsonString) as Record<string, any>;
const array = response.result as Array<Record<string, any>>;
for (const row of array) {
const divHtml = collectionItemMaker(row);
collectionView.insertAdjacentHTML('beforeend', divHtml);
}
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error("Error reading CSV file:", error);
} finally {
dismissLoader();
collectionView.style.opacity = '1.0';
}
}
切換暗黑模式
function initDarkMode() {
darkModeSwitch = document.querySelector('#switch');
if (!darkModeSwitch) return;
const isDarkMode = localStorage.getItem('darkMode') === 'true';
darkModeSwitch.checked = isDarkMode;
document.documentElement.classList.toggle('dark-mode', isDarkMode);
darkModeSwitch.addEventListener('change', () => {
document.documentElement.classList.toggle('dark-mode', darkModeSwitch?.checked);
localStorage.setItem('darkMode', String(darkModeSwitch?.checked));
});
}
自訂APP圖示
- 最後再用
scripts/icon.sh
來把圖片轉成圖示,記得要安裝imagemagick… - 再把轉換完成的圖示存到
src-tauri/icons
中就完成了…
brew install imagemagick
chmod +x scripts/icon.sh
cd scripts
./icon.sh csv-file.png
執行程式
- 這裡我們就來打包一下,製作一個屬於自己的APP吧,製作一個屬於自己的APP吧…
npm run tauri build
自訂APP顯示名稱
- 最後呢,來自訂一下
APP顯示名稱
,就是在src-tauri/tauri.conf.json
來做設定…
// tauri.conf.json
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CSV讀取器",
...
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "CSV讀取器",
"width": 800,
"height": 600
}
]
...
}
- 再執行一次看看吧,完工灑花…
npm run tauri dev
npm run tauri build
《補充》加入Logger
- 俗話說得好:「人在江湖飄,哪能不挨刀;人在江湖上,哪有不犯錯」的,是吧?
- 人的心思再細,也是不可能寫出沒有錯誤的程式碼的,所以…我們需要加入Logger來記錄程式運行過程中的錯誤訊息。
- 先在
src-tauri/Cargo.toml
加入下列依賴套件,用來產生有日期文字的log…
log = "0.4.27"
env_logger = "0.10.2"
chrono = "0.4.41"
- 細節就不多說了,主要就是在應用程序資源目錄中創建
logs
目錄… - 然後把錯誤寫入
<日期>.log
之中,會存在target/debug/logs/
之下… - 先將下列的程式碼加入
src-tauri/src/library/util.rs
中…
/// util.rs
...
use std::fs::{read_dir, File, OpenOptions, create_dir_all};
use env_logger::Env;
use env_logger::{fmt::Color, Builder};
use chrono::Local;
use colored::Colorize;
...
pub fn logger_setting(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let log_dir = app.path().resolve("logs", BaseDirectory::Resource)?;
create_dir_all(&log_dir)?;
let log_file_name = format!("{}.log", Local::now().format("%Y%m%d"));
let log_file_path = log_dir.join(log_file_name);
ww_print!(format!("Log file location: {:?}", log_file_path));
let log_file = OpenOptions::new()
.create(true)
.append(true)
.open(log_file_path)?;
Builder::from_env(Env::default().default_filter_or("debug"))
.format(|buffer, record| {
let mut style = buffer.style();
let level_color = match record.level() {
log::Level::Error => Color::Red,
log::Level::Warn => Color::Yellow,
log::Level::Info => Color::Green,
log::Level::Debug => Color::Blue,
log::Level::Trace => Color::Cyan,
};
writeln!(
buffer,
"{} [{}] {} - {}",
Local::now().format("%Y-%m-%d %H:%M:%S"),
style.set_color(level_color).value(record.level()),
record.target(),
record.args()
)
})
.target(env_logger::Target::Pipe(Box::new(log_file)))
.init();
Ok(())
}
- 然後在
src-tauri/src/lib.rs
中加入設定,使用.setup()
加入中間件,然後使用info!() / debug!()
印印看… - 之後就可以在
logs
下面看到錯誤訊息了,當然在APP的Resources/logs
下也是看得到的…
/// lib.rs
...
use log::{debug, info};
use library::utils::{read_csv_file, read_type_set, folder_files, logger_setting};
#[tauri::command]
fn read_csv(app: AppHandle, filename: String) -> String {
info!("Loading CSV file: {}", filename);
debug!("Loading CSV file: {}", filename);
let records = match read_csv_file(app.clone(), filename) {
Ok(records) => records,
Err(error) => return serde_json::json!({ "error": error.to_string() }).to_string(),
};
serde_json::json!({ "result": records }).to_string()
}
...
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if let Err(error) = logger_setting(app) { eprintln!("Failed to setup logging: {}", error); }
Ok(())
})
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![read_csv, csv_list, read_type])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
《補充》Rust format! 巨集使用指南
1. 基本字串格式化
let name = "Alice";
let age = 25;
let message = format!("名字: {}, 年齡: {}", name, age);
2. 命名參數
let message = format!(
"名字: {name}, 年齡: {age}",
name = "Bob",
age = 30
);
3. 數字格式化
二進制
let binary = format!("{:b}", 42); // "101010"
十六進制
let hex = format!("{:x}", 42); // "2a"
let hex_upper = format!("{:X}", 42); // "2A"
八進制
let octal = format!("{:o}", 42); // "52"
科學記號
let scientific = format!("{:e}", 1234.5678); // "1.2345678e3"
4. 對齊和填充
// 靠左對齊,寬度為 5
let left = format!("{:<5}", "hi"); // "hi "
// 靠右對齊,寬度為 5
let right = format!("{:>5}", "hi"); // " hi"
// 置中對齊,寬度為 5
let center = format!("{:^5}", "hi"); // " hi "
// 使用 0 填充
let zero = format!("{:0>5}", "123"); // "00123"
5. 精確度控制
// 浮點數精確度
let pi = format!("{:.2}", 3.1415926); // "3.14"
// 字串截取
let text = format!("{:.3}", "abcdef"); // "abc"
6. Debug 格式化
#[derive(Debug)]
struct Point { x: i32, y: i32 }
let point = Point { x: 1, y: 2 };
// 一般 debug 輸出
let debug = format!("{:?}", point); // Point { x: 1, y: 2 }
// 美化輸出
let pretty = format!("{:#?}", point);
7. 動態寬度和精確度
let width = 5;
let precision = 2;
let num = 3.1415926;
let formatted = format!(
"{:width$.precision$}",
num,
width=8,
precision=2
);
8. 指定型別
let num = 42;
let hex = format!("{num:x}"); // 十六進制
let binary = format!("{num:b}"); // 二進制
組合使用範例
let complex = format!(
"{:0>width$b}", // 二進制,靠右對齊,用 0 填充
42,
width = 8
); // "00101010"