【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

  • 安裝完了rust之後,再來就來裝tauri…
  • 因為tauri說穿了,就是個前後端綁在一起的網頁程式,所以nodejs也要裝喲…
cargo install create-tauri-app
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庫是直接用系統上的,所以打包起來檔案會小很多很多的…

打包、建立

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之下…
  • 當然,也可以打包成WindowsLinux的格式,只是還是再要安裝相關的套件…
  • 而且聽說在Tauri 3.0之後,就可以打包成iOSAndroid的手機格式了,真的是太神了啊…
  • 好了,馬上輕鬆秒殺,這篇文就這樣劃下完美的句點囉…

建立後端API

事前準備

  • 這裡呢,主要是要用Rust的CSV套件來讀取CSV檔案…
  • 而程式的寫法跟上一期的大同小異,Cargo.toml的設定如下…
[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"
  • 而要使用的自定小工具在src-tauri/src/library之下的程式碼如下,也是跟上一期的差不多…
  • 記得該抄的抄一抄,該貼的貼一貼,小工具要好好的先準備一下,之後才好做事
// 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的整體APP外觀畫面,再處理src/styles.css,把畫面美化一下…
  • 這裡就不細說了,美觀上的東西都是見仁見智的嘛…
<!-- 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);
}
:root {
  --max-width: 1440px;
  --card-width: 300px;
}

程式主功能

  • 這裡的核心就是src/main.ts這個檔案…
  • 主要是使用invoke()這個函數來讀取Rust端釋出的函數…
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';
  }
}

切換暗黑模式

  • 切換暗黑模式是以initDarkMode()這個函數來處理…
  • 主要是監聽這個函數切換事件,去增加或移除dark-mode這個class…
  • 然後把這設定值存到本地端,這樣下一次就不需要再切換一次了…
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"

範例程式碼下載

後記

  • 其實筆者同一個專案,前前後後已經做了三次了,雖然中間有修修改改,主要也是怕出錯,因為筆者不想要是用運氣矇出來的,然後下一次就…做不出來,所以筆者都會把從網路上 / GPT得來的程式碼整理成小工具,當然…也是因為筆者的記性差,好不容易做出來的東西,以後要用的時候,還要再想一次,很燒腦啊,而且對typescript也不熟,直接就複製貼上不是很好嗎?而且…筆者的智商也不高,也非人人口中的高知識分子,而是靠一點一滴累積出來的,也許有人能讀了文件就會做了,但是筆者還是喜歡從做中學,所以筆者最喜歡的學習方式之一,況且筆者的英文又差,所以只能慢慢的學習…